Repository: overmindtech/cli Branch: main Commit: c4fae77e40c8 Files: 1937 Total size: 12.5 MB Directory structure: gitextract_qkwcrphn/ ├── .github/ │ ├── e2eapply.tape │ ├── e2eplan.tape │ └── workflows/ │ ├── docker-release.yml │ ├── finalize-copybara-sync.yml │ ├── release.yml │ ├── tag-on-merge.yml │ └── tests.yml ├── .gitignore ├── .goreleaser.yaml ├── .terraform.lock.hcl ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── aws-source/ │ ├── .deadcode-ignore │ ├── acceptance/ │ │ └── nats-server.conf │ ├── adapters/ │ │ ├── adapterhelpers_always_get_source.go │ │ ├── adapterhelpers_always_get_source_test.go │ │ ├── adapterhelpers_describe_source.go │ │ ├── adapterhelpers_describe_source_test.go │ │ ├── adapterhelpers_get_list_adapter_v2.go │ │ ├── adapterhelpers_get_list_adapter_v2_test.go │ │ ├── adapterhelpers_get_list_source.go │ │ ├── adapterhelpers_get_list_source_test.go │ │ ├── adapterhelpers_notfound_cache_test.go │ │ ├── adapterhelpers_shared_tests.go │ │ ├── adapterhelpers_sources.go │ │ ├── adapterhelpers_util.go │ │ ├── adapterhelpers_util_test.go │ │ ├── apigateway-api-key.go │ │ ├── apigateway-api-key_test.go │ │ ├── apigateway-authorizer.go │ │ ├── apigateway-authorizer_test.go │ │ ├── apigateway-deployment.go │ │ ├── apigateway-deployment_test.go │ │ ├── apigateway-domain-name.go │ │ ├── apigateway-domain-name_test.go │ │ ├── apigateway-integration.go │ │ ├── apigateway-integration_test.go │ │ ├── apigateway-method-response.go │ │ ├── apigateway-method-response_test.go │ │ ├── apigateway-method.go │ │ ├── apigateway-method_test.go │ │ ├── apigateway-model.go │ │ ├── apigateway-model_test.go │ │ ├── apigateway-resource.go │ │ ├── apigateway-resource_test.go │ │ ├── apigateway-rest-api.go │ │ ├── apigateway-rest-api_test.go │ │ ├── apigateway-stage.go │ │ ├── apigateway-stage_test.go │ │ ├── autoscaling-auto-scaling-group.go │ │ ├── autoscaling-auto-scaling-group_test.go │ │ ├── autoscaling-auto-scaling-policy.go │ │ ├── autoscaling-auto-scaling-policy_test.go │ │ ├── cloudfront-cache-policy.go │ │ ├── cloudfront-cache-policy_test.go │ │ ├── cloudfront-continuous-deployment-policy.go │ │ ├── cloudfront-continuous-deployment-policy_test.go │ │ ├── cloudfront-distribution.go │ │ ├── cloudfront-distribution_test.go │ │ ├── cloudfront-function.go │ │ ├── cloudfront-function_test.go │ │ ├── cloudfront-key-group.go │ │ ├── cloudfront-key-group_test.go │ │ ├── cloudfront-origin-access-control.go │ │ ├── cloudfront-origin-access-control_test.go │ │ ├── cloudfront-origin-request-policy.go │ │ ├── cloudfront-origin-request-policy_test.go │ │ ├── cloudfront-realtime-log-config.go │ │ ├── cloudfront-realtime-log-config_test.go │ │ ├── cloudfront-response-headers-policy.go │ │ ├── cloudfront-response-headers-policy_test.go │ │ ├── cloudfront-streaming-distribution.go │ │ ├── cloudfront-streaming-distribution_test.go │ │ ├── cloudfront.go │ │ ├── cloudfront_test.go │ │ ├── cloudwatch-alarm.go │ │ ├── cloudwatch-alarm_test.go │ │ ├── cloudwatch-instance-metric.go │ │ ├── cloudwatch-instance-metric_integration_test.go │ │ ├── cloudwatch-instance-metric_test.go │ │ ├── cloudwatch_metric_links.go │ │ ├── cloudwatch_metric_links_test.go │ │ ├── directconnect-connection.go │ │ ├── directconnect-connection_test.go │ │ ├── directconnect-customer-metadata.go │ │ ├── directconnect-customer-metadata_test.go │ │ ├── directconnect-direct-connect-gateway-association-proposal.go │ │ ├── directconnect-direct-connect-gateway-association-proposal_test.go │ │ ├── directconnect-direct-connect-gateway-association.go │ │ ├── directconnect-direct-connect-gateway-association_test.go │ │ ├── directconnect-direct-connect-gateway-attachment.go │ │ ├── directconnect-direct-connect-gateway-attachment_test.go │ │ ├── directconnect-direct-connect-gateway.go │ │ ├── directconnect-direct-connect-gateway_test.go │ │ ├── directconnect-hosted-connection.go │ │ ├── directconnect-hosted-connection_test.go │ │ ├── directconnect-interconnect.go │ │ ├── directconnect-interconnect_test.go │ │ ├── directconnect-lag.go │ │ ├── directconnect-lag_test.go │ │ ├── directconnect-location.go │ │ ├── directconnect-location_test.go │ │ ├── directconnect-router-configuration.go │ │ ├── directconnect-router-configuration_test.go │ │ ├── directconnect-virtual-gateway.go │ │ ├── directconnect-virtual-gateway_test.go │ │ ├── directconnect-virtual-interface.go │ │ ├── directconnect-virtual-interface_test.go │ │ ├── directconnect.go │ │ ├── directconnect_test.go │ │ ├── dynamodb-backup.go │ │ ├── dynamodb-backup_test.go │ │ ├── dynamodb-table.go │ │ ├── dynamodb-table_test.go │ │ ├── dynamodb.go │ │ ├── dynamodb_test.go │ │ ├── ec2-address.go │ │ ├── ec2-address_test.go │ │ ├── ec2-capacity-reservation-fleet.go │ │ ├── ec2-capacity-reservation-fleet_test.go │ │ ├── ec2-capacity-reservation.go │ │ ├── ec2-capacity-reservation_test.go │ │ ├── ec2-egress-only-internet-gateway.go │ │ ├── ec2-egress-only-internet-gateway_test.go │ │ ├── ec2-iam-instance-profile-association.go │ │ ├── ec2-iam-instance-profile-association_test.go │ │ ├── ec2-image.go │ │ ├── ec2-image_test.go │ │ ├── ec2-instance-event-window.go │ │ ├── ec2-instance-event-window_test.go │ │ ├── ec2-instance-status.go │ │ ├── ec2-instance-status_test.go │ │ ├── ec2-instance.go │ │ ├── ec2-instance_test.go │ │ ├── ec2-internet-gateway.go │ │ ├── ec2-internet-gateway_test.go │ │ ├── ec2-key-pair.go │ │ ├── ec2-key-pair_test.go │ │ ├── ec2-launch-template-version.go │ │ ├── ec2-launch-template-version_test.go │ │ ├── ec2-launch-template.go │ │ ├── ec2-launch-template_test.go │ │ ├── ec2-nat-gateway.go │ │ ├── ec2-nat-gateway_test.go │ │ ├── ec2-network-acl.go │ │ ├── ec2-network-acl_test.go │ │ ├── ec2-network-interface-permission.go │ │ ├── ec2-network-interface-permission_test.go │ │ ├── ec2-network-interface.go │ │ ├── ec2-network-interface_test.go │ │ ├── ec2-placement-group.go │ │ ├── ec2-placement-group_test.go │ │ ├── ec2-reserved-instance.go │ │ ├── ec2-reserved-instance_test.go │ │ ├── ec2-route-table.go │ │ ├── ec2-route-table_test.go │ │ ├── ec2-security-group-rule.go │ │ ├── ec2-security-group-rule_test.go │ │ ├── ec2-security-group.go │ │ ├── ec2-security-group_test.go │ │ ├── ec2-snapshot.go │ │ ├── ec2-snapshot_test.go │ │ ├── ec2-subnet.go │ │ ├── ec2-subnet_test.go │ │ ├── ec2-transit-gateway-route-table-association.go │ │ ├── ec2-transit-gateway-route-table-association_test.go │ │ ├── ec2-transit-gateway-route-table-propagation.go │ │ ├── ec2-transit-gateway-route-table-propagation_test.go │ │ ├── ec2-transit-gateway-route-table.go │ │ ├── ec2-transit-gateway-route-table_test.go │ │ ├── ec2-transit-gateway-route.go │ │ ├── ec2-transit-gateway-route_test.go │ │ ├── ec2-volume-status.go │ │ ├── ec2-volume-status_test.go │ │ ├── ec2-volume.go │ │ ├── ec2-volume_test.go │ │ ├── ec2-vpc-endpoint.go │ │ ├── ec2-vpc-endpoint_test.go │ │ ├── ec2-vpc-peering-connection.go │ │ ├── ec2-vpc-peering-connection_test.go │ │ ├── ec2-vpc.go │ │ ├── ec2-vpc_test.go │ │ ├── ec2.go │ │ ├── ec2_test.go │ │ ├── ecs-capacity-provider.go │ │ ├── ecs-capacity-provider_test.go │ │ ├── ecs-cluster.go │ │ ├── ecs-cluster_test.go │ │ ├── ecs-container-instance.go │ │ ├── ecs-container-instance_test.go │ │ ├── ecs-service.go │ │ ├── ecs-service_test.go │ │ ├── ecs-task-definition.go │ │ ├── ecs-task-definition_test.go │ │ ├── ecs-task.go │ │ ├── ecs-task_test.go │ │ ├── ecs.go │ │ ├── ecs_test.go │ │ ├── efs-access-point.go │ │ ├── efs-access-point_test.go │ │ ├── efs-backup-policy.go │ │ ├── efs-backup-policy_test.go │ │ ├── efs-file-system.go │ │ ├── efs-file-system_test.go │ │ ├── efs-mount-target.go │ │ ├── efs-mount-target_test.go │ │ ├── efs-replication-configuration.go │ │ ├── efs-replication-configuration_test.go │ │ ├── efs.go │ │ ├── efs_test.go │ │ ├── eks-addon.go │ │ ├── eks-addon_test.go │ │ ├── eks-cluster.go │ │ ├── eks-cluster_test.go │ │ ├── eks-fargate-profile.go │ │ ├── eks-fargate-profile_test.go │ │ ├── eks-nodegroup.go │ │ ├── eks-nodegroup_test.go │ │ ├── eks.go │ │ ├── eks_test.go │ │ ├── elb-instance-health.go │ │ ├── elb-instance-health_test.go │ │ ├── elb-load-balancer.go │ │ ├── elb-load-balancer_test.go │ │ ├── elbv2-listener.go │ │ ├── elbv2-listener_test.go │ │ ├── elbv2-load-balancer.go │ │ ├── elbv2-load-balancer_test.go │ │ ├── elbv2-rule.go │ │ ├── elbv2-rule_test.go │ │ ├── elbv2-target-group.go │ │ ├── elbv2-target-group_test.go │ │ ├── elbv2-target-health.go │ │ ├── elbv2-target-health_test.go │ │ ├── elbv2.go │ │ ├── elbv2_test.go │ │ ├── iam-group.go │ │ ├── iam-group_test.go │ │ ├── iam-instance-profile.go │ │ ├── iam-instance-profile_test.go │ │ ├── iam-policy.go │ │ ├── iam-policy_test.go │ │ ├── iam-role.go │ │ ├── iam-role_test.go │ │ ├── iam-user.go │ │ ├── iam-user_test.go │ │ ├── iam.go │ │ ├── iam_test.go │ │ ├── integration/ │ │ │ ├── apigateway/ │ │ │ │ ├── apigateway_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── find.go │ │ │ │ ├── main_test.go │ │ │ │ ├── setup.go │ │ │ │ ├── teardown.go │ │ │ │ └── util.go │ │ │ ├── ec2/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── find.go │ │ │ │ ├── instance_test.go │ │ │ │ ├── main_test.go │ │ │ │ ├── setup.go │ │ │ │ ├── teardown.go │ │ │ │ └── util.go │ │ │ ├── ec2-transit-gateway/ │ │ │ │ ├── client.go │ │ │ │ ├── main_test.go │ │ │ │ ├── setup.go │ │ │ │ ├── teardown.go │ │ │ │ ├── transit_gateway_route_table_association_test.go │ │ │ │ ├── transit_gateway_route_table_propagation_test.go │ │ │ │ ├── transit_gateway_route_table_test.go │ │ │ │ └── transit_gateway_route_test.go │ │ │ ├── errors.go │ │ │ ├── kms/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── find.go │ │ │ │ ├── kms_test.go │ │ │ │ ├── main_test.go │ │ │ │ ├── setup.go │ │ │ │ ├── teardown.go │ │ │ │ └── util.go │ │ │ ├── networkmanager/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── find.go │ │ │ │ ├── main_test.go │ │ │ │ ├── networkmanager_test.go │ │ │ │ ├── setup.go │ │ │ │ ├── tags.go │ │ │ │ └── teardown.go │ │ │ ├── ssm/ │ │ │ │ └── main_test.go │ │ │ ├── util.go │ │ │ └── util_test.go │ │ ├── kms-alias.go │ │ ├── kms-alias_test.go │ │ ├── kms-custom-key-store.go │ │ ├── kms-custom-key-store_test.go │ │ ├── kms-grant.go │ │ ├── kms-grant_test.go │ │ ├── kms-key-policy.go │ │ ├── kms-key-policy_test.go │ │ ├── kms-key.go │ │ ├── kms-key_test.go │ │ ├── kms.go │ │ ├── lambda-event-source-mapping.go │ │ ├── lambda-event-source-mapping_test.go │ │ ├── lambda-function.go │ │ ├── lambda-function_test.go │ │ ├── lambda-layer-version.go │ │ ├── lambda-layer-version_test.go │ │ ├── lambda-layer.go │ │ ├── lambda-layer_test.go │ │ ├── lambda.go │ │ ├── lambda_test.go │ │ ├── main.go │ │ ├── network-firewall-firewall-policy.go │ │ ├── network-firewall-firewall-policy_test.go │ │ ├── network-firewall-firewall.go │ │ ├── network-firewall-firewall_test.go │ │ ├── network-firewall-rule-group.go │ │ ├── network-firewall-rule-group_test.go │ │ ├── network-firewall-tls-inspection-configuration.go │ │ ├── network-firewall-tls-inspection-configuration_test.go │ │ ├── networkfirewall.go │ │ ├── networkfirewall_test.go │ │ ├── networkmanager-connect-attachment.go │ │ ├── networkmanager-connect-attachment_test.go │ │ ├── networkmanager-connect-peer-association.go │ │ ├── networkmanager-connect-peer-association_test.go │ │ ├── networkmanager-connect-peer.go │ │ ├── networkmanager-connect-peer_test.go │ │ ├── networkmanager-connection.go │ │ ├── networkmanager-connection_test.go │ │ ├── networkmanager-core-network-policy.go │ │ ├── networkmanager-core-network-policy_test.go │ │ ├── networkmanager-core-network.go │ │ ├── networkmanager-core-network_test.go │ │ ├── networkmanager-device.go │ │ ├── networkmanager-device_test.go │ │ ├── networkmanager-global-network.go │ │ ├── networkmanager-global-network_test.go │ │ ├── networkmanager-link-association.go │ │ ├── networkmanager-link-association_test.go │ │ ├── networkmanager-link.go │ │ ├── networkmanager-link_test.go │ │ ├── networkmanager-network-resource-relationship.go │ │ ├── networkmanager-network-resource-relationship_test.go │ │ ├── networkmanager-site-to-site-vpn-attachment.go │ │ ├── networkmanager-site-to-site-vpn-attachment_test.go │ │ ├── networkmanager-site.go │ │ ├── networkmanager-site_test.go │ │ ├── networkmanager-transit-gateway-connect-peer-association.go │ │ ├── networkmanager-transit-gateway-connect-peer-association_test.go │ │ ├── networkmanager-transit-gateway-peering.go │ │ ├── networkmanager-transit-gateway-peering_test.go │ │ ├── networkmanager-transit-gateway-registration.go │ │ ├── networkmanager-transit-gateway-registration_test.go │ │ ├── networkmanager-transit-gateway-route-table-attachment.go │ │ ├── networkmanager-transit-gateway-route-table-attachment_test.go │ │ ├── networkmanager-vpc-attachment.go │ │ ├── networkmanager-vpc-attachment_test.go │ │ ├── networkmanager.go │ │ ├── networkmanager_test.go │ │ ├── rds-db-cluster-parameter-group.go │ │ ├── rds-db-cluster-parameter-group_test.go │ │ ├── rds-db-cluster.go │ │ ├── rds-db-cluster_test.go │ │ ├── rds-db-instance.go │ │ ├── rds-db-instance_test.go │ │ ├── rds-db-parameter-group.go │ │ ├── rds-db-parameter-group_test.go │ │ ├── rds-db-subnet-group.go │ │ ├── rds-db-subnet-group_test.go │ │ ├── rds-option-group.go │ │ ├── rds-option-group_test.go │ │ ├── rds.go │ │ ├── rds_test.go │ │ ├── route53-health-check.go │ │ ├── route53-health-check_test.go │ │ ├── route53-hosted-zone.go │ │ ├── route53-hosted-zone_test.go │ │ ├── route53-resource-record-set.go │ │ ├── route53-resource-record-set_test.go │ │ ├── route53.go │ │ ├── route53_test.go │ │ ├── s3.go │ │ ├── s3_test.go │ │ ├── sns-data-protection-policy.go │ │ ├── sns-data-protection-policy_test.go │ │ ├── sns-endpoint.go │ │ ├── sns-endpoint_test.go │ │ ├── sns-platform-application.go │ │ ├── sns-platform-application_test.go │ │ ├── sns-subscription.go │ │ ├── sns-subscription_test.go │ │ ├── sns-topic.go │ │ ├── sns-topic_test.go │ │ ├── sns.go │ │ ├── sqs-queue.go │ │ ├── sqs-queue_test.go │ │ ├── sqs.go │ │ ├── ssm-parameter.go │ │ ├── ssm-parameter_test.go │ │ └── tracing.go │ ├── build/ │ │ └── package/ │ │ └── Dockerfile │ ├── cmd/ │ │ └── root.go │ ├── docker-compose.yml │ ├── main.go │ ├── module/ │ │ ├── provider/ │ │ │ ├── .github/ │ │ │ │ └── workflows/ │ │ │ │ ├── finalize-copybara-sync.yml │ │ │ │ ├── release.yml │ │ │ │ └── tag-on-merge.yml │ │ │ ├── .goreleaser.yml │ │ │ ├── LICENSE │ │ │ ├── datasource_aws_external_id.go │ │ │ ├── main.go │ │ │ ├── provider.go │ │ │ ├── provider_test.go │ │ │ ├── resource_aws_source.go │ │ │ └── terraform-registry-manifest.json │ │ └── terraform/ │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ ├── finalize-copybara-sync.yml │ │ │ └── tag-on-merge.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── examples/ │ │ │ ├── multi-account/ │ │ │ │ └── main.tf │ │ │ └── single-account/ │ │ │ └── main.tf │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── variables.tf │ │ └── versions.tf │ └── proc/ │ ├── proc.go │ └── proc_test.go ├── cmd/ │ ├── auth_client.go │ ├── auth_client_test.go │ ├── bookmarks.go │ ├── bookmarks_create_bookmark.go │ ├── bookmarks_get_affected_bookmarks.go │ ├── bookmarks_get_bookmark.go │ ├── changes.go │ ├── changes_end_change.go │ ├── changes_get_change.go │ ├── changes_get_change_test.go │ ├── changes_get_signals.go │ ├── changes_get_signals_test.go │ ├── changes_list_changes.go │ ├── changes_start_analysis.go │ ├── changes_start_analysis_test.go │ ├── changes_start_change.go │ ├── changes_submit_plan.go │ ├── changes_submit_plan_test.go │ ├── changes_submit_signal.go │ ├── changes_submit_signal_test.go │ ├── explore.go │ ├── explore_test.go │ ├── flags.go │ ├── flags_test.go │ ├── integrations.go │ ├── integrations_tfc.go │ ├── invites.go │ ├── invites_crud.go │ ├── knowledge.go │ ├── knowledge_dir_flag_test.go │ ├── knowledge_list.go │ ├── knowledge_list_test.go │ ├── logging.go │ ├── pterm.go │ ├── repo.go │ ├── repo_test.go │ ├── request.go │ ├── request_load.go │ ├── request_query.go │ ├── root.go │ ├── root_test.go │ ├── snapshots.go │ ├── snapshots_create.go │ ├── snapshots_get_snapshot.go │ ├── terraform.go │ ├── terraform_apply.go │ ├── terraform_plan.go │ ├── terraform_plan_test.go │ ├── theme.go │ ├── theme_darwin.go │ ├── theme_linux.go │ ├── theme_test.go │ ├── theme_windows.go │ ├── version_check.go │ └── version_check_test.go ├── demos/ │ └── plan.tape ├── docs.overmind.tech/ │ └── docs/ │ └── sources/ │ ├── aws/ │ │ └── data/ │ │ ├── apigateway-api-key.json │ │ ├── apigateway-authorizer.json │ │ ├── apigateway-deployment.json │ │ ├── apigateway-domain-name.json │ │ ├── apigateway-integration.json │ │ ├── apigateway-method-response.json │ │ ├── apigateway-method.json │ │ ├── apigateway-model.json │ │ ├── apigateway-resource.json │ │ ├── apigateway-rest-api.json │ │ ├── apigateway-stage.json │ │ ├── autoscaling-auto-scaling-group.json │ │ ├── cloudfront-cache-policy.json │ │ ├── cloudfront-continuous-deployment-policy.json │ │ ├── cloudfront-distribution.json │ │ ├── cloudfront-function.json │ │ ├── cloudfront-key-group.json │ │ ├── cloudfront-origin-access-control.json │ │ ├── cloudfront-origin-request-policy.json │ │ ├── cloudfront-realtime-log-config.json │ │ ├── cloudfront-response-headers-policy.json │ │ ├── cloudfront-streaming-distribution.json │ │ ├── cloudwatch-alarm.json │ │ ├── directconnect-connection.json │ │ ├── directconnect-customer-metadata.json │ │ ├── directconnect-direct-connect-gateway-association-proposal.json │ │ ├── directconnect-direct-connect-gateway-association.json │ │ ├── directconnect-direct-connect-gateway-attachment.json │ │ ├── directconnect-direct-connect-gateway.json │ │ ├── directconnect-hosted-connection.json │ │ ├── directconnect-interconnect.json │ │ ├── directconnect-lag.json │ │ ├── directconnect-location.json │ │ ├── directconnect-router-configuration.json │ │ ├── directconnect-virtual-gateway.json │ │ ├── directconnect-virtual-interface.json │ │ ├── dynamodb-backup.json │ │ ├── dynamodb-table.json │ │ ├── ec2-address.json │ │ ├── ec2-capacity-reservation-fleet.json │ │ ├── ec2-capacity-reservation.json │ │ ├── ec2-egress-only-internet-gateway.json │ │ ├── ec2-iam-instance-profile-association.json │ │ ├── ec2-image.json │ │ ├── ec2-instance-event-window.json │ │ ├── ec2-instance-status.json │ │ ├── ec2-instance.json │ │ ├── ec2-internet-gateway.json │ │ ├── ec2-key-pair.json │ │ ├── ec2-launch-template-version.json │ │ ├── ec2-launch-template.json │ │ ├── ec2-nat-gateway.json │ │ ├── ec2-network-acl.json │ │ ├── ec2-network-interface-permission.json │ │ ├── ec2-network-interface.json │ │ ├── ec2-placement-group.json │ │ ├── ec2-reserved-instance.json │ │ ├── ec2-route-table.json │ │ ├── ec2-security-group-rule.json │ │ ├── ec2-security-group.json │ │ ├── ec2-snapshot.json │ │ ├── ec2-subnet.json │ │ ├── ec2-transit-gateway-route-table-association.json │ │ ├── ec2-transit-gateway-route-table-propagation.json │ │ ├── ec2-transit-gateway-route-table.json │ │ ├── ec2-transit-gateway-route.json │ │ ├── ec2-volume-status.json │ │ ├── ec2-volume.json │ │ ├── ec2-vpc-endpoint.json │ │ ├── ec2-vpc-peering-connection.json │ │ ├── ec2-vpc.json │ │ ├── ecs-capacity-provider.json │ │ ├── ecs-cluster.json │ │ ├── ecs-container-instance.json │ │ ├── ecs-service.json │ │ ├── ecs-task-definition.json │ │ ├── ecs-task.json │ │ ├── efs-access-point.json │ │ ├── efs-backup-policy.json │ │ ├── efs-file-system.json │ │ ├── efs-mount-target.json │ │ ├── efs-replication-configuration.json │ │ ├── eks-addon.json │ │ ├── eks-cluster.json │ │ ├── eks-fargate-profile.json │ │ ├── eks-nodegroup.json │ │ ├── elb-instance-health.json │ │ ├── elb-load-balancer.json │ │ ├── elbv2-listener.json │ │ ├── elbv2-load-balancer.json │ │ ├── elbv2-rule.json │ │ ├── elbv2-target-group.json │ │ ├── elbv2-target-health.json │ │ ├── iam-group.json │ │ ├── iam-instance-profile.json │ │ ├── iam-policy.json │ │ ├── iam-role.json │ │ ├── iam-user.json │ │ ├── kms-alias.json │ │ ├── kms-custom-key-store.json │ │ ├── kms-grant.json │ │ ├── kms-key-policy.json │ │ ├── kms-key.json │ │ ├── lambda-event-source-mapping.json │ │ ├── lambda-function.json │ │ ├── lambda-layer-version.json │ │ ├── lambda-layer.json │ │ ├── network-firewall-firewall-policy.json │ │ ├── network-firewall-firewall.json │ │ ├── network-firewall-rule-group.json │ │ ├── network-firewall-tls-inspection-configuration.json │ │ ├── networkmanager-connect-attachment.json │ │ ├── networkmanager-connect-peer-association.json │ │ ├── networkmanager-connect-peer.json │ │ ├── networkmanager-connection.json │ │ ├── networkmanager-core-network-policy.json │ │ ├── networkmanager-core-network.json │ │ ├── networkmanager-device.json │ │ ├── networkmanager-global-network.json │ │ ├── networkmanager-link-association.json │ │ ├── networkmanager-link.json │ │ ├── networkmanager-network-resource-relationship.json │ │ ├── networkmanager-site-to-site-vpn-attachment.json │ │ ├── networkmanager-site.json │ │ ├── networkmanager-transit-gateway-connect-peer-association.json │ │ ├── networkmanager-transit-gateway-peering.json │ │ ├── networkmanager-transit-gateway-registration.json │ │ ├── networkmanager-transit-gateway-route-table-attachment.json │ │ ├── networkmanager-vpc-attachment.json │ │ ├── rds-db-cluster-parameter-group.json │ │ ├── rds-db-cluster.json │ │ ├── rds-db-instance.json │ │ ├── rds-db-parameter-group.json │ │ ├── rds-db-subnet-group.json │ │ ├── rds-option-group.json │ │ ├── route53-health-check.json │ │ ├── route53-hosted-zone.json │ │ ├── route53-resource-record-set.json │ │ ├── s3-bucket.json │ │ ├── sns-data-protection-policy.json │ │ ├── sns-endpoint.json │ │ ├── sns-platform-application.json │ │ ├── sns-subscription.json │ │ ├── sns-topic.json │ │ ├── sqs-queue.json │ │ └── ssm-parameter.json │ ├── embed.go │ ├── gcp/ │ │ └── data/ │ │ ├── gcp-ai-platform-batch-prediction-job.json │ │ ├── gcp-ai-platform-custom-job.json │ │ ├── gcp-ai-platform-endpoint.json │ │ ├── gcp-ai-platform-model-deployment-monitoring-job.json │ │ ├── gcp-ai-platform-model.json │ │ ├── gcp-ai-platform-pipeline-job.json │ │ ├── gcp-artifact-registry-docker-image.json │ │ ├── gcp-big-query-data-transfer-transfer-config.json │ │ ├── gcp-big-query-dataset.json │ │ ├── gcp-big-query-routine.json │ │ ├── gcp-big-query-table.json │ │ ├── gcp-big-table-admin-app-profile.json │ │ ├── gcp-big-table-admin-backup.json │ │ ├── gcp-big-table-admin-cluster.json │ │ ├── gcp-big-table-admin-instance.json │ │ ├── gcp-big-table-admin-table.json │ │ ├── gcp-certificate-manager-certificate.json │ │ ├── gcp-cloud-billing-billing-info.json │ │ ├── gcp-cloud-build-build.json │ │ ├── gcp-cloud-functions-function.json │ │ ├── gcp-cloud-kms-crypto-key-version.json │ │ ├── gcp-cloud-kms-crypto-key.json │ │ ├── gcp-cloud-kms-key-ring.json │ │ ├── gcp-cloud-resource-manager-project.json │ │ ├── gcp-cloud-resource-manager-tag-value.json │ │ ├── gcp-compute-address.json │ │ ├── gcp-compute-autoscaler.json │ │ ├── gcp-compute-backend-service.json │ │ ├── gcp-compute-disk.json │ │ ├── gcp-compute-external-vpn-gateway.json │ │ ├── gcp-compute-firewall.json │ │ ├── gcp-compute-forwarding-rule.json │ │ ├── gcp-compute-global-address.json │ │ ├── gcp-compute-global-forwarding-rule.json │ │ ├── gcp-compute-health-check.json │ │ ├── gcp-compute-http-health-check.json │ │ ├── gcp-compute-image.json │ │ ├── gcp-compute-instance-group-manager.json │ │ ├── gcp-compute-instance-group.json │ │ ├── gcp-compute-instance-template.json │ │ ├── gcp-compute-instance.json │ │ ├── gcp-compute-instant-snapshot.json │ │ ├── gcp-compute-machine-image.json │ │ ├── gcp-compute-network-endpoint-group.json │ │ ├── gcp-compute-network.json │ │ ├── gcp-compute-node-group.json │ │ ├── gcp-compute-node-template.json │ │ ├── gcp-compute-project.json │ │ ├── gcp-compute-public-delegated-prefix.json │ │ ├── gcp-compute-region-commitment.json │ │ ├── gcp-compute-regional-instance-group-manager.json │ │ ├── gcp-compute-reservation.json │ │ ├── gcp-compute-route.json │ │ ├── gcp-compute-router.json │ │ ├── gcp-compute-security-policy.json │ │ ├── gcp-compute-snapshot.json │ │ ├── gcp-compute-ssl-certificate.json │ │ ├── gcp-compute-ssl-policy.json │ │ ├── gcp-compute-subnetwork.json │ │ ├── gcp-compute-target-http-proxy.json │ │ ├── gcp-compute-target-https-proxy.json │ │ ├── gcp-compute-target-pool.json │ │ ├── gcp-compute-url-map.json │ │ ├── gcp-compute-vpn-gateway.json │ │ ├── gcp-compute-vpn-tunnel.json │ │ ├── gcp-container-cluster.json │ │ ├── gcp-container-node-pool.json │ │ ├── gcp-dataflow-job.json │ │ ├── gcp-dataform-repository.json │ │ ├── gcp-dataplex-aspect-type.json │ │ ├── gcp-dataplex-data-scan.json │ │ ├── gcp-dataplex-entry-group.json │ │ ├── gcp-dataproc-autoscaling-policy.json │ │ ├── gcp-dataproc-cluster.json │ │ ├── gcp-dns-managed-zone.json │ │ ├── gcp-essential-contacts-contact.json │ │ ├── gcp-file-instance.json │ │ ├── gcp-iam-role.json │ │ ├── gcp-iam-service-account-key.json │ │ ├── gcp-iam-service-account.json │ │ ├── gcp-logging-bucket.json │ │ ├── gcp-logging-link.json │ │ ├── gcp-logging-saved-query.json │ │ ├── gcp-logging-sink.json │ │ ├── gcp-monitoring-alert-policy.json │ │ ├── gcp-monitoring-custom-dashboard.json │ │ ├── gcp-monitoring-notification-channel.json │ │ ├── gcp-orgpolicy-policy.json │ │ ├── gcp-pub-sub-subscription.json │ │ ├── gcp-pub-sub-topic.json │ │ ├── gcp-redis-instance.json │ │ ├── gcp-run-revision.json │ │ ├── gcp-run-service.json │ │ ├── gcp-secret-manager-secret.json │ │ ├── gcp-security-center-management-security-center-service.json │ │ ├── gcp-service-directory-endpoint.json │ │ ├── gcp-service-usage-service.json │ │ ├── gcp-spanner-database.json │ │ ├── gcp-spanner-instance.json │ │ ├── gcp-sql-admin-backup-run.json │ │ ├── gcp-sql-admin-backup.json │ │ ├── gcp-sql-admin-instance.json │ │ ├── gcp-storage-bucket-iam-policy.json │ │ ├── gcp-storage-bucket.json │ │ └── gcp-storage-transfer-transfer-job.json │ ├── k8s/ │ │ └── data/ │ │ ├── ClusterRole.json │ │ ├── ClusterRoleBinding.json │ │ ├── ConfigMap.json │ │ ├── CronJob.json │ │ ├── DaemonSet.json │ │ ├── Deployment.json │ │ ├── EndpointSlice.json │ │ ├── Endpoints.json │ │ ├── HorizontalPodAutoscaler.json │ │ ├── Ingress.json │ │ ├── Job.json │ │ ├── LimitRange.json │ │ ├── NetworkPolicy.json │ │ ├── Node.json │ │ ├── PersistentVolume.json │ │ ├── PersistentVolumeClaim.json │ │ ├── Pod.json │ │ ├── PodDisruptionBudget.json │ │ ├── PriorityClass.json │ │ ├── ReplicaSet.json │ │ ├── ReplicationController.json │ │ ├── ResourceQuota.json │ │ ├── Role.json │ │ ├── RoleBinding.json │ │ ├── Secret.json │ │ ├── Service.json │ │ ├── ServiceAccount.json │ │ ├── StatefulSet.json │ │ ├── StorageClass.json │ │ └── VolumeAttachment.json │ └── stdlib/ │ └── data/ │ ├── certificate.json │ ├── dns.json │ ├── http.json │ ├── ip.json │ ├── rdap-asn.json │ ├── rdap-domain.json │ ├── rdap-entity.json │ ├── rdap-ip-network.json │ └── rdap-nameserver.json ├── examples/ │ └── create-bookmark.json ├── go/ │ ├── audit/ │ │ ├── main.go │ │ └── main_test.go │ ├── auth/ │ │ ├── auth.go │ │ ├── auth_client.go │ │ ├── auth_test.go │ │ ├── context_aware_auth_test.go │ │ ├── gcpauth.go │ │ ├── mcpoauth.go │ │ ├── mcpoauth_test.go │ │ ├── middleware.go │ │ ├── middleware_test.go │ │ ├── nats.go │ │ ├── nats_test.go │ │ └── tracing.go │ ├── cliauth/ │ │ ├── cliauth.go │ │ └── cliauth_test.go │ ├── discovery/ │ │ ├── adapter.go │ │ ├── adapter_test.go │ │ ├── adapterhost.go │ │ ├── adapterhost_bench_test.go │ │ ├── adapterhost_test.go │ │ ├── cmd.go │ │ ├── cmd_test.go │ │ ├── doc.go │ │ ├── engine.go │ │ ├── engine_initerror_test.go │ │ ├── engine_test.go │ │ ├── enginerequests.go │ │ ├── enginerequests_test.go │ │ ├── execute_query_trace_test.go │ │ ├── getfindmutex.go │ │ ├── getfindmutex_test.go │ │ ├── heartbeat.go │ │ ├── heartbeat_test.go │ │ ├── item_tests.go │ │ ├── logs.go │ │ ├── logs_test.go │ │ ├── main_test.go │ │ ├── nats_shared_test.go │ │ ├── nats_watcher.go │ │ ├── nats_watcher_test.go │ │ ├── nil_publisher.go │ │ ├── performance_test.go │ │ ├── querytracker.go │ │ ├── querytracker_test.go │ │ ├── shared_test.go │ │ └── tracing.go │ ├── logging/ │ │ ├── logging.go │ │ └── logging_test.go │ ├── sdp-go/ │ │ ├── .gitignore │ │ ├── account.go │ │ ├── account.pb.go │ │ ├── apikey.go │ │ ├── apikeys.pb.go │ │ ├── area51.pb.go │ │ ├── auth0support.pb.go │ │ ├── bookmarks.go │ │ ├── bookmarks.pb.go │ │ ├── cached_entry.pb.go │ │ ├── changes.go │ │ ├── changes.pb.go │ │ ├── changes_test.go │ │ ├── changetimeline.go │ │ ├── changetimeline_test.go │ │ ├── cli.pb.go │ │ ├── compare.go │ │ ├── config.pb.go │ │ ├── connection.go │ │ ├── connection_test.go │ │ ├── encoder_test.go │ │ ├── errors.go │ │ ├── gateway.go │ │ ├── gateway.pb.go │ │ ├── gateway_test.go │ │ ├── genhandler.go │ │ ├── graph/ │ │ │ ├── main.go │ │ │ └── main_test.go │ │ ├── handler_cancelquery.go │ │ ├── handler_gatewayresponse.go │ │ ├── handler_natsgetlogrecordsrequest.go │ │ ├── handler_natsgetlogrecordsresponse.go │ │ ├── handler_query.go │ │ ├── handler_queryresponse.go │ │ ├── host_trust.go │ │ ├── host_trust_test.go │ │ ├── instance_detect.go │ │ ├── invites.pb.go │ │ ├── items.go │ │ ├── items.pb.go │ │ ├── items_test.go │ │ ├── link_extract.go │ │ ├── link_extract_test.go │ │ ├── logs.go │ │ ├── logs.pb.go │ │ ├── logs_test.go │ │ ├── progress.go │ │ ├── progress_test.go │ │ ├── proto_clone_test.go │ │ ├── responses.go │ │ ├── responses.pb.go │ │ ├── revlink.pb.go │ │ ├── sdpconnect/ │ │ │ ├── account.connect.go │ │ │ ├── apikeys.connect.go │ │ │ ├── area51.connect.go │ │ │ ├── auth0support.connect.go │ │ │ ├── bookmarks.connect.go │ │ │ ├── changes.connect.go │ │ │ ├── cli.connect.go │ │ │ ├── config.connect.go │ │ │ ├── invites.connect.go │ │ │ ├── logs.connect.go │ │ │ ├── revlink.connect.go │ │ │ ├── signal.connect.go │ │ │ └── snapshots.connect.go │ │ ├── sdpws/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── messagehandler.go │ │ │ └── utils.go │ │ ├── signal.pb.go │ │ ├── signals.go │ │ ├── snapshots.go │ │ ├── snapshots.pb.go │ │ ├── test_utils.go │ │ ├── test_utils_test.go │ │ ├── tracing.go │ │ ├── tracing_test.go │ │ ├── util.go │ │ ├── util.pb.go │ │ ├── util_test.go │ │ ├── validation.go │ │ └── validation_test.go │ ├── sdpcache/ │ │ ├── bolt.go │ │ ├── boltstore.go │ │ ├── boltstore_test.go │ │ ├── cache.go │ │ ├── cache_benchmark_test.go │ │ ├── cache_contract_test.go │ │ ├── cache_stuck_test.go │ │ ├── cache_test.go │ │ ├── item_generator_test.go │ │ ├── lookup_coordinator.go │ │ ├── memory.go │ │ ├── memory_test.go │ │ ├── noop_cache_test.go │ │ ├── pending.go │ │ ├── purger.go │ │ ├── sharded.go │ │ └── sharded_test.go │ └── tracing/ │ ├── deferlog.go │ ├── header_carrier.go │ ├── main.go │ ├── main_test.go │ ├── memory.go │ └── memory_test.go ├── go.mod ├── go.sum ├── gon-amd64.json ├── gon-arm64.json ├── k8s-source/ │ ├── acceptance/ │ │ └── nats-server.conf │ ├── adapters/ │ │ ├── clusterrole.go │ │ ├── clusterrole_test.go │ │ ├── clusterrolebinding.go │ │ ├── clusterrolebinding_test.go │ │ ├── configmap.go │ │ ├── configmap_test.go │ │ ├── cronjob.go │ │ ├── cronjob_test.go │ │ ├── daemonset.go │ │ ├── daemonset_test.go │ │ ├── deployment.go │ │ ├── deployment_test.go │ │ ├── endpoints.go │ │ ├── endpoints_test.go │ │ ├── endpointslice.go │ │ ├── endpointslice_test.go │ │ ├── generic_source.go │ │ ├── generic_source_test.go │ │ ├── horizontalpodautoscaler.go │ │ ├── horizontalpodautoscaler_test.go │ │ ├── ingress.go │ │ ├── ingress_test.go │ │ ├── job.go │ │ ├── job_test.go │ │ ├── limitrange.go │ │ ├── limitrange_test.go │ │ ├── main.go │ │ ├── networkpolicy.go │ │ ├── networkpolicy_test.go │ │ ├── node.go │ │ ├── node_test.go │ │ ├── persistentvolume.go │ │ ├── persistentvolume_test.go │ │ ├── persistentvolumeclaim.go │ │ ├── persistentvolumeclaim_test.go │ │ ├── poddisruptionbudget.go │ │ ├── poddisruptionbudget_test.go │ │ ├── pods.go │ │ ├── pods_test.go │ │ ├── priorityclass.go │ │ ├── priorityclass_test.go │ │ ├── replicaset.go │ │ ├── replicaset_test.go │ │ ├── replicationcontroller.go │ │ ├── replicationcontroller_test.go │ │ ├── resourcequota.go │ │ ├── resourcequota_test.go │ │ ├── role.go │ │ ├── role_test.go │ │ ├── rolebinding.go │ │ ├── rolebinding_test.go │ │ ├── secret.go │ │ ├── secret_test.go │ │ ├── service.go │ │ ├── service_test.go │ │ ├── serviceaccount.go │ │ ├── serviceaccount_test.go │ │ ├── shared_test.go │ │ ├── shared_util.go │ │ ├── shared_util_test.go │ │ ├── statefulset.go │ │ ├── statefulset_test.go │ │ ├── storageclass.go │ │ ├── storageclass_test.go │ │ ├── volumeattachment.go │ │ └── volumeattachment_test.go │ ├── build/ │ │ └── package/ │ │ └── Dockerfile │ ├── cmd/ │ │ └── root.go │ ├── config.json │ ├── cr.sh │ ├── deployments/ │ │ └── overmind-kube-source/ │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates/ │ │ │ ├── NOTES.txt │ │ │ ├── _helpers.tpl │ │ │ ├── clusterrole.yaml │ │ │ ├── clusterrolebinding.yaml │ │ │ ├── configmap.yaml │ │ │ ├── deployment.yaml │ │ │ ├── poddisruptionbudget.yaml │ │ │ ├── secret.yaml │ │ │ └── serviceaccount.yaml │ │ └── values.yaml │ ├── main.go │ └── proc/ │ └── proc.go ├── knowledge/ │ ├── discover.go │ └── discover_test.go ├── main.go ├── main.tf ├── sources/ │ ├── aws/ │ │ ├── apigateway-api-key.go │ │ ├── apigateway-stage.go │ │ ├── base.go │ │ ├── errors.go │ │ ├── shared/ │ │ │ ├── item-types.go │ │ │ └── models.go │ │ └── validation_test.go │ ├── azure/ │ │ ├── README.MD │ │ ├── build/ │ │ │ └── package/ │ │ │ └── Dockerfile │ │ ├── clients/ │ │ │ ├── application-gateways-client.go │ │ │ ├── application-security-groups-client.go │ │ │ ├── availability-sets-client.go │ │ │ ├── batch-accounts-client.go │ │ │ ├── batch-application-client.go │ │ │ ├── batch-application-package-client.go │ │ │ ├── batch-pool-client.go │ │ │ ├── batch-private-endpoint-connection-client.go │ │ │ ├── blob-containers-client.go │ │ │ ├── capacity-reservation-groups-client.go │ │ │ ├── capacity-reservations-client.go │ │ │ ├── compute-disk-access-private-endpoint-connection-client.go │ │ │ ├── dbforpostgresql-configurations-client.go │ │ │ ├── dbforpostgresql-flexible-server-administrator-client.go │ │ │ ├── dbforpostgresql-flexible-server-backup-client.go │ │ │ ├── dbforpostgresql-flexible-server-private-endpoint-connection-client.go │ │ │ ├── dbforpostgresql-flexible-server-replica-client.go │ │ │ ├── dbforpostgresql-flexible-server-virtual-endpoint-client.go │ │ │ ├── ddos-protection-plans-client.go │ │ │ ├── dedicated-host-groups-client.go │ │ │ ├── dedicated-hosts-client.go │ │ │ ├── default-security-rules-client.go │ │ │ ├── disk-accesses-client.go │ │ │ ├── disk-encryption-sets-client.go │ │ │ ├── disks-client.go │ │ │ ├── documentdb-database-accounts-client.go │ │ │ ├── documentdb-private-endpoint-connection-client.go │ │ │ ├── elastic-san-client.go │ │ │ ├── elastic-san-volume-client.go │ │ │ ├── elastic-san-volume-group-client.go │ │ │ ├── elastic-san-volume-snapshot-client.go │ │ │ ├── encryption-scopes-client.go │ │ │ ├── federated-identity-credentials-client.go │ │ │ ├── fileshares-client.go │ │ │ ├── flow-logs-client.go │ │ │ ├── galleries-client.go │ │ │ ├── gallery-application-versions-client.go │ │ │ ├── gallery-applications-client.go │ │ │ ├── gallery-images-client.go │ │ │ ├── images-client.go │ │ │ ├── interface-ip-configurations-client.go │ │ │ ├── ip-groups-client.go │ │ │ ├── keyvault-key-client.go │ │ │ ├── keyvault-managed-hsm-private-endpoint-connection-client.go │ │ │ ├── load-balancer-backend-address-pools-client.go │ │ │ ├── load-balancer-frontend-ip-configurations-client.go │ │ │ ├── load-balancer-probes-client.go │ │ │ ├── load-balancers-client.go │ │ │ ├── local-network-gateways-client.go │ │ │ ├── maintenance-configuration-client.go │ │ │ ├── managed-hsms-client.go │ │ │ ├── nat-gateways-client.go │ │ │ ├── network-interfaces-client.go │ │ │ ├── network-private-endpoint-client.go │ │ │ ├── network-security-groups-client.go │ │ │ ├── network-watchers-client.go │ │ │ ├── operational-insights-workspace-client.go │ │ │ ├── pager.go │ │ │ ├── pager_mocks.go │ │ │ ├── postgresql-databases-client.go │ │ │ ├── postgresql-flexible-server-firewall-rule-client.go │ │ │ ├── postgresql-flexible-servers-client.go │ │ │ ├── private-dns-zones-client.go │ │ │ ├── private-link-services-client.go │ │ │ ├── proximity-placement-groups-client.go │ │ │ ├── public-ip-addresses.go │ │ │ ├── public-ip-prefixes-client.go │ │ │ ├── queues-client.go │ │ │ ├── record-sets-client.go │ │ │ ├── role-assignments-client.go │ │ │ ├── role-definitions-client.go │ │ │ ├── route-tables-client.go │ │ │ ├── routes-client.go │ │ │ ├── secrets-client.go │ │ │ ├── security-rules-client.go │ │ │ ├── shared-gallery-images-client.go │ │ │ ├── snapshots-client.go │ │ │ ├── sql-database-schemas-client.go │ │ │ ├── sql-databases-client.go │ │ │ ├── sql-elastic-pool-client.go │ │ │ ├── sql-failover-groups-client.go │ │ │ ├── sql-server-firewall-rule-client.go │ │ │ ├── sql-server-keys-client.go │ │ │ ├── sql-server-private-endpoint-connection-client.go │ │ │ ├── sql-server-virtual-network-rule-client.go │ │ │ ├── sql-servers-client.go │ │ │ ├── storage-accounts-client.go │ │ │ ├── storage-private-endpoint-connection-client.go │ │ │ ├── subnets-client.go │ │ │ ├── tables-client.go │ │ │ ├── user-assigned-identities-client.go │ │ │ ├── vaults-client.go │ │ │ ├── virtual-machine-extensions-client.go │ │ │ ├── virtual-machine-run-commands-client.go │ │ │ ├── virtual-machine-scale-sets-client.go │ │ │ ├── virtual-machines-client.go │ │ │ ├── virtual-network-gateway-connections-client.go │ │ │ ├── virtual-network-gateways-client.go │ │ │ ├── virtual-network-links-client.go │ │ │ ├── virtual-network-peerings-client.go │ │ │ ├── virtual-networks-client.go │ │ │ └── zones-client.go │ │ ├── cmd/ │ │ │ ├── root.go │ │ │ └── root_test.go │ │ ├── docs/ │ │ │ ├── federated-credentials.md │ │ │ ├── testing-federated-auth.md │ │ │ └── usage.md │ │ ├── integration-tests/ │ │ │ ├── README.md │ │ │ ├── authorization-role-assignment_test.go │ │ │ ├── authorization-role-definition_test.go │ │ │ ├── batch-batch-accounts_test.go │ │ │ ├── batch-batch-application-package_test.go │ │ │ ├── batch-private-endpoint-connection_test.go │ │ │ ├── compute-availability-set_test.go │ │ │ ├── compute-capacity-reservation-group_test.go │ │ │ ├── compute-dedicated-host-group_test.go │ │ │ ├── compute-disk-access_test.go │ │ │ ├── compute-disk-encryption-set_test.go │ │ │ ├── compute-disk_test.go │ │ │ ├── compute-gallery-application-version_test.go │ │ │ ├── compute-image_test.go │ │ │ ├── compute-proximity-placement-group_test.go │ │ │ ├── compute-snapshot_test.go │ │ │ ├── compute-virtual-machine-extension_test.go │ │ │ ├── compute-virtual-machine-run-command_test.go │ │ │ ├── compute-virtual-machine-scale-set_test.go │ │ │ ├── compute-virtual-machine_test.go │ │ │ ├── dbforpostgresql-database_test.go │ │ │ ├── dbforpostgresql-flexible-server-administrator_test.go │ │ │ ├── dbforpostgresql-flexible-server-backup_test.go │ │ │ ├── dbforpostgresql-flexible-server-configuration_test.go │ │ │ ├── dbforpostgresql-flexible-server-replica_test.go │ │ │ ├── dbforpostgresql-flexible-server-virtual-endpoint_test.go │ │ │ ├── dbforpostgresql-flexible-server_test.go │ │ │ ├── documentdb-database-accounts_test.go │ │ │ ├── elastic-san-volume_test.go │ │ │ ├── helpers_test.go │ │ │ ├── keyvault-managed-hsm_test.go │ │ │ ├── keyvault-secret_test.go │ │ │ ├── keyvault-vault_test.go │ │ │ ├── keyvault_helpers_test.go │ │ │ ├── main_test.go │ │ │ ├── maintenance-maintenance-configuration_test.go │ │ │ ├── managedidentity-federated-identity-credential_test.go │ │ │ ├── managedidentity-user-assigned-identity_test.go │ │ │ ├── network-application-gateway_test.go │ │ │ ├── network-dns-virtual-network-link_test.go │ │ │ ├── network-flow-log_test.go │ │ │ ├── network-ip-group_test.go │ │ │ ├── network-load-balancer-backend-address-pool_test.go │ │ │ ├── network-load-balancer-frontend-ip-configuration_test.go │ │ │ ├── network-load-balancer-probe_test.go │ │ │ ├── network-load-balancer_test.go │ │ │ ├── network-local-network-gateway_test.go │ │ │ ├── network-network-interface-ip-configuration_test.go │ │ │ ├── network-network-interface_test.go │ │ │ ├── network-network-security-group_test.go │ │ │ ├── network-network-watcher_test.go │ │ │ ├── network-private-link-service_test.go │ │ │ ├── network-public-ip-address_test.go │ │ │ ├── network-route-table_test.go │ │ │ ├── network-virtual-network-gateway-connection_test.go │ │ │ ├── network-virtual-network_test.go │ │ │ ├── network-zone_test.go │ │ │ ├── operational-insights-workspace_test.go │ │ │ ├── sql-database-schema_test.go │ │ │ ├── sql-database_test.go │ │ │ ├── sql-server-failover-group_test.go │ │ │ ├── sql-server-key_test.go │ │ │ ├── sql-server_test.go │ │ │ ├── storage-account_test.go │ │ │ ├── storage-blob-container_test.go │ │ │ ├── storage-fileshare_test.go │ │ │ ├── storage-queues_test.go │ │ │ └── storage-table_test.go │ │ ├── main.go │ │ ├── manual/ │ │ │ ├── README.md │ │ │ ├── adapters.go │ │ │ ├── authorization-role-assignment.go │ │ │ ├── authorization-role-assignment_test.go │ │ │ ├── authorization-role-definition.go │ │ │ ├── authorization-role-definition_test.go │ │ │ ├── batch-batch-accounts.go │ │ │ ├── batch-batch-accounts_test.go │ │ │ ├── batch-batch-application-package.go │ │ │ ├── batch-batch-application-package_test.go │ │ │ ├── batch-batch-application.go │ │ │ ├── batch-batch-application_test.go │ │ │ ├── batch-batch-pool.go │ │ │ ├── batch-batch-pool_test.go │ │ │ ├── batch-private-endpoint-connection.go │ │ │ ├── batch-private-endpoint-connection_test.go │ │ │ ├── compute-availability-set.go │ │ │ ├── compute-availability-set_test.go │ │ │ ├── compute-capacity-reservation-group.go │ │ │ ├── compute-capacity-reservation-group_test.go │ │ │ ├── compute-capacity-reservation.go │ │ │ ├── compute-capacity-reservation_test.go │ │ │ ├── compute-dedicated-host-group.go │ │ │ ├── compute-dedicated-host-group_test.go │ │ │ ├── compute-dedicated-host.go │ │ │ ├── compute-dedicated-host_test.go │ │ │ ├── compute-disk-access-private-endpoint-connection.go │ │ │ ├── compute-disk-access-private-endpoint-connection_test.go │ │ │ ├── compute-disk-access.go │ │ │ ├── compute-disk-access_test.go │ │ │ ├── compute-disk-encryption-set.go │ │ │ ├── compute-disk-encryption-set_test.go │ │ │ ├── compute-disk.go │ │ │ ├── compute-disk_test.go │ │ │ ├── compute-gallery-application-version.go │ │ │ ├── compute-gallery-application-version_test.go │ │ │ ├── compute-gallery-application.go │ │ │ ├── compute-gallery-application_test.go │ │ │ ├── compute-gallery-image.go │ │ │ ├── compute-gallery-image_test.go │ │ │ ├── compute-gallery.go │ │ │ ├── compute-gallery_test.go │ │ │ ├── compute-image.go │ │ │ ├── compute-image_test.go │ │ │ ├── compute-proximity-placement-group.go │ │ │ ├── compute-proximity-placement-group_test.go │ │ │ ├── compute-shared-gallery-image.go │ │ │ ├── compute-shared-gallery-image_test.go │ │ │ ├── compute-snapshot.go │ │ │ ├── compute-snapshot_test.go │ │ │ ├── compute-virtual-machine-extension.go │ │ │ ├── compute-virtual-machine-extension_test.go │ │ │ ├── compute-virtual-machine-run-command.go │ │ │ ├── compute-virtual-machine-run-command_test.go │ │ │ ├── compute-virtual-machine-scale-set.go │ │ │ ├── compute-virtual-machine-scale-set_test.go │ │ │ ├── compute-virtual-machine.go │ │ │ ├── compute-virtual-machine_test.go │ │ │ ├── dbforpostgresql-database.go │ │ │ ├── dbforpostgresql-database_test.go │ │ │ ├── dbforpostgresql-flexible-server-administrator.go │ │ │ ├── dbforpostgresql-flexible-server-administrator_test.go │ │ │ ├── dbforpostgresql-flexible-server-backup.go │ │ │ ├── dbforpostgresql-flexible-server-backup_test.go │ │ │ ├── dbforpostgresql-flexible-server-configuration.go │ │ │ ├── dbforpostgresql-flexible-server-configuration_test.go │ │ │ ├── dbforpostgresql-flexible-server-firewall-rule.go │ │ │ ├── dbforpostgresql-flexible-server-firewall-rule_test.go │ │ │ ├── dbforpostgresql-flexible-server-private-endpoint-connection.go │ │ │ ├── dbforpostgresql-flexible-server-private-endpoint-connection_test.go │ │ │ ├── dbforpostgresql-flexible-server-replica.go │ │ │ ├── dbforpostgresql-flexible-server-replica_test.go │ │ │ ├── dbforpostgresql-flexible-server-virtual-endpoint.go │ │ │ ├── dbforpostgresql-flexible-server-virtual-endpoint_test.go │ │ │ ├── dbforpostgresql-flexible-server.go │ │ │ ├── dbforpostgresql-flexible-server_test.go │ │ │ ├── dns_links.go │ │ │ ├── documentdb-database-accounts.go │ │ │ ├── documentdb-database-accounts_test.go │ │ │ ├── documentdb-private-endpoint-connection.go │ │ │ ├── documentdb-private-endpoint-connection_test.go │ │ │ ├── elastic-san-volume-group.go │ │ │ ├── elastic-san-volume-group_test.go │ │ │ ├── elastic-san-volume-snapshot.go │ │ │ ├── elastic-san-volume-snapshot_test.go │ │ │ ├── elastic-san-volume.go │ │ │ ├── elastic-san-volume_test.go │ │ │ ├── elastic-san.go │ │ │ ├── elastic-san_test.go │ │ │ ├── keyvault-key.go │ │ │ ├── keyvault-key_test.go │ │ │ ├── keyvault-managed-hsm-private-endpoint-connection.go │ │ │ ├── keyvault-managed-hsm-private-endpoint-connection_test.go │ │ │ ├── keyvault-managed-hsm.go │ │ │ ├── keyvault-managed-hsm_test.go │ │ │ ├── keyvault-secret.go │ │ │ ├── keyvault-secret_test.go │ │ │ ├── keyvault-vault.go │ │ │ ├── keyvault-vault_test.go │ │ │ ├── links_helpers.go │ │ │ ├── maintenance-maintenance-configuration.go │ │ │ ├── maintenance-maintenance-configuration_test.go │ │ │ ├── managedidentity-federated-identity-credential.go │ │ │ ├── managedidentity-federated-identity-credential_test.go │ │ │ ├── managedidentity-user-assigned-identity.go │ │ │ ├── managedidentity-user-assigned-identity_test.go │ │ │ ├── network-application-gateway.go │ │ │ ├── network-application-gateway_test.go │ │ │ ├── network-application-security-group.go │ │ │ ├── network-application-security-group_test.go │ │ │ ├── network-ddos-protection-plan.go │ │ │ ├── network-ddos-protection-plan_test.go │ │ │ ├── network-default-security-rule.go │ │ │ ├── network-default-security-rule_test.go │ │ │ ├── network-dns-record-set.go │ │ │ ├── network-dns-record-set_test.go │ │ │ ├── network-dns-virtual-network-link.go │ │ │ ├── network-dns-virtual-network-link_test.go │ │ │ ├── network-flow-log.go │ │ │ ├── network-flow-log_test.go │ │ │ ├── network-ip-group.go │ │ │ ├── network-ip-group_test.go │ │ │ ├── network-load-balancer-backend-address-pool.go │ │ │ ├── network-load-balancer-backend-address-pool_test.go │ │ │ ├── network-load-balancer-frontend-ip-configuration.go │ │ │ ├── network-load-balancer-frontend-ip-configuration_test.go │ │ │ ├── network-load-balancer-probe.go │ │ │ ├── network-load-balancer-probe_test.go │ │ │ ├── network-load-balancer.go │ │ │ ├── network-load-balancer_test.go │ │ │ ├── network-local-network-gateway.go │ │ │ ├── network-local-network-gateway_test.go │ │ │ ├── network-nat-gateway.go │ │ │ ├── network-nat-gateway_test.go │ │ │ ├── network-network-interface-ip-configuration.go │ │ │ ├── network-network-interface-ip-configuration_test.go │ │ │ ├── network-network-interface.go │ │ │ ├── network-network-interface_test.go │ │ │ ├── network-network-security-group.go │ │ │ ├── network-network-security-group_test.go │ │ │ ├── network-network-watcher.go │ │ │ ├── network-network-watcher_test.go │ │ │ ├── network-private-dns-zone.go │ │ │ ├── network-private-dns-zone_test.go │ │ │ ├── network-private-endpoint.go │ │ │ ├── network-private-endpoint_test.go │ │ │ ├── network-private-link-service.go │ │ │ ├── network-private-link-service_test.go │ │ │ ├── network-public-ip-address.go │ │ │ ├── network-public-ip-address_test.go │ │ │ ├── network-public-ip-prefix.go │ │ │ ├── network-public-ip-prefix_test.go │ │ │ ├── network-route-table.go │ │ │ ├── network-route-table_test.go │ │ │ ├── network-route.go │ │ │ ├── network-route_test.go │ │ │ ├── network-security-rule.go │ │ │ ├── network-security-rule_test.go │ │ │ ├── network-subnet.go │ │ │ ├── network-subnet_test.go │ │ │ ├── network-virtual-network-gateway-connection.go │ │ │ ├── network-virtual-network-gateway-connection_test.go │ │ │ ├── network-virtual-network-gateway.go │ │ │ ├── network-virtual-network-gateway_test.go │ │ │ ├── network-virtual-network-peering.go │ │ │ ├── network-virtual-network-peering_test.go │ │ │ ├── network-virtual-network.go │ │ │ ├── network-virtual-network_test.go │ │ │ ├── network-zone.go │ │ │ ├── network-zone_test.go │ │ │ ├── operational-insights-workspace.go │ │ │ ├── operational-insights-workspace_test.go │ │ │ ├── sql-database-schema.go │ │ │ ├── sql-database-schema_test.go │ │ │ ├── sql-database.go │ │ │ ├── sql-database_test.go │ │ │ ├── sql-elastic-pool.go │ │ │ ├── sql-elastic-pool_test.go │ │ │ ├── sql-server-failover-group.go │ │ │ ├── sql-server-failover-group_test.go │ │ │ ├── sql-server-firewall-rule.go │ │ │ ├── sql-server-firewall-rule_test.go │ │ │ ├── sql-server-key.go │ │ │ ├── sql-server-key_test.go │ │ │ ├── sql-server-private-endpoint-connection.go │ │ │ ├── sql-server-private-endpoint-connection_test.go │ │ │ ├── sql-server-virtual-network-rule.go │ │ │ ├── sql-server-virtual-network-rule_test.go │ │ │ ├── sql-server.go │ │ │ ├── sql-server_test.go │ │ │ ├── storage-account.go │ │ │ ├── storage-account_test.go │ │ │ ├── storage-blob-container.go │ │ │ ├── storage-blob-container_test.go │ │ │ ├── storage-encryption-scope.go │ │ │ ├── storage-encryption-scope_test.go │ │ │ ├── storage-fileshare.go │ │ │ ├── storage-fileshare_test.go │ │ │ ├── storage-private-endpoint-connection.go │ │ │ ├── storage-private-endpoint-connection_test.go │ │ │ ├── storage-queues.go │ │ │ ├── storage-queues_test.go │ │ │ ├── storage-table.go │ │ │ └── storage-table_test.go │ │ ├── proc/ │ │ │ ├── proc.go │ │ │ └── proc_test.go │ │ ├── setup_helper_script.sh │ │ └── shared/ │ │ ├── adapter-meta.go │ │ ├── azure-http-client.go │ │ ├── base.go │ │ ├── credentials.go │ │ ├── errors.go │ │ ├── item-types.go │ │ ├── mocks/ │ │ │ ├── mock_application_gateways_client.go │ │ │ ├── mock_application_security_groups_client.go │ │ │ ├── mock_availability_sets_client.go │ │ │ ├── mock_batch_accounts_client.go │ │ │ ├── mock_batch_application_client.go │ │ │ ├── mock_batch_application_package_client.go │ │ │ ├── mock_batch_pool_client.go │ │ │ ├── mock_batch_private_endpoint_connection_client.go │ │ │ ├── mock_blob_containers_client.go │ │ │ ├── mock_capacity_reservation_groups_client.go │ │ │ ├── mock_capacity_reservations_client.go │ │ │ ├── mock_compute_disk_access_private_endpoint_connection_client.go │ │ │ ├── mock_dbforpostgresql_configurations_client.go │ │ │ ├── mock_dbforpostgresql_flexible_server_administrator_client.go │ │ │ ├── mock_dbforpostgresql_flexible_server_backup_client.go │ │ │ ├── mock_dbforpostgresql_flexible_server_private_endpoint_connection_client.go │ │ │ ├── mock_dbforpostgresql_flexible_server_replica_client.go │ │ │ ├── mock_dbforpostgresql_flexible_server_virtual_endpoint_client.go │ │ │ ├── mock_ddos_protection_plans_client.go │ │ │ ├── mock_dedicated_host_groups_client.go │ │ │ ├── mock_dedicated_hosts_client.go │ │ │ ├── mock_default_security_rules_client.go │ │ │ ├── mock_disk_accesses_client.go │ │ │ ├── mock_disk_encryption_sets_client.go │ │ │ ├── mock_disks_client.go │ │ │ ├── mock_documentdb_database_accounts_client.go │ │ │ ├── mock_documentdb_private_endpoint_connection_client.go │ │ │ ├── mock_elastic_san_client.go │ │ │ ├── mock_elastic_san_volume_client.go │ │ │ ├── mock_elastic_san_volume_group_client.go │ │ │ ├── mock_elastic_san_volume_snapshot_client.go │ │ │ ├── mock_encryption_scopes_client.go │ │ │ ├── mock_federated_identity_credentials_client.go │ │ │ ├── mock_file_shares_client.go │ │ │ ├── mock_flow_logs_client.go │ │ │ ├── mock_galleries_client.go │ │ │ ├── mock_gallery_application_versions_client.go │ │ │ ├── mock_gallery_applications_client.go │ │ │ ├── mock_gallery_images_client.go │ │ │ ├── mock_images_client.go │ │ │ ├── mock_interface_ip_configurations_client.go │ │ │ ├── mock_ip_groups_client.go │ │ │ ├── mock_keyvault_key_client.go │ │ │ ├── mock_keyvault_managed_hsm_private_endpoint_connection_client.go │ │ │ ├── mock_load_balancer_backend_address_pools_client.go │ │ │ ├── mock_load_balancer_frontend_ip_configurations_client.go │ │ │ ├── mock_load_balancer_probes_client.go │ │ │ ├── mock_load_balancers_client.go │ │ │ ├── mock_local_network_gateways_client.go │ │ │ ├── mock_maintenance_configuration_client.go │ │ │ ├── mock_managed_hsms_client.go │ │ │ ├── mock_nat_gateways_client.go │ │ │ ├── mock_network_interfaces_client.go │ │ │ ├── mock_network_private_endpoint_client.go │ │ │ ├── mock_network_security_groups_client.go │ │ │ ├── mock_network_watchers_client.go │ │ │ ├── mock_operational_insights_workspace_client.go │ │ │ ├── mock_postgresql_databases_client.go │ │ │ ├── mock_postgresql_flexible_server_firewall_rule_client.go │ │ │ ├── mock_postgresql_flexible_servers_client.go │ │ │ ├── mock_private_dns_zones_client.go │ │ │ ├── mock_private_link_services_client.go │ │ │ ├── mock_proximity_placement_groups_client.go │ │ │ ├── mock_public_ip_addresses_client.go │ │ │ ├── mock_public_ip_prefixes_client.go │ │ │ ├── mock_queues_client.go │ │ │ ├── mock_record_sets_client.go │ │ │ ├── mock_role_assignments_client.go │ │ │ ├── mock_role_definitions_client.go │ │ │ ├── mock_route_tables_client.go │ │ │ ├── mock_routes_client.go │ │ │ ├── mock_secrets_client.go │ │ │ ├── mock_security_rules_client.go │ │ │ ├── mock_shared_gallery_images_client.go │ │ │ ├── mock_snapshots_client.go │ │ │ ├── mock_sql_database_schemas_client.go │ │ │ ├── mock_sql_databases_client.go │ │ │ ├── mock_sql_elastic_pool_client.go │ │ │ ├── mock_sql_failover_groups_client.go │ │ │ ├── mock_sql_server_firewall_rule_client.go │ │ │ ├── mock_sql_server_keys_client.go │ │ │ ├── mock_sql_server_private_endpoint_connection_client.go │ │ │ ├── mock_sql_server_virtual_network_rule_client.go │ │ │ ├── mock_sql_servers_client.go │ │ │ ├── mock_storage_accounts_client.go │ │ │ ├── mock_storage_accounts_pager.go │ │ │ ├── mock_storage_private_endpoint_connection_client.go │ │ │ ├── mock_subnets_client.go │ │ │ ├── mock_tables_client.go │ │ │ ├── mock_user_assigned_identities_client.go │ │ │ ├── mock_vaults_client.go │ │ │ ├── mock_virtual_machine_extensions_client.go │ │ │ ├── mock_virtual_machine_run_commands_client.go │ │ │ ├── mock_virtual_machine_scale_sets_client.go │ │ │ ├── mock_virtual_machines_client.go │ │ │ ├── mock_virtual_machines_pager.go │ │ │ ├── mock_virtual_network_gateway_connections_client.go │ │ │ ├── mock_virtual_network_gateways_client.go │ │ │ ├── mock_virtual_network_links_client.go │ │ │ ├── mock_virtual_network_peerings_client.go │ │ │ ├── mock_virtual_networks_client.go │ │ │ ├── mock_zones_client.go │ │ │ └── pager_helpers.go │ │ ├── models.go │ │ ├── resource_id_item_type.go │ │ ├── resource_id_item_type_test.go │ │ ├── scope.go │ │ ├── utils.go │ │ └── utils_test.go │ ├── example/ │ │ ├── base.go │ │ ├── custom_searchable_listable.go │ │ ├── errors.go │ │ ├── metadata_test.go │ │ ├── mocks/ │ │ │ └── mock_external_api_client.go │ │ ├── shared/ │ │ │ └── models.go │ │ ├── standard_searchable_listable.go │ │ ├── standard_searchable_listable_test.go │ │ └── validation_test.go │ ├── gcp/ │ │ ├── README.md │ │ ├── build/ │ │ │ └── package/ │ │ │ └── Dockerfile │ │ ├── cmd/ │ │ │ ├── root.go │ │ │ └── root_test.go │ │ ├── dynamic/ │ │ │ ├── README.md │ │ │ ├── adapter-listable.go │ │ │ ├── adapter-searchable-listable.go │ │ │ ├── adapter-searchable.go │ │ │ ├── adapter.go │ │ │ ├── adapter_test.go │ │ │ ├── adapters/ │ │ │ │ ├── ai-platform-batch-prediction-job.go │ │ │ │ ├── ai-platform-batch-prediction-job_test.go │ │ │ │ ├── ai-platform-custom-job.go │ │ │ │ ├── ai-platform-custom-job_test.go │ │ │ │ ├── ai-platform-endpoint.go │ │ │ │ ├── ai-platform-endpoint_test.go │ │ │ │ ├── ai-platform-model-deployment-monitoring-job.go │ │ │ │ ├── ai-platform-model-deployment-monitoring-job_test.go │ │ │ │ ├── ai-platform-model.go │ │ │ │ ├── ai-platform-model_test.go │ │ │ │ ├── ai-platform-pipeline-job.go │ │ │ │ ├── ai-platform-pipeline-job_test.go │ │ │ │ ├── artifact-registry-docker-image.go │ │ │ │ ├── artifact-registry-docker-image_test.go │ │ │ │ ├── artifact-registry-repository.go │ │ │ │ ├── big-query-data-transfer-transfer-config.go │ │ │ │ ├── big-query-data-transfer-transfer-config_test.go │ │ │ │ ├── big-table-admin-app-profile.go │ │ │ │ ├── big-table-admin-app-profile_test.go │ │ │ │ ├── big-table-admin-backup.go │ │ │ │ ├── big-table-admin-backup_test.go │ │ │ │ ├── big-table-admin-cluster.go │ │ │ │ ├── big-table-admin-cluster_test.go │ │ │ │ ├── big-table-admin-instance.go │ │ │ │ ├── big-table-admin-instance_test.go │ │ │ │ ├── big-table-admin-table.go │ │ │ │ ├── big-table-admin-table_test.go │ │ │ │ ├── cloud-billing-billing-info.go │ │ │ │ ├── cloud-billing-billing-info_test.go │ │ │ │ ├── cloud-build-build.go │ │ │ │ ├── cloud-build-build_test.go │ │ │ │ ├── cloud-resource-manager-project.go │ │ │ │ ├── cloud-resource-manager-project_test.go │ │ │ │ ├── cloud-resource-manager-tag-key.go │ │ │ │ ├── cloud-resource-manager-tag-key_test.go │ │ │ │ ├── cloud-resource-manager-tag-value.go │ │ │ │ ├── cloud-resource-manager-tag-value_test.go │ │ │ │ ├── cloudfunctions-function.go │ │ │ │ ├── cloudfunctions-function_test.go │ │ │ │ ├── compute-accelerator-type.go │ │ │ │ ├── compute-disk-type.go │ │ │ │ ├── compute-external-vpn-gateway.go │ │ │ │ ├── compute-external-vpn-gateway_test.go │ │ │ │ ├── compute-firewall.go │ │ │ │ ├── compute-firewall_test.go │ │ │ │ ├── compute-global-address.go │ │ │ │ ├── compute-global-address_test.go │ │ │ │ ├── compute-global-forwarding-rule.go │ │ │ │ ├── compute-global-forwarding-rule_test.go │ │ │ │ ├── compute-http-health-check.go │ │ │ │ ├── compute-http-health-check_test.go │ │ │ │ ├── compute-instance-template.go │ │ │ │ ├── compute-instance-template_test.go │ │ │ │ ├── compute-license.go │ │ │ │ ├── compute-network-endpoint-group.go │ │ │ │ ├── compute-network-endpoint-group_test.go │ │ │ │ ├── compute-network.go │ │ │ │ ├── compute-network_test.go │ │ │ │ ├── compute-project.go │ │ │ │ ├── compute-project_test.go │ │ │ │ ├── compute-public-delegated-prefix.go │ │ │ │ ├── compute-public-delegated-prefix_test.go │ │ │ │ ├── compute-region-commitment.go │ │ │ │ ├── compute-region-commitment_test.go │ │ │ │ ├── compute-resource-policy.go │ │ │ │ ├── compute-route.go │ │ │ │ ├── compute-route_test.go │ │ │ │ ├── compute-router.go │ │ │ │ ├── compute-router_test.go │ │ │ │ ├── compute-ssl-certificate.go │ │ │ │ ├── compute-ssl-certificate_test.go │ │ │ │ ├── compute-ssl-policy.go │ │ │ │ ├── compute-ssl-policy_test.go │ │ │ │ ├── compute-storage-pool.go │ │ │ │ ├── compute-subnetwork.go │ │ │ │ ├── compute-subnetwork_test.go │ │ │ │ ├── compute-target-http-proxy.go │ │ │ │ ├── compute-target-http-proxy_test.go │ │ │ │ ├── compute-target-https-proxy.go │ │ │ │ ├── compute-target-https-proxy_test.go │ │ │ │ ├── compute-target-pool.go │ │ │ │ ├── compute-target-pool_test.go │ │ │ │ ├── compute-url-map.go │ │ │ │ ├── compute-url-map_test.go │ │ │ │ ├── compute-vpn-gateway.go │ │ │ │ ├── compute-vpn-gateway_test.go │ │ │ │ ├── compute-vpn-tunnel.go │ │ │ │ ├── compute-vpn-tunnel_test.go │ │ │ │ ├── container-cluster.go │ │ │ │ ├── container-cluster_test.go │ │ │ │ ├── container-node-pool.go │ │ │ │ ├── container-node-pool_test.go │ │ │ │ ├── dataflow-job.go │ │ │ │ ├── dataflow-job_test.go │ │ │ │ ├── dataform-repository.go │ │ │ │ ├── dataform-repository_test.go │ │ │ │ ├── dataplex-aspect-type.go │ │ │ │ ├── dataplex-aspect-type_test.go │ │ │ │ ├── dataplex-data-scan.go │ │ │ │ ├── dataplex-data-scan_test.go │ │ │ │ ├── dataplex-entry-group.go │ │ │ │ ├── dataplex-entry-group_test.go │ │ │ │ ├── dataproc-auto-scaling-policy.go │ │ │ │ ├── dataproc-auto-scaling-policy_test.go │ │ │ │ ├── dataproc-cluster.go │ │ │ │ ├── dataproc-cluster_test.go │ │ │ │ ├── dns-managed-zone.go │ │ │ │ ├── dns-managed-zone_test.go │ │ │ │ ├── essential-contacts-contact.go │ │ │ │ ├── essential-contacts-contact_test.go │ │ │ │ ├── eventarc-trigger.go │ │ │ │ ├── eventarc-trigger_test.go │ │ │ │ ├── file-instance.go │ │ │ │ ├── file-instance_test.go │ │ │ │ ├── iam-role.go │ │ │ │ ├── iam-role_test.go │ │ │ │ ├── logging-bucket.go │ │ │ │ ├── logging-bucket_test.go │ │ │ │ ├── logging-link.go │ │ │ │ ├── logging-link_test.go │ │ │ │ ├── logging-saved-query.go │ │ │ │ ├── logging-saved-query_test.go │ │ │ │ ├── models.go │ │ │ │ ├── monitoring-alert-policy.go │ │ │ │ ├── monitoring-alert-policy_test.go │ │ │ │ ├── monitoring-custom-dashboard.go │ │ │ │ ├── monitoring-custom-dashboard_test.go │ │ │ │ ├── monitoring-notification-channel.go │ │ │ │ ├── monitoring-notification-channel_test.go │ │ │ │ ├── orgpolicy-policy.go │ │ │ │ ├── orgpolicy-policy_test.go │ │ │ │ ├── pubsub-subscription.go │ │ │ │ ├── pubsub-subscription_test.go │ │ │ │ ├── pubsub-topic.go │ │ │ │ ├── pubsub-topic_test.go │ │ │ │ ├── redis-instance.go │ │ │ │ ├── redis-instance_test.go │ │ │ │ ├── run-revision.go │ │ │ │ ├── run-revision_test.go │ │ │ │ ├── run-service.go │ │ │ │ ├── run-service_test.go │ │ │ │ ├── run-worker-pool.go │ │ │ │ ├── secret-manager-secret.go │ │ │ │ ├── secret-manager-secret_test.go │ │ │ │ ├── security-center-management-security-center-service.go │ │ │ │ ├── security-center-management-security-center-service_test.go │ │ │ │ ├── service-directory-endpoint.go │ │ │ │ ├── service-directory-endpoint_test.go │ │ │ │ ├── service-directory-service.go │ │ │ │ ├── service-usage-service.go │ │ │ │ ├── service-usage-service_test.go │ │ │ │ ├── spanner-backup.go │ │ │ │ ├── spanner-database.go │ │ │ │ ├── spanner-database_test.go │ │ │ │ ├── spanner-instance-config.go │ │ │ │ ├── spanner-instance.go │ │ │ │ ├── spanner-instance_test.go │ │ │ │ ├── sql-admin-backup-run.go │ │ │ │ ├── sql-admin-backup-run_test.go │ │ │ │ ├── sql-admin-backup.go │ │ │ │ ├── sql-admin-backup_test.go │ │ │ │ ├── sql-admin-instance.go │ │ │ │ ├── sql-admin-instance_test.go │ │ │ │ ├── storage-bucket.go │ │ │ │ ├── storage-bucket_test.go │ │ │ │ ├── storage-transfer-transfer-job.go │ │ │ │ └── storage-transfer-transfer-job_test.go │ │ │ ├── adapters.go │ │ │ ├── adapters_test.go │ │ │ ├── ai-tools/ │ │ │ │ ├── README.md │ │ │ │ ├── build.sh │ │ │ │ ├── generate-adapter-ticket-cmd/ │ │ │ │ │ └── main.go │ │ │ │ └── generate-test-ticket-cmd/ │ │ │ │ └── main.go │ │ │ ├── errors.go │ │ │ ├── shared.go │ │ │ ├── shared_test.go │ │ │ └── testing.go │ │ ├── integration-tests/ │ │ │ ├── README.md │ │ │ ├── big-query-model_test.go │ │ │ ├── compute-address_test.go │ │ │ ├── compute-autoscaler_test.go │ │ │ ├── compute-disk_test.go │ │ │ ├── compute-forwarding-rule_test.go │ │ │ ├── compute-healthcheck_test.go │ │ │ ├── compute-image_test.go │ │ │ ├── compute-instance-group-manager_test.go │ │ │ ├── compute-instance-group_test.go │ │ │ ├── compute-instance_test.go │ │ │ ├── compute-instant-snapshot_test.go │ │ │ ├── compute-machine-image_test.go │ │ │ ├── compute-network_test.go │ │ │ ├── compute-node-group_test.go │ │ │ ├── compute-reservation_test.go │ │ │ ├── compute-snapshot_test.go │ │ │ ├── compute-subnetwork_test.go │ │ │ ├── computer-instance-template_test.go │ │ │ ├── kms_vs_asset_inventory_test.go │ │ │ ├── main_test.go │ │ │ ├── network-tags_test.go │ │ │ ├── service-account-impersonation_test.go │ │ │ ├── spanner-database_test.go │ │ │ └── spanner-instance_test.go │ │ ├── main.go │ │ ├── manual/ │ │ │ ├── README.md │ │ │ ├── adapters.go │ │ │ ├── big-query-dataset.go │ │ │ ├── big-query-dataset_test.go │ │ │ ├── big-query-model.go │ │ │ ├── big-query-model_test.go │ │ │ ├── big-query-routine.go │ │ │ ├── big-query-routine_test.go │ │ │ ├── big-query-table.go │ │ │ ├── big-query-table_test.go │ │ │ ├── certificate-manager-certificate.go │ │ │ ├── certificate-manager-certificate_test.go │ │ │ ├── cloud-kms-crypto-key-version.go │ │ │ ├── cloud-kms-crypto-key-version_test.go │ │ │ ├── cloud-kms-crypto-key.go │ │ │ ├── cloud-kms-crypto-key_test.go │ │ │ ├── cloud-kms-key-ring.go │ │ │ ├── cloud-kms-key-ring_test.go │ │ │ ├── compute-address.go │ │ │ ├── compute-address_test.go │ │ │ ├── compute-autoscaler.go │ │ │ ├── compute-autoscaler_test.go │ │ │ ├── compute-backend-service.go │ │ │ ├── compute-backend-service_test.go │ │ │ ├── compute-disk.go │ │ │ ├── compute-disk_test.go │ │ │ ├── compute-forwarding-rule.go │ │ │ ├── compute-forwarding-rule_test.go │ │ │ ├── compute-healthcheck.go │ │ │ ├── compute-healthcheck_test.go │ │ │ ├── compute-image.go │ │ │ ├── compute-image_test.go │ │ │ ├── compute-instance-group-manager-shared.go │ │ │ ├── compute-instance-group-manager.go │ │ │ ├── compute-instance-group-manager_test.go │ │ │ ├── compute-instance-group.go │ │ │ ├── compute-instance-group_test.go │ │ │ ├── compute-instance.go │ │ │ ├── compute-instance_test.go │ │ │ ├── compute-instant-snapshot.go │ │ │ ├── compute-instant-snapshot_test.go │ │ │ ├── compute-machine-image.go │ │ │ ├── compute-machine-image_test.go │ │ │ ├── compute-node-group.go │ │ │ ├── compute-node-group_test.go │ │ │ ├── compute-node-template.go │ │ │ ├── compute-node-template_test.go │ │ │ ├── compute-region-instance-group-manager.go │ │ │ ├── compute-region-instance-group-manager_test.go │ │ │ ├── compute-reservation.go │ │ │ ├── compute-reservation_test.go │ │ │ ├── compute-security-policy.go │ │ │ ├── compute-security-policy_test.go │ │ │ ├── compute-snapshot.go │ │ │ ├── compute-snapshot_test.go │ │ │ ├── iam-service-account-key.go │ │ │ ├── iam-service-account-key_test.go │ │ │ ├── iam-service-account.go │ │ │ ├── iam-service-account_test.go │ │ │ ├── logging-sink.go │ │ │ ├── logging-sink_test.go │ │ │ ├── storage-bucket-iam-policy.go │ │ │ └── storage-bucket-iam-policy_test.go │ │ ├── proc/ │ │ │ ├── proc.go │ │ │ └── proc_test.go │ │ ├── setup/ │ │ │ ├── README.md │ │ │ ├── scripts/ │ │ │ │ ├── overmind-gcp-roles.sh │ │ │ │ ├── overmind-gcp-source-permission-check.sh │ │ │ │ ├── overmind-gcp-source-setup-impersonation.sh │ │ │ │ └── overmind-gcp-source-setup.sh │ │ │ └── tutorial.md │ │ └── shared/ │ │ ├── adapter-meta.go │ │ ├── adapter-meta_test.go │ │ ├── base.go │ │ ├── big-query-clients.go │ │ ├── certificate-manager-clients.go │ │ ├── compute-clients.go │ │ ├── cross_project_linking_test.go │ │ ├── errors.go │ │ ├── gcp-http-client.go │ │ ├── iam-clients.go │ │ ├── init_test.go │ │ ├── item-types.go │ │ ├── kms-asset-loader.go │ │ ├── link-rules.go │ │ ├── linker.go │ │ ├── linker_test.go │ │ ├── location_info.go │ │ ├── location_info_test.go │ │ ├── logging-clients.go │ │ ├── manual-adapter-links.go │ │ ├── manual-adapter-links_test.go │ │ ├── mocks/ │ │ │ ├── mock_big_query_dataset_client.go │ │ │ ├── mock_certificate_manager_certificate_client.go │ │ │ ├── mock_compute_instance_client.go │ │ │ ├── mock_iam_clients.go │ │ │ └── mock_logging_config_client.go │ │ ├── models.go │ │ ├── network-security-clients.go │ │ ├── predefined-roles.go │ │ ├── storage-iam.go │ │ ├── terraform-mappings.go │ │ ├── terraform-mappings_test.go │ │ ├── utils.go │ │ └── utils_test.go │ ├── shared/ │ │ ├── base.go │ │ ├── shared.go │ │ ├── testing.go │ │ ├── util.go │ │ └── util_test.go │ ├── snapshot/ │ │ ├── README.md │ │ ├── adapters/ │ │ │ ├── adapter.go │ │ │ ├── adapter_test.go │ │ │ ├── catalog.go │ │ │ ├── index.go │ │ │ ├── index_test.go │ │ │ ├── loader.go │ │ │ ├── loader_test.go │ │ │ └── main.go │ │ ├── build/ │ │ │ └── package/ │ │ │ └── Dockerfile │ │ ├── cmd/ │ │ │ └── root.go │ │ └── main.go │ ├── stdlib/ │ │ ├── items.go │ │ └── shared/ │ │ └── models.go │ ├── transformer.go │ └── transformer_test.go ├── stdlib-source/ │ ├── adapters/ │ │ ├── certificate.go │ │ ├── certificate_test.go │ │ ├── dns.go │ │ ├── dns_test.go │ │ ├── http.go │ │ ├── http_test.go │ │ ├── ip.go │ │ ├── ip_cache.go │ │ ├── ip_cache_test.go │ │ ├── ip_test.go │ │ ├── main.go │ │ ├── main_test.go │ │ ├── rdap-asn.go │ │ ├── rdap-asn_test.go │ │ ├── rdap-domain.go │ │ ├── rdap-domain_test.go │ │ ├── rdap-entity.go │ │ ├── rdap-entity_test.go │ │ ├── rdap-ip-network.go │ │ ├── rdap-ip-network_test.go │ │ ├── rdap-nameserver.go │ │ ├── rdap-nameserver_test.go │ │ └── test/ │ │ ├── data.go │ │ ├── testdog.go │ │ ├── testfood.go │ │ ├── testgroup.go │ │ ├── testhobby.go │ │ ├── testlocation.go │ │ ├── testperson.go │ │ └── testregion.go │ ├── build/ │ │ └── package/ │ │ └── Dockerfile │ ├── cmd/ │ │ └── root.go │ └── main.go └── tfutils/ ├── aws_config.go ├── aws_config_test.go ├── azure_config.go ├── azure_config_test.go ├── gcp_config.go ├── gcp_config_test.go ├── plan.go ├── plan_mapper.go ├── plan_mapper_test.go ├── repo_to_scope.go ├── repo_to_scope_test.go └── testdata/ ├── binary-plan.tfplan ├── config_from_provider/ │ ├── ca-bundle.crt │ └── test.tf ├── invalid_vars.tfvars ├── plan.json ├── providers.tf ├── state.json ├── subfolder/ │ └── more_providers.tf ├── test_vars.tfvars └── tfvars.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/e2eapply.tape ================================================ Output e2e/apply.mp4 Set Width 1920 Set Height 1080 Set FontSize 12 Set CursorBlink false Hide Type "export PATH=$PWD:$PATH" Enter Type "clear" Enter Show Type@1ms "overmind terraform apply -- tfplan" Enter Sleep 70 Screenshot e2e/apply.png ================================================ FILE: .github/e2eplan.tape ================================================ Output e2e/plan.mp4 Set Width 1920 Set Height 1080 Set FontSize 12 Set CursorBlink false Hide Type "export PATH=$PWD:$PATH" Enter Type "clear" Enter Show Type@1ms "overmind terraform plan -- -out tfplan" Enter Sleep 2 Enter Sleep 80 Screenshot e2e/plan.png ================================================ FILE: .github/workflows/docker-release.yml ================================================ name: Build and Release Docker Container on: push: tags: - 'v*' jobs: build-and-push: name: Build and Push CLI Container runs-on: depot-ubuntu-22.04 permissions: contents: write packages: write id-token: write steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Extract version from tag id: extract_version run: | VERSION=${GITHUB_REF#refs/tags/v} echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: depot/use-action@v1.3.1 with: project: xnsnw3m20t - name: Build and push container uses: depot/build-push-action@v1.17.0 id: build with: project: xnsnw3m20t context: . file: ./Dockerfile sbom: true platforms: linux/amd64,linux/arm64 push: true tags: | ghcr.io/overmindtech/cli:latest ghcr.io/overmindtech/cli:${{ steps.extract_version.outputs.version }} ================================================ FILE: .github/workflows/finalize-copybara-sync.yml ================================================ name: Finalize Copybara Sync on: push: branches: - 'copybara/v*' # Cancel any in-progress runs when a new commit is pushed to the same branch # This ensures only the latest commit is processed when Copybara sends many commits quickly concurrency: group: copybara-sync-${{ github.ref }} cancel-in-progress: true jobs: finalize: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Extract version from branch name id: version run: | # Extract v1.2.3 from copybara/v1.2.3 VERSION=$(echo "$GITHUB_REF" | sed 's|refs/heads/copybara/||') echo "version=$VERSION" >> $GITHUB_OUTPUT - uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Configure Git run: | git config user.name "GitHub Actions Bot" git config user.email "actions@github.com" - name: Run go mod tidy run: go mod tidy - name: Commit and push go mod tidy changes env: HEAD_BRANCH: ${{ github.ref_name }} run: | if ! git diff --quiet go.mod go.sum; then git add go.mod go.sum git commit -m "Run go mod tidy" git push origin "$HEAD_BRANCH" else echo "No changes from go mod tidy" fi - name: Extract original commit author id: author run: | # Get the GitHub username from the most recent non-bot commit # Copybara preserves the original author via pass_thru AUTHOR_EMAIL=$(git log -1 --format='%ae' --author='^(?!.*actions@github.com)' --perl-regexp 2>/dev/null || git log -1 --format='%ae') AUTHOR_NAME=$(git log -1 --format='%an' --author='^(?!.*actions@github.com)' --perl-regexp 2>/dev/null || git log -1 --format='%an') echo "email=$AUTHOR_EMAIL" >> $GITHUB_OUTPUT echo "name=$AUTHOR_NAME" >> $GITHUB_OUTPUT # Try to find GitHub username from email (works for users with public email) # Format: username@users.noreply.github.com or regular email if [[ "$AUTHOR_EMAIL" =~ ^([^@]+)@users\.noreply\.github\.com$ ]]; then # Extract username from noreply email (handles 12345678+username format) GITHUB_USER=$(echo "${BASH_REMATCH[1]}" | sed 's/^[0-9]*+//') echo "github_user=$GITHUB_USER" >> $GITHUB_OUTPUT else echo "github_user=" >> $GITHUB_OUTPUT fi - name: Create Pull Request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} AUTHOR_NAME: ${{ steps.author.outputs.name }} AUTHOR_EMAIL: ${{ steps.author.outputs.email }} GITHUB_USER: ${{ steps.author.outputs.github_user }} HEAD_BRANCH: ${{ github.ref_name }} run: | # Build PR body PR_BODY="## Copybara Sync - Release ${VERSION} This PR was automatically created by Copybara, syncing changes from the [overmindtech/workspace](https://github.com/overmindtech/workspace) monorepo. **Original author:** ${AUTHOR_NAME} (${AUTHOR_EMAIL}) ### What happens when this PR is merged? 1. The \`tag-on-merge\` workflow will automatically create the \`${VERSION}\` tag on main 2. This tag will trigger the release workflow, which will: - Run tests - Build and publish release binaries via GoReleaser - Upload packages to Cloudsmith ### Review Checklist - [ ] Changes look correct and match the expected monorepo sync - [ ] Tests pass (see CI checks below) " # Create the PR PR_URL=$(gh pr create \ --base main \ --head "$HEAD_BRANCH" \ --title "Release ${VERSION}" \ --body "$PR_BODY") echo "Created PR: $PR_URL" # Try to assign reviewer - prefer original author, fall back to Engineering team if [ -n "$GITHUB_USER" ]; then echo "Requesting review from original author: $GITHUB_USER" gh pr edit "$PR_URL" --add-reviewer "$GITHUB_USER" || true fi # Always add Engineering team as reviewer echo "Requesting review from Engineering team" gh pr edit "$PR_URL" --add-reviewer "overmindtech/Engineering" || true ================================================ FILE: .github/workflows/release.yml ================================================ name: goreleaser-release on: push: tags: - 'v*' jobs: test: name: Run Tests runs-on: depot-ubuntu-22.04-4 steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.x check-latest: true cache: true - name: Go Test run: | go run main.go --version go test -race -v -timeout 5m github.com/overmindtech/cli github.com/overmindtech/cli/tfutils # Actually release the binaries including signing them release: runs-on: depot-ubuntu-22.04-32 if: ${{ github.event_name != 'pull_request' }} needs: test permissions: contents: write packages: write # id-token + attestations are required for Sigstore OIDC + the GitHub # Artifact Attestations API used by attest-build-provenance. id-token: write attestations: write steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.x check-latest: true cache: true # Syft is the SBOM generator GoReleaser shells out to (see the `sboms:` # block in .goreleaser.yaml). It is not preinstalled on GitHub-hosted # runners, so download it before goreleaser runs. - name: Install Syft uses: anchore/sbom-action/download-syft@v0.24.0 - name: Run GoReleaser (publish) uses: goreleaser/goreleaser-action@v7 with: # renovate: datasource=github-releases depName=goreleaser/goreleaser version: "v2.15.4" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used to create PRs on the Winget repo WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} # Generate SLSA Level 3 build provenance attestations for the release # archives and the checksums file. Each attestation is signed via Sigstore # using the workflow's GitHub OIDC identity, recorded in the public Rekor # transparency log, and surfaced on the repo's Attestations tab. Customers # verify with `gh attestation verify --repo overmindtech/cli` or # `cosign verify-blob-attestation`. See # docs.overmind.tech/docs/cli/verifying-releases.md. - name: Attest build provenance for release archives uses: actions/attest-build-provenance@v3 with: subject-path: | dist/overmind_cli_*.tar.gz dist/overmind_cli_*.zip dist/checksums.txt - name: Install cloudsmith CLI run: | pip install --upgrade cloudsmith-cli - name: Upload packages to cloudsmith run: | for i in dist/*.apk; do cloudsmith push alpine overmind/tools/alpine/any-version $i done for i in dist/*.deb; do cloudsmith push deb overmind/tools/any-distro/any-version $i done for i in dist/*.rpm; do cloudsmith push rpm overmind/tools/any-distro/any-version $i done env: CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} ================================================ FILE: .github/workflows/tag-on-merge.yml ================================================ name: Tag Release on Merge on: pull_request: types: - closed branches: - main jobs: tag-release: # Only run if the PR was merged (not just closed) and came from a copybara branch if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'copybara/v') runs-on: ubuntu-latest permissions: contents: write steps: - name: Extract version from branch name id: version env: BRANCH: ${{ github.event.pull_request.head.ref }} run: | # Extract v1.2.3 from copybara/v1.2.3 VERSION=$(echo "$BRANCH" | sed 's|copybara/||') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Extracted version: $VERSION" - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} - name: Configure Git run: | git config user.name "GitHub Actions Bot" git config user.email "actions@github.com" - name: Create and push tag env: VERSION: ${{ steps.version.outputs.version }} run: | echo "Creating tag: $VERSION" git tag "$VERSION" git push origin "$VERSION" echo "Successfully pushed tag $VERSION" - name: Delete copybara branch env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: ${{ github.event.pull_request.head.ref }} run: | echo "Deleting branch: $BRANCH" git push origin --delete "$BRANCH" || echo "Branch may have already been deleted" ================================================ FILE: .github/workflows/tests.yml ================================================ name: Run Tests on: push jobs: test: name: Run Tests runs-on: depot-ubuntu-24.04-4 concurrency: group: cli-tests-${{ github.ref }} cancel-in-progress: true # we only need test results from the latest commit steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.x check-latest: true cache: true - name: Go Test run: | go run main.go --version # Only run the tests that are relevant to the release. All other tests have already run internally. go test -race -v -timeout 5m github.com/overmindtech/cli github.com/overmindtech/cli/tfutils ================================================ FILE: .gitignore ================================================ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test gon # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work dist/ output .DS_Store .terraform overmind.plan terraform.tfstate terraform.tfstate.backup /tmp/ /node_modules __debug_bin* # ignore local terraform files tfplan.json* ================================================ FILE: .goreleaser.yaml ================================================ # Make sure to check the documentation at https://goreleaser.com version: 2 builds: - binary: overmind id: overmind env: - CGO_ENABLED=0 goos: - linux - windows ldflags: - -s -w -X github.com/overmindtech/cli/go/tracing.version={{.Version}} - binary: overmind id: overmind-macos env: - CGO_ENABLED=0 goos: - darwin ldflags: - -s -w -X github.com/overmindtech/cli/go/tracing.version={{.Version}} # For now we are going to disable signing MacOS packages. This works on Dylan's # person laptop, but we haven't worked out a way to get this set up in a github # action yet. # signs: # - id: amd64 # signature: "overmind-cli-amd64.dmg" # ids: # - overmind-macos # here we filter the macos only build id # cmd: ./gon # args: # - gon-amd64.json # artifacts: all # - id: arm64 # signature: "overmind-cli-arm64.dmg" # ids: # - overmind-macos # here we filter the macos only build id # cmd: ./gon # args: # - gon-arm64.json # artifacts: all archives: - formats: [tar.gz] # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .Binary }}_ {{- .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} # use zip for windows archives format_overrides: - goos: windows formats: [zip] files: - LICENSE - README.md nfpms: - id: nfpm package_name: overmind-cli file_name_template: "{{ .ConventionalFileName }}" # Build IDs for the builds you want to create NFPM packages for. # Defaults empty, which means no filtering. ids: - overmind vendor: Overmind homepage: https://overmind.tech/ maintainer: Overmind description: |- Predict what will happen for any given change license: Apache 2.0 formats: - apk - deb - rpm - archlinux bindir: /usr/bin section: default priority: extra winget: - name: OvermindCLI publisher: Overmind short_description: "Predict what will happen for any given change" license: "FSL-1.1-Apache-2.0" publisher_url: https://overmind.tech/ publisher_support_url: "https://github.com/overmindtech/cli/issues/new" package_identifier: Overmind.OvermindCLI homepage: "https://overmind.tech/" description: "Overmind calculates the impact of Terraform changes in your infrastructure, including the blast radius and likely risks." license_url: "https://github.com/overmindtech/cli?tab=License-1-ov-file#readme" copyright: "Copyright 2024 Overmind Technology Inc." # Setting this will prevent goreleaser to actually try to commit the updated # package - instead, it will be stored on the dist directory only, # leaving the responsibility of publishing it to the user. # # If set to auto, the release will not be uploaded to the repository # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 skip_upload: auto release_notes_url: "https://github.com/overmindtech/cli/releases/tag/{{ .Tag }}" # Repository to push the generated files to. repository: owner: overmindtech name: winget-pkgs branch: "{{.ProjectName}}-{{.Version}}" # Optionally a token can be provided, if it differs from the token # provided to GoReleaser token: "{{ .Env.WINGET_TOKEN }}" # Sets up pull request creation instead of just pushing to the given branch. # Make sure the 'branch' property is different from base before enabling # it. # # Since: v1.17 pull_request: enabled: true draft: false # Base can also be another repository, in which case the owner and name # above will be used as HEAD, allowing cross-repository pull requests. # # Since: v1.19 base: owner: microsoft name: winget-pkgs branch: master checksum: name_template: "checksums.txt" # Generate one SPDX-format SBOM per release archive using Syft (the GoReleaser # default). Each SBOM is uploaded as a sibling release asset. See # docs.overmind.tech/docs/cli/verifying-releases.md for the customer-facing # verification commands. sboms: - id: archive-sbom artifacts: archive documents: - "{{ .ArtifactName }}.spdx.json" snapshot: version_template: "{{ if .Version }}{{ $cleanVersion := replace .Version \"kargo/\" \"\" }}{{ incpatch $cleanVersion }}{{ else }}0.0.1{{ end }}-next" changelog: sort: asc filters: exclude: - "^docs:" - "^test:" # The lines beneath this are called `modelines`. See `:help modeline` # Feel free to remove those if you don't want/use them. # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj ================================================ FILE: .terraform.lock.hcl ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { version = "6.41.0" constraints = ">= 4.56.0, >= 6.39.0" hashes = [ "h1:0WhwadQsRyMh7+ULLH6RBEr6IyaQZ5ep6l/GGGwLfYg=", "h1:1FXO2hJl4cpcT7FAfI5ArdtU1fJL8PWEHPF7x/o6BkY=", "h1:3f4f8yZBP/wdxgresI5RZSZvN82SzjDfQI4YVmzC2Ts=", "h1:JIWQGFFHRiAARjCFZYckU+q4cGtsspjpQ+88qMEErLk=", "h1:P5G2OYd40P7QUS0dM2uw3WJ0y+VzOoIO7RvRRqA62D0=", "h1:Xf/kQ9A+CT8ZpCKfL8gDkId/JnA6Q+Y0KxLVALlGQuY=", "h1:XhGMmUFwwi5L5QQ+lAvRsSsXgVSV5RgJr49Bd2aZgEk=", "h1:ZlRFSpwbPosZj6ZER2OoxGQEbFI0PkC9GoAq4VpYvT0=", "h1:d1iTaanE9Q9qZE83hf6g3Kt+hFCExLXOYmzyo0KrQfY=", "h1:dc1ChE3N91+iuj8xjS031sLXnjEL92qtE3ZUVOk6zoI=", "h1:gk7KrN9LYvncV2VoWlfumFLEaWUwDx6jpaXovzi7c7k=", "h1:gmD0jxj+nOAIg0GGzmxKpFKd7Xnib3niuvKGPuV36g4=", "h1:iFwjmQtc0tIkSpB1KL+LcqLfuR6KYIGs49ea+LdVXXQ=", "h1:wuS3cwipVBJH0AP11NwtGKAQjb6OZVmTiBq3+/9ojTA=", "zh:01835476adda6d93095e37fdf782f14e6709f6922dc62e88994f9684627deb69", "zh:0b9bc5eda9def53df19e1a37562dcb67c1fba8452803e1b5601e75653c986255", "zh:196f81d97ea2951d2c6667709445d7c36b5fd8603890c774495806b4da0743aa", "zh:1ba36118b0146e5c3603020509b34d09e693db50ec29f9be5badc9f2a4fd95b8", "zh:4253c2f6066ce279e5ce48849c78fc193f222dc42a929e6876b9563ed5c23fcf", "zh:457ed8609680338dbcb9809e263638a530c06ba43208af9e660803133dc5e1e6", "zh:4e8de5f3fcc3e3f41b6703ad4c0fa62175d0c29afcf56ac82eb16c604bb6dafa", "zh:583ae622cfe4633fabca071ded738e9ef3398d9e9916cb26350aad28068462c5", "zh:5b8bf11070c13e21a0fef05713a25be0392c7d064bd2199c2f99939cc345e8c5", "zh:6aa7ae41d4fff4e95cedcbeaa143b2af9ad3e3a4b40208801b701d77a738b2c7", "zh:8143670bca48af3b873b0db83443dbdcb868442f1e789ffba356d6adf4dbd7b9", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", "zh:c22951cf0a4b169607ea066717dd499f46221af035903497b97f24590fb79d2c", "zh:dbd9cd975206a41a9c12006d3a30c77b95c0e660fcba2cc3bb0ee5779431c107", "zh:e6ea2e0f142001b7f2229e8d39eb7f8037084723861be9d53478005196cd7f9e", ] } provider "registry.terraform.io/hashicorp/google" { version = "7.28.0" constraints = ">= 4.0.0" hashes = [ "h1:M3DrxwI8FiHJpvq3yVX2QWZeqv5dyLt3nQ1YBm/TNXA=", "zh:078c16b9c5e9067e72070367846976b58f906d8efab6fc4fc1325661717dc9cc", "zh:08b839014b428233a3a83d15045e7559b07fc035c7f73cc1ee2694c50c4dea54", "zh:0c76ea69f75633bdfc67a0cd6ea510332c0cb0f2d4968b8a070e546fb47e444e", "zh:3a308492ad4c153583f7b8ecc3c80bf0bbc15a32c62b5b3794efb27db01ff26b", "zh:6754f51373994470f78937856982b0a39648ac302713d07205d320a13ad41d82", "zh:79d387214f55df16c795f11988a0285a4bfa846c447faa85008b953b77081eb1", "zh:8de432482d77d1a1077b2dc3db764b8ba6d1b07a4b991a07c960855adc0b031b", "zh:900daa2435de1928a9868aa4c17d8b7b109ab363c97f7fe274466193af1412b0", "zh:96c25183a7f13b3de9a5631aa2a13ed1a4285b8393df90c2380c2fe74f350ab5", "zh:971121626be01245acd9a4520a63e1405e4f528d3c83f39a28f8caaeac235b45", "zh:e90d5e7d7bf47c8cf5bbf2e5d0bf855ed10350ad3584795a6911f85fdb5c0c3c", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } provider "registry.terraform.io/hashicorp/random" { version = "3.8.1" constraints = ">= 3.0.0" hashes = [ "h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=", "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0", "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66", "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9", "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05", "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8", "zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b", "zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699", ] } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to the CLI Just open a PR and we'll review it. ================================================ FILE: Dockerfile ================================================ FROM ghcr.io/opentofu/opentofu:minimal AS tofu FROM alpine:3.23.4 # Copy the tofu binary from the minimal image COPY --from=tofu /usr/local/bin/tofu /usr/local/bin/tofu # Add the Overmind public key directly ADD https://dl.cloudsmith.io/public/overmind/tools/rsa.7B6E65C2058FDB78.key \ /etc/apk/keys/tools@overmind-7B6E65C2058FDB78.rsa.pub # Add repository config ADD https://dl.cloudsmith.io/public/overmind/tools/config.alpine.txt?distro=alpine&codename=v3.8 \ /tmp/config.alpine.txt RUN cat /tmp/config.alpine.txt >> /etc/apk/repositories \ && rm /tmp/config.alpine.txt RUN apk update RUN apk add --no-cache overmind-cli ================================================ FILE: LICENSE ================================================ # Functional Source License, Version 1.1, Apache 2.0 Future License ## Abbreviation FSL-1.1-Apache-2.0 ## Notice Copyright 2024 Overmind Technology Inc. ## Terms and Conditions ### Licensor ("We") The party offering the Software under these Terms and Conditions. ### The Software The "Software" is each version of the software that we make available under these Terms and Conditions, as indicated by our inclusion of these Terms and Conditions with the Software. ### License Grant Subject to your compliance with this License Grant and the Patents, Redistribution and Trademark clauses below, we hereby grant you the right to use, copy, modify, create derivative works, publicly perform, publicly display and redistribute the Software for any Permitted Purpose identified below. ### Permitted Purpose A Permitted Purpose is any purpose other than a Competing Use. A Competing Use means making the Software available to others in a commercial product or service that: 1. substitutes for the Software; 2. substitutes for any other product or service we offer using the Software that exists as of the date we make the Software available; or 3. offers the same or substantially similar functionality as the Software. Permitted Purposes specifically include using the Software: 1. for your internal use and access; 2. for non-commercial education; 3. for non-commercial research; and 4. in connection with professional services that you provide to a licensee using the Software in accordance with these Terms and Conditions. ### Patents To the extent your use for a Permitted Purpose would necessarily infringe our patents, the license grant above includes a license under our patents. If you make a claim against any party that the Software infringes or contributes to the infringement of any patent, then your patent license to the Software ends immediately. ### Redistribution The Terms and Conditions apply to all copies, modifications and derivatives of the Software. If you redistribute any copies, modifications or derivatives of the Software, you must include a copy of or a link to these Terms and Conditions and not remove any copyright notices provided in or with the Software. ### Disclaimer THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. ### Trademarks Except for displaying the License Details and identifying us as the origin of the Software, you have no right under these Terms and Conditions to use our trademarks, trade names, service marks or product names. ## Grant of Future License We hereby irrevocably grant you an additional license to use the Software under the Apache License, Version 2.0 that is effective on the second anniversary of the date we make the Software available. On or after that date, you may use the Software under the Apache License, Version 2.0, in which case the following will apply: Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

Overmind

Overmind CLI

Discord Server

🎥 Watch a demo | 📖 Docs | 🚀 Sign up | 🙌 Follow us

# What is Overmind? Overmind is a **tribal knowledge database** that empowers your team to manage infrastructure confidently, even without extensive experience. ### Signs your team needs Overmind - **Blocked Experts & Slow Onboarding** - Expert team members spend too much time on approvals, reducing overall productivity. - Newer staff face a steep learning curve, delaying their effectiveness. - **Limited Dependency Visibility** - Tools like Terraform show intended changes but don't reveal underlying dependencies. - Difficulty in assessing whether changes will disrupt existing applications. - **Complex Outage Troubleshooting** - Pinpointing issues during outages is challenging due to hidden dependencies. - Outages often result from intricate, unforeseen relationships rather than simple cause-and-effect. # Quick Start Install the Overmind CLI using brew: ```shell brew install overmindtech/overmind/overmind-cli ``` Launch the assistant and explore your newly configured AWS source: ```shell overmind explore ``` Run a terraform plan: ```shell overmind terraform plan ``` ![Running 'overmind terraform plan' and viewing in the app]()
Install on other platforms ## Prerequisites - Terraform environment set up - Access to all required credentials - Ability to install and run the Overmind CLI ## Installation ### MacOS To install on Mac with homebrew use: ```shell brew install overmindtech/overmind/overmind-cli ``` ### Windows Install using [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): ```shell winget install Overmind.OvermindCLI ``` Or manually download the [latest release](https://github.com/overmindtech/cli/releases/latest), extract `overmind.exe`, and add to your `PATH` ### Ubuntu / Debian Set up the repository automatically: ```shell curl -1sLf \ 'https://dl.cloudsmith.io/public/overmind/tools/setup.deb.sh' \ | sudo -E bash ``` Or set it up manually ```shell # NOTE: For Debian Stretch, Ubuntu 16.04 and later keyring_location=/usr/share/keyrings/overmind-tools-archive-keyring.gpg # NOTE: For Debian Jessie, Ubuntu 15.10 and earlier keyring_location=/etc/apt/trusted.gpg.d/overmind-tools.gpg # Capture the codename codename=$(lsb_release -cs) apt-get install -y debian-keyring # debian only apt-get install -y debian-archive-keyring # debian only apt-get install -y apt-transport-https curl -1sLf 'https://dl.cloudsmith.io/public/overmind/tools/gpg.BC5CDEFB4E37A1B3.key' | gpg --dearmor >> ${keyring_location} curl -1sLf 'https://dl.cloudsmith.io/public/overmind/tools/config.deb.txt?distro=ubuntu&$codename=xenial&component=main' > /etc/apt/sources.list.d/overmind-tools.list chmod 0644 /etc/apt/sources.list.d/overmind-tools.list chmod 0644 /usr/share/keyrings/overmind-tools-archive-keyring.gpg apt-get update ``` Then install the CLI: ```shell apt-get install overmind-cli ``` ### RHEL Set up the repository automatically: ```shell curl -1sLf \ 'https://dl.cloudsmith.io/public/overmind/tools/setup.rpm.sh' \ | sudo -E bash ``` Or set it up manually ```shell yum install yum-utils pygpgme rpm --import 'https://dl.cloudsmith.io/public/overmind/tools/gpg.BC5CDEFB4E37A1B3.key' curl -1sLf 'https://dl.cloudsmith.io/public/overmind/tools/config.rpm.txt?distro=amzn&codename=2023' > /tmp/overmind-tools.repo yum-config-manager --add-repo '/tmp/overmind-tools.repo' yum -q makecache -y --disablerepo='*' --enablerepo='overmind-tools' ``` Then install the CLI: ```shell sudo yum install overmind-cli ``` ### Alpine Set up the repository automatically: ```shell sudo apk add --no-cache bash curl -1sLf \ 'https://dl.cloudsmith.io/public/overmind/tools/setup.alpine.sh' \ | sudo -E bash ``` Or set it up manually ```shell curl -1sLf 'https://dl.cloudsmith.io/public/overmind/tools/rsa.7B6E65C2058FDB78.key' > /etc/apk/keys/tools@overmind-7B6E65C2058FDB78.rsa.pub curl -1sLf 'https://dl.cloudsmith.io/public/overmind/tools/config.alpine.txt?distro=alpine&codename=v3.8' >> /etc/apk/repositories apk update ``` Then install the CLI: ```shell apk add overmind-cli ``` ### Container / Docker You can use the CLI via Docker which includes both OpenTofu and the CLI: ```shell docker pull ghcr.io/overmindtech/cli:latest docker run --rm ghcr.io/overmindtech/cli:latest overmind terraform plan ``` This is useful for CI/CD environments where you need a reproducible Terraform execution environment. ### Arch Packages for Arch are available on the [releases page](https://github.com/overmindtech/cli/releases/latest) for manual download and installation. Additionally a community maintained package can be found in the [aur](https://aur.archlinux.org/packages/overmind-cli-bin). ### ASDF Overmind can be installed using [asdf](https://asdf-vm.com/): ```shell # Add the plugin asdf plugin add overmind-cli https://github.com/overmindtech/asdf-overmind-cli.git # Show all installable versions asdf list-all overmind-cli # Install specific version asdf install overmind-cli latest # Set a version globally (on your ~/.tool-versions file) asdf global overmind-cli latest # Now overmind-cli commands are available overmind --version ```
# Discover CLI Commands - `overmind explore` Overmind Assistant is a chat assistant that has real-time access to all your AWS, GCP and K8S infrastructure. It alleviates the mental exhaustion of manual troubleshooting, simplifies incident resolution by easily accessing historical data, and automates time-consuming tasks such as documentation and Terraform code generation. You can access the assistant by running `overmind explore`. - `overmind terraform plan / apply` Overmind can identify the blast radius and uncover potential risks with `overmind terraform plan` before they harm your infrastructure, allowing anyone to make changes with confidence. It can also track the impact of the changes you make with `overmind terraform apply`, so that you can be sure that your changes haven't had any unexpected downstream impact. - `overmind knowledge list` View which knowledge files Overmind would discover from your current location. Knowledge files in `.overmind/knowledge/` teach the AI investigator about your infrastructure context, standards, and approved patterns. This command shows the resolved knowledge directory path, valid files with their metadata, and any validation warnings for invalid files. You can specify multiple knowledge directories to layer organizational and stack-specific knowledge: ```bash overmind knowledge list \ --knowledge-dir .overmind/knowledge \ --knowledge-dir ./stacks/prod/.overmind/knowledge ``` When the same knowledge file name appears in multiple directories, later directories override earlier ones. For more details, see the [Knowledge Files documentation](https://docs.overmind.tech/docs/knowledge/knowledge). ## Cloud Provider Support The CLI automatically discovers AWS and GCP providers from your Terraform configuration. ## How We Solve It?

🔍 Blast Radius: Overmind maps out all potential dependencies and interactions within your infra in realtime. Supports over 120 AWS resources and all Kubernetes.

🚨 Risks: Discover specific risks that would be invisible otherwise. Risks are delivered directly to the pull request. Make deployment decisions within minutes not hours.
## Advanced Use ### Passing Arguments Overmind's `overmind terraform plan` and `overmind terraform apply` commands mostly just wrap the `terraform` that you already have installed, adding all of Overmind's features on top. This means that no matter how you're using Terraform today, this will still work with Overmind. For example if you're using a more complex command like: ```shell terraform plan -var-file=production.tfvars -parallelism=20 -auto-approve ``` Then you would add `overmind` to the beginning, and your arguments after a double-dash e.g. ```shell overmind terraform plan -- -var-file=production.tfvars -parallelism=20 -auto-approve ``` ## Join the Community - Join our [Discord](https://discord.com/invite/5UKsqAkPWG) - Contact us via email at [sales@overmind.tech](mailto:sales@overmind.tech) - Follow us on [LinkedIn](https://www.linkedin.com/company/overmindtech/) ## Additional Resources - [Documentation](https://docs.overmind.tech) - [Getting Started Guide](https://docs.overmind.tech) - [Overmind Blog](https://overmind.tech/blog) ## Reporting Bugs - Want to report a bug or request a feature? [Open an issue](https://github.com/overmindtech/cli/issues/new) or ask on Discord. ## Development Please look in the [CONTRIBUTING.md](https://github.com/overmindtech/cli/blob/main/CONTRIBUTING.md) document. ## License See the [LICENSE](/LICENSE) file for licensing information. Overmind is made with ❤️ in 🇺🇸🇬🇧🇦🇹🇫🇷🇷🇴 ================================================ FILE: aws-source/.deadcode-ignore ================================================ adapterhelpers/shared_tests.go:19:6: unreachable func: PtrInt32 adapterhelpers/shared_tests.go:27:6: unreachable func: PtrFloat32 adapterhelpers/shared_tests.go:31:6: unreachable func: PtrFloat64 adapterhelpers/shared_tests.go:35:6: unreachable func: PtrTime adapterhelpers/shared_tests.go:39:6: unreachable func: PtrBool adapterhelpers/shared_tests.go:73:21: unreachable func: VPCConfig.Cleanup adapterhelpers/shared_tests.go:77:21: unreachable func: VPCConfig.RunCleanup adapterhelpers/shared_tests.go:88:21: unreachable func: VPCConfig.Fetch adapterhelpers/shared_tests.go:121:21: unreachable func: VPCConfig.CreateGateway adapterhelpers/shared_tests.go:198:6: unreachable func: retry adapterhelpers/shared_tests.go:221:21: unreachable func: QueryTests.Execute adapterhelpers/shared_tests.go:238:6: unreachable func: lirMatches adapterhelpers/shared_tests.go:246:6: unreachable func: CheckQuery adapterhelpers/util.go:180:18: unreachable func: E2ETest.Run adapterhelpers/util.go:326:6: unreachable func: GetAutoConfig adapters/rds.go:25:24: unreachable func: mockRdsClient.DescribeDBClusterParameterGroups adapters/rds.go:29:24: unreachable func: mockRdsClient.DescribeDBClusterParameters adapters/rds.go:33:24: unreachable func: mockRdsClient.ListTagsForResource adapters/rds.go:44:24: unreachable func: mockRdsClient.DescribeDBClusters adapters/rds.go:48:24: unreachable func: mockRdsClient.DescribeDBInstances adapters/rds.go:52:24: unreachable func: mockRdsClient.DescribeDBSubnetGroups adapters/rds.go:56:24: unreachable func: mockRdsClient.DescribeOptionGroups adapters/rds.go:60:24: unreachable func: mockRdsClient.DescribeDBParameterGroups adapters/rds.go:64:24: unreachable func: mockRdsClient.DescribeDBParameters ================================================ FILE: aws-source/acceptance/nats-server.conf ================================================ # Client port of 4222 on all interfaces port: 4222 # HTTP monitoring port monitor_port: 8222 # This is for clustering multiple servers together. cluster { # It is recommended to set a cluster name name: "my_cluster" # Route connections to be received on any interface on port 6222 port: 6222 # Routes are protected, so need to use them with --routes flag # e.g. --routes=nats-route://ruser:T0pS3cr3t@otherdockerhost:6222 authorization { user: ruser timeout: 0.75 } # Routes are actively solicited and connected to from this server. # This Docker image has none by default, but you can pass a # flag to the nats-server docker image to create one to an existing server. routes = [] } websocket { port: 4433 no_tls: true } ================================================ FILE: aws-source/adapters/adapterhelpers_always_get_source.go ================================================ package adapters import ( "context" "errors" "fmt" "sync/atomic" "time" "buf.build/go/protovalidate" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/sourcegraph/conc/pool" ) // MaxParallel An integer that defaults to 10 type MaxParallel int // Value Get the value of MaxParallel, defaulting to 10 func (m MaxParallel) Value() int { if m == 0 { return 10 } return int(m) } // AlwaysGetAdapter This adapter is designed for AWS APIs that have separate List // and Get functions. It also assumes that the results of the list function // cannot be converted directly into items as they do not contain enough // information, and therefore they always need to be passed to the Get function // before returning. An example is the `ListClusters` API in EKS which returns a // list of cluster names. type AlwaysGetAdapter[ListInput InputType, ListOutput OutputType, GetInput InputType, GetOutput OutputType, ClientStruct ClientStructType, Options OptionsType] struct { ItemType string // The type of items to return Client ClientStruct // The AWS API client AccountID string // The AWS account ID Region string // The AWS region this is related to MaxParallel MaxParallel // How many Get request to run in parallel for a single List request AdapterMetadata *sdp.AdapterMetadata // Disables List(), meaning all calls will return empty results. This does // not affect Search() DisableList bool // A function that gets the details of a given item. This should include the // tags if relevant GetFunc func(ctx context.Context, client ClientStruct, scope string, input GetInput) (*sdp.Item, error) // The input to the ListFunc. This is static ListInput ListInput // A function that maps from the SDP get inputs to the relevant input for // the GetFunc GetInputMapper func(scope, query string) GetInput // If this is set, Search queries will always use the automatic ARN resolver // if the input is an ARN, falling back to the `SearchInputMapper` if it // isn't AlwaysSearchARNs bool // Maps search terms from an SDP Search request into the relevant input for // the ListFunc. If this is not set, Search() will handle ARNs like most AWS // adapters. Note that this and `SearchGetInputMapper` are mutually exclusive SearchInputMapper func(scope, query string) (ListInput, error) // Maps search terms from an SDP Search request into the relevant input for // the GetFunc. If this is not set, Search() will handle ARNs like most AWS // adapters. Note that this and `SearchInputMapper` are mutually exclusive SearchGetInputMapper func(scope, query string) (GetInput, error) // A function that returns a paginator for the ListFunc ListFuncPaginatorBuilder func(client ClientStruct, input ListInput) Paginator[ListOutput, Options] // A function that accepts the output of a ListFunc and maps this to a slice // of inputs to pass to the GetFunc. The input used for the ListFunc is also // included in case it is required ListFuncOutputMapper func(output ListOutput, input ListInput) ([]GetInput, error) CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // This is mandatory } func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) cacheDuration() time.Duration { if s.CacheDuration == 0 { return DefaultCacheDuration } return s.CacheDuration } // Validate Checks that the adapter has been set up correctly func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) Validate() error { if !s.DisableList { if s.ListFuncPaginatorBuilder == nil { return errors.New("ListFuncPaginatorBuilder is nil") } if s.ListFuncOutputMapper == nil { return errors.New("ListFuncOutputMapper is nil") } } if s.GetFunc == nil { return errors.New("GetFunc is nil") } if s.GetInputMapper == nil { return errors.New("GetInputMapper is nil") } if s.SearchGetInputMapper != nil && s.SearchInputMapper != nil { return errors.New("SearchGetInputMapper and SearchInputMapper are mutually exclusive") } return protovalidate.Validate(s.AdapterMetadata) } func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) Type() string { return s.ItemType } func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) Name() string { return fmt.Sprintf("%v-adapter", s.ItemType) } func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) Metadata() *sdp.AdapterMetadata { return s.AdapterMetadata } // List of scopes that this adapter is capable of find items for. This will be // in the format {accountID}.{region} func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) Scopes() []string { return []string{ FormatScope(s.AccountID, s.Region), } } func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != s.Scopes()[0] { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), } } var err error var item *sdp.Item if err = s.Validate(); err != nil { return nil, WrapAWSError(err) } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.ItemType, query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) > 0 { return cachedItems[0], nil } else { return nil, nil } } input := s.GetInputMapper(scope, query) item, err = s.GetFunc(ctx, s.Client, scope, input) if err != nil { err := WrapAWSError(err) if !CanRetry(err) { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) } return nil, err } s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) return item, nil } // List Lists all available items. This is done by running the ListFunc, then // passing these results to GetFunc in order to get the details func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) ListStream(ctx context.Context, scope string, ignoreCache bool, stream discovery.QueryResultStream) { if scope != s.Scopes()[0] { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), }) return } if err := s.Validate(); err != nil { stream.SendError(WrapAWSError(err)) return } // Check to see if we have supplied the required functions if s.DisableList { // In this case we can't run list, so just return empty return } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.ItemType, "", ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } s.listInternal(ctx, scope, s.ListInput, ck, stream) } func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) listInternal(ctx context.Context, scope string, input ListInput, ck sdpcache.CacheKey, stream discovery.QueryResultStream) { paginator := s.ListFuncPaginatorBuilder(s.Client, input) var newGetInputs []GetInput p := pool.New().WithContext(ctx).WithMaxGoroutines(s.MaxParallel.Value()) // Track whether any items were found and if we had an error var itemsSent atomic.Int64 var hadError atomic.Bool defer func() { // Always wait for everything to be completed before returning err := p.Wait() if err != nil { sentry.CaptureException(err) } // Only cache not-found when no items were found AND no error occurred // If we had an error, that error is already cached, don't overwrite it shouldCacheNotFound := itemsSent.Load() == 0 && !hadError.Load() if shouldCacheNotFound { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found in scope %s", s.ItemType, scope), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, s.cacheDuration(), ck) } }() for paginator.HasMorePages() { output, err := paginator.NextPage(ctx) if err != nil { hadError.Store(true) err := WrapAWSError(err) if !CanRetry(err) { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) } stream.SendError(err) return } newGetInputs, err = s.ListFuncOutputMapper(output, input) if err != nil { hadError.Store(true) err := WrapAWSError(err) if !CanRetry(err) { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) } stream.SendError(err) return } for _, input := range newGetInputs { // This call will block if no workers are available, and therefore // we will only load new pages once there are workers ready to // accept that work p.Go(func(ctx context.Context) error { item, err := s.GetFunc(ctx, s.Client, scope, input) if err != nil { // Don't cache individual errors as they are cheap to re-run stream.SendError(WrapAWSError(err)) // Mark that we had an error so we don't cache NOTFOUND hadError.Store(true) } if item != nil { s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) stream.SendItem(item) itemsSent.Add(1) } return nil }) } } } // Search Searches for AWS resources by ARN func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) SearchStream(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { if scope != s.Scopes()[0] { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), }) return } if err := s.Validate(); err != nil { stream.SendError(WrapAWSError(err)) return } if s.SearchInputMapper == nil && s.SearchGetInputMapper == nil { s.SearchARN(ctx, scope, query, ignoreCache, stream) } else { // If we should always look for ARNs first, do that if s.AlwaysSearchARNs { if _, err := ParseARN(query); err == nil { s.SearchARN(ctx, scope, query, ignoreCache, stream) } else { s.SearchCustom(ctx, scope, query, ignoreCache, stream) } } else { s.SearchCustom(ctx, scope, query, ignoreCache, stream) } } } // SearchCustom Searches using custom mapping logic. The SearchInputMapper is // used to create an input for ListFunc, at which point the usual logic is used func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) SearchCustom(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.ItemType, query, ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } if s.SearchInputMapper != nil { input, err := s.SearchInputMapper(scope, query) if err != nil { // Don't bother caching this error since it costs nearly nothing stream.SendError(WrapAWSError(err)) return } s.listInternal(ctx, scope, input, ck, stream) } else if s.SearchGetInputMapper != nil { input, err := s.SearchGetInputMapper(scope, query) if err != nil { // Don't cache this as it costs nearly nothing stream.SendError(WrapAWSError(err)) return } item, err := s.GetFunc(ctx, s.Client, scope, input) if err != nil { err := WrapAWSError(err) if !CanRetry(err) { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) } stream.SendError(err) return } if item != nil { s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) stream.SendItem(item) } else { // Cache not-found when item is nil notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%s not found for search query '%s'", s.ItemType, query), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, s.cacheDuration(), ck) } } else { stream.SendError(errors.New("SearchCustom called without SearchInputMapper or SearchGetInputMapper")) return } } func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) SearchARN(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { // Parse the ARN a, err := ParseARN(query) if err != nil { stream.SendError(WrapAWSError(err)) return } if a.ContainsWildcard() { // We can't handle wildcards by default so bail out stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("wildcards are not supported by adapter %v", s.Name()), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), }) return } if arnScope := FormatScope(a.AccountID, a.Region); arnScope != scope { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("ARN scope %v does not match request scope %v", arnScope, scope), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), }) return } item, err := s.Get(ctx, scope, a.ResourceID(), ignoreCache) if err != nil { stream.SendError(WrapAWSError(err)) return } if item != nil { stream.SendItem(item) } } // Weight Returns the priority weighting of items returned by this sourcs. // This is used to resolve conflicts where two sources of the same type // return an item for a GET request. In this instance only one item can be // seen on, so the one with the higher weight value will win. func (s *AlwaysGetAdapter[ListInput, ListOutput, GetInput, GetOutput, ClientStruct, Options]) Weight() int { return 100 } ================================================ FILE: aws-source/adapters/adapterhelpers_always_get_source_test.go ================================================ package adapters import ( "context" "errors" "fmt" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "google.golang.org/protobuf/types/known/structpb" ) func TestMaxParallel(t *testing.T) { var p MaxParallel if p.Value() != 10 { t.Errorf("expected max parallel to be 10, got %v", p) } } func TestAlwaysGetSourceType(t *testing.T) { lgs := AlwaysGetAdapter[any, any, any, any, any, any]{ ItemType: "foo", } if lgs.Type() != "foo" { t.Errorf("expected type to be foo, got %v", lgs.Type()) } } func TestAlwaysGetSourceName(t *testing.T) { lgs := AlwaysGetAdapter[any, any, any, any, any, any]{ ItemType: "foo", } if lgs.Name() != "foo-adapter" { t.Errorf("expected name to be foo-adapter, got %v", lgs.Name()) } } func TestAlwaysGetSourceScopes(t *testing.T) { lgs := AlwaysGetAdapter[any, any, any, any, any, any]{ AccountID: "foo", Region: "bar", } if lgs.Scopes()[0] != "foo.bar" { t.Errorf("expected scope to be foo.bar, got %v", lgs.Scopes()[0]) } } func TestAlwaysGetSourceGet(t *testing.T) { t.Run("with no errors", func(t *testing.T) { lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, ListInput: "", ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { return &sdp.Item{}, nil }, GetInputMapper: func(scope, query string) string { return "" }, cache: sdpcache.NewNoOpCache(), } _, err := lgs.Get(context.Background(), "foo.bar", "", false) if err != nil { t.Error(err) } }) t.Run("with an error", func(t *testing.T) { lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, ListInput: "", ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { return &sdp.Item{}, errors.New("foo") }, GetInputMapper: func(scope, query string) string { return "" }, cache: sdpcache.NewNoOpCache(), } _, err := lgs.Get(context.Background(), "foo.bar", "", false) if err == nil { t.Error("expected error") } }) } func TestAlwaysGetSourceList(t *testing.T) { t.Run("with no errors", func(t *testing.T) { lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, MaxParallel: MaxParallel(1), ListInput: "", ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { return &sdp.Item{}, nil }, GetInputMapper: func(scope, query string) string { return "" }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() lgs.ListStream(context.Background(), "foo.bar", false, stream) if len(stream.GetErrors()) != 0 { t.Errorf("expected no errors, got %v: %v", len(stream.GetErrors()), stream.GetErrors()) } if len(stream.GetItems()) != 6 { t.Errorf("expected 6 results, got %v: %v", len(stream.GetItems()), stream.GetItems()) } }) t.Run("with a failing output mapper", func(t *testing.T) { lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, MaxParallel: MaxParallel(1), ListInput: "", ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return nil, errors.New("output mapper error") }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { return &sdp.Item{}, nil }, GetInputMapper: func(scope, query string) string { return "" }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() lgs.ListStream(context.Background(), "foo.bar", false, stream) errs := stream.GetErrors() if len(errs) != 1 { t.Fatalf("expected 1 error, got %v: %v", len(errs), errs) } qErr := &sdp.QueryError{} if !errors.As(errs[0], &qErr) { t.Errorf("expected error to be a QueryError, got %v", errs[0]) } else { if qErr.GetErrorString() != "output mapper error" { t.Errorf("expected 'output mapper error', got '%v'", qErr.GetErrorString()) } } }) t.Run("with a failing GetFunc", func(t *testing.T) { lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, MaxParallel: MaxParallel(1), ListInput: "", ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { return nil, errors.New("get func error") }, GetInputMapper: func(scope, query string) string { return "" }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() lgs.ListStream(context.Background(), "foo.bar", false, stream) errs := stream.GetErrors() if len(errs) != 6 { t.Fatalf("expected 6 error, got %v", len(errs)) } items := stream.GetItems() if len(items) != 0 { t.Errorf("expected no items, got %v", len(items)) } }) } func TestAlwaysGetSourceSearch(t *testing.T) { t.Run("with ARN search", func(t *testing.T) { lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, MaxParallel: MaxParallel(1), ListInput: "", ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { if input == "foo.bar.id" { return &sdp.Item{}, nil } else { return nil, sdp.NewQueryError(errors.New("bad query details")) } }, GetInputMapper: func(scope, query string) string { return scope + "." + query }, cache: sdpcache.NewNoOpCache(), } t.Run("bad ARN", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() lgs.SearchStream(context.Background(), "foo.bar", "query", false, stream) if len(stream.GetErrors()) == 0 { t.Error("expected error because the ARN was bad") } }) t.Run("good ARN but bad scope", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() lgs.SearchStream(context.Background(), "foo.bar", "arn:aws:service:region:account:type/id", false, stream) if len(stream.GetErrors()) == 0 { t.Error("expected error because the ARN had a bad scope") } }) t.Run("good ARN", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() lgs.SearchStream(context.Background(), "foo.bar", "arn:aws:service:bar:foo:type/id", false, stream) if len(stream.GetErrors()) != 0 { t.Errorf("expected no errors, got %v: %v", len(stream.GetErrors()), stream.GetErrors()) } }) }) t.Run("with Custom & ARN search", func(t *testing.T) { lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, MaxParallel: MaxParallel(1), ListInput: "", AlwaysSearchARNs: true, SearchInputMapper: func(scope, query string) (string, error) { return query, nil }, ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { if input == "foo.bar.id" { return &sdp.Item{}, nil } else { return nil, sdp.NewQueryError(errors.New("bad query details")) } }, GetInputMapper: func(scope, query string) string { return scope + "." + query }, cache: sdpcache.NewNoOpCache(), } t.Run("ARN", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() lgs.SearchStream(context.Background(), "foo.bar", "arn:aws:service:bar:foo:type/id", false, stream) errs := stream.GetErrors() if len(errs) != 0 { t.Error(errs[0]) } items := stream.GetItems() if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } }) t.Run("other search", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() lgs.SearchStream(context.Background(), "foo.bar", "id", false, stream) errs := stream.GetErrors() if len(errs) != 6 { t.Errorf("expected 6 error, got %v", len(errs)) } items := stream.GetItems() if len(items) != 0 { t.Errorf("expected 0 items, got %v", len(items)) } }) }) t.Run("with custom search logic", func(t *testing.T) { searchMapperCalled := false lgs := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, ListInput: "", ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { return &sdp.Item{}, nil }, SearchInputMapper: func(scope, query string) (string, error) { searchMapperCalled = true return "", nil }, GetInputMapper: func(scope, query string) string { return "" }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() lgs.SearchStream(context.Background(), "foo.bar", "bar", false, stream) errs := stream.GetErrors() if len(errs) != 0 { t.Error(errs[0]) } if !searchMapperCalled { t.Error("search mapper not called") } }) t.Run("with SearchGetInputMapper", func(t *testing.T) { ags := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "bar", Client: struct{}{}, MaxParallel: MaxParallel(1), ListInput: "", AlwaysSearchARNs: true, SearchGetInputMapper: func(scope, query string) (string, error) { return "foo.bar.id", nil }, ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { // Returns 3 pages return &TestPaginator{DataFunc: func() string { return "foo" }} }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns 2 gets per page return []string{"", ""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { if input == "foo.bar.id" { return &sdp.Item{}, nil } else { return nil, sdp.NewQueryError(errors.New("bad query details")) } }, GetInputMapper: func(scope, query string) string { return scope + "." + query }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() ags.SearchStream(context.Background(), "foo.bar", "id", false, stream) errs := stream.GetErrors() if len(errs) != 0 { t.Error(errs[0]) } items := stream.GetItems() if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } }) } func TestAlwaysGetSourceCaching(t *testing.T) { ctx := t.Context() generation := 0 s := AlwaysGetAdapter[string, string, string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test", AccountID: "foo", Region: "eu-west-2", Client: struct{}{}, ListInput: "", cache: sdpcache.NewMemoryCache(), ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[string, struct{}] { return &TestPaginator{ DataFunc: func() string { generation += 1 return fmt.Sprintf("%v", generation) }, MaxPages: 1, } }, ListFuncOutputMapper: func(output, input string) ([]string, error) { // Returns only 1 get per page to avoid confusing the cache with duplicate items return []string{""}, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, input string) (*sdp.Item, error) { generation += 1 return &sdp.Item{ Scope: "foo.eu-west-2", Type: "test-type", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue("test-item"), "generation": structpb.NewStringValue(fmt.Sprintf("%v%v", input, generation)), }, }, }, }, nil }, GetInputMapper: func(scope, query string) string { return "" }, } t.Run("get", func(t *testing.T) { // get first, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } firstGen, err := first.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } // get again withCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } withCacheGen, err := withCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCacheGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCacheGen) } // get ignore cache withoutCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", true) if err != nil { t.Fatal(err) } withoutCacheGen, err := withoutCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if withoutCacheGen == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCacheGen) } }) t.Run("list", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() // First query s.ListStream(ctx, "foo.eu-west-2", false, stream) // Second time we're expecting caching s.ListStream(ctx, "foo.eu-west-2", false, stream) // Third time we're expecting no caching since we asked it to ignore s.ListStream(ctx, "foo.eu-west-2", true, stream) errs := stream.GetErrors() if len(errs) != 0 { for _, err := range errs { t.Error(err) } t.Fatal("expected no errors") } items := stream.GetItems() if len(items) != 3 { t.Errorf("expected 3 items, got %v", len(items)) } firstGen, err := items[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withCache, err := items[1].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withoutCache, err := items[2].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCache { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCache) } if withoutCache == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCache) } }) t.Run("search", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() // First query s.SearchStream(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", false, stream) // Second time we're expecting caching s.SearchStream(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", false, stream) // Third time we're expecting no caching since we asked it to ignore s.SearchStream(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", true, stream) errs := stream.GetErrors() if len(errs) != 0 { for _, err := range errs { t.Error(err) } t.Fatal("expected no errors") } items := stream.GetItems() if len(items) != 3 { t.Errorf("expected 3 items, got %v", len(items)) } firstGen, err := items[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withCache, err := items[1].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withoutCache, err := items[2].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCache { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCache) } if withoutCache == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCache) } }) } var adapterMetadata = &sdp.AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_test_adapter.test_adapter", }, }, } ================================================ FILE: aws-source/adapters/adapterhelpers_describe_source.go ================================================ package adapters import ( "context" "errors" "fmt" "strings" "time" "buf.build/go/protovalidate" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // relatively short cache duration to cover a single Change Analysis run. // Previously this was 1 hour and we had issues with stale data where customers // were able to create resources that were not visible to them. const DefaultCacheDuration = 5 * time.Minute // DescribeOnlyAdapter Generates a adapter for AWS APIs that only use a `Describe` // function for both List and Get operations. EC2 is a good example of this, // where running Describe with no params returns everything, but params can be // supplied to reduce the number of results. type DescribeOnlyAdapter[Input InputType, Output OutputType, ClientStruct ClientStructType, Options OptionsType] struct { MaxResultsPerPage int32 // Max results per page when making API queries ItemType string // The type of items that will be returned AdapterMetadata *sdp.AdapterMetadata CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // The cache for this adapter (set during creation, can be nil for tests) // The function that should be used to describe the resources that this // adapter is related to DescribeFunc func(ctx context.Context, client ClientStruct, input Input) (Output, error) // A function that returns the input object that will be passed to // DescribeFunc for a GET request InputMapperGet func(scope, query string) (Input, error) // A function that returns the input object that will be passed to // DescribeFunc for a LIST request InputMapperList func(scope string) (Input, error) // A function that maps a search query to the required input. If this is // unset then a search request will default to searching by ARN InputMapperSearch func(ctx context.Context, client ClientStruct, scope string, query string) (Input, error) // A PostSearchFilter, if set, will be called after the search has been // completed. This can be used to filter the results of the search before // they are returned to the user, based on the query. This is used in // situations where the underlying API doesn't allow for granular enough // searching to match a given query string, and we need to apply some // additional filtering to the response. // // A good example if this is allowing users to search using ARNs that // contain IAM-Style wildcards. Since IAM is enforced *after* a query is // run, most APIs don't provide detailed enough search options to completely // replicate this functionality in the query, and instead we need to filter // the results ourselves. // // This will only be applied when the InputMapperSearch function is also set PostSearchFilter func(ctx context.Context, query string, items []*sdp.Item) ([]*sdp.Item, error) // A function that returns a paginator for this API. If this is nil, we will // assume that the API is not paginated e.g. // https://aws.github.io/aws-sdk-go-v2/docs/making-requests/#using-paginators PaginatorBuilder func(client ClientStruct, params Input) Paginator[Output, Options] // A function that returns a slice of items for a given output. The scope // and input are passed in on order to assist in creating the items if // needed, but primarily this function should iterate over the output and // create new items for each result OutputMapper func(ctx context.Context, client ClientStruct, scope string, input Input, output Output) ([]*sdp.Item, error) // The region that this adapter is configured in, each adapter can only be // configured for one region. Getting data from many regions requires a // adapter per region. This is used in the scope of returned resources Region string // AccountID The id of the account that is being used. This is used by // sources as the first element in the scope AccountID string // Client The AWS client to use when making requests Client ClientStruct // UseListForGet If true, the adapter will use the List function to get items // This option should be used when the Describe function does not support // getting a single item by ID. The adapter will then filter the items // itself. // InputMapperGet should still be defined. It will be used to create the // input for the List function. The output of the List function will be // filtered by the adapter to find the item with the matching ID. // See the directconnect-virtual-gateway adapter for an example of this. UseListForGet bool } // Returns the duration that items should be cached for. This will use the // `CacheDuration` for this adapter if set, otherwise it will use the default // duration of 1 hour func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) cacheDuration() time.Duration { if s.CacheDuration == 0 { return DefaultCacheDuration } return s.CacheDuration } // Validate Checks that the adapter is correctly set up and returns an error if // not func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Validate() error { if s.DescribeFunc == nil { return errors.New("adapter describe func is nil") } if s.MaxResultsPerPage == 0 { s.MaxResultsPerPage = DefaultMaxResultsPerPage } if s.InputMapperGet == nil { return errors.New("adapter get input mapper is nil") } if s.OutputMapper == nil { return errors.New("adapter output mapper is nil") } return protovalidate.Validate(s.AdapterMetadata) } // Paginated returns whether or not this adapter is using a paginated API func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Paginated() bool { return s.PaginatorBuilder != nil } func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Type() string { return s.ItemType } func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Name() string { return fmt.Sprintf("%v-adapter", s.ItemType) } func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Metadata() *sdp.AdapterMetadata { return s.AdapterMetadata } // List of scopes that this adapter is capable of find items for. This will be // in the format {accountID}.{region} func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Scopes() []string { return []string{ FormatScope(s.AccountID, s.Region), } } // Get Get a single item with a given scope and query. The item returned // should have a UniqueAttributeValue that matches the `query` parameter. The // ctx parameter contains a golang context object which should be used to allow // this adapter to timeout or be cancelled when executing potentially // long-running actions func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != s.Scopes()[0] { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), } } var input Input var output Output var err error var items []*sdp.Item err = s.Validate() if err != nil { return nil, WrapAWSError(err) } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.ItemType, query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) > 0 { return cachedItems[0], nil } else { return nil, nil } } // Get the input object input, err = s.InputMapperGet(scope, query) if err != nil { err = s.processError(ctx, err, ck) return nil, err } // Call the API using the object output, err = s.DescribeFunc(ctx, s.Client, input) if err != nil { err = s.processError(ctx, err, ck) return nil, err } items, err = s.OutputMapper(ctx, s.Client, scope, input, output) if err != nil { err = s.processError(ctx, err, ck) return nil, err } if s.UseListForGet { // If we're using List for Get, we need to filter the items ourselves var filteredItems []*sdp.Item for _, item := range items { if item.UniqueAttributeValue() == query { filteredItems = append(filteredItems, item) break } } items = filteredItems } numItems := len(items) switch { case numItems > 1: itemNames := make([]string, 0, len(items)) // Get the names for logging for i := range items { itemNames = append(itemNames, items[i].GloballyUniqueName()) } qErr := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("Request returned > 1 item for a GET request. Items: %v", strings.Join(itemNames, ", ")), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, qErr, s.cacheDuration(), ck) return nil, qErr case numItems == 0: qErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%v %v not found", s.Type(), query), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, qErr, s.cacheDuration(), ck) return nil, qErr } s.cache.StoreItem(ctx, items[0], s.cacheDuration(), ck) return items[0], nil } // List Lists all items in a given scope func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) ListStream(ctx context.Context, scope string, ignoreCache bool, stream discovery.QueryResultStream) { if scope != s.Scopes()[0] { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), }) return } if s.InputMapperList == nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("list is not supported for %v resources", s.ItemType), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), }) return } err := s.Validate() if err != nil { stream.SendError(WrapAWSError(err)) return } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.ItemType, "", ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } input, err := s.InputMapperList(scope) if err != nil { err = s.processError(ctx, err, ck) stream.SendError(err) return } s.describe(ctx, nil, input, scope, ck, stream) } // Search Searches for AWS resources by ARN func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) SearchStream(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { if scope != s.Scopes()[0] { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), }) return } if s.InputMapperSearch == nil { s.searchARN(ctx, scope, query, ignoreCache, stream) } else { s.searchCustom(ctx, scope, query, ignoreCache, stream) } } func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) searchARN(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { // Parse the ARN a, err := ParseARN(query) if err != nil { stream.SendError(WrapAWSError(err)) return } if a.ContainsWildcard() { // We can't handle wildcards by default so bail out stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("wildcards are not supported by adapter %v", s.Name()), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), }) return } if arnScope := FormatScope(a.AccountID, a.Region); arnScope != scope { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("ARN scope %v does not match request scope %v", arnScope, scope), Scope: scope, }) return } // this already uses the cache, so needs no extra handling item, err := s.Get(ctx, scope, a.ResourceID(), ignoreCache) if err != nil { stream.SendError(err) return } if item != nil { stream.SendItem(item) } } // searchCustom Runs custom search logic using the `InputMapperSearch` function func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) searchCustom(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.ItemType, query, ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } input, err := s.InputMapperSearch(ctx, s.Client, scope, query) if err != nil { stream.SendError(WrapAWSError(err)) return } s.describe(ctx, &query, input, scope, ck, stream) } // Processes an error returned by the AWS API so that it can be handled by // Overmind. This includes extracting the correct error type, wrapping in an SDP // error, and caching that error if it is non-transient (like a 404) func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) processError(ctx context.Context, err error, cacheKey sdpcache.CacheKey) error { var sdpErr *sdp.QueryError if err != nil { sdpErr = WrapAWSError(err) // Only cache the error if is something that won't be fixed by retrying if sdpErr.GetErrorType() == sdp.QueryError_NOTFOUND || sdpErr.GetErrorType() == sdp.QueryError_NOSCOPE { s.cache.StoreUnavailableItem(ctx, sdpErr, s.cacheDuration(), cacheKey) } } return sdpErr } // describe Runs describe on the given input, intelligently choosing whether to // run the paginated or unpaginated query. This handles caching, error handling, // and post-search filtering if the query param is passed func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) describe(ctx context.Context, query *string, input Input, scope string, ck sdpcache.CacheKey, stream discovery.QueryResultStream) { // Track whether any items were found itemsSent := 0 if s.Paginated() { paginator := s.PaginatorBuilder(s.Client, input) for paginator.HasMorePages() { output, err := paginator.NextPage(ctx) if err != nil { stream.SendError(s.processError(ctx, err, ck)) return } items, err := s.OutputMapper(ctx, s.Client, scope, input, output) if err != nil { stream.SendError(s.processError(ctx, err, ck)) return } if query != nil && s.PostSearchFilter != nil { items, err = s.PostSearchFilter(ctx, *query, items) if err != nil { stream.SendError(s.processError(ctx, err, ck)) return } } for _, item := range items { s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) stream.SendItem(item) itemsSent++ } } } else { output, err := s.DescribeFunc(ctx, s.Client, input) if err != nil { stream.SendError(s.processError(ctx, err, ck)) return } items, err := s.OutputMapper(ctx, s.Client, scope, input, output) if err != nil { stream.SendError(s.processError(ctx, err, ck)) return } if query != nil && s.PostSearchFilter != nil { items, err = s.PostSearchFilter(ctx, *query, items) if err != nil { stream.SendError(s.processError(ctx, err, ck)) return } } for _, item := range items { s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) stream.SendItem(item) itemsSent++ } } // Cache not-found when no items were found if itemsSent == 0 { var errorString string if query != nil { errorString = fmt.Sprintf("no %s found for search query '%s' in scope %s", s.ItemType, *query, scope) } else { errorString = fmt.Sprintf("no %s found in scope %s", s.ItemType, scope) } notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: errorString, Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, s.cacheDuration(), ck) } } // Weight Returns the priority weighting of items returned by this adapter. // This is used to resolve conflicts where two sources of the same type // return an item for a GET request. In this instance only one item can be // seen on, so the one with the higher weight value will win. func (s *DescribeOnlyAdapter[Input, Output, ClientStruct, Options]) Weight() int { return 100 } ================================================ FILE: aws-source/adapters/adapterhelpers_describe_source_test.go ================================================ package adapters import ( "context" "errors" "fmt" "regexp" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "google.golang.org/protobuf/types/known/structpb" ) func TestType(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "foo", } if s.Type() != "foo" { t.Errorf("expected type to be foo, got %v", s.Type()) } } func TestName(t *testing.T) { // Basically just test that it's not empty. It doesn't matter what it is s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "foo", } if s.Name() == "" { t.Error("blank name") } } func TestScopes(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "outer-space", AccountID: "mars", } scopes := s.Scopes() if len(scopes) != 1 { t.Errorf("expected 1 scope, got %v", len(scopes)) } if scopes[0] != "mars.outer-space" { t.Errorf("expected scope to be mars.outer-space, got %v", scopes[0]) } } func TestGet(t *testing.T) { t.Run("when everything goes well", func(t *testing.T) { var inputMapperCalled bool var outputMapperCalled bool var describeFuncCalled bool s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { inputMapperCalled = true return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { outputMapperCalled = true return []*sdp.Item{ {}, }, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { describeFuncCalled = true return "", nil }, cache: sdpcache.NewNoOpCache(), } item, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err != nil { t.Error(err) } if !inputMapperCalled { t.Error("input mapper not called") } if !outputMapperCalled { t.Error("output mapper not called") } if !describeFuncCalled { t.Error("describe func not called") } if item == nil { t.Error("nil item") } }) t.Run("use get for list: output returns multiple sources", func(t *testing.T) { uniqueAttribute := "virtualGatewayId" uniqueAttributeValue := "test-id" var inputMapperCalled bool var outputMapperCalled bool var describeFuncCalled bool s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { inputMapperCalled = true return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { outputMapperCalled = true return []*sdp.Item{ { UniqueAttribute: uniqueAttribute, Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ uniqueAttribute: structpb.NewStringValue(uniqueAttributeValue), }, }, }, }, { UniqueAttribute: uniqueAttribute, Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ uniqueAttribute: structpb.NewStringValue("some-value"), }, }, }, }, }, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { describeFuncCalled = true return "", nil }, UseListForGet: true, cache: sdpcache.NewNoOpCache(), } item, err := s.Get(context.Background(), "foo.eu-west-2", uniqueAttributeValue, false) if err != nil { t.Error(err) } if !inputMapperCalled { t.Error("input mapper not called") } if !outputMapperCalled { t.Error("output mapper not called") } if !describeFuncCalled { t.Error("describe func not called") } if item == nil { t.Error("nil item") } }) t.Run("with too many results", func(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ {}, {}, {}, }, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error") } }) t.Run("with no results", func(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{}, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error") } }) } func TestSearchARN(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "region", AccountID: "account-id", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ {}, }, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "fancy", nil }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() s.SearchStream(context.Background(), "account-id.region", "arn:partition:service:region:account-id:resource-type:resource-id", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } } func TestSearchCustom(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "region", AccountID: "account-id", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ { Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue(output), }, }, }, }, }, nil }, InputMapperSearch: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "custom", nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return input, nil }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() s.SearchStream(context.Background(), "account-id.region", "foo", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } if items[0].UniqueAttributeValue() != "custom" { t.Errorf("expected item to be 'custom', got %v", items[0].UniqueAttributeValue()) } t.Run("with a post-search filter", func(t *testing.T) { s.PostSearchFilter = func(ctx context.Context, query string, items []*sdp.Item) ([]*sdp.Item, error) { return nil, nil } stream := discovery.NewRecordingQueryResultStream() s.SearchStream(context.Background(), "account-id.region", "bar", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 0 { t.Errorf("expected 0 item, got %v", len(items)) } }) } func TestNoInputMapper(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ {}, }, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } t.Run("Get", func(t *testing.T) { _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error but got nil") } }) t.Run("List", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "foo.eu-west-2", false, stream) if len(stream.GetErrors()) == 0 { t.Error("expected error but got none") } }) } func TestNoOutputMapper(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } t.Run("Get", func(t *testing.T) { _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error but got nil") } }) t.Run("List", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "foo.eu-west-2", false, stream) if len(stream.GetErrors()) == 0 { t.Error("expected error but got none") } }) } func TestNoDescribeFunc(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ {}, }, nil }, cache: sdpcache.NewNoOpCache(), } t.Run("Get", func(t *testing.T) { _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error but got nil") } }) t.Run("List", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "foo.eu-west-2", false, stream) if len(stream.GetErrors()) == 0 { t.Error("expected error but got none") } }) } func TestFailingInputMapper(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", errors.New("foobar") }, InputMapperList: func(scope string) (string, error) { return "input", errors.New("foobar") }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ {}, }, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } fooBar := regexp.MustCompile("foobar") t.Run("Get", func(t *testing.T) { _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error but got nil") } if !fooBar.MatchString(err.Error()) { t.Errorf("expected error string '%v' to contain foobar", err.Error()) } }) t.Run("List", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "foo.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) == 0 { t.Error("expected error but got none") } if !fooBar.MatchString(errs[0].Error()) { t.Errorf("expected error string '%v' to contain foobar", errs[0].Error()) } }) } func TestFailingOutputMapper(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return nil, errors.New("foobar") }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } fooBar := regexp.MustCompile("foobar") t.Run("Get", func(t *testing.T) { _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error but got nil") } if !fooBar.MatchString(err.Error()) { t.Errorf("expected error string '%v' to contain foobar", err.Error()) } }) t.Run("List", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "foo.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) == 0 { t.Error("expected error but got none") } if !fooBar.MatchString(errs[0].Error()) { t.Errorf("expected error string '%v' to contain foobar", errs[0].Error()) } }) } func TestFailingDescribeFunc(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ {}, }, nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", errors.New("foobar") }, cache: sdpcache.NewNoOpCache(), } fooBar := regexp.MustCompile("foobar") t.Run("Get", func(t *testing.T) { _, err := s.Get(context.Background(), "foo.eu-west-2", "bar", false) if err == nil { t.Error("expected error but got nil") } if !fooBar.MatchString(err.Error()) { t.Errorf("expected error string '%v' to contain foobar", err.Error()) } }) t.Run("List", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "foo.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) == 0 { t.Error("expected error but got none") } if !fooBar.MatchString(errs[0].Error()) { t.Errorf("expected error string '%v' to contain foobar", errs[0].Error()) } }) } type TestPaginator struct { DataFunc func() string MaxPages int page int } func (t *TestPaginator) HasMorePages() bool { if t.MaxPages == 0 { t.MaxPages = 3 } return t.page < t.MaxPages } func (t *TestPaginator) NextPage(context.Context, ...func(struct{})) (string, error) { data := t.DataFunc() t.page++ return data, nil } func TestPaginated(t *testing.T) { s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, MaxResultsPerPage: 1, Region: "eu-west-2", AccountID: "foo", InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ {}, }, nil }, PaginatorBuilder: func(client struct{}, params string) Paginator[string, struct{}] { return &TestPaginator{DataFunc: func() string { return "foo" }} }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } t.Run("detecting pagination", func(t *testing.T) { if !s.Paginated() { t.Error("pagination not detected") } if err := s.Validate(); err != nil { t.Error(err) } }) t.Run("paginating a List query", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "foo.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 3 { t.Errorf("expected 3 items, got %v", len(items)) } }) } func TestDescribeOnlySourceCaching(t *testing.T) { ctx := context.Background() generation := 0 s := DescribeOnlyAdapter[string, string, struct{}, struct{}]{ AdapterMetadata: adapterMetadata, ItemType: "test-type", MaxResultsPerPage: 1, Region: "eu-west-2", AccountID: "foo", cache: sdpcache.NewMemoryCache(), InputMapperGet: func(scope, query string) (string, error) { return "input", nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, OutputMapper: func(_ context.Context, _ struct{}, scope, input, output string) ([]*sdp.Item, error) { return []*sdp.Item{ { Scope: "foo.eu-west-2", Type: "test-type", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue("test-item"), "generation": structpb.NewStringValue(output), }, }, }, }, }, nil }, PaginatorBuilder: func(client struct{}, params string) Paginator[string, struct{}] { return &TestPaginator{ DataFunc: func() string { generation += 1 return fmt.Sprintf("%v", generation) }, MaxPages: 1, } }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { generation += 1 return fmt.Sprintf("%v", generation), nil }, } t.Run("get", func(t *testing.T) { // get first, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } firstGen, err := first.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } // get again withCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } withCacheGen, err := withCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCacheGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCacheGen) } // get ignore cache withoutCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", true) if err != nil { t.Fatal(err) } withoutCacheGen, err := withoutCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if withoutCacheGen == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCacheGen) } }) t.Run("list", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() // Fist list s.ListStream(ctx, "foo.eu-west-2", false, stream) // List again, expect caching s.ListStream(ctx, "foo.eu-west-2", false, stream) // List again, ignore cache s.ListStream(ctx, "foo.eu-west-2", true, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 3 { t.Fatalf("expected 3 items, got %v", len(items)) } firstGen, err := items[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withCache, err := items[1].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withoutCache, err := items[2].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCache { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCache) } if withoutCache == firstGen { t.Errorf("without cache: expected generation %v, got %v", firstGen, withoutCache) } }) t.Run("search", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() // First time s.SearchStream(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", false, stream) // Search again, expect caching s.SearchStream(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", false, stream) // Search again, ignore cache s.SearchStream(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", true, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 3 { t.Fatalf("expected 3 items, got %v", len(items)) } firstGen, err := items[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withCache, err := items[1].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withoutCache, err := items[2].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCache { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCache) } if withoutCache == firstGen { t.Errorf("without cache: expected generation %v, got %v", firstGen, withoutCache) } }) } // TestListCachingZeroItems demonstrates that LIST caching works when 0 items are returned. // This is a simple test to verify that repeated LIST calls don't hit the backend when // the first call returned no items. func TestListCachingZeroItems(t *testing.T) { ctx := context.Background() describeCalls := 0 cache := sdpcache.NewMemoryCache() adapter := &DescribeOnlyAdapter[string, string, struct{}, struct{}]{ ItemType: "ec2-instance", Region: "us-east-1", AccountID: "123456789012", cache: cache, AdapterMetadata: &sdp.AdapterMetadata{ Type: "ec2-instance", DescriptiveName: "EC2 Instance", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get an EC2 instance by ID", ListDescription: "List all EC2 instances", }, }, InputMapperGet: func(scope, query string) (string, error) { return query, nil }, InputMapperList: func(scope string) (string, error) { return "", nil }, DescribeFunc: func(ctx context.Context, client struct{}, input string) (string, error) { describeCalls++ t.Logf("DescribeFunc called (call #%d)", describeCalls) return "", nil }, OutputMapper: func(ctx context.Context, client struct{}, scope, input, output string) ([]*sdp.Item, error) { // Return empty slice - simulates no EC2 instances found return []*sdp.Item{}, nil }, } // First LIST call - should hit the backend stream1 := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, "123456789012.us-east-1", false, stream1) if describeCalls != 1 { t.Errorf("First call: expected 1 DescribeFunc call, got %d", describeCalls) } if len(stream1.GetItems()) != 0 { t.Errorf("First call: expected 0 items, got %d", len(stream1.GetItems())) } t.Logf("First call complete: %d items, %d errors", len(stream1.GetItems()), len(stream1.GetErrors())) // Second LIST call - should hit cache, NOT the backend stream2 := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, "123456789012.us-east-1", false, stream2) if describeCalls != 1 { t.Errorf("Second call: expected still 1 DescribeFunc call (cache hit), got %d", describeCalls) } if len(stream2.GetItems()) != 0 { t.Errorf("Second call: expected 0 items, got %d", len(stream2.GetItems())) } // For backward compatibility, cached NOTFOUND is treated as empty result (no error) // This matches the behavior of the first call which returns empty stream with no errors if len(stream2.GetErrors()) != 0 { t.Errorf("Second call: expected 0 errors from cache (backward compatibility), got %d errors", len(stream2.GetErrors())) } t.Logf("Second call complete: %d items, %d errors (cache hit!)", len(stream2.GetItems()), len(stream2.GetErrors())) // Third LIST call with ignoreCache=true - should bypass cache and hit backend stream3 := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, "123456789012.us-east-1", true, stream3) // ignoreCache=true if describeCalls != 2 { t.Errorf("Third call (ignoreCache): expected 2 DescribeFunc calls, got %d", describeCalls) } t.Logf("Third call (ignoreCache=true) complete: %d items, %d errors", len(stream3.GetItems()), len(stream3.GetErrors())) } ================================================ FILE: aws-source/adapters/adapterhelpers_get_list_adapter_v2.go ================================================ package adapters import ( "context" "errors" "fmt" "slices" "time" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // GetListAdapterV2 A adapter for AWS APIs where the Get and List functions both // return the full item, such as many of the IAM APIs. This version supports // paginated APIs and streaming results. type GetListAdapterV2[ListInput InputType, ListOutput OutputType, AWSItem AWSItemType, ClientStruct ClientStructType, Options OptionsType] struct { ItemType string // The type of items that will be returned Client ClientStruct // The AWS API client AccountID string // The AWS account ID Region string // The AWS region this is related to SupportGlobalResources bool // If true, this will also support resources in the "aws" scope which are global AdapterMetadata *sdp.AdapterMetadata CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // The cache for this adapter (set during creation, can be nil for tests) // Disables List(), meaning all calls will return empty results. This does // not affect Search() DisableList bool // GetFunc Gets the details of a specific item, returns the AWS // representation of that item, and an error GetFunc func(ctx context.Context, client ClientStruct, scope string, query string) (AWSItem, error) // A function that returns the input object that will be passed to // ListFunc for a LIST request InputMapperList func(scope string) (ListInput, error) // ListFunc Lists all items that it can find this should be used only if the // API does not have a paginator, otherwise use ListFuncPaginatorBuilder ListFunc func(ctx context.Context, client ClientStruct, input ListInput) (ListOutput, error) // A function that returns a paginator for this API. If this is nil, we will // assume that the API is not paginated e.g. // https://aws.github.io/aws-sdk-go-v2/docs/making-requests/#using-paginators // // If this is set then ListFunc will be ignored ListFuncPaginatorBuilder func(client ClientStruct, params ListInput) Paginator[ListOutput, Options] // Extracts the list of items from the output of the ListFunc, these will be // passed to the ItemMapper for conversion to SDP items ListExtractor func(ctx context.Context, output ListOutput, client ClientStruct) ([]AWSItem, error) // NOTE // // This does not yet support custom searching, this will be added in a // future version // ItemMapper Maps an AWS representation of an item to the SDP version, the // query will be nil if the method was LIST ItemMapper func(query *string, scope string, awsItem AWSItem) (*sdp.Item, error) // ListTagsFunc Optional function that will be used to list tags for a // resource ListTagsFunc func(context.Context, AWSItem, ClientStruct) (map[string]string, error) } func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) cacheDuration() time.Duration { if s.CacheDuration == 0 { return DefaultCacheDuration } return s.CacheDuration } // Validate Checks that the adapter has been set up correctly func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) Validate() error { if s.GetFunc == nil { return errors.New("GetFunc is nil") } if !s.DisableList { if s.ListFunc == nil && s.ListFuncPaginatorBuilder == nil { return errors.New("ListFunc and ListFuncPaginatorBuilder are nil") } if s.ListExtractor == nil { return errors.New("ListExtractor is nil") } if s.InputMapperList == nil { return errors.New("InputMapperList is nil") } } if s.ItemMapper == nil { return errors.New("ItemMapper is nil") } return nil } func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) Type() string { return s.ItemType } func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) Name() string { return fmt.Sprintf("%v-adapter", s.ItemType) } func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) Metadata() *sdp.AdapterMetadata { return s.AdapterMetadata } // List of scopes that this adapter is capable of find items for. This will be // in the format {accountID}.{region} func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) Scopes() []string { scopes := make([]string, 0) scopes = append(scopes, FormatScope(s.AccountID, s.Region)) if s.SupportGlobalResources { scopes = append(scopes, "aws") } return scopes } // hasScope Returns whether or not this adapter has the given scope func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) hasScope(scope string) bool { if scope == "aws" && s.SupportGlobalResources { // There is a special global "account" that is used for global resources // called "aws" return true } return slices.Contains(s.Scopes(), scope) } // Get retrieves an item from the adapter based on the provided scope, query, and // cache settings. It uses the defined `GetFunc`, `ItemMapper`, and // `ListTagsFunc` to retrieve and map the item. func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if !s.hasScope(scope) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), } } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.ItemType, query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) == 0 { return nil, nil } else { return cachedItems[0], nil } } awsItem, err := s.GetFunc(ctx, s.Client, scope, query) if err != nil { err := WrapAWSError(err) if !CanRetry(err) { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) } return nil, err } item, err := s.ItemMapper(&query, scope, awsItem) if err != nil { // Don't cache this as wrapping is very cheap and better to just try // again than store in memory return nil, WrapAWSError(err) } if s.ListTagsFunc != nil { item.Tags, err = s.ListTagsFunc(ctx, awsItem, s.Client) if err != nil { item.Tags = HandleTagsError(ctx, err) } } s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) return item, nil } // List Lists all available items. This is done by running the ListFunc, then // passing these results to GetFunc in order to get the details func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) ListStream(ctx context.Context, scope string, ignoreCache bool, stream discovery.QueryResultStream) { if !s.hasScope(scope) { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), }) return } if s.DisableList { return } if err := s.Validate(); err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), }) return } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.ItemType, "", ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } listInput, err := s.InputMapperList(scope) if err != nil { stream.SendError(WrapAWSError(err)) return } // Track whether any items were found and if we had an error itemsSent := 0 hadError := false // Define the function to send the outputs sendOutputs := func(out ListOutput) { // Extract the items in the correct format awsItems, err := s.ListExtractor(ctx, out, s.Client) if err != nil { hadError = true stream.SendError(WrapAWSError(err)) return } // Map the items to SDP items, send on the stream, and save to the // cache for _, awsItem := range awsItems { item, err := s.ItemMapper(nil, scope, awsItem) if err != nil { hadError = true stream.SendError(WrapAWSError(err)) continue } if s.ListTagsFunc != nil { item.Tags, err = s.ListTagsFunc(ctx, awsItem, s.Client) if err != nil { item.Tags = HandleTagsError(ctx, err) } } stream.SendItem(item) itemsSent++ s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) } } // See if this is paginated or not and use the appropriate method if s.ListFuncPaginatorBuilder != nil { paginator := s.ListFuncPaginatorBuilder(s.Client, listInput) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { hadError = true stream.SendError(WrapAWSError(err)) return } sendOutputs(out) } } else if s.ListFunc != nil { out, err := s.ListFunc(ctx, s.Client, listInput) if err != nil { hadError = true stream.SendError(WrapAWSError(err)) return } sendOutputs(out) } // Cache not-found only when no items were found AND no error occurred // If we had an error, that error is already sent to the stream, don't overwrite it if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found in scope %s", s.ItemType, scope), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, s.cacheDuration(), ck) } } // Search Searches for AWS resources, this can be implemented either as a // generic ARN search that tries to extract the globally unique name from the // ARN and pass this to a Get request, or a custom search function that can be // used to search for items in a different, adapter-specific way func (s *GetListAdapterV2[ListInput, ListOutput, AWSItem, ClientStruct, Options]) SearchStream(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { if !s.hasScope(scope) { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), }) return } // Parse the ARN a, err := ParseARN(query) if err != nil { stream.SendError(WrapAWSError(err)) return } if a.ContainsWildcard() { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("wildcards are not supported by adapter %v", s.Name()), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), }) return } if arnScope := FormatScope(a.AccountID, a.Region); !s.hasScope(arnScope) { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("ARN scope %v does not match request scope %v", arnScope, scope), Scope: scope, }) return } // Since this gits the Get method, and this method implements caching, we // don't need to implement it here item, err := s.Get(ctx, scope, a.ResourceID(), ignoreCache) if err != nil { stream.SendError(err) return } if item != nil { stream.SendItem(item) } } ================================================ FILE: aws-source/adapters/adapterhelpers_get_list_adapter_v2_test.go ================================================ package adapters import ( "context" "errors" "fmt" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "google.golang.org/protobuf/types/known/structpb" ) func TestGetListAdapterV2Type(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "foo", } if s.Type() != "foo" { t.Errorf("expected type to be foo got %v", s.Type()) } } func TestGetListAdapterV2Name(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "foo", } if s.Name() != "foo-adapter" { t.Errorf("expected type to be foo-adapter got %v", s.Name()) } } func TestGetListAdapterV2Scopes(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ AccountID: "foo", Region: "bar", } if s.Scopes()[0] != "foo.bar" { t.Errorf("expected scope to be foo.bar, got %v", s.Scopes()[0]) } } func TestGetListAdapterV2Get(t *testing.T) { t.Run("with no errors", func(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ItemMapper: func(query *string, scope, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, ListTagsFunc: func(ctx context.Context, s1 string, s2 struct{}) (map[string]string, error) { return map[string]string{ "foo": "bar", }, nil }, cache: sdpcache.NewNoOpCache(), } item, err := s.Get(context.Background(), "12345.eu-west-2", "", false) if err != nil { t.Error(err) } if item.GetTags()["foo"] != "bar" { t.Errorf("expected tag foo to be bar, got %v", item.GetTags()["foo"]) } }) t.Run("with an error in the GetFunc", func(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", errors.New("get func error") }, ItemMapper: func(query *string, scope, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, cache: sdpcache.NewNoOpCache(), } if _, err := s.Get(context.Background(), "12345.eu-west-2", "", false); err == nil { t.Error("expected error got nil") } }) t.Run("with an error in the mapper", func(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ItemMapper: func(query *string, scope, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, errors.New("mapper error") }, cache: sdpcache.NewNoOpCache(), } if _, err := s.Get(context.Background(), "12345.eu-west-2", "", false); err == nil { t.Error("expected error got nil") } }) } func TestGetListAdapterV2ListStream(t *testing.T) { t.Run("with no errors", func(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, input string) ([]string, error) { return []string{"one", "two"}, nil }, ItemMapper: func(query *string, scope, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, ListExtractor: func(ctx context.Context, output []string, client struct{}) ([]string, error) { return output, nil }, ListTagsFunc: func(ctx context.Context, s1 string, s2 struct{}) (map[string]string, error) { return map[string]string{ "foo": "bar", }, nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "12345.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 2 { t.Errorf("expected 2 items, got %v", len(items)) } }) t.Run("with an error in the ListFunc", func(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, errors.New("list func error") }, ItemMapper: func(query *string, scope, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "12345.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) == 0 { t.Error("expected errors got none") } }) t.Run("with an error in the mapper", func(t *testing.T) { s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListExtractor: func(ctx context.Context, output []string, client struct{}) ([]string, error) { return output, nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, nil }, ItemMapper: func(query *string, scope, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, errors.New("mapper error") }, InputMapperList: func(scope string) (string, error) { return "input", nil }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() s.ListStream(context.Background(), "12345.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) != 2 { t.Errorf("expected 2 errors got %v", len(errs)) } items := stream.GetItems() if len(items) != 0 { t.Errorf("expected no items, got %v", len(items)) } }) } // MockPaginator is a mock implementation of the Paginator interface type MockPaginator struct { pages [][]string pageIdx int hasPages bool } func (p *MockPaginator) HasMorePages() bool { return p.hasPages && p.pageIdx < len(p.pages) } func (p *MockPaginator) NextPage(ctx context.Context, opts ...func(struct{})) ([]string, error) { if !p.HasMorePages() { return nil, errors.New("no more pages available") } page := p.pages[p.pageIdx] p.pageIdx++ return page, nil } func TestListFuncPaginatorBuilder(t *testing.T) { adapter := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "test-item", AccountID: "foo", Region: "eu-west-2", Client: struct{}{}, InputMapperList: func(scope string) (string, error) { return "test-input", nil }, ListFuncPaginatorBuilder: func(client struct{}, input string) Paginator[[]string, struct{}] { return &MockPaginator{ pages: [][]string{ {"item1", "item2"}, {"item3", "item4"}, }, hasPages: true, } }, ListExtractor: func(ctx context.Context, output []string, client struct{}) ([]string, error) { return output, nil }, ItemMapper: func(query *string, scope string, awsItem string) (*sdp.Item, error) { attrs, _ := sdp.ToAttributes(map[string]any{ "id": awsItem, }) return &sdp.Item{ Type: "test-item", UniqueAttribute: "id", Attributes: attrs, Scope: scope, }, nil }, GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, cache: sdpcache.NewNoOpCache(), } stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(context.Background(), "foo.eu-west-2", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 4 { t.Errorf("expected 4 items, got %v", len(items)) } } func TestGetListAdapterV2Caching(t *testing.T) { ctx := context.Background() generation := 0 s := GetListAdapterV2[string, []string, string, struct{}, struct{}]{ ItemType: "test-type", Region: "eu-west-2", AccountID: "foo", cache: sdpcache.NewCache(ctx), GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { generation += 1 return fmt.Sprintf("%v", generation), nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { generation += 1 return []string{fmt.Sprintf("%v", generation)}, nil }, ListExtractor: func(ctx context.Context, output []string, client struct{}) ([]string, error) { return output, nil }, InputMapperList: func(scope string) (string, error) { return "input", nil }, ItemMapper: func(query *string, scope string, output string) (*sdp.Item, error) { return &sdp.Item{ Scope: "foo.eu-west-2", Type: "test-type", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue("test-item"), "generation": structpb.NewStringValue(output), }, }, }, }, nil }, } t.Run("get", func(t *testing.T) { // get first, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } firstGen, err := first.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } // get again withCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } withCacheGen, err := withCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCacheGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCacheGen) } // get ignore cache withoutCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", true) if err != nil { t.Fatal(err) } withoutCacheGen, err := withoutCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if withoutCacheGen == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCacheGen) } }) t.Run("list", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() // First call s.ListStream(ctx, "foo.eu-west-2", false, stream) // Second call with caching s.ListStream(ctx, "foo.eu-west-2", false, stream) // Third call without caching s.ListStream(ctx, "foo.eu-west-2", true, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() firstGen, err := items[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withCacheGen, err := items[1].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } withoutCacheGen, err := items[2].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCacheGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCacheGen) } if withoutCacheGen == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCacheGen) } }) } // TestGetListAdapterV2_ListExtractorErrorNoNotFoundCache tests that when ListExtractor fails, // we don't incorrectly cache NOTFOUND. The error should be sent, but NOTFOUND should not be cached // because the failure was due to extraction errors, not because items don't exist. func TestGetListAdapterV2_ListExtractorErrorNoNotFoundCache(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() listCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapterV2[*MockInput, *MockOutput, *MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { return nil, errors.New("should not be called in LIST test") }, InputMapperList: func(scope string) (*MockInput, error) { return &MockInput{}, nil }, ListFunc: func(ctx context.Context, client *MockClient, input *MockInput) (*MockOutput, error) { listCalls++ // Return a valid output that indicates items exist return &MockOutput{}, nil }, ListExtractor: func(ctx context.Context, output *MockOutput, client *MockClient) ([]*MockAWSItem, error) { // Simulate extraction failure - this should NOT result in NOTFOUND caching return nil, errors.New("extraction failed") }, ItemMapper: func(query *string, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call - ListExtractor fails, should send error but NOT cache NOTFOUND stream1 := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, "123456789012.us-east-1", false, stream1) if len(stream1.GetItems()) != 0 { t.Errorf("Expected 0 items, got %d", len(stream1.GetItems())) } if len(stream1.GetErrors()) != 1 { t.Errorf("Expected 1 error from ListExtractor failure, got %d", len(stream1.GetErrors())) } if listCalls != 1 { t.Errorf("Expected 1 ListFunc call, got %d", listCalls) } // Second call - should NOT hit cache (NOTFOUND was not cached), should try again stream2 := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, "123456789012.us-east-1", false, stream2) if listCalls != 2 { t.Errorf("Expected 2 ListFunc calls (no cache hit because NOTFOUND was not cached), got %d", listCalls) } if len(stream2.GetItems()) != 0 { t.Errorf("Expected 0 items, got %d", len(stream2.GetItems())) } if len(stream2.GetErrors()) != 1 { t.Errorf("Expected 1 error from ListExtractor failure, got %d", len(stream2.GetErrors())) } } ================================================ FILE: aws-source/adapters/adapterhelpers_get_list_source.go ================================================ package adapters import ( "context" "errors" "fmt" "slices" "time" "buf.build/go/protovalidate" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // GetListAdapter A adapter for AWS APIs where the Get and List functions both // return the full item, such as many of the IAM APIs type GetListAdapter[AWSItem AWSItemType, ClientStruct ClientStructType, Options OptionsType] struct { ItemType string // The type of items that will be returned Client ClientStruct // The AWS API client AccountID string // The AWS account ID Region string // The AWS region this is related to SupportGlobalResources bool // If true, this will also support resources in the "aws" scope which are global AdapterMetadata *sdp.AdapterMetadata CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // The cache for this adapter (set during creation, can be nil for tests) // Disables List(), meaning all calls will return empty results. This does // not affect Search() DisableList bool // GetFunc Gets the details of a specific item, returns the AWS // representation of that item, and an error GetFunc func(ctx context.Context, client ClientStruct, scope string, query string) (AWSItem, error) // ListFunc Lists all items that it can find. Returning a slice of AWS items ListFunc func(ctx context.Context, client ClientStruct, scope string) ([]AWSItem, error) // Optional search func that will be used for Search Requests. If this is // unset, Search will simply use ARNs SearchFunc func(ctx context.Context, client ClientStruct, scope string, query string) ([]AWSItem, error) // ItemMapper Maps an AWS representation of an item to the SDP version ItemMapper func(query, scope string, awsItem AWSItem) (*sdp.Item, error) // ListTagsFunc Optional function that will be used to list tags for a // resource ListTagsFunc func(context.Context, AWSItem, ClientStruct) (map[string]string, error) } func (s *GetListAdapter[AWSItem, ClientStruct, Options]) cacheDuration() time.Duration { if s.CacheDuration == 0 { return DefaultCacheDuration } return s.CacheDuration } // Validate Checks that the adapter has been set up correctly func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Validate() error { if s.GetFunc == nil { return errors.New("GetFunc is nil") } if !s.DisableList { if s.ListFunc == nil { return errors.New("ListFunc is nil") } } if s.ItemMapper == nil { return errors.New("ItemMapper is nil") } return protovalidate.Validate(s.AdapterMetadata) } func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Type() string { return s.ItemType } func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Name() string { return fmt.Sprintf("%v-adapter", s.ItemType) } func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Metadata() *sdp.AdapterMetadata { return s.AdapterMetadata } // List of scopes that this adapter is capable of find items for. This will be // in the format {accountID}.{region} func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Scopes() []string { scopes := make([]string, 0) scopes = append(scopes, FormatScope(s.AccountID, s.Region)) if s.SupportGlobalResources { scopes = append(scopes, "aws") } return scopes } // hasScope Returns whether or not this adapter has the given scope func (s *GetListAdapter[AWSItem, ClientStruct, Options]) hasScope(scope string) bool { if scope == "aws" && s.SupportGlobalResources { // There is a special global "account" that is used for global resources // called "aws" return true } return slices.Contains(s.Scopes(), scope) } // Get retrieves an item from the adapter based on the provided scope, query, and // cache settings. It uses the defined `GetFunc`, `ItemMapper`, and // `ListTagsFunc` to retrieve and map the item. func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if !s.hasScope(scope) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), } } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.ItemType, query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) == 0 { return nil, nil } else { return cachedItems[0], nil } } awsItem, err := s.GetFunc(ctx, s.Client, scope, query) if err != nil { err := WrapAWSError(err) if !CanRetry(err) { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) } return nil, err } item, err := s.ItemMapper(query, scope, awsItem) if err != nil { // Don't cache this as wrapping is very cheap and better to just try // again than store in memory return nil, WrapAWSError(err) } if s.ListTagsFunc != nil { item.Tags, err = s.ListTagsFunc(ctx, awsItem, s.Client) if err != nil { item.Tags = HandleTagsError(ctx, err) } } s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) return item, nil } // List Lists all available items. This is done by running the ListFunc, then // passing these results to GetFunc in order to get the details func (s *GetListAdapter[AWSItem, ClientStruct, Options]) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if !s.hasScope(scope) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), } } if s.DisableList { return []*sdp.Item{}, nil } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.ItemType, "", ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } return nil, qErr } if cacheHit { return cachedItems, nil } awsItems, err := s.ListFunc(ctx, s.Client, scope) if err != nil { err := WrapAWSError(err) if !CanRetry(err) { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) } return nil, err } items := make([]*sdp.Item, 0) hadError := false for _, awsItem := range awsItems { item, err := s.ItemMapper("", scope, awsItem) if err != nil { hadError = true continue } if s.ListTagsFunc != nil { item.Tags, err = s.ListTagsFunc(ctx, awsItem, s.Client) if err != nil { item.Tags = HandleTagsError(ctx, err) } } items = append(items, item) s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) } // Cache not-found only when no items were found AND no error occurred if len(items) == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found in scope %s", s.ItemType, scope), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, s.cacheDuration(), ck) } return items, nil } // Search Searches for AWS resources, this can be implemented either as a // generic ARN search that tries to extract the globally unique name from the // ARN and pass this to a Get request, or a custom search function that can be // used to search for items in a different, adapter-specific way func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if !s.hasScope(scope) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), } } if s.SearchFunc != nil { return s.SearchCustom(ctx, scope, query, ignoreCache) } else { return s.SearchARN(ctx, scope, query, ignoreCache) } } // Extracts the `ResourceID` and scope from the ARN, then calls `Get` with the // extracted `ResourceID` func (s *GetListAdapter[AWSItem, ClientStruct, Options]) SearchARN(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { // Parse the ARN a, err := ParseARN(query) if err != nil { return nil, WrapAWSError(err) } if a.ContainsWildcard() { // We can't handle wildcards by default so bail out return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("wildcards are not supported by adapter %v", s.Name()), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } } if arnScope := FormatScope(a.AccountID, a.Region); !s.hasScope(arnScope) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("ARN scope %v does not match request scope %v", arnScope, scope), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } } // Since this gits the Get method, and this method implements caching, we // don't need to implement it here item, err := s.Get(ctx, scope, a.ResourceID(), ignoreCache) if err != nil { return nil, WrapAWSError(err) } if item != nil { return []*sdp.Item{item}, nil } return []*sdp.Item{}, nil } // Custom search function that can be used to search for items in a different, // adapter-specific way func (s *GetListAdapter[AWSItem, ClientStruct, Options]) SearchCustom(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.ItemType, query, ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } return nil, qErr } if cacheHit { return cachedItems, nil } awsItems, err := s.SearchFunc(ctx, s.Client, scope, query) if err != nil { err = WrapAWSError(err) s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) return nil, err } items := make([]*sdp.Item, 0) hadError := false var item *sdp.Item for _, awsItem := range awsItems { item, err = s.ItemMapper(query, scope, awsItem) if err != nil { hadError = true continue } items = append(items, item) s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) } // Cache not-found only when no items were found AND no error occurred if len(items) == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found for search query '%s' in scope %s", s.ItemType, query, scope), Scope: scope, SourceName: s.Name(), ItemType: s.ItemType, ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, s.cacheDuration(), ck) } return items, nil } // Weight Returns the priority weighting of items returned by this adapter. // This is used to resolve conflicts where two adapters of the same type // return an item for a GET request. In this instance only one item can be // seen on, so the one with the higher weight value will win. func (s *GetListAdapter[AWSItem, ClientStruct, Options]) Weight() int { return 100 } ================================================ FILE: aws-source/adapters/adapterhelpers_get_list_source_test.go ================================================ package adapters import ( "context" "errors" "fmt" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "google.golang.org/protobuf/types/known/structpb" ) func TestGetListSourceType(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "foo", } if s.Type() != "foo" { t.Errorf("expected type to be foo got %v", s.Type()) } } func TestGetListSourceName(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "foo", } if s.Name() != "foo-adapter" { t.Errorf("expected type to be foo-adapter got %v", s.Name()) } } func TestGetListSourceScopes(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ AccountID: "foo", Region: "bar", } if s.Scopes()[0] != "foo.bar" { t.Errorf("expected scope to be foo.bar, got %v", s.Scopes()[0]) } } func TestGetListSourceGet(t *testing.T) { t.Run("with no errors", func(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, nil }, ItemMapper: func(query, scope string, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, ListTagsFunc: func(ctx context.Context, s1 string, s2 struct{}) (map[string]string, error) { return map[string]string{ "foo": "bar", }, nil }, cache: sdpcache.NewNoOpCache(), } item, err := s.Get(context.Background(), "12345.eu-west-2", "", false) if err != nil { t.Error(err) } if item.GetTags()["foo"] != "bar" { t.Errorf("expected tag foo to be bar, got %v", item.GetTags()["foo"]) } }) t.Run("with an error in the GetFunc", func(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", errors.New("get func error") }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, nil }, ItemMapper: func(query, scope string, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, cache: sdpcache.NewNoOpCache(), } if _, err := s.Get(context.Background(), "12345.eu-west-2", "", false); err == nil { t.Error("expected error got nil") } }) t.Run("with an error in the mapper", func(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, nil }, ItemMapper: func(query, scope string, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, errors.New("mapper error") }, cache: sdpcache.NewNoOpCache(), } if _, err := s.Get(context.Background(), "12345.eu-west-2", "", false); err == nil { t.Error("expected error got nil") } }) } func TestGetListSourceList(t *testing.T) { t.Run("with no errors", func(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, nil }, ItemMapper: func(query, scope string, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, ListTagsFunc: func(ctx context.Context, s1 string, s2 struct{}) (map[string]string, error) { return map[string]string{ "foo": "bar", }, nil }, cache: sdpcache.NewNoOpCache(), } if items, err := s.List(context.Background(), "12345.eu-west-2", false); err != nil { t.Error(err) } else { if len(items) != 2 { t.Errorf("expected 2 items, got %v", len(items)) } if items[0].GetTags()["foo"] != "bar" { t.Errorf("expected tag foo to be bar, got %v", items[0].GetTags()["foo"]) } } }) t.Run("with an error in the ListFunc", func(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, errors.New("list func error") }, ItemMapper: func(query, scope string, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, cache: sdpcache.NewNoOpCache(), } if _, err := s.List(context.Background(), "12345.eu-west-2", false); err == nil { t.Error("expected error got nil") } }) t.Run("with an error in the mapper", func(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, nil }, ItemMapper: func(query, scope string, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, errors.New("mapper error") }, cache: sdpcache.NewNoOpCache(), } if items, err := s.List(context.Background(), "12345.eu-west-2", false); err != nil { t.Error(err) } else { if len(items) != 0 { t.Errorf("expected no items, got %v", len(items)) } } }) } func TestGetListSourceSearch(t *testing.T) { t.Run("with ARN search", func(t *testing.T) { s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "person", Region: "eu-west-2", AccountID: "12345", GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { return "", nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { return []string{"", ""}, nil }, ItemMapper: func(query, scope string, awsItem string) (*sdp.Item, error) { return &sdp.Item{}, nil }, cache: sdpcache.NewNoOpCache(), } t.Run("bad ARN", func(t *testing.T) { _, err := s.Search(context.Background(), "12345.eu-west-2", "query", false) if err == nil { t.Error("expected error because the ARN was bad") } }) t.Run("good ARN but bad scope", func(t *testing.T) { _, err := s.Search(context.Background(), "12345.eu-west-2", "arn:aws:service:region:account:type/id", false) if err == nil { t.Error("expected error because the ARN had a bad scope") } }) t.Run("good ARN", func(t *testing.T) { _, err := s.Search(context.Background(), "12345.eu-west-2", "arn:aws:service:eu-west-2:12345:type/id", false) if err != nil { t.Error(err) } }) }) } func TestGetListSourceCaching(t *testing.T) { ctx := context.Background() generation := 0 s := GetListAdapter[string, struct{}, struct{}]{ ItemType: "test-type", Region: "eu-west-2", AccountID: "foo", cache: sdpcache.NewMemoryCache(), GetFunc: func(ctx context.Context, client struct{}, scope, query string) (string, error) { generation += 1 return fmt.Sprintf("%v", generation), nil }, ListFunc: func(ctx context.Context, client struct{}, scope string) ([]string, error) { generation += 1 return []string{fmt.Sprintf("%v", generation)}, nil }, SearchFunc: func(ctx context.Context, client struct{}, scope, query string) ([]string, error) { generation += 1 return []string{fmt.Sprintf("%v", generation)}, nil }, ItemMapper: func(query, scope string, output string) (*sdp.Item, error) { return &sdp.Item{ Scope: "foo.eu-west-2", Type: "test-type", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue("test-item"), "generation": structpb.NewStringValue(output), }, }, }, }, nil }, } t.Run("get", func(t *testing.T) { // get first, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } firstGen, err := first.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } // get again withCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } withCacheGen, err := withCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCacheGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCacheGen) } // get ignore cache withoutCache, err := s.Get(ctx, "foo.eu-west-2", "test-item", true) if err != nil { t.Fatal(err) } withoutCacheGen, err := withoutCache.GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if withoutCacheGen == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCacheGen) } }) t.Run("list", func(t *testing.T) { // list first, err := s.List(ctx, "foo.eu-west-2", false) if err != nil { t.Fatal(err) } firstGen, err := first[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } // list again withCache, err := s.List(ctx, "foo.eu-west-2", false) if err != nil { t.Fatal(err) } withCacheGen, err := withCache[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCacheGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCacheGen) } // list ignore cache withoutCache, err := s.List(ctx, "foo.eu-west-2", true) if err != nil { t.Fatal(err) } withoutCacheGen, err := withoutCache[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if withoutCacheGen == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCacheGen) } }) t.Run("search", func(t *testing.T) { // search first, err := s.Search(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", false) if err != nil { t.Fatal(err) } firstGen, err := first[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } // Get the result of the search getCachedItem, err := s.Get(ctx, "foo.eu-west-2", "test-item", false) if err != nil { t.Fatal(err) } // Check that we get a valid item if err := getCachedItem.Validate(); err != nil { t.Fatal(err) } // Check the generation to make sure it was actually served from the cache cachedGeneration, _ := getCachedItem.GetAttributes().Get("generation") if firstGen != cachedGeneration { t.Errorf("expected generation %v, got %v", firstGen, cachedGeneration) } // search again withCache, err := s.Search(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", false) if err != nil { t.Fatal(err) } withCacheGen, err := withCache[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if firstGen != withCacheGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withCacheGen) } // search ignore cache withoutCache, err := s.Search(ctx, "foo.eu-west-2", "arn:aws:test-type:eu-west-2:foo:test-item", true) if err != nil { t.Fatal(err) } withoutCacheGen, err := withoutCache[0].GetAttributes().Get("generation") if err != nil { t.Fatal(err) } if withoutCacheGen == firstGen { t.Errorf("with cache: expected generation %v, got %v", firstGen, withoutCacheGen) } }) } ================================================ FILE: aws-source/adapters/adapterhelpers_notfound_cache_test.go ================================================ package adapters import ( "context" "errors" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // TestGetListAdapterV2_GetNotFoundCaching tests that GetListAdapterV2 caches not-found error results func TestGetListAdapterV2_GetNotFoundCaching(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() getCalls := 0 // Mock AWS item type type MockAWSItem struct { Name string } adapter := &GetListAdapterV2[*MockInput, *MockOutput, *MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { getCalls++ // Return NOTFOUND error (typical AWS behavior) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "resource not found", Scope: scope, } }, ItemMapper: func(query *string, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call should invoke GetFunc and get error item, err := adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if item != nil { t.Errorf("Expected nil item, got %v", item) } // First call returns the error (but it's cached) if err == nil { t.Error("Expected NOTFOUND error, got nil") } if getCalls != 1 { t.Errorf("Expected 1 GetFunc call, got %d", getCalls) } // Second call should hit cache and return the cached NOTFOUND error item, err = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if item != nil { t.Errorf("Expected nil item on cache hit, got %v", item) } var qErr *sdp.QueryError if err == nil { t.Error("Expected NOTFOUND error on cache hit, got nil") } else if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("Expected NOTFOUND error on cache hit, got %v", err) } if getCalls != 1 { t.Errorf("Expected still 1 GetFunc call (cache hit), got %d", getCalls) } } // TestGetListAdapterV2_ListNotFoundCaching tests that GetListAdapterV2 caches not-found results when LIST returns 0 items func TestGetListAdapterV2_ListNotFoundCaching(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() listCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapterV2[*MockInput, *MockOutput, *MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { return nil, errors.New("should not be called in LIST test") }, InputMapperList: func(scope string) (*MockInput, error) { return &MockInput{}, nil }, ListFunc: func(ctx context.Context, client *MockClient, input *MockInput) (*MockOutput, error) { listCalls++ return &MockOutput{}, nil }, ListExtractor: func(ctx context.Context, output *MockOutput, client *MockClient) ([]*MockAWSItem, error) { // Return empty slice to simulate no items found return []*MockAWSItem{}, nil }, ItemMapper: func(query *string, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // Use test stream to collect results stream := &testQueryResultStream{} // First call should invoke ListFunc adapter.ListStream(ctx, "123456789012.us-east-1", false, stream) if len(stream.items) != 0 { t.Errorf("Expected 0 items, got %d", len(stream.items)) } if listCalls != 1 { t.Errorf("Expected 1 ListFunc call, got %d", listCalls) } // Second call should hit cache stream2 := &testQueryResultStream{} adapter.ListStream(ctx, "123456789012.us-east-1", false, stream2) if len(stream2.items) != 0 { t.Errorf("Expected 0 items on cache hit, got %d", len(stream2.items)) } // For backward compatibility, cached NOTFOUND is treated as empty result (no error) // This matches the behavior of the first call which returns empty stream with no errors if len(stream2.errors) != 0 { t.Errorf("Expected 0 errors from cache (backward compatibility), got %d", len(stream2.errors)) } if listCalls != 1 { t.Errorf("Expected still 1 ListFunc call (cache hit), got %d", listCalls) } } // TestGetListAdapter_GetNotFoundCaching tests GetListAdapter's GET not-found caching func TestGetListAdapter_GetNotFoundCaching(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() getCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapter[*MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { getCalls++ // Return NOTFOUND error (typical AWS behavior) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "resource not found", Scope: scope, } }, ItemMapper: func(query, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call returns error (which gets cached) item, err := adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if item != nil { t.Errorf("Expected nil item, got %v", item) } if err == nil { t.Error("Expected NOTFOUND error, got nil") } if getCalls != 1 { t.Errorf("Expected 1 GetFunc call, got %d", getCalls) } // Second call should hit cache and return the cached NOTFOUND error item, err = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if item != nil { t.Errorf("Expected nil item on cache hit, got %v", item) } var qErr *sdp.QueryError if err == nil { t.Error("Expected NOTFOUND error on cache hit, got nil") } else if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("Expected NOTFOUND error on cache hit, got %v", err) } if getCalls != 1 { t.Errorf("Expected still 1 GetFunc call (cache hit), got %d", getCalls) } } // TestGetListAdapter_ListNotFoundCaching tests GetListAdapter's LIST not-found caching func TestGetListAdapter_ListNotFoundCaching(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() listCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapter[*MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { return nil, errors.New("should not be called") }, ListFunc: func(ctx context.Context, client *MockClient, scope string) ([]*MockAWSItem, error) { listCalls++ return []*MockAWSItem{}, nil // Empty list }, ItemMapper: func(query, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call items, err := adapter.List(ctx, "123456789012.us-east-1", false) if len(items) != 0 { t.Errorf("Expected 0 items, got %d", len(items)) } if err != nil { t.Errorf("Expected nil error, got %v", err) } if listCalls != 1 { t.Errorf("Expected 1 ListFunc call, got %d", listCalls) } // Second call should hit cache and return empty result with nil error (backward compatibility) items2, err := adapter.List(ctx, "123456789012.us-east-1", false) // Should get empty result with nil error for backward compatibility if len(items2) != 0 { t.Errorf("Expected 0 items from cache, got %d", len(items2)) } if err != nil { t.Errorf("Expected nil error from cache (backward compat), got %v", err) } if listCalls != 1 { t.Errorf("Expected still 1 ListFunc call (cache hit), got %d", listCalls) } } // TestAlwaysGetAdapter_GetNotFoundCaching tests AlwaysGetAdapter's GET not-found caching func TestAlwaysGetAdapter_GetNotFoundCaching(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() getFuncCalls := 0 adapter := &AlwaysGetAdapter[*MockInput, *MockOutput, *MockGetInput, *MockGetOutput, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetInputMapper: func(scope, query string) *MockGetInput { return &MockGetInput{} }, GetFunc: func(ctx context.Context, client *MockClient, scope string, input *MockGetInput) (*sdp.Item, error) { getFuncCalls++ // Return NOTFOUND error (typical AWS behavior) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "resource not found", Scope: scope, } }, // Add ListFuncPaginatorBuilder to avoid validation error ListFuncPaginatorBuilder: func(client *MockClient, input *MockInput) Paginator[*MockOutput, *MockOptions] { return nil // Not used in GET test }, ListFuncOutputMapper: func(output *MockOutput, input *MockInput) ([]*MockGetInput, error) { return nil, nil // Not used in GET test }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call returns error (which gets cached) item, err := adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if item != nil { t.Errorf("Expected nil item, got %v", item) } if err == nil { t.Error("Expected NOTFOUND error, got nil") } if getFuncCalls != 1 { t.Errorf("Expected 1 GetFunc call, got %d", getFuncCalls) } // Second call should hit cache and return the cached NOTFOUND error item, err = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if item != nil { t.Errorf("Expected nil item on cache hit, got %v", item) } var qErr *sdp.QueryError if err == nil { t.Error("Expected NOTFOUND error on cache hit, got nil") } else if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("Expected NOTFOUND error on cache hit, got %v", err) } if getFuncCalls != 1 { t.Errorf("Expected still 1 GetFunc call (cache hit), got %d", getFuncCalls) } } // TestDescribeOnlyAdapter_ListNotFoundCaching tests DescribeOnlyAdapter's LIST not-found caching func TestDescribeOnlyAdapter_ListNotFoundCaching(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() describeCalls := 0 adapter := &DescribeOnlyAdapter[*MockInput, *MockOutput, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", MaxResultsPerPage: 100, // Set to avoid validation using default DescribeFunc: func(ctx context.Context, client *MockClient, input *MockInput) (*MockOutput, error) { describeCalls++ return &MockOutput{}, nil }, InputMapperGet: func(scope, query string) (*MockInput, error) { return &MockInput{}, nil }, InputMapperList: func(scope string) (*MockInput, error) { return &MockInput{}, nil }, OutputMapper: func(ctx context.Context, client *MockClient, scope string, input *MockInput, output *MockOutput) ([]*sdp.Item, error) { // Return empty slice to simulate no items found return []*sdp.Item{}, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } stream := &testQueryResultStream{} // First call adapter.ListStream(ctx, "123456789012.us-east-1", false, stream) if len(stream.items) != 0 { t.Errorf("Expected 0 items, got %d", len(stream.items)) } if describeCalls != 1 { t.Errorf("Expected 1 DescribeFunc call, got %d", describeCalls) } // Second call should hit cache stream2 := &testQueryResultStream{} adapter.ListStream(ctx, "123456789012.us-east-1", false, stream2) if len(stream2.items) != 0 { t.Errorf("Expected 0 items on cache hit, got %d", len(stream2.items)) } // For backward compatibility, cached NOTFOUND is treated as empty result (no error) // This matches the behavior of the first call which returns empty stream with no errors if len(stream2.errors) != 0 { t.Errorf("Expected 0 errors from cache (backward compatibility), got %d", len(stream2.errors)) } if describeCalls != 1 { t.Errorf("Expected still 1 DescribeFunc call (cache hit), got %d", describeCalls) } } // Mock types for testing type MockClient struct{} type MockInput struct{} type MockOutput struct{} type MockGetInput struct{} type MockGetOutput struct{} type MockOptions struct{} // testQueryResultStream is a simple implementation of QueryResultStream for testing type testQueryResultStream struct { items []*sdp.Item errors []*sdp.QueryError } func (s *testQueryResultStream) SendItem(item *sdp.Item) { s.items = append(s.items, item) } func (s *testQueryResultStream) SendError(err error) { var qErr *sdp.QueryError if errors.As(err, &qErr) { s.errors = append(s.errors, qErr) } else { s.errors = append(s.errors, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), }) } } // TestNotFoundCacheExpiry tests that not-found cache entries expire correctly func TestNotFoundCacheExpiry(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() getFuncCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapterV2[*MockInput, *MockOutput, *MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, CacheDuration: 100 * time.Millisecond, // Short duration for testing AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { getFuncCalls++ return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not found", } }, ItemMapper: func(query *string, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call - should cache not-found _, _ = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if getFuncCalls != 1 { t.Errorf("Expected 1 GetFunc call, got %d", getFuncCalls) } // Immediate second call - should hit cache _, _ = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if getFuncCalls != 1 { t.Errorf("Expected still 1 GetFunc call (cache hit), got %d", getFuncCalls) } // Wait for cache to expire time.Sleep(150 * time.Millisecond) // Third call after expiry - should invoke GetFunc again _, _ = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if getFuncCalls != 2 { t.Errorf("Expected 2 GetFunc calls (cache expired), got %d", getFuncCalls) } } // TestNotFoundCacheIgnoreCache tests that ignoreCache parameter bypasses not-found cache func TestNotFoundCacheIgnoreCache(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() getFuncCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapterV2[*MockInput, *MockOutput, *MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { getFuncCalls++ return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not found", } }, ItemMapper: func(query *string, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call with ignoreCache=false _, _ = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if getFuncCalls != 1 { t.Errorf("Expected 1 GetFunc call, got %d", getFuncCalls) } // Second call with ignoreCache=true - should bypass cache _, _ = adapter.Get(ctx, "123456789012.us-east-1", "test-query", true) if getFuncCalls != 2 { t.Errorf("Expected 2 GetFunc calls (ignore cache), got %d", getFuncCalls) } // Third call with ignoreCache=false - should still hit cache from first call _, _ = adapter.Get(ctx, "123456789012.us-east-1", "test-query", false) if getFuncCalls != 2 { t.Errorf("Expected still 2 GetFunc calls (cache hit), got %d", getFuncCalls) } } // TestNotFoundCacheDifferentQueries tests that different queries get separate cache entries func TestNotFoundCacheDifferentQueries(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() getFuncCalls := 0 queriesReceived := make(map[string]int) type MockAWSItem struct { Name string } adapter := &GetListAdapterV2[*MockInput, *MockOutput, *MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { getFuncCalls++ queriesReceived[query]++ return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not found", } }, ItemMapper: func(query *string, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { return &sdp.Item{ Type: "test-item", UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{}, Scope: scope, }, nil }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // Query for item1 _, _ = adapter.Get(ctx, "123456789012.us-east-1", "item1", false) _, _ = adapter.Get(ctx, "123456789012.us-east-1", "item1", false) // Cache hit // Query for item2 _, _ = adapter.Get(ctx, "123456789012.us-east-1", "item2", false) _, _ = adapter.Get(ctx, "123456789012.us-east-1", "item2", false) // Cache hit // Should have called GetFunc once per unique query if getFuncCalls != 2 { t.Errorf("Expected 2 GetFunc calls (1 per unique query), got %d", getFuncCalls) } if queriesReceived["item1"] != 1 { t.Errorf("Expected 1 call for item1, got %d", queriesReceived["item1"]) } if queriesReceived["item2"] != 1 { t.Errorf("Expected 1 call for item2, got %d", queriesReceived["item2"]) } } // TestGetListAdapter_ListItemMapperErrorNoNotFoundCache tests that when ListFunc returns items // but ItemMapper fails for all of them, we don't incorrectly cache NOTFOUND. Items actually exist // but couldn't be mapped, so NOTFOUND should not be cached. func TestGetListAdapter_ListItemMapperErrorNoNotFoundCache(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() listCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapter[*MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { return nil, errors.New("should not be called in LIST test") }, ListFunc: func(ctx context.Context, client *MockClient, scope string) ([]*MockAWSItem, error) { listCalls++ // Return items that exist return []*MockAWSItem{ {Name: "item1"}, {Name: "item2"}, }, nil }, ItemMapper: func(query, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { // Simulate mapping failure for all items - this should NOT result in NOTFOUND caching return nil, errors.New("mapping failed") }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call - ItemMapper fails for all items, should NOT cache NOTFOUND items1, err1 := adapter.List(ctx, "123456789012.us-east-1", false) if len(items1) != 0 { t.Errorf("Expected 0 items (all mapping failed), got %d", len(items1)) } if err1 != nil { t.Errorf("Expected nil error (errors are silently ignored via continue), got %v", err1) } if listCalls != 1 { t.Errorf("Expected 1 ListFunc call, got %d", listCalls) } // Second call - should NOT hit cache (NOTFOUND was not cached), should try again items2, err2 := adapter.List(ctx, "123456789012.us-east-1", false) if listCalls != 2 { t.Errorf("Expected 2 ListFunc calls (no cache hit because NOTFOUND was not cached), got %d", listCalls) } if len(items2) != 0 { t.Errorf("Expected 0 items, got %d", len(items2)) } if err2 != nil { t.Errorf("Expected nil error, got %v", err2) } } // TestGetListAdapter_SearchCustomItemMapperErrorNoNotFoundCache tests that when SearchFunc returns items // but ItemMapper fails for all of them, we don't incorrectly cache NOTFOUND. Items actually exist // but couldn't be mapped, so NOTFOUND should not be cached. func TestGetListAdapter_SearchCustomItemMapperErrorNoNotFoundCache(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() searchCalls := 0 type MockAWSItem struct { Name string } adapter := &GetListAdapter[*MockAWSItem, *MockClient, *MockOptions]{ ItemType: "test-item", cache: cache, AccountID: "123456789012", Region: "us-east-1", GetFunc: func(ctx context.Context, client *MockClient, scope string, query string) (*MockAWSItem, error) { return nil, errors.New("should not be called in SEARCH test") }, ListFunc: func(ctx context.Context, client *MockClient, scope string) ([]*MockAWSItem, error) { return nil, errors.New("should not be called in SEARCH test") }, SearchFunc: func(ctx context.Context, client *MockClient, scope string, query string) ([]*MockAWSItem, error) { searchCalls++ // Return items that exist return []*MockAWSItem{ {Name: "item1"}, {Name: "item2"}, }, nil }, ItemMapper: func(query, scope string, awsItem *MockAWSItem) (*sdp.Item, error) { // Simulate mapping failure for all items - this should NOT result in NOTFOUND caching return nil, errors.New("mapping failed") }, AdapterMetadata: &sdp.AdapterMetadata{ Type: "test-item", DescriptiveName: "Test Item", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a test item", ListDescription: "List all test items", }, }, } // First call - ItemMapper fails for all items, should NOT cache NOTFOUND items1, err1 := adapter.SearchCustom(ctx, "123456789012.us-east-1", "test-query", false) if len(items1) != 0 { t.Errorf("Expected 0 items (all mapping failed), got %d", len(items1)) } if err1 != nil { t.Errorf("Expected nil error (errors are silently ignored via continue), got %v", err1) } if searchCalls != 1 { t.Errorf("Expected 1 SearchFunc call, got %d", searchCalls) } // Second call - should NOT hit cache (NOTFOUND was not cached), should try again items2, err2 := adapter.SearchCustom(ctx, "123456789012.us-east-1", "test-query", false) if searchCalls != 2 { t.Errorf("Expected 2 SearchFunc calls (no cache hit because NOTFOUND was not cached), got %d", searchCalls) } if len(items2) != 0 { t.Errorf("Expected 0 items, got %d", len(items2)) } if err2 != nil { t.Errorf("Expected nil error, got %v", err2) } } ================================================ FILE: aws-source/adapters/adapterhelpers_shared_tests.go ================================================ package adapters import ( "context" "fmt" "log" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" ) type Subnet struct { ID *string CIDR string AvailabilityZone string } type VPCConfig struct { // These are populated after Fetching ID *string // Subnets in this VPC Subnets []*Subnet cleanupFunctions []func() } var purposeKey = "Purpose" var nameKey = "Name" var tagValue = "automated-testing-" + time.Now().Format("2006-01-02T15:04:05.000Z") var TestTags = []types.Tag{ { Key: &purposeKey, Value: &tagValue, }, { Key: &nameKey, Value: &tagValue, }, } func (v *VPCConfig) Cleanup(f func()) { v.cleanupFunctions = append(v.cleanupFunctions, f) } func (v *VPCConfig) RunCleanup() { for len(v.cleanupFunctions) > 0 { n := len(v.cleanupFunctions) - 1 // Top element v.cleanupFunctions[n]() v.cleanupFunctions = v.cleanupFunctions[:n] // Pop } } // Fetch Fetches the VPC and subnets and registers cleanup actions for them func (v *VPCConfig) Fetch(client *ec2.Client) error { // manually configured VPC in eu-west-2 vpcid := "vpc-061f0bb58acec88ad" v.ID = &vpcid // vpcOutput.Vpc.VpcId filterName := "vpc-id" subnetOutput, err := client.DescribeSubnets( context.Background(), &ec2.DescribeSubnetsInput{ Filters: []types.Filter{ { Name: &filterName, Values: []string{vpcid}, }, }, }, ) if err != nil { return err } for _, subnet := range subnetOutput.Subnets { v.Subnets = append(v.Subnets, &Subnet{ ID: subnet.SubnetId, CIDR: *subnet.CidrBlock, AvailabilityZone: *subnet.AvailabilityZone, }) } return nil } // CreateGateway Creates a new internet gateway for the duration of the test to save 40$ per month vs running it 24/7 func (v *VPCConfig) CreateGateway(client *ec2.Client) error { var err error // Create internet gateway and assign to VPC var gatewayOutput *ec2.CreateInternetGatewayOutput gatewayOutput, err = client.CreateInternetGateway( context.Background(), &ec2.CreateInternetGatewayInput{ TagSpecifications: []types.TagSpecification{ { ResourceType: types.ResourceTypeInternetGateway, Tags: TestTags, }, }, }, ) if err != nil { return err } internetGatewayId := gatewayOutput.InternetGateway.InternetGatewayId v.Cleanup(func() { del := func() error { _, err := client.DeleteInternetGateway( context.Background(), &ec2.DeleteInternetGatewayInput{ InternetGatewayId: internetGatewayId, }, ) return err } err := retry(10, time.Second, del) if err != nil { log.Println(err) } }) _, err = client.AttachInternetGateway( context.Background(), &ec2.AttachInternetGatewayInput{ InternetGatewayId: internetGatewayId, VpcId: v.ID, }, ) if err != nil { return err } v.Cleanup(func() { del := func() error { _, err := client.DetachInternetGateway( context.Background(), &ec2.DetachInternetGatewayInput{ InternetGatewayId: internetGatewayId, VpcId: v.ID, }, ) return err } err := retry(10, time.Second, del) if err != nil { log.Println(err) } }) return nil } func retry(attempts int, sleep time.Duration, f func() error) (err error) { for i := range attempts { if i > 0 { time.Sleep(sleep) sleep *= 2 } err = f() if err == nil { return nil } } return fmt.Errorf("after %d attempts, last error: %w", attempts, err) } type QueryTest struct { ExpectedType string ExpectedMethod sdp.QueryMethod ExpectedQuery string ExpectedScope string } type QueryTests []QueryTest func (i QueryTests) Execute(t *testing.T, item *sdp.Item) { for _, test := range i { var found bool // TODO(LIQs): update this to receive and evaluate edges instead of linked item queries for _, lir := range item.GetLinkedItemQueries() { if lirMatches(test, lir.GetQuery()) { found = true break } } if !found { t.Errorf("could not find linked item request in %v requests.\nType: %v\nQuery: %v\nScope: %v", len(item.GetLinkedItemQueries()), test.ExpectedType, test.ExpectedQuery, test.ExpectedScope) } } } func lirMatches(test QueryTest, req *sdp.Query) bool { return (test.ExpectedMethod == req.GetMethod() && test.ExpectedQuery == req.GetQuery() && test.ExpectedScope == req.GetScope() && test.ExpectedType == req.GetType()) } // CheckQuery Checks that an item request matches the expected params func CheckQuery(t *testing.T, item *sdp.Query, itemName string, expectedType string, expectedQuery string, expectedScope string) { if item.GetType() != expectedType { t.Errorf("%s.Type '%v' != '%v'", itemName, item.GetType(), expectedType) } if item.GetMethod() != sdp.QueryMethod_GET { t.Errorf("%s.Method '%v' != '%v'", itemName, item.GetMethod(), sdp.QueryMethod_GET) } if item.GetQuery() != expectedQuery { t.Errorf("%s.Query '%v' != '%v'", itemName, item.GetQuery(), expectedQuery) } if item.GetScope() != expectedScope { t.Errorf("%s.Scope '%v' != '%v'", itemName, item.GetScope(), expectedScope) } } ================================================ FILE: aws-source/adapters/adapterhelpers_sources.go ================================================ package adapters import "context" const DefaultMaxResultsPerPage = 100 // These `any` types exist just for documentation // ClientStructType represents the AWS API client that actions are run against. This is // usually a struct that comes from the `New()` or `NewFromConfig()` functions // in the relevant package e.g. // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/eks@v1.26.0#Client type ClientStructType any // InputType is the type of data that will be sent to the a List/Describe // function. This is typically a struct ending with the word Input such as: // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/eks@v1.26.0#DescribeClusterInput type InputType any // OutputType is the type of output to expect from the List/Describe function, // this is usually named the same as the input type, but with `Output` on the // end e.g. // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/eks@v1.26.0#DescribeClusterOutput type OutputType any // OptionsType The options struct that is passed to the client when it created, // and also to `optFns` when getting more pages: // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/eks@v1.26.0#ListClustersPaginator.NextPage type OptionsType any // AWSItemType A struct that represents the item in the AWS API e.g. // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/route53@v1.25.2/types#HostedZone type AWSItemType any // Paginator Represents an AWS API Paginator: // https://aws.github.io/aws-sdk-go-v2/docs/making-requests/#using-paginators // The Output param should be the type of output that this specific paginator // returns e.g. *ec2.DescribeInstancesOutput type Paginator[Output OutputType, Options OptionsType] interface { HasMorePages() bool NextPage(context.Context, ...func(Options)) (Output, error) } ================================================ FILE: aws-source/adapters/adapterhelpers_util.go ================================================ package adapters import ( "context" "errors" "fmt" "net/http" "os" "regexp" "slices" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/arn" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sts" awsHttp "github.com/aws/smithy-go/transport/http" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // FormatScope Formats an account ID and region into the corresponding Overmind // scope. This will be in the format {accountID}.{region}. If both accountID and // region are empty, returns sdp.WILDCARD (for resources like S3 buckets that // don't include account/region in their ARNs). func FormatScope(accountID, region string) string { if accountID == "" && region == "" { return sdp.WILDCARD } if region == "" { return accountID } return fmt.Sprintf("%v.%v", accountID, region) } // ParseScope Parses a scope and returns the account id and region func ParseScope(scope string) (string, string, error) { sections := strings.Split(scope, ".") if len(sections) != 2 { return "", "", fmt.Errorf("could not split scope '%v' into 2 sections", scope) } return sections[0], sections[1], nil } // Returns whether or not it makes sense to retry the error. This can be used to // decide whether we should cache the error or not. Errors such as the item // being not found, or the scope not existing should not be retried for example func CanRetry(err *sdp.QueryError) bool { switch err.GetErrorType() { //nolint:exhaustive case sdp.QueryError_NOTFOUND, sdp.QueryError_NOSCOPE: return false default: return true } } // A parsed representation of the parts of the ARN that Overmind needs to care // about // // Format example: // // arn:partition:service:region:account-id:resource-type:resource-id type ARN struct { arn.ARN } // ResourceID The ID of the resource, this is everything after the type and // might also include a version or other components depending on the service // e.g. ecs-template-ecs-demo-app:1 would be the ResourceID for // "arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1" func (a *ARN) ResourceID() string { // Find the first separator separatorLocation := strings.IndexFunc(a.Resource, func(r rune) bool { return r == '/' || r == ':' }) // Remove the first field since this is the type, then keep the rest return a.Resource[separatorLocation+1:] } // Type The type of the resource, this is everything after the service and // before the resource ID // // e.g. "task-definition" would be the Type for // "arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1" func (a *ARN) Type() string { // Find the first separator separatorLocation := strings.IndexFunc(a.Resource, func(r rune) bool { return r == '/' || r == ':' }) if separatorLocation == -1 { return a.Resource } // Keep the first field since this is the type, then remove the rest return a.Resource[:separatorLocation] } // Matches checks if the IAM wildcards included in the ARN match another ARN // using the logic that IAM uses. For example if the ARN is // "arn:aws:s3:::amzn-s3-demo-bucket/*" then it will match // "arn:aws:s3:::amzn-s3-demo-bucket/thing" but not // "arn:aws:s3:::some-other-bucket/object" func (a *ARN) IAMWildcardMatches(arn string) bool { targetARN, err := ParseARN(arn) if err != nil { return false } // You can't use a wildcard in the service segment if a.Service != targetARN.Service { return false } // Convert * wildcard to regex pattern and escape other special chars convertToPattern := func(s string) string { // Escape regex special chars except * and ? special := []string{".", "+", "^", "$", "(", ")", "[", "]", "{", "}", "|"} escaped := s for _, ch := range special { escaped = strings.ReplaceAll(escaped, ch, "\\"+ch) } // Convert * to .* and ? to . for regex escaped = strings.ReplaceAll(escaped, "*", ".*") escaped = strings.ReplaceAll(escaped, "?", ".") return "^" + escaped + "$" } // Check each component using pattern matching components := []struct { pattern string target string }{ {a.Region, targetARN.Region}, {a.AccountID, targetARN.AccountID}, {a.Resource, targetARN.Resource}, } for _, c := range components { pattern := convertToPattern(c.pattern) matched, err := regexp.MatchString(pattern, c.target) if err != nil || !matched { return false } } return true } func (a *ARN) ContainsWildcard() bool { possibleWildcardLocations := a.Partition + a.Region + a.AccountID + a.Resource return strings.Contains(possibleWildcardLocations, "*") || strings.Contains(possibleWildcardLocations, "?") } // ParseARN Parses an ARN and tries to determine the resource ID from it. The // logic is that the resource ID will be the last component when separated by // slashes or colons: https://devopscube.com/aws-arn-guide/ func ParseARN(arnString string) (*ARN, error) { a, err := arn.Parse(arnString) if err != nil { return nil, err } return &ARN{ ARN: a, }, nil } // awsPartitionDNSSuffixes maps AWS partition names to their DNS suffixes. // This is the single source of truth for all AWS partition DNS suffixes. // See: https://docs.aws.amazon.com/general/latest/gr/rande.html var awsPartitionDNSSuffixes = map[string]string{ "aws": "amazonaws.com", "aws-us-gov": "amazonaws.com", "aws-cn": "amazonaws.com.cn", "aws-iso": "c2s.ic.gov", "aws-iso-b": "sc2s.sgov.gov", "aws-eu": "amazonaws.eu", } // GetPartitionDNSSuffix returns the DNS suffix for a given AWS partition. // This is used to construct service URLs that work across all AWS partitions. func GetPartitionDNSSuffix(partition string) string { if suffix, ok := awsPartitionDNSSuffixes[partition]; ok { return suffix } return "amazonaws.com" // Default to commercial partition } // GetAllAWSPartitionDNSSuffixes returns all known AWS partition DNS suffixes. // This is useful for checking if a string (like a service principal) belongs // to any AWS partition. func GetAllAWSPartitionDNSSuffixes() []string { // Use a map to deduplicate (aws and aws-us-gov share the same suffix) seen := make(map[string]bool) suffixes := make([]string, 0, len(awsPartitionDNSSuffixes)) for _, suffix := range awsPartitionDNSSuffixes { if !seen[suffix] { seen[suffix] = true suffixes = append(suffixes, suffix) } } return suffixes } // WrapAWSError Wraps an AWS error in the appropriate SDP error func WrapAWSError(err error) *sdp.QueryError { var responseErr *awsHttp.ResponseError if errors.As(err, &responseErr) { // If the input is bad, access is denied, or the thing wasn't found then // we should assume that it is not exist for this adapter if slices.Contains([]int{400, 403, 404}, responseErr.HTTPStatusCode()) { return &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), } } } return sdp.NewQueryError(err) } // Adds an event to the span to note the error, and returns a set of tags that // return a standardised set of tags that contains `errorGettingTags` and // `error` func HandleTagsError(ctx context.Context, err error) map[string]string { if err == nil { return nil } // Attach an event in the span span := trace.SpanFromContext(ctx) span.AddEvent("Error getting tags", trace.WithAttributes( attribute.String("error", err.Error()), )) return map[string]string{ "errorGettingTags": "true", "error": err.Error(), } } // E2ETest A struct that runs end to end tests on a fully configured adapters. // These tests aren't particularly detailed, but they are designed to ensure // that there aren't any really obvious error when it's actually configured with // AWS credentials type E2ETest struct { // The adapter to test Adapter discovery.Adapter // A search query that should return > 0 results GoodSearchQuery *string // Skips get tests SkipGet bool // Skips list tests SkipList bool // Skips checking that a know bad get query returns a NOTFOUND error SkipNotFoundCheck bool // A timeout used for all tests Timeout time.Duration } // The purpose of these tests is mostly to give an entrypoint for debugging in a // real environment func (e E2ETest) Run(t *testing.T) { t.Parallel() t.Helper() type Validator interface { Validate() error } if v, ok := e.Adapter.(Validator); ok { if err := v.Validate(); err != nil { t.Fatalf("adapter failed validation: %v", err) } } // Determine the scope so that we can use this for all queries scopes := e.Adapter.Scopes() if len(scopes) == 0 { t.Fatalf("some scopes, got %v", len(scopes)) } scope := scopes[0] t.Run(fmt.Sprintf("Adapter: %v", e.Adapter.Name()), func(t *testing.T) { if e.GoodSearchQuery != nil { t.Run(fmt.Sprintf("Good search query: %v", e.GoodSearchQuery), func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), e.Timeout) defer cancel() var items []*sdp.Item var err error if searchSrc, ok := e.Adapter.(discovery.SearchableAdapter); ok { items, err = searchSrc.Search(ctx, scope, *e.GoodSearchQuery, false) } else if streamSrc, ok := e.Adapter.(discovery.SearchStreamableAdapter); ok { stream := discovery.NewRecordingQueryResultStream() streamSrc.SearchStream(context.Background(), scope, *e.GoodSearchQuery, false, stream) if len(stream.GetErrors()) > 0 { err = stream.GetErrors()[0] } items = stream.GetItems() } else { t.Skip("adapter is not searchable or streamable") } if err != nil { t.Error(err) } if len(items) == 0 { t.Error("no items returned") } for _, item := range items { if err = item.Validate(); err != nil { t.Error(err) } if item.GetType() != e.Adapter.Type() { t.Errorf("mismatched item type \"%v\" and adapter type \"%v\"", item.GetType(), e.Adapter.Type()) } } }) } t.Run("List query", func(t *testing.T) { if e.SkipList { t.Skip("list tests deliberately skipped") } var items []*sdp.Item errs := make([]error, 0) ctx, cancel := context.WithTimeout(context.Background(), e.Timeout) defer cancel() if streamingAdapter, ok := e.Adapter.(discovery.ListStreamableAdapter); ok { stream := discovery.NewRecordingQueryResultStream() streamingAdapter.ListStream(context.Background(), scope, false, stream) items = stream.GetItems() errs = stream.GetErrors() } else if listableAdapter, ok := e.Adapter.(discovery.ListableAdapter); ok { var err error items, err = listableAdapter.List(ctx, scope, false) if err != nil { errs = append(errs, err) } } else { t.Skip("adapter is not listable or streamable") } allNames := make(map[string]bool) for _, err := range errs { t.Error(err) } for _, item := range items { if _, exists := allNames[item.UniqueAttributeValue()]; exists { t.Errorf("duplicate item found: %v", item.UniqueAttributeValue()) } else { allNames[item.UniqueAttributeValue()] = true } if err := item.Validate(); err != nil { t.Error(err) } if item.GetType() != e.Adapter.Type() { t.Errorf("mismatched item type \"%v\" and adapter type \"%v\"", item.GetType(), e.Adapter.Type()) } } if len(items) > 0 { // Do a get for a known good item query := items[0].UniqueAttributeValue() t.Run(fmt.Sprintf("Good get query: %v", query), func(t *testing.T) { if e.SkipGet { t.Skip("get tests deliberately skipped") } ctx, cancel := context.WithTimeout(context.Background(), e.Timeout) defer cancel() item, err := e.Adapter.Get(ctx, scope, query, false) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } if item.GetType() != e.Adapter.Type() { t.Errorf("mismatched item type \"%v\" and adapter type \"%v\"", item.GetType(), e.Adapter.Type()) } }) } }) t.Run("bad get query", func(t *testing.T) { if e.SkipGet { t.Skip("get tests deliberately skipped") } ctx, cancel := context.WithTimeout(context.Background(), e.Timeout) defer cancel() _, err := e.Adapter.Get(ctx, scope, "this is a known bad get query", false) if err == nil { t.Error("expected error, got nil") } if !e.SkipNotFoundCheck { // Make sure the error is an SDP error var sdpErr *sdp.QueryError if errors.As(err, &sdpErr) { if sdpErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected error to be NOTFOUND, got %v\nError: %v", sdpErr.GetErrorType().String(), sdpErr.GetErrorString()) } } else { t.Errorf("Error (%T) was not (*sdp.QueryError)", err) } } }) }) } // GetAutoConfig Uses automatic local config (i.e. `aws configure`) to get an // AWS config object, AWS account ID and region. Skips the tests if this is // unavailable func GetAutoConfig(t *testing.T) (aws.Config, string, string) { t.Helper() config, err := config.LoadDefaultConfig(context.Background()) if err != nil { rawCIString := os.Getenv("CI") if strings.EqualFold(rawCIString, "true") { // These tests were always just really simple smoke tests that relied on data being already populated in AWS. // They were just a good way to check the shape of the data coming back during development. t.Skip("Skipping test because no AWS credentials are available in CI environment. They are for during development ONLY.") } else { t.Fatalf("Failed to load default config: %v", err) } } // Add OTel instrumentation config.HTTPClient = &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport), } stsClient := sts.NewFromConfig(config) var callerID *sts.GetCallerIdentityOutput callerID, err = stsClient.GetCallerIdentity(context.Background(), &sts.GetCallerIdentityInput{}) if err != nil { t.Fatalf("Failed to get caller identity, for config: %+v. %v", config, err) } return config, *callerID.Account, config.Region } // Converts an interface to SDP attributes using the `sdp.ToAttributesSorted` // function, and also allows the user to exclude certain top-level fields from // the resulting attributes func ToAttributesWithExclude(i any, exclusions ...string) (*sdp.ItemAttributes, error) { attrs, err := sdp.ToAttributesViaJson(i) if err != nil { return nil, err } for _, exclusion := range exclusions { if s := attrs.GetAttrStruct(); s != nil { delete(s.GetFields(), exclusion) } } return attrs, nil } ================================================ FILE: aws-source/adapters/adapterhelpers_util_test.go ================================================ package adapters import ( "testing" ) func TestParseARN(t *testing.T) { t.Run("arn:partition:service:region:account-id:resource-type:resource-id", func(t *testing.T) { arn := "arn:partition:service:region:account-id:resource-type:resource-id" a, err := ParseARN(arn) if err != nil { t.Error(err) } if a.AccountID != "account-id" { t.Errorf("expected account ID to be account-id, got %v", a.AccountID) } if a.Region != "region" { t.Errorf("expected region to be region, got %v", a.Region) } if a.ResourceID() != "resource-id" { t.Errorf("expected resource ID to be resource-id, got %v", a.ResourceID()) } if a.Service != "service" { t.Errorf("expected service to be service, got %v", a.Service) } }) t.Run("arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1", func(t *testing.T) { arn := "arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1" a, err := ParseARN(arn) if err != nil { t.Error(err) } if a.AccountID != "052392120703" { t.Errorf("expected account ID to be 052392120703, got %v", a.AccountID) } if a.Region != "eu-west-1" { t.Errorf("expected region to be eu-west-1, got %v", a.Region) } if a.Service != "ecs" { t.Errorf("expected service to be ecs, got %v", a.Service) } if a.Resource != "task-definition/ecs-template-ecs-demo-app:1" { t.Errorf("expected resource ID to be task-definition/ecs-template-ecs-demo-app:1, got %v", a.ResourceID()) } if a.ResourceID() != "ecs-template-ecs-demo-app:1" { t.Errorf("expected ResourceID to be ecs-template-ecs-demo-app:1, got %v", a.ResourceID()) } }) t.Run("arn:aws:ec2:us-east-1:4575734578134:instance/i-054dsfg34gdsfg38", func(t *testing.T) { arn := "arn:aws:ec2:us-east-1:4575734578134:instance/i-054dsfg34gdsfg38" a, err := ParseARN(arn) if err != nil { t.Error(err) } if a.AccountID != "4575734578134" { t.Errorf("expected account ID to be 4575734578134, got %v", a.AccountID) } if a.Region != "us-east-1" { t.Errorf("expected account ID to be us-east-1, got %v", a.Region) } if a.ResourceID() != "i-054dsfg34gdsfg38" { t.Errorf("expected account ID to be i-054dsfg34gdsfg38, got %v", a.ResourceID()) } }) t.Run("arn:aws:eks:eu-west-2:944651592624:nodegroup/dogfood/intel-20230616142016591700000005/6ec4624a-05ef-bdad-e69a-fe9832885421", func(t *testing.T) { arn := "arn:aws:eks:eu-west-2:944651592624:nodegroup/dogfood/intel-20230616142016591700000005/6ec4624a-05ef-bdad-e69a-fe9832885421" a, err := ParseARN(arn) if err != nil { t.Error(err) } if a.AccountID != "944651592624" { t.Errorf("expected account ID to be 944651592624, got %v", a.AccountID) } if a.Region != "eu-west-2" { t.Errorf("expected account ID to be eu-west-2, got %v", a.Region) } if a.ResourceID() != "dogfood/intel-20230616142016591700000005/6ec4624a-05ef-bdad-e69a-fe9832885421" { t.Errorf("expected account ID to be dogfood/intel-20230616142016591700000005/6ec4624a-05ef-bdad-e69a-fe9832885421, got %v", a.ResourceID()) } }) t.Run("arn:aws:iam::942836531449:policy/OvermindReadonly", func(t *testing.T) { arn := "arn:aws:iam::942836531449:policy/OvermindReadonly" a, err := ParseARN(arn) if err != nil { t.Error(err) } if a.ResourceID() != "OvermindReadonly" { t.Errorf("expected account ID to be OvermindReadonly, got %v", a.ResourceID()) } }) t.Run("arn:aws:elasticloadbalancing:eu-west-2:540044833068:targetgroup/lambda-rvaaio9n3auuhnvvvjmp/6f23de9c63bd4653", func(t *testing.T) { arn := "arn:aws:elasticloadbalancing:eu-west-2:540044833068:targetgroup/lambda-rvaaio9n3auuhnvvvjmp/6f23de9c63bd4653" a, err := ParseARN(arn) if err != nil { t.Error(err) } if a.Type() != "targetgroup" { t.Errorf("expected type to be targetgroup, got %v", a.Type()) } }) } func TestIAMWildcardMatches(t *testing.T) { tests := []struct { Name string ARN string ShouldMatch []string ShouldNotMatch []string }{ { Name: "ARN with no wildcards", ARN: "arn:aws:iam::123456789:user/Bob", ShouldMatch: []string{ "arn:aws:iam::123456789:user/Bob", }, ShouldNotMatch: []string{ "arn:aws:iam::123456789:user/Alice", "arn:aws:iam::123456789:role/Bob", "arn:aws:iam::123456789:role/Alice", }, }, { Name: "Complex multi-wildcard ARN", // The asterisk (*) character can expand to replace everything // within a segment, including characters like a forward slash (/) // that may otherwise appear to be a delimiter within a given // service namespace. For example, consider the following Amazon S3 // ARN as the same wildcard expansion logic applies to all services. ARN: "arn:aws:s3:::amzn-s3-demo-bucket/*/test/*", // The wildcards in the ARN apply to all of the following objects in // the bucket, not only the first object listed. ShouldMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1/test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/test/3/object.jpg ", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/3/test/4/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1///test///object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/test/.jpg", "arn:aws:s3:::amzn-s3-demo-bucket//test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/test/", }, // Consider the last two objects in the previous list. An Amazon S3 // object name can begin or end with the conventional delimiter // forward slash (/) character. While / works as a delimiter, there // is no specific significance when this character is used within a // resource ARN. It is treated the same as any other valid // character. The ARN would not match the following objects: ShouldNotMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1-test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/test.jpg", }, }, { Name: "* at the end", ARN: "arn:aws:s3:::amzn-s3-demo-bucket/*", ShouldMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1/test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/test/3/object.jpg ", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/3/test/4/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1///test///object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/test/.jpg", "arn:aws:s3:::amzn-s3-demo-bucket//test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/test/", "arn:aws:s3:::amzn-s3-demo-bucket/1-test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/test/object.jpg", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/test.jpg", }, }, { Name: "ARN using a ? wildcard", ARN: "arn:aws:s3:::amzn-s3-demo-bucket/??", ShouldMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/11", "arn:aws:s3:::amzn-s3-demo-bucket/ab", "arn:aws:s3:::amzn-s3-demo-bucket///", }, ShouldNotMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1/2", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/3", }, }, { Name: "ARN using a ? wildcard in the middle", ARN: "arn:aws:s3:::amzn-s3-demo-bucket/1?/2", ShouldMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1a/2", "arn:aws:s3:::amzn-s3-demo-bucket/1b/2", }, ShouldNotMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1/2", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/3", }, }, { Name: "ARN using a ? and * wildcard", ARN: "arn:aws:s3:::amzn-s3-demo-bucket/1?/2*", ShouldMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1a/234567890", "arn:aws:s3:::amzn-s3-demo-bucket/1b/2c", }, ShouldNotMatch: []string{ "arn:aws:s3:::amzn-s3-demo-bucket/1/2", "arn:aws:s3:::amzn-s3-demo-bucket/1/2/3", }, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { a, err := ParseARN(test.ARN) if err != nil { t.Fatal(err) } for _, match := range test.ShouldMatch { if !a.IAMWildcardMatches(match) { t.Errorf("expected %v to match %v", a.String(), match) } } for _, match := range test.ShouldNotMatch { if a.IAMWildcardMatches(match) { t.Errorf("expected %v to not match %v", a.String(), match) } } }) } } func TestGetPartitionDNSSuffix(t *testing.T) { tests := []struct { name string partition string expected string }{ { name: "aws partition", partition: "aws", expected: "amazonaws.com", }, { name: "aws-cn partition", partition: "aws-cn", expected: "amazonaws.com.cn", }, { name: "aws-us-gov partition", partition: "aws-us-gov", expected: "amazonaws.com", }, { name: "aws-iso partition", partition: "aws-iso", expected: "c2s.ic.gov", }, { name: "aws-iso-b partition", partition: "aws-iso-b", expected: "sc2s.sgov.gov", }, { name: "aws-eu partition", partition: "aws-eu", expected: "amazonaws.eu", }, { name: "unknown partition defaults to amazonaws.com", partition: "unknown-partition", expected: "amazonaws.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := GetPartitionDNSSuffix(tt.partition) if result != tt.expected { t.Errorf("GetPartitionDNSSuffix(%q) = %q, want %q", tt.partition, result, tt.expected) } }) } } ================================================ FILE: aws-source/adapters/apigateway-api-key.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // convertGetApiKeyOutputToApiKey converts a GetApiKeyOutput to an ApiKey func convertGetApiKeyOutputToApiKey(output *apigateway.GetApiKeyOutput) *types.ApiKey { return &types.ApiKey{ Id: output.Id, Name: output.Name, Enabled: output.Enabled, CreatedDate: output.CreatedDate, LastUpdatedDate: output.LastUpdatedDate, StageKeys: output.StageKeys, Tags: output.Tags, } } func apiKeyListFunc(ctx context.Context, client *apigateway.Client, _ string) ([]*types.ApiKey, error) { out, err := client.GetApiKeys(ctx, &apigateway.GetApiKeysInput{}) if err != nil { return nil, err } var items []*types.ApiKey for _, apiKey := range out.Items { items = append(items, &apiKey) } return items, nil } func apiKeyOutputMapper(scope string, awsItem *types.ApiKey) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "apigateway-api-key", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, Tags: awsItem.Tags, } for _, key := range awsItem.StageKeys { // {restApiId}/{stage} if sections := strings.Split(key, "/"); len(sections) == 2 { restAPIID := sections[0] if restAPIID != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-rest-api", Method: sdp.QueryMethod_GET, Query: restAPIID, Scope: scope, }, }) } } } return &item, nil } func NewAPIGatewayApiKeyAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.ApiKey, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.ApiKey, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-api-key", Client: client, AccountID: accountID, Region: region, AdapterMetadata: apiKeyAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.ApiKey, error) { out, err := client.GetApiKey(ctx, &apigateway.GetApiKeyInput{ ApiKey: &query, }) if err != nil { return nil, err } return convertGetApiKeyOutputToApiKey(out), nil }, ListFunc: apiKeyListFunc, SearchFunc: func(ctx context.Context, client *apigateway.Client, scope string, query string) ([]*types.ApiKey, error) { out, err := client.GetApiKeys(ctx, &apigateway.GetApiKeysInput{ NameQuery: &query, }) if err != nil { return nil, err } var items []*types.ApiKey for _, apiKey := range out.Items { items = append(items, &apiKey) } return items, nil }, ItemMapper: func(_, scope string, awsItem *types.ApiKey) (*sdp.Item, error) { return apiKeyOutputMapper(scope, awsItem) }, } } var apiKeyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-api-key", DescriptiveName: "API Key", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an API Key by ID", ListDescription: "List all API Keys", SearchDescription: "Search for API Keys by their name", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_api_key.id"}, }, }) ================================================ FILE: aws-source/adapters/apigateway-api-key_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestApiKeyOutputMapper(t *testing.T) { awsItem := &types.ApiKey{ Id: aws.String("api-key-id"), Name: aws.String("api-key-name"), Enabled: true, CreatedDate: aws.Time(time.Now()), LastUpdatedDate: aws.Time(time.Now()), StageKeys: []string{"rest-api-id/stage"}, Tags: map[string]string{"key": "value"}, } item, err := apiKeyOutputMapper("scope", awsItem) if err != nil { t.Fatalf("unexpected error: %v", err) } if err := item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "apigateway-rest-api", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rest-api-id", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayApiKeyAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayApiKeyAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-authorizer.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // convertGetAuthorizerOutputToAuthorizer converts a GetAuthorizerOutput to an Authorizer func convertGetAuthorizerOutputToAuthorizer(output *apigateway.GetAuthorizerOutput) *types.Authorizer { return &types.Authorizer{ Id: output.Id, Name: output.Name, Type: output.Type, ProviderARNs: output.ProviderARNs, AuthType: output.AuthType, AuthorizerUri: output.AuthorizerUri, AuthorizerCredentials: output.AuthorizerCredentials, IdentitySource: output.IdentitySource, IdentityValidationExpression: output.IdentityValidationExpression, AuthorizerResultTtlInSeconds: output.AuthorizerResultTtlInSeconds, } } func authorizerOutputMapper(query, scope string, awsItem *types.Authorizer) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "apigateway-authorizer", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-rest-api", Method: sdp.QueryMethod_GET, Query: strings.Split(query, "/")[0], Scope: scope, }, }) return &item, nil } func NewAPIGatewayAuthorizerAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.Authorizer, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.Authorizer, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-authorizer", Client: client, AccountID: accountID, Region: region, AdapterMetadata: authorizerAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.Authorizer, error) { f := strings.Split(query, "/") if len(f) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the rest-api-id/authorizer-id, but found: %s", query), } } out, err := client.GetAuthorizer(ctx, &apigateway.GetAuthorizerInput{ RestApiId: &f[0], AuthorizerId: &f[1], }) if err != nil { return nil, err } return convertGetAuthorizerOutputToAuthorizer(out), nil }, DisableList: true, SearchFunc: func(ctx context.Context, client *apigateway.Client, scope string, query string) ([]*types.Authorizer, error) { out, err := client.GetAuthorizers(ctx, &apigateway.GetAuthorizersInput{ RestApiId: &query, }) if err != nil { return nil, err } authorizers := make([]*types.Authorizer, 0, len(out.Items)) for _, authorizer := range out.Items { authorizers = append(authorizers, &authorizer) } return authorizers, nil }, ItemMapper: func(query, scope string, awsItem *types.Authorizer) (*sdp.Item, error) { return authorizerOutputMapper(query, scope, awsItem) }, } } var authorizerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-authorizer", DescriptiveName: "API Gateway Authorizer", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an API Gateway Authorizer by its rest API ID and ID: rest-api-id/authorizer-id", SearchDescription: "Search for API Gateway Authorizers by their rest API ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_authorizer.id"}, }, }) ================================================ FILE: aws-source/adapters/apigateway-authorizer_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestAuthorizerOutputMapper(t *testing.T) { awsItem := &types.Authorizer{ Id: aws.String("authorizer-id"), Name: aws.String("authorizer-name"), Type: types.AuthorizerTypeRequest, ProviderARNs: []string{"arn:aws:iam::123456789012:role/service-role"}, AuthType: aws.String("custom"), AuthorizerUri: aws.String("arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:my-function/invocations"), AuthorizerCredentials: aws.String("arn:aws:iam::123456789012:role/service-role"), IdentitySource: aws.String("method.request.header.Authorization"), IdentityValidationExpression: aws.String(".*"), AuthorizerResultTtlInSeconds: aws.Int32(300), } item, err := authorizerOutputMapper("rest-api-id", "scope", awsItem) if err != nil { t.Fatalf("unexpected error: %v", err) } if err := item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "apigateway-rest-api", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rest-api-id", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayAuthorizerAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayAuthorizerAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-deployment.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // convertGetDeploymentOutputToDeployment converts a GetDeploymentOutput to a Deployment func convertGetDeploymentOutputToDeployment(output *apigateway.GetDeploymentOutput) *types.Deployment { return &types.Deployment{ Id: output.Id, CreatedDate: output.CreatedDate, Description: output.Description, ApiSummary: output.ApiSummary, } } func deploymentOutputMapper(query, scope string, awsItem *types.Deployment) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "apigateway-deployment", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } restAPIID := strings.Split(query, "/")[0] item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-rest-api", Method: sdp.QueryMethod_GET, Query: restAPIID, Scope: scope, }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-stage", Method: sdp.QueryMethod_SEARCH, Query: fmt.Sprintf("%s/%s", restAPIID, *awsItem.Id), Scope: scope, }, }) return &item, nil } func NewAPIGatewayDeploymentAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.Deployment, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.Deployment, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-deployment", Client: client, AccountID: accountID, Region: region, AdapterMetadata: deploymentAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.Deployment, error) { f := strings.Split(query, "/") if len(f) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the rest-api-id/deployment-id, but found: %s", query), } } out, err := client.GetDeployment(ctx, &apigateway.GetDeploymentInput{ RestApiId: &f[0], DeploymentId: &f[1], }) if err != nil { return nil, err } return convertGetDeploymentOutputToDeployment(out), nil }, DisableList: true, SearchFunc: func(ctx context.Context, client *apigateway.Client, scope string, query string) ([]*types.Deployment, error) { out, err := client.GetDeployments(ctx, &apigateway.GetDeploymentsInput{ RestApiId: &query, }) if err != nil { return nil, err } response := make([]*types.Deployment, 0, len(out.Items)) for _, item := range out.Items { response = append(response, &item) } return response, nil }, ItemMapper: func(query, scope string, awsItem *types.Deployment) (*sdp.Item, error) { return deploymentOutputMapper(query, scope, awsItem) }, } } var deploymentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-deployment", DescriptiveName: "API Gateway Deployment", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an API Gateway Deployment by its rest API ID and ID: rest-api-id/deployment-id", SearchDescription: "Search for API Gateway Deployments by their rest API ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_deployment.id"}, }, }) ================================================ FILE: aws-source/adapters/apigateway-deployment_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDeploymentOutputMapper(t *testing.T) { awsItem := &types.Deployment{ Id: aws.String("deployment-id"), CreatedDate: aws.Time(time.Now()), Description: aws.String("deployment-description"), ApiSummary: map[string]map[string]types.MethodSnapshot{}, } item, err := deploymentOutputMapper("rest-api-id", "scope", awsItem) if err != nil { t.Fatalf("unexpected error: %v", err) } if err := item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "apigateway-rest-api", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rest-api-id", ExpectedScope: "scope", }, { ExpectedType: "apigateway-stage", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "rest-api-id/deployment-id", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayDeploymentAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayDeploymentAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-domain-name.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func convertGetDomainNameOutputToDomainName(output *apigateway.GetDomainNameOutput) *types.DomainName { return &types.DomainName{ DomainName: output.DomainName, CertificateArn: output.CertificateArn, CertificateName: output.CertificateName, CertificateUploadDate: output.CertificateUploadDate, DistributionDomainName: output.DistributionDomainName, DistributionHostedZoneId: output.DistributionHostedZoneId, RegionalDomainName: output.RegionalDomainName, RegionalHostedZoneId: output.RegionalHostedZoneId, EndpointConfiguration: output.EndpointConfiguration, DomainNameStatus: output.DomainNameStatus, DomainNameStatusMessage: output.DomainNameStatusMessage, SecurityPolicy: output.SecurityPolicy, MutualTlsAuthentication: output.MutualTlsAuthentication, Tags: output.Tags, OwnershipVerificationCertificateArn: output.OwnershipVerificationCertificateArn, RegionalCertificateName: output.RegionalCertificateName, RegionalCertificateArn: output.RegionalCertificateArn, } } func domainNameOutputMapper(_, scope string, awsItem *types.DomainName) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "apigateway-domain-name", UniqueAttribute: "DomainName", Attributes: attributes, Scope: scope, Tags: awsItem.Tags, } // Health based on the DomainNameStatus switch awsItem.DomainNameStatus { case types.DomainNameStatusAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.DomainNameStatusUpdating, types.DomainNameStatusPending, types.DomainNameStatusPendingCertificateReimport, types.DomainNameStatusPendingOwnershipVerification: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.DomainNameStatusFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("unknown Domain Name State: %s", awsItem.DomainNameStatus), } } if awsItem.RegionalHostedZoneId != nil { //+overmind:link route53-hosted-zone item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *awsItem.RegionalHostedZoneId, Scope: scope, }, }) } if awsItem.DistributionHostedZoneId != nil { //+overmind:link route53-hosted-zone item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *awsItem.DistributionHostedZoneId, Scope: scope, }, }) } if awsItem.CertificateArn != nil { if a, err := ParseARN(*awsItem.CertificateArn); err == nil { //+overmind:link acm-certificate item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_GET, Query: *awsItem.CertificateArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if awsItem.RegionalCertificateArn != nil { if a, err := ParseARN(*awsItem.RegionalCertificateArn); err == nil { //+overmind:link acm-certificate item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_GET, Query: *awsItem.RegionalCertificateArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if awsItem.RegionalDomainName != nil { //+overmind:link apigateway-domain-name item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-domain-name", Method: sdp.QueryMethod_GET, Query: *awsItem.RegionalDomainName, Scope: scope, }, }) } if awsItem.OwnershipVerificationCertificateArn != nil { if a, err := ParseARN(*awsItem.OwnershipVerificationCertificateArn); err == nil { //+overmind:link acm-certificate item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_GET, Query: *awsItem.OwnershipVerificationCertificateArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } // TODO: if cloudfront distribution supports searching by name, link it here via awsItem.DistributionDomainName return &item, nil } func NewAPIGatewayDomainNameAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.DomainName, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.DomainName, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-domain-name", Client: client, AccountID: accountID, Region: region, AdapterMetadata: apiGatewayDomainNameAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.DomainName, error) { if query == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "query must be the domain-name, but found empty query", } } out, err := client.GetDomainName(ctx, &apigateway.GetDomainNameInput{ DomainName: &query, }) if err != nil { return nil, err } return convertGetDomainNameOutputToDomainName(out), nil }, ListFunc: func(ctx context.Context, client *apigateway.Client, scope string) ([]*types.DomainName, error) { out, err := client.GetDomainNames(ctx, &apigateway.GetDomainNamesInput{}) if err != nil { return nil, err } var domainNames []*types.DomainName for _, domainName := range out.Items { domainNames = append(domainNames, &domainName) } return domainNames, nil }, ItemMapper: func(query, scope string, awsItem *types.DomainName) (*sdp.Item, error) { return domainNameOutputMapper(query, scope, awsItem) }, } } var apiGatewayDomainNameAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-domain-name", DescriptiveName: "API Gateway Domain Name", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Domain Name by domain-name", Search: true, SearchDescription: "Search Domain Names by ARN", List: true, ListDescription: "List Domain Names", }, PotentialLinks: []string{"acm-certificate"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_domain_name.domain_name"}, }, }) ================================================ FILE: aws-source/adapters/apigateway-domain-name_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) /* { "certificateArn": "string", "certificateName": "string", "certificateUploadDate": number, "distributionDomainName": "string", "distributionHostedZoneId": "string", "domainName": "string", "domainNameStatus": "string", "domainNameStatusMessage": "string", "endpointConfiguration": { "types": [ "string" ], "vpcEndpointIds": [ "string" ] }, "mutualTlsAuthentication": { "truststoreUri": "string", "truststoreVersion": "string", "truststoreWarnings": [ "string" ] }, "ownershipVerificationCertificateArn": "string", "regionalCertificateArn": "string", "regionalCertificateName": "string", "regionalDomainName": "string", "regionalHostedZoneId": "string", "securityPolicy": "string", "tags": { "string" : "string" } } */ func TestDomainNameOutputMapper(t *testing.T) { domainName := &types.DomainName{ CertificateArn: new("arn:aws:acm:region:account-id:certificate/certificate-id"), CertificateName: new("certificate-name"), CertificateUploadDate: new(time.Now()), DistributionDomainName: new("distribution-domain-name"), DistributionHostedZoneId: new("distribution-hosted-zone-id"), DomainName: new("domain-name"), DomainNameStatus: types.DomainNameStatusAvailable, DomainNameStatusMessage: new("status-message"), EndpointConfiguration: &types.EndpointConfiguration{Types: []types.EndpointType{types.EndpointTypeEdge}}, MutualTlsAuthentication: &types.MutualTlsAuthentication{TruststoreUri: new("truststore-uri")}, OwnershipVerificationCertificateArn: new("arn:aws:acm:region:account-id:certificate/ownership-verification-certificate-id"), RegionalCertificateArn: new("arn:aws:acm:region:account-id:certificate/regional-certificate-id"), RegionalCertificateName: new("regional-certificate-name"), RegionalDomainName: new("regional-domain-name"), RegionalHostedZoneId: new("regional-hosted-zone-id"), SecurityPolicy: types.SecurityPolicyTls12, Tags: map[string]string{"key": "value"}, } item, err := domainNameOutputMapper("domain-name", "scope", domainName) if err != nil { t.Fatal(err) } if err := item.Validate(); err != nil { t.Error(err) } a, err := ParseARN("arn:aws:acm:region:account-id:certificate/regional-certificate-id") if err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "acm-certificate", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "arn:aws:acm:region:account-id:certificate/certificate-id", ExpectedScope: FormatScope(a.AccountID, a.Region), }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "distribution-hosted-zone-id", ExpectedScope: "scope", }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "regional-hosted-zone-id", ExpectedScope: "scope", }, { ExpectedType: "acm-certificate", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "arn:aws:acm:region:account-id:certificate/regional-certificate-id", ExpectedScope: FormatScope(a.AccountID, a.Region), }, { ExpectedType: "acm-certificate", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "arn:aws:acm:region:account-id:certificate/ownership-verification-certificate-id", ExpectedScope: FormatScope(a.AccountID, a.Region), }, { ExpectedType: "apigateway-domain-name", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "regional-domain-name", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayDomainNameAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayDomainNameAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-integration.go ================================================ package adapters import ( "context" "fmt" "log/slog" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type apiGatewayIntegrationGetter interface { GetIntegration(ctx context.Context, params *apigateway.GetIntegrationInput, optFns ...func(*apigateway.Options)) (*apigateway.GetIntegrationOutput, error) } func apiGatewayIntegrationGetFunc(ctx context.Context, client apiGatewayIntegrationGetter, scope string, input *apigateway.GetIntegrationInput) (*sdp.Item, error) { if input == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "query must be in the format of: rest-api-id/resource-id/http-method", } } output, err := client.GetIntegration(ctx, input) if err != nil { return nil, err } attributes, err := ToAttributesWithExclude(output, "tags") if err != nil { return nil, err } // We create a custom ID of {rest-api-id}/{resource-id}/{http-method} e.g. // rest-api-id/resource-id/GET integrationID := fmt.Sprintf( "%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod, ) err = attributes.Set("IntegrationID", integrationID) if err != nil { return nil, err } item := &sdp.Item{ Type: "apigateway-integration", UniqueAttribute: "IntegrationID", Attributes: attributes, Scope: scope, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-method", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod), Scope: scope, }, }) if output.ConnectionId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-vpc-link", Method: sdp.QueryMethod_GET, Query: *output.ConnectionId, Scope: scope, }, }) } return item, nil } func NewAPIGatewayIntegrationAdapter(client apiGatewayIntegrationGetter, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*apigateway.GetIntegrationInput, *apigateway.GetIntegrationOutput, *apigateway.GetIntegrationInput, *apigateway.GetIntegrationOutput, apiGatewayIntegrationGetter, *apigateway.Options] { return &AlwaysGetAdapter[*apigateway.GetIntegrationInput, *apigateway.GetIntegrationOutput, *apigateway.GetIntegrationInput, *apigateway.GetIntegrationOutput, apiGatewayIntegrationGetter, *apigateway.Options]{ ItemType: "apigateway-integration", Client: client, AccountID: accountID, Region: region, AdapterMetadata: apiGatewayIntegrationAdapterMetadata, cache: cache, GetFunc: apiGatewayIntegrationGetFunc, GetInputMapper: func(scope, query string) *apigateway.GetIntegrationInput { // We are using a custom id of {rest-api-id}/{resource-id}/{http-method} e.g. // rest-api-id/resource-id/GET f := strings.Split(query, "/") if len(f) != 3 { slog.Error( "query must be in the format of: rest-api-id/resource-id/http-method", "found", query, ) return nil } return &apigateway.GetIntegrationInput{ RestApiId: &f[0], ResourceId: &f[1], HttpMethod: &f[2], } }, DisableList: true, } } var apiGatewayIntegrationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-integration", DescriptiveName: "API Gateway Integration", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get an Integration by rest-api id, resource id, and http-method", Search: true, SearchDescription: "Search Integrations by ARN", }, }) ================================================ FILE: aws-source/adapters/apigateway-integration_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type mockAPIGatewayIntegrationClient struct{} func (m *mockAPIGatewayIntegrationClient) GetIntegration(ctx context.Context, params *apigateway.GetIntegrationInput, optFns ...func(*apigateway.Options)) (*apigateway.GetIntegrationOutput, error) { return &apigateway.GetIntegrationOutput{ IntegrationResponses: map[string]types.IntegrationResponse{ "200": { ResponseTemplates: map[string]string{ "application/json": "", }, StatusCode: aws.String("200"), }, }, CacheKeyParameters: []string{}, Uri: aws.String("arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123412341234:function:My_Function/invocations"), HttpMethod: aws.String("POST"), CacheNamespace: aws.String("y9h6rt"), Type: "AWS", ConnectionId: aws.String("vpc-connection-id"), }, nil } func TestApiGatewayIntegrationGetFunc(t *testing.T) { ctx := context.Background() cli := mockAPIGatewayIntegrationClient{} input := &apigateway.GetIntegrationInput{ RestApiId: aws.String("rest-api-id"), ResourceId: aws.String("resource-id"), HttpMethod: aws.String("GET"), } item, err := apiGatewayIntegrationGetFunc(ctx, &cli, "scope", input) if err != nil { t.Fatalf("unexpected error: %v", err) } if err = item.Validate(); err != nil { t.Fatal(err) } integrationID := fmt.Sprintf("%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod) tests := QueryTests{ { ExpectedType: "apigateway-method", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: integrationID, ExpectedScope: "scope", }, { ExpectedType: "apigateway-vpc-link", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-connection-id", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayIntegrationAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayIntegrationAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-method-response.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func apiGatewayMethodResponseGetFunc(ctx context.Context, client apigatewayClient, scope string, input *apigateway.GetMethodResponseInput) (*sdp.Item, error) { if input == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "query must be in the format of: the rest-api-id/resource-id/http-method/status-code", } } output, err := client.GetMethodResponse(ctx, input) if err != nil { return nil, err } attributes, err := ToAttributesWithExclude(output, "tags") if err != nil { return nil, err } // We create a custom ID of {rest-api-id}/{resource-id}/{http-method}/{status-code} e.g. // rest-api-id/resource-id/GET/200 methodResponseID := fmt.Sprintf( "%s/%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod, *input.StatusCode, ) err = attributes.Set("MethodResponseID", methodResponseID) if err != nil { return nil, err } item := &sdp.Item{ Type: "apigateway-method-response", UniqueAttribute: "MethodResponseID", Attributes: attributes, Scope: scope, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-method", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod), Scope: scope, }, }) return item, nil } func NewAPIGatewayMethodResponseAdapter(client apigatewayClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*apigateway.GetMethodResponseInput, *apigateway.GetMethodResponseOutput, *apigateway.GetMethodResponseInput, *apigateway.GetMethodResponseOutput, apigatewayClient, *apigateway.Options] { return &AlwaysGetAdapter[*apigateway.GetMethodResponseInput, *apigateway.GetMethodResponseOutput, *apigateway.GetMethodResponseInput, *apigateway.GetMethodResponseOutput, apigatewayClient, *apigateway.Options]{ ItemType: "apigateway-method-response", Client: client, AccountID: accountID, Region: region, AdapterMetadata: apiGatewayMethodResponseAdapterMetadata, cache: cache, GetFunc: apiGatewayMethodResponseGetFunc, GetInputMapper: func(scope, query string) *apigateway.GetMethodResponseInput { // We are using a custom id of {rest-api-id}/{resource-id}/{http-method}/{status-code} e.g. // rest-api-id/resource-id/GET/200 f := strings.Split(query, "/") if len(f) != 4 { return nil } return &apigateway.GetMethodResponseInput{ RestApiId: &f[0], ResourceId: &f[1], HttpMethod: &f[2], StatusCode: &f[3], } }, DisableList: true, } } var apiGatewayMethodResponseAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-method-response", DescriptiveName: "API Gateway Method Response", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Method Response by it's ID: {rest-api-id}/{resource-id}/{http-method}/{status-code}", Search: true, SearchDescription: "Search Method Responses by ARN", }, }) ================================================ FILE: aws-source/adapters/apigateway-method-response_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (m *mockAPIGatewayClient) GetMethodResponse(ctx context.Context, params *apigateway.GetMethodResponseInput, optFns ...func(*apigateway.Options)) (*apigateway.GetMethodResponseOutput, error) { return &apigateway.GetMethodResponseOutput{ ResponseModels: map[string]string{ "application/json": "Empty", }, StatusCode: aws.String("200"), }, nil } func TestApiGatewayMethodResponseGetFunc(t *testing.T) { ctx := context.Background() cli := mockAPIGatewayClient{} input := &apigateway.GetMethodResponseInput{ RestApiId: aws.String("rest-api-id"), ResourceId: aws.String("resource-id"), HttpMethod: aws.String("GET"), StatusCode: aws.String("200"), } item, err := apiGatewayMethodResponseGetFunc(ctx, &cli, "scope", input) if err != nil { t.Fatalf("unexpected error: %v", err) } if err = item.Validate(); err != nil { t.Fatal(err) } methodID := fmt.Sprintf("%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod) tests := QueryTests{ { ExpectedType: "apigateway-method", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: methodID, ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayMethodResponseAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayMethodResponseAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-method.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type apigatewayClient interface { GetMethod(ctx context.Context, params *apigateway.GetMethodInput, optFns ...func(*apigateway.Options)) (*apigateway.GetMethodOutput, error) GetMethodResponse(ctx context.Context, params *apigateway.GetMethodResponseInput, optFns ...func(*apigateway.Options)) (*apigateway.GetMethodResponseOutput, error) } func apiGatewayMethodGetFunc(ctx context.Context, client apigatewayClient, scope string, input *apigateway.GetMethodInput) (*sdp.Item, error) { if input == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "query must be in the format of: the rest-api-id/resource-id/http-method", } } output, err := client.GetMethod(ctx, input) if err != nil { return nil, err } attributes, err := ToAttributesWithExclude(output, "tags") if err != nil { return nil, err } // We create a custom ID of {rest-api-id}/{resource-id}/{http-method} e.g. // rest-api-id/resource-id/GET methodID := fmt.Sprintf( "%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod, ) err = attributes.Set("MethodID", methodID) if err != nil { return nil, err } item := &sdp.Item{ Type: "apigateway-method", UniqueAttribute: "MethodID", Attributes: attributes, Scope: scope, } if output.MethodIntegration != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-integration", Method: sdp.QueryMethod_GET, Query: methodID, Scope: scope, }, }) } if output.AuthorizerId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-authorizer", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s", *input.RestApiId, *output.AuthorizerId), Scope: scope, }, }) } if output.RequestValidatorId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-request-validator", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s", *input.RestApiId, *output.RequestValidatorId), Scope: scope, }, }) } for statusCode := range output.MethodResponses { if input.RestApiId != nil && input.ResourceId != nil && input.HttpMethod != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-method-response", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod, statusCode), Scope: scope, }, }) } } return item, nil } func NewAPIGatewayMethodAdapter(client apigatewayClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*apigateway.GetMethodInput, *apigateway.GetMethodOutput, *apigateway.GetMethodInput, *apigateway.GetMethodOutput, apigatewayClient, *apigateway.Options] { return &AlwaysGetAdapter[*apigateway.GetMethodInput, *apigateway.GetMethodOutput, *apigateway.GetMethodInput, *apigateway.GetMethodOutput, apigatewayClient, *apigateway.Options]{ ItemType: "apigateway-method", Client: client, AccountID: accountID, Region: region, AdapterMetadata: apiGatewayMethodAdapterMetadata, cache: cache, GetFunc: apiGatewayMethodGetFunc, GetInputMapper: func(scope, query string) *apigateway.GetMethodInput { // We are using a custom id of {rest-api-id}/{resource-id}/{http-method} e.g. // rest-api-id/resource-id/GET f := strings.Split(query, "/") if len(f) != 3 { return nil } return &apigateway.GetMethodInput{ RestApiId: &f[0], ResourceId: &f[1], HttpMethod: &f[2], } }, DisableList: true, } } var apiGatewayMethodAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-method", DescriptiveName: "API Gateway Method", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Method by it's ID: {rest-api-id}/{resource-id}/{http-method}", Search: true, SearchDescription: "Search Methods by ARN", }, PotentialLinks: []string{ "apigateway-integration", "apigateway-authorizer", "apigateway-request-validator", "apigateway-method-response", }, }) ================================================ FILE: aws-source/adapters/apigateway-method_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type mockAPIGatewayClient struct{} func (m *mockAPIGatewayClient) GetMethod(ctx context.Context, params *apigateway.GetMethodInput, optFns ...func(*apigateway.Options)) (*apigateway.GetMethodOutput, error) { return &apigateway.GetMethodOutput{ ApiKeyRequired: aws.Bool(false), HttpMethod: aws.String("GET"), AuthorizationType: aws.String("NONE"), AuthorizerId: aws.String("authorizer-id"), RequestParameters: map[string]bool{}, RequestValidatorId: aws.String("request-validator-id"), MethodResponses: map[string]types.MethodResponse{ "200": { ResponseModels: map[string]string{ "application/json": "Empty", }, StatusCode: aws.String("200"), }, }, MethodIntegration: &types.Integration{ IntegrationResponses: map[string]types.IntegrationResponse{ "200": { ResponseTemplates: map[string]string{ "application/json": "", }, StatusCode: aws.String("200"), }, }, CacheKeyParameters: []string{}, Uri: aws.String("arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123412341234:function:My_Function/invocations"), HttpMethod: aws.String("POST"), CacheNamespace: aws.String("y9h6rt"), Type: "AWS", }, }, nil } func TestApiGatewayGetFunc(t *testing.T) { ctx := context.Background() cli := mockAPIGatewayClient{} input := &apigateway.GetMethodInput{ RestApiId: aws.String("rest-api-id"), ResourceId: aws.String("resource-id"), HttpMethod: aws.String("GET"), } item, err := apiGatewayMethodGetFunc(ctx, &cli, "scope", input) if err != nil { t.Fatalf("unexpected error: %v", err) } if err = item.Validate(); err != nil { t.Fatal(err) } methodID := fmt.Sprintf("%s/%s/%s", *input.RestApiId, *input.ResourceId, *input.HttpMethod) authorizerID := fmt.Sprintf("%s/%s", *input.RestApiId, "authorizer-id") validatorID := fmt.Sprintf("%s/%s", *input.RestApiId, "request-validator-id") tests := QueryTests{ { ExpectedType: "apigateway-integration", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: methodID, ExpectedScope: "scope", }, { ExpectedType: "apigateway-authorizer", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: authorizerID, ExpectedScope: "scope", }, { ExpectedType: "apigateway-request-validator", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: validatorID, ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayMethodAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayMethodAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-model.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func convertGetModelOutputToModel(output *apigateway.GetModelOutput) *types.Model { return &types.Model{ Id: output.Id, Name: output.Name, Description: output.Description, Schema: output.Schema, ContentType: output.ContentType, } } func modelOutputMapper(query, scope string, awsItem *types.Model) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } restAPIID := strings.Split(query, "/")[0] err = attributes.Set("UniqueAttribute", fmt.Sprintf("%s/%s", restAPIID, *awsItem.Name)) if err != nil { return nil, err } item := sdp.Item{ Type: "apigateway-model", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-rest-api", Method: sdp.QueryMethod_GET, Query: restAPIID, Scope: scope, }, }) return &item, nil } func NewAPIGatewayModelAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.Model, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.Model, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-model", Client: client, AccountID: accountID, Region: region, AdapterMetadata: modelAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.Model, error) { f := strings.Split(query, "/") if len(f) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the rest-api-id/model-name, but found: %s", query), } } out, err := client.GetModel(ctx, &apigateway.GetModelInput{ RestApiId: &f[0], ModelName: &f[1], }) if err != nil { return nil, err } return convertGetModelOutputToModel(out), nil }, DisableList: true, SearchFunc: func(ctx context.Context, client *apigateway.Client, scope string, query string) ([]*types.Model, error) { out, err := client.GetModels(ctx, &apigateway.GetModelsInput{ RestApiId: &query, }) if err != nil { return nil, err } var items []*types.Model for _, model := range out.Items { items = append(items, &model) } return items, nil }, ItemMapper: func(query, scope string, awsItem *types.Model) (*sdp.Item, error) { return modelOutputMapper(query, scope, awsItem) }, } } var modelAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-model", DescriptiveName: "API Gateway Model", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an API Gateway Model by its rest API ID and model name: rest-api-id/model-name", SearchDescription: "Search for API Gateway Models by their rest API ID: rest-api-id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_model.id"}, }, }) ================================================ FILE: aws-source/adapters/apigateway-model_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestModelOutputMapper(t *testing.T) { awsItem := &types.Model{ Id: aws.String("model-id"), Name: aws.String("model-name"), Description: aws.String("description"), Schema: aws.String("{\"type\": \"object\"}"), ContentType: aws.String("application/json"), } item, err := modelOutputMapper("rest-api-id/model-name", "scope", awsItem) if err != nil { t.Fatalf("unexpected error: %v", err) } if err := item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "apigateway-rest-api", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rest-api-id", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayModelAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayModelAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-resource.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func convertGetResourceOutputToResource(output *apigateway.GetResourceOutput) *types.Resource { return &types.Resource{ Id: output.Id, ParentId: output.ParentId, Path: output.Path, PathPart: output.PathPart, ResourceMethods: output.ResourceMethods, } } // query: rest-api-id/resource-id for get request // query: rest-api-id for search request func resourceOutputMapper(query, scope string, awsItem *types.Resource) (*sdp.Item, error) { var restApiID string f := strings.Split(query, "/") switch len(f) { case 1, 2: restApiID = f[0] default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the rest-api-id/resource-id or rest-api-id, but found: %s", query), } } attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } err = attributes.Set("UniqueName", fmt.Sprintf("%s/%s", restApiID, *awsItem.Id)) if err != nil { return nil, err } item := sdp.Item{ Type: "apigateway-resource", UniqueAttribute: "UniqueName", Attributes: attributes, Scope: scope, } for methodString := range awsItem.ResourceMethods { if awsItem.Id != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-method", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s/%s", restApiID, *awsItem.Id, methodString), Scope: scope, }, }) } } return &item, nil } func NewAPIGatewayResourceAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.Resource, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.Resource, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-resource", Client: client, AccountID: accountID, Region: region, AdapterMetadata: apiGatewayResourceAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.Resource, error) { f := strings.Split(query, "/") if len(f) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the rest-api-id/resource-id, but found: %s", query), } } out, err := client.GetResource(ctx, &apigateway.GetResourceInput{ RestApiId: &f[0], // rest-api-id ResourceId: &f[1], // resource-id }) if err != nil { return nil, err } return convertGetResourceOutputToResource(out), nil }, DisableList: true, SearchFunc: func(ctx context.Context, client *apigateway.Client, scope string, query string) ([]*types.Resource, error) { out, err := client.GetResources(ctx, &apigateway.GetResourcesInput{ RestApiId: &query, }) if err != nil { return nil, err } var resources []*types.Resource for _, resource := range out.Items { resources = append(resources, &resource) } return resources, nil }, ItemMapper: func(query, scope string, awsItem *types.Resource) (*sdp.Item, error) { return resourceOutputMapper(query, scope, awsItem) }, } } var apiGatewayResourceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-resource", DescriptiveName: "API Gateway", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Resource by rest-api-id/resource-id", SearchDescription: "Search Resources by REST API ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_resource.id"}, }, PotentialLinks: []string{ "apigateway-method", }, }) ================================================ FILE: aws-source/adapters/apigateway-resource_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdpcache" ) /* { "id": "string", "parentId": "string", "path": "string", "pathPart": "string", "resourceMethods": { "string" : { "apiKeyRequired": boolean, "authorizationScopes": [ "string" ], "authorizationType": "string", "authorizerId": "string", "httpMethod": "string", "methodIntegration": { "cacheKeyParameters": [ "string" ], "cacheNamespace": "string", "connectionId": "string", "connectionType": "string", "contentHandling": "string", "credentials": "string", "httpMethod": "string", "integrationResponses": { "string" : { "contentHandling": "string", "responseParameters": { "string" : "string" }, "responseTemplates": { "string" : "string" }, "selectionPattern": "string", "statusCode": "string" } }, "passthroughBehavior": "string", "requestParameters": { "string" : "string" }, "requestTemplates": { "string" : "string" }, "timeoutInMillis": number, "tlsConfig": { "insecureSkipVerification": boolean }, "type": "string", "uri": "string" }, "methodResponses": { "string" : { "responseModels": { "string" : "string" }, "responseParameters": { "string" : boolean }, "statusCode": "string" } }, "operationName": "string", "requestModels": { "string" : "string" }, "requestParameters": { "string" : boolean }, "requestValidatorId": "string" } } } */ func TestResourceOutputMapper(t *testing.T) { resource := &types.Resource{ Id: new("test-id"), ParentId: new("parent-id"), Path: new("/test-path"), PathPart: new("test-path-part"), ResourceMethods: map[string]types.Method{ "GET": { ApiKeyRequired: new(true), AuthorizationScopes: []string{"scope1", "scope2"}, AuthorizationType: new("NONE"), AuthorizerId: new("authorizer-id"), HttpMethod: new("GET"), MethodIntegration: &types.Integration{ CacheKeyParameters: []string{"param1", "param2"}, CacheNamespace: new("namespace"), ConnectionId: new("connection-id"), ConnectionType: types.ConnectionTypeInternet, ContentHandling: types.ContentHandlingStrategyConvertToBinary, Credentials: new("credentials"), HttpMethod: new("POST"), IntegrationResponses: map[string]types.IntegrationResponse{ "200": { ContentHandling: types.ContentHandlingStrategyConvertToText, ResponseParameters: map[string]string{ "param1": "value1", }, ResponseTemplates: map[string]string{ "template1": "value1", }, SelectionPattern: new("pattern"), StatusCode: new("200"), }, }, PassthroughBehavior: new("WHEN_NO_MATCH"), RequestParameters: map[string]string{ "param1": "value1", }, RequestTemplates: map[string]string{ "template1": "value1", }, TimeoutInMillis: int32(29000), TlsConfig: &types.TlsConfig{ InsecureSkipVerification: false, }, Type: types.IntegrationTypeAwsProxy, Uri: new("uri"), }, MethodResponses: map[string]types.MethodResponse{ "200": { ResponseModels: map[string]string{ "model1": "value1", }, ResponseParameters: map[string]bool{ "param1": true, }, StatusCode: new("200"), }, }, OperationName: new("operation"), RequestModels: map[string]string{ "model1": "value1", }, RequestParameters: map[string]bool{ "param1": true, }, RequestValidatorId: new("validator-id"), }, }, } item, err := resourceOutputMapper("rest-api-13", "scope", resource) if err != nil { t.Fatal(err) } if err := item.Validate(); err != nil { t.Error(err) } } func TestNewAPIGatewayResourceAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayResourceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-rest-api.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" log "github.com/sirupsen/logrus" ) // convertGetRestApiOutputToRestApi converts a GetRestApiOutput to a RestApi func convertGetRestApiOutputToRestApi(output *apigateway.GetRestApiOutput) *types.RestApi { return &types.RestApi{ CreatedDate: output.CreatedDate, Description: output.Description, Id: output.Id, Name: output.Name, Tags: output.Tags, ApiKeySource: output.ApiKeySource, BinaryMediaTypes: output.BinaryMediaTypes, DisableExecuteApiEndpoint: output.DisableExecuteApiEndpoint, EndpointConfiguration: output.EndpointConfiguration, MinimumCompressionSize: output.MinimumCompressionSize, Policy: output.Policy, RootResourceId: output.RootResourceId, Version: output.Version, Warnings: output.Warnings, } } func restApiListFunc(ctx context.Context, client *apigateway.Client, _ string) ([]*types.RestApi, error) { out, err := client.GetRestApis(ctx, &apigateway.GetRestApisInput{}) if err != nil { return nil, err } var items []*types.RestApi for _, restAPI := range out.Items { items = append(items, &restAPI) } return items, nil } func restApiOutputMapper(scope string, awsItem *types.RestApi) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } if awsItem.Policy != nil { type restAPIWithParsedPolicy struct { *types.RestApi PolicyDocument *policy.Policy } restApi := restAPIWithParsedPolicy{ RestApi: awsItem, } restApi.PolicyDocument, err = ParsePolicyDocument(*awsItem.Policy) if err != nil { log.WithFields(log.Fields{ "error": err, "scope": scope, "policyDocument": *awsItem.Policy, }).Error("Error parsing policy document") return nil, nil //nolint:nilerr } attributes, err = ToAttributesWithExclude(restApi, "tags") if err != nil { return nil, err } } item := sdp.Item{ Type: "apigateway-rest-api", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, Tags: awsItem.Tags, } if awsItem.EndpointConfiguration != nil && awsItem.EndpointConfiguration.VpcEndpointIds != nil { for _, vpcEndpointID := range awsItem.EndpointConfiguration.VpcEndpointIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc-endpoint", Method: sdp.QueryMethod_GET, Query: vpcEndpointID, Scope: scope, }, }) } } if awsItem.RootResourceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-resource", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s", *awsItem.Id, *awsItem.RootResourceId), Scope: scope, }, }) } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-resource", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.Id, Scope: scope, }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-model", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.Id, Scope: scope, }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-deployment", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.Id, Scope: scope, }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-authorizer", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.Id, Scope: scope, }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-stage", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.Id, Scope: scope, }, }) return &item, nil } func NewAPIGatewayRestApiAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.RestApi, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.RestApi, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-rest-api", Client: client, AccountID: accountID, Region: region, AdapterMetadata: restApiAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.RestApi, error) { out, err := client.GetRestApi(ctx, &apigateway.GetRestApiInput{ RestApiId: &query, }) if err != nil { return nil, err } return convertGetRestApiOutputToRestApi(out), nil }, ListFunc: restApiListFunc, SearchFunc: func(ctx context.Context, client *apigateway.Client, scope string, query string) ([]*types.RestApi, error) { out, err := client.GetRestApis(ctx, &apigateway.GetRestApisInput{}) if err != nil { return nil, err } var items []*types.RestApi for _, restAPI := range out.Items { if *restAPI.Name == query { items = append(items, &restAPI) } } return items, nil }, ItemMapper: func(_, scope string, awsItem *types.RestApi) (*sdp.Item, error) { return restApiOutputMapper(scope, awsItem) }, } } var restApiAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-rest-api", DescriptiveName: "REST API", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a REST API by ID", ListDescription: "List all REST APIs", SearchDescription: "Search for REST APIs by their name", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_rest_api.id"}, }, PotentialLinks: []string{"ec2-vpc-endpoint", "apigateway-resource"}, }) ================================================ FILE: aws-source/adapters/apigateway-rest-api_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) /* { "apiKeySource": "string", "binaryMediaTypes": [ "string" ], "createdDate": number, "description": "string", "disableExecuteApiEndpoint": boolean, "endpointConfiguration": { "types": [ "string" ], "vpcEndpointIds": [ "string" ] }, "id": "string", "minimumCompressionSize": number, "name": "string", "policy": "string", "rootResourceId": "string", "tags": { "string" : "string" }, "version": "string", "warnings": [ "string" ] } */ func TestRestApiOutputMapper(t *testing.T) { output := &apigateway.GetRestApiOutput{ ApiKeySource: types.ApiKeySourceTypeHeader, BinaryMediaTypes: []string{"application/json"}, CreatedDate: new(time.Now()), Description: new("Example API"), DisableExecuteApiEndpoint: false, EndpointConfiguration: &types.EndpointConfiguration{ Types: []types.EndpointType{types.EndpointTypePrivate}, VpcEndpointIds: []string{"vpce-12345678"}, }, Id: new("abc123"), MinimumCompressionSize: new(int32(1024)), Name: new("ExampleAPI"), Policy: new("{\"Version\": \"2012-10-17\", \"Statement\": [{\"Effect\": \"Allow\", \"Principal\": \"*\", \"Action\": \"execute-api:Invoke\", \"Resource\": \"*\"}]}"), RootResourceId: new("root123"), Tags: map[string]string{ "env": "production", }, Version: new("v1"), Warnings: []string{"This is a warning"}, } item, err := restApiOutputMapper("scope", convertGetRestApiOutputToRestApi(output)) if err != nil { t.Fatal(err) } if err := item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "ec2-vpc-endpoint", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpce-12345678", ExpectedScope: "scope", }, { ExpectedType: "apigateway-resource", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "abc123/root123", ExpectedScope: "scope", }, { ExpectedType: "apigateway-resource", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "abc123", ExpectedScope: "scope", }, { ExpectedType: "apigateway-model", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "abc123", ExpectedScope: "scope", }, { ExpectedType: "apigateway-deployment", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "abc123", ExpectedScope: "scope", }, { ExpectedType: "apigateway-authorizer", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "abc123", ExpectedScope: "scope", }, { ExpectedType: "apigateway-stage", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "abc123", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewAPIGatewayRestApiAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayRestApiAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/apigateway-stage.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func convertGetStageOutputToStage(output *apigateway.GetStageOutput) *types.Stage { return &types.Stage{ DeploymentId: output.DeploymentId, StageName: output.StageName, Description: output.Description, CreatedDate: output.CreatedDate, LastUpdatedDate: output.LastUpdatedDate, Variables: output.Variables, AccessLogSettings: output.AccessLogSettings, CacheClusterEnabled: output.CacheClusterEnabled, CacheClusterSize: output.CacheClusterSize, CacheClusterStatus: output.CacheClusterStatus, CanarySettings: output.CanarySettings, ClientCertificateId: output.ClientCertificateId, DocumentationVersion: output.DocumentationVersion, MethodSettings: output.MethodSettings, TracingEnabled: output.TracingEnabled, WebAclArn: output.WebAclArn, Tags: output.Tags, } } func stageOutputMapper(query, scope string, awsItem *types.Stage) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem, "tags") if err != nil { return nil, err } // if it is `GET`, the query will be: rest-api-id/stage-name // if it is `SEARCH`, the query will be: rest-api-id/deployment-id or rest-api-id restAPIID := strings.Split(query, "/")[0] err = attributes.Set("UniqueAttribute", fmt.Sprintf("%s/%s", restAPIID, *awsItem.StageName)) if err != nil { return nil, err } item := sdp.Item{ Type: "apigateway-stage", UniqueAttribute: "StageName", Attributes: attributes, Scope: scope, Tags: awsItem.Tags, } if awsItem.DeploymentId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-deployment", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s", restAPIID, *awsItem.DeploymentId), Scope: scope, }, }) } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "apigateway-rest-api", Method: sdp.QueryMethod_GET, Query: restAPIID, Scope: scope, }, }) return &item, nil } func NewAPIGatewayStageAdapter(client *apigateway.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.Stage, *apigateway.Client, *apigateway.Options] { return &GetListAdapter[*types.Stage, *apigateway.Client, *apigateway.Options]{ ItemType: "apigateway-stage", Client: client, AccountID: accountID, Region: region, AdapterMetadata: stageAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *apigateway.Client, scope, query string) (*types.Stage, error) { f := strings.Split(query, "/") if len(f) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the rest-api-id/stage-name, but found: %s", query), } } out, err := client.GetStage(ctx, &apigateway.GetStageInput{ RestApiId: &f[0], StageName: &f[1], }) if err != nil { return nil, err } return convertGetStageOutputToStage(out), nil }, DisableList: true, SearchFunc: func(ctx context.Context, client *apigateway.Client, scope string, query string) ([]*types.Stage, error) { f := strings.Split(query, "/") var input *apigateway.GetStagesInput switch len(f) { case 1: input = &apigateway.GetStagesInput{ RestApiId: &f[0], } case 2: input = &apigateway.GetStagesInput{ RestApiId: &f[0], DeploymentId: &f[1], } default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf( "query must be in the format of: the rest-api-id/deployment-id or rest-api-id, but found: %s", query, ), } } out, err := client.GetStages(ctx, input) if err != nil { return nil, err } var items []*types.Stage for _, stage := range out.Item { items = append(items, &stage) } return items, nil }, ItemMapper: func(query, scope string, awsItem *types.Stage) (*sdp.Item, error) { return stageOutputMapper(query, scope, awsItem) }, } } var stageAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "apigateway-stage", DescriptiveName: "API Gateway Stage", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an API Gateway Stage by its rest API ID and stage name: rest-api-id/stage-name", SearchDescription: "Search for API Gateway Stages by their rest API ID or with rest API ID and deployment-id: rest-api-id/deployment-id", }, PotentialLinks: []string{"wafv2-web-acl"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_api_gateway_stage.id"}, }, }) ================================================ FILE: aws-source/adapters/apigateway-stage_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestStageOutputMapper(t *testing.T) { awsItem := &types.Stage{ DeploymentId: aws.String("deployment-id"), StageName: aws.String("stage-name"), Description: aws.String("description"), CreatedDate: aws.Time(time.Now()), LastUpdatedDate: aws.Time(time.Now()), Variables: map[string]string{"key": "value"}, AccessLogSettings: &types.AccessLogSettings{}, CacheClusterEnabled: true, CacheClusterSize: "0.5", CacheClusterStatus: types.CacheClusterStatusAvailable, CanarySettings: &types.CanarySettings{}, ClientCertificateId: aws.String("client-cert-id"), DocumentationVersion: aws.String("1.0"), MethodSettings: map[string]types.MethodSetting{}, TracingEnabled: true, WebAclArn: aws.String("web-acl-arn"), Tags: map[string]string{"tag-key": "tag-value"}, } queries := []string{"rest-api-id/stage-name", "rest-api-id/deployment-id", "rest-api-id"} for _, query := range queries { item, err := stageOutputMapper(query, "scope", awsItem) if err != nil { t.Fatalf("unexpected error: %v", err) } if err := item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "apigateway-deployment", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rest-api-id/deployment-id", ExpectedScope: "scope", }, { ExpectedType: "apigateway-rest-api", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rest-api-id", ExpectedScope: "scope", }, } tests.Execute(t, item) } } func TestNewAPIGatewayStageAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := apigateway.NewFromConfig(config) adapter := NewAPIGatewayStageAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/autoscaling-auto-scaling-group.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/autoscaling" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func autoScalingGroupOutputMapper(_ context.Context, _ *autoscaling.Client, scope string, _ *autoscaling.DescribeAutoScalingGroupsInput, output *autoscaling.DescribeAutoScalingGroupsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) var item sdp.Item var attributes *sdp.ItemAttributes var err error for _, asg := range output.AutoScalingGroups { attributes, err = ToAttributesWithExclude(asg) if err != nil { return nil, err } item = sdp.Item{ Type: "autoscaling-auto-scaling-group", UniqueAttribute: "AutoScalingGroupName", Scope: scope, Attributes: attributes, } tags := make(map[string]string) for _, tag := range asg.Tags { if tag.Key != nil && tag.Value != nil { tags[*tag.Key] = *tag.Value } } item.Tags = tags if asg.MixedInstancesPolicy != nil { if asg.MixedInstancesPolicy.LaunchTemplate != nil { if asg.MixedInstancesPolicy.LaunchTemplate.LaunchTemplateSpecification != nil { if asg.MixedInstancesPolicy.LaunchTemplate.LaunchTemplateSpecification.LaunchTemplateId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-launch-template", Method: sdp.QueryMethod_GET, Query: *asg.MixedInstancesPolicy.LaunchTemplate.LaunchTemplateSpecification.LaunchTemplateId, Scope: scope, }, }) } } } } var a *ARN var err error for _, tgARN := range asg.TargetGroupARNs { if a, err = ParseARN(tgARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-target-group", Method: sdp.QueryMethod_SEARCH, Query: tgARN, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, instance := range asg.Instances { if instance.InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *instance.InstanceId, Scope: scope, }, }) } if instance.LaunchTemplate != nil { if instance.LaunchTemplate.LaunchTemplateId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-launch-template", Method: sdp.QueryMethod_GET, Query: *instance.LaunchTemplate.LaunchTemplateId, Scope: scope, }, }) } } } if asg.ServiceLinkedRoleARN != nil { if a, err = ParseARN(*asg.ServiceLinkedRoleARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *asg.ServiceLinkedRoleARN, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if asg.LaunchConfigurationName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "autoscaling-launch-configuration", Method: sdp.QueryMethod_GET, Query: *asg.LaunchConfigurationName, Scope: scope, }, }) } if asg.LaunchTemplate != nil { if asg.LaunchTemplate.LaunchTemplateId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-launch-template", Method: sdp.QueryMethod_GET, Query: *asg.LaunchTemplate.LaunchTemplateId, Scope: scope, }, }) } } if asg.PlacementGroup != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-placement-group", Method: sdp.QueryMethod_GET, Query: *asg.PlacementGroup, Scope: scope, }, }) } items = append(items, &item) } return items, nil } // func NewAutoScalingGroupAdapter(client *autoscaling.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*autoscaling.DescribeAutoScalingGroupsInput, *autoscaling.DescribeAutoScalingGroupsOutput, *autoscaling.Client, *autoscaling.Options] { return &DescribeOnlyAdapter[*autoscaling.DescribeAutoScalingGroupsInput, *autoscaling.DescribeAutoScalingGroupsOutput, *autoscaling.Client, *autoscaling.Options]{ ItemType: "autoscaling-auto-scaling-group", AccountID: accountID, Region: region, Client: client, AdapterMetadata: autoScalingGroupAdapterMetadata, cache: cache, InputMapperGet: func(scope, query string) (*autoscaling.DescribeAutoScalingGroupsInput, error) { return &autoscaling.DescribeAutoScalingGroupsInput{ AutoScalingGroupNames: []string{query}, }, nil }, InputMapperList: func(scope string) (*autoscaling.DescribeAutoScalingGroupsInput, error) { return &autoscaling.DescribeAutoScalingGroupsInput{}, nil }, InputMapperSearch: func(ctx context.Context, client *autoscaling.Client, scope, query string) (*autoscaling.DescribeAutoScalingGroupsInput, error) { // Parse the ARN to extract the AutoScaling Group name // AutoScaling Group ARNs have the format: // arn:aws:autoscaling:region:account-id:autoScalingGroup:uuid:autoScalingGroupName/name arn, err := ParseARN(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid ARN format for autoscaling-auto-scaling-group", } } // Check if it's an autoscaling ARN if arn.Service != "autoscaling" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not for autoscaling service", } } // The resource part looks like: autoScalingGroup:uuid:autoScalingGroupName/actual-name // We need to extract just the "actual-name" part if strings.Contains(arn.Resource, "autoScalingGroupName/") { parts := strings.Split(arn.Resource, "autoScalingGroupName/") if len(parts) == 2 { asgName := parts[1] return &autoscaling.DescribeAutoScalingGroupsInput{ AutoScalingGroupNames: []string{asgName}, }, nil } } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "could not extract AutoScaling Group name from ARN", } }, PaginatorBuilder: func(client *autoscaling.Client, params *autoscaling.DescribeAutoScalingGroupsInput) Paginator[*autoscaling.DescribeAutoScalingGroupsOutput, *autoscaling.Options] { return autoscaling.NewDescribeAutoScalingGroupsPaginator(client, params) }, DescribeFunc: func(ctx context.Context, client *autoscaling.Client, input *autoscaling.DescribeAutoScalingGroupsInput) (*autoscaling.DescribeAutoScalingGroupsOutput, error) { return client.DescribeAutoScalingGroups(ctx, input) }, OutputMapper: autoScalingGroupOutputMapper, } } var autoScalingGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "autoscaling-auto-scaling-group", DescriptiveName: "Autoscaling Group", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an Autoscaling Group by name", ListDescription: "List Autoscaling Groups", SearchDescription: "Search for Autoscaling Groups by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_autoscaling_group.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"ec2-launch-template", "elbv2-target-group", "ec2-instance", "iam-role", "autoscaling-launch-configuration", "ec2-placement-group"}, }) ================================================ FILE: aws-source/adapters/autoscaling-auto-scaling-group_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/autoscaling" "github.com/aws/aws-sdk-go-v2/service/autoscaling/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestAutoScalingGroupOutputMapper(t *testing.T) { t.Parallel() output := autoscaling.DescribeAutoScalingGroupsOutput{ AutoScalingGroups: []types.AutoScalingGroup{ { AutoScalingGroupName: new("eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), AutoScalingGroupARN: new("arn:aws:autoscaling:eu-west-2:944651592624:autoScalingGroup:1cbb0e22-818f-4d8b-8662-77f73d3713ca:autoScalingGroupName/eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), MixedInstancesPolicy: &types.MixedInstancesPolicy{ LaunchTemplate: &types.LaunchTemplate{ LaunchTemplateSpecification: &types.LaunchTemplateSpecification{ LaunchTemplateId: new("lt-0174ff2b8909d0c75"), // link LaunchTemplateName: new("eks-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), Version: new("1"), }, Overrides: []types.LaunchTemplateOverrides{ { InstanceType: new("t3.large"), }, }, }, InstancesDistribution: &types.InstancesDistribution{ OnDemandAllocationStrategy: new("prioritized"), OnDemandBaseCapacity: new(int32(0)), OnDemandPercentageAboveBaseCapacity: new(int32(100)), SpotAllocationStrategy: new("lowest-price"), SpotInstancePools: new(int32(2)), }, }, MinSize: new(int32(1)), MaxSize: new(int32(3)), DesiredCapacity: new(int32(1)), DefaultCooldown: new(int32(300)), AvailabilityZones: []string{ // link "eu-west-2c", "eu-west-2a", "eu-west-2b", }, LoadBalancerNames: []string{}, // Ignored, classic load balancer TargetGroupARNs: []string{ "arn:partition:service:region:account-id:resource-type/resource-id", // link }, HealthCheckType: new("EC2"), HealthCheckGracePeriod: new(int32(15)), Instances: []types.Instance{ { InstanceId: new("i-0be6c4fe789cb1b78"), // link InstanceType: new("t3.large"), AvailabilityZone: new("eu-west-2c"), LifecycleState: types.LifecycleStateInService, HealthStatus: new("Healthy"), LaunchTemplate: &types.LaunchTemplateSpecification{ LaunchTemplateId: new("lt-0174ff2b8909d0c75"), // Link LaunchTemplateName: new("eks-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), Version: new("1"), }, ProtectedFromScaleIn: new(false), }, }, CreatedTime: new(time.Now()), SuspendedProcesses: []types.SuspendedProcess{}, VPCZoneIdentifier: new("subnet-0e234bef35fc4a9e1,subnet-09d5f6fa75b0b4569,subnet-0960234bbc4edca03"), EnabledMetrics: []types.EnabledMetric{}, Tags: []types.TagDescription{ { ResourceId: new("eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), ResourceType: new("auto-scaling-group"), Key: new("eks:cluster-name"), Value: new("dogfood"), PropagateAtLaunch: new(true), }, { ResourceId: new("eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), ResourceType: new("auto-scaling-group"), Key: new("eks:nodegroup-name"), Value: new("default-20230117110031319900000013"), PropagateAtLaunch: new(true), }, { ResourceId: new("eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), ResourceType: new("auto-scaling-group"), Key: new("k8s.io/cluster-autoscaler/dogfood"), Value: new("owned"), PropagateAtLaunch: new(true), }, { ResourceId: new("eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), ResourceType: new("auto-scaling-group"), Key: new("k8s.io/cluster-autoscaler/enabled"), Value: new("true"), PropagateAtLaunch: new(true), }, { ResourceId: new("eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"), ResourceType: new("auto-scaling-group"), Key: new("kubernetes.io/cluster/dogfood"), Value: new("owned"), PropagateAtLaunch: new(true), }, }, TerminationPolicies: []string{ "AllocationStrategy", "OldestLaunchTemplate", "OldestInstance", }, NewInstancesProtectedFromScaleIn: new(false), ServiceLinkedRoleARN: new("arn:aws:iam::944651592624:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling"), // link CapacityRebalance: new(true), TrafficSources: []types.TrafficSourceIdentifier{ { Identifier: new("arn:partition:service:region:account-id:resource-type/resource-id"), // We will skip this for now since it's related to VPC lattice groups which are still in preview }, }, Context: new("foo"), DefaultInstanceWarmup: new(int32(10)), DesiredCapacityType: new("foo"), LaunchConfigurationName: new("launchConfig"), // link LaunchTemplate: &types.LaunchTemplateSpecification{ LaunchTemplateId: new("id"), // link LaunchTemplateName: new("launchTemplateName"), }, MaxInstanceLifetime: new(int32(30)), PlacementGroup: new("placementGroup"), // link (ec2) PredictedCapacity: new(int32(1)), Status: new("OK"), WarmPoolConfiguration: &types.WarmPoolConfiguration{ InstanceReusePolicy: &types.InstanceReusePolicy{ ReuseOnScaleIn: new(true), }, MaxGroupPreparedCapacity: new(int32(1)), MinSize: new(int32(1)), PoolState: types.WarmPoolStateHibernated, Status: types.WarmPoolStatusPendingDelete, }, WarmPoolSize: new(int32(1)), }, }, } items, err := autoScalingGroupOutputMapper(context.Background(), nil, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-launch-template", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "lt-0174ff2b8909d0c75", ExpectedScope: "foo", }, { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service:region:account-id:resource-type/resource-id", ExpectedScope: "account-id.region", }, { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-0be6c4fe789cb1b78", ExpectedScope: "foo", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::944651592624:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling", ExpectedScope: "944651592624", }, { ExpectedType: "autoscaling-launch-configuration", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "launchConfig", ExpectedScope: "foo", }, { ExpectedType: "ec2-launch-template", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-placement-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "placementGroup", ExpectedScope: "foo", }, { ExpectedType: "ec2-launch-template", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "lt-0174ff2b8909d0c75", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestAutoScalingGroupInputMapperSearch(t *testing.T) { t.Parallel() adapter := NewAutoScalingGroupAdapter(&autoscaling.Client{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) tests := []struct { name string query string expectedNames []string expectError bool }{ { name: "Valid AutoScaling Group ARN", query: "arn:aws:autoscaling:eu-west-2:123456789012:autoScalingGroup:1cbb0e22-818f-4d8b-8662-77f73d3713ca:autoScalingGroupName/eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c", expectedNames: []string{"eks-default-20230117110031319900000013-96c2dfb1-a11b-b5e4-6efb-0fea7e22855c"}, expectError: false, }, { name: "Valid AutoScaling Group ARN with hyphenated name", query: "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:abcd1234-5678-90ab-cdef-1234567890ab:autoScalingGroupName/CodeDeploy_sis_imports_adp_worker_d-MUAZOWH2E", expectedNames: []string{"CodeDeploy_sis_imports_adp_worker_d-MUAZOWH2E"}, expectError: false, }, { name: "Invalid ARN - not autoscaling service", query: "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0", expectError: true, }, { name: "Invalid ARN - malformed", query: "not-an-arn/malformed", expectError: true, }, { name: "Invalid ARN - missing autoScalingGroupName", query: "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:abcd1234-5678-90ab-cdef-1234567890ab", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() input, err := adapter.InputMapperSearch(context.Background(), &autoscaling.Client{}, "123456789012.us-east-1", tt.query) if tt.expectError { if err == nil { t.Errorf("Expected error for query %s, but got none", tt.query) } return } if err != nil { t.Errorf("Unexpected error for query %s: %v", tt.query, err) return } if input == nil { t.Errorf("Expected non-nil input for query %s", tt.query) return } if len(input.AutoScalingGroupNames) != len(tt.expectedNames) { t.Errorf("Expected %d AutoScalingGroupNames, got %d. Expected: %v, Actual: %v", len(tt.expectedNames), len(input.AutoScalingGroupNames), tt.expectedNames, input.AutoScalingGroupNames) return } for i, expectedName := range tt.expectedNames { if input.AutoScalingGroupNames[i] != expectedName { t.Errorf("Expected AutoScalingGroupName %s at index %d, got %s", expectedName, i, input.AutoScalingGroupNames[i]) } } }) } } ================================================ FILE: aws-source/adapters/autoscaling-auto-scaling-policy.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/autoscaling" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func scalingPolicyOutputMapper(_ context.Context, _ *autoscaling.Client, scope string, _ *autoscaling.DescribePoliciesInput, output *autoscaling.DescribePoliciesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0, len(output.ScalingPolicies)) for _, policy := range output.ScalingPolicies { // Both AutoScalingGroupName and PolicyName are required to form a unique identifier if policy.AutoScalingGroupName == nil || policy.PolicyName == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "policy is missing AutoScalingGroupName or PolicyName", } } attributes, err := ToAttributesWithExclude(policy) if err != nil { return nil, err } // The uniqueAttributeValue is the combination of ASG name and policy name // i.e., "my-asg/scale-up-policy" err = attributes.Set("UniqueName", fmt.Sprintf("%s/%s", *policy.AutoScalingGroupName, *policy.PolicyName)) if err != nil { return nil, err } item := sdp.Item{ Type: "autoscaling-auto-scaling-policy", UniqueAttribute: "UniqueName", Scope: scope, Attributes: attributes, } // Link to the Auto Scaling Group (already validated as non-nil above) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "autoscaling-auto-scaling-group", Method: sdp.QueryMethod_GET, Query: *policy.AutoScalingGroupName, Scope: scope, }, }) // Link to CloudWatch Alarms for _, alarm := range policy.Alarms { if alarm.AlarmName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudwatch-alarm", Method: sdp.QueryMethod_GET, Query: *alarm.AlarmName, Scope: scope, }, }) } } // Link to ELBv2 resources from TargetTrackingConfiguration if policy.TargetTrackingConfiguration != nil && policy.TargetTrackingConfiguration.PredefinedMetricSpecification != nil && policy.TargetTrackingConfiguration.PredefinedMetricSpecification.ResourceLabel != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, parseResourceLabelLinks(*policy.TargetTrackingConfiguration.PredefinedMetricSpecification.ResourceLabel, scope)...) } // Link to ELBv2 resources from PredictiveScalingConfiguration if policy.PredictiveScalingConfiguration != nil { for _, metricSpec := range policy.PredictiveScalingConfiguration.MetricSpecifications { // PredefinedMetricPairSpecification if metricSpec.PredefinedMetricPairSpecification != nil && metricSpec.PredefinedMetricPairSpecification.ResourceLabel != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, parseResourceLabelLinks(*metricSpec.PredefinedMetricPairSpecification.ResourceLabel, scope)...) } // PredefinedLoadMetricSpecification if metricSpec.PredefinedLoadMetricSpecification != nil && metricSpec.PredefinedLoadMetricSpecification.ResourceLabel != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, parseResourceLabelLinks(*metricSpec.PredefinedLoadMetricSpecification.ResourceLabel, scope)...) } // PredefinedScalingMetricSpecification if metricSpec.PredefinedScalingMetricSpecification != nil && metricSpec.PredefinedScalingMetricSpecification.ResourceLabel != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, parseResourceLabelLinks(*metricSpec.PredefinedScalingMetricSpecification.ResourceLabel, scope)...) } } } items = append(items, &item) } return items, nil } // parseResourceLabelLinks parses a ResourceLabel string and returns LinkedItemQueries // for ELBv2 target groups and load balancers. // The ResourceLabel format is: app/my-alb/778d41231b141a0f/targetgroup/my-alb-target-group/943f017f100becff // Where: // - app// is the final portion of an Application Load Balancer ARN // - net// is the final portion of a Network Load Balancer ARN // - gwy// is the final portion of a Gateway Load Balancer ARN // - targetgroup// is the final portion of the target group ARN func parseResourceLabelLinks(resourceLabel string, scope string) []*sdp.LinkedItemQuery { var links []*sdp.LinkedItemQuery sections := strings.Split(resourceLabel, "/") // Expected format: {app|net|gwy}/lb-name/hash/targetgroup/tg-name/hash (6 sections) if len(sections) < 6 { return links } // Extract load balancer name (index 1 when starting with "app", "net", or "gwy") // These prefixes correspond to ALB, NLB, and GLB respectively if (sections[0] == "app" || sections[0] == "net" || sections[0] == "gwy") && sections[1] != "" { links = append(links, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-load-balancer", Method: sdp.QueryMethod_GET, Query: sections[1], Scope: scope, }, }) } // Find "targetgroup" and extract the target group name (next element) for i, section := range sections { if section == "targetgroup" && i+1 < len(sections) && sections[i+1] != "" { links = append(links, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-target-group", Method: sdp.QueryMethod_GET, Query: sections[i+1], Scope: scope, }, }) break } } return links } func NewAutoScalingPolicyAdapter(client *autoscaling.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*autoscaling.DescribePoliciesInput, *autoscaling.DescribePoliciesOutput, *autoscaling.Client, *autoscaling.Options] { return &DescribeOnlyAdapter[*autoscaling.DescribePoliciesInput, *autoscaling.DescribePoliciesOutput, *autoscaling.Client, *autoscaling.Options]{ ItemType: "autoscaling-auto-scaling-policy", AccountID: accountID, Region: region, Client: client, AdapterMetadata: scalingPolicyAdapterMetadata, cache: cache, InputMapperGet: func(scope, query string) (*autoscaling.DescribePoliciesInput, error) { // Query must be in the format: asgName/policyName // e.g., "my-asg/scale-up-policy" parts := strings.SplitN(query, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format asgName/policyName, got: %s", query), } } return &autoscaling.DescribePoliciesInput{ AutoScalingGroupName: &parts[0], PolicyNames: []string{parts[1]}, }, nil }, InputMapperList: func(scope string) (*autoscaling.DescribePoliciesInput, error) { return &autoscaling.DescribePoliciesInput{}, nil }, InputMapperSearch: func(ctx context.Context, client *autoscaling.Client, scope, query string) (*autoscaling.DescribePoliciesInput, error) { // Parse the ARN to extract the policy name and ASG name // Scaling Policy ARNs have the format: // arn:aws:autoscaling:region:account-id:scalingPolicy:uuid:autoScalingGroupName/group-name:policyName/policy-name arn, err := ParseARN(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid ARN format for autoscaling-auto-scaling-policy", } } // Check if it's an autoscaling ARN if arn.Service != "autoscaling" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not for autoscaling service", } } // The resource part looks like: scalingPolicy:uuid:autoScalingGroupName/group-name:policyName/policy-name // We need to extract the ASG name and policy name if !strings.Contains(arn.Resource, "scalingPolicy:") { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not for a scaling policy", } } var asgName, policyName string // Extract ASG name if strings.Contains(arn.Resource, "autoScalingGroupName/") { parts := strings.Split(arn.Resource, "autoScalingGroupName/") if len(parts) >= 2 { // Now we have something like "group-name:policyName/policy-name" asgPart := parts[1] // Split on ":policyName/" to separate ASG name from policy name part if strings.Contains(asgPart, ":policyName/") { asgPolicyParts := strings.Split(asgPart, ":policyName/") if len(asgPolicyParts) == 2 { asgName = asgPolicyParts[0] policyName = asgPolicyParts[1] } } } } if asgName == "" || policyName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "could not extract ASG name and policy name from ARN", } } return &autoscaling.DescribePoliciesInput{ AutoScalingGroupName: &asgName, PolicyNames: []string{policyName}, }, nil }, PaginatorBuilder: func(client *autoscaling.Client, params *autoscaling.DescribePoliciesInput) Paginator[*autoscaling.DescribePoliciesOutput, *autoscaling.Options] { return autoscaling.NewDescribePoliciesPaginator(client, params) }, DescribeFunc: func(ctx context.Context, client *autoscaling.Client, input *autoscaling.DescribePoliciesInput) (*autoscaling.DescribePoliciesOutput, error) { return client.DescribePolicies(ctx, input) }, OutputMapper: scalingPolicyOutputMapper, } } var scalingPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "autoscaling-auto-scaling-policy", DescriptiveName: "Autoscaling Policy", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an Autoscaling Policy by {asgName}/{policyName}", ListDescription: "List Autoscaling Policies", SearchDescription: "Search for Autoscaling Policies by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_autoscaling_policy.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"autoscaling-auto-scaling-group", "cloudwatch-alarm", "elbv2-load-balancer", "elbv2-target-group"}, }) ================================================ FILE: aws-source/adapters/autoscaling-auto-scaling-policy_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/autoscaling" "github.com/aws/aws-sdk-go-v2/service/autoscaling/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestScalingPolicyOutputMapper(t *testing.T) { t.Parallel() output := autoscaling.DescribePoliciesOutput{ ScalingPolicies: []types.ScalingPolicy{ { PolicyName: new("scale-up-policy"), PolicyARN: new("arn:aws:autoscaling:us-east-1:123456789012:scalingPolicy:12345678-1234-1234-1234-123456789012:autoScalingGroupName/my-asg:policyName/scale-up-policy"), AutoScalingGroupName: new("my-asg"), PolicyType: new("TargetTrackingScaling"), AdjustmentType: new("ChangeInCapacity"), MinAdjustmentMagnitude: new(int32(1)), ScalingAdjustment: new(int32(1)), Cooldown: new(int32(300)), MetricAggregationType: new("Average"), EstimatedInstanceWarmup: new(int32(300)), Enabled: new(true), TargetTrackingConfiguration: &types.TargetTrackingConfiguration{ PredefinedMetricSpecification: &types.PredefinedMetricSpecification{ PredefinedMetricType: types.MetricTypeALBRequestCountPerTarget, ResourceLabel: new("app/my-alb/778d41231b141a0f/targetgroup/my-alb-target-group/943f017f100becff"), }, TargetValue: new(50.0), }, Alarms: []types.Alarm{ { AlarmName: new("my-alarm-high"), AlarmARN: new("arn:aws:cloudwatch:us-east-1:123456789012:alarm:my-alarm-high"), }, { AlarmName: new("my-alarm-low"), AlarmARN: new("arn:aws:cloudwatch:us-east-1:123456789012:alarm:my-alarm-low"), }, }, }, { PolicyName: new("step-scaling-policy"), PolicyARN: new("arn:aws:autoscaling:us-east-1:123456789012:scalingPolicy:87654321-4321-4321-4321-210987654321:autoScalingGroupName/my-asg:policyName/step-scaling-policy"), AutoScalingGroupName: new("my-asg"), PolicyType: new("StepScaling"), AdjustmentType: new("PercentChangeInCapacity"), MinAdjustmentMagnitude: new(int32(2)), MetricAggregationType: new("Average"), EstimatedInstanceWarmup: new(int32(60)), Enabled: new(true), StepAdjustments: []types.StepAdjustment{ { MetricIntervalLowerBound: new(0.0), MetricIntervalUpperBound: new(10.0), ScalingAdjustment: new(int32(10)), }, { MetricIntervalLowerBound: new(10.0), ScalingAdjustment: new(int32(20)), }, }, Alarms: []types.Alarm{ { AlarmName: new("step-alarm"), AlarmARN: new("arn:aws:cloudwatch:us-east-1:123456789012:alarm:step-alarm"), }, }, }, { PolicyName: new("simple-scaling-policy"), PolicyARN: new("arn:aws:autoscaling:us-east-1:123456789012:scalingPolicy:11111111-2222-3333-4444-555555555555:autoScalingGroupName/another-asg:policyName/simple-scaling-policy"), AutoScalingGroupName: new("another-asg"), PolicyType: new("SimpleScaling"), AdjustmentType: new("ExactCapacity"), ScalingAdjustment: new(int32(5)), Cooldown: new(int32(600)), Enabled: new(false), }, { PolicyName: new("predictive-scaling-policy"), PolicyARN: new("arn:aws:autoscaling:us-east-1:123456789012:scalingPolicy:99999999-8888-7777-6666-555555555555:autoScalingGroupName/predictive-asg:policyName/predictive-scaling-policy"), AutoScalingGroupName: new("predictive-asg"), PolicyType: new("PredictiveScaling"), Enabled: new(true), PredictiveScalingConfiguration: &types.PredictiveScalingConfiguration{ MetricSpecifications: []types.PredictiveScalingMetricSpecification{ { TargetValue: new(40.0), PredefinedMetricPairSpecification: &types.PredictiveScalingPredefinedMetricPair{ PredefinedMetricType: types.PredefinedMetricPairTypeALBRequestCount, ResourceLabel: new("app/predictive-alb/abc123def456/targetgroup/predictive-tg/789xyz"), }, }, }, Mode: types.PredictiveScalingModeForecastAndScale, }, }, }, } items, err := scalingPolicyOutputMapper(context.Background(), nil, "test-scope", nil, &output) if err != nil { t.Errorf("Unexpected error: %v", err) } if len(items) != 4 { t.Errorf("Expected 4 items, got %v", len(items)) } for _, item := range items { if err := item.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } } // Test the first policy (TargetTrackingScaling with multiple alarms) item := items[0] if item.GetType() != "autoscaling-auto-scaling-policy" { t.Errorf("Expected type 'autoscaling-auto-scaling-policy', got '%v'", item.GetType()) } if item.GetUniqueAttribute() != "UniqueName" { t.Errorf("Expected unique attribute 'UniqueName', got '%v'", item.GetUniqueAttribute()) } // Verify the UniqueName attribute is set correctly (asgName/policyName format) uniqueName, err := item.GetAttributes().Get("UniqueName") if err != nil { t.Errorf("Expected UniqueName attribute to be set: %v", err) } if uniqueName != "my-asg/scale-up-policy" { t.Errorf("Expected UniqueName 'my-asg/scale-up-policy', got '%v'", uniqueName) } // Check linked items tests := QueryTests{ { ExpectedType: "autoscaling-auto-scaling-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-asg", ExpectedScope: "test-scope", }, { ExpectedType: "cloudwatch-alarm", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-alarm-high", ExpectedScope: "test-scope", }, { ExpectedType: "cloudwatch-alarm", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-alarm-low", ExpectedScope: "test-scope", }, { ExpectedType: "elbv2-load-balancer", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-alb", ExpectedScope: "test-scope", }, { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-alb-target-group", ExpectedScope: "test-scope", }, } tests.Execute(t, item) // Test the second policy (StepScaling) item2 := items[1] tests2 := QueryTests{ { ExpectedType: "autoscaling-auto-scaling-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-asg", ExpectedScope: "test-scope", }, { ExpectedType: "cloudwatch-alarm", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "step-alarm", ExpectedScope: "test-scope", }, } tests2.Execute(t, item2) // Test the third policy (SimpleScaling with no alarms) item3 := items[2] tests3 := QueryTests{ { ExpectedType: "autoscaling-auto-scaling-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "another-asg", ExpectedScope: "test-scope", }, } tests3.Execute(t, item3) // Verify the third policy has no alarm links alarmLinkCount := 0 for _, link := range item3.GetLinkedItemQueries() { if link.GetQuery().GetType() == "cloudwatch-alarm" { alarmLinkCount++ } } if alarmLinkCount != 0 { t.Errorf("Expected 0 alarm links for simple-scaling-policy, got %v", alarmLinkCount) } // Test the fourth policy (PredictiveScaling with ALB ResourceLabel) item4 := items[3] tests4 := QueryTests{ { ExpectedType: "autoscaling-auto-scaling-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "predictive-asg", ExpectedScope: "test-scope", }, { ExpectedType: "elbv2-load-balancer", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "predictive-alb", ExpectedScope: "test-scope", }, { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "predictive-tg", ExpectedScope: "test-scope", }, } tests4.Execute(t, item4) } func TestParseResourceLabelLinks(t *testing.T) { t.Parallel() tests := []struct { name string resourceLabel string expectedLBName string expectedTGName string expectedCount int }{ { name: "Valid ALB resource label", resourceLabel: "app/my-alb/778d41231b141a0f/targetgroup/my-target-group/943f017f100becff", expectedLBName: "my-alb", expectedTGName: "my-target-group", expectedCount: 2, }, { name: "Valid ALB resource label with hyphens", resourceLabel: "app/my-load-balancer-name/abc123/targetgroup/my-tg-name/def456", expectedLBName: "my-load-balancer-name", expectedTGName: "my-tg-name", expectedCount: 2, }, { name: "Valid NLB resource label", resourceLabel: "net/my-nlb/778d41231b141a0f/targetgroup/my-target-group/943f017f100becff", expectedLBName: "my-nlb", expectedTGName: "my-target-group", expectedCount: 2, }, { name: "Valid GLB resource label", resourceLabel: "gwy/my-glb/778d41231b141a0f/targetgroup/my-target-group/943f017f100becff", expectedLBName: "my-glb", expectedTGName: "my-target-group", expectedCount: 2, }, { name: "Too few sections", resourceLabel: "app/my-alb/targetgroup", expectedCount: 0, }, { name: "Empty string", resourceLabel: "", expectedCount: 0, }, { name: "Unknown prefix", resourceLabel: "unknown/my-lb/778d41231b141a0f/targetgroup/my-target-group/943f017f100becff", expectedLBName: "", expectedTGName: "my-target-group", expectedCount: 1, // Only target group, no LB for unknown prefix }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() links := parseResourceLabelLinks(tt.resourceLabel, "test-scope") if len(links) != tt.expectedCount { t.Errorf("Expected %d links, got %d", tt.expectedCount, len(links)) return } if tt.expectedCount == 0 { return } // Check for load balancer link if tt.expectedLBName != "" { foundLB := false for _, link := range links { if link.GetQuery().GetType() == "elbv2-load-balancer" { foundLB = true if link.GetQuery().GetQuery() != tt.expectedLBName { t.Errorf("Expected LB name %s, got %s", tt.expectedLBName, link.GetQuery().GetQuery()) } if link.GetQuery().GetScope() != "test-scope" { t.Errorf("Expected scope test-scope, got %s", link.GetQuery().GetScope()) } } } if !foundLB { t.Error("Expected load balancer link not found") } } // Check for target group link if tt.expectedTGName != "" { foundTG := false for _, link := range links { if link.GetQuery().GetType() == "elbv2-target-group" { foundTG = true if link.GetQuery().GetQuery() != tt.expectedTGName { t.Errorf("Expected TG name %s, got %s", tt.expectedTGName, link.GetQuery().GetQuery()) } if link.GetQuery().GetScope() != "test-scope" { t.Errorf("Expected scope test-scope, got %s", link.GetQuery().GetScope()) } } } if !foundTG { t.Error("Expected target group link not found") } } }) } } func TestScalingPolicyInputMapperSearch(t *testing.T) { t.Parallel() adapter := NewAutoScalingPolicyAdapter(&autoscaling.Client{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) tests := []struct { name string query string expectedASGName string expectedPolicyName string expectError bool }{ { name: "Valid Scaling Policy ARN", query: "arn:aws:autoscaling:us-east-1:123456789012:scalingPolicy:12345678-1234-1234-1234-123456789012:autoScalingGroupName/my-asg:policyName/scale-up-policy", expectedASGName: "my-asg", expectedPolicyName: "scale-up-policy", expectError: false, }, { name: "Valid Scaling Policy ARN with hyphenated names", query: "arn:aws:autoscaling:eu-west-2:987654321098:scalingPolicy:abcd1234-5678-90ab-cdef-1234567890ab:autoScalingGroupName/my-test-asg-name:policyName/my-test-policy-name", expectedASGName: "my-test-asg-name", expectedPolicyName: "my-test-policy-name", expectError: false, }, { name: "Valid Scaling Policy ARN with underscores", query: "arn:aws:autoscaling:ap-southeast-1:111222333444:scalingPolicy:11111111-2222-3333-4444-555555555555:autoScalingGroupName/my_asg_name:policyName/my_policy_name", expectedASGName: "my_asg_name", expectedPolicyName: "my_policy_name", expectError: false, }, { name: "Invalid ARN - not autoscaling service", query: "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0", expectError: true, }, { name: "Invalid ARN - malformed", query: "not-an-arn/malformed", expectError: true, }, { name: "Invalid ARN - not a scaling policy", query: "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:12345678-1234-1234-1234-123456789012:autoScalingGroupName/my-asg", expectError: true, }, { name: "Invalid ARN - missing autoScalingGroupName", query: "arn:aws:autoscaling:us-east-1:123456789012:scalingPolicy:12345678-1234-1234-1234-123456789012:policyName/scale-up-policy", expectError: true, }, { name: "Invalid ARN - missing policyName", query: "arn:aws:autoscaling:us-east-1:123456789012:scalingPolicy:12345678-1234-1234-1234-123456789012:autoScalingGroupName/my-asg", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() input, err := adapter.InputMapperSearch(context.Background(), &autoscaling.Client{}, "123456789012.us-east-1", tt.query) if tt.expectError { if err == nil { t.Errorf("Expected error for query %s, but got none", tt.query) } return } if err != nil { t.Errorf("Unexpected error for query %s: %v", tt.query, err) return } if input == nil { t.Errorf("Expected non-nil input for query %s", tt.query) return } if input.AutoScalingGroupName == nil { t.Errorf("Expected non-nil AutoScalingGroupName for query %s", tt.query) return } if *input.AutoScalingGroupName != tt.expectedASGName { t.Errorf("Expected AutoScalingGroupName %s, got %s", tt.expectedASGName, *input.AutoScalingGroupName) } if len(input.PolicyNames) != 1 { t.Errorf("Expected 1 PolicyName, got %d. PolicyNames: %v", len(input.PolicyNames), input.PolicyNames) return } if input.PolicyNames[0] != tt.expectedPolicyName { t.Errorf("Expected PolicyName %s, got %s", tt.expectedPolicyName, input.PolicyNames[0]) } }) } } func TestScalingPolicyInputMapperGet(t *testing.T) { t.Parallel() adapter := NewAutoScalingPolicyAdapter(&autoscaling.Client{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) tests := []struct { name string query string expectedASGName string expectedPolicyName string expectError bool }{ { name: "Valid composite key", query: "my-asg/scale-up-policy", expectedASGName: "my-asg", expectedPolicyName: "scale-up-policy", expectError: false, }, { name: "Valid composite key with hyphenated names", query: "my-test-asg-name/my-test-policy-name", expectedASGName: "my-test-asg-name", expectedPolicyName: "my-test-policy-name", expectError: false, }, { name: "Valid composite key with underscores", query: "my_asg_name/my_policy_name", expectedASGName: "my_asg_name", expectedPolicyName: "my_policy_name", expectError: false, }, { name: "Valid composite key with slashes in policy name", query: "my-asg/path/to/policy", expectedASGName: "my-asg", expectedPolicyName: "path/to/policy", expectError: false, }, { name: "Invalid - missing policy name", query: "my-asg/", expectError: true, }, { name: "Invalid - missing ASG name", query: "/scale-up-policy", expectError: true, }, { name: "Invalid - no slash separator", query: "just-a-policy-name", expectError: true, }, { name: "Invalid - empty string", query: "", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() input, err := adapter.InputMapperGet("123456789012.us-east-1", tt.query) if tt.expectError { if err == nil { t.Errorf("Expected error for query %s, but got none", tt.query) } return } if err != nil { t.Errorf("Unexpected error for query %s: %v", tt.query, err) return } if input == nil { t.Errorf("Expected non-nil input for query %s", tt.query) return } if input.AutoScalingGroupName == nil { t.Errorf("Expected non-nil AutoScalingGroupName for query %s", tt.query) return } if *input.AutoScalingGroupName != tt.expectedASGName { t.Errorf("Expected AutoScalingGroupName %s, got %s", tt.expectedASGName, *input.AutoScalingGroupName) } if len(input.PolicyNames) != 1 { t.Errorf("Expected 1 PolicyName, got %d. PolicyNames: %v", len(input.PolicyNames), input.PolicyNames) return } if input.PolicyNames[0] != tt.expectedPolicyName { t.Errorf("Expected PolicyName %s, got %s", tt.expectedPolicyName, input.PolicyNames[0]) } }) } } ================================================ FILE: aws-source/adapters/cloudfront-cache-policy.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func cachePolicyListFunc(ctx context.Context, client CloudFrontClient, scope string) ([]*types.CachePolicy, error) { var policyType types.CachePolicyType switch scope { case "aws": policyType = types.CachePolicyTypeManaged default: policyType = types.CachePolicyTypeCustom } out, err := client.ListCachePolicies(ctx, &cloudfront.ListCachePoliciesInput{ Type: policyType, }) if err != nil { return nil, err } policies := make([]*types.CachePolicy, 0, len(out.CachePolicyList.Items)) for i := range out.CachePolicyList.Items { policies = append(policies, out.CachePolicyList.Items[i].CachePolicy) } return policies, nil } func NewCloudfrontCachePolicyAdapter(client CloudFrontClient, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.CachePolicy, CloudFrontClient, *cloudfront.Options] { return &GetListAdapter[*types.CachePolicy, CloudFrontClient, *cloudfront.Options]{ ItemType: "cloudfront-cache-policy", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: cachePolicyAdapterMetadata, cache: cache, SupportGlobalResources: true, // Some policies are global GetFunc: func(ctx context.Context, client CloudFrontClient, scope, query string) (*types.CachePolicy, error) { out, err := client.GetCachePolicy(ctx, &cloudfront.GetCachePolicyInput{ Id: &query, }) if err != nil { return nil, err } return out.CachePolicy, nil }, ListFunc: cachePolicyListFunc, ItemMapper: func(_, scope string, awsItem *types.CachePolicy) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-cache-policy", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } return &item, nil }, } } var cachePolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-cache-policy", DescriptiveName: "CloudFront Cache Policy", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a CloudFront Cache Policy", ListDescription: "List CloudFront Cache Policies", SearchDescription: "Search CloudFront Cache Policies by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_cloudfront_cache_policy.id"}, }, }) ================================================ FILE: aws-source/adapters/cloudfront-cache-policy_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdpcache" ) var testCachePolicy = &types.CachePolicy{ Id: new("test-id"), LastModifiedTime: new(time.Now()), CachePolicyConfig: &types.CachePolicyConfig{ MinTTL: new(int64(1)), Name: new("test-name"), Comment: new("test-comment"), DefaultTTL: new(int64(1)), MaxTTL: new(int64(1)), ParametersInCacheKeyAndForwardedToOrigin: &types.ParametersInCacheKeyAndForwardedToOrigin{ CookiesConfig: &types.CachePolicyCookiesConfig{ CookieBehavior: types.CachePolicyCookieBehaviorAll, Cookies: &types.CookieNames{ Quantity: new(int32(1)), Items: []string{ "test-cookie", }, }, }, EnableAcceptEncodingGzip: new(true), HeadersConfig: &types.CachePolicyHeadersConfig{ HeaderBehavior: types.CachePolicyHeaderBehaviorWhitelist, Headers: &types.Headers{ Quantity: new(int32(1)), Items: []string{ "test-header", }, }, }, QueryStringsConfig: &types.CachePolicyQueryStringsConfig{ QueryStringBehavior: types.CachePolicyQueryStringBehaviorWhitelist, QueryStrings: &types.QueryStringNames{ Quantity: new(int32(1)), Items: []string{ "test-query-string", }, }, }, EnableAcceptEncodingBrotli: new(true), }, }, } func (t TestCloudFrontClient) ListCachePolicies(ctx context.Context, params *cloudfront.ListCachePoliciesInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListCachePoliciesOutput, error) { return &cloudfront.ListCachePoliciesOutput{ CachePolicyList: &types.CachePolicyList{ Items: []types.CachePolicySummary{ { Type: types.CachePolicyTypeManaged, CachePolicy: testCachePolicy, }, }, }, }, nil } func (t TestCloudFrontClient) GetCachePolicy(ctx context.Context, params *cloudfront.GetCachePolicyInput, optFns ...func(*cloudfront.Options)) (*cloudfront.GetCachePolicyOutput, error) { return &cloudfront.GetCachePolicyOutput{ CachePolicy: testCachePolicy, }, nil } func TestCachePolicyListFunc(t *testing.T) { policies, err := cachePolicyListFunc(context.Background(), TestCloudFrontClient{}, "aws") if err != nil { t.Fatal(err) } if len(policies) != 1 { t.Fatalf("expected 1 policy, got %d", len(policies)) } } func TestNewCloudfrontCachePolicyAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontCachePolicyAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-continuous-deployment-policy.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func continuousDeploymentPolicyItemMapper(_, scope string, awsItem *types.ContinuousDeploymentPolicy) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-continuous-deployment-policy", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } if awsItem.ContinuousDeploymentPolicyConfig != nil && awsItem.ContinuousDeploymentPolicyConfig.StagingDistributionDnsNames != nil { for _, name := range awsItem.ContinuousDeploymentPolicyConfig.StagingDistributionDnsNames.Items { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: name, Scope: "global", }, }) } } return &item, nil } // Terraform is not yet supported for this: https://github.com/hashicorp/terraform-provider-aws/issues/28920 func NewCloudfrontContinuousDeploymentPolicyAdapter(client *cloudfront.Client, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.ContinuousDeploymentPolicy, *cloudfront.Client, *cloudfront.Options] { return &GetListAdapter[*types.ContinuousDeploymentPolicy, *cloudfront.Client, *cloudfront.Options]{ ItemType: "cloudfront-continuous-deployment-policy", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region SupportGlobalResources: true, // Some policies are global AdapterMetadata: continuousDeploymentPolicyAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *cloudfront.Client, scope, query string) (*types.ContinuousDeploymentPolicy, error) { out, err := client.GetContinuousDeploymentPolicy(ctx, &cloudfront.GetContinuousDeploymentPolicyInput{ Id: &query, }) if err != nil { return nil, err } return out.ContinuousDeploymentPolicy, nil }, ListFunc: func(ctx context.Context, client *cloudfront.Client, scope string) ([]*types.ContinuousDeploymentPolicy, error) { out, err := client.ListContinuousDeploymentPolicies(ctx, &cloudfront.ListContinuousDeploymentPoliciesInput{}) if err != nil { return nil, err } policies := make([]*types.ContinuousDeploymentPolicy, 0, len(out.ContinuousDeploymentPolicyList.Items)) for _, policy := range out.ContinuousDeploymentPolicyList.Items { policies = append(policies, policy.ContinuousDeploymentPolicy) } return policies, nil }, ItemMapper: continuousDeploymentPolicyItemMapper, } } var continuousDeploymentPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-continuous-deployment-policy", DescriptiveName: "CloudFront Continuous Deployment Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a CloudFront Continuous Deployment Policy by ID", ListDescription: "List CloudFront Continuous Deployment Policies", SearchDescription: "Search CloudFront Continuous Deployment Policies by ARN", }, PotentialLinks: []string{"dns"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/cloudfront-continuous-deployment-policy_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestContinuousDeploymentPolicyItemMapper(t *testing.T) { item, err := continuousDeploymentPolicyItemMapper("", "test", &types.ContinuousDeploymentPolicy{ Id: new("test-id"), LastModifiedTime: new(time.Now()), ContinuousDeploymentPolicyConfig: &types.ContinuousDeploymentPolicyConfig{ Enabled: new(true), StagingDistributionDnsNames: &types.StagingDistributionDnsNames{ Quantity: new(int32(1)), Items: []string{ "staging.test.com", // link }, }, TrafficConfig: &types.TrafficConfig{ Type: types.ContinuousDeploymentPolicyTypeSingleWeight, SingleHeaderConfig: &types.ContinuousDeploymentSingleHeaderConfig{ Header: new("test-header"), Value: new("test-value"), }, SingleWeightConfig: &types.ContinuousDeploymentSingleWeightConfig{ Weight: new(float32(1)), SessionStickinessConfig: &types.SessionStickinessConfig{ IdleTTL: new(int32(1)), MaximumTTL: new(int32(2)), }, }, }, }, }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "staging.test.com", ExpectedScope: "global", }, } tests.Execute(t, item) } func TestNewCloudfrontContinuousDeploymentPolicyAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontContinuousDeploymentPolicyAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-distribution.go ================================================ package adapters import ( "context" "regexp" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var s3DnsRegex = regexp.MustCompile(`([^\.]+)\.s3\.([^\.]+)\.amazonaws\.com`) func distributionGetFunc(ctx context.Context, client CloudFrontClient, scope string, input *cloudfront.GetDistributionInput) (*sdp.Item, error) { out, err := client.GetDistribution(ctx, input) if err != nil { return nil, err } d := out.Distribution if d == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "distribution was nil", } } var tags map[string]string // get tags tagsOut, err := client.ListTagsForResource(ctx, &cloudfront.ListTagsForResourceInput{ Resource: d.ARN, }) if err == nil { tags = cloudfrontTagsToMap(tagsOut.Tags) } else { tags = HandleTagsError(ctx, err) } attributes, err := ToAttributesWithExclude(d) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-distribution", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, Tags: tags, } if d.Status != nil { switch *d.Status { case "InProgress": item.Health = sdp.Health_HEALTH_PENDING.Enum() case "Deployed": item.Health = sdp.Health_HEALTH_OK.Enum() } } if d.DomainName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *d.DomainName, Scope: "global", }, }) } if d.ActiveTrustedKeyGroups != nil { for _, keyGroup := range d.ActiveTrustedKeyGroups.Items { if keyGroup.KeyGroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-key-group", Method: sdp.QueryMethod_GET, Query: *keyGroup.KeyGroupId, Scope: scope, }, }) } } } for _, record := range d.AliasICPRecordals { if record.CNAME != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *record.CNAME, Scope: "global", }, }) } } if dc := d.DistributionConfig; dc != nil { if dc.Aliases != nil { for _, alias := range dc.Aliases.Items { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: alias, Scope: "global", }, }) } } if dc.ContinuousDeploymentPolicyId != nil && *dc.ContinuousDeploymentPolicyId != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-continuous-deployment-policy", Method: sdp.QueryMethod_GET, Query: *dc.ContinuousDeploymentPolicyId, Scope: scope, }, }) } if dc.CacheBehaviors != nil { for _, behavior := range dc.CacheBehaviors.Items { if behavior.CachePolicyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-cache-policy", Method: sdp.QueryMethod_GET, Query: *behavior.CachePolicyId, Scope: scope, }, }) } if behavior.FieldLevelEncryptionId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-field-level-encryption", Method: sdp.QueryMethod_GET, Query: *behavior.FieldLevelEncryptionId, Scope: scope, }, }) } if behavior.OriginRequestPolicyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-origin-request-policy", Method: sdp.QueryMethod_GET, Query: *behavior.OriginRequestPolicyId, Scope: scope, }, }) } if behavior.RealtimeLogConfigArn != nil { if arn, err := ParseARN(*behavior.RealtimeLogConfigArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-realtime-log-config", Method: sdp.QueryMethod_SEARCH, Query: *behavior.RealtimeLogConfigArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if behavior.ResponseHeadersPolicyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-response-headers-policy", Method: sdp.QueryMethod_GET, Query: *behavior.ResponseHeadersPolicyId, Scope: scope, }, }) } if behavior.TrustedKeyGroups != nil { for _, keyGroup := range behavior.TrustedKeyGroups.Items { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-key-group", Query: keyGroup, Method: sdp.QueryMethod_GET, Scope: scope, }, }) } } if behavior.FunctionAssociations != nil { for _, function := range behavior.FunctionAssociations.Items { if function.FunctionARN != nil { if arn, err := ParseARN(*function.FunctionARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-function", Method: sdp.QueryMethod_SEARCH, Query: *function.FunctionARN, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } } if behavior.LambdaFunctionAssociations != nil { for _, function := range behavior.LambdaFunctionAssociations.Items { if arn, err := ParseARN(*function.LambdaFunctionARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_SEARCH, Query: *function.LambdaFunctionARN, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } } } if dc.Origins != nil { for _, origin := range dc.Origins.Items { if origin.DomainName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *origin.DomainName, Scope: "global", }, }) } if origin.OriginAccessControlId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-origin-access-control", Method: sdp.QueryMethod_GET, Query: *origin.OriginAccessControlId, Scope: scope, }, }) } if origin.S3OriginConfig != nil { // If this is set then the origin is an S3 bucket, so we can // try to get the bucket name from the domain name if origin.DomainName != nil { matches := s3DnsRegex.FindStringSubmatch(*origin.DomainName) if len(matches) == 3 { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "s3-bucket", Method: sdp.QueryMethod_GET, Query: matches[1], Scope: FormatScope(scope, ""), // S3 buckets are global }, }) } } if origin.S3OriginConfig.OriginAccessIdentity != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-cloud-front-origin-access-identity", Method: sdp.QueryMethod_GET, Query: *origin.S3OriginConfig.OriginAccessIdentity, Scope: scope, }, }) } } } } if dc.DefaultCacheBehavior != nil { if dc.DefaultCacheBehavior.CachePolicyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-cache-policy", Method: sdp.QueryMethod_GET, Query: *dc.DefaultCacheBehavior.CachePolicyId, Scope: scope, }, }) } if dc.DefaultCacheBehavior.FieldLevelEncryptionId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-field-level-encryption", Method: sdp.QueryMethod_GET, Query: *dc.DefaultCacheBehavior.FieldLevelEncryptionId, Scope: scope, }, }) } if dc.DefaultCacheBehavior.OriginRequestPolicyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-origin-request-policy", Method: sdp.QueryMethod_GET, Query: *dc.DefaultCacheBehavior.OriginRequestPolicyId, Scope: scope, }, }) } if dc.DefaultCacheBehavior.RealtimeLogConfigArn != nil { if arn, err := ParseARN(*dc.DefaultCacheBehavior.RealtimeLogConfigArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-realtime-log-config", Method: sdp.QueryMethod_GET, Query: *dc.DefaultCacheBehavior.RealtimeLogConfigArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if dc.DefaultCacheBehavior.ResponseHeadersPolicyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-response-headers-policy", Method: sdp.QueryMethod_GET, Query: *dc.DefaultCacheBehavior.ResponseHeadersPolicyId, Scope: scope, }, }) } if dc.DefaultCacheBehavior.TrustedKeyGroups != nil { for _, keyGroup := range dc.DefaultCacheBehavior.TrustedKeyGroups.Items { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-key-group", Query: keyGroup, Method: sdp.QueryMethod_GET, Scope: scope, }, }) } } if dc.DefaultCacheBehavior.FunctionAssociations != nil { for _, function := range dc.DefaultCacheBehavior.FunctionAssociations.Items { if function.FunctionARN != nil { if arn, err := ParseARN(*function.FunctionARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-function", Method: sdp.QueryMethod_SEARCH, Query: *function.FunctionARN, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } } if dc.DefaultCacheBehavior.LambdaFunctionAssociations != nil { for _, function := range dc.DefaultCacheBehavior.LambdaFunctionAssociations.Items { if arn, err := ParseARN(*function.LambdaFunctionARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_SEARCH, Query: *function.LambdaFunctionARN, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } } if dc.Logging != nil && dc.Logging.Bucket != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *dc.Logging.Bucket, Scope: "global", }, }) } if dc.ViewerCertificate != nil { if dc.ViewerCertificate.ACMCertificateArn != nil { if arn, err := ParseARN(*dc.ViewerCertificate.ACMCertificateArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_SEARCH, Query: *dc.ViewerCertificate.ACMCertificateArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if dc.ViewerCertificate.IAMCertificateId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-server-certificate", Method: sdp.QueryMethod_GET, Query: *dc.ViewerCertificate.IAMCertificateId, Scope: scope, }, }) } } if dc.WebACLId != nil { if arn, err := ParseARN(*dc.WebACLId); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "wafv2-web-acl", Method: sdp.QueryMethod_SEARCH, Query: *dc.WebACLId, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } else { // Else assume it's a V1 ID item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "waf-web-acl", Method: sdp.QueryMethod_GET, Query: *dc.WebACLId, Scope: scope, }, }) } } } return &item, nil } func NewCloudfrontDistributionAdapter(client CloudFrontClient, accountID string, cache sdpcache.Cache) *AlwaysGetAdapter[*cloudfront.ListDistributionsInput, *cloudfront.ListDistributionsOutput, *cloudfront.GetDistributionInput, *cloudfront.GetDistributionOutput, CloudFrontClient, *cloudfront.Options] { return &AlwaysGetAdapter[*cloudfront.ListDistributionsInput, *cloudfront.ListDistributionsOutput, *cloudfront.GetDistributionInput, *cloudfront.GetDistributionOutput, CloudFrontClient, *cloudfront.Options]{ ItemType: "cloudfront-distribution", Client: client, AccountID: accountID, AdapterMetadata: distributionAdapterMetadata, cache: cache, Region: "", // Cloudfront resources aren't tied to a region ListInput: &cloudfront.ListDistributionsInput{}, ListFuncPaginatorBuilder: func(client CloudFrontClient, input *cloudfront.ListDistributionsInput) Paginator[*cloudfront.ListDistributionsOutput, *cloudfront.Options] { return cloudfront.NewListDistributionsPaginator(client, input) }, GetInputMapper: func(scope, query string) *cloudfront.GetDistributionInput { return &cloudfront.GetDistributionInput{ Id: &query, } }, ListFuncOutputMapper: func(output *cloudfront.ListDistributionsOutput, input *cloudfront.ListDistributionsInput) ([]*cloudfront.GetDistributionInput, error) { var inputs []*cloudfront.GetDistributionInput for _, distribution := range output.DistributionList.Items { inputs = append(inputs, &cloudfront.GetDistributionInput{ Id: distribution.Id, }) } return inputs, nil }, GetFunc: distributionGetFunc, } } var distributionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-distribution", DescriptiveName: "CloudFront Distribution", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Search: true, Get: true, List: true, GetDescription: "Get a distribution by ID", ListDescription: "List all distributions", SearchDescription: "Search distributions by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_cloudfront_distribution.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{ "cloudfront-key-group", "cloudfront-cloud-front-origin-access-identity", "cloudfront-continuous-deployment-policy", "cloudfront-cache-policy", "cloudfront-field-level-encryption", "cloudfront-function", "cloudfront-origin-request-policy", "cloudfront-realtime-log-config", "cloudfront-response-headers-policy", "dns", "lambda-function", "s3-bucket", }, }) ================================================ FILE: aws-source/adapters/cloudfront-distribution_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t TestCloudFrontClient) GetDistribution(ctx context.Context, params *cloudfront.GetDistributionInput, optFns ...func(*cloudfront.Options)) (*cloudfront.GetDistributionOutput, error) { return &cloudfront.GetDistributionOutput{ Distribution: &types.Distribution{ ARN: new("arn:aws:cloudfront::123456789012:distribution/test-id"), DomainName: new("d111111abcdef8.cloudfront.net"), // link Id: new("test-id"), InProgressInvalidationBatches: new(int32(1)), LastModifiedTime: new(time.Now()), Status: new("Deployed"), // health: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-returned.html ActiveTrustedKeyGroups: &types.ActiveTrustedKeyGroups{ Enabled: new(true), Quantity: new(int32(1)), Items: []types.KGKeyPairIds{ { KeyGroupId: new("key-group-1"), // link KeyPairIds: &types.KeyPairIds{ Quantity: new(int32(1)), Items: []string{ "123456789", }, }, }, }, }, ActiveTrustedSigners: &types.ActiveTrustedSigners{ Enabled: new(true), Quantity: new(int32(1)), Items: []types.Signer{ { AwsAccountNumber: new("123456789"), KeyPairIds: &types.KeyPairIds{ Quantity: new(int32(1)), Items: []string{ "123456789", }, }, }, }, }, AliasICPRecordals: []types.AliasICPRecordal{ { CNAME: new("something.foo.bar.com"), // link ICPRecordalStatus: types.ICPRecordalStatusApproved, }, }, DistributionConfig: &types.DistributionConfig{ CallerReference: new("test-caller-reference"), Comment: new("test-comment"), Enabled: new(true), Aliases: &types.Aliases{ Quantity: new(int32(1)), Items: []string{ "www.example.com", // link }, }, Staging: new(true), ContinuousDeploymentPolicyId: new("test-continuous-deployment-policy-id"), // link CacheBehaviors: &types.CacheBehaviors{ Quantity: new(int32(1)), Items: []types.CacheBehavior{ { PathPattern: new("/foo"), TargetOriginId: new("CustomOriginConfig"), ViewerProtocolPolicy: types.ViewerProtocolPolicyHttpsOnly, AllowedMethods: &types.AllowedMethods{ Items: []types.Method{ types.MethodGet, }, }, CachePolicyId: new("test-cache-policy-id"), // link Compress: new(true), DefaultTTL: new(int64(1)), FieldLevelEncryptionId: new("test-field-level-encryption-id"), // link MaxTTL: new(int64(1)), MinTTL: new(int64(1)), OriginRequestPolicyId: new("test-origin-request-policy-id"), // link RealtimeLogConfigArn: new("arn:aws:logs:us-east-1:123456789012:realtime-log-config/test-id"), // link ResponseHeadersPolicyId: new("test-response-headers-policy-id"), // link SmoothStreaming: new(true), TrustedKeyGroups: &types.TrustedKeyGroups{ Enabled: new(true), Quantity: new(int32(1)), Items: []string{ "key-group-1", // link }, }, TrustedSigners: &types.TrustedSigners{ Enabled: new(true), Quantity: new(int32(1)), Items: []string{ "123456789", }, }, ForwardedValues: &types.ForwardedValues{ Cookies: &types.CookiePreference{ Forward: types.ItemSelectionWhitelist, WhitelistedNames: &types.CookieNames{ Quantity: new(int32(1)), Items: []string{ "cookie_123", }, }, }, QueryString: new(true), Headers: &types.Headers{ Quantity: new(int32(1)), Items: []string{ "X-Customer-Header", }, }, QueryStringCacheKeys: &types.QueryStringCacheKeys{ Quantity: new(int32(1)), Items: []string{ "test-query-string-cache-key", }, }, }, FunctionAssociations: &types.FunctionAssociations{ Quantity: new(int32(1)), Items: []types.FunctionAssociation{ { EventType: types.EventTypeOriginRequest, FunctionARN: new("arn:aws:cloudfront::123412341234:function/1234"), // link }, }, }, LambdaFunctionAssociations: &types.LambdaFunctionAssociations{ Quantity: new(int32(1)), Items: []types.LambdaFunctionAssociation{ { EventType: types.EventTypeOriginResponse, LambdaFunctionARN: new("arn:aws:lambda:us-east-1:123456789012:function:test-function"), // link IncludeBody: new(true), }, }, }, }, }, }, Origins: &types.Origins{ Items: []types.Origin{ { DomainName: new("DOC-EXAMPLE-BUCKET.s3.us-west-2.amazonaws.com"), // link Id: new("CustomOriginConfig"), ConnectionAttempts: new(int32(3)), ConnectionTimeout: new(int32(10)), CustomHeaders: &types.CustomHeaders{ Quantity: new(int32(1)), Items: []types.OriginCustomHeader{ { HeaderName: new("test-header-name"), HeaderValue: new("test-header-value"), }, }, }, CustomOriginConfig: &types.CustomOriginConfig{ HTTPPort: new(int32(80)), HTTPSPort: new(int32(443)), OriginProtocolPolicy: types.OriginProtocolPolicyMatchViewer, OriginKeepaliveTimeout: new(int32(5)), OriginReadTimeout: new(int32(30)), OriginSslProtocols: &types.OriginSslProtocols{ Items: types.SslProtocolSSLv3.Values(), }, }, OriginAccessControlId: new("test-origin-access-control-id"), // link OriginPath: new("/foo"), OriginShield: &types.OriginShield{ Enabled: new(true), OriginShieldRegion: new("eu-west-1"), }, S3OriginConfig: &types.S3OriginConfig{ OriginAccessIdentity: new("test-origin-access-identity"), // link }, }, }, }, DefaultCacheBehavior: &types.DefaultCacheBehavior{ TargetOriginId: new("CustomOriginConfig"), ViewerProtocolPolicy: types.ViewerProtocolPolicyHttpsOnly, CachePolicyId: new("test-cache-policy-id"), // link Compress: new(true), DefaultTTL: new(int64(1)), FieldLevelEncryptionId: new("test-field-level-encryption-id"), // link MaxTTL: new(int64(1)), MinTTL: new(int64(1)), OriginRequestPolicyId: new("test-origin-request-policy-id"), // link RealtimeLogConfigArn: new("arn:aws:logs:us-east-1:123456789012:realtime-log-config/test-id"), // link ResponseHeadersPolicyId: new("test-response-headers-policy-id"), // link SmoothStreaming: new(true), ForwardedValues: &types.ForwardedValues{ Cookies: &types.CookiePreference{ Forward: types.ItemSelectionWhitelist, WhitelistedNames: &types.CookieNames{ Quantity: new(int32(1)), Items: []string{ "cooke_123", }, }, }, QueryString: new(true), Headers: &types.Headers{ Quantity: new(int32(1)), Items: []string{ "X-Customer-Header", }, }, QueryStringCacheKeys: &types.QueryStringCacheKeys{ Quantity: new(int32(1)), Items: []string{ "test-query-string-cache-key", }, }, }, FunctionAssociations: &types.FunctionAssociations{ Quantity: new(int32(1)), Items: []types.FunctionAssociation{ { EventType: types.EventTypeViewerRequest, FunctionARN: new("arn:aws:cloudfront::123412341234:function/1234"), // link }, }, }, LambdaFunctionAssociations: &types.LambdaFunctionAssociations{ Quantity: new(int32(1)), Items: []types.LambdaFunctionAssociation{ { EventType: types.EventTypeOriginRequest, LambdaFunctionARN: new("arn:aws:lambda:us-east-1:123456789012:function:test-function"), // link IncludeBody: new(true), }, }, }, TrustedKeyGroups: &types.TrustedKeyGroups{ Enabled: new(true), Quantity: new(int32(1)), Items: []string{ "key-group-1", // link }, }, TrustedSigners: &types.TrustedSigners{ Enabled: new(true), Quantity: new(int32(1)), Items: []string{ "123456789", }, }, AllowedMethods: &types.AllowedMethods{ Items: []types.Method{ types.MethodGet, }, Quantity: new(int32(1)), CachedMethods: &types.CachedMethods{ Items: []types.Method{ types.MethodGet, }, }, }, }, CustomErrorResponses: &types.CustomErrorResponses{ Quantity: new(int32(1)), Items: []types.CustomErrorResponse{ { ErrorCode: new(int32(404)), ErrorCachingMinTTL: new(int64(1)), ResponseCode: new("200"), ResponsePagePath: new("/foo"), }, }, }, DefaultRootObject: new("index.html"), HttpVersion: types.HttpVersionHttp11, IsIPV6Enabled: new(true), Logging: &types.LoggingConfig{ Bucket: new("aws-cf-access-logs.s3.amazonaws.com"), // link Enabled: new(true), IncludeCookies: new(true), Prefix: new("test-prefix"), }, OriginGroups: &types.OriginGroups{ Quantity: new(int32(1)), Items: []types.OriginGroup{ { FailoverCriteria: &types.OriginGroupFailoverCriteria{ StatusCodes: &types.StatusCodes{ Items: []int32{ 404, }, Quantity: new(int32(1)), }, }, Id: new("test-id"), Members: &types.OriginGroupMembers{ Quantity: new(int32(1)), Items: []types.OriginGroupMember{ { OriginId: new("CustomOriginConfig"), }, }, }, }, }, }, PriceClass: types.PriceClassPriceClass200, Restrictions: &types.Restrictions{ GeoRestriction: &types.GeoRestriction{ Quantity: new(int32(1)), RestrictionType: types.GeoRestrictionTypeWhitelist, Items: []string{ "US", }, }, }, ViewerCertificate: &types.ViewerCertificate{ ACMCertificateArn: new("arn:aws:acm:us-east-1:123456789012:certificate/test-id"), // link Certificate: new("test-certificate"), CertificateSource: types.CertificateSourceAcm, CloudFrontDefaultCertificate: new(true), IAMCertificateId: new("test-iam-certificate-id"), // link MinimumProtocolVersion: types.MinimumProtocolVersion(types.SslProtocolSSLv3), SSLSupportMethod: types.SSLSupportMethodSniOnly, }, // Note this can also be in the format: 473e64fd-f30b-4765-81a0-62ad96dd167a for WAF Classic WebACLId: new("arn:aws:wafv2:us-east-1:123456789012:global/webacl/ExampleWebACL/473e64fd-f30b-4765-81a0-62ad96dd167a"), // link }, }, }, nil } func (t TestCloudFrontClient) ListDistributions(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { return &cloudfront.ListDistributionsOutput{ DistributionList: &types.DistributionList{ IsTruncated: new(false), Items: []types.DistributionSummary{ { Id: new("test-id"), }, }, }, }, nil } func TestDistributionGetFunc(t *testing.T) { scope := "123456789012" item, err := distributionGetFunc(context.Background(), TestCloudFrontClient{}, scope, &cloudfront.GetDistributionInput{}) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } if item.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("expected health to be HEALTH_OK, got %s", item.GetHealth()) } tests := QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "d111111abcdef8.cloudfront.net", ExpectedScope: "global", }, { ExpectedType: "cloudfront-key-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "key-group-1", ExpectedScope: scope, }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "something.foo.bar.com", ExpectedScope: "global", }, { ExpectedType: "cloudfront-continuous-deployment-policy", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-continuous-deployment-policy-id", ExpectedScope: scope, }, { ExpectedType: "cloudfront-cache-policy", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-cache-policy-id", ExpectedScope: scope, }, { ExpectedType: "cloudfront-field-level-encryption", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-field-level-encryption-id", ExpectedScope: scope, }, { ExpectedType: "cloudfront-origin-request-policy", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-origin-request-policy-id", ExpectedScope: scope, }, { ExpectedType: "cloudfront-realtime-log-config", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:logs:us-east-1:123456789012:realtime-log-config/test-id", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "cloudfront-response-headers-policy", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-response-headers-policy-id", ExpectedScope: scope, }, { ExpectedType: "cloudfront-key-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "key-group-1", ExpectedScope: scope, }, { ExpectedType: "cloudfront-function", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:cloudfront::123412341234:function/1234", ExpectedScope: "123412341234", }, { ExpectedType: "lambda-function", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:lambda:us-east-1:123456789012:function:test-function", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "DOC-EXAMPLE-BUCKET.s3.us-west-2.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "cloudfront-origin-access-control", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-origin-access-control-id", ExpectedScope: scope, }, { ExpectedType: "cloudfront-cloud-front-origin-access-identity", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-origin-access-identity", ExpectedScope: scope, }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "aws-cf-access-logs.s3.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "acm-certificate", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:acm:us-east-1:123456789012:certificate/test-id", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "iam-server-certificate", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-iam-certificate-id", ExpectedScope: scope, }, { ExpectedType: "wafv2-web-acl", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:wafv2:us-east-1:123456789012:global/webacl/ExampleWebACL/473e64fd-f30b-4765-81a0-62ad96dd167a", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "DOC-EXAMPLE-BUCKET", ExpectedScope: "123456789012", }, } tests.Execute(t, item) } func TestNewCloudfrontDistributionAdapter(t *testing.T) { config, account, _ := GetAutoConfig(t) client := cloudfront.NewFromConfig(config) adapter := NewCloudfrontDistributionAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-function.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func functionItemMapper(_, scope string, awsItem *types.FunctionSummary) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-function", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, } return &item, nil } func NewCloudfrontCloudfrontFunctionAdapter(client *cloudfront.Client, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.FunctionSummary, *cloudfront.Client, *cloudfront.Options] { return &GetListAdapter[*types.FunctionSummary, *cloudfront.Client, *cloudfront.Options]{ ItemType: "cloudfront-function", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: cloudfrontFunctionAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *cloudfront.Client, scope, query string) (*types.FunctionSummary, error) { out, err := client.DescribeFunction(ctx, &cloudfront.DescribeFunctionInput{ Name: &query, }) if err != nil { return nil, err } return out.FunctionSummary, nil }, ListFunc: func(ctx context.Context, client *cloudfront.Client, scope string) ([]*types.FunctionSummary, error) { out, err := client.ListFunctions(ctx, &cloudfront.ListFunctionsInput{ Stage: types.FunctionStageLive, }) if err != nil { return nil, err } summaries := make([]*types.FunctionSummary, 0, len(out.FunctionList.Items)) for _, item := range out.FunctionList.Items { summaries = append(summaries, &item) } return summaries, nil }, ItemMapper: functionItemMapper, } } var cloudfrontFunctionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-function", DescriptiveName: "CloudFront Function", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a CloudFront Function by name", ListDescription: "List CloudFront Functions", SearchDescription: "Search CloudFront Functions by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_cloudfront_function.name"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/cloudfront-function_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestFunctionItemMapper(t *testing.T) { summary := types.FunctionSummary{ FunctionConfig: &types.FunctionConfig{ Comment: new("test-comment"), Runtime: types.FunctionRuntimeCloudfrontJs20, }, FunctionMetadata: &types.FunctionMetadata{ FunctionARN: new("arn:aws:cloudfront::123456789012:function/test-function"), LastModifiedTime: new(time.Now()), CreatedTime: new(time.Now()), Stage: types.FunctionStageLive, }, Name: new("test-function"), Status: new("test-status"), } item, err := functionItemMapper("", "test", &summary) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewCloudfrontCloudfrontFunctionAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontCloudfrontFunctionAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-key-group.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func KeyGroupItemMapper(_, scope string, awsItem *types.KeyGroup) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-key-group", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } return &item, nil } func NewCloudfrontKeyGroupAdapter(client *cloudfront.Client, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.KeyGroup, *cloudfront.Client, *cloudfront.Options] { return &GetListAdapter[*types.KeyGroup, *cloudfront.Client, *cloudfront.Options]{ ItemType: "cloudfront-key-group", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: keyGroupAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *cloudfront.Client, scope, query string) (*types.KeyGroup, error) { out, err := client.GetKeyGroup(ctx, &cloudfront.GetKeyGroupInput{ Id: &query, }) if err != nil { return nil, err } return out.KeyGroup, nil }, ListFunc: func(ctx context.Context, client *cloudfront.Client, scope string) ([]*types.KeyGroup, error) { out, err := client.ListKeyGroups(ctx, &cloudfront.ListKeyGroupsInput{}) if err != nil { return nil, err } keyGroups := make([]*types.KeyGroup, 0, len(out.KeyGroupList.Items)) for _, item := range out.KeyGroupList.Items { keyGroups = append(keyGroups, item.KeyGroup) } return keyGroups, nil }, ItemMapper: KeyGroupItemMapper, } } var keyGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-key-group", DescriptiveName: "CloudFront Key Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a CloudFront Key Group by ID", ListDescription: "List CloudFront Key Groups", SearchDescription: "Search CloudFront Key Groups by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_cloudfront_key_group.id"}, }, }) ================================================ FILE: aws-source/adapters/cloudfront-key-group_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestKeyGroupItemMapper(t *testing.T) { group := types.KeyGroup{ Id: new("test-id"), KeyGroupConfig: &types.KeyGroupConfig{ Items: []string{ "some-identity", }, Name: new("test-name"), Comment: new("test-comment"), }, LastModifiedTime: new(time.Now()), } item, err := KeyGroupItemMapper("", "test", &group) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewCloudfrontKeyGroupAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontKeyGroupAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-origin-access-control.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func originAccessControlListFunc(ctx context.Context, client *cloudfront.Client, scope string) ([]*types.OriginAccessControl, error) { out, err := client.ListOriginAccessControls(ctx, &cloudfront.ListOriginAccessControlsInput{}) if err != nil { return nil, err } originAccessControls := make([]*types.OriginAccessControl, 0, len(out.OriginAccessControlList.Items)) for _, item := range out.OriginAccessControlList.Items { // Annoyingly the "summary" types has exactly the same information as // the type returned by get, but in a slightly different format. So we // map it to the get format here originAccessControls = append(originAccessControls, &types.OriginAccessControl{ Id: item.Id, OriginAccessControlConfig: &types.OriginAccessControlConfig{ Name: item.Name, OriginAccessControlOriginType: item.OriginAccessControlOriginType, SigningBehavior: item.SigningBehavior, SigningProtocol: item.SigningProtocol, Description: item.Description, }, }) } return originAccessControls, nil } func originAccessControlItemMapper(_, scope string, awsItem *types.OriginAccessControl) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-origin-access-control", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } return &item, nil } func NewCloudfrontOriginAccessControlAdapter(client *cloudfront.Client, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.OriginAccessControl, *cloudfront.Client, *cloudfront.Options] { return &GetListAdapter[*types.OriginAccessControl, *cloudfront.Client, *cloudfront.Options]{ ItemType: "cloudfront-origin-access-control", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: originAccessControlAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *cloudfront.Client, scope, query string) (*types.OriginAccessControl, error) { out, err := client.GetOriginAccessControl(ctx, &cloudfront.GetOriginAccessControlInput{ Id: &query, }) if err != nil { return nil, err } return out.OriginAccessControl, nil }, ListFunc: originAccessControlListFunc, ItemMapper: originAccessControlItemMapper, } } var originAccessControlAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-origin-access-control", DescriptiveName: "Cloudfront Origin Access Control", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get Origin Access Control by ID", ListDescription: "List Origin Access Controls", SearchDescription: "Origin Access Control by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_cloudfront_origin_access_control.id"}, }, }) ================================================ FILE: aws-source/adapters/cloudfront-origin-access-control_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestOriginAccessControlItemMapper(t *testing.T) { x := types.OriginAccessControl{ Id: new("test"), OriginAccessControlConfig: &types.OriginAccessControlConfig{ Name: new("example-name"), OriginAccessControlOriginType: types.OriginAccessControlOriginTypesS3, SigningBehavior: types.OriginAccessControlSigningBehaviorsAlways, SigningProtocol: types.OriginAccessControlSigningProtocolsSigv4, Description: new("example-description"), }, } item, err := originAccessControlItemMapper("", "test", &x) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewCloudfrontOriginAccessControlAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontOriginAccessControlAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-origin-request-policy.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func originRequestPolicyItemMapper(_, scope string, awsItem *types.OriginRequestPolicy) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-origin-request-policy", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } return &item, nil } func NewCloudfrontOriginRequestPolicyAdapter(client *cloudfront.Client, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.OriginRequestPolicy, *cloudfront.Client, *cloudfront.Options] { return &GetListAdapter[*types.OriginRequestPolicy, *cloudfront.Client, *cloudfront.Options]{ ItemType: "cloudfront-origin-request-policy", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: originRequestPolicyAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *cloudfront.Client, scope, query string) (*types.OriginRequestPolicy, error) { out, err := client.GetOriginRequestPolicy(ctx, &cloudfront.GetOriginRequestPolicyInput{ Id: &query, }) if err != nil { return nil, err } return out.OriginRequestPolicy, nil }, ListFunc: func(ctx context.Context, client *cloudfront.Client, scope string) ([]*types.OriginRequestPolicy, error) { out, err := client.ListOriginRequestPolicies(ctx, &cloudfront.ListOriginRequestPoliciesInput{}) if err != nil { return nil, err } policies := make([]*types.OriginRequestPolicy, 0, len(out.OriginRequestPolicyList.Items)) for _, policy := range out.OriginRequestPolicyList.Items { policies = append(policies, policy.OriginRequestPolicy) } return policies, nil }, ItemMapper: originRequestPolicyItemMapper, } } var originRequestPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-origin-request-policy", DescriptiveName: "CloudFront Origin Request Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get Origin Request Policy by ID", ListDescription: "List Origin Request Policies", SearchDescription: "Origin Request Policy by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_cloudfront_origin_request_policy.id"}, }, }) ================================================ FILE: aws-source/adapters/cloudfront-origin-request-policy_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestOriginRequestPolicyItemMapper(t *testing.T) { x := types.OriginRequestPolicy{ Id: new("test"), LastModifiedTime: new(time.Now()), OriginRequestPolicyConfig: &types.OriginRequestPolicyConfig{ Name: new("example-policy"), Comment: new("example comment"), QueryStringsConfig: &types.OriginRequestPolicyQueryStringsConfig{ QueryStringBehavior: types.OriginRequestPolicyQueryStringBehaviorAllExcept, QueryStrings: &types.QueryStringNames{ Quantity: new(int32(1)), Items: []string{"test"}, }, }, CookiesConfig: &types.OriginRequestPolicyCookiesConfig{ CookieBehavior: types.OriginRequestPolicyCookieBehaviorAll, Cookies: &types.CookieNames{ Quantity: new(int32(1)), Items: []string{"test"}, }, }, HeadersConfig: &types.OriginRequestPolicyHeadersConfig{ HeaderBehavior: types.OriginRequestPolicyHeaderBehaviorAllViewer, Headers: &types.Headers{ Quantity: new(int32(1)), Items: []string{"test"}, }, }, }, } item, err := originRequestPolicyItemMapper("", "test", &x) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewCloudfrontOriginRequestPolicyAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontOriginRequestPolicyAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-realtime-log-config.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func realtimeLogConfigsItemMapper(_, scope string, awsItem *types.RealtimeLogConfig) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-realtime-log-config", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, } for _, endpoint := range awsItem.EndPoints { if endpoint.KinesisStreamConfig != nil { if endpoint.KinesisStreamConfig.RoleARN != nil { if arn, err := ParseARN(*endpoint.KinesisStreamConfig.RoleARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *endpoint.KinesisStreamConfig.RoleARN, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if endpoint.KinesisStreamConfig.StreamARN != nil { if arn, err := ParseARN(*endpoint.KinesisStreamConfig.StreamARN); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kinesis-stream", Method: sdp.QueryMethod_SEARCH, Query: *endpoint.KinesisStreamConfig.StreamARN, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } } return &item, nil } func NewCloudfrontRealtimeLogConfigsAdapter(client *cloudfront.Client, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.RealtimeLogConfig, *cloudfront.Client, *cloudfront.Options] { return &GetListAdapter[*types.RealtimeLogConfig, *cloudfront.Client, *cloudfront.Options]{ ItemType: "cloudfront-realtime-log-config", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: realtimeLogConfigsAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *cloudfront.Client, scope, query string) (*types.RealtimeLogConfig, error) { out, err := client.GetRealtimeLogConfig(ctx, &cloudfront.GetRealtimeLogConfigInput{ Name: &query, }) if err != nil { return nil, err } return out.RealtimeLogConfig, nil }, ListFunc: func(ctx context.Context, client *cloudfront.Client, scope string) ([]*types.RealtimeLogConfig, error) { out, err := client.ListRealtimeLogConfigs(ctx, &cloudfront.ListRealtimeLogConfigsInput{}) if err != nil { return nil, err } logConfigs := make([]*types.RealtimeLogConfig, 0, len(out.RealtimeLogConfigs.Items)) for _, logConfig := range out.RealtimeLogConfigs.Items { logConfigs = append(logConfigs, &logConfig) } return logConfigs, nil }, ItemMapper: realtimeLogConfigsItemMapper, } } var realtimeLogConfigsAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-realtime-log-config", DescriptiveName: "CloudFront Realtime Log Config", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get Realtime Log Config by Name", ListDescription: "List Realtime Log Configs", SearchDescription: "Search Realtime Log Configs by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_cloudfront_realtime_log_config.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, }) ================================================ FILE: aws-source/adapters/cloudfront-realtime-log-config_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestRealtimeLogConfigsItemMapper(t *testing.T) { x := types.RealtimeLogConfig{ Name: new("test"), SamplingRate: new(int64(100)), ARN: new("arn:aws:cloudfront::123456789012:realtime-log-config/12345678-1234-1234-1234-123456789012"), EndPoints: []types.EndPoint{ { StreamType: new("Kinesis"), KinesisStreamConfig: &types.KinesisStreamConfig{ RoleARN: new("arn:aws:iam::123456789012:role/CloudFront_Logger"), // link StreamARN: new("arn:aws:kinesis:us-east-1:123456789012:stream/cloudfront-logs"), // link }, }, }, Fields: []string{ "date", }, } item, err := realtimeLogConfigsItemMapper("", "test", &x) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "iam-role", ExpectedQuery: "arn:aws:iam::123456789012:role/CloudFront_Logger", ExpectedScope: "123456789012", ExpectedMethod: sdp.QueryMethod_SEARCH, }, { ExpectedType: "kinesis-stream", ExpectedQuery: "arn:aws:kinesis:us-east-1:123456789012:stream/cloudfront-logs", ExpectedScope: "123456789012.us-east-1", ExpectedMethod: sdp.QueryMethod_SEARCH, }, } tests.Execute(t, item) } func TestNewCloudfrontRealtimeLogConfigsAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontRealtimeLogConfigsAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-response-headers-policy.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func ResponseHeadersPolicyItemMapper(_, scope string, awsItem *types.ResponseHeadersPolicy) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-response-headers-policy", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } return &item, nil } func NewCloudfrontResponseHeadersPolicyAdapter(client *cloudfront.Client, accountID string, cache sdpcache.Cache) *GetListAdapter[*types.ResponseHeadersPolicy, *cloudfront.Client, *cloudfront.Options] { return &GetListAdapter[*types.ResponseHeadersPolicy, *cloudfront.Client, *cloudfront.Options]{ ItemType: "cloudfront-response-headers-policy", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: responseHeadersPolicyAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *cloudfront.Client, scope, query string) (*types.ResponseHeadersPolicy, error) { out, err := client.GetResponseHeadersPolicy(ctx, &cloudfront.GetResponseHeadersPolicyInput{ Id: &query, }) if err != nil { return nil, err } return out.ResponseHeadersPolicy, nil }, ListFunc: func(ctx context.Context, client *cloudfront.Client, scope string) ([]*types.ResponseHeadersPolicy, error) { out, err := client.ListResponseHeadersPolicies(ctx, &cloudfront.ListResponseHeadersPoliciesInput{}) if err != nil { return nil, err } policies := make([]*types.ResponseHeadersPolicy, 0, len(out.ResponseHeadersPolicyList.Items)) for _, policy := range out.ResponseHeadersPolicyList.Items { policies = append(policies, policy.ResponseHeadersPolicy) } return policies, nil }, ItemMapper: ResponseHeadersPolicyItemMapper, } } var responseHeadersPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudfront-response-headers-policy", DescriptiveName: "CloudFront Response Headers Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get Response Headers Policy by ID", ListDescription: "List Response Headers Policies", SearchDescription: "Search Response Headers Policy by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_cloudfront_response_headers_policy.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/cloudfront-response-headers-policy_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestResponseHeadersPolicyItemMapper(t *testing.T) { x := types.ResponseHeadersPolicy{ Id: new("test"), LastModifiedTime: new(time.Now()), ResponseHeadersPolicyConfig: &types.ResponseHeadersPolicyConfig{ Name: new("example-policy"), Comment: new("example comment"), CorsConfig: &types.ResponseHeadersPolicyCorsConfig{ AccessControlAllowCredentials: new(true), AccessControlAllowHeaders: &types.ResponseHeadersPolicyAccessControlAllowHeaders{ Items: []string{"X-Customer-Header"}, Quantity: new(int32(1)), }, }, CustomHeadersConfig: &types.ResponseHeadersPolicyCustomHeadersConfig{ Quantity: new(int32(1)), Items: []types.ResponseHeadersPolicyCustomHeader{ { Header: new("X-Customer-Header"), Override: new(true), Value: new("test"), }, }, }, RemoveHeadersConfig: &types.ResponseHeadersPolicyRemoveHeadersConfig{ Quantity: new(int32(1)), Items: []types.ResponseHeadersPolicyRemoveHeader{ { Header: new("X-Private-Header"), }, }, }, SecurityHeadersConfig: &types.ResponseHeadersPolicySecurityHeadersConfig{ ContentSecurityPolicy: &types.ResponseHeadersPolicyContentSecurityPolicy{ ContentSecurityPolicy: new("default-src 'none';"), Override: new(true), }, ContentTypeOptions: &types.ResponseHeadersPolicyContentTypeOptions{ Override: new(true), }, FrameOptions: &types.ResponseHeadersPolicyFrameOptions{ FrameOption: types.FrameOptionsListDeny, Override: new(true), }, ReferrerPolicy: &types.ResponseHeadersPolicyReferrerPolicy{ Override: new(true), ReferrerPolicy: types.ReferrerPolicyListNoReferrer, }, StrictTransportSecurity: &types.ResponseHeadersPolicyStrictTransportSecurity{ AccessControlMaxAgeSec: new(int32(86400)), Override: new(true), IncludeSubdomains: new(true), Preload: new(true), }, XSSProtection: &types.ResponseHeadersPolicyXSSProtection{ Override: new(true), Protection: new(true), ModeBlock: new(true), ReportUri: new("https://example.com/report"), }, }, ServerTimingHeadersConfig: &types.ResponseHeadersPolicyServerTimingHeadersConfig{ Enabled: new(true), SamplingRate: new(0.1), }, }, } item, err := ResponseHeadersPolicyItemMapper("", "test", &x) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewCloudfrontResponseHeadersPolicyAdapter(t *testing.T) { client, account, _ := CloudfrontGetAutoConfig(t) adapter := NewCloudfrontResponseHeadersPolicyAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront-streaming-distribution.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func streamingDistributionGetFunc(ctx context.Context, client CloudFrontClient, scope string, input *cloudfront.GetStreamingDistributionInput) (*sdp.Item, error) { out, err := client.GetStreamingDistribution(ctx, input) if err != nil { return nil, err } d := out.StreamingDistribution if d == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "streaming distribution was nil", } } var tags map[string]string // Get the tags tagsOut, err := client.ListTagsForResource(ctx, &cloudfront.ListTagsForResourceInput{ Resource: d.ARN, }) if err == nil { tags = cloudfrontTagsToMap(tagsOut.Tags) } else { tags = HandleTagsError(ctx, err) } if err != nil { return nil, fmt.Errorf("failed to get tags for streaming distribution %v: %w", *d.Id, err) } attributes, err := ToAttributesWithExclude(d) if err != nil { return nil, err } item := sdp.Item{ Type: "cloudfront-streaming-distribution", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, Tags: tags, } if d.Status != nil { switch *d.Status { case "InProgress": item.Health = sdp.Health_HEALTH_PENDING.Enum() case "Deployed": item.Health = sdp.Health_HEALTH_OK.Enum() } } if d.DomainName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *d.DomainName, Scope: "global", }, }) } if dc := d.StreamingDistributionConfig; dc != nil { if dc.S3Origin != nil { if dc.S3Origin.DomainName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *dc.S3Origin.DomainName, Scope: "global", }, }) } if dc.S3Origin.OriginAccessIdentity != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudfront-cloud-front-origin-access-identity", Method: sdp.QueryMethod_GET, Query: *dc.S3Origin.OriginAccessIdentity, Scope: scope, }, }) } } if dc.Aliases != nil { for _, alias := range dc.Aliases.Items { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: alias, Scope: "global", }, }) } } if dc.Logging != nil && dc.Logging.Bucket != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *dc.Logging.Bucket, Scope: "global", }, }) } } return &item, nil } func NewCloudfrontStreamingDistributionAdapter(client CloudFrontClient, accountID string, cache sdpcache.Cache) *AlwaysGetAdapter[*cloudfront.ListStreamingDistributionsInput, *cloudfront.ListStreamingDistributionsOutput, *cloudfront.GetStreamingDistributionInput, *cloudfront.GetStreamingDistributionOutput, CloudFrontClient, *cloudfront.Options] { return &AlwaysGetAdapter[*cloudfront.ListStreamingDistributionsInput, *cloudfront.ListStreamingDistributionsOutput, *cloudfront.GetStreamingDistributionInput, *cloudfront.GetStreamingDistributionOutput, CloudFrontClient, *cloudfront.Options]{ ItemType: "cloudfront-streaming-distribution", Client: client, AccountID: accountID, Region: "", // Cloudfront resources aren't tied to a region AdapterMetadata: streamingDistributionAdapterMetadata, cache: cache, ListInput: &cloudfront.ListStreamingDistributionsInput{}, ListFuncPaginatorBuilder: func(client CloudFrontClient, input *cloudfront.ListStreamingDistributionsInput) Paginator[*cloudfront.ListStreamingDistributionsOutput, *cloudfront.Options] { return cloudfront.NewListStreamingDistributionsPaginator(client, input) }, GetInputMapper: func(scope, query string) *cloudfront.GetStreamingDistributionInput { return &cloudfront.GetStreamingDistributionInput{ Id: &query, } }, ListFuncOutputMapper: func(output *cloudfront.ListStreamingDistributionsOutput, input *cloudfront.ListStreamingDistributionsInput) ([]*cloudfront.GetStreamingDistributionInput, error) { var inputs []*cloudfront.GetStreamingDistributionInput for _, sd := range output.StreamingDistributionList.Items { inputs = append(inputs, &cloudfront.GetStreamingDistributionInput{ Id: sd.Id, }) } return inputs, nil }, GetFunc: streamingDistributionGetFunc, } } var streamingDistributionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "CloudFront Streaming Distribution", Type: "cloudfront-streaming-distribution", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Search: true, Get: true, List: true, GetDescription: "Get a Streaming Distribution by ID", ListDescription: "List Streaming Distributions", SearchDescription: "Search Streaming Distributions by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "aws_cloudfront_distribution.arn", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_cloudfront_distribution.id", }, }, PotentialLinks: []string{"dns"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/cloudfront-streaming-distribution_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t TestCloudFrontClient) GetStreamingDistribution(ctx context.Context, params *cloudfront.GetStreamingDistributionInput, optFns ...func(*cloudfront.Options)) (*cloudfront.GetStreamingDistributionOutput, error) { return &cloudfront.GetStreamingDistributionOutput{ ETag: new("E2QWRUHAPOMQZL"), StreamingDistribution: &types.StreamingDistribution{ ARN: new("arn:aws:cloudfront::123456789012:streaming-distribution/EDFDVBD632BHDS5"), DomainName: new("d111111abcdef8.cloudfront.net"), // link Id: new("EDFDVBD632BHDS5"), Status: new("Deployed"), // health LastModifiedTime: new(time.Now()), ActiveTrustedSigners: &types.ActiveTrustedSigners{ Enabled: new(true), Quantity: new(int32(1)), Items: []types.Signer{ { AwsAccountNumber: new("123456789012"), KeyPairIds: &types.KeyPairIds{ Quantity: new(int32(1)), Items: []string{ "APKAJDGKZRVEXAMPLE", }, }, }, }, }, StreamingDistributionConfig: &types.StreamingDistributionConfig{ CallerReference: new("test"), Comment: new("test"), Enabled: new(true), S3Origin: &types.S3Origin{ DomainName: new("myawsbucket.s3.amazonaws.com"), // link OriginAccessIdentity: new("origin-access-identity/cloudfront/E127EXAMPLE51Z"), // link }, TrustedSigners: &types.TrustedSigners{ Enabled: new(true), Quantity: new(int32(1)), Items: []string{ "self", }, }, Aliases: &types.Aliases{ Quantity: new(int32(1)), Items: []string{ "example.com", // link }, }, Logging: &types.StreamingLoggingConfig{ Bucket: new("myawslogbucket.s3.amazonaws.com"), // link Enabled: new(true), Prefix: new("myprefix"), }, PriceClass: types.PriceClassPriceClassAll, }, }, }, nil } func (t TestCloudFrontClient) ListStreamingDistributions(ctx context.Context, params *cloudfront.ListStreamingDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListStreamingDistributionsOutput, error) { return &cloudfront.ListStreamingDistributionsOutput{ StreamingDistributionList: &types.StreamingDistributionList{ IsTruncated: new(false), Items: []types.StreamingDistributionSummary{ { Id: new("test-id"), }, }, }, }, nil } func TestStreamingDistributionGetFunc(t *testing.T) { item, err := streamingDistributionGetFunc(context.Background(), TestCloudFrontClient{}, "foo", &cloudfront.GetStreamingDistributionInput{}) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } if item.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("expected health to be HEALTH_OK, got %s", item.GetHealth()) } tests := QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "d111111abcdef8.cloudfront.net", ExpectedScope: "global", }, } tests.Execute(t, item) } func TestNewCloudfrontStreamingDistributionAdapter(t *testing.T) { config, account, _ := GetAutoConfig(t) client := cloudfront.NewFromConfig(config) adapter := NewCloudfrontStreamingDistributionAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudfront.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" ) // Converts a CloudFront Tags object to a map func cloudfrontTagsToMap(tags *types.Tags) map[string]string { if tags == nil { return nil } tagMap := make(map[string]string) for _, tag := range tags.Items { if tag.Key != nil && tag.Value != nil { tagMap[*tag.Key] = *tag.Value } } return tagMap } type CloudFrontClient interface { GetCachePolicy(ctx context.Context, params *cloudfront.GetCachePolicyInput, optFns ...func(*cloudfront.Options)) (*cloudfront.GetCachePolicyOutput, error) ListCachePolicies(ctx context.Context, params *cloudfront.ListCachePoliciesInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListCachePoliciesOutput, error) GetDistribution(ctx context.Context, params *cloudfront.GetDistributionInput, optFns ...func(*cloudfront.Options)) (*cloudfront.GetDistributionOutput, error) ListDistributions(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) GetStreamingDistribution(ctx context.Context, params *cloudfront.GetStreamingDistributionInput, optFns ...func(*cloudfront.Options)) (*cloudfront.GetStreamingDistributionOutput, error) ListStreamingDistributions(ctx context.Context, params *cloudfront.ListStreamingDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListStreamingDistributionsOutput, error) ListTagsForResource(ctx context.Context, params *cloudfront.ListTagsForResourceInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListTagsForResourceOutput, error) } ================================================ FILE: aws-source/adapters/cloudfront_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" ) func (c TestCloudFrontClient) ListTagsForResource(ctx context.Context, params *cloudfront.ListTagsForResourceInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListTagsForResourceOutput, error) { return &cloudfront.ListTagsForResourceOutput{ Tags: &types.Tags{ Items: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, }, }, nil } type TestCloudFrontClient struct{} func CloudfrontGetAutoConfig(t *testing.T) (*cloudfront.Client, string, string) { config, account, region := GetAutoConfig(t) client := cloudfront.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/cloudwatch-alarm.go ================================================ package adapters import ( "context" "encoding/json" "errors" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type CloudwatchClient interface { ListTagsForResource(ctx context.Context, params *cloudwatch.ListTagsForResourceInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.ListTagsForResourceOutput, error) DescribeAlarms(ctx context.Context, params *cloudwatch.DescribeAlarmsInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) DescribeAlarmsForMetric(ctx context.Context, params *cloudwatch.DescribeAlarmsForMetricInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsForMetricOutput, error) } // ToQueryString Converts an alarm query input to the correct for search string func ToQueryString(input *cloudwatch.DescribeAlarmsForMetricInput) (string, error) { b, err := json.Marshal(input) if err != nil { return "", err } return string(b), nil } // fromQueryString Converts a search string to an alarm query input func fromQueryString(query string) (*cloudwatch.DescribeAlarmsForMetricInput, error) { input := &cloudwatch.DescribeAlarmsForMetricInput{} if err := json.Unmarshal([]byte(query), input); err != nil { return nil, err } return input, nil } // Converts cloudwatch tags to a map func cloudwatchTagsToMap(tags []types.Tag) map[string]string { out := make(map[string]string) for _, tag := range tags { out[*tag.Key] = *tag.Value } return out } type Alarm struct { Metric *types.MetricAlarm Composite *types.CompositeAlarm } func alarmOutputMapper(ctx context.Context, client CloudwatchClient, scope string, input *cloudwatch.DescribeAlarmsInput, output *cloudwatch.DescribeAlarmsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) allAlarms := make([]Alarm, 0) for i := range output.MetricAlarms { allAlarms = append(allAlarms, Alarm{Metric: &output.MetricAlarms[i]}) } for i := range output.CompositeAlarms { allAlarms = append(allAlarms, Alarm{Composite: &output.CompositeAlarms[i]}) } for _, alarm := range allAlarms { var attrs *sdp.ItemAttributes var err error var arn *string if alarm.Metric != nil { attrs, err = ToAttributesWithExclude(alarm.Metric) arn = alarm.Metric.AlarmArn } if alarm.Composite != nil { attrs, err = ToAttributesWithExclude(alarm.Composite) arn = alarm.Composite.AlarmArn } if err != nil { return nil, err } var tags map[string]string // Get the tags tagsOut, err := client.ListTagsForResource(ctx, &cloudwatch.ListTagsForResourceInput{ ResourceARN: arn, }) if err == nil { tags = cloudwatchTagsToMap(tagsOut.Tags) } else { tags = HandleTagsError(ctx, err) } item := sdp.Item{ Type: "cloudwatch-alarm", UniqueAttribute: "AlarmName", Scope: scope, Attributes: attrs, Tags: tags, } // Combine all actions so that we can link the targeted item allActions := make([]string, 0) if alarm.Metric != nil { allActions = append(allActions, alarm.Metric.OKActions...) allActions = append(allActions, alarm.Metric.AlarmActions...) allActions = append(allActions, alarm.Metric.InsufficientDataActions...) } if alarm.Composite != nil { allActions = append(allActions, alarm.Composite.OKActions...) allActions = append(allActions, alarm.Composite.AlarmActions...) allActions = append(allActions, alarm.Composite.InsufficientDataActions...) } for _, action := range allActions { if q, err := actionToLink(action); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, q) } } // Calculate state and convert this to health var stateValue types.StateValue if alarm.Metric != nil { stateValue = alarm.Metric.StateValue } if alarm.Composite != nil { stateValue = alarm.Composite.StateValue } switch stateValue { case types.StateValueOk: item.Health = sdp.Health_HEALTH_OK.Enum() case types.StateValueAlarm: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.StateValueInsufficientData: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } // Link to the suppressor alarm if alarm.Composite != nil && alarm.Composite.ActionsSuppressor != nil { if arn, err := ParseARN(*alarm.Composite.ActionsSuppressor); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudwatch-alarm", Method: sdp.QueryMethod_GET, Query: arn.ResourceID(), Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if alarm.Metric != nil && alarm.Metric.Namespace != nil { // Possible links for a metric alarm // // Check for links based on the metric that is being monitored q, err := SuggestedQuery(*alarm.Metric.Namespace, scope, alarm.Metric.Dimensions) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, q) } } items = append(items, &item) } return items, nil } func NewCloudwatchAlarmAdapter(client *cloudwatch.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*cloudwatch.DescribeAlarmsInput, *cloudwatch.DescribeAlarmsOutput, CloudwatchClient, *cloudwatch.Options] { return &DescribeOnlyAdapter[*cloudwatch.DescribeAlarmsInput, *cloudwatch.DescribeAlarmsOutput, CloudwatchClient, *cloudwatch.Options]{ ItemType: "cloudwatch-alarm", Client: client, Region: region, AccountID: accountID, AdapterMetadata: cloudwatchAlarmAdapterMetadata, cache: cache, PaginatorBuilder: func(client CloudwatchClient, params *cloudwatch.DescribeAlarmsInput) Paginator[*cloudwatch.DescribeAlarmsOutput, *cloudwatch.Options] { return cloudwatch.NewDescribeAlarmsPaginator(client, params) }, DescribeFunc: func(ctx context.Context, client CloudwatchClient, input *cloudwatch.DescribeAlarmsInput) (*cloudwatch.DescribeAlarmsOutput, error) { return client.DescribeAlarms(ctx, input) }, InputMapperGet: func(scope, query string) (*cloudwatch.DescribeAlarmsInput, error) { return &cloudwatch.DescribeAlarmsInput{ AlarmNames: []string{query}, }, nil }, InputMapperList: func(scope string) (*cloudwatch.DescribeAlarmsInput, error) { return &cloudwatch.DescribeAlarmsInput{}, nil }, InputMapperSearch: func(ctx context.Context, client CloudwatchClient, scope, query string) (*cloudwatch.DescribeAlarmsInput, error) { // Search uses the DescribeAlarmsForMetric API call to find alarms // based on a JSON input input, err := fromQueryString(query) if err != nil { return nil, err } out, err := client.DescribeAlarmsForMetric(ctx, input) if err != nil { return nil, err } name := make([]string, 0) for _, alarm := range out.MetricAlarms { if alarm.AlarmName != nil { name = append(name, *alarm.AlarmName) } } return &cloudwatch.DescribeAlarmsInput{ AlarmNames: name, }, nil }, OutputMapper: alarmOutputMapper, } } var cloudwatchAlarmAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "CloudWatch Alarm", Type: "cloudwatch-alarm", PotentialLinks: []string{"cloudwatch-metric"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an alarm by name", ListDescription: "List all alarms", SearchDescription: "Search for alarms. This accepts JSON in the format of `cloudwatch.DescribeAlarmsForMetricInput`", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_cloudwatch_metric_alarm.alarm_name", }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, }) // actionToLink converts an action string to a link to the resource that the // action refers to. The actions to execute when this alarm transitions to the // ALARM state from any other state. Each action is specified as an Amazon // Resource Name (ARN). Valid values: EC2 actions: // // * arn:aws:automate:region:ec2:stop // // * arn:aws:automate:region:ec2:terminate // // * arn:aws:automate:region:ec2:reboot // // * arn:aws:automate:region:ec2:recover // // * arn:aws:swf:region:account-id:action/actions/AWS_EC2.InstanceId.Stop/1.0 // // * // arn:aws:swf:region:account-id:action/actions/AWS_EC2.InstanceId.Terminate/1.0 // // * arn:aws:swf:region:account-id:action/actions/AWS_EC2.InstanceId.Reboot/1.0 // // * arn:aws:swf:region:account-id:action/actions/AWS_EC2.InstanceId.Recover/1.0 // // Autoscaling action: // // * // arn:aws:autoscaling:region:account-id:scalingPolicy:policy-id:autoScalingGroupName/group-friendly-name:policyName/policy-friendly-name // // SSN notification action: // // * // arn:aws:sns:region:account-id:sns-topic-name:autoScalingGroupName/group-friendly-name:policyName/policy-friendly-name // // SSM integration actions: // // * arn:aws:ssm:region:account-id:opsitem:severity#CATEGORY=category-name // // * arn:aws:ssm-incidents::account-id:responseplan/response-plan-name func actionToLink(action string) (*sdp.LinkedItemQuery, error) { arn, err := ParseARN(action) if err != nil { return nil, err } switch arn.Service { case "autoscaling": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "autoscaling-policy", Method: sdp.QueryMethod_SEARCH, Query: action, Scope: FormatScope(arn.AccountID, arn.Region), }, }, nil case "sns": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sns-topic", Method: sdp.QueryMethod_SEARCH, Query: action, Scope: FormatScope(arn.AccountID, arn.Region), }, }, nil case "ssm": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ssm-ops-item", Method: sdp.QueryMethod_SEARCH, Query: action, Scope: FormatScope(arn.AccountID, arn.Region), }, }, nil case "ssm-incidents": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ssm-incidents-response-plan", Method: sdp.QueryMethod_SEARCH, Query: action, Scope: FormatScope(arn.AccountID, arn.Region), }, }, nil default: return nil, errors.New("unknown service in ARN: " + arn.Service) } } ================================================ FILE: aws-source/adapters/cloudwatch-alarm_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type testCloudwatchClient struct{} func (c testCloudwatchClient) ListTagsForResource(ctx context.Context, params *cloudwatch.ListTagsForResourceInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.ListTagsForResourceOutput, error) { return &cloudwatch.ListTagsForResourceOutput{ Tags: []types.Tag{ { Key: new("Name"), Value: new("example"), }, }, }, nil } func (c testCloudwatchClient) DescribeAlarms(ctx context.Context, params *cloudwatch.DescribeAlarmsInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) { return nil, nil } func (c testCloudwatchClient) DescribeAlarmsForMetric(ctx context.Context, params *cloudwatch.DescribeAlarmsForMetricInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsForMetricOutput, error) { return nil, nil } func TestAlarmOutputMapper(t *testing.T) { output := &cloudwatch.DescribeAlarmsOutput{ MetricAlarms: []types.MetricAlarm{ { AlarmName: new("TargetTracking-table/dylan-tfstate-AlarmHigh-14069c4a-6dcc-48a2-bfe6-b5547c90c43d"), AlarmArn: new("arn:aws:cloudwatch:eu-west-2:052392120703:alarm:TargetTracking-table/dylan-tfstate-AlarmHigh-14069c4a-6dcc-48a2-bfe6-b5547c90c43d"), AlarmDescription: new("DO NOT EDIT OR DELETE. For TargetTrackingScaling policy arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b."), AlarmConfigurationUpdatedTimestamp: new(time.Now()), ActionsEnabled: new(true), OKActions: []string{ "arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b", }, AlarmActions: []string{ "arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b", }, InsufficientDataActions: []string{ "arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b", }, StateValue: types.StateValueOk, StateReason: new("Threshold Crossed: 2 datapoints [0.0 (09/01/23 14:02:00), 1.0 (09/01/23 14:01:00)] were not greater than the threshold (42.0)."), StateReasonData: new("{\"version\":\"1.0\",\"queryDate\":\"2023-01-09T14:07:25.504+0000\",\"startDate\":\"2023-01-09T14:01:00.000+0000\",\"statistic\":\"Sum\",\"period\":60,\"recentDatapoints\":[1.0,0.0],\"threshold\":42.0,\"evaluatedDatapoints\":[{\"timestamp\":\"2023-01-09T14:02:00.000+0000\",\"sampleCount\":1.0,\"value\":0.0}]}"), StateUpdatedTimestamp: new(time.Now()), MetricName: new("ConsumedWriteCapacityUnits"), Namespace: new("AWS/DynamoDB"), Statistic: types.StatisticSum, Dimensions: []types.Dimension{ { Name: new("TableName"), Value: new("dylan-tfstate"), }, }, Period: new(int32(60)), EvaluationPeriods: new(int32(2)), Threshold: new(42.0), ComparisonOperator: types.ComparisonOperatorGreaterThanThreshold, StateTransitionedTimestamp: new(time.Now()), }, }, CompositeAlarms: []types.CompositeAlarm{ { AlarmName: new("TargetTracking2-table/dylan-tfstate-AlarmHigh-14069c4a-6dcc-48a2-bfe6-b5547c90c43d"), AlarmArn: new("arn:aws:cloudwatch:eu-west-2:052392120703:alarm:TargetTracking2-table/dylan-tfstate-AlarmHigh-14069c4a-6dcc-48a2-bfe6-b5547c90c43d"), AlarmDescription: new("DO NOT EDIT OR DELETE. For TargetTrackingScaling policy arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b."), AlarmConfigurationUpdatedTimestamp: new(time.Now()), ActionsEnabled: new(true), OKActions: []string{ "arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b", }, AlarmActions: []string{ "arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b", }, InsufficientDataActions: []string{ "arn:aws:autoscaling:eu-west-2:052392120703:scalingPolicy:32f3f053-dc75-46fa-9cd4-8e8c34c47b37:resource/dynamodb/table/dylan-tfstate:policyName/$dylan-tfstate-scaling-policy:createdBy/e5bd51d8-94a8-461e-a989-08f4d10b326b", }, StateValue: types.StateValueOk, StateReason: new("Threshold Crossed: 2 datapoints [0.0 (09/01/23 14:02:00), 1.0 (09/01/23 14:01:00)] were not greater than the threshold (42.0)."), StateReasonData: new("{\"version\":\"1.0\",\"queryDate\":\"2023-01-09T14:07:25.504+0000\",\"startDate\":\"2023-01-09T14:01:00.000+0000\",\"statistic\":\"Sum\",\"period\":60,\"recentDatapoints\":[1.0,0.0],\"threshold\":42.0,\"evaluatedDatapoints\":[{\"timestamp\":\"2023-01-09T14:02:00.000+0000\",\"sampleCount\":1.0,\"value\":0.0}]}"), StateUpdatedTimestamp: new(time.Now()), StateTransitionedTimestamp: new(time.Now()), ActionsSuppressedBy: types.ActionsSuppressedByAlarm, ActionsSuppressedReason: new("Alarm is in INSUFFICIENT_DATA state"), // link ActionsSuppressor: new("arn:aws:cloudwatch:eu-west-2:052392120703:alarm:TargetTracking2-table/dylan-tfstate-AlarmHigh-14069c4a-6dcc-48a2-bfe6-b5547c90c43d"), ActionsSuppressorExtensionPeriod: new(int32(0)), ActionsSuppressorWaitPeriod: new(int32(0)), AlarmRule: new("ALARM TargetTracking2-table/dylan-tfstate-AlarmHigh-14069c4a-6dcc-48a2-bfe6-b5547c90c43d"), }, }, } scope := "123456789012.eu-west-2" items, err := alarmOutputMapper(context.Background(), testCloudwatchClient{}, scope, &cloudwatch.DescribeAlarmsInput{}, output) if err != nil { t.Error(err) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } item := items[1] if err = item.Validate(); err != nil { t.Error(err) } if item.GetTags()["Name"] != "example" { t.Errorf("Expected tag Name to be example, got %s", item.GetTags()["Name"]) } tests := QueryTests{ { ExpectedType: "cloudwatch-alarm", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "TargetTracking2-table/dylan-tfstate-AlarmHigh-14069c4a-6dcc-48a2-bfe6-b5547c90c43d", ExpectedScope: "052392120703.eu-west-2", }, } tests.Execute(t, item) item = items[0] if err = item.Validate(); err != nil { t.Error(err) } tests = QueryTests{ { ExpectedType: "dynamodb-table", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dylan-tfstate", ExpectedScope: scope, }, } tests.Execute(t, item) } // testCloudwatchClientWithTagError returns an error when fetching tags // to simulate scenarios where tag access is denied but alarm data is available type testCloudwatchClientWithTagError struct{} func (c testCloudwatchClientWithTagError) ListTagsForResource(ctx context.Context, params *cloudwatch.ListTagsForResourceInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.ListTagsForResourceOutput, error) { return nil, fmt.Errorf("access denied: cannot list tags for resource") } func (c testCloudwatchClientWithTagError) DescribeAlarms(ctx context.Context, params *cloudwatch.DescribeAlarmsInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) { return nil, nil } func (c testCloudwatchClientWithTagError) DescribeAlarmsForMetric(ctx context.Context, params *cloudwatch.DescribeAlarmsForMetricInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsForMetricOutput, error) { return nil, nil } // TestAlarmOutputMapperWithTagError tests that items are still returned when // tag fetching fails. This is a regression test for a bug where a leftover // error check caused the mapper to return nil items when ListTagsForResource // failed, even though the alarm data was successfully retrieved. func TestAlarmOutputMapperWithTagError(t *testing.T) { output := &cloudwatch.DescribeAlarmsOutput{ MetricAlarms: []types.MetricAlarm{ { AlarmName: new("api-51c748b4-cpu-credits-low"), AlarmArn: new("arn:aws:cloudwatch:eu-west-2:052392120703:alarm:api-51c748b4-cpu-credits-low"), AlarmDescription: new("CPU credits low alarm"), StateValue: types.StateValueOk, MetricName: new("CPUCreditBalance"), Namespace: new("AWS/EC2"), }, }, } scope := "123456789012.eu-west-2" // Use the client that returns an error when fetching tags items, err := alarmOutputMapper(context.Background(), testCloudwatchClientWithTagError{}, scope, &cloudwatch.DescribeAlarmsInput{}, output) if err != nil { t.Errorf("Expected no error when tag fetching fails, but got: %v", err) } if len(items) != 1 { t.Fatalf("Expected 1 item to be returned even when tag fetching fails, got %d", len(items)) } item := items[0] if err = item.Validate(); err != nil { t.Error(err) } // Verify the alarm name is correct alarmName, err := item.GetAttributes().Get("AlarmName") if err != nil { t.Errorf("Failed to get AlarmName: %v", err) } if alarmName != "api-51c748b4-cpu-credits-low" { t.Errorf("Expected AlarmName to be 'api-51c748b4-cpu-credits-low', got %v", alarmName) } } func TestNewCloudwatchAlarmAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := cloudwatch.NewFromConfig(config) adapter := NewCloudwatchAlarmAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/cloudwatch-instance-metric.go ================================================ package adapters import ( "context" "fmt" "regexp" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // CloudwatchMetricClient defines the CloudWatch client interface for metrics type CloudwatchMetricClient interface { GetMetricData(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) } // EC2 instance metrics to fetch // Metric units (as returned by CloudWatch with Average statistic over 15-minute period): // - CPUUtilization: Percentage (0-100) // - NetworkIn: Average bytes per second // - NetworkOut: Average bytes per second // - StatusCheckFailed: Count (0 = OK, 1 = Failed) // - CPUCreditBalance: Number of CPU credits available (for T2/T3 instances) // - CPUCreditUsage: Number of CPU credits consumed (for T2/T3 instances) // - DiskReadOps: Average read operations per second (instance store volumes) // - DiskWriteOps: Average write operations per second (instance store volumes) var ec2InstanceMetrics = []string{ "CPUUtilization", "NetworkIn", "NetworkOut", "StatusCheckFailed", "CPUCreditBalance", "CPUCreditUsage", "DiskReadOps", "DiskWriteOps", } // validateInstanceID validates that the query is a valid EC2 instance ID func validateInstanceID(instanceID string) error { // EC2 instance IDs start with "i-" followed by either 8 characters (older instances) // or 17 characters (newer instances, default since 2016). Both use hexadecimal characters (0-9, a-f). matched, err := regexp.MatchString(`^i-[0-9a-f]{8}$|^i-[0-9a-f]{17}$`, instanceID) if err != nil { return fmt.Errorf("failed to validate instance ID: %w", err) } if !matched { return fmt.Errorf("invalid instance ID format: %s (expected format: i-xxxxxxxx or i-xxxxxxxxxxxxxxxxx)", instanceID) } return nil } // formatBytes formats bytes to human-readable format (KB, MB, GB, TB) func formatBytes(bytes float64) string { const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 TB = GB * 1024 ) switch { case bytes >= TB: return fmt.Sprintf("%.2f TB", bytes/TB) case bytes >= GB: return fmt.Sprintf("%.2f GB", bytes/GB) case bytes >= MB: return fmt.Sprintf("%.2f MB", bytes/MB) case bytes >= KB: return fmt.Sprintf("%.2f KB", bytes/KB) default: return fmt.Sprintf("%.0f bytes", bytes) } } // formatBytesPerSecond formats bytes per second to human-readable format func formatBytesPerSecond(bytesPerSec float64) string { return formatBytes(bytesPerSec) + "/s" } // formatOpsPerSecond formats operations per second func formatOpsPerSecond(opsPerSec float64) string { if opsPerSec >= 1000000 { return fmt.Sprintf("%.2f M ops/s", opsPerSec/1000000) } if opsPerSec >= 1000 { return fmt.Sprintf("%.2f K ops/s", opsPerSec/1000) } return fmt.Sprintf("%.2f ops/s", opsPerSec) } // formatMetricValue formats a metric value based on its name func formatMetricValue(metricName string, value float64) string { switch metricName { case "CPUUtilization": return fmt.Sprintf("%.2f%%", value) case "NetworkIn", "NetworkOut": // These are average bytes per second over the 15-minute period return formatBytesPerSecond(value) case "StatusCheckFailed": // This is a count (0 or 1), show as boolean-like if value == 0 { return "OK" } return "Failed" case "CPUCreditBalance", "CPUCreditUsage": // These are counts of credits return fmt.Sprintf("%.2f credits", value) case "DiskReadOps", "DiskWriteOps": // These are average operations per second over the 15-minute period return formatOpsPerSecond(value) default: return fmt.Sprintf("%.2f", value) } } // metricOutputMapper converts CloudWatch GetMetricData output to an SDP item func metricOutputMapper(ctx context.Context, client CloudwatchMetricClient, scope string, instanceID string, output *cloudwatch.GetMetricDataOutput) (*sdp.Item, error) { // Build attributes map with instance ID attrsMap := map[string]any{ "InstanceId": instanceID, "PeriodMinutes": 15, "Statistic": "Average", "DataAvailable": false, "LastUpdated": "", } // Map metric results to attributes var lastTime time.Time hasData := false for _, result := range output.MetricDataResults { if len(result.Values) > 0 && len(result.Timestamps) > 0 { // Get the most recent value (last in the arrays) value := result.Values[len(result.Values)-1] timestamp := result.Timestamps[len(result.Timestamps)-1] // Use the metric label as the attribute name metricName := aws.ToString(result.Label) // Store raw value attrsMap[metricName] = value // Store formatted value for human readability formattedKey := metricName + "_Formatted" attrsMap[formattedKey] = formatMetricValue(metricName, value) // Track the most recent timestamp if timestamp.After(lastTime) { lastTime = timestamp } hasData = true } } attrsMap["DataAvailable"] = hasData if !lastTime.IsZero() { attrsMap["LastUpdated"] = lastTime.Format(time.RFC3339) } attrs, err := sdp.ToAttributes(attrsMap) if err != nil { return nil, fmt.Errorf("failed to convert attributes: %w", err) } item := &sdp.Item{ Type: "cloudwatch-instance-metric", UniqueAttribute: "InstanceId", Scope: scope, Attributes: attrs, } return item, nil } // CloudwatchInstanceMetricAdapter is a custom adapter for CloudWatch EC2 instance metrics type CloudwatchInstanceMetricAdapter struct { Client CloudwatchMetricClient AccountID string Region string CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // The cache for this adapter (set during creation, can be nil for tests) } // Default cache duration for metrics - matches the 15-minute period over which metrics are averaged const defaultMetricCacheDuration = 15 * time.Minute func (a *CloudwatchInstanceMetricAdapter) cacheDuration() time.Duration { if a.CacheDuration == 0 { return defaultMetricCacheDuration } return a.CacheDuration } // Type returns the type of items this adapter returns func (a *CloudwatchInstanceMetricAdapter) Type() string { return "cloudwatch-instance-metric" } // Name returns the name of this adapter func (a *CloudwatchInstanceMetricAdapter) Name() string { return "cloudwatch-instance-metric-adapter" } // Metadata returns the adapter metadata func (a *CloudwatchInstanceMetricAdapter) Metadata() *sdp.AdapterMetadata { return cloudwatchInstanceMetricAdapterMetadata } // Scopes returns the scopes this adapter can query func (a *CloudwatchInstanceMetricAdapter) Scopes() []string { return []string{FormatScope(a.AccountID, a.Region)} } // Get fetches CloudWatch metrics for an EC2 instance by instance ID func (a *CloudwatchInstanceMetricAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != FormatScope(a.AccountID, a.Region) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("scope %s does not match adapter scope %s", scope, FormatScope(a.AccountID, a.Region)), Scope: scope, } } // Query is just the instance ID instanceID := query if err := validateInstanceID(instanceID); err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } // Check cache first var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError cacheHit, ck, cachedItems, qErr, done := a.cache.Lookup(ctx, a.Name(), sdp.QueryMethod_GET, scope, a.Type(), query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit && len(cachedItems) > 0 { return cachedItems[0], nil } // Query CloudWatch for the last 15 minutes endTime := time.Now() startTime := endTime.Add(-15 * time.Minute) // Build metric data queries for all metrics metricQueries := make([]types.MetricDataQuery, 0, len(ec2InstanceMetrics)) for i, metricName := range ec2InstanceMetrics { id := fmt.Sprintf("m%d", i) metricQueries = append(metricQueries, types.MetricDataQuery{ Id: aws.String(id), MetricStat: &types.MetricStat{ Metric: &types.Metric{ Namespace: aws.String("AWS/EC2"), MetricName: aws.String(metricName), Dimensions: []types.Dimension{ { Name: aws.String("InstanceId"), Value: aws.String(instanceID), }, }, }, Period: aws.Int32(900), // 15 minutes Stat: aws.String("Average"), }, Label: aws.String(metricName), }) } input := &cloudwatch.GetMetricDataInput{ MetricDataQueries: metricQueries, StartTime: aws.Time(startTime), EndTime: aws.Time(endTime), } output, err := a.Client.GetMetricData(ctx, input) if err != nil { qErr := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to get metric data: %v", err), Scope: scope, } // Cache the error a.cache.StoreUnavailableItem(ctx, qErr, a.cacheDuration(), ck) return nil, qErr } item, err := metricOutputMapper(ctx, a.Client, scope, instanceID, output) if err != nil { qErr := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to map metric output: %v", err), Scope: scope, } // Cache the error a.cache.StoreUnavailableItem(ctx, qErr, a.cacheDuration(), ck) return nil, qErr } // Store in cache a.cache.StoreItem(ctx, item, a.cacheDuration(), ck) return item, nil } // List is not supported for instance metrics - you must query specific instances func (a *CloudwatchInstanceMetricAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { // Listing all instance metrics is not practical // Return empty list with no error return []*sdp.Item{}, nil } // Search is not supported for instance metrics func (a *CloudwatchInstanceMetricAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { // Search delegates to Get for this adapter item, err := a.Get(ctx, scope, query, ignoreCache) if err != nil { return nil, err } return []*sdp.Item{item}, nil } // Weight returns the priority weight of this adapter func (a *CloudwatchInstanceMetricAdapter) Weight() int { return 100 } // NewCloudwatchInstanceMetricAdapter creates a new CloudWatch instance metric adapter func NewCloudwatchInstanceMetricAdapter(client *cloudwatch.Client, accountID string, region string, cache sdpcache.Cache) *CloudwatchInstanceMetricAdapter { return &CloudwatchInstanceMetricAdapter{ Client: client, AccountID: accountID, Region: region, cache: cache, } } var cloudwatchInstanceMetricAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "cloudwatch-instance-metric", DescriptiveName: "CloudWatch Instance Metric", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: false, // Listing all instance metrics is not practical Search: true, GetDescription: "Get CloudWatch metrics for an EC2 instance by instance ID (e.g., 'i-1234567890abcdef0')", SearchDescription: "Search for CloudWatch metrics for an EC2 instance using instance ID (e.g., 'i-1234567890abcdef0')", }, PotentialLinks: []string{"ec2-instance"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, }) ================================================ FILE: aws-source/adapters/cloudwatch-instance-metric_integration_test.go ================================================ //go:build integration package adapters import ( "context" "encoding/json" "os" "testing" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/overmindtech/cli/go/sdpcache" ) // TestCloudwatchInstanceMetricIntegration fetches real CloudWatch metrics for an EC2 instance // Run with: TEST_INSTANCE_ID=i-xxx AWS_PROFILE=terraform-example go test -v -tags=integration -run "TestCloudwatchInstanceMetricIntegration" ./aws-source/adapters/... func TestCloudwatchInstanceMetricIntegration(t *testing.T) { instanceID := os.Getenv("TEST_INSTANCE_ID") if instanceID == "" { t.Skip("Skipping integration test: TEST_INSTANCE_ID environment variable not set") } config, account, region := GetAutoConfig(t) client := cloudwatch.NewFromConfig(config) adapter := NewCloudwatchInstanceMetricAdapter(client, account, region, sdpcache.NewNoOpCache()) scope := FormatScope(account, region) // Query is just the instance ID query := instanceID t.Logf("Querying CloudWatch for instance: %s", instanceID) t.Logf("Query: %s", query) ctx := context.Background() item, err := adapter.Get(ctx, scope, query, false) if err != nil { t.Fatalf("Failed to get metrics: %v", err) } // Pretty print the item attributes attrs := item.GetAttributes().GetAttrStruct().AsMap() prettyJSON, _ := json.MarshalIndent(attrs, "", " ") t.Logf("\n=== CloudWatch Instance Metric Result ===\n%s\n", string(prettyJSON)) // Log key metrics t.Logf("\n=== Summary ===") t.Logf("Instance: %s", instanceID) t.Logf("Data Available: %v", attrs["DataAvailable"]) if attrs["DataAvailable"] == true { t.Logf("Last Updated: %v", attrs["LastUpdated"]) // Log all metrics for _, metricName := range ec2InstanceMetrics { if value, exists := attrs[metricName]; exists { t.Logf("%s: %v", metricName, value) } } } else { t.Logf("No data available for this instance") } } ================================================ FILE: aws-source/adapters/cloudwatch-instance-metric_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/overmindtech/cli/go/sdpcache" ) // testCloudwatchMetricClient is a mock client for testing GetMetricData type testCloudwatchMetricClient struct{} func (c testCloudwatchMetricClient) GetMetricData(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) { now := time.Now() // Return data for all metrics results := make([]types.MetricDataResult, 0, len(ec2InstanceMetrics)) for i, metricName := range ec2InstanceMetrics { // Each metric gets a single value (15-minute average) value := 50.0 + float64(i)*5.0 // Different values for each metric var id string if params.MetricDataQueries[i].Id != nil { id = *params.MetricDataQueries[i].Id } else { id = fmt.Sprintf("m%d", i) } results = append(results, types.MetricDataResult{ Id: aws.String(id), Label: aws.String(metricName), Timestamps: []time.Time{now}, Values: []float64{value}, StatusCode: types.StatusCodeComplete, }) } return &cloudwatch.GetMetricDataOutput{ MetricDataResults: results, Messages: []types.MessageData{}, }, nil } // testCloudwatchMetricClientEmpty returns no data type testCloudwatchMetricClientEmpty struct{} func (c testCloudwatchMetricClientEmpty) GetMetricData(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) { return &cloudwatch.GetMetricDataOutput{ MetricDataResults: []types.MetricDataResult{}, Messages: []types.MessageData{}, }, nil } // testCloudwatchMetricClientWithCallCount tracks how many times GetMetricData is called type testCloudwatchMetricClientWithCallCount struct { callCount int } func (c *testCloudwatchMetricClientWithCallCount) GetMetricData(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) { c.callCount++ now := time.Now() // Return data for all metrics results := make([]types.MetricDataResult, 0, len(ec2InstanceMetrics)) for i, metricName := range ec2InstanceMetrics { // Each metric gets a single value (15-minute average) value := 50.0 + float64(i)*5.0 // Different values for each metric var id string if params.MetricDataQueries[i].Id != nil { id = *params.MetricDataQueries[i].Id } else { id = fmt.Sprintf("m%d", i) } results = append(results, types.MetricDataResult{ Id: aws.String(id), Label: aws.String(metricName), Timestamps: []time.Time{now}, Values: []float64{value}, StatusCode: types.StatusCodeComplete, }) } return &cloudwatch.GetMetricDataOutput{ MetricDataResults: results, Messages: []types.MessageData{}, }, nil } func TestValidateInstanceID(t *testing.T) { tests := []struct { name string instanceID string expectError bool }{ { name: "valid instance ID - 17 characters (newer format)", instanceID: "i-1234567890abcdef0", expectError: false, }, { name: "valid instance ID - 8 characters (older format)", instanceID: "i-12345678", expectError: false, }, { name: "invalid format - missing i-", instanceID: "1234567890abcdef0", expectError: true, }, { name: "invalid format - too short", instanceID: "i-1234567", expectError: true, }, { name: "invalid format - wrong length (9 characters)", instanceID: "i-123456789", expectError: true, }, { name: "invalid format - too long", instanceID: "i-1234567890abcdef01", expectError: true, }, { name: "invalid format - invalid characters", instanceID: "i-1234567890abcdefg", expectError: true, }, { name: "empty string", instanceID: "", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateInstanceID(tt.instanceID) if tt.expectError { if err == nil { t.Errorf("expected error but got nil") } } else { if err != nil { t.Errorf("unexpected error: %v", err) } } }) } } func TestMetricOutputMapper(t *testing.T) { ctx := context.Background() client := testCloudwatchMetricClient{} scope := "123456789012.eu-west-2" instanceID := "i-1234567890abcdef0" now := time.Now() output := &cloudwatch.GetMetricDataOutput{ MetricDataResults: []types.MetricDataResult{ { Id: aws.String("m0"), Label: aws.String("CPUUtilization"), Timestamps: []time.Time{now}, Values: []float64{45.5}, StatusCode: types.StatusCodeComplete, }, { Id: aws.String("m1"), Label: aws.String("NetworkIn"), Timestamps: []time.Time{now}, Values: []float64{1024.0}, StatusCode: types.StatusCodeComplete, }, }, Messages: []types.MessageData{}, } item, err := metricOutputMapper(ctx, client, scope, instanceID, output) if err != nil { t.Fatalf("unexpected error: %v", err) } if err = item.Validate(); err != nil { t.Errorf("item validation failed: %v", err) } // Check type and unique attribute if item.GetType() != "cloudwatch-instance-metric" { t.Errorf("expected type cloudwatch-instance-metric, got %s", item.GetType()) } if item.GetUniqueAttribute() != "InstanceId" { t.Errorf("expected unique attribute InstanceId, got %s", item.GetUniqueAttribute()) } if item.GetScope() != scope { t.Errorf("expected scope %s, got %s", scope, item.GetScope()) } // Check attributes attrs := item.GetAttributes() if attrs == nil { t.Fatal("attributes are nil") } // Verify key attributes exist attrMap := attrs.GetAttrStruct().AsMap() if attrMap["InstanceId"] != instanceID { t.Errorf("expected InstanceId %s, got %v", instanceID, attrMap["InstanceId"]) } if attrMap["DataAvailable"] != true { t.Errorf("expected DataAvailable true, got %v", attrMap["DataAvailable"]) } if attrMap["CPUUtilization"].(float64) != 45.5 { t.Errorf("expected CPUUtilization 45.5, got %v", attrMap["CPUUtilization"]) } if attrMap["CPUUtilization_Formatted"] != "45.50%" { t.Errorf("expected CPUUtilization_Formatted '45.50%%', got %v", attrMap["CPUUtilization_Formatted"]) } if attrMap["NetworkIn"].(float64) != 1024.0 { t.Errorf("expected NetworkIn 1024.0, got %v", attrMap["NetworkIn"]) } if attrMap["NetworkIn_Formatted"] != "1.00 KB/s" { t.Errorf("expected NetworkIn_Formatted '1.00 KB/s', got %v", attrMap["NetworkIn_Formatted"]) } // Verify metadata about the averaging period if attrMap["Statistic"] != "Average" { t.Errorf("expected Statistic 'Average', got %v", attrMap["Statistic"]) } if attrMap["PeriodMinutes"].(float64) != 15 { t.Errorf("expected PeriodMinutes 15, got %v", attrMap["PeriodMinutes"]) } } func TestMetricOutputMapperNoData(t *testing.T) { ctx := context.Background() client := testCloudwatchMetricClientEmpty{} scope := "123456789012.eu-west-2" instanceID := "i-1234567890abcdef0" output := &cloudwatch.GetMetricDataOutput{ MetricDataResults: []types.MetricDataResult{}, Messages: []types.MessageData{}, } item, err := metricOutputMapper(ctx, client, scope, instanceID, output) if err != nil { t.Fatalf("unexpected error: %v", err) } if err = item.Validate(); err != nil { t.Errorf("item validation failed: %v", err) } attrMap := item.GetAttributes().GetAttrStruct().AsMap() // Should indicate no data available if attrMap["DataAvailable"] != false { t.Errorf("expected DataAvailable false, got %v", attrMap["DataAvailable"]) } } func TestCloudwatchInstanceMetricAdapterGet(t *testing.T) { adapter := &CloudwatchInstanceMetricAdapter{ Client: testCloudwatchMetricClient{}, AccountID: "123456789012", Region: "eu-west-2", cache: sdpcache.NewNoOpCache(), } scope := "123456789012.eu-west-2" query := "i-1234567890abcdef0" item, err := adapter.Get(context.Background(), scope, query, false) if err != nil { t.Fatalf("unexpected error: %v", err) } if item == nil { t.Fatal("expected item, got nil") } if item.GetType() != "cloudwatch-instance-metric" { t.Errorf("expected type cloudwatch-instance-metric, got %s", item.GetType()) } // Verify all metrics are present attrMap := item.GetAttributes().GetAttrStruct().AsMap() for _, metricName := range ec2InstanceMetrics { if _, exists := attrMap[metricName]; !exists { t.Errorf("expected metric %s to be present in attributes", metricName) } } } func TestCloudwatchInstanceMetricAdapterGetWrongScope(t *testing.T) { adapter := &CloudwatchInstanceMetricAdapter{ Client: testCloudwatchMetricClient{}, AccountID: "123456789012", Region: "eu-west-2", cache: sdpcache.NewNoOpCache(), } wrongScope := "999999999999.us-east-1" query := "i-1234567890abcdef0" _, err := adapter.Get(context.Background(), wrongScope, query, false) if err == nil { t.Error("expected error for wrong scope, got nil") } } func TestCloudwatchInstanceMetricAdapterGetInvalidQuery(t *testing.T) { adapter := &CloudwatchInstanceMetricAdapter{ Client: testCloudwatchMetricClient{}, AccountID: "123456789012", Region: "eu-west-2", cache: sdpcache.NewNoOpCache(), } scope := "123456789012.eu-west-2" tests := []struct { name string query string }{ {"invalid format", "not-an-instance-id"}, {"too short", "i-123"}, {"missing prefix", "1234567890abcdef0"}, {"empty string", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := adapter.Get(context.Background(), scope, tt.query, false) if err == nil { t.Errorf("expected error for %s, got nil", tt.name) } }) } } func TestCloudwatchInstanceMetricAdapterList(t *testing.T) { adapter := &CloudwatchInstanceMetricAdapter{ Client: testCloudwatchMetricClient{}, AccountID: "123456789012", Region: "eu-west-2", cache: sdpcache.NewNoOpCache(), } scope := "123456789012.eu-west-2" items, err := adapter.List(context.Background(), scope, false) if err != nil { t.Fatalf("unexpected error: %v", err) } // List should return empty - we can't list all instance metrics if len(items) != 0 { t.Errorf("expected 0 items from List, got %d", len(items)) } } func TestCloudwatchInstanceMetricAdapterScopes(t *testing.T) { adapter := &CloudwatchInstanceMetricAdapter{ Client: testCloudwatchMetricClient{}, AccountID: "123456789012", Region: "eu-west-2", } scopes := adapter.Scopes() if len(scopes) != 1 { t.Fatalf("expected 1 scope, got %d", len(scopes)) } if scopes[0] != "123456789012.eu-west-2" { t.Errorf("expected scope 123456789012.eu-west-2, got %s", scopes[0]) } } func TestCloudwatchInstanceMetricAdapterMetadata(t *testing.T) { adapter := &CloudwatchInstanceMetricAdapter{ Client: testCloudwatchMetricClient{}, AccountID: "123456789012", Region: "eu-west-2", } metadata := adapter.Metadata() if metadata == nil { t.Fatal("expected metadata, got nil") } if metadata.GetType() != "cloudwatch-instance-metric" { t.Errorf("expected type cloudwatch-instance-metric, got %s", metadata.GetType()) } } func TestNewCloudwatchInstanceMetricAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := cloudwatch.NewFromConfig(config) adapter := NewCloudwatchInstanceMetricAdapter(client, account, region, sdpcache.NewNoOpCache()) if adapter.Type() != "cloudwatch-instance-metric" { t.Errorf("expected type cloudwatch-instance-metric, got %s", adapter.Type()) } if adapter.Name() != "cloudwatch-instance-metric-adapter" { t.Errorf("expected name cloudwatch-instance-metric-adapter, got %s", adapter.Name()) } } func TestCloudwatchInstanceMetricAdapterCaching(t *testing.T) { client := &testCloudwatchMetricClientWithCallCount{} adapter := &CloudwatchInstanceMetricAdapter{ Client: client, AccountID: "123456789012", Region: "eu-west-2", cache: sdpcache.NewMemoryCache(), } scope := "123456789012.eu-west-2" query := "i-1234567890abcdef0" // First call should hit the API first, err := adapter.Get(context.Background(), scope, query, false) if err != nil { t.Fatalf("unexpected error on first call: %v", err) } if first == nil { t.Fatal("expected first item, got nil") } if client.callCount != 1 { t.Errorf("expected 1 API call, got %d", client.callCount) } // Second call should use cache (ignoreCache=false) second, err := adapter.Get(context.Background(), scope, query, false) if err != nil { t.Fatalf("unexpected error on second call: %v", err) } if second == nil { t.Fatal("expected second item, got nil") } // Should still be 1 call since we used cache if client.callCount != 1 { t.Errorf("expected 1 API call after cache hit, got %d", client.callCount) } // Verify both items are the same (from cache) // Compare by checking the InstanceId attribute firstAttrs := first.GetAttributes().GetAttrStruct().AsMap() secondAttrs := second.GetAttributes().GetAttrStruct().AsMap() if firstAttrs["InstanceId"] != secondAttrs["InstanceId"] { t.Error("cached item should match original item") } } func TestCloudwatchInstanceMetricAdapterIgnoreCache(t *testing.T) { client := &testCloudwatchMetricClientWithCallCount{} adapter := &CloudwatchInstanceMetricAdapter{ Client: client, AccountID: "123456789012", Region: "eu-west-2", cache: sdpcache.NewNoOpCache(), } scope := "123456789012.eu-west-2" query := "i-1234567890abcdef0" // First call should hit the API _, err := adapter.Get(context.Background(), scope, query, false) if err != nil { t.Fatalf("unexpected error on first call: %v", err) } if client.callCount != 1 { t.Errorf("expected 1 API call, got %d", client.callCount) } // Second call with ignoreCache=true should bypass cache and hit API again _, err = adapter.Get(context.Background(), scope, query, true) if err != nil { t.Fatalf("unexpected error on second call: %v", err) } // Should be 2 calls since we ignored cache if client.callCount != 2 { t.Errorf("expected 2 API calls after ignoreCache=true, got %d", client.callCount) } } // testCloudwatchMetricClientError always returns an error type testCloudwatchMetricClientError struct{} func (c testCloudwatchMetricClientError) GetMetricData(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) { return nil, fmt.Errorf("API error") } func TestCloudwatchInstanceMetricAdapterErrorCaching(t *testing.T) { adapter := &CloudwatchInstanceMetricAdapter{ Client: testCloudwatchMetricClientError{}, AccountID: "123456789012", Region: "eu-west-2", cache: sdpcache.NewNoOpCache(), } scope := "123456789012.eu-west-2" query := "i-1234567890abcdef0" // First call should fail and cache the error _, err := adapter.Get(context.Background(), scope, query, false) if err == nil { t.Fatal("expected error on first call, got nil") } // Second call should return the cached error without calling the API again // We can't easily verify the API wasn't called, but we can verify the same error is returned _, err2 := adapter.Get(context.Background(), scope, query, false) if err2 == nil { t.Fatal("expected cached error on second call, got nil") } if err.Error() != err2.Error() { t.Errorf("expected same error message, got different: %v vs %v", err, err2) } } ================================================ FILE: aws-source/adapters/cloudwatch_metric_links.go ================================================ package adapters import ( "errors" "strings" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/overmindtech/cli/go/sdp-go" ) var ErrNoQuery = errors.New("no query found") // SuggestQueries Suggests a linked item query based on the namespace and // dimensions of a metric. For metrics with many dimensions, it will use the // most specific dimension since many metrics have overlapping dimensions that // get more and more specific // // The full list of services that provide cloudwatch metrics can be found here: // https://github.com/awsdocs/amazon-cloudwatch-user-guide/blob/master/doc_source/aws-services-cloudwatch-metrics.md // // The below list is not exhaustive and improvements are welcome func SuggestedQuery(namespace string, scope string, dimensions []types.Dimension) (*sdp.LinkedItemQuery, error) { var query *sdp.Query var err error accountID, _, err := ParseScope(scope) if err != nil { return nil, err } switch namespace { case "AWS/Route53": if d := getDimension("HostedZoneId", dimensions); d != nil { query = &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } if d := getDimension("HealthCheckId", dimensions); d != nil { query = &sdp.Query{ Type: "route53-health-check", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/Lambda": if d := getDimension("FunctionName", dimensions); d != nil { query = &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/DynamoDB": if d := getDimension("TableName", dimensions); d != nil { query = &sdp.Query{ Type: "dynamodb-table", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/ECS": if d := getDimension("ServiceName", dimensions); d != nil { query = &sdp.Query{ Type: "ecs-service", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } break } if d := getDimension("ClusterName", dimensions); d != nil { query = &sdp.Query{ Type: "ecs-cluster", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } break } case "AWS/ELB": if d := getDimension("LoadBalancerName", dimensions); d != nil { query = &sdp.Query{ Type: "elb-load-balancer", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/EC2": if d := getDimension("InstanceId", dimensions); d != nil { query = &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } if d := getDimension("AutoScalingGroupName", dimensions); d != nil { query = &sdp.Query{ Type: "autoscaling-auto-scaling-group", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } if d := getDimension("ImageId", dimensions); d != nil { query = &sdp.Query{ Type: "ec2-image", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/RDS": if d := getDimension("DBInstanceIdentifier", dimensions); d != nil { query = &sdp.Query{ Type: "rds-db-instance", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } break } if d := getDimension("DBClusterIdentifier", dimensions); d != nil { query = &sdp.Query{ Type: "rds-db-cluster", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } break } case "AWS/EBS": if d := getDimension("VolumeId", dimensions); d != nil { query = &sdp.Query{ Type: "ec2-volume", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/ApplicationELB", "AWS/NetworkELB": if d := getDimension("TargetGroup", dimensions); d != nil { sections := strings.Split(*d.Value, "/") if len(sections) == 3 { query = &sdp.Query{ Type: "elbv2-target-group", Method: sdp.QueryMethod_GET, Query: sections[1], Scope: scope, } break } } if d := getDimension("LoadBalancer", dimensions); d != nil { sections := strings.Split(*d.Value, "/") if len(sections) == 3 { query = &sdp.Query{ Type: "elbv2-load-balancer", Method: sdp.QueryMethod_GET, Query: sections[1], Scope: scope, } break } } case "AWS/Backup": if d := getDimension("BackupVaultName", dimensions); d != nil { query = &sdp.Query{ Type: "backup-backup-vault", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/S3": if d := getDimension("BucketName", dimensions); d != nil { query = &sdp.Query{ Type: "s3-bucket", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: FormatScope(accountID, ""), } } case "AWS/NATGateway": if d := getDimension("NatGatewayId", dimensions); d != nil { query = &sdp.Query{ Type: "ec2-nat-gateway", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/CertificateManager": if d := getDimension("CertificateArn", dimensions); d != nil { query = &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } case "AWS/EFS": if d := getDimension("FileSystemId", dimensions); d != nil { query = &sdp.Query{ Type: "efs-file-system", Method: sdp.QueryMethod_GET, Query: *d.Value, Scope: scope, } } } if query == nil { err = ErrNoQuery } return &sdp.LinkedItemQuery{ Query: query, }, err } func getDimension(name string, dimensions []types.Dimension) *types.Dimension { for _, dimension := range dimensions { if *dimension.Name == name { return &dimension } } return nil } ================================================ FILE: aws-source/adapters/cloudwatch_metric_links_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "testing" ) func TestSuggestedQuery(t *testing.T) { t.Parallel() cases := []struct { Name string Namespace string Dimensions []types.Dimension ExpectedType string ExpectedQuery string }{ { Name: "AWS/EC2 Instance", Namespace: "AWS/EC2", Dimensions: []types.Dimension{ { Name: aws.String("InstanceId"), Value: aws.String("i-1234567890abcdef0"), }, }, ExpectedType: "ec2-instance", ExpectedQuery: "i-1234567890abcdef0", }, { Name: "AWS/EC2 AutoScalingGroup", Namespace: "AWS/EC2", Dimensions: []types.Dimension{ { Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg"), }, }, ExpectedType: "autoscaling-auto-scaling-group", ExpectedQuery: "my-asg", }, { Name: "AWS/EC2 Image", Namespace: "AWS/EC2", Dimensions: []types.Dimension{ { Name: aws.String("ImageId"), Value: aws.String("ami-1234567890abcdef0"), }, }, ExpectedType: "ec2-image", ExpectedQuery: "ami-1234567890abcdef0", }, { Name: "AWS/ApplicationELB with multiple dimensions", Namespace: "AWS/ApplicationELB", Dimensions: []types.Dimension{ { Name: aws.String("TargetGroup"), Value: aws.String("targetgroup/k8s-default-smartloo-d63873991a/98720d5dcd06067a"), }, { Name: aws.String("LoadBalancer"), Value: aws.String("app/ingress/1bf10920c5bd199d"), }, }, ExpectedType: "elbv2-target-group", ExpectedQuery: "k8s-default-smartloo-d63873991a", }, { Name: "AWS/ApplicationELB with one dimension", Namespace: "AWS/ApplicationELB", Dimensions: []types.Dimension{ { Name: aws.String("LoadBalancer"), Value: aws.String("app/ingress/1bf10920c5bd199d"), }, }, ExpectedType: "elbv2-load-balancer", ExpectedQuery: "ingress", }, { Name: "Backup", Namespace: "AWS/Backup", Dimensions: []types.Dimension{ { Name: aws.String("BackupVaultName"), Value: aws.String("aws/efs/automatic-backup-vault"), }, }, ExpectedType: "backup-backup-vault", ExpectedQuery: "aws/efs/automatic-backup-vault", }, { Name: "Certificate", Namespace: "AWS/CertificateManager", Dimensions: []types.Dimension{ { Name: aws.String("CertificateArn"), Value: aws.String("arn:aws:acm:eu-west-2:944651592624:certificate/3092dd18-f6cd-4ae7-b129-9023904bb7d0"), }, }, ExpectedType: "acm-certificate", ExpectedQuery: "arn:aws:acm:eu-west-2:944651592624:certificate/3092dd18-f6cd-4ae7-b129-9023904bb7d0", }, { Name: "EBS Volume", Namespace: "AWS/EBS", Dimensions: []types.Dimension{ { Name: aws.String("VolumeId"), Value: aws.String("vol-1234567890abcdef0"), }, }, ExpectedType: "ec2-volume", ExpectedQuery: "vol-1234567890abcdef0", }, { Name: "EBS Filesystem", Namespace: "AWS/EFS", Dimensions: []types.Dimension{ { Name: aws.String("FileSystemId"), Value: aws.String("fs-12345678"), }, }, ExpectedType: "efs-file-system", ExpectedQuery: "fs-12345678", }, { Name: "RDS Cluster", Namespace: "AWS/RDS", Dimensions: []types.Dimension{ { Name: aws.String("DBClusterIdentifier"), Value: aws.String("my-cluster"), }, }, ExpectedType: "rds-db-cluster", ExpectedQuery: "my-cluster", }, { Name: "RDS DB Instance", Namespace: "AWS/RDS", Dimensions: []types.Dimension{ { Name: aws.String("DBInstanceIdentifier"), Value: aws.String("my-instance"), }, }, ExpectedType: "rds-db-instance", ExpectedQuery: "my-instance", }, { Name: "RDS with cluster and instance", Namespace: "AWS/RDS", Dimensions: []types.Dimension{ { Name: aws.String("DBClusterIdentifier"), Value: aws.String("my-cluster"), }, { Name: aws.String("DBInstanceIdentifier"), Value: aws.String("my-instance"), }, }, ExpectedType: "rds-db-instance", ExpectedQuery: "my-instance", }, { Name: "S3 Bucket", Namespace: "AWS/S3", Dimensions: []types.Dimension{ { Name: aws.String("BucketName"), Value: aws.String("my-bucket"), }, }, ExpectedType: "s3-bucket", ExpectedQuery: "my-bucket", }, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { t.Parallel() scope := "123456789012.eu-west-2" query, err := SuggestedQuery(c.Namespace, scope, c.Dimensions) if err != nil { t.Fatal(err) } if query.GetQuery().GetType() != c.ExpectedType { t.Fatalf("expected type %q, got %q", c.ExpectedType, query.GetQuery().GetType()) } if query.GetQuery().GetQuery() != c.ExpectedQuery { t.Fatalf("expected query %q, got %q", c.ExpectedQuery, query.GetQuery().GetQuery()) } }) } } ================================================ FILE: aws-source/adapters/directconnect-connection.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func directconnectConnectionOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeConnectionsInput, output *directconnect.DescribeConnectionsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, connection := range output.Connections { attributes, err := ToAttributesWithExclude(connection, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-connection", UniqueAttribute: "ConnectionId", Attributes: attributes, Scope: scope, Tags: directconnectTagsToMap(connection.Tags), } if connection.LagId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-lag", Method: sdp.QueryMethod_GET, Query: *connection.LagId, Scope: scope, }, }) } if connection.Location != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-location", Method: sdp.QueryMethod_GET, Query: *connection.Location, Scope: scope, }, }) } if connection.LoaIssueTime != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-loa", Method: sdp.QueryMethod_GET, Query: *connection.ConnectionId, Scope: scope, }, }) } // Virtual Interfaces item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-virtual-interface", Method: sdp.QueryMethod_SEARCH, Query: *connection.ConnectionId, Scope: scope, }, }) items = append(items, &item) } return items, nil } func NewDirectConnectConnectionAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeConnectionsInput, *directconnect.DescribeConnectionsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeConnectionsInput, *directconnect.DescribeConnectionsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-connection", AdapterMetadata: directconnectConnectionAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeConnectionsInput) (*directconnect.DescribeConnectionsOutput, error) { return client.DescribeConnections(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeConnectionsInput, error) { return &directconnect.DescribeConnectionsInput{ ConnectionId: &query, }, nil }, InputMapperList: func(scope string) (*directconnect.DescribeConnectionsInput, error) { return &directconnect.DescribeConnectionsInput{}, nil }, OutputMapper: directconnectConnectionOutputMapper, } } var directconnectConnectionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-connection", DescriptiveName: "Connection", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a connection by ID", ListDescription: "List all connections", SearchDescription: "Search connection by ARN", }, PotentialLinks: []string{"directconnect-lag", "directconnect-location", "directconnect-loa", "directconnect-virtual-interface"}, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_dx_connection.id", }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/directconnect-connection_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDirectconnectConnectionOutputMapper(t *testing.T) { output := &directconnect.DescribeConnectionsOutput{ Connections: []types.Connection{ { AwsDeviceV2: new("EqDC2-123h49s71dabc"), AwsLogicalDeviceId: new("device-1"), Bandwidth: new("1Gbps"), ConnectionId: new("dxcon-fguhmqlc"), ConnectionName: new("My_Connection"), ConnectionState: "down", EncryptionMode: new("must_encrypt"), HasLogicalRedundancy: "unknown", JumboFrameCapable: new(true), LagId: new("dxlag-ffrz71kw"), LoaIssueTime: new(time.Now()), Location: new("EqDC2"), Region: new("us-east-1"), ProviderName: new("provider-1"), OwnerAccount: new("123456789012"), PartnerName: new("partner-1"), Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, }, }, } items, err := directconnectConnectionOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "directconnect-lag", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxlag-ffrz71kw", ExpectedScope: "foo", }, { ExpectedType: "directconnect-location", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "EqDC2", ExpectedScope: "foo", }, { ExpectedType: "directconnect-loa", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxcon-fguhmqlc", ExpectedScope: "foo", }, { ExpectedType: "directconnect-virtual-interface", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dxcon-fguhmqlc", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectConnectionAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectConnectionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-customer-metadata.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func customerMetadataOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeCustomerMetadataInput, output *directconnect.DescribeCustomerMetadataOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, agreement := range output.Agreements { attributes, err := ToAttributesWithExclude(agreement, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-customer-metadata", UniqueAttribute: "AgreementName", Attributes: attributes, Scope: scope, } items = append(items, &item) } return items, nil } func NewDirectConnectCustomerMetadataAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeCustomerMetadataInput, *directconnect.DescribeCustomerMetadataOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeCustomerMetadataInput, *directconnect.DescribeCustomerMetadataOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-customer-metadata", AdapterMetadata: customerMetadataAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeCustomerMetadataInput) (*directconnect.DescribeCustomerMetadataOutput, error) { return client.DescribeCustomerMetadata(ctx, input) }, // We want to use the list API for get and list operations UseListForGet: true, InputMapperGet: func(scope, _ string) (*directconnect.DescribeCustomerMetadataInput, error) { return &directconnect.DescribeCustomerMetadataInput{}, nil }, InputMapperList: func(scope string) (*directconnect.DescribeCustomerMetadataInput, error) { return &directconnect.DescribeCustomerMetadataInput{}, nil }, OutputMapper: customerMetadataOutputMapper, } } var customerMetadataAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-customer-metadata", DescriptiveName: "Customer Metadata", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a customer agreement by name", ListDescription: "List all customer agreements", SearchDescription: "Search customer agreements by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/directconnect-customer-metadata_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestCustomerMetadataOutputMapper(t *testing.T) { output := &directconnect.DescribeCustomerMetadataOutput{ Agreements: []types.CustomerAgreement{ { AgreementName: new("example-customer-agreement"), Status: new("signed"), }, }, } items, err := customerMetadataOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } } func TestNewDirectConnectCustomerMetadataAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectCustomerMetadataAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway-association-proposal.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func directConnectGatewayAssociationProposalOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeDirectConnectGatewayAssociationProposalsInput, output *directconnect.DescribeDirectConnectGatewayAssociationProposalsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, associationProposal := range output.DirectConnectGatewayAssociationProposals { attributes, err := ToAttributesWithExclude(associationProposal, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-direct-connect-gateway-association-proposal", UniqueAttribute: "ProposalId", Attributes: attributes, Scope: scope, } if associationProposal.DirectConnectGatewayId != nil && associationProposal.AssociatedGateway != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway-association", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%s/%s", *associationProposal.DirectConnectGatewayId, *associationProposal.AssociatedGateway.Id), Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewDirectConnectGatewayAssociationProposalAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewayAssociationProposalsInput, *directconnect.DescribeDirectConnectGatewayAssociationProposalsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewayAssociationProposalsInput, *directconnect.DescribeDirectConnectGatewayAssociationProposalsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, AdapterMetadata: directConnectGatewayAssociationProposalAdapterMetadata, cache: cache, ItemType: "directconnect-direct-connect-gateway-association-proposal", DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeDirectConnectGatewayAssociationProposalsInput) (*directconnect.DescribeDirectConnectGatewayAssociationProposalsOutput, error) { return client.DescribeDirectConnectGatewayAssociationProposals(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeDirectConnectGatewayAssociationProposalsInput, error) { return &directconnect.DescribeDirectConnectGatewayAssociationProposalsInput{ ProposalId: &query, }, nil }, InputMapperList: func(scope string) (*directconnect.DescribeDirectConnectGatewayAssociationProposalsInput, error) { return &directconnect.DescribeDirectConnectGatewayAssociationProposalsInput{}, nil }, OutputMapper: directConnectGatewayAssociationProposalOutputMapper, } } var directConnectGatewayAssociationProposalAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "Direct Connect Gateway Association Proposal", Type: "directconnect-direct-connect-gateway-association-proposal", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Direct Connect Gateway Association Proposal by ID", ListDescription: "List all Direct Connect Gateway Association Proposals", SearchDescription: "Search Direct Connect Gateway Association Proposals by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_dx_gateway_association_proposal.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, PotentialLinks: []string{"directconnect-direct-connect-gateway-association"}, }) ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway-association-proposal_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDirectConnectGatewayAssociationProposalOutputMapper(t *testing.T) { output := &directconnect.DescribeDirectConnectGatewayAssociationProposalsOutput{ DirectConnectGatewayAssociationProposals: []types.DirectConnectGatewayAssociationProposal{ { ProposalId: new("c2ede9b4-bbc6-4d33-923c-bc4feEXAMPLE"), DirectConnectGatewayId: new("5f294f92-bafb-4011-916d-9b0bexample"), DirectConnectGatewayOwnerAccount: new("123456789012"), ProposalState: types.DirectConnectGatewayAssociationProposalStateRequested, AssociatedGateway: &types.AssociatedGateway{ Id: new("tgw-02f776b1a7EXAMPLE"), Type: types.GatewayTypeTransitGateway, OwnerAccount: new("111122223333"), Region: new("us-east-1"), }, ExistingAllowedPrefixesToDirectConnectGateway: []types.RouteFilterPrefix{ { Cidr: new("192.168.2.0/30"), }, { Cidr: new("192.168.1.0/30"), }, }, RequestedAllowedPrefixesToDirectConnectGateway: []types.RouteFilterPrefix{ { Cidr: new("192.168.1.0/30"), }, }, }, }, } items, err := directConnectGatewayAssociationProposalOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "directconnect-direct-connect-gateway-association", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("%s/%s", "5f294f92-bafb-4011-916d-9b0bexample", "tgw-02f776b1a7EXAMPLE"), ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectGatewayAssociationProposalAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectGatewayAssociationProposalAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway-association.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) const ( directConnectGatewayIDVirtualGatewayIDFormat = "direct_connect_gateway_id/virtual_gateway_id" virtualGatewayIDFormat = "virtual_gateway_id" ) func directConnectGatewayAssociationOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeDirectConnectGatewayAssociationsInput, output *directconnect.DescribeDirectConnectGatewayAssociationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, association := range output.DirectConnectGatewayAssociations { attributes, err := ToAttributesWithExclude(association, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-direct-connect-gateway-association", UniqueAttribute: "AssociationId", Attributes: attributes, Scope: scope, } // stateChangeError =>The error message if the state of an object failed to advance. if association.StateChangeError != nil { item.Health = sdp.Health_HEALTH_ERROR.Enum() } else { item.Health = sdp.Health_HEALTH_OK.Enum() } if association.DirectConnectGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway", Method: sdp.QueryMethod_GET, Query: *association.DirectConnectGatewayId, Scope: "global", }, }) } if association.VirtualGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-virtual-gateway", Method: sdp.QueryMethod_GET, Query: *association.VirtualGatewayId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewDirectConnectGatewayAssociationAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewayAssociationsInput, *directconnect.DescribeDirectConnectGatewayAssociationsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewayAssociationsInput, *directconnect.DescribeDirectConnectGatewayAssociationsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-direct-connect-gateway-association", AdapterMetadata: directConnectGatewayAssociationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeDirectConnectGatewayAssociationsInput) (*directconnect.DescribeDirectConnectGatewayAssociationsOutput, error) { return client.DescribeDirectConnectGatewayAssociations(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeDirectConnectGatewayAssociationsInput, error) { // query must be either: // - in the format of "directConnectGatewayID/virtualGatewayID" // - virtualGatewayID => associatedGatewayID dxGatewayID, virtualGatewayID, err := parseDirectConnectGatewayAssociationGetInputQuery(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), } } if dxGatewayID != "" { return &directconnect.DescribeDirectConnectGatewayAssociationsInput{ DirectConnectGatewayId: &dxGatewayID, VirtualGatewayId: &virtualGatewayID, }, nil } else { return &directconnect.DescribeDirectConnectGatewayAssociationsInput{ AssociatedGatewayId: &virtualGatewayID, }, nil } }, InputMapperList: func(scope string) (*directconnect.DescribeDirectConnectGatewayAssociationsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for directconnect-direct-connect-gateway-association, use search", } }, OutputMapper: directConnectGatewayAssociationOutputMapper, InputMapperSearch: func(ctx context.Context, client *directconnect.Client, scope, query string) (*directconnect.DescribeDirectConnectGatewayAssociationsInput, error) { return &directconnect.DescribeDirectConnectGatewayAssociationsInput{ DirectConnectGatewayId: &query, }, nil }, } } var directConnectGatewayAssociationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "Direct Connect Gateway Association", Type: "directconnect-direct-connect-gateway-association", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a direct connect gateway association by direct connect gateway ID and virtual gateway ID", SearchDescription: "Search direct connect gateway associations by direct connect gateway ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_dx_gateway_association.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"directconnect-direct-connect-gateway"}, }) // parseDirectConnectGatewayAssociationGetInputQuery expects a query: // - in the format of "directConnectGatewayID/virtualGatewayID" // - virtualGatewayID => associatedGatewayID // // First returned item is directConnectGatewayID, second is virtualGatewayID func parseDirectConnectGatewayAssociationGetInputQuery(query string) (string, string, error) { ids := strings.Split(query, "/") switch len(ids) { case 1: return "", ids[0], nil case 2: return ids[0], ids[1], nil default: return "", "", fmt.Errorf("invalid query, expected in the format of %s or %s, got: %s", directConnectGatewayIDVirtualGatewayIDFormat, virtualGatewayIDFormat, query) } } ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway-association_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDirectConnectGatewayAssociationOutputMapper_Health_OK(t *testing.T) { output := &directconnect.DescribeDirectConnectGatewayAssociationsOutput{ DirectConnectGatewayAssociations: []types.DirectConnectGatewayAssociation{ { AssociationState: types.DirectConnectGatewayAssociationStateAssociating, AssociationId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), VirtualGatewayOwnerAccount: new("123456789012"), DirectConnectGatewayId: new("5f294f92-bafb-4011-916d-9b0bexample"), VirtualGatewayId: new("vgw-6efe725e"), }, }, } items, err := directConnectGatewayAssociationOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetHealth() != sdp.Health_HEALTH_OK { t.Fatalf("expected health to be OK, got: %v", item.GetHealth()) } tests := QueryTests{ { ExpectedType: "directconnect-direct-connect-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "5f294f92-bafb-4011-916d-9b0bexample", ExpectedScope: "global", }, { ExpectedType: "directconnect-virtual-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vgw-6efe725e", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestDirectConnectGatewayAssociationOutputMapper_Health_Error(t *testing.T) { output := &directconnect.DescribeDirectConnectGatewayAssociationsOutput{ DirectConnectGatewayAssociations: []types.DirectConnectGatewayAssociation{ { AssociationState: types.DirectConnectGatewayAssociationStateAssociating, AssociationId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), VirtualGatewayOwnerAccount: new("123456789012"), DirectConnectGatewayId: new("5f294f92-bafb-4011-916d-9b0bexample"), VirtualGatewayId: new("vgw-6efe725e"), StateChangeError: new("something went wrong"), }, }, } items, err := directConnectGatewayAssociationOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetHealth() != sdp.Health_HEALTH_ERROR { t.Fatalf("expected health to be ERROR, got: %v", item.GetHealth()) } tests := QueryTests{ { ExpectedType: "directconnect-direct-connect-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "5f294f92-bafb-4011-916d-9b0bexample", ExpectedScope: "global", }, { ExpectedType: "directconnect-virtual-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vgw-6efe725e", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectGatewayAssociationAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectGatewayAssociationAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway-attachment.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func directConnectGatewayAttachmentOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeDirectConnectGatewayAttachmentsInput, output *directconnect.DescribeDirectConnectGatewayAttachmentsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, attachment := range output.DirectConnectGatewayAttachments { attributes, err := ToAttributesWithExclude(attachment, "tags") if err != nil { return nil, err } // The uniqueAttributeValue for this is a custom field: // {gatewayId}/{virtualInterfaceId} // i.e., "cf68415c-f4ae-48f2-87a7-3b52cexample/dxvif-ffhhk74f" err = attributes.Set("UniqueName", fmt.Sprintf("%s/%s", *attachment.DirectConnectGatewayId, *attachment.VirtualInterfaceId)) if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-direct-connect-gateway-attachment", UniqueAttribute: "UniqueName", Attributes: attributes, Scope: scope, } // stateChangeError =>The error message if the state of an object failed to advance. if attachment.StateChangeError != nil { item.Health = sdp.Health_HEALTH_ERROR.Enum() } else { item.Health = sdp.Health_HEALTH_OK.Enum() } if attachment.DirectConnectGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway", Method: sdp.QueryMethod_GET, Query: *attachment.DirectConnectGatewayId, Scope: "global", }, }) } if attachment.VirtualInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-virtual-interface", Method: sdp.QueryMethod_GET, Query: *attachment.VirtualInterfaceId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewDirectConnectGatewayAttachmentAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewayAttachmentsInput, *directconnect.DescribeDirectConnectGatewayAttachmentsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewayAttachmentsInput, *directconnect.DescribeDirectConnectGatewayAttachmentsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-direct-connect-gateway-attachment", AdapterMetadata: directConnectGatewayAttachmentAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeDirectConnectGatewayAttachmentsInput) (*directconnect.DescribeDirectConnectGatewayAttachmentsOutput, error) { return client.DescribeDirectConnectGatewayAttachments(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeDirectConnectGatewayAttachmentsInput, error) { gatewayID, virtualInterfaceID, err := parseGatewayIDVirtualInterfaceID(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), } } return &directconnect.DescribeDirectConnectGatewayAttachmentsInput{ DirectConnectGatewayId: &gatewayID, VirtualInterfaceId: &virtualInterfaceID, }, nil }, InputMapperList: func(scope string) (*directconnect.DescribeDirectConnectGatewayAttachmentsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for directconnect-direct-connect-gateway-attachment, use search", } }, OutputMapper: directConnectGatewayAttachmentOutputMapper, InputMapperSearch: func(ctx context.Context, client *directconnect.Client, scope, query string) (*directconnect.DescribeDirectConnectGatewayAttachmentsInput, error) { return &directconnect.DescribeDirectConnectGatewayAttachmentsInput{ VirtualInterfaceId: &query, }, nil }, } } var directConnectGatewayAttachmentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-direct-connect-gateway-attachment", DescriptiveName: "Direct Connect Gateway Attachment", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a direct connect gateway attachment by DirectConnectGatewayId/VirtualInterfaceId", SearchDescription: "Search direct connect gateway attachments for given VirtualInterfaceId", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"directconnect-direct-connect-gateway", "directconnect-virtual-interface"}, }) // parseGatewayIDVirtualInterfaceID expects a query in the format of "gatewayID/virtualInterfaceID" // First returned item is gatewayID, second is virtualInterfaceID func parseGatewayIDVirtualInterfaceID(query string) (string, string, error) { ids := strings.Split(query, "/") if len(ids) != 2 { return "", "", fmt.Errorf("invalid query, expected in the format of %s, got: %s", gatewayIDVirtualInterfaceIDFormat, query) } return ids[0], ids[1], nil } ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway-attachment_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDirectConnectGatewayAttachmentOutputMapper_Health_OK(t *testing.T) { output := &directconnect.DescribeDirectConnectGatewayAttachmentsOutput{ DirectConnectGatewayAttachments: []types.DirectConnectGatewayAttachment{ { VirtualInterfaceOwnerAccount: new("123456789012"), VirtualInterfaceRegion: new("us-east-2"), VirtualInterfaceId: new("dxvif-ffhhk74f"), DirectConnectGatewayId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), AttachmentState: "detaching", }, }, } items, err := directConnectGatewayAttachmentOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetHealth() != sdp.Health_HEALTH_OK { t.Fatalf("expected health to be OK, got: %v", item.GetHealth()) } tests := QueryTests{ { ExpectedType: "directconnect-direct-connect-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cf68415c-f4ae-48f2-87a7-3b52cexample", ExpectedScope: "global", }, { ExpectedType: "directconnect-virtual-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxvif-ffhhk74f", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestDirectConnectGatewayAttachmentOutputMapper_Health_Error(t *testing.T) { output := &directconnect.DescribeDirectConnectGatewayAttachmentsOutput{ DirectConnectGatewayAttachments: []types.DirectConnectGatewayAttachment{ { VirtualInterfaceOwnerAccount: new("123456789012"), VirtualInterfaceRegion: new("us-east-2"), VirtualInterfaceId: new("dxvif-ffhhk74f"), DirectConnectGatewayId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), AttachmentState: "detaching", StateChangeError: new("error"), }, }, } items, err := directConnectGatewayAttachmentOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetHealth() != sdp.Health_HEALTH_ERROR { t.Fatalf("expected health to be ERROR, got: %v", item.GetHealth()) } tests := QueryTests{ { ExpectedType: "directconnect-direct-connect-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cf68415c-f4ae-48f2-87a7-3b52cexample", ExpectedScope: "global", }, { ExpectedType: "directconnect-virtual-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxvif-ffhhk74f", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectGatewayAttachmentAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectGatewayAttachmentAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func directConnectGatewayOutputMapper(ctx context.Context, cli *directconnect.Client, scope string, _ *directconnect.DescribeDirectConnectGatewaysInput, output *directconnect.DescribeDirectConnectGatewaysOutput) ([]*sdp.Item, error) { // create a slice of ARNs for the resources resourceARNs := make([]string, 0, len(output.DirectConnectGateways)) for _, directConnectGateway := range output.DirectConnectGateways { resourceARNs = append(resourceARNs, directconnectARN( scope, *directConnectGateway.OwnerAccount, *directConnectGateway.DirectConnectGatewayId, )) } tags := make(map[string][]types.Tag) var err error if len(resourceARNs) > 0 { // get tags for the resources in a map by their ARNs tags, err = arnToTags(ctx, cli, resourceARNs) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), } } } items := make([]*sdp.Item, 0) for _, directConnectGateway := range output.DirectConnectGateways { attributes, err := ToAttributesWithExclude(directConnectGateway, "tags") if err != nil { return nil, err } relevantTags := tags[directconnectARN(scope, *directConnectGateway.OwnerAccount, *directConnectGateway.DirectConnectGatewayId)] item := sdp.Item{ Type: "directconnect-direct-connect-gateway", UniqueAttribute: "DirectConnectGatewayId", Attributes: attributes, Scope: scope, Tags: directconnectTagsToMap(relevantTags), } // stateChangeError =>The error message if the state of an object failed to advance. if directConnectGateway.StateChangeError != nil { item.Health = sdp.Health_HEALTH_ERROR.Enum() } else { item.Health = sdp.Health_HEALTH_OK.Enum() } items = append(items, &item) } return items, nil } // arn constructs an ARN for a direct connect gateway // https://docs.aws.amazon.com/managedservices/latest/userguide/find-arn.html // https://docs.aws.amazon.com/service-authorization/latest/reference/list_awsdirectconnect.html#awsdirectconnect-resources-for-iam-policies func directconnectARN(region, accountID, gatewayID string) string { // arn:aws:service:region:account-id:resource-type/resource-id return fmt.Sprintf("arn:aws:directconnect:%s:%s:dx-gateway/%s", region, accountID, gatewayID) } func NewDirectConnectGatewayAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewaysInput, *directconnect.DescribeDirectConnectGatewaysOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeDirectConnectGatewaysInput, *directconnect.DescribeDirectConnectGatewaysOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-direct-connect-gateway", AdapterMetadata: directConnectGatewayAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeDirectConnectGatewaysInput) (*directconnect.DescribeDirectConnectGatewaysOutput, error) { return client.DescribeDirectConnectGateways(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeDirectConnectGatewaysInput, error) { return &directconnect.DescribeDirectConnectGatewaysInput{ DirectConnectGatewayId: &query, }, nil }, InputMapperList: func(scope string) (*directconnect.DescribeDirectConnectGatewaysInput, error) { return &directconnect.DescribeDirectConnectGatewaysInput{}, nil }, OutputMapper: directConnectGatewayOutputMapper, } } var directConnectGatewayAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-direct-connect-gateway", DescriptiveName: "Direct Connect Gateway", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a direct connect gateway by ID", ListDescription: "List all direct connect gateways", SearchDescription: "Search direct connect gateway by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_dx_gateway.id", }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/directconnect-direct-connect-gateway_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDirectConnectGatewayOutputMapper_Health_OK(t *testing.T) { output := &directconnect.DescribeDirectConnectGatewaysOutput{ DirectConnectGateways: []types.DirectConnectGateway{ { AmazonSideAsn: new(int64(64512)), DirectConnectGatewayId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), OwnerAccount: new("123456789012"), DirectConnectGatewayName: new("DxGateway2"), DirectConnectGatewayState: types.DirectConnectGatewayStateAvailable, }, }, } items, err := directConnectGatewayOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } if items[0].GetHealth() != sdp.Health_HEALTH_OK { t.Fatalf("expected health to be OK, got: %v", items[0].GetHealth()) } } func TestDirectConnectGatewayOutputMapper_Health_ERROR(t *testing.T) { output := &directconnect.DescribeDirectConnectGatewaysOutput{ DirectConnectGateways: []types.DirectConnectGateway{ { AmazonSideAsn: new(int64(64512)), DirectConnectGatewayId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), OwnerAccount: new("123456789012"), DirectConnectGatewayName: new("DxGateway2"), DirectConnectGatewayState: types.DirectConnectGatewayStateAvailable, StateChangeError: new("error"), }, }, } items, err := directConnectGatewayOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } if items[0].GetHealth() != sdp.Health_HEALTH_ERROR { t.Fatalf("expected health to be ERROR, got: %v", items[0].GetHealth()) } } func TestNewDirectConnectGatewayAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectGatewayAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } func Test_arn(t *testing.T) { tests := []struct { name string region string accountID string gatewayID string want string }{ { name: "us-west-2", region: "us-west-2", accountID: "123456789012", gatewayID: "cf68415c-f4ae-48f2-87a7-3b52cexample", want: "arn:aws:directconnect:us-west-2:123456789012:dx-gateway/cf68415c-f4ae-48f2-87a7-3b52cexample", }, { name: "us-east-1", region: "us-east-1", accountID: "123456789012", gatewayID: "cf68415c-f4ae-48f2-87a7-3b52cexample", want: "arn:aws:directconnect:us-east-1:123456789012:dx-gateway/cf68415c-f4ae-48f2-87a7-3b52cexample", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := directconnectARN(tt.region, tt.accountID, tt.gatewayID); got != tt.want { t.Errorf("arn() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: aws-source/adapters/directconnect-hosted-connection.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func hostedConnectionOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeHostedConnectionsInput, output *directconnect.DescribeHostedConnectionsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, connection := range output.Connections { attributes, err := ToAttributesWithExclude(connection, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-hosted-connection", UniqueAttribute: "ConnectionId", Attributes: attributes, Scope: scope, Tags: directconnectTagsToMap(connection.Tags), } if connection.LagId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-lag", Method: sdp.QueryMethod_GET, Query: *connection.LagId, Scope: scope, }, }) } if connection.Location != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-location", Method: sdp.QueryMethod_GET, Query: *connection.Location, Scope: scope, }, }) } if connection.LoaIssueTime != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-loa", Method: sdp.QueryMethod_GET, Query: *connection.ConnectionId, Scope: scope, }, }) } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-virtual-interface", Method: sdp.QueryMethod_SEARCH, Query: *connection.ConnectionId, Scope: scope, }, }) items = append(items, &item) } return items, nil } func NewDirectConnectHostedConnectionAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeHostedConnectionsInput, *directconnect.DescribeHostedConnectionsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeHostedConnectionsInput, *directconnect.DescribeHostedConnectionsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-hosted-connection", AdapterMetadata: hostedConnectionAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeHostedConnectionsInput) (*directconnect.DescribeHostedConnectionsOutput, error) { return client.DescribeHostedConnections(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeHostedConnectionsInput, error) { return &directconnect.DescribeHostedConnectionsInput{ ConnectionId: &query, }, nil }, InputMapperSearch: func(ctx context.Context, client *directconnect.Client, scope, query string) (*directconnect.DescribeHostedConnectionsInput, error) { return &directconnect.DescribeHostedConnectionsInput{ ConnectionId: &query, }, nil }, // InputMapperList: func(scope string) (*directconnect.DescribeHostedConnectionsInput, error) { // return &directconnect.DescribeHostedConnectionsInput{}, nil // }, OutputMapper: hostedConnectionOutputMapper, } } var hostedConnectionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-hosted-connection", DescriptiveName: "Hosted Connection", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Hosted Connection by connection ID", SearchDescription: "Search Hosted Connections by Interconnect or LAG ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_dx_hosted_connection.id"}, }, PotentialLinks: []string{"directconnect-lag", "directconnect-location", "directconnect-loa", "directconnect-virtual-interface"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/directconnect-hosted-connection_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestHostedConnectionOutputMapper(t *testing.T) { output := &directconnect.DescribeHostedConnectionsOutput{ Connections: []types.Connection{ { AwsDeviceV2: new("EqDC2-123h49s71dabc"), AwsLogicalDeviceId: new("device-1"), Bandwidth: new("1Gbps"), ConnectionId: new("dxcon-fguhmqlc"), ConnectionName: new("My_Connection"), ConnectionState: "down", EncryptionMode: new("must_encrypt"), HasLogicalRedundancy: "unknown", JumboFrameCapable: new(true), LagId: new("dxlag-ffrz71kw"), LoaIssueTime: new(time.Now()), Location: new("EqDC2"), Region: new("us-east-1"), ProviderName: new("provider-1"), OwnerAccount: new("123456789012"), PartnerName: new("partner-1"), Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, }, }, } items, err := hostedConnectionOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "directconnect-lag", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxlag-ffrz71kw", ExpectedScope: "foo", }, { ExpectedType: "directconnect-location", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "EqDC2", ExpectedScope: "foo", }, { ExpectedType: "directconnect-loa", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxcon-fguhmqlc", ExpectedScope: "foo", }, { ExpectedType: "directconnect-virtual-interface", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dxcon-fguhmqlc", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectHostedConnectionAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectHostedConnectionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-interconnect.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func interconnectOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeInterconnectsInput, output *directconnect.DescribeInterconnectsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, interconnect := range output.Interconnects { attributes, err := ToAttributesWithExclude(interconnect, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-interconnect", UniqueAttribute: "InterconnectId", Attributes: attributes, Scope: scope, Tags: directconnectTagsToMap(interconnect.Tags), } switch interconnect.InterconnectState { case types.InterconnectStateRequested: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.InterconnectStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.InterconnectStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.InterconnectStateDown: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.InterconnectStateDeleting: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.InterconnectStateDeleted: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.InterconnectStateUnknown: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } if interconnect.InterconnectId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-hosted-connection", Method: sdp.QueryMethod_SEARCH, Query: *interconnect.InterconnectId, Scope: scope, }, }) } if interconnect.LagId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-lag", Method: sdp.QueryMethod_GET, Query: *interconnect.LagId, Scope: scope, }, }) } if interconnect.LoaIssueTime != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-loa", Method: sdp.QueryMethod_GET, Query: *interconnect.InterconnectId, Scope: scope, }, }) } if interconnect.Location != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-location", Method: sdp.QueryMethod_GET, Query: *interconnect.Location, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewDirectConnectInterconnectAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeInterconnectsInput, *directconnect.DescribeInterconnectsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeInterconnectsInput, *directconnect.DescribeInterconnectsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-interconnect", AdapterMetadata: interconnectAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeInterconnectsInput) (*directconnect.DescribeInterconnectsOutput, error) { return client.DescribeInterconnects(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeInterconnectsInput, error) { return &directconnect.DescribeInterconnectsInput{ InterconnectId: &query, }, nil }, InputMapperList: func(scope string) (*directconnect.DescribeInterconnectsInput, error) { return &directconnect.DescribeInterconnectsInput{}, nil }, OutputMapper: interconnectOutputMapper, } } var interconnectAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-interconnect", DescriptiveName: "Interconnect", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"directconnect-hosted-connection", "directconnect-lag", "directconnect-loa", "directconnect-location"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Interconnect by InterconnectId", ListDescription: "List all Interconnects", SearchDescription: "Search Interconnects by ARN", }, }) ================================================ FILE: aws-source/adapters/directconnect-interconnect_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestInterconnectOutputMapper(t *testing.T) { output := &directconnect.DescribeInterconnectsOutput{ Interconnects: []types.Interconnect{ { AwsDeviceV2: new("EqDC2-123h49s71dabc"), AwsLogicalDeviceId: new("device-1"), Bandwidth: new("1Gbps"), HasLogicalRedundancy: types.HasLogicalRedundancyUnknown, InterconnectId: new("dxcon-fguhmqlc"), InterconnectName: new("interconnect-1"), InterconnectState: types.InterconnectStateAvailable, JumboFrameCapable: new(true), LagId: new("dxlag-ffrz71kw"), LoaIssueTime: new(time.Now()), Location: new("EqDC2"), Region: new("us-east-1"), ProviderName: new("provider-1"), Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, }, }, } items, err := interconnectOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "directconnect-lag", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxlag-ffrz71kw", ExpectedScope: "foo", }, { ExpectedType: "directconnect-location", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "EqDC2", ExpectedScope: "foo", }, { ExpectedType: "directconnect-loa", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxcon-fguhmqlc", ExpectedScope: "foo", }, { ExpectedType: "directconnect-hosted-connection", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dxcon-fguhmqlc", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestInterconnectHealth(t *testing.T) { cases := []struct { state types.InterconnectState health sdp.Health }{ { state: types.InterconnectStateRequested, health: sdp.Health_HEALTH_PENDING, }, { state: types.InterconnectStatePending, health: sdp.Health_HEALTH_PENDING, }, { state: types.InterconnectStateAvailable, health: sdp.Health_HEALTH_OK, }, { state: types.InterconnectStateDown, health: sdp.Health_HEALTH_ERROR, }, { state: types.InterconnectStateDeleting, health: sdp.Health_HEALTH_UNKNOWN, }, { state: types.InterconnectStateDeleted, health: sdp.Health_HEALTH_UNKNOWN, }, { state: types.InterconnectStateUnknown, health: sdp.Health_HEALTH_UNKNOWN, }, } for _, c := range cases { output := &directconnect.DescribeInterconnectsOutput{ Interconnects: []types.Interconnect{ { InterconnectState: c.state, LagId: new("dxlag-fgsu9erb"), }, }, } items, err := interconnectOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetHealth() != c.health { t.Errorf("expected health to be %v, got: %v", c.health, item.GetHealth()) } } } func TestNewDirectConnectInterconnectAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectInterconnectAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, // Listing these in our test account gives "An error occurred // (DirectConnectClientException) when calling the DescribeInterconnects // operation: Account [NUMBER] is not an authorized Direct Connect // partner in eu-west-2." // // Skipping tests for now SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-lag.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func lagOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeLagsInput, output *directconnect.DescribeLagsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, lag := range output.Lags { attributes, err := ToAttributesWithExclude(lag, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-lag", UniqueAttribute: "LagId", Attributes: attributes, Scope: scope, Tags: directconnectTagsToMap(lag.Tags), } switch lag.LagState { case types.LagStateRequested: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.LagStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.LagStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.LagStateDown: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.LagStateDeleting: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.LagStateDeleted: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.LagStateUnknown: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } for _, connection := range lag.Connections { if connection.ConnectionId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-connection", Method: sdp.QueryMethod_GET, Query: *connection.ConnectionId, Scope: scope, }, }) } } if lag.LagId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-hosted-connection", Method: sdp.QueryMethod_SEARCH, Query: *lag.LagId, Scope: scope, }, }) } if lag.Location != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-location", Method: sdp.QueryMethod_GET, // This is location code, not its name Query: *lag.Location, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewDirectConnectLagAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeLagsInput, *directconnect.DescribeLagsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeLagsInput, *directconnect.DescribeLagsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-lag", AdapterMetadata: lagAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeLagsInput) (*directconnect.DescribeLagsOutput, error) { return client.DescribeLags(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeLagsInput, error) { return &directconnect.DescribeLagsInput{ LagId: &query, }, nil }, InputMapperList: func(scope string) (*directconnect.DescribeLagsInput, error) { return &directconnect.DescribeLagsInput{}, nil }, OutputMapper: lagOutputMapper, } } var lagAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-lag", DescriptiveName: "Link Aggregation Group", PotentialLinks: []string{"directconnect-connection", "directconnect-hosted-connection", "directconnect-location"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Link Aggregation Group by ID", ListDescription: "List all Link Aggregation Groups", SearchDescription: "Search Link Aggregation Group by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_dx_lag.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/directconnect-lag_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" ) func TestLagHealth(t *testing.T) { cases := []struct { state types.LagState health sdp.Health }{ { state: types.LagStateRequested, health: sdp.Health_HEALTH_PENDING, }, { state: types.LagStatePending, health: sdp.Health_HEALTH_PENDING, }, { state: types.LagStateAvailable, health: sdp.Health_HEALTH_OK, }, { state: types.LagStateDown, health: sdp.Health_HEALTH_ERROR, }, { state: types.LagStateDeleting, health: sdp.Health_HEALTH_UNKNOWN, }, { state: types.LagStateDeleted, health: sdp.Health_HEALTH_UNKNOWN, }, { state: types.LagStateUnknown, health: sdp.Health_HEALTH_UNKNOWN, }, } for _, c := range cases { output := &directconnect.DescribeLagsOutput{ Lags: []types.Lag{ { LagState: c.state, LagId: new("dxlag-fgsu9erb"), }, }, } items, err := lagOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetHealth() != c.health { t.Errorf("expected health to be %v, got: %v", c.health, item.GetHealth()) } } } func TestLagOutputMapper(t *testing.T) { output := &directconnect.DescribeLagsOutput{ Lags: []types.Lag{ { AwsDeviceV2: new("EqDC2-19y7z3m17xpuz"), NumberOfConnections: int32(2), LagState: types.LagStateAvailable, OwnerAccount: new("123456789012"), LagName: new("DA-LAG"), Connections: []types.Connection{ { OwnerAccount: new("123456789012"), ConnectionId: new("dxcon-ffnikghc"), LagId: new("dxlag-fgsu9erb"), ConnectionState: "requested", Bandwidth: new("10Gbps"), Location: new("EqDC2"), ConnectionName: new("Requested Connection 1 for Lag dxlag-fgsu9erb"), Region: new("us-east-1"), }, { OwnerAccount: new("123456789012"), ConnectionId: new("dxcon-fglgbdea"), LagId: new("dxlag-fgsu9erb"), ConnectionState: "requested", Bandwidth: new("10Gbps"), Location: new("EqDC2"), ConnectionName: new("Requested Connection 2 for Lag dxlag-fgsu9erb"), Region: new("us-east-1"), }, }, LagId: new("dxlag-fgsu9erb"), MinimumLinks: int32(0), ConnectionsBandwidth: new("10Gbps"), Region: new("us-east-1"), Location: new("EqDC2"), }, }, } items, err := lagOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetHealth() != sdp.Health_HEALTH_OK { t.Fatalf("expected health to be OK, got: %v", item.GetHealth()) } tests := QueryTests{ { ExpectedType: "directconnect-connection", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxcon-ffnikghc", ExpectedScope: "foo", }, { ExpectedType: "directconnect-connection", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxcon-fglgbdea", ExpectedScope: "foo", }, { ExpectedType: "directconnect-location", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "EqDC2", ExpectedScope: "foo", }, { ExpectedType: "directconnect-hosted-connection", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dxlag-fgsu9erb", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectLagAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectLagAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-location.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func locationOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeLocationsInput, output *directconnect.DescribeLocationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, location := range output.Locations { attributes, err := ToAttributesWithExclude(location, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-location", UniqueAttribute: "LocationCode", Attributes: attributes, Scope: scope, } items = append(items, &item) } return items, nil } func NewDirectConnectLocationAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeLocationsInput, *directconnect.DescribeLocationsOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeLocationsInput, *directconnect.DescribeLocationsOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-location", AdapterMetadata: directconnectLocationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeLocationsInput) (*directconnect.DescribeLocationsOutput, error) { return client.DescribeLocations(ctx, input) }, // We want to use the list API for get and list operations UseListForGet: true, InputMapperGet: func(scope, _ string) (*directconnect.DescribeLocationsInput, error) { return &directconnect.DescribeLocationsInput{}, nil }, InputMapperList: func(scope string) (*directconnect.DescribeLocationsInput, error) { return &directconnect.DescribeLocationsInput{}, nil }, OutputMapper: locationOutputMapper, } } var directconnectLocationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-location", DescriptiveName: "Direct Connect Location", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Location by its code", ListDescription: "List all Direct Connect Locations", SearchDescription: "Search Direct Connect Locations by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_dx_location.location_code"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/directconnect-location_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestLocationOutputMapper(t *testing.T) { output := &directconnect.DescribeLocationsOutput{ Locations: []types.Location{ { AvailableMacSecPortSpeeds: []string{"1 Gbps", "10 Gbps"}, AvailablePortSpeeds: []string{"50 Mbps", "100 Mbps", "1 Gbps", "10 Gbps"}, AvailableProviders: []string{"ProviderA", "ProviderB", "ProviderC"}, LocationName: new("NAP do Brasil, Barueri, Sao Paulo"), LocationCode: new("TNDB"), Region: new("us-east-1"), }, }, } items, err := locationOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } } func TestNewDirectConnectLocationAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectLocationAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-router-configuration.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func routerConfigurationOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeRouterConfigurationInput, output *directconnect.DescribeRouterConfigurationOutput) ([]*sdp.Item, error) { if output == nil || output.Router == nil { return nil, nil } attributes, err := ToAttributesWithExclude(output, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-router-configuration", UniqueAttribute: "VirtualInterfaceId", Attributes: attributes, Scope: scope, } if output.VirtualInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-virtual-interface", Method: sdp.QueryMethod_GET, Query: *output.VirtualInterfaceId, Scope: scope, }, }) } return []*sdp.Item{ &item, }, nil } func NewDirectConnectRouterConfigurationAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeRouterConfigurationInput, *directconnect.DescribeRouterConfigurationOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeRouterConfigurationInput, *directconnect.DescribeRouterConfigurationOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-router-configuration", AdapterMetadata: routerConfigurationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeRouterConfigurationInput) (*directconnect.DescribeRouterConfigurationOutput, error) { return client.DescribeRouterConfiguration(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeRouterConfigurationInput, error) { return &directconnect.DescribeRouterConfigurationInput{ VirtualInterfaceId: &query, }, nil }, OutputMapper: routerConfigurationOutputMapper, } } var routerConfigurationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-router-configuration", DescriptiveName: "Router Configuration", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Router Configuration by Virtual Interface ID", SearchDescription: "Search Router Configuration by ARN", }, PotentialLinks: []string{"directconnect-virtual-interface"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_dx_router_configuration.virtual_interface_id"}, }, }) ================================================ FILE: aws-source/adapters/directconnect-router-configuration_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestRouterConfigurationOutputMapper(t *testing.T) { output := &directconnect.DescribeRouterConfigurationOutput{ CustomerRouterConfig: new("some config"), Router: &types.RouterType{ Platform: new("2900 Series Routers"), RouterTypeIdentifier: new("CiscoSystemsInc-2900SeriesRouters-IOS124"), Software: new("IOS 12.4+"), Vendor: new("Cisco Systems, Inc."), XsltTemplateName: new("customer-router-cisco-generic.xslt"), XsltTemplateNameForMacSec: new(""), }, VirtualInterfaceId: new("dxvif-ffhhk74f"), VirtualInterfaceName: new("PrivateVirtualInterface"), } items, err := routerConfigurationOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "directconnect-virtual-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxvif-ffhhk74f", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectRouterConfigurationAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectRouterConfigurationAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-virtual-gateway.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func virtualGatewayOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeVirtualGatewaysInput, output *directconnect.DescribeVirtualGatewaysOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, virtualGateway := range output.VirtualGateways { attributes, err := ToAttributesWithExclude(virtualGateway, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-virtual-gateway", UniqueAttribute: "VirtualGatewayId", Attributes: attributes, Scope: scope, } items = append(items, &item) } return items, nil } func NewDirectConnectVirtualGatewayAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeVirtualGatewaysInput, *directconnect.DescribeVirtualGatewaysOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeVirtualGatewaysInput, *directconnect.DescribeVirtualGatewaysOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-virtual-gateway", AdapterMetadata: virtualGatewayAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeVirtualGatewaysInput) (*directconnect.DescribeVirtualGatewaysOutput, error) { return client.DescribeVirtualGateways(ctx, input) }, // We want to use the list API for get and list operations UseListForGet: true, InputMapperGet: func(scope, _ string) (*directconnect.DescribeVirtualGatewaysInput, error) { return &directconnect.DescribeVirtualGatewaysInput{}, nil }, InputMapperList: func(scope string) (*directconnect.DescribeVirtualGatewaysInput, error) { return &directconnect.DescribeVirtualGatewaysInput{}, nil }, OutputMapper: virtualGatewayOutputMapper, } } var virtualGatewayAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-virtual-gateway", DescriptiveName: "Direct Connect Virtual Gateway", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a virtual gateway by ID", ListDescription: "List all virtual gateways", SearchDescription: "Search virtual gateways by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/directconnect-virtual-gateway_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestVirtualGatewayOutputMapper(t *testing.T) { output := &directconnect.DescribeVirtualGatewaysOutput{ VirtualGateways: []types.VirtualGateway{ { VirtualGatewayId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), VirtualGatewayState: new("available"), }, }, } items, err := virtualGatewayOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } } func TestNewDirectConnectVirtualGatewayAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectVirtualGatewayAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect-virtual-interface.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) const gatewayIDVirtualInterfaceIDFormat = "gateway_id/virtual_interface_id" func virtualInterfaceOutputMapper(_ context.Context, _ *directconnect.Client, scope string, _ *directconnect.DescribeVirtualInterfacesInput, output *directconnect.DescribeVirtualInterfacesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, virtualInterface := range output.VirtualInterfaces { attributes, err := ToAttributesWithExclude(virtualInterface, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "directconnect-virtual-interface", UniqueAttribute: "VirtualInterfaceId", Attributes: attributes, Scope: scope, Tags: directconnectTagsToMap(virtualInterface.Tags), } if virtualInterface.ConnectionId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-connection", Method: sdp.QueryMethod_GET, Query: *virtualInterface.ConnectionId, Scope: scope, }, }) } if virtualInterface.DirectConnectGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway", Method: sdp.QueryMethod_GET, Query: *virtualInterface.DirectConnectGatewayId, Scope: "global", }, }) } if virtualInterface.AmazonAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-ip-network", Method: sdp.QueryMethod_SEARCH, Query: *virtualInterface.AmazonAddress, Scope: "global", }, }) } if virtualInterface.CustomerAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-ip-network", Method: sdp.QueryMethod_SEARCH, Query: *virtualInterface.CustomerAddress, Scope: "global", }, }) } // Pinpoint a single attachment if virtualInterface.DirectConnectGatewayId != nil && virtualInterface.VirtualInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway-attachment", Method: sdp.QueryMethod_GET, // returns a single attachment Query: fmt.Sprintf("%s/%s", *virtualInterface.DirectConnectGatewayId, *virtualInterface.VirtualInterfaceId), Scope: scope, }, }) } // Find all affected attachments if virtualInterface.VirtualInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway-attachment", Method: sdp.QueryMethod_SEARCH, // returns list of attachments for the given virtual interface id Query: *virtualInterface.VirtualInterfaceId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewDirectConnectVirtualInterfaceAdapter(client *directconnect.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*directconnect.DescribeVirtualInterfacesInput, *directconnect.DescribeVirtualInterfacesOutput, *directconnect.Client, *directconnect.Options] { return &DescribeOnlyAdapter[*directconnect.DescribeVirtualInterfacesInput, *directconnect.DescribeVirtualInterfacesOutput, *directconnect.Client, *directconnect.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "directconnect-virtual-interface", AdapterMetadata: virtualInterfaceAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *directconnect.Client, input *directconnect.DescribeVirtualInterfacesInput) (*directconnect.DescribeVirtualInterfacesOutput, error) { return client.DescribeVirtualInterfaces(ctx, input) }, InputMapperGet: func(scope, query string) (*directconnect.DescribeVirtualInterfacesInput, error) { return &directconnect.DescribeVirtualInterfacesInput{ VirtualInterfaceId: &query, }, nil }, InputMapperList: func(scope string) (*directconnect.DescribeVirtualInterfacesInput, error) { return &directconnect.DescribeVirtualInterfacesInput{}, nil }, OutputMapper: virtualInterfaceOutputMapper, InputMapperSearch: func(ctx context.Context, client *directconnect.Client, scope, query string) (*directconnect.DescribeVirtualInterfacesInput, error) { return &directconnect.DescribeVirtualInterfacesInput{ ConnectionId: &query, }, nil }, } } var virtualInterfaceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "directconnect-virtual-interface", DescriptiveName: "Virtual Interface", PotentialLinks: []string{"directconnect-connection", "directconnect-direct-connect-gateway", "rdap-ip-network", "directconnect-direct-connect-gateway-attachment", "directconnect-virtual-interface"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a virtual interface by ID", ListDescription: "List all virtual interfaces", SearchDescription: "Search virtual interfaces by connection ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_dx_private_virtual_interface.id"}, {TerraformQueryMap: "aws_dx_public_virtual_interface.id"}, {TerraformQueryMap: "aws_dx_transit_virtual_interface.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/directconnect-virtual-interface_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestVirtualInterfaceOutputMapper(t *testing.T) { output := &directconnect.DescribeVirtualInterfacesOutput{ VirtualInterfaces: []types.VirtualInterface{ { VirtualInterfaceId: new("dxvif-ffhhk74f"), ConnectionId: new("dxcon-fguhmqlc"), VirtualInterfaceState: "verifying", CustomerAddress: new("192.168.1.2/30"), AmazonAddress: new("192.168.1.1/30"), VirtualInterfaceType: new("private"), VirtualInterfaceName: new("PrivateVirtualInterface"), DirectConnectGatewayId: new("cf68415c-f4ae-48f2-87a7-3b52cexample"), }, }, } items, err := virtualInterfaceOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "directconnect-connection", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxcon-fguhmqlc", ExpectedScope: "foo", }, { ExpectedType: "directconnect-direct-connect-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cf68415c-f4ae-48f2-87a7-3b52cexample", ExpectedScope: "global", }, { ExpectedType: "rdap-ip-network", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "192.168.1.1/30", ExpectedScope: "global", }, { ExpectedType: "rdap-ip-network", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "192.168.1.2/30", ExpectedScope: "global", }, { ExpectedType: "directconnect-direct-connect-gateway-attachment", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("%s/%s", "cf68415c-f4ae-48f2-87a7-3b52cexample", "dxvif-ffhhk74f"), ExpectedScope: "foo", }, { ExpectedType: "directconnect-direct-connect-gateway-attachment", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dxvif-ffhhk74f", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDirectConnectVirtualInterfaceAdapter(t *testing.T) { client, account, region := directconnectGetAutoConfig(t) adapter := NewDirectConnectVirtualInterfaceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/directconnect.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/directconnect" "github.com/aws/aws-sdk-go-v2/service/directconnect/types" ) // Converts a slice of tags to a map func directconnectTagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } return tagsMap } func arnToTags(ctx context.Context, cli *directconnect.Client, resourceARNs []string) (map[string][]types.Tag, error) { if cli == nil { return nil, nil } tagsOutput, err := cli.DescribeTags(ctx, &directconnect.DescribeTagsInput{ ResourceArns: resourceARNs, }) if err != nil { return nil, err } tags := make(map[string][]types.Tag, len(tagsOutput.ResourceTags)) for _, tag := range tagsOutput.ResourceTags { tags[*tag.ResourceArn] = tag.Tags } return tags, nil } ================================================ FILE: aws-source/adapters/directconnect_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/directconnect" "testing" ) func directconnectGetAutoConfig(t *testing.T) (*directconnect.Client, string, string) { config, account, region := GetAutoConfig(t) client := directconnect.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/dynamodb-backup.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func backupGetFunc(ctx context.Context, client Client, scope string, input *dynamodb.DescribeBackupInput) (*sdp.Item, error) { out, err := client.DescribeBackup(ctx, input) if err != nil { return nil, err } if out.BackupDescription == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "backup description was nil", } } if out.BackupDescription.BackupDetails == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "backup details were nil", } } details := out.BackupDescription.BackupDetails attributes, err := ToAttributesWithExclude(details) if err != nil { return nil, err } item := sdp.Item{ Type: "dynamodb-backup", UniqueAttribute: "BackupName", Attributes: attributes, Scope: scope, } if out.BackupDescription.SourceTableDetails != nil { if out.BackupDescription.SourceTableDetails.TableName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dynamodb-table", Method: sdp.QueryMethod_GET, Query: *out.BackupDescription.SourceTableDetails.TableName, Scope: scope, }, }) } } return &item, nil } // NewBackupAdapter This adapter is a bit strange. This is the only thing I've // found so far that can only be queries by ARN for Get. For this reason I'm // going to just disable GET. LIST works fine and allows it to be linked to the // table so this is enough for me at the moment func NewDynamoDBBackupAdapter(client Client, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*dynamodb.ListBackupsInput, *dynamodb.ListBackupsOutput, *dynamodb.DescribeBackupInput, *dynamodb.DescribeBackupOutput, Client, *dynamodb.Options] { return &AlwaysGetAdapter[*dynamodb.ListBackupsInput, *dynamodb.ListBackupsOutput, *dynamodb.DescribeBackupInput, *dynamodb.DescribeBackupOutput, Client, *dynamodb.Options]{ ItemType: "dynamodb-backup", Client: client, AccountID: accountID, Region: region, GetFunc: backupGetFunc, ListInput: &dynamodb.ListBackupsInput{}, AdapterMetadata: dynamodbBackupAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *dynamodb.DescribeBackupInput { // Get is not supported since you can't search by name return nil }, ListFuncOutputMapper: func(output *dynamodb.ListBackupsOutput, input *dynamodb.ListBackupsInput) ([]*dynamodb.DescribeBackupInput, error) { inputs := make([]*dynamodb.DescribeBackupInput, 0) for _, summary := range output.BackupSummaries { if summary.BackupArn != nil { inputs = append(inputs, &dynamodb.DescribeBackupInput{ BackupArn: summary.BackupArn, }) } } return inputs, nil }, ListFuncPaginatorBuilder: func(client Client, input *dynamodb.ListBackupsInput) Paginator[*dynamodb.ListBackupsOutput, *dynamodb.Options] { return NewListBackupsPaginator(client, input) }, SearchInputMapper: func(scope, query string) (*dynamodb.ListBackupsInput, error) { // Search by table name since you can't so it by ARN return &dynamodb.ListBackupsInput{ TableName: &query, }, nil }, } } var dynamodbBackupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "dynamodb-backup", DescriptiveName: "DynamoDB Backup", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ List: true, Search: true, ListDescription: "List all DynamoDB backups", SearchDescription: "Search for a DynamoDB backup by table name", }, PotentialLinks: []string{"dynamodb-table"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, }) // Another AWS API that doesn't provide a paginator *and* does pagination // completely differently from everything else? You don't say. // // ░░░░░░░░░░░░░░▄▄▄▄▄▄▄▄▄▄▄▄░░░░░░░░░░░░░░ // ░░░░░░░░░░░░▄████████████████▄░░░░░░░░░░ // ░░░░░░░░░░▄██▀░░░░░░░▀▀████████▄░░░░░░░░ // ░░░░░░░░░▄█▀░░░░░░░░░░░░░▀▀██████▄░░░░░░ // ░░░░░░░░░███▄░░░░░░░░░░░░░░░▀██████░░░░░ // ░░░░░░░░▄░░▀▀█░░░░░░░░░░░░░░░░██████░░░░ // ░░░░░░░█▄██▀▄░░░░░▄███▄▄░░░░░░███████░░░ // ░░░░░░▄▀▀▀██▀░░░░░▄▄▄░░▀█░░░░█████████░░ // ░░░░░▄▀░░░░▄▀░▄░░█▄██▀▄░░░░░██████████░░ // ░░░░░█░░░░▀░░░█░░░▀▀▀▀▀░░░░░██████████▄░ // ░░░░░░░▄█▄░░░░░▄░░░░░░░░░░░░██████████▀░ // ░░░░░░█▀░░░░▀▀░░░░░░░░░░░░░███▀███████░░ // ░░░▄▄░▀░▄░░░░░░░░░░░░░░░░░░▀░░░██████░░░ // ██████░░█▄█▀░▄░░██░░░░░░░░░░░█▄█████▀░░░ // ██████░░░▀████▀░▀░░░░░░░░░░░▄▀█████████▄ // ██████░░░░░░░░░░░░░░░░░░░░▀▄████████████ // ██████░░▄░░░░░░░░░░░░░▄░░░██████████████ // ██████░░░░░░░░░░░░░▄█▀░░▄███████████████ // ███████▄▄░░░░░░░░░▀░░░▄▀▄███████████████ // ListBackupsPaginator is a paginator for DescribeCapacityProviders type ListBackupsPaginator struct { client Client params *dynamodb.ListBackupsInput lastARN *string firstPage bool } // NewListBackupsPaginator returns a new ListBackupsPaginator func NewListBackupsPaginator(client Client, params *dynamodb.ListBackupsInput) *ListBackupsPaginator { if params == nil { params = &dynamodb.ListBackupsInput{} } return &ListBackupsPaginator{ client: client, params: params, firstPage: true, lastARN: params.ExclusiveStartBackupArn, } } // HasMorePages returns a boolean indicating whether more pages are available func (p *ListBackupsPaginator) HasMorePages() bool { return p.firstPage || (p.lastARN != nil && len(*p.lastARN) != 0) } // NextPage retrieves the next DescribeCapacityProviders page. func (p *ListBackupsPaginator) NextPage(ctx context.Context, optFns ...func(*dynamodb.Options)) (*dynamodb.ListBackupsOutput, error) { if !p.HasMorePages() { return nil, fmt.Errorf("no more pages available") } params := *p.params params.ExclusiveStartBackupArn = p.lastARN result, err := p.client.ListBackups(ctx, ¶ms, optFns...) if err != nil { return nil, err } p.firstPage = false prevToken := p.lastARN p.lastARN = result.LastEvaluatedBackupArn if prevToken != nil && p.lastARN != nil && *prevToken == *p.lastARN { p.lastARN = nil } return result, nil } ================================================ FILE: aws-source/adapters/dynamodb-backup_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *DynamoDBTestClient) DescribeBackup(ctx context.Context, params *dynamodb.DescribeBackupInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DescribeBackupOutput, error) { return &dynamodb.DescribeBackupOutput{ BackupDescription: &types.BackupDescription{ BackupDetails: &types.BackupDetails{ BackupArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test2/backup/01673461724486-a6007753"), BackupName: new("test2-backup"), BackupSizeBytes: new(int64(0)), BackupStatus: types.BackupStatusAvailable, BackupType: types.BackupTypeUser, BackupCreationDateTime: new(time.Now()), }, SourceTableDetails: &types.SourceTableDetails{ TableName: new("test2"), // link TableId: new("12670f3b-8ca1-463b-b15e-f2e27eaf70b0"), TableArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test2"), TableSizeBytes: new(int64(0)), KeySchema: []types.KeySchemaElement{ { AttributeName: new("ArtistId"), KeyType: types.KeyTypeHash, }, { AttributeName: new("Concert"), KeyType: types.KeyTypeRange, }, }, TableCreationDateTime: new(time.Now()), ProvisionedThroughput: &types.ProvisionedThroughput{ ReadCapacityUnits: new(int64(5)), WriteCapacityUnits: new(int64(5)), }, ItemCount: new(int64(0)), BillingMode: types.BillingModeProvisioned, }, SourceTableFeatureDetails: &types.SourceTableFeatureDetails{ GlobalSecondaryIndexes: []types.GlobalSecondaryIndexInfo{ { IndexName: new("GSI"), KeySchema: []types.KeySchemaElement{ { AttributeName: new("TicketSales"), KeyType: types.KeyTypeHash, }, }, Projection: &types.Projection{ ProjectionType: types.ProjectionTypeKeysOnly, }, ProvisionedThroughput: &types.ProvisionedThroughput{ ReadCapacityUnits: new(int64(5)), WriteCapacityUnits: new(int64(5)), }, }, }, }, }, }, nil } func (t *DynamoDBTestClient) ListBackups(ctx context.Context, params *dynamodb.ListBackupsInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ListBackupsOutput, error) { return &dynamodb.ListBackupsOutput{ BackupSummaries: []types.BackupSummary{ { TableName: new("test2"), TableId: new("12670f3b-8ca1-463b-b15e-f2e27eaf70b0"), TableArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test2"), BackupArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test2/backup/01673461724486-a6007753"), BackupName: new("test2-backup"), BackupCreationDateTime: new(time.Now()), BackupStatus: types.BackupStatusAvailable, BackupType: types.BackupTypeUser, BackupSizeBytes: new(int64(10)), }, }, }, nil } func TestBackupGetFunc(t *testing.T) { item, err := backupGetFunc(context.Background(), &DynamoDBTestClient{}, "foo", &dynamodb.DescribeBackupInput{}) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "dynamodb-table", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test2", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewDynamoDBBackupAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := dynamodb.NewFromConfig(config) adapter := NewDynamoDBBackupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipGet: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/dynamodb-table.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func tableGetFunc(ctx context.Context, client Client, scope string, input *dynamodb.DescribeTableInput) (*sdp.Item, error) { out, err := client.DescribeTable(ctx, input) if err != nil { return nil, err } if out.Table == nil { return nil, errors.New("returned table is nil") } table := out.Table var nextToken *string tagsMap := make(map[string]string) // Get the tags for this table, keep looping until we run out of pages for { tagsOut, err := client.ListTagsOfResource(ctx, &dynamodb.ListTagsOfResourceInput{ ResourceArn: table.TableArn, NextToken: nextToken, }) if err != nil { tagsMap = HandleTagsError(ctx, err) break } // Add tags to map for _, tag := range tagsOut.Tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } nextToken = tagsOut.NextToken if nextToken == nil { break } } attributes, err := ToAttributesWithExclude(table) if err != nil { return nil, err } item := sdp.Item{ Type: "dynamodb-table", UniqueAttribute: "TableName", Scope: scope, Attributes: attributes, Tags: tagsMap, } var a *ARN streamsOut, err := client.DescribeKinesisStreamingDestination(ctx, &dynamodb.DescribeKinesisStreamingDestinationInput{ TableName: table.TableName, }) if err == nil { for _, dest := range streamsOut.KinesisDataStreamDestinations { if dest.StreamArn != nil { if a, err = ParseARN(*dest.StreamArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kinesis-stream", Method: sdp.QueryMethod_SEARCH, Query: *dest.StreamArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } if table.RestoreSummary != nil { if table.RestoreSummary.SourceBackupArn != nil { if a, err = ParseARN(*table.RestoreSummary.SourceBackupArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "backup-recovery-point", Method: sdp.QueryMethod_SEARCH, Query: *table.RestoreSummary.SourceBackupArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if table.RestoreSummary.SourceTableArn != nil { if a, err = ParseARN(*table.RestoreSummary.SourceTableArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dynamodb-table", Method: sdp.QueryMethod_SEARCH, Query: *table.RestoreSummary.SourceTableArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if table.SSEDescription != nil { if table.SSEDescription.KMSMasterKeyArn != nil { if a, err = ParseARN(*table.SSEDescription.KMSMasterKeyArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *table.SSEDescription.KMSMasterKeyArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } return &item, nil } func NewDynamoDBTableAdapter(client Client, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*dynamodb.ListTablesInput, *dynamodb.ListTablesOutput, *dynamodb.DescribeTableInput, *dynamodb.DescribeTableOutput, Client, *dynamodb.Options] { return &AlwaysGetAdapter[*dynamodb.ListTablesInput, *dynamodb.ListTablesOutput, *dynamodb.DescribeTableInput, *dynamodb.DescribeTableOutput, Client, *dynamodb.Options]{ ItemType: "dynamodb-table", Client: client, AccountID: accountID, Region: region, GetFunc: tableGetFunc, ListInput: &dynamodb.ListTablesInput{}, AdapterMetadata: dynamodbTableAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *dynamodb.DescribeTableInput { return &dynamodb.DescribeTableInput{ TableName: &query, } }, ListFuncPaginatorBuilder: func(client Client, input *dynamodb.ListTablesInput) Paginator[*dynamodb.ListTablesOutput, *dynamodb.Options] { return dynamodb.NewListTablesPaginator(client, input) }, ListFuncOutputMapper: func(output *dynamodb.ListTablesOutput, input *dynamodb.ListTablesInput) ([]*dynamodb.DescribeTableInput, error) { if output == nil { return nil, errors.New("cannot map nil output") } inputs := make([]*dynamodb.DescribeTableInput, 0, len(output.TableNames)) for i := range output.TableNames { inputs = append(inputs, &dynamodb.DescribeTableInput{ TableName: &output.TableNames[i], }) } return inputs, nil }, } } var dynamodbTableAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "dynamodb-table", DescriptiveName: "DynamoDB Table", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a DynamoDB table by name", ListDescription: "List all DynamoDB tables", SearchDescription: "Search for DynamoDB tables by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, PotentialLinks: []string{"kinesis-stream", "backup-recovery-point", "dynamodb-table", "kms-key"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "aws_dynamodb_table.arn"}, }, }) ================================================ FILE: aws-source/adapters/dynamodb-table_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *DynamoDBTestClient) DescribeTable(context.Context, *dynamodb.DescribeTableInput, ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) { return &dynamodb.DescribeTableOutput{ Table: &types.TableDescription{ AttributeDefinitions: []types.AttributeDefinition{ { AttributeName: new("ArtistId"), AttributeType: types.ScalarAttributeTypeS, }, { AttributeName: new("Concert"), AttributeType: types.ScalarAttributeTypeS, }, { AttributeName: new("TicketSales"), AttributeType: types.ScalarAttributeTypeS, }, }, TableName: new("test-DDBTable-1X52D7BWAAB2H"), KeySchema: []types.KeySchemaElement{ { AttributeName: new("ArtistId"), KeyType: types.KeyTypeHash, }, { AttributeName: new("Concert"), KeyType: types.KeyTypeRange, }, }, TableStatus: types.TableStatusActive, CreationDateTime: new(time.Now()), ProvisionedThroughput: &types.ProvisionedThroughputDescription{ NumberOfDecreasesToday: new(int64(0)), ReadCapacityUnits: new(int64(5)), WriteCapacityUnits: new(int64(5)), }, TableSizeBytes: new(int64(0)), ItemCount: new(int64(0)), TableArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test-DDBTable-1X52D7BWAAB2H"), TableId: new("32ef65bf-d6f3-4508-a3db-f201df09e437"), GlobalSecondaryIndexes: []types.GlobalSecondaryIndexDescription{ { IndexName: new("GSI"), KeySchema: []types.KeySchemaElement{ { AttributeName: new("TicketSales"), KeyType: types.KeyTypeHash, }, }, Projection: &types.Projection{ ProjectionType: types.ProjectionTypeKeysOnly, }, IndexStatus: types.IndexStatusActive, ProvisionedThroughput: &types.ProvisionedThroughputDescription{ NumberOfDecreasesToday: new(int64(0)), ReadCapacityUnits: new(int64(5)), WriteCapacityUnits: new(int64(5)), }, IndexSizeBytes: new(int64(0)), ItemCount: new(int64(0)), IndexArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test-DDBTable-1X52D7BWAAB2H/index/GSI"), // no link, t }, }, ArchivalSummary: &types.ArchivalSummary{ ArchivalBackupArn: new("arn:aws:backups:eu-west-1:052392120703:some-backup/one"), // link ArchivalDateTime: new(time.Now()), ArchivalReason: new("fear"), }, BillingModeSummary: &types.BillingModeSummary{ BillingMode: types.BillingModePayPerRequest, }, GlobalTableVersion: new("1"), LatestStreamArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test-DDBTable-1X52D7BWAAB2H/stream/2023-01-11T16:53:02.371"), // This doesn't get linked because there is no more data to get LatestStreamLabel: new("2023-01-11T16:53:02.371"), LocalSecondaryIndexes: []types.LocalSecondaryIndexDescription{ { IndexArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test-DDBTable-1X52D7BWAAB2H/index/GSX"), // no link IndexName: new("GSX"), IndexSizeBytes: new(int64(29103)), ItemCount: new(int64(234234)), KeySchema: []types.KeySchemaElement{ { AttributeName: new("TicketSales"), KeyType: types.KeyTypeHash, }, }, Projection: &types.Projection{ NonKeyAttributes: []string{ "att1", }, ProjectionType: types.ProjectionTypeInclude, }, }, }, Replicas: []types.ReplicaDescription{ { GlobalSecondaryIndexes: []types.ReplicaGlobalSecondaryIndexDescription{ { IndexName: new("name"), }, }, KMSMasterKeyId: new("keyID"), RegionName: new("eu-west-2"), // link ReplicaStatus: types.ReplicaStatusActive, ReplicaTableClassSummary: &types.TableClassSummary{ TableClass: types.TableClassStandard, }, }, }, RestoreSummary: &types.RestoreSummary{ RestoreDateTime: new(time.Now()), RestoreInProgress: new(false), SourceBackupArn: new("arn:aws:backup:eu-west-1:052392120703:recovery-point:89d0f956-d3a6-42fd-abbd-7d397766bc7e"), // link SourceTableArn: new("arn:aws:dynamodb:eu-west-1:052392120703:table/test-DDBTable-1X52D7BWAAB2H"), // link }, SSEDescription: &types.SSEDescription{ InaccessibleEncryptionDateTime: new(time.Now()), KMSMasterKeyArn: new("arn:aws:service:region:account:type/id"), // link SSEType: types.SSETypeAes256, Status: types.SSEStatusDisabling, }, StreamSpecification: &types.StreamSpecification{ StreamEnabled: new(true), StreamViewType: types.StreamViewTypeKeysOnly, }, TableClassSummary: &types.TableClassSummary{ LastUpdateDateTime: new(time.Now()), TableClass: types.TableClassStandard, }, }, }, nil } func (t *DynamoDBTestClient) ListTables(context.Context, *dynamodb.ListTablesInput, ...func(*dynamodb.Options)) (*dynamodb.ListTablesOutput, error) { return &dynamodb.ListTablesOutput{ TableNames: []string{ "test-DDBTable-1X52D7BWAAB2H", }, }, nil } func (t *DynamoDBTestClient) DescribeKinesisStreamingDestination(ctx context.Context, params *dynamodb.DescribeKinesisStreamingDestinationInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DescribeKinesisStreamingDestinationOutput, error) { return &dynamodb.DescribeKinesisStreamingDestinationOutput{ KinesisDataStreamDestinations: []types.KinesisDataStreamDestination{ { DestinationStatus: types.DestinationStatusActive, DestinationStatusDescription: new("description"), StreamArn: new("arn:aws:kinesis:eu-west-1:052392120703:stream/test"), }, }, }, nil } func (t *DynamoDBTestClient) ListTagsOfResource(context.Context, *dynamodb.ListTagsOfResourceInput, ...func(*dynamodb.Options)) (*dynamodb.ListTagsOfResourceOutput, error) { return &dynamodb.ListTagsOfResourceOutput{ Tags: []types.Tag{ { Key: new("key"), Value: new("value"), }, }, NextToken: nil, }, nil } func TestTableGetFunc(t *testing.T) { item, err := tableGetFunc(context.Background(), &DynamoDBTestClient{}, "foo", &dynamodb.DescribeTableInput{}) if err != nil { t.Fatal(err) } if item.GetTags()["key"] != "value" { t.Errorf("expected tag key to be 'value', got '%s'", item.GetTags()["key"]) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "kinesis-stream", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kinesis:eu-west-1:052392120703:stream/test", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "backup-recovery-point", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:backup:eu-west-1:052392120703:recovery-point:89d0f956-d3a6-42fd-abbd-7d397766bc7e", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "dynamodb-table", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:dynamodb:eu-west-1:052392120703:table/test-DDBTable-1X52D7BWAAB2H", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, } tests.Execute(t, item) } func TestNewDynamoDBTableAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := dynamodb.NewFromConfig(config) adapter := NewDynamoDBTableAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/dynamodb.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/dynamodb" ) type Client interface { DescribeKinesisStreamingDestination(ctx context.Context, params *dynamodb.DescribeKinesisStreamingDestinationInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DescribeKinesisStreamingDestinationOutput, error) DescribeBackup(ctx context.Context, params *dynamodb.DescribeBackupInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DescribeBackupOutput, error) ListBackups(ctx context.Context, params *dynamodb.ListBackupsInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ListBackupsOutput, error) ListTagsOfResource(ctx context.Context, params *dynamodb.ListTagsOfResourceInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ListTagsOfResourceOutput, error) dynamodb.DescribeTableAPIClient dynamodb.ListTablesAPIClient } ================================================ FILE: aws-source/adapters/dynamodb_test.go ================================================ package adapters type DynamoDBTestClient struct{} ================================================ FILE: aws-source/adapters/ec2-address.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // AddressInputMapperGet Maps adapter calls to the correct input for the AZ API func addressInputMapperGet(scope, query string) (*ec2.DescribeAddressesInput, error) { return &ec2.DescribeAddressesInput{ PublicIps: []string{ query, }, }, nil } // AddressInputMapperList Maps adapter calls to the correct input for the AZ API func addressInputMapperList(scope string) (*ec2.DescribeAddressesInput, error) { return &ec2.DescribeAddressesInput{}, nil } // AddressOutputMapper Maps API output to items func addressOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeAddressesInput, output *ec2.DescribeAddressesOutput) ([]*sdp.Item, error) { if output == nil { return nil, errors.New("empty output") } items := make([]*sdp.Item, 0) var err error var attrs *sdp.ItemAttributes for _, address := range output.Addresses { attrs, err = ToAttributesWithExclude(address, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "ec2-address", UniqueAttribute: "PublicIp", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.PublicIp, Scope: "global", }, }, }, Tags: ec2TagsToMap(address.Tags), } if address.InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *address.InstanceId, Scope: scope, }, }) } if address.CarrierIp != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.CarrierIp, Scope: "global", }, }) } if address.CustomerOwnedIp != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.CustomerOwnedIp, Scope: "global", }, }) } if address.NetworkInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: *address.NetworkInterfaceId, Scope: scope, }, }) } if address.PrivateIpAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.PrivateIpAddress, Scope: "global", }, }) } items = append(items, &item) } return items, nil } // NewAddressAdapter Creates a new adapter for aws-Address resources func NewEC2AddressAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeAddressesInput, *ec2.DescribeAddressesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeAddressesInput, *ec2.DescribeAddressesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-address", AdapterMetadata: addressAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeAddressesInput) (*ec2.DescribeAddressesOutput, error) { return client.DescribeAddresses(ctx, input) }, InputMapperGet: addressInputMapperGet, InputMapperList: addressInputMapperList, OutputMapper: addressOutputMapper, } } var addressAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-address", DescriptiveName: "EC2 Address", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an EC2 address by Public IP", ListDescription: "List EC2 addresses", SearchDescription: "Search for EC2 addresses by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_eip.public_ip"}, {TerraformQueryMap: "aws_eip_association.public_ip"}, }, PotentialLinks: []string{"ec2-instance", "ip", "ec2-network-interface"}, }) ================================================ FILE: aws-source/adapters/ec2-address_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestAddressInputMapperGet(t *testing.T) { input, err := addressInputMapperGet("foo", "az-name") if err != nil { t.Error(err) } if len(input.PublicIps) != 1 { t.Fatalf("expected 1 Address, got %v", len(input.PublicIps)) } if input.PublicIps[0] != "az-name" { t.Errorf("expected Address to be to be az-name, got %v", input.PublicIps[0]) } } func TestAddressInputMapperList(t *testing.T) { input, err := addressInputMapperList("foo") if err != nil { t.Error(err) } if len(input.PublicIps) != 0 { t.Fatalf("expected 0 zone names, got %v", len(input.PublicIps)) } } func TestAddressOutputMapper(t *testing.T) { output := ec2.DescribeAddressesOutput{ Addresses: []types.Address{ { PublicIp: new("3.11.82.6"), AllocationId: new("eipalloc-030a6f43bc6086267"), Domain: types.DomainTypeVpc, PublicIpv4Pool: new("amazon"), NetworkBorderGroup: new("eu-west-2"), InstanceId: new("instance"), CarrierIp: new("3.11.82.7"), CustomerOwnedIp: new("3.11.82.8"), NetworkInterfaceId: new("foo"), PrivateIpAddress: new("3.11.82.9"), }, }, } items, err := addressOutputMapper(context.Background(), nil, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: *output.Addresses[0].PublicIp, ExpectedScope: "global", }, { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: *output.Addresses[0].InstanceId, ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: *output.Addresses[0].CarrierIp, ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: *output.Addresses[0].CustomerOwnedIp, ExpectedScope: "global", }, { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: *output.Addresses[0].NetworkInterfaceId, ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: *output.Addresses[0].PrivateIpAddress, ExpectedScope: "global", }, } tests.Execute(t, item) } func TestNewEC2AddressAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2AddressAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-capacity-reservation-fleet.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func capacityReservationFleetOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeCapacityReservationFleetsInput, output *ec2.DescribeCapacityReservationFleetsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, cr := range output.CapacityReservationFleets { attributes, err := ToAttributesWithExclude(cr, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "ec2-capacity-reservation-fleet", UniqueAttribute: "CapacityReservationFleetId", Attributes: attributes, Scope: scope, Tags: ec2TagsToMap(cr.Tags), } for _, spec := range cr.InstanceTypeSpecifications { if spec.CapacityReservationId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-capacity-reservation", Method: sdp.QueryMethod_GET, Query: *spec.CapacityReservationId, Scope: scope, }, }) } } switch cr.State { case types.CapacityReservationFleetStateSubmitted: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.CapacityReservationFleetStateModifying: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.CapacityReservationFleetStateActive: item.Health = sdp.Health_HEALTH_OK.Enum() case types.CapacityReservationFleetStatePartiallyFulfilled: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.CapacityReservationFleetStateExpiring: item.Health = sdp.Health_HEALTH_WARNING.Enum() case types.CapacityReservationFleetStateExpired: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.CapacityReservationFleetStateCancelling: item.Health = sdp.Health_HEALTH_WARNING.Enum() case types.CapacityReservationFleetStateCancelled: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.CapacityReservationFleetStateFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() } items = append(items, &item) } return items, nil } func NewEC2CapacityReservationFleetAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeCapacityReservationFleetsInput, *ec2.DescribeCapacityReservationFleetsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeCapacityReservationFleetsInput, *ec2.DescribeCapacityReservationFleetsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-capacity-reservation-fleet", AdapterMetadata: capacityReservationFleetAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeCapacityReservationFleetsInput) (*ec2.DescribeCapacityReservationFleetsOutput, error) { return client.DescribeCapacityReservationFleets(ctx, input) }, InputMapperGet: func(scope, query string) (*ec2.DescribeCapacityReservationFleetsInput, error) { return &ec2.DescribeCapacityReservationFleetsInput{ CapacityReservationFleetIds: []string{query}, }, nil }, InputMapperList: func(scope string) (*ec2.DescribeCapacityReservationFleetsInput, error) { return &ec2.DescribeCapacityReservationFleetsInput{}, nil }, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeCapacityReservationFleetsInput) Paginator[*ec2.DescribeCapacityReservationFleetsOutput, *ec2.Options] { return ec2.NewDescribeCapacityReservationFleetsPaginator(client, params) }, OutputMapper: capacityReservationFleetOutputMapper, } } var capacityReservationFleetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-capacity-reservation-fleet", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, DescriptiveName: "Capacity Reservation Fleet", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a capacity reservation fleet by ID", ListDescription: "List capacity reservation fleets", SearchDescription: "Search capacity reservation fleets by ARN", }, PotentialLinks: []string{"ec2-capacity-reservation"}, }) ================================================ FILE: aws-source/adapters/ec2-capacity-reservation-fleet_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestCapacityReservationFleetOutputMapper(t *testing.T) { output := &ec2.DescribeCapacityReservationFleetsOutput{ CapacityReservationFleets: []types.CapacityReservationFleet{ { AllocationStrategy: new("prioritized"), CapacityReservationFleetArn: new("arn:aws:ec2:us-east-1:123456789012:capacity-reservation/fleet/crf-1234567890abcdef0"), CapacityReservationFleetId: new("crf-1234567890abcdef0"), CreateTime: new(time.Now()), EndDate: nil, InstanceMatchCriteria: types.FleetInstanceMatchCriteriaOpen, InstanceTypeSpecifications: []types.FleetCapacityReservation{ { AvailabilityZone: new("us-east-1a"), // link AvailabilityZoneId: new("use1-az1"), CapacityReservationId: new("cr-1234567890abcdef0"), // link CreateDate: new(time.Now()), EbsOptimized: new(true), FulfilledCapacity: new(float64(1)), InstancePlatform: types.CapacityReservationInstancePlatformLinuxUnix, InstanceType: types.InstanceTypeA12xlarge, Priority: new(int32(1)), TotalInstanceCount: new(int32(1)), Weight: new(float64(1)), }, }, State: types.CapacityReservationFleetStateActive, // health Tenancy: types.FleetCapacityReservationTenancyDefault, TotalFulfilledCapacity: new(float64(1)), TotalTargetCapacity: new(int32(1)), }, }, } items, err := capacityReservationFleetOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{} tests.Execute(t, item) } func TestNewEC2CapacityReservationFleetAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2CapacityReservationFleetAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-capacity-reservation.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func capacityReservationOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeCapacityReservationsInput, output *ec2.DescribeCapacityReservationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, cr := range output.CapacityReservations { attributes, err := ToAttributesWithExclude(cr, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "ec2-capacity-reservation", UniqueAttribute: "CapacityReservationId", Attributes: attributes, Scope: scope, Tags: ec2TagsToMap(cr.Tags), } if cr.CapacityReservationFleetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-capacity-reservation-fleet", Method: sdp.QueryMethod_GET, Query: *cr.CapacityReservationFleetId, Scope: scope, }, }) } if cr.OutpostArn != nil { if arn, err := ParseARN(*cr.OutpostArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "outposts-outpost", Method: sdp.QueryMethod_SEARCH, Query: *cr.OutpostArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if cr.PlacementGroupArn != nil { if arn, err := ParseARN(*cr.PlacementGroupArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-placement-group", Method: sdp.QueryMethod_SEARCH, Query: *cr.PlacementGroupArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } items = append(items, &item) } return items, nil } func NewEC2CapacityReservationAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeCapacityReservationsInput, *ec2.DescribeCapacityReservationsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeCapacityReservationsInput, *ec2.DescribeCapacityReservationsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-capacity-reservation", AdapterMetadata: capacityReservationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeCapacityReservationsInput) (*ec2.DescribeCapacityReservationsOutput, error) { return client.DescribeCapacityReservations(ctx, input) }, InputMapperGet: func(scope, query string) (*ec2.DescribeCapacityReservationsInput, error) { return &ec2.DescribeCapacityReservationsInput{ CapacityReservationIds: []string{query}, }, nil }, InputMapperList: func(scope string) (*ec2.DescribeCapacityReservationsInput, error) { return &ec2.DescribeCapacityReservationsInput{}, nil }, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeCapacityReservationsInput) Paginator[*ec2.DescribeCapacityReservationsOutput, *ec2.Options] { return ec2.NewDescribeCapacityReservationsPaginator(client, params) }, OutputMapper: capacityReservationOutputMapper, } } var capacityReservationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-capacity-reservation", DescriptiveName: "Capacity Reservation", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a capacity reservation fleet by ID", ListDescription: "List capacity reservation fleets", SearchDescription: "Search capacity reservation fleets by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ec2_capacity_reservation_fleet.id"}, }, PotentialLinks: []string{"outposts-outpost", "ec2-placement-group", "ec2-capacity-reservation-fleet"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/ec2-capacity-reservation_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestCapacityReservationOutputMapper(t *testing.T) { output := &ec2.DescribeCapacityReservationsOutput{ CapacityReservations: []types.CapacityReservation{ { AvailabilityZone: new("us-east-1a"), // links AvailabilityZoneId: new("use1-az1"), AvailableInstanceCount: new(int32(1)), CapacityReservationArn: new("arn:aws:ec2:us-east-1:123456789012:capacity-reservation/cr-1234567890abcdef0"), CapacityReservationId: new("cr-1234567890abcdef0"), CapacityReservationFleetId: new("crf-1234567890abcdef0"), // link CreateDate: new(time.Now()), EbsOptimized: new(true), EndDateType: types.EndDateTypeUnlimited, EndDate: nil, InstanceMatchCriteria: types.InstanceMatchCriteriaTargeted, InstancePlatform: types.CapacityReservationInstancePlatformLinuxUnix, InstanceType: new("t2.micro"), OutpostArn: new("arn:aws:ec2:us-east-1:123456789012:outpost/op-1234567890abcdef0"), // link OwnerId: new("123456789012"), PlacementGroupArn: new("arn:aws:ec2:us-east-1:123456789012:placement-group/pg-1234567890abcdef0"), // link StartDate: new(time.Now()), State: types.CapacityReservationStateActive, Tenancy: types.CapacityReservationTenancyDefault, TotalInstanceCount: new(int32(1)), CapacityAllocations: []types.CapacityAllocation{ { AllocationType: types.AllocationTypeUsed, Count: new(int32(1)), }, }, }, }, } items, err := capacityReservationOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-capacity-reservation-fleet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "crf-1234567890abcdef0", ExpectedScope: "foo", }, { ExpectedType: "outposts-outpost", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-east-1:123456789012:outpost/op-1234567890abcdef0", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "ec2-placement-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-east-1:123456789012:placement-group/pg-1234567890abcdef0", ExpectedScope: "123456789012.us-east-1", }, } tests.Execute(t, item) } func TestNewEC2CapacityReservationAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2CapacityReservationAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-egress-only-internet-gateway.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func egressOnlyInternetGatewayInputMapperGet(scope string, query string) (*ec2.DescribeEgressOnlyInternetGatewaysInput, error) { return &ec2.DescribeEgressOnlyInternetGatewaysInput{ EgressOnlyInternetGatewayIds: []string{ query, }, }, nil } func egressOnlyInternetGatewayInputMapperList(scope string) (*ec2.DescribeEgressOnlyInternetGatewaysInput, error) { return &ec2.DescribeEgressOnlyInternetGatewaysInput{}, nil } func egressOnlyInternetGatewayOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeEgressOnlyInternetGatewaysInput, output *ec2.DescribeEgressOnlyInternetGatewaysOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, gw := range output.EgressOnlyInternetGateways { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(gw, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-egress-only-internet-gateway", UniqueAttribute: "EgressOnlyInternetGatewayId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(gw.Tags), } for _, attachment := range gw.Attachments { if attachment.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *attachment.VpcId, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2EgressOnlyInternetGatewayAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeEgressOnlyInternetGatewaysInput, *ec2.DescribeEgressOnlyInternetGatewaysOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeEgressOnlyInternetGatewaysInput, *ec2.DescribeEgressOnlyInternetGatewaysOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-egress-only-internet-gateway", AdapterMetadata: egressOnlyInternetGatewayAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeEgressOnlyInternetGatewaysInput) (*ec2.DescribeEgressOnlyInternetGatewaysOutput, error) { return client.DescribeEgressOnlyInternetGateways(ctx, input) }, InputMapperGet: egressOnlyInternetGatewayInputMapperGet, InputMapperList: egressOnlyInternetGatewayInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeEgressOnlyInternetGatewaysInput) Paginator[*ec2.DescribeEgressOnlyInternetGatewaysOutput, *ec2.Options] { return ec2.NewDescribeEgressOnlyInternetGatewaysPaginator(client, params) }, OutputMapper: egressOnlyInternetGatewayOutputMapper, } } var egressOnlyInternetGatewayAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-egress-only-internet-gateway", DescriptiveName: "Egress Only Internet Gateway", PotentialLinks: []string{"ec2-vpc"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an egress only internet gateway by ID", ListDescription: "List all egress only internet gateways", SearchDescription: "Search egress only internet gateways by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "egress_only_internet_gateway.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-egress-only-internet-gateway_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestEgressOnlyInternetGatewayInputMapperGet(t *testing.T) { input, err := egressOnlyInternetGatewayInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.EgressOnlyInternetGatewayIds) != 1 { t.Fatalf("expected 1 EgressOnlyInternetGateway ID, got %v", len(input.EgressOnlyInternetGatewayIds)) } if input.EgressOnlyInternetGatewayIds[0] != "bar" { t.Errorf("expected EgressOnlyInternetGateway ID to be bar, got %v", input.EgressOnlyInternetGatewayIds[0]) } } func TestEgressOnlyInternetGatewayInputMapperList(t *testing.T) { input, err := egressOnlyInternetGatewayInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.EgressOnlyInternetGatewayIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestEgressOnlyInternetGatewayOutputMapper(t *testing.T) { output := &ec2.DescribeEgressOnlyInternetGatewaysOutput{ EgressOnlyInternetGateways: []types.EgressOnlyInternetGateway{ { Attachments: []types.InternetGatewayAttachment{ { State: types.AttachmentStatusAttached, VpcId: new("vpc-0d7892e00e573e701"), }, }, EgressOnlyInternetGatewayId: new("eigw-0ff50f360e066777a"), }, }, } items, err := egressOnlyInternetGatewayOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2EgressOnlyInternetGatewayAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2EgressOnlyInternetGatewayAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-iam-instance-profile-association.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func iamInstanceProfileAssociationOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeIamInstanceProfileAssociationsInput, output *ec2.DescribeIamInstanceProfileAssociationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, assoc := range output.IamInstanceProfileAssociations { attributes, err := ToAttributesWithExclude(assoc) if err != nil { return nil, err } item := sdp.Item{ Type: "ec2-iam-instance-profile-association", UniqueAttribute: "AssociationId", Attributes: attributes, Scope: scope, } if assoc.IamInstanceProfile != nil && assoc.IamInstanceProfile.Arn != nil { if arn, err := ParseARN(*assoc.IamInstanceProfile.Arn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-instance-profile", Method: sdp.QueryMethod_SEARCH, Query: *assoc.IamInstanceProfile.Arn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if assoc.InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *assoc.InstanceId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } // NewIamInstanceProfileAssociationAdapter Creates a new adapter for aws-IamInstanceProfileAssociation resources func NewEC2IamInstanceProfileAssociationAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeIamInstanceProfileAssociationsInput, *ec2.DescribeIamInstanceProfileAssociationsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeIamInstanceProfileAssociationsInput, *ec2.DescribeIamInstanceProfileAssociationsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-iam-instance-profile-association", AdapterMetadata: iamInstanceProfileAssociationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeIamInstanceProfileAssociationsInput) (*ec2.DescribeIamInstanceProfileAssociationsOutput, error) { return client.DescribeIamInstanceProfileAssociations(ctx, input) }, InputMapperGet: func(scope, query string) (*ec2.DescribeIamInstanceProfileAssociationsInput, error) { return &ec2.DescribeIamInstanceProfileAssociationsInput{ AssociationIds: []string{query}, }, nil }, InputMapperList: func(scope string) (*ec2.DescribeIamInstanceProfileAssociationsInput, error) { return &ec2.DescribeIamInstanceProfileAssociationsInput{}, nil }, OutputMapper: iamInstanceProfileAssociationOutputMapper, } } var iamInstanceProfileAssociationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-iam-instance-profile-association", DescriptiveName: "IAM Instance Profile Association", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an IAM Instance Profile Association by ID", ListDescription: "List all IAM Instance Profile Associations", SearchDescription: "Search IAM Instance Profile Associations by ARN", }, PotentialLinks: []string{"iam-instance-profile", "ec2-instance"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/ec2-iam-instance-profile-association_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestIamInstanceProfileAssociationOutputMapper(t *testing.T) { output := ec2.DescribeIamInstanceProfileAssociationsOutput{ IamInstanceProfileAssociations: []types.IamInstanceProfileAssociation{ { AssociationId: new("eipassoc-1234567890abcdef0"), IamInstanceProfile: &types.IamInstanceProfile{ Arn: new("arn:aws:iam::123456789012:instance-profile/webserver"), // link Id: new("AIDACKCEVSQ6C2EXAMPLE"), }, InstanceId: new("i-1234567890abcdef0"), // link State: types.IamInstanceProfileAssociationStateAssociated, Timestamp: new(time.Now()), }, }, } items, err := iamInstanceProfileAssociationOutputMapper(context.Background(), nil, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "iam-instance-profile", ExpectedQuery: "arn:aws:iam::123456789012:instance-profile/webserver", ExpectedScope: "123456789012", ExpectedMethod: sdp.QueryMethod_SEARCH, }, { ExpectedType: "ec2-instance", ExpectedQuery: "i-1234567890abcdef0", ExpectedScope: "foo", ExpectedMethod: sdp.QueryMethod_GET, }, } tests.Execute(t, item) } func TestNewEC2IamInstanceProfileAssociationAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2IamInstanceProfileAssociationAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-image.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // ImageInputMapperGet Gets a given image. As opposed to list, get will get // details of any image given a correct ID, not just images owned by the current // account func imageInputMapperGet(scope string, query string) (*ec2.DescribeImagesInput, error) { return &ec2.DescribeImagesInput{ ImageIds: []string{ query, }, }, nil } // ImageInputMapperList Lists images that are owned by the current account, as // opposed to all available images since this is simply way too much data func imageInputMapperList(scope string) (*ec2.DescribeImagesInput, error) { return &ec2.DescribeImagesInput{ Owners: []string{ // Avoid getting every image in existence, just get the ones // relevant to this scope i.e. owned by this account in this region "self", }, }, nil } func imageOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeImagesInput, output *ec2.DescribeImagesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, image := range output.Images { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(image, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-image", UniqueAttribute: "ImageId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(image.Tags), } items = append(items, &item) } return items, nil } func NewEC2ImageAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeImagesInput, *ec2.DescribeImagesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeImagesInput, *ec2.DescribeImagesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-image", AdapterMetadata: imageAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeImagesInput) (*ec2.DescribeImagesOutput, error) { return client.DescribeImages(ctx, input) }, InputMapperGet: imageInputMapperGet, InputMapperList: imageInputMapperList, OutputMapper: imageOutputMapper, } } var imageAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-image", DescriptiveName: "Amazon Machine Image (AMI)", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an AMI by ID", ListDescription: "List all AMIs", SearchDescription: "Search AMIs by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ami.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ec2-image_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestImageInputMapperGet(t *testing.T) { input, err := imageInputMapperGet("foo", "az-name") if err != nil { t.Error(err) } if len(input.ImageIds) != 1 { t.Fatalf("expected 1 zone names, got %v", len(input.ImageIds)) } if input.ImageIds[0] != "az-name" { t.Errorf("expected zone name to be to be az-name, got %v", input.ImageIds[0]) } } func TestImageInputMapperList(t *testing.T) { input, err := imageInputMapperList("foo") if err != nil { t.Error(err) } if len(input.ImageIds) != 0 { t.Fatalf("expected 0 zone names, got %v", len(input.ImageIds)) } } func TestImageOutputMapper(t *testing.T) { output := ec2.DescribeImagesOutput{ Images: []types.Image{ { Architecture: "x86_64", CreationDate: new("2022-12-16T19:37:36.000Z"), ImageId: new("ami-0ed3646be6ecd97c5"), ImageLocation: new("052392120703/test"), ImageType: types.ImageTypeValuesMachine, Public: new(false), OwnerId: new("052392120703"), PlatformDetails: new("Linux/UNIX"), UsageOperation: new("RunInstances"), State: types.ImageStateAvailable, BlockDeviceMappings: []types.BlockDeviceMapping{ { DeviceName: new("/dev/xvda"), Ebs: &types.EbsBlockDevice{ DeleteOnTermination: new(true), SnapshotId: new("snap-0efd796ecbd599f8d"), VolumeSize: new(int32(8)), VolumeType: types.VolumeTypeGp2, Encrypted: new(false), }, }, }, EnaSupport: new(true), Hypervisor: types.HypervisorTypeXen, Name: new("test"), RootDeviceName: new("/dev/xvda"), RootDeviceType: types.DeviceTypeEbs, SriovNetSupport: new("simple"), VirtualizationType: types.VirtualizationTypeHvm, Tags: []types.Tag{ { Key: new("Name"), Value: new("test"), }, }, }, }, } items, err := imageOutputMapper(context.Background(), nil, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.UniqueAttributeValue() != *output.Images[0].ImageId { t.Errorf("Expected item unique attribute value to be %v, got %v", *output.Images[0].ImageId, item.UniqueAttributeValue()) } } func TestNewEC2ImageAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2ImageAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-instance-event-window.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func instanceEventWindowInputMapperGet(scope, query string) (*ec2.DescribeInstanceEventWindowsInput, error) { return &ec2.DescribeInstanceEventWindowsInput{ InstanceEventWindowIds: []string{ query, }, }, nil } func instanceEventWindowInputMapperList(scope string) (*ec2.DescribeInstanceEventWindowsInput, error) { return &ec2.DescribeInstanceEventWindowsInput{}, nil } func instanceEventWindowOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeInstanceEventWindowsInput, output *ec2.DescribeInstanceEventWindowsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, ew := range output.InstanceEventWindows { attrs, err := ToAttributesWithExclude(ew, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-instance-event-window", UniqueAttribute: "InstanceEventWindowId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(ew.Tags), } if at := ew.AssociationTarget; at != nil { for _, id := range at.DedicatedHostIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-host", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } for _, id := range at.InstanceIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2InstanceEventWindowAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeInstanceEventWindowsInput, *ec2.DescribeInstanceEventWindowsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeInstanceEventWindowsInput, *ec2.DescribeInstanceEventWindowsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-instance-event-window", AdapterMetadata: instanceEventWindowAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeInstanceEventWindowsInput) (*ec2.DescribeInstanceEventWindowsOutput, error) { return client.DescribeInstanceEventWindows(ctx, input) }, InputMapperGet: instanceEventWindowInputMapperGet, InputMapperList: instanceEventWindowInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeInstanceEventWindowsInput) Paginator[*ec2.DescribeInstanceEventWindowsOutput, *ec2.Options] { return ec2.NewDescribeInstanceEventWindowsPaginator(client, params) }, OutputMapper: instanceEventWindowOutputMapper, } } var instanceEventWindowAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-instance-event-window", DescriptiveName: "EC2 Instance Event Window", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an event window by ID", ListDescription: "List all event windows", SearchDescription: "Search for event windows by ARN", }, PotentialLinks: []string{"ec2-host", "ec2-instance"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/ec2-instance-event-window_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestInstanceEventWindowInputMapperGet(t *testing.T) { input, err := instanceEventWindowInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.InstanceEventWindowIds) != 1 { t.Fatalf("expected 1 InstanceEventWindow ID, got %v", len(input.InstanceEventWindowIds)) } if input.InstanceEventWindowIds[0] != "bar" { t.Errorf("expected InstanceEventWindow ID to be bar, got %v", input.InstanceEventWindowIds[0]) } } func TestInstanceEventWindowInputMapperList(t *testing.T) { input, err := instanceEventWindowInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.InstanceEventWindowIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestInstanceEventWindowOutputMapper(t *testing.T) { output := &ec2.DescribeInstanceEventWindowsOutput{ InstanceEventWindows: []types.InstanceEventWindow{ { AssociationTarget: &types.InstanceEventWindowAssociationTarget{ DedicatedHostIds: []string{ "dedicated", }, InstanceIds: []string{ "instance", }, }, CronExpression: new("something"), InstanceEventWindowId: new("window-123"), Name: new("test"), State: types.InstanceEventWindowStateActive, TimeRanges: []types.InstanceEventWindowTimeRange{ { StartHour: new(int32(1)), EndHour: new(int32(2)), EndWeekDay: types.WeekDayFriday, StartWeekDay: types.WeekDayMonday, }, }, Tags: []types.Tag{}, }, }, } items, err := instanceEventWindowOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-host", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dedicated", ExpectedScope: "foo", }, { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "instance", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2InstanceEventWindowAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2InstanceEventWindowAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-instance-status.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func instanceStatusInputMapperGet(scope, query string) (*ec2.DescribeInstanceStatusInput, error) { return &ec2.DescribeInstanceStatusInput{ InstanceIds: []string{ query, }, }, nil } func instanceStatusInputMapperList(scope string) (*ec2.DescribeInstanceStatusInput, error) { return &ec2.DescribeInstanceStatusInput{}, nil } func instanceStatusOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeInstanceStatusInput, output *ec2.DescribeInstanceStatusOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, instanceStatus := range output.InstanceStatuses { attrs, err := ToAttributesWithExclude(instanceStatus) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-instance-status", UniqueAttribute: "InstanceId", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *instanceStatus.InstanceId, Scope: scope, }, }, }, } switch instanceStatus.SystemStatus.Status { case types.SummaryStatusOk: item.Health = sdp.Health_HEALTH_OK.Enum() case types.SummaryStatusImpaired: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.SummaryStatusInsufficientData: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.SummaryStatusNotApplicable: item.Health = nil case types.SummaryStatusInitializing: item.Health = sdp.Health_HEALTH_PENDING.Enum() } items = append(items, &item) } return items, nil } func NewEC2InstanceStatusAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeInstanceStatusInput, *ec2.DescribeInstanceStatusOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeInstanceStatusInput, *ec2.DescribeInstanceStatusOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-instance-status", AdapterMetadata: instanceStatusAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeInstanceStatusInput) (*ec2.DescribeInstanceStatusOutput, error) { return client.DescribeInstanceStatus(ctx, input) }, InputMapperGet: instanceStatusInputMapperGet, InputMapperList: instanceStatusInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeInstanceStatusInput) Paginator[*ec2.DescribeInstanceStatusOutput, *ec2.Options] { return ec2.NewDescribeInstanceStatusPaginator(client, params) }, OutputMapper: instanceStatusOutputMapper, } } var instanceStatusAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-instance-status", DescriptiveName: "EC2 Instance Status", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an EC2 instance status by Instance ID", ListDescription: "List all EC2 instance statuses", SearchDescription: "Search EC2 instance statuses by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, }) ================================================ FILE: aws-source/adapters/ec2-instance-status_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestInstanceStatusInputMapperGet(t *testing.T) { input, err := instanceStatusInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.InstanceIds) != 1 { t.Fatalf("expected 1 instanceStatus ID, got %v", len(input.InstanceIds)) } if input.InstanceIds[0] != "bar" { t.Errorf("expected instanceStatus ID to be bar, got %v", input.InstanceIds[0]) } } func TestInstanceStatusInputMapperList(t *testing.T) { input, err := instanceStatusInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.InstanceIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestInstanceStatusOutputMapper(t *testing.T) { output := &ec2.DescribeInstanceStatusOutput{ InstanceStatuses: []types.InstanceStatus{ { AvailabilityZone: new("eu-west-2c"), // link InstanceId: new("i-022bdccde30270570"), // link InstanceState: &types.InstanceState{ Code: new(int32(16)), Name: types.InstanceStateNameRunning, }, InstanceStatus: &types.InstanceStatusSummary{ Details: []types.InstanceStatusDetails{ { Name: types.StatusNameReachability, Status: types.StatusTypePassed, }, }, Status: types.SummaryStatusOk, }, SystemStatus: &types.InstanceStatusSummary{ Details: []types.InstanceStatusDetails{ { Name: types.StatusNameReachability, Status: types.StatusTypePassed, }, }, Status: types.SummaryStatusImpaired, }, }, }, } items, err := instanceStatusOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-022bdccde30270570", ExpectedScope: item.GetScope(), }, } tests.Execute(t, item) } func TestNewEC2InstanceStatusAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2InstanceStatusAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-instance.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var ( codePending = int32(0) codeRunning = int32(16) codeShuttingDown = int32(32) codeTerminated = int32(48) codeStopping = int32(64) codeStopped = int32(80) ) func instanceInputMapperGet(scope, query string) (*ec2.DescribeInstancesInput, error) { return &ec2.DescribeInstancesInput{ InstanceIds: []string{ query, }, }, nil } func instanceInputMapperList(scope string) (*ec2.DescribeInstancesInput, error) { return &ec2.DescribeInstancesInput{}, nil } func instanceOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeInstancesInput, output *ec2.DescribeInstancesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, reservation := range output.Reservations { for _, instance := range reservation.Instances { attrs, err := ToAttributesWithExclude(instance, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-instance", UniqueAttribute: "InstanceId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(instance.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ // Always get the status Type: "ec2-instance-status", Method: sdp.QueryMethod_GET, Query: *instance.InstanceId, Scope: scope, }, }, { Query: &sdp.Query{ // Get CloudWatch metrics for this instance Type: "cloudwatch-instance-metric", Method: sdp.QueryMethod_GET, Query: *instance.InstanceId, Scope: scope, }, }, }, } if instance.State != nil { switch aws.ToInt32(instance.State.Code) { case codeRunning: item.Health = sdp.Health_HEALTH_OK.Enum() case codePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case codeShuttingDown: item.Health = sdp.Health_HEALTH_PENDING.Enum() case codeStopping: item.Health = sdp.Health_HEALTH_PENDING.Enum() case codeTerminated, codeStopped: // No health for things that aren't running } } if instance.IamInstanceProfile != nil { // Prefer the ARN if instance.IamInstanceProfile.Arn != nil { if arn, err := ParseARN(*instance.IamInstanceProfile.Arn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-instance-profile", Method: sdp.QueryMethod_SEARCH, Query: *instance.IamInstanceProfile.Arn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } else if instance.IamInstanceProfile.Id != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-instance-profile", Method: sdp.QueryMethod_GET, Query: *instance.IamInstanceProfile.Id, Scope: scope, }, }) } } if instance.CapacityReservationId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-capacity-reservation", Method: sdp.QueryMethod_GET, Query: *instance.CapacityReservationId, Scope: scope, }, }) } for _, assoc := range instance.ElasticGpuAssociations { if assoc.ElasticGpuId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-elastic-gpu", Method: sdp.QueryMethod_GET, Query: *assoc.ElasticGpuId, Scope: scope, }, }) } } for _, assoc := range instance.ElasticInferenceAcceleratorAssociations { if assoc.ElasticInferenceAcceleratorArn != nil { if arn, err := ParseARN(*assoc.ElasticInferenceAcceleratorArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elastic-inference-accelerator", Method: sdp.QueryMethod_SEARCH, Query: *assoc.ElasticInferenceAcceleratorArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } for _, license := range instance.Licenses { if license.LicenseConfigurationArn != nil { if arn, err := ParseARN(*license.LicenseConfigurationArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "license-manager-license-configuration", Method: sdp.QueryMethod_SEARCH, Query: *license.LicenseConfigurationArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } if instance.OutpostArn != nil { if arn, err := ParseARN(*instance.OutpostArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "outposts-outpost", Method: sdp.QueryMethod_SEARCH, Query: *instance.OutpostArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if instance.SpotInstanceRequestId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-spot-instance-request", Method: sdp.QueryMethod_GET, Query: *instance.SpotInstanceRequestId, Scope: scope, }, }) } if instance.ImageId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-image", Method: sdp.QueryMethod_GET, Query: *instance.ImageId, Scope: scope, }, }) } if instance.KeyName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-key-pair", Method: sdp.QueryMethod_GET, Query: *instance.KeyName, Scope: scope, }, }) } if instance.Placement != nil { if instance.Placement.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-placement-group", Method: sdp.QueryMethod_GET, Query: *instance.Placement.GroupId, Scope: scope, }, }) } } if instance.Ipv6Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *instance.Ipv6Address, Scope: "global", }, }) } for _, nic := range instance.NetworkInterfaces { // IPs for _, ip := range nic.Ipv6Addresses { if ip.Ipv6Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ip.Ipv6Address, Scope: "global", }, }) } } for _, ip := range nic.PrivateIpAddresses { if ip.PrivateIpAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ip.PrivateIpAddress, Scope: "global", }, }) } } // Subnet if nic.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *nic.SubnetId, Scope: scope, }, }) } // VPC if nic.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *nic.VpcId, Scope: scope, }, }) } } if instance.PublicDnsName != nil && *instance.PublicDnsName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *instance.PublicDnsName, Scope: "global", }, }) } if instance.PublicIpAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *instance.PublicIpAddress, Scope: "global", }, }) } // Security groups for _, group := range instance.SecurityGroups { if group.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *group.GroupId, Scope: scope, }, }) } } for _, mapping := range instance.BlockDeviceMappings { if mapping.Ebs != nil && mapping.Ebs.VolumeId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-volume", Method: sdp.QueryMethod_GET, Query: *mapping.Ebs.VolumeId, Scope: scope, }, }) } } items = append(items, &item) } } return items, nil } func NewEC2InstanceAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeInstancesInput, *ec2.DescribeInstancesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeInstancesInput, *ec2.DescribeInstancesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-instance", AdapterMetadata: ec2InstanceAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) { return client.DescribeInstances(ctx, input) }, InputMapperGet: instanceInputMapperGet, InputMapperList: instanceInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeInstancesInput) Paginator[*ec2.DescribeInstancesOutput, *ec2.Options] { return ec2.NewDescribeInstancesPaginator(client, params) }, OutputMapper: instanceOutputMapper, } } var ec2InstanceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-instance", DescriptiveName: "EC2 Instance", PotentialLinks: []string{"ec2-instance-status", "cloudwatch-instance-metric", "iam-instance-profile", "ec2-capacity-reservation", "ec2-elastic-gpu", "elastic-inference-accelerator", "license-manager-license-configuration", "outposts-outpost", "ec2-spot-instance-request", "ec2-image", "ec2-key-pair", "ec2-placement-group", "ip", "ec2-subnet", "ec2-vpc", "dns", "ec2-security-group", "ec2-volume"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an EC2 instance by ID", ListDescription: "List all EC2 instances", SearchDescription: "Search EC2 instances by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_instance.id", }, { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "aws_instance.arn", }, }, }) ================================================ FILE: aws-source/adapters/ec2-instance_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestInstanceInputMapperGet(t *testing.T) { input, err := instanceInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.InstanceIds) != 1 { t.Fatalf("expected 1 instance ID, got %v", len(input.InstanceIds)) } if input.InstanceIds[0] != "bar" { t.Errorf("expected instance ID to be bar, got %v", input.InstanceIds[0]) } } func TestInstanceInputMapperList(t *testing.T) { input, err := instanceInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.InstanceIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestInstanceOutputMapper(t *testing.T) { output := &ec2.DescribeInstancesOutput{ Reservations: []types.Reservation{ { Instances: []types.Instance{ { AmiLaunchIndex: new(int32(0)), PublicIpAddress: new("43.5.36.7"), ImageId: new("ami-04706e771f950937f"), InstanceId: new("i-04c7b2794f7bc3d6a"), IamInstanceProfile: &types.IamInstanceProfile{ Arn: new("arn:aws:iam::052392120703:instance-profile/test"), Id: new("AIDAJQEAZVQ7Y2EYQ2Z6Q"), }, BootMode: types.BootModeValuesLegacyBios, CurrentInstanceBootMode: types.InstanceBootModeValuesLegacyBios, ElasticGpuAssociations: []types.ElasticGpuAssociation{ { ElasticGpuAssociationId: new("ega-0a1b2c3d4e5f6g7h8"), ElasticGpuAssociationState: new("associated"), ElasticGpuAssociationTime: new("now"), ElasticGpuId: new("egp-0a1b2c3d4e5f6g7h8"), }, }, CapacityReservationId: new("cr-0a1b2c3d4e5f6g7h8"), InstanceType: types.InstanceTypeT2Micro, ElasticInferenceAcceleratorAssociations: []types.ElasticInferenceAcceleratorAssociation{ { ElasticInferenceAcceleratorArn: new("arn:aws:elastic-inference:us-east-1:052392120703:accelerator/eia-0a1b2c3d4e5f6g7h8"), ElasticInferenceAcceleratorAssociationId: new("eiaa-0a1b2c3d4e5f6g7h8"), ElasticInferenceAcceleratorAssociationState: new("associated"), ElasticInferenceAcceleratorAssociationTime: new(time.Now()), }, }, InstanceLifecycle: types.InstanceLifecycleTypeScheduled, Ipv6Address: new("2001:db8:3333:4444:5555:6666:7777:8888"), KeyName: new("dylan.ratcliffe"), KernelId: new("aki-0a1b2c3d4e5f6g7h8"), Licenses: []types.LicenseConfiguration{ { LicenseConfigurationArn: new("arn:aws:license-manager:us-east-1:052392120703:license-configuration:lic-0a1b2c3d4e5f6g7h8"), }, }, OutpostArn: new("arn:aws:outposts:us-east-1:052392120703:outpost/op-0a1b2c3d4e5f6g7h8"), Platform: types.PlatformValuesWindows, RamdiskId: new("ari-0a1b2c3d4e5f6g7h8"), SpotInstanceRequestId: new("sir-0a1b2c3d4e5f6g7h8"), SriovNetSupport: new("simple"), StateReason: &types.StateReason{ Code: new("foo"), Message: new("bar"), }, TpmSupport: new("foo"), LaunchTime: new(time.Now()), Monitoring: &types.Monitoring{ State: types.MonitoringStateDisabled, }, Placement: &types.Placement{ AvailabilityZone: new("eu-west-2c"), // link GroupName: new(""), GroupId: new("groupId"), Tenancy: types.TenancyDefault, }, PrivateDnsName: new("ip-172-31-95-79.eu-west-2.compute.internal"), PrivateIpAddress: new("172.31.95.79"), ProductCodes: []types.ProductCode{}, PublicDnsName: new(""), State: &types.InstanceState{ Code: new(int32(16)), Name: types.InstanceStateNameRunning, }, StateTransitionReason: new(""), SubnetId: new("subnet-0450a637af9984235"), VpcId: new("vpc-0d7892e00e573e701"), Architecture: types.ArchitectureValuesX8664, BlockDeviceMappings: []types.InstanceBlockDeviceMapping{ { DeviceName: new("/dev/xvda"), Ebs: &types.EbsInstanceBlockDevice{ AttachTime: new(time.Now()), DeleteOnTermination: new(true), Status: types.AttachmentStatusAttached, VolumeId: new("vol-06c7211d9e79a355e"), }, }, }, ClientToken: new("eafad400-29e0-4b5c-a0fc-ef74c77659c4"), EbsOptimized: new(false), EnaSupport: new(true), Hypervisor: types.HypervisorTypeXen, NetworkInterfaces: []types.InstanceNetworkInterface{ { Attachment: &types.InstanceNetworkInterfaceAttachment{ AttachTime: new(time.Now()), AttachmentId: new("eni-attach-02b19215d0dd9c7be"), DeleteOnTermination: new(true), DeviceIndex: new(int32(0)), Status: types.AttachmentStatusAttached, NetworkCardIndex: new(int32(0)), }, Description: new(""), Groups: []types.GroupIdentifier{ { GroupName: new("default"), GroupId: new("sg-094e151c9fc5da181"), }, }, Ipv6Addresses: []types.InstanceIpv6Address{}, MacAddress: new("02:8c:61:38:6f:c2"), NetworkInterfaceId: new("eni-09711a69e6d511358"), OwnerId: new("052392120703"), PrivateDnsName: new("ip-172-31-95-79.eu-west-2.compute.internal"), PrivateIpAddress: new("172.31.95.79"), PrivateIpAddresses: []types.InstancePrivateIpAddress{ { Primary: new(true), PrivateDnsName: new("ip-172-31-95-79.eu-west-2.compute.internal"), PrivateIpAddress: new("172.31.95.79"), }, }, SourceDestCheck: new(true), Status: types.NetworkInterfaceStatusInUse, SubnetId: new("subnet-0450a637af9984235"), VpcId: new("vpc-0d7892e00e573e701"), InterfaceType: new("interface"), }, }, RootDeviceName: new("/dev/xvda"), RootDeviceType: types.DeviceTypeEbs, SecurityGroups: []types.GroupIdentifier{ { GroupName: new("default"), GroupId: new("sg-094e151c9fc5da181"), }, }, SourceDestCheck: new(true), Tags: []types.Tag{ { Key: new("Name"), Value: new("test"), }, }, VirtualizationType: types.VirtualizationTypeHvm, CpuOptions: &types.CpuOptions{ CoreCount: new(int32(1)), ThreadsPerCore: new(int32(1)), }, CapacityReservationSpecification: &types.CapacityReservationSpecificationResponse{ CapacityReservationPreference: types.CapacityReservationPreferenceOpen, }, HibernationOptions: &types.HibernationOptions{ Configured: new(false), }, MetadataOptions: &types.InstanceMetadataOptionsResponse{ State: types.InstanceMetadataOptionsStateApplied, HttpTokens: types.HttpTokensStateOptional, HttpPutResponseHopLimit: new(int32(1)), HttpEndpoint: types.InstanceMetadataEndpointStateEnabled, HttpProtocolIpv6: types.InstanceMetadataProtocolStateDisabled, InstanceMetadataTags: types.InstanceMetadataTagsStateDisabled, }, EnclaveOptions: &types.EnclaveOptions{ Enabled: new(false), }, PlatformDetails: new("Linux/UNIX"), UsageOperation: new("RunInstances"), UsageOperationUpdateTime: new(time.Now()), PrivateDnsNameOptions: &types.PrivateDnsNameOptionsResponse{ HostnameType: types.HostnameTypeIpName, EnableResourceNameDnsARecord: new(true), EnableResourceNameDnsAAAARecord: new(false), }, MaintenanceOptions: &types.InstanceMaintenanceOptions{ AutoRecovery: types.InstanceAutoRecoveryStateDefault, }, }, }, }, }, } items, err := instanceOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-image", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ami-04706e771f950937f", ExpectedScope: item.GetScope(), }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "172.31.95.79", ExpectedScope: "global", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0450a637af9984235", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: item.GetScope(), }, { ExpectedType: "iam-instance-profile", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::052392120703:instance-profile/test", ExpectedScope: "052392120703", }, { ExpectedType: "ec2-capacity-reservation", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cr-0a1b2c3d4e5f6g7h8", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-elastic-gpu", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "egp-0a1b2c3d4e5f6g7h8", ExpectedScope: item.GetScope(), }, { ExpectedType: "elastic-inference-accelerator", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elastic-inference:us-east-1:052392120703:accelerator/eia-0a1b2c3d4e5f6g7h8", ExpectedScope: "052392120703.us-east-1", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:db8:3333:4444:5555:6666:7777:8888", ExpectedScope: "global", }, { ExpectedType: "license-manager-license-configuration", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:license-manager:us-east-1:052392120703:license-configuration:lic-0a1b2c3d4e5f6g7h8", ExpectedScope: "052392120703.us-east-1", }, { ExpectedType: "outposts-outpost", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:outposts:us-east-1:052392120703:outpost/op-0a1b2c3d4e5f6g7h8", ExpectedScope: "052392120703.us-east-1", }, { ExpectedType: "ec2-spot-instance-request", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sir-0a1b2c3d4e5f6g7h8", ExpectedScope: item.GetScope(), }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "43.5.36.7", ExpectedScope: "global", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-094e151c9fc5da181", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-instance-status", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-04c7b2794f7bc3d6a", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-volume", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vol-06c7211d9e79a355e", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-placement-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "groupId", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2InstanceAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2InstanceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-internet-gateway.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func internetGatewayInputMapperGet(scope string, query string) (*ec2.DescribeInternetGatewaysInput, error) { return &ec2.DescribeInternetGatewaysInput{ InternetGatewayIds: []string{ query, }, }, nil } func internetGatewayInputMapperList(scope string) (*ec2.DescribeInternetGatewaysInput, error) { return &ec2.DescribeInternetGatewaysInput{}, nil } func internetGatewayOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeInternetGatewaysInput, output *ec2.DescribeInternetGatewaysOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, gw := range output.InternetGateways { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(gw, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-internet-gateway", UniqueAttribute: "InternetGatewayId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(gw.Tags), } // VPCs for _, attachment := range gw.Attachments { if attachment.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *attachment.VpcId, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2InternetGatewayAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeInternetGatewaysInput, *ec2.DescribeInternetGatewaysOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeInternetGatewaysInput, *ec2.DescribeInternetGatewaysOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-internet-gateway", AdapterMetadata: internetGatewayAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeInternetGatewaysInput) (*ec2.DescribeInternetGatewaysOutput, error) { return client.DescribeInternetGateways(ctx, input) }, InputMapperGet: internetGatewayInputMapperGet, InputMapperList: internetGatewayInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeInternetGatewaysInput) Paginator[*ec2.DescribeInternetGatewaysOutput, *ec2.Options] { return ec2.NewDescribeInternetGatewaysPaginator(client, params) }, OutputMapper: internetGatewayOutputMapper, } } var internetGatewayAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-internet-gateway", DescriptiveName: "Internet Gateway", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an internet gateway by ID", ListDescription: "List all internet gateways", SearchDescription: "Search internet gateways by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_internet_gateway.id"}, }, PotentialLinks: []string{"ec2-vpc"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-internet-gateway_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestInternetGatewayInputMapperGet(t *testing.T) { input, err := internetGatewayInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.InternetGatewayIds) != 1 { t.Fatalf("expected 1 InternetGateway ID, got %v", len(input.InternetGatewayIds)) } if input.InternetGatewayIds[0] != "bar" { t.Errorf("expected InternetGateway ID to be bar, got %v", input.InternetGatewayIds[0]) } } func TestInternetGatewayInputMapperList(t *testing.T) { input, err := internetGatewayInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.InternetGatewayIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestInternetGatewayOutputMapper(t *testing.T) { output := &ec2.DescribeInternetGatewaysOutput{ InternetGateways: []types.InternetGateway{ { Attachments: []types.InternetGatewayAttachment{ { State: types.AttachmentStatusAttached, VpcId: new("vpc-0d7892e00e573e701"), }, }, InternetGatewayId: new("igw-03809416c9e2fcb66"), OwnerId: new("052392120703"), Tags: []types.Tag{ { Key: new("Name"), Value: new("test"), }, }, }, }, } items, err := internetGatewayOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: item.GetScope(), }, } tests.Execute(t, item) } func TestNewEC2InternetGatewayAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2InternetGatewayAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-key-pair.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func keyPairInputMapperGet(scope string, query string) (*ec2.DescribeKeyPairsInput, error) { return &ec2.DescribeKeyPairsInput{ KeyNames: []string{ query, }, }, nil } func keyPairInputMapperList(scope string) (*ec2.DescribeKeyPairsInput, error) { return &ec2.DescribeKeyPairsInput{}, nil } func keyPairOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeKeyPairsInput, output *ec2.DescribeKeyPairsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, gw := range output.KeyPairs { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(gw, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-key-pair", UniqueAttribute: "KeyName", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(gw.Tags), } items = append(items, &item) } return items, nil } func NewEC2KeyPairAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeKeyPairsInput, *ec2.DescribeKeyPairsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeKeyPairsInput, *ec2.DescribeKeyPairsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-key-pair", AdapterMetadata: keyPairAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeKeyPairsInput) (*ec2.DescribeKeyPairsOutput, error) { return client.DescribeKeyPairs(ctx, input) }, InputMapperGet: keyPairInputMapperGet, InputMapperList: keyPairInputMapperList, OutputMapper: keyPairOutputMapper, } } var keyPairAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-key-pair", DescriptiveName: "Key Pair", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a key pair by name", ListDescription: "List all key pairs", SearchDescription: "Search for key pairs by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_key_pair.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/ec2-key-pair_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestKeyPairInputMapperGet(t *testing.T) { input, err := keyPairInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.KeyNames) != 1 { t.Fatalf("expected 1 KeyPair ID, got %v", len(input.KeyNames)) } if input.KeyNames[0] != "bar" { t.Errorf("expected KeyPair ID to be bar, got %v", input.KeyNames[0]) } } func TestKeyPairInputMapperList(t *testing.T) { input, err := keyPairInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.KeyNames) != 0 { t.Errorf("non-empty input: %v", input) } } func TestKeyPairOutputMapper(t *testing.T) { output := &ec2.DescribeKeyPairsOutput{ KeyPairs: []types.KeyPairInfo{ { KeyPairId: new("key-04d7068d3a33bf9b2"), KeyFingerprint: new("df:73:bb:86:a7:cd:9e:18:16:10:50:79:fa:3b:4f:c7:1d:32:cf:58"), KeyName: new("dylan.ratcliffe"), KeyType: types.KeyTypeRsa, Tags: []types.Tag{}, CreateTime: new(time.Now()), PublicKey: new("PUB"), }, }, } items, err := keyPairOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } } func TestNewEC2KeyPairAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2KeyPairAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-launch-template-version.go ================================================ package adapters import ( "context" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func launchTemplateVersionInputMapperGet(scope string, query string) (*ec2.DescribeLaunchTemplateVersionsInput, error) { // We are expecting the query to be {id}.{version} sections := strings.Split(query, ".") if len(sections) != 2 { return nil, errors.New("input did not have 2 sections") } return &ec2.DescribeLaunchTemplateVersionsInput{ LaunchTemplateId: §ions[0], Versions: []string{ sections[1], }, }, nil } func launchTemplateVersionInputMapperList(scope string) (*ec2.DescribeLaunchTemplateVersionsInput, error) { return &ec2.DescribeLaunchTemplateVersionsInput{ Versions: []string{ "$Latest", "$Default", }, }, nil } func launchTemplateVersionOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeLaunchTemplateVersionsInput, output *ec2.DescribeLaunchTemplateVersionsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, ltv := range output.LaunchTemplateVersions { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(ltv) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if ltv.LaunchTemplateId != nil && ltv.VersionNumber != nil { // Create a custom UAV here since there is no one unique attribute. // The new UAV will be {templateId}.{version} attrs.Set("VersionIdCombo", fmt.Sprintf("%v.%v", *ltv.LaunchTemplateId, *ltv.VersionNumber)) } else { return nil, errors.New("ec2-launch-template-version must have LaunchTemplateId and VersionNumber populated") } item := sdp.Item{ Type: "ec2-launch-template-version", UniqueAttribute: "VersionIdCombo", Scope: scope, Attributes: attrs, } if lt := ltv.LaunchTemplateData; lt != nil { for _, ni := range lt.NetworkInterfaces { for _, ip := range ni.Ipv6Addresses { if ip.Ipv6Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ip.Ipv6Address, Scope: "global", }, }) } } if ni.NetworkInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: *ni.NetworkInterfaceId, Scope: scope, }, }) } for _, ip := range ni.PrivateIpAddresses { if ip.PrivateIpAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ip.PrivateIpAddress, Scope: "global", }, }) } } if ni.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *ni.SubnetId, Scope: scope, }, }) } for _, group := range ni.Groups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: group, Scope: scope, }, }) } } if lt.ImageId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-image", Method: sdp.QueryMethod_GET, Query: *lt.ImageId, Scope: scope, }, }) } if lt.KeyName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-key-pair", Method: sdp.QueryMethod_GET, Query: *lt.KeyName, Scope: scope, }, }) } for _, mapping := range lt.BlockDeviceMappings { if mapping.Ebs != nil && mapping.Ebs.SnapshotId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-snapshot", Method: sdp.QueryMethod_GET, Query: *mapping.Ebs.SnapshotId, Scope: scope, }, }) } } if spec := lt.CapacityReservationSpecification; spec != nil { if target := spec.CapacityReservationTarget; target != nil { if target.CapacityReservationId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-capacity-reservation", Method: sdp.QueryMethod_GET, Query: *target.CapacityReservationId, Scope: scope, }, }) } } } if lt.Placement != nil { if lt.Placement.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-placement-group", Method: sdp.QueryMethod_GET, Query: *lt.Placement.GroupId, Scope: scope, }, }) } if lt.Placement.HostId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-host", Method: sdp.QueryMethod_GET, Query: *lt.Placement.HostId, Scope: scope, }, }) } } for _, id := range lt.SecurityGroupIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2LaunchTemplateVersionAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeLaunchTemplateVersionsInput, *ec2.DescribeLaunchTemplateVersionsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeLaunchTemplateVersionsInput, *ec2.DescribeLaunchTemplateVersionsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-launch-template-version", AdapterMetadata: launchTemplateVersionAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeLaunchTemplateVersionsInput) (*ec2.DescribeLaunchTemplateVersionsOutput, error) { return client.DescribeLaunchTemplateVersions(ctx, input) }, InputMapperGet: launchTemplateVersionInputMapperGet, InputMapperList: launchTemplateVersionInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeLaunchTemplateVersionsInput) Paginator[*ec2.DescribeLaunchTemplateVersionsOutput, *ec2.Options] { return ec2.NewDescribeLaunchTemplateVersionsPaginator(client, params) }, OutputMapper: launchTemplateVersionOutputMapper, } } var launchTemplateVersionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-launch-template-version", DescriptiveName: "Launch Template Version", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a launch template version by {templateId}.{version}", ListDescription: "List all launch template versions", SearchDescription: "Search launch template versions by ARN", }, PotentialLinks: []string{"ec2-network-interface", "ec2-subnet", "ec2-security-group", "ec2-image", "ec2-key-pair", "ec2-snapshot", "ec2-capacity-reservation", "ec2-placement-group", "ec2-host", "ip"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ec2-launch-template-version_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestLaunchTemplateVersionInputMapperGet(t *testing.T) { input, err := launchTemplateVersionInputMapperGet("foo", "bar.10") if err != nil { t.Error(err) } if len(input.Versions) != 1 { t.Fatalf("expected 1 version, got %v", len(input.Versions)) } if input.Versions[0] != "10" { t.Fatalf("expected version to be 10, got %v", input.Versions[0]) } if *input.LaunchTemplateId != "bar" { t.Errorf("expected LaunchTemplateId to be bar, got %v", *input.LaunchTemplateId) } } func TestLaunchTemplateVersionInputMapperList(t *testing.T) { input, err := launchTemplateVersionInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Versions) != 2 { t.Errorf("expected 2 inputs, got %v: %v", len(input.Versions), input) } } func TestLaunchTemplateVersionOutputMapper(t *testing.T) { output := &ec2.DescribeLaunchTemplateVersionsOutput{ LaunchTemplateVersions: []types.LaunchTemplateVersion{ { LaunchTemplateId: new("lt-015547202038ae102"), LaunchTemplateName: new("test"), VersionNumber: new(int64(1)), CreateTime: new(time.Now()), CreatedBy: new("arn:aws:sts::052392120703:assumed-role/AWSReservedSSO_AWSAdministratorAccess_c1c3c9c54821c68a/dylan@overmind.tech"), DefaultVersion: new(true), LaunchTemplateData: &types.ResponseLaunchTemplateData{ NetworkInterfaces: []types.LaunchTemplateInstanceNetworkInterfaceSpecification{ { Ipv6Addresses: []types.InstanceIpv6Address{ { Ipv6Address: new("ipv6"), }, }, NetworkInterfaceId: new("networkInterface"), PrivateIpAddresses: []types.PrivateIpAddressSpecification{ { Primary: new(true), PrivateIpAddress: new("ip"), }, }, SubnetId: new("subnet"), DeviceIndex: new(int32(0)), Groups: []string{ "sg-094e151c9fc5da181", }, }, }, ImageId: new("ami-084e8c05825742534"), InstanceType: types.InstanceTypeT1Micro, KeyName: new("dylan.ratcliffe"), BlockDeviceMappings: []types.LaunchTemplateBlockDeviceMapping{ { Ebs: &types.LaunchTemplateEbsBlockDevice{ SnapshotId: new("snap"), }, }, }, CapacityReservationSpecification: &types.LaunchTemplateCapacityReservationSpecificationResponse{ CapacityReservationPreference: types.CapacityReservationPreferenceNone, CapacityReservationTarget: &types.CapacityReservationTargetResponse{ CapacityReservationId: new("cap"), }, }, CpuOptions: &types.LaunchTemplateCpuOptions{}, CreditSpecification: &types.CreditSpecification{}, ElasticGpuSpecifications: []types.ElasticGpuSpecificationResponse{}, EnclaveOptions: &types.LaunchTemplateEnclaveOptions{}, ElasticInferenceAccelerators: []types.LaunchTemplateElasticInferenceAcceleratorResponse{}, Placement: &types.LaunchTemplatePlacement{ AvailabilityZone: new("foo"), GroupId: new("placement"), HostId: new("host"), }, SecurityGroupIds: []string{ "secGroup", }, }, }, }, } items, err := launchTemplateVersionOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ipv6", ExpectedScope: "global", }, { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "networkInterface", ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ip", ExpectedScope: "global", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-094e151c9fc5da181", ExpectedScope: "foo", }, { ExpectedType: "ec2-image", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ami-084e8c05825742534", ExpectedScope: "foo", }, { ExpectedType: "ec2-key-pair", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dylan.ratcliffe", ExpectedScope: "foo", }, { ExpectedType: "ec2-snapshot", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "snap", ExpectedScope: "foo", }, { ExpectedType: "ec2-capacity-reservation", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cap", ExpectedScope: "foo", }, { ExpectedType: "ec2-placement-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "placement", ExpectedScope: "foo", }, { ExpectedType: "ec2-host", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "host", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "secGroup", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2LaunchTemplateVersionAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2LaunchTemplateVersionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipNotFoundCheck: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-launch-template.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func launchTemplateInputMapperGet(scope string, query string) (*ec2.DescribeLaunchTemplatesInput, error) { return &ec2.DescribeLaunchTemplatesInput{ LaunchTemplateIds: []string{ query, }, }, nil } func launchTemplateInputMapperList(scope string) (*ec2.DescribeLaunchTemplatesInput, error) { return &ec2.DescribeLaunchTemplatesInput{}, nil } func launchTemplateOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeLaunchTemplatesInput, output *ec2.DescribeLaunchTemplatesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, LaunchTemplate := range output.LaunchTemplates { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(LaunchTemplate, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-launch-template", UniqueAttribute: "LaunchTemplateId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(LaunchTemplate.Tags), } items = append(items, &item) } return items, nil } func NewEC2LaunchTemplateAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeLaunchTemplatesInput, *ec2.DescribeLaunchTemplatesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeLaunchTemplatesInput, *ec2.DescribeLaunchTemplatesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-launch-template", AdapterMetadata: launchTemplateAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeLaunchTemplatesInput) (*ec2.DescribeLaunchTemplatesOutput, error) { return client.DescribeLaunchTemplates(ctx, input) }, InputMapperGet: launchTemplateInputMapperGet, InputMapperList: launchTemplateInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeLaunchTemplatesInput) Paginator[*ec2.DescribeLaunchTemplatesOutput, *ec2.Options] { return ec2.NewDescribeLaunchTemplatesPaginator(client, params) }, OutputMapper: launchTemplateOutputMapper, } } var launchTemplateAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-launch-template", DescriptiveName: "Launch Template", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a launch template by ID", ListDescription: "List all launch templates", SearchDescription: "Search for launch templates by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_launch_template.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ec2-launch-template_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestLaunchTemplateInputMapperGet(t *testing.T) { input, err := launchTemplateInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.LaunchTemplateIds) != 1 { t.Fatalf("expected 1 LaunchTemplate ID, got %v", len(input.LaunchTemplateIds)) } if input.LaunchTemplateIds[0] != "bar" { t.Errorf("expected LaunchTemplate ID to be bar, got %v", input.LaunchTemplateIds[0]) } } func TestLaunchTemplateInputMapperList(t *testing.T) { input, err := launchTemplateInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.LaunchTemplateIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestLaunchTemplateOutputMapper(t *testing.T) { output := &ec2.DescribeLaunchTemplatesOutput{ LaunchTemplates: []types.LaunchTemplate{ { CreateTime: new(time.Now()), CreatedBy: new("me"), DefaultVersionNumber: new(int64(1)), LatestVersionNumber: new(int64(10)), LaunchTemplateId: new("id"), LaunchTemplateName: new("hello"), Tags: []types.Tag{}, }, }, } items, err := launchTemplateOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } } func TestNewEC2LaunchTemplateAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2LaunchTemplateAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-nat-gateway.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func natGatewayInputMapperGet(scope string, query string) (*ec2.DescribeNatGatewaysInput, error) { return &ec2.DescribeNatGatewaysInput{ NatGatewayIds: []string{ query, }, }, nil } func natGatewayInputMapperList(scope string) (*ec2.DescribeNatGatewaysInput, error) { return &ec2.DescribeNatGatewaysInput{}, nil } func natGatewayOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeNatGatewaysInput, output *ec2.DescribeNatGatewaysOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, ng := range output.NatGateways { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(ng, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-nat-gateway", UniqueAttribute: "NatGatewayId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(ng.Tags), } for _, address := range ng.NatGatewayAddresses { if address.NetworkInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: *address.NetworkInterfaceId, Scope: scope, }, }) } if address.PrivateIp != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.PrivateIp, Scope: "global", }, }) } if address.PublicIp != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.PublicIp, Scope: "global", }, }) } } if ng.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *ng.SubnetId, Scope: scope, }, }) } if ng.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *ng.VpcId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2NatGatewayAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeNatGatewaysInput, *ec2.DescribeNatGatewaysOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeNatGatewaysInput, *ec2.DescribeNatGatewaysOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-nat-gateway", AdapterMetadata: natGatewayAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeNatGatewaysInput) (*ec2.DescribeNatGatewaysOutput, error) { return client.DescribeNatGateways(ctx, input) }, InputMapperGet: natGatewayInputMapperGet, InputMapperList: natGatewayInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeNatGatewaysInput) Paginator[*ec2.DescribeNatGatewaysOutput, *ec2.Options] { return ec2.NewDescribeNatGatewaysPaginator(client, params) }, OutputMapper: natGatewayOutputMapper, } } var natGatewayAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-nat-gateway", DescriptiveName: "NAT Gateway", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a NAT Gateway by ID", ListDescription: "List all NAT gateways", SearchDescription: "Search for NAT gateways by ARN", }, PotentialLinks: []string{"ec2-vpc", "ec2-subnet", "ec2-network-interface", "ip"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_nat_gateway.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-nat-gateway_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestNatGatewayInputMapperGet(t *testing.T) { input, err := natGatewayInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.NatGatewayIds) != 1 { t.Fatalf("expected 1 NatGateway ID, got %v", len(input.NatGatewayIds)) } if input.NatGatewayIds[0] != "bar" { t.Errorf("expected NatGateway ID to be bar, got %v", input.NatGatewayIds[0]) } } func TestNatGatewayInputMapperList(t *testing.T) { input, err := natGatewayInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filter) != 0 || len(input.NatGatewayIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestNatGatewayOutputMapper(t *testing.T) { output := &ec2.DescribeNatGatewaysOutput{ NatGateways: []types.NatGateway{ { CreateTime: new(time.Now()), DeleteTime: new(time.Now()), FailureCode: new("Gateway.NotAttached"), FailureMessage: new("Network vpc-0d7892e00e573e701 has no Internet gateway attached"), NatGatewayAddresses: []types.NatGatewayAddress{ { AllocationId: new("eipalloc-000a9739291350592"), NetworkInterfaceId: new("eni-0c59532b8e10343ae"), PrivateIp: new("172.31.89.23"), }, }, NatGatewayId: new("nat-0e4e73d7ac46af25e"), State: types.NatGatewayStateFailed, SubnetId: new("subnet-0450a637af9984235"), VpcId: new("vpc-0d7892e00e573e701"), Tags: []types.Tag{ { Key: new("Name"), Value: new("test"), }, }, ConnectivityType: types.ConnectivityTypePublic, }, { CreateTime: new(time.Now()), NatGatewayAddresses: []types.NatGatewayAddress{ { AllocationId: new("eipalloc-000a9739291350592"), NetworkInterfaceId: new("eni-0b4652e6f2aa36d78"), PrivateIp: new("172.31.35.98"), PublicIp: new("18.170.133.9"), }, }, NatGatewayId: new("nat-0e07f7530ef076766"), State: types.NatGatewayStateAvailable, SubnetId: new("subnet-0d8ae4b4e07647efa"), VpcId: new("vpc-0d7892e00e573e701"), Tags: []types.Tag{ { Key: new("Name"), Value: new("test"), }, }, ConnectivityType: types.ConnectivityTypePublic, }, }, } items, err := natGatewayOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 2 { t.Fatalf("expected 2 items, got %v", len(items)) } item := items[1] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "eni-0b4652e6f2aa36d78", ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "172.31.35.98", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "18.170.133.9", ExpectedScope: "global", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0d8ae4b4e07647efa", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2NatGatewayAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2NatGatewayAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-network-acl.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func networkAclInputMapperGet(scope string, query string) (*ec2.DescribeNetworkAclsInput, error) { return &ec2.DescribeNetworkAclsInput{ NetworkAclIds: []string{ query, }, }, nil } func networkAclInputMapperList(scope string) (*ec2.DescribeNetworkAclsInput, error) { return &ec2.DescribeNetworkAclsInput{}, nil } func networkAclOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeNetworkAclsInput, output *ec2.DescribeNetworkAclsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, networkAcl := range output.NetworkAcls { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(networkAcl, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-network-acl", UniqueAttribute: "NetworkAclId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(networkAcl.Tags), } for _, assoc := range networkAcl.Associations { if assoc.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *assoc.SubnetId, Scope: scope, }, }) } } if networkAcl.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *networkAcl.VpcId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2NetworkAclAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeNetworkAclsInput, *ec2.DescribeNetworkAclsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeNetworkAclsInput, *ec2.DescribeNetworkAclsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-network-acl", AdapterMetadata: networkAclAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeNetworkAclsInput) (*ec2.DescribeNetworkAclsOutput, error) { return client.DescribeNetworkAcls(ctx, input) }, InputMapperGet: networkAclInputMapperGet, InputMapperList: networkAclInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeNetworkAclsInput) Paginator[*ec2.DescribeNetworkAclsOutput, *ec2.Options] { return ec2.NewDescribeNetworkAclsPaginator(client, params) }, OutputMapper: networkAclOutputMapper, } } var networkAclAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-network-acl", DescriptiveName: "Network ACL", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a network ACL", ListDescription: "List all network ACLs", SearchDescription: "Search for network ACLs by ARN", }, PotentialLinks: []string{"ec2-subnet", "ec2-vpc"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_network_acl.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/ec2-network-acl_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestNetworkAclInputMapperGet(t *testing.T) { input, err := networkAclInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.NetworkAclIds) != 1 { t.Fatalf("expected 1 NetworkAcl ID, got %v", len(input.NetworkAclIds)) } if input.NetworkAclIds[0] != "bar" { t.Errorf("expected NetworkAcl ID to be bar, got %v", input.NetworkAclIds[0]) } } func TestNetworkAclInputMapperList(t *testing.T) { input, err := networkAclInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.NetworkAclIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestNetworkAclOutputMapper(t *testing.T) { output := &ec2.DescribeNetworkAclsOutput{ NetworkAcls: []types.NetworkAcl{ { Associations: []types.NetworkAclAssociation{ { NetworkAclAssociationId: new("aclassoc-0f85f8b1fde0a5939"), NetworkAclId: new("acl-0a346e8e6f5a9ad91"), SubnetId: new("subnet-0450a637af9984235"), }, { NetworkAclAssociationId: new("aclassoc-064b78003a2d309a4"), NetworkAclId: new("acl-0a346e8e6f5a9ad91"), SubnetId: new("subnet-06c0dea0437180c61"), }, { NetworkAclAssociationId: new("aclassoc-0575080579a7381f5"), NetworkAclId: new("acl-0a346e8e6f5a9ad91"), SubnetId: new("subnet-0d8ae4b4e07647efa"), }, }, Entries: []types.NetworkAclEntry{ { CidrBlock: new("0.0.0.0/0"), Egress: new(true), Protocol: new("-1"), RuleAction: types.RuleActionAllow, RuleNumber: new(int32(100)), }, { CidrBlock: new("0.0.0.0/0"), Egress: new(true), Protocol: new("-1"), RuleAction: types.RuleActionDeny, RuleNumber: new(int32(32767)), }, }, IsDefault: new(true), NetworkAclId: new("acl-0a346e8e6f5a9ad91"), Tags: []types.Tag{}, VpcId: new("vpc-0d7892e00e573e701"), OwnerId: new("052392120703"), }, }, } items, err := networkAclOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-06c0dea0437180c61", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0d8ae4b4e07647efa", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0450a637af9984235", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2NetworkAclAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2NetworkAclAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-network-interface-permission.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func networkInterfacePermissionInputMapperGet(scope string, query string) (*ec2.DescribeNetworkInterfacePermissionsInput, error) { return &ec2.DescribeNetworkInterfacePermissionsInput{ NetworkInterfacePermissionIds: []string{ query, }, }, nil } func networkInterfacePermissionInputMapperList(scope string) (*ec2.DescribeNetworkInterfacePermissionsInput, error) { return &ec2.DescribeNetworkInterfacePermissionsInput{}, nil } func networkInterfacePermissionOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeNetworkInterfacePermissionsInput, output *ec2.DescribeNetworkInterfacePermissionsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, ni := range output.NetworkInterfacePermissions { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(ni) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-network-interface-permission", UniqueAttribute: "NetworkInterfacePermissionId", Scope: scope, Attributes: attrs, } if ni.NetworkInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: *ni.NetworkInterfaceId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2NetworkInterfacePermissionAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeNetworkInterfacePermissionsInput, *ec2.DescribeNetworkInterfacePermissionsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeNetworkInterfacePermissionsInput, *ec2.DescribeNetworkInterfacePermissionsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-network-interface-permission", AdapterMetadata: networkInterfacePermissionAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeNetworkInterfacePermissionsInput) (*ec2.DescribeNetworkInterfacePermissionsOutput, error) { return client.DescribeNetworkInterfacePermissions(ctx, input) }, InputMapperGet: networkInterfacePermissionInputMapperGet, InputMapperList: networkInterfacePermissionInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeNetworkInterfacePermissionsInput) Paginator[*ec2.DescribeNetworkInterfacePermissionsOutput, *ec2.Options] { return ec2.NewDescribeNetworkInterfacePermissionsPaginator(client, params) }, OutputMapper: networkInterfacePermissionOutputMapper, } } var networkInterfacePermissionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-network-interface-permission", DescriptiveName: "Network Interface Permission", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a network interface permission by ID", ListDescription: "List all network interface permissions", SearchDescription: "Search network interface permissions by ARN", }, PotentialLinks: []string{"ec2-network-interface"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/ec2-network-interface-permission_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestNetworkInterfacePermissionInputMapperGet(t *testing.T) { input, err := networkInterfacePermissionInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.NetworkInterfacePermissionIds) != 1 { t.Fatalf("expected 1 NetworkInterfacePermission ID, got %v", len(input.NetworkInterfacePermissionIds)) } if input.NetworkInterfacePermissionIds[0] != "bar" { t.Errorf("expected NetworkInterfacePermission ID to be bar, got %v", input.NetworkInterfacePermissionIds[0]) } } func TestNetworkInterfacePermissionInputMapperList(t *testing.T) { input, err := networkInterfacePermissionInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.NetworkInterfacePermissionIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestNetworkInterfacePermissionOutputMapper(t *testing.T) { output := &ec2.DescribeNetworkInterfacePermissionsOutput{ NetworkInterfacePermissions: []types.NetworkInterfacePermission{ { NetworkInterfacePermissionId: new("eni-perm-0b6211455242c105e"), NetworkInterfaceId: new("eni-07f8f3d404036c833"), AwsService: new("routing.hyperplane.eu-west-2.amazonaws.com"), Permission: types.InterfacePermissionTypeInstanceAttach, PermissionState: &types.NetworkInterfacePermissionState{ State: types.NetworkInterfacePermissionStateCodeGranted, }, }, }, } items, err := networkInterfacePermissionOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "eni-07f8f3d404036c833", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2NetworkInterfacePermissionAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2NetworkInterfacePermissionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-network-interface.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func networkInterfaceInputMapperGet(scope string, query string) (*ec2.DescribeNetworkInterfacesInput, error) { return &ec2.DescribeNetworkInterfacesInput{ NetworkInterfaceIds: []string{ query, }, }, nil } func networkInterfaceInputMapperList(scope string) (*ec2.DescribeNetworkInterfacesInput, error) { return &ec2.DescribeNetworkInterfacesInput{}, nil } func networkInterfaceInputMapperSearch(_ context.Context, _ *ec2.Client, scope, query string) (*ec2.DescribeNetworkInterfacesInput, error) { // If query looks like a security group ID, filter by it // This enables security groups to discover their attached network interfaces if strings.HasPrefix(query, "sg-") { return &ec2.DescribeNetworkInterfacesInput{ Filters: []types.Filter{ { Name: aws.String("group-id"), Values: []string{query}, }, }, }, nil } // Otherwise try to parse as an ARN arn, err := ParseARN(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "query must be a security group ID (sg-*) or a valid ARN", Scope: scope, } } // Extract network interface ID from ARN // ARN format: arn:aws:ec2:region:account:network-interface/eni-xxx if arn.Type() == "network-interface" { return &ec2.DescribeNetworkInterfacesInput{ NetworkInterfaceIds: []string{arn.ResourceID()}, }, nil } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "unsupported ARN type for network interface search", Scope: scope, } } func networkInterfaceOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeNetworkInterfacesInput, output *ec2.DescribeNetworkInterfacesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, ni := range output.NetworkInterfaces { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(ni, "tagSet") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-network-interface", UniqueAttribute: "NetworkInterfaceId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(ni.TagSet), } if ni.Attachment != nil { if ni.Attachment.InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *ni.Attachment.InstanceId, Scope: scope, }, }) } } for _, sg := range ni.Groups { if sg.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *sg.GroupId, Scope: scope, }, }) } } for _, ip := range ni.Ipv6Addresses { if ip.Ipv6Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ip.Ipv6Address, Scope: "global", }, }) } } for _, ip := range ni.PrivateIpAddresses { if assoc := ip.Association; assoc != nil { if assoc.PublicDnsName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *assoc.PublicDnsName, Scope: "global", }, }) } if assoc.PublicIp != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *assoc.PublicIp, Scope: "global", }, }) } if assoc.CarrierIp != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *assoc.CarrierIp, Scope: "global", }, }) } if assoc.CustomerOwnedIp != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *assoc.CustomerOwnedIp, Scope: "global", }, }) } } if ip.PrivateDnsName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *ip.PrivateDnsName, Scope: "global", }, }) } if ip.PrivateIpAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ip.PrivateIpAddress, Scope: "global", }, }) } } if ni.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *ni.SubnetId, Scope: scope, }, }) } if ni.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *ni.VpcId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2NetworkInterfaceAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeNetworkInterfacesInput, *ec2.DescribeNetworkInterfacesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeNetworkInterfacesInput, *ec2.DescribeNetworkInterfacesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-network-interface", AdapterMetadata: networkInterfaceAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeNetworkInterfacesInput) (*ec2.DescribeNetworkInterfacesOutput, error) { return client.DescribeNetworkInterfaces(ctx, input) }, InputMapperGet: networkInterfaceInputMapperGet, InputMapperList: networkInterfaceInputMapperList, InputMapperSearch: networkInterfaceInputMapperSearch, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeNetworkInterfacesInput) Paginator[*ec2.DescribeNetworkInterfacesOutput, *ec2.Options] { return ec2.NewDescribeNetworkInterfacesPaginator(client, params) }, OutputMapper: networkInterfaceOutputMapper, } } var networkInterfaceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-network-interface", DescriptiveName: "EC2 Network Interface", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a network interface by ID", ListDescription: "List all network interfaces", SearchDescription: "Search network interfaces by ARN or security group ID (sg-*)", }, PotentialLinks: []string{"ec2-instance", "ec2-security-group", "ip", "dns", "ec2-subnet", "ec2-vpc"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_network_interface.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-network-interface_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestNetworkInterfaceInputMapperGet(t *testing.T) { input, err := networkInterfaceInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.NetworkInterfaceIds) != 1 { t.Fatalf("expected 1 NetworkInterface ID, got %v", len(input.NetworkInterfaceIds)) } if input.NetworkInterfaceIds[0] != "bar" { t.Errorf("expected NetworkInterface ID to be bar, got %v", input.NetworkInterfaceIds[0]) } } func TestNetworkInterfaceInputMapperList(t *testing.T) { input, err := networkInterfaceInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.NetworkInterfaceIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestNetworkInterfaceInputMapperSearch(t *testing.T) { t.Parallel() tests := []struct { name string query string expectFilter bool filterName string filterValue string expectENIId bool eniId string expectError bool }{ { name: "Security group ID", query: "sg-0437857de45b640ce", expectFilter: true, filterName: "group-id", filterValue: "sg-0437857de45b640ce", }, { name: "Network interface ARN", query: "arn:aws:ec2:eu-west-2:123456789012:network-interface/eni-0b4652e6f2aa36d78", expectENIId: true, eniId: "eni-0b4652e6f2aa36d78", }, { name: "Invalid query", query: "invalid-query", expectError: true, }, { name: "Invalid ARN type", query: "arn:aws:ec2:eu-west-2:123456789012:instance/i-1234567890abcdef0", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() input, err := networkInterfaceInputMapperSearch(context.Background(), nil, "123456789012.eu-west-2", tt.query) if tt.expectError { if err == nil { t.Errorf("expected error for query %s, got nil", tt.query) } return } if err != nil { t.Errorf("unexpected error for query %s: %v", tt.query, err) return } if tt.expectFilter { if len(input.Filters) != 1 { t.Errorf("expected 1 filter, got %d", len(input.Filters)) return } if *input.Filters[0].Name != tt.filterName { t.Errorf("expected filter name %s, got %s", tt.filterName, *input.Filters[0].Name) } if len(input.Filters[0].Values) != 1 || input.Filters[0].Values[0] != tt.filterValue { t.Errorf("expected filter value %s, got %v", tt.filterValue, input.Filters[0].Values) } } if tt.expectENIId { if len(input.NetworkInterfaceIds) != 1 { t.Errorf("expected 1 network interface ID, got %d", len(input.NetworkInterfaceIds)) return } if input.NetworkInterfaceIds[0] != tt.eniId { t.Errorf("expected network interface ID %s, got %s", tt.eniId, input.NetworkInterfaceIds[0]) } } }) } } func TestNetworkInterfaceOutputMapper(t *testing.T) { output := &ec2.DescribeNetworkInterfacesOutput{ NetworkInterfaces: []types.NetworkInterface{ { Association: &types.NetworkInterfaceAssociation{ AllocationId: new("eipalloc-000a9739291350592"), AssociationId: new("eipassoc-049cda1f947e5efe6"), IpOwnerId: new("052392120703"), PublicDnsName: new("ec2-18-170-133-9.eu-west-2.compute.amazonaws.com"), PublicIp: new("18.170.133.9"), }, Attachment: &types.NetworkInterfaceAttachment{ AttachmentId: new("ela-attach-03e560efca8c9e5d8"), DeleteOnTermination: new(false), DeviceIndex: new(int32(1)), InstanceOwnerId: new("amazon-aws"), Status: types.AttachmentStatusAttached, InstanceId: new("foo"), }, AvailabilityZone: new("eu-west-2b"), Description: new("Interface for NAT Gateway nat-0e07f7530ef076766"), Groups: []types.GroupIdentifier{ { GroupId: new("group-123"), GroupName: new("something"), }, }, InterfaceType: types.NetworkInterfaceTypeNatGateway, Ipv6Addresses: []types.NetworkInterfaceIpv6Address{ { Ipv6Address: new("2001:db8:1234:0000:0000:0000:0000:0000"), }, }, MacAddress: new("0a:f4:55:b0:6c:be"), NetworkInterfaceId: new("eni-0b4652e6f2aa36d78"), OwnerId: new("052392120703"), PrivateDnsName: new("ip-172-31-35-98.eu-west-2.compute.internal"), PrivateIpAddress: new("172.31.35.98"), PrivateIpAddresses: []types.NetworkInterfacePrivateIpAddress{ { Association: &types.NetworkInterfaceAssociation{ AllocationId: new("eipalloc-000a9739291350592"), AssociationId: new("eipassoc-049cda1f947e5efe6"), IpOwnerId: new("052392120703"), PublicDnsName: new("ec2-18-170-133-9.eu-west-2.compute.amazonaws.com"), PublicIp: new("18.170.133.9"), CarrierIp: new("18.170.133.10"), CustomerOwnedIp: new("18.170.133.11"), }, Primary: new(true), PrivateDnsName: new("ip-172-31-35-98.eu-west-2.compute.internal"), PrivateIpAddress: new("172.31.35.98"), }, }, RequesterId: new("440527171281"), RequesterManaged: new(true), SourceDestCheck: new(false), Status: types.NetworkInterfaceStatusInUse, SubnetId: new("subnet-0d8ae4b4e07647efa"), TagSet: []types.Tag{}, VpcId: new("vpc-0d7892e00e573e701"), }, }, } items, err := networkInterfaceOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "foo", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "group-123", ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:db8:1234:0000:0000:0000:0000:0000", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ip-172-31-35-98.eu-west-2.compute.internal", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "172.31.35.98", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "18.170.133.9", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "18.170.133.10", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ec2-18-170-133-9.eu-west-2.compute.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "18.170.133.11", ExpectedScope: "global", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0d8ae4b4e07647efa", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2NetworkInterfaceAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2NetworkInterfaceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-placement-group.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func placementGroupInputMapperGet(scope string, query string) (*ec2.DescribePlacementGroupsInput, error) { return &ec2.DescribePlacementGroupsInput{ GroupIds: []string{ query, }, }, nil } func placementGroupInputMapperList(scope string) (*ec2.DescribePlacementGroupsInput, error) { return &ec2.DescribePlacementGroupsInput{}, nil } func placementGroupOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribePlacementGroupsInput, output *ec2.DescribePlacementGroupsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, ng := range output.PlacementGroups { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(ng, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-placement-group", UniqueAttribute: "GroupId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(ng.Tags), } items = append(items, &item) } return items, nil } func NewEC2PlacementGroupAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribePlacementGroupsInput, *ec2.DescribePlacementGroupsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribePlacementGroupsInput, *ec2.DescribePlacementGroupsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-placement-group", AdapterMetadata: placementGroupAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribePlacementGroupsInput) (*ec2.DescribePlacementGroupsOutput, error) { return client.DescribePlacementGroups(ctx, input) }, InputMapperGet: placementGroupInputMapperGet, InputMapperList: placementGroupInputMapperList, OutputMapper: placementGroupOutputMapper, } } var placementGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-placement-group", DescriptiveName: "Placement Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a placement group by ID", ListDescription: "List all placement groups", SearchDescription: "Search for placement groups by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_placement_group.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ec2-placement-group_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestPlacementGroupInputMapperGet(t *testing.T) { input, err := placementGroupInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.GroupIds) != 1 { t.Fatalf("expected 1 PlacementGroup ID, got %v", len(input.GroupIds)) } if input.GroupIds[0] != "bar" { t.Errorf("expected PlacementGroup ID to be bar, got %v", input.GroupIds[0]) } } func TestPlacementGroupInputMapperList(t *testing.T) { input, err := placementGroupInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.GroupIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestPlacementGroupOutputMapper(t *testing.T) { output := &ec2.DescribePlacementGroupsOutput{ PlacementGroups: []types.PlacementGroup{ { GroupArn: new("arn"), GroupId: new("id"), GroupName: new("name"), SpreadLevel: types.SpreadLevelHost, State: types.PlacementGroupStateAvailable, Strategy: types.PlacementStrategyCluster, PartitionCount: new(int32(1)), Tags: []types.Tag{}, }, }, } items, err := placementGroupOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 items, got %v", len(items)) } } func TestNewEC2PlacementGroupAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2PlacementGroupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-reserved-instance.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func reservedInstanceInputMapperGet(scope, query string) (*ec2.DescribeReservedInstancesInput, error) { return &ec2.DescribeReservedInstancesInput{ ReservedInstancesIds: []string{ query, }, }, nil } func reservedInstanceInputMapperList(scope string) (*ec2.DescribeReservedInstancesInput, error) { return &ec2.DescribeReservedInstancesInput{}, nil } func reservedInstanceOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeReservedInstancesInput, output *ec2.DescribeReservedInstancesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, reservation := range output.ReservedInstances { attrs, err := ToAttributesWithExclude(reservation, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-reserved-instance", UniqueAttribute: "ReservedInstancesId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(reservation.Tags), } items = append(items, &item) } return items, nil } func NewEC2ReservedInstanceAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeReservedInstancesInput, *ec2.DescribeReservedInstancesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeReservedInstancesInput, *ec2.DescribeReservedInstancesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-reserved-instance", AdapterMetadata: reservedInstanceAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeReservedInstancesInput) (*ec2.DescribeReservedInstancesOutput, error) { return client.DescribeReservedInstances(ctx, input) }, InputMapperGet: reservedInstanceInputMapperGet, InputMapperList: reservedInstanceInputMapperList, OutputMapper: reservedInstanceOutputMapper, } } var reservedInstanceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-reserved-instance", DescriptiveName: "Reserved EC2 Instance", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a reserved EC2 instance by ID", ListDescription: "List all reserved EC2 instances", SearchDescription: "Search reserved EC2 instances by ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ec2-reserved-instance_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestReservedInstanceInputMapperGet(t *testing.T) { input, err := reservedInstanceInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.ReservedInstancesIds) != 1 { t.Fatalf("expected 1 Reservedinstance ID, got %v", len(input.ReservedInstancesIds)) } if input.ReservedInstancesIds[0] != "bar" { t.Errorf("expected Reservedinstance ID to be bar, got %v", input.ReservedInstancesIds[0]) } } func TestReservedInstanceInputMapperList(t *testing.T) { input, err := reservedInstanceInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.ReservedInstancesIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestReservedInstanceOutputMapper(t *testing.T) { output := &ec2.DescribeReservedInstancesOutput{ ReservedInstances: []types.ReservedInstances{ { AvailabilityZone: new("az"), CurrencyCode: types.CurrencyCodeValuesUsd, Duration: new(int64(100)), End: new(time.Now()), FixedPrice: new(float32(1.23)), InstanceCount: new(int32(1)), InstanceTenancy: types.TenancyDedicated, InstanceType: types.InstanceTypeA14xlarge, OfferingClass: types.OfferingClassTypeConvertible, OfferingType: types.OfferingTypeValuesAllUpfront, ProductDescription: types.RIProductDescription("foo"), RecurringCharges: []types.RecurringCharge{ { Amount: new(1.111), Frequency: types.RecurringChargeFrequencyHourly, }, }, ReservedInstancesId: new("id"), Scope: types.ScopeAvailabilityZone, Start: new(time.Now()), State: types.ReservedInstanceStateActive, UsagePrice: new(float32(99.00000001)), }, }, } items, err := reservedInstanceOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{} tests.Execute(t, item) } func TestNewEC2ReservedInstanceAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2ReservedInstanceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-route-table.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func routeTableInputMapperGet(scope string, query string) (*ec2.DescribeRouteTablesInput, error) { return &ec2.DescribeRouteTablesInput{ RouteTableIds: []string{ query, }, }, nil } func routeTableInputMapperList(scope string) (*ec2.DescribeRouteTablesInput, error) { return &ec2.DescribeRouteTablesInput{}, nil } func routeTableOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeRouteTablesInput, output *ec2.DescribeRouteTablesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, rt := range output.RouteTables { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(rt, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-route-table", UniqueAttribute: "RouteTableId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(rt.Tags), } for _, assoc := range rt.Associations { if assoc.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *assoc.SubnetId, Scope: scope, }, }) } if assoc.GatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-internet-gateway", Method: sdp.QueryMethod_GET, Query: *assoc.GatewayId, Scope: scope, }, }) } } for _, route := range rt.Routes { if route.GatewayId != nil { if strings.HasPrefix(*route.GatewayId, "igw") { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-internet-gateway", Method: sdp.QueryMethod_GET, Query: *route.GatewayId, Scope: scope, }, }) } if strings.HasPrefix(*route.GatewayId, "vpce") { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc-endpoint", Method: sdp.QueryMethod_GET, Query: *route.GatewayId, Scope: scope, }, }) } } if route.CarrierGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-carrier-gateway", Method: sdp.QueryMethod_GET, Query: *route.CarrierGatewayId, Scope: scope, }, }) } if route.EgressOnlyInternetGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-egress-only-internet-gateway", Method: sdp.QueryMethod_GET, Query: *route.EgressOnlyInternetGatewayId, Scope: scope, }, }) } if route.InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *route.InstanceId, Scope: scope, }, }) } if route.LocalGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-local-gateway", Method: sdp.QueryMethod_GET, Query: *route.LocalGatewayId, Scope: scope, }, }) } if route.NatGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-nat-gateway", Method: sdp.QueryMethod_GET, Query: *route.NatGatewayId, Scope: scope, }, }) } if route.NetworkInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: *route.NetworkInterfaceId, Scope: scope, }, }) } if route.TransitGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_GET, Query: *route.TransitGatewayId, Scope: scope, }, }) } if route.VpcPeeringConnectionId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc-peering-connection", Method: sdp.QueryMethod_GET, Query: *route.VpcPeeringConnectionId, Scope: scope, }, }) } } if rt.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *rt.VpcId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2RouteTableAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeRouteTablesInput, *ec2.DescribeRouteTablesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeRouteTablesInput, *ec2.DescribeRouteTablesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-route-table", AdapterMetadata: routeTableAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeRouteTablesInput) (*ec2.DescribeRouteTablesOutput, error) { return client.DescribeRouteTables(ctx, input) }, InputMapperGet: routeTableInputMapperGet, InputMapperList: routeTableInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeRouteTablesInput) Paginator[*ec2.DescribeRouteTablesOutput, *ec2.Options] { return ec2.NewDescribeRouteTablesPaginator(client, params) }, OutputMapper: routeTableOutputMapper, } } var routeTableAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-route-table", DescriptiveName: "Route Table", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a route table by ID", ListDescription: "List all route tables", SearchDescription: "Search route tables by ARN", }, PotentialLinks: []string{"ec2-vpc", "ec2-subnet", "ec2-internet-gateway", "ec2-vpc-endpoint", "ec2-carrier-gateway", "ec2-egress-only-internet-gateway", "ec2-instance", "ec2-local-gateway", "ec2-nat-gateway", "ec2-network-interface", "ec2-transit-gateway", "ec2-vpc-peering-connection"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_route_table.id"}, {TerraformQueryMap: "aws_route_table_association.route_table_id"}, {TerraformQueryMap: "aws_default_route_table.default_route_table_id"}, {TerraformQueryMap: "aws_route.route_table_id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-route-table_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestRouteTableInputMapperGet(t *testing.T) { input, err := routeTableInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.RouteTableIds) != 1 { t.Fatalf("expected 1 RouteTable ID, got %v", len(input.RouteTableIds)) } if input.RouteTableIds[0] != "bar" { t.Errorf("expected RouteTable ID to be bar, got %v", input.RouteTableIds[0]) } } func TestRouteTableInputMapperList(t *testing.T) { input, err := routeTableInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.RouteTableIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestRouteTableOutputMapper(t *testing.T) { output := &ec2.DescribeRouteTablesOutput{ RouteTables: []types.RouteTable{ { Associations: []types.RouteTableAssociation{ { Main: new(false), RouteTableAssociationId: new("rtbassoc-0aa1442039abff3db"), RouteTableId: new("rtb-00b1197fa95a6b35f"), SubnetId: new("subnet-06c0dea0437180c61"), GatewayId: new("ID"), AssociationState: &types.RouteTableAssociationState{ State: types.RouteTableAssociationStateCodeAssociated, }, }, }, PropagatingVgws: []types.PropagatingVgw{ { GatewayId: new("goo"), }, }, RouteTableId: new("rtb-00b1197fa95a6b35f"), Routes: []types.Route{ { DestinationCidrBlock: new("172.31.0.0/16"), GatewayId: new("igw-12345"), Origin: types.RouteOriginCreateRouteTable, State: types.RouteStateActive, }, { DestinationPrefixListId: new("pl-7ca54015"), GatewayId: new("vpce-09fcbac4dcf142db3"), Origin: types.RouteOriginCreateRoute, State: types.RouteStateActive, CarrierGatewayId: new("id"), EgressOnlyInternetGatewayId: new("id"), InstanceId: new("id"), InstanceOwnerId: new("id"), LocalGatewayId: new("id"), NatGatewayId: new("id"), NetworkInterfaceId: new("id"), TransitGatewayId: new("id"), VpcPeeringConnectionId: new("id"), }, }, VpcId: new("vpc-0d7892e00e573e701"), OwnerId: new("052392120703"), }, }, } items, err := routeTableOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-06c0dea0437180c61", ExpectedScope: "foo", }, { ExpectedType: "ec2-internet-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ID", ExpectedScope: "foo", }, { ExpectedType: "ec2-carrier-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-egress-only-internet-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-local-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-nat-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-transit-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc-peering-connection", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc-endpoint", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpce-09fcbac4dcf142db3", ExpectedScope: "foo", }, { ExpectedType: "ec2-internet-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "igw-12345", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2RouteTableAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2RouteTableAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-security-group-rule.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func securityGroupRuleInputMapperGet(scope string, query string) (*ec2.DescribeSecurityGroupRulesInput, error) { return &ec2.DescribeSecurityGroupRulesInput{ SecurityGroupRuleIds: []string{ query, }, }, nil } func securityGroupRuleInputMapperList(scope string) (*ec2.DescribeSecurityGroupRulesInput, error) { return &ec2.DescribeSecurityGroupRulesInput{}, nil } func securityGroupRuleOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeSecurityGroupRulesInput, output *ec2.DescribeSecurityGroupRulesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, securityGroupRule := range output.SecurityGroupRules { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(securityGroupRule, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-security-group-rule", UniqueAttribute: "SecurityGroupRuleId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(securityGroupRule.Tags), } if securityGroupRule.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *securityGroupRule.GroupId, Scope: scope, }, }) } if rg := securityGroupRule.ReferencedGroupInfo; rg != nil { if rg.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *rg.GroupId, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2SecurityGroupRuleAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeSecurityGroupRulesInput, *ec2.DescribeSecurityGroupRulesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeSecurityGroupRulesInput, *ec2.DescribeSecurityGroupRulesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-security-group-rule", AdapterMetadata: securityGroupRuleAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeSecurityGroupRulesInput) (*ec2.DescribeSecurityGroupRulesOutput, error) { return client.DescribeSecurityGroupRules(ctx, input) }, InputMapperGet: securityGroupRuleInputMapperGet, InputMapperList: securityGroupRuleInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeSecurityGroupRulesInput) Paginator[*ec2.DescribeSecurityGroupRulesOutput, *ec2.Options] { return ec2.NewDescribeSecurityGroupRulesPaginator(client, params) }, OutputMapper: securityGroupRuleOutputMapper, } } var securityGroupRuleAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-security-group-rule", DescriptiveName: "Security Group Rule", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a security group rule by ID", ListDescription: "List all security group rules", SearchDescription: "Search security group rules by ARN", }, PotentialLinks: []string{"ec2-security-group"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_security_group_rule.security_group_rule_id"}, {TerraformQueryMap: "aws_vpc_security_group_ingress_rule.security_group_rule_id"}, {TerraformQueryMap: "aws_vpc_security_group_egress_rule.security_group_rule_id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/ec2-security-group-rule_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestSecurityGroupRuleInputMapperGet(t *testing.T) { input, err := securityGroupRuleInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.SecurityGroupRuleIds) != 1 { t.Fatalf("expected 1 SecurityGroupRule ID, got %v", len(input.SecurityGroupRuleIds)) } if input.SecurityGroupRuleIds[0] != "bar" { t.Errorf("expected SecurityGroupRule ID to be bar, got %v", input.SecurityGroupRuleIds[0]) } } func TestSecurityGroupRuleInputMapperList(t *testing.T) { input, err := securityGroupRuleInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.SecurityGroupRuleIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestSecurityGroupRuleOutputMapper(t *testing.T) { output := &ec2.DescribeSecurityGroupRulesOutput{ SecurityGroupRules: []types.SecurityGroupRule{ { SecurityGroupRuleId: new("sgr-0b0e42d1431e832bd"), GroupId: new("sg-0814766e46f201c22"), GroupOwnerId: new("052392120703"), IsEgress: new(false), IpProtocol: new("tcp"), FromPort: new(int32(2049)), ToPort: new(int32(2049)), ReferencedGroupInfo: &types.ReferencedSecurityGroup{ GroupId: new("sg-09371b4a54fe7ab38"), UserId: new("052392120703"), }, Description: new("Created by the LIW for EFS at 2022-12-16T19:14:27.033Z"), Tags: []types.Tag{}, }, { SecurityGroupRuleId: new("sgr-04b583a90b4fa4ada"), GroupId: new("sg-09371b4a54fe7ab38"), GroupOwnerId: new("052392120703"), IsEgress: new(true), IpProtocol: new("tcp"), FromPort: new(int32(2049)), ToPort: new(int32(2049)), ReferencedGroupInfo: &types.ReferencedSecurityGroup{ GroupId: new("sg-0814766e46f201c22"), UserId: new("052392120703"), }, Description: new("Created by the LIW for EFS at 2022-12-16T19:14:27.349Z"), Tags: []types.Tag{}, }, }, } items, err := securityGroupRuleOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 2 { t.Fatalf("expected 2 items, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-0814766e46f201c22", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-09371b4a54fe7ab38", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2SecurityGroupRuleAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2SecurityGroupRuleAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-security-group.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func securityGroupInputMapperGet(scope string, query string) (*ec2.DescribeSecurityGroupsInput, error) { return &ec2.DescribeSecurityGroupsInput{ GroupIds: []string{ query, }, }, nil } func securityGroupInputMapperList(scope string) (*ec2.DescribeSecurityGroupsInput, error) { return &ec2.DescribeSecurityGroupsInput{}, nil } func securityGroupOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeSecurityGroupsInput, output *ec2.DescribeSecurityGroupsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, securityGroup := range output.SecurityGroups { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(securityGroup, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-security-group", UniqueAttribute: "GroupId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(securityGroup.Tags), } // VPC if securityGroup.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *securityGroup.VpcId, Scope: scope, }, }) } // Network Interfaces using this security group // Link to network interfaces using this security group so the graph and blast radius analysis can traverse to attached instances. if securityGroup.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_SEARCH, Query: *securityGroup.GroupId, Scope: scope, }, }) } item.LinkedItemQueries = append(item.LinkedItemQueries, extractLinkedSecurityGroups(securityGroup.IpPermissions, scope)...) item.LinkedItemQueries = append(item.LinkedItemQueries, extractLinkedSecurityGroups(securityGroup.IpPermissionsEgress, scope)...) items = append(items, &item) } return items, nil } func NewEC2SecurityGroupAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeSecurityGroupsInput, *ec2.DescribeSecurityGroupsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeSecurityGroupsInput, *ec2.DescribeSecurityGroupsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-security-group", AdapterMetadata: securityGroupAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeSecurityGroupsInput) (*ec2.DescribeSecurityGroupsOutput, error) { return client.DescribeSecurityGroups(ctx, input) }, InputMapperGet: securityGroupInputMapperGet, InputMapperList: securityGroupInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeSecurityGroupsInput) Paginator[*ec2.DescribeSecurityGroupsOutput, *ec2.Options] { return ec2.NewDescribeSecurityGroupsPaginator(client, params) }, OutputMapper: securityGroupOutputMapper, InputMapperSearch: func(ctx context.Context, client *ec2.Client, scope, query string) (*ec2.DescribeSecurityGroupsInput, error) { return &ec2.DescribeSecurityGroupsInput{ GroupNames: []string{query}, }, nil }, } } var securityGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-security-group", DescriptiveName: "Security Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a security group by ID", ListDescription: "List all security groups", SearchDescription: "Search for security groups by ARN", }, PotentialLinks: []string{"ec2-vpc", "ec2-network-interface"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_security_group.id"}, {TerraformQueryMap: "aws_security_group_rule.security_group_id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) // extractLinkedSecurityGroups Extracts related security groups from IP // permissions func extractLinkedSecurityGroups(permissions []types.IpPermission, scope string) []*sdp.LinkedItemQuery { currentAccount, region, err := ParseScope(scope) requests := make([]*sdp.LinkedItemQuery, 0) var relatedAccount string if err != nil { return requests } for _, permission := range permissions { for _, idGroup := range permission.UserIdGroupPairs { if idGroup.UserId != nil { relatedAccount = *idGroup.UserId } else { relatedAccount = currentAccount } if idGroup.GroupId != nil { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *idGroup.GroupId, Scope: FormatScope(relatedAccount, region), }, }) } } } return requests } ================================================ FILE: aws-source/adapters/ec2-security-group_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestSecurityGroupInputMapperGet(t *testing.T) { input, err := securityGroupInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.GroupIds) != 1 { t.Fatalf("expected 1 SecurityGroup ID, got %v", len(input.GroupIds)) } if input.GroupIds[0] != "bar" { t.Errorf("expected SecurityGroup ID to be bar, got %v", input.GroupIds[0]) } } func TestSecurityGroupInputMapperList(t *testing.T) { input, err := securityGroupInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.GroupIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestSecurityGroupOutputMapper(t *testing.T) { output := &ec2.DescribeSecurityGroupsOutput{ SecurityGroups: []types.SecurityGroup{ { Description: new("default VPC security group"), GroupName: new("default"), IpPermissions: []types.IpPermission{ { IpProtocol: new("-1"), IpRanges: []types.IpRange{}, Ipv6Ranges: []types.Ipv6Range{}, PrefixListIds: []types.PrefixListId{}, UserIdGroupPairs: []types.UserIdGroupPair{ { GroupId: new("sg-094e151c9fc5da181"), UserId: new("052392120704"), }, }, }, }, OwnerId: new("052392120703"), GroupId: new("sg-094e151c9fc5da181"), IpPermissionsEgress: []types.IpPermission{ { IpProtocol: new("-1"), IpRanges: []types.IpRange{ { CidrIp: new("0.0.0.0/0"), }, }, Ipv6Ranges: []types.Ipv6Range{}, PrefixListIds: []types.PrefixListId{}, UserIdGroupPairs: []types.UserIdGroupPair{}, }, }, VpcId: new("vpc-0d7892e00e573e701"), }, }, } items, err := securityGroupOutputMapper(context.Background(), nil, "052392120703.eu-west-2", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "sg-094e151c9fc5da181", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-094e151c9fc5da181", ExpectedScope: "052392120704.eu-west-2", }, } tests.Execute(t, item) } func TestNewEC2SecurityGroupAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2SecurityGroupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-snapshot.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func snapshotInputMapperGet(scope string, query string) (*ec2.DescribeSnapshotsInput, error) { return &ec2.DescribeSnapshotsInput{ SnapshotIds: []string{ query, }, }, nil } func snapshotInputMapperList(scope string) (*ec2.DescribeSnapshotsInput, error) { return &ec2.DescribeSnapshotsInput{ OwnerIds: []string{ // Avoid getting every snapshot in existence, just get the ones // relevant to this scope i.e. owned by this account in this region "self", }, }, nil } func snapshotOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeSnapshotsInput, output *ec2.DescribeSnapshotsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, snapshot := range output.Snapshots { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(snapshot, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-snapshot", UniqueAttribute: "SnapshotId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(snapshot.Tags), } if snapshot.VolumeId != nil { // Ignore the arbitrary ID that is used by Amazon if *snapshot.VolumeId != "vol-ffffffff" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-volume", Method: sdp.QueryMethod_GET, Query: *snapshot.VolumeId, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2SnapshotAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeSnapshotsInput, *ec2.DescribeSnapshotsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeSnapshotsInput, *ec2.DescribeSnapshotsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-snapshot", AdapterMetadata: snapshotAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeSnapshotsInput) (*ec2.DescribeSnapshotsOutput, error) { return client.DescribeSnapshots(ctx, input) }, InputMapperGet: snapshotInputMapperGet, InputMapperList: snapshotInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeSnapshotsInput) Paginator[*ec2.DescribeSnapshotsOutput, *ec2.Options] { return ec2.NewDescribeSnapshotsPaginator(client, params) }, OutputMapper: snapshotOutputMapper, } } var snapshotAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-snapshot", DescriptiveName: "EC2 Snapshot", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a snapshot by ID", ListDescription: "List all snapshots", SearchDescription: "Search snapshots by ARN", }, PotentialLinks: []string{"ec2-volume"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) ================================================ FILE: aws-source/adapters/ec2-snapshot_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestSnapshotInputMapperGet(t *testing.T) { input, err := snapshotInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.SnapshotIds) != 1 { t.Fatalf("expected 1 Snapshot ID, got %v", len(input.SnapshotIds)) } if input.SnapshotIds[0] != "bar" { t.Errorf("expected Snapshot ID to be bar, got %v", input.SnapshotIds[0]) } } func TestSnapshotInputMapperList(t *testing.T) { input, err := snapshotInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.SnapshotIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestSnapshotOutputMapper(t *testing.T) { output := &ec2.DescribeSnapshotsOutput{ Snapshots: []types.Snapshot{ { DataEncryptionKeyId: new("ek"), KmsKeyId: new("key"), SnapshotId: new("id"), Description: new("foo"), Encrypted: new(false), OutpostArn: new("something"), OwnerAlias: new("something"), OwnerId: new("owner"), Progress: new("50%"), RestoreExpiryTime: new(time.Now()), StartTime: new(time.Now()), State: types.SnapshotStatePending, StateMessage: new("pending"), StorageTier: types.StorageTierArchive, Tags: []types.Tag{}, VolumeId: new("volumeId"), VolumeSize: new(int32(1024)), }, }, } items, err := snapshotOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-volume", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "volumeId", ExpectedScope: item.GetScope(), }, } tests.Execute(t, item) } func TestNewEC2SnapshotAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2SnapshotAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-subnet.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func subnetInputMapperGet(scope string, query string) (*ec2.DescribeSubnetsInput, error) { return &ec2.DescribeSubnetsInput{ SubnetIds: []string{ query, }, }, nil } func subnetInputMapperList(scope string) (*ec2.DescribeSubnetsInput, error) { return &ec2.DescribeSubnetsInput{}, nil } func subnetOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeSubnetsInput, output *ec2.DescribeSubnetsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, subnet := range output.Subnets { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(subnet, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-subnet", UniqueAttribute: "SubnetId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(subnet.Tags), } if subnet.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *subnet.VpcId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2SubnetAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeSubnetsInput, *ec2.DescribeSubnetsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeSubnetsInput, *ec2.DescribeSubnetsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-subnet", AdapterMetadata: subnetAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeSubnetsInput) (*ec2.DescribeSubnetsOutput, error) { return client.DescribeSubnets(ctx, input) }, InputMapperGet: subnetInputMapperGet, InputMapperList: subnetInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeSubnetsInput) Paginator[*ec2.DescribeSubnetsOutput, *ec2.Options] { return ec2.NewDescribeSubnetsPaginator(client, params) }, OutputMapper: subnetOutputMapper, } } var subnetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-subnet", DescriptiveName: "EC2 Subnet", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a subnet by ID", ListDescription: "List all subnets", SearchDescription: "Search for subnets by ARN", }, PotentialLinks: []string{"ec2-vpc"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_route_table_association.subnet_id"}, {TerraformQueryMap: "aws_subnet.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-subnet_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestSubnetInputMapperGet(t *testing.T) { input, err := subnetInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.SubnetIds) != 1 { t.Fatalf("expected 1 Subnet ID, got %v", len(input.SubnetIds)) } if input.SubnetIds[0] != "bar" { t.Errorf("expected Subnet ID to be bar, got %v", input.SubnetIds[0]) } } func TestSubnetInputMapperList(t *testing.T) { input, err := subnetInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.SubnetIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestSubnetOutputMapper(t *testing.T) { output := &ec2.DescribeSubnetsOutput{ Subnets: []types.Subnet{ { AvailabilityZone: new("eu-west-2c"), AvailabilityZoneId: new("euw2-az1"), AvailableIpAddressCount: new(int32(4091)), CidrBlock: new("172.31.80.0/20"), DefaultForAz: new(false), MapPublicIpOnLaunch: new(false), MapCustomerOwnedIpOnLaunch: new(false), State: types.SubnetStateAvailable, SubnetId: new("subnet-0450a637af9984235"), VpcId: new("vpc-0d7892e00e573e701"), OwnerId: new("052392120703"), AssignIpv6AddressOnCreation: new(false), Ipv6CidrBlockAssociationSet: []types.SubnetIpv6CidrBlockAssociation{ { AssociationId: new("id-1234"), Ipv6CidrBlock: new("something"), Ipv6CidrBlockState: &types.SubnetCidrBlockState{ State: types.SubnetCidrBlockStateCodeAssociated, StatusMessage: new("something here"), }, }, }, Tags: []types.Tag{}, SubnetArn: new("arn:aws:ec2:eu-west-2:052392120703:subnet/subnet-0450a637af9984235"), EnableDns64: new(false), Ipv6Native: new(false), PrivateDnsNameOptionsOnLaunch: &types.PrivateDnsNameOptionsOnLaunch{ HostnameType: types.HostnameTypeIpName, EnableResourceNameDnsARecord: new(false), EnableResourceNameDnsAAAARecord: new(false), }, }, }, } items, err := subnetOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEC2SubnetAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2SubnetAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route-table-association.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // APIs used: // - DescribeTransitGatewayRouteTables — list route tables (to then fetch associations per table). // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeTransitGatewayRouteTables.html // - GetTransitGatewayRouteTableAssociations — list associations for a route table. // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_GetTransitGatewayRouteTableAssociations.html // transitGatewayRouteTableAssociationItem holds an association plus its route table ID for unique identification. type transitGatewayRouteTableAssociationItem struct { RouteTableID string Association types.TransitGatewayRouteTableAssociation } const associationIDSep = "|" func transitGatewayRouteTableAssociationID(routeTableID, attachmentID string) string { return routeTableID + associationIDSep + attachmentID } // parseCompositeID splits query by the given separator; accepts both `|` and `_` (Terraform uses `_`). // Returns (left, right); empty left means invalid. func parseCompositeID(query, sep string) (string, string) { parts := strings.SplitN(query, sep, 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "" } return parts[0], parts[1] } func parseAssociationQuery(query string) (routeTableID, attachmentID string, err error) { if a, b := parseCompositeID(query, associationIDSep); a != "" { return a, b, nil } if a, b := parseCompositeID(query, "_"); a != "" { return a, b, nil } return "", "", fmt.Errorf("query must be TransitGatewayRouteTableId|TransitGatewayAttachmentId") } func getTransitGatewayRouteTableAssociation(ctx context.Context, client *ec2.Client, _, query string) (*transitGatewayRouteTableAssociationItem, error) { routeTableID, attachmentID, err := parseAssociationQuery(query) if err != nil { return nil, err } pg := ec2.NewGetTransitGatewayRouteTableAssociationsPaginator(client, &ec2.GetTransitGatewayRouteTableAssociationsInput{ TransitGatewayRouteTableId: &routeTableID, }) for pg.HasMorePages() { out, err := pg.NextPage(ctx) if err != nil { return nil, err } for i := range out.Associations { a := &out.Associations[i] if a.TransitGatewayAttachmentId != nil && *a.TransitGatewayAttachmentId == attachmentID { return &transitGatewayRouteTableAssociationItem{RouteTableID: routeTableID, Association: *a}, nil } } } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("association %s not found", query), } } func listTransitGatewayRouteTableAssociations(ctx context.Context, client *ec2.Client, _ string) ([]*transitGatewayRouteTableAssociationItem, error) { // List all route tables, then get associations for each. rtPaginator := ec2.NewDescribeTransitGatewayRouteTablesPaginator(client, &ec2.DescribeTransitGatewayRouteTablesInput{}) var items []*transitGatewayRouteTableAssociationItem for rtPaginator.HasMorePages() { rtOut, err := rtPaginator.NextPage(ctx) if err != nil { return nil, err } for _, rt := range rtOut.TransitGatewayRouteTables { if rt.TransitGatewayRouteTableId == nil { continue } rtID := *rt.TransitGatewayRouteTableId assocPaginator := ec2.NewGetTransitGatewayRouteTableAssociationsPaginator(client, &ec2.GetTransitGatewayRouteTableAssociationsInput{ TransitGatewayRouteTableId: &rtID, }) for assocPaginator.HasMorePages() { assocOut, err := assocPaginator.NextPage(ctx) if err != nil { return nil, err } for i := range assocOut.Associations { items = append(items, &transitGatewayRouteTableAssociationItem{ RouteTableID: rtID, Association: assocOut.Associations[i], }) } } } } return items, nil } // searchTransitGatewayRouteTableAssociations returns all associations for a single route table. // Query must be a TransitGatewayRouteTableId (e.g. tgw-rtb-xxxxx). func searchTransitGatewayRouteTableAssociations(ctx context.Context, client *ec2.Client, _, query string) ([]*transitGatewayRouteTableAssociationItem, error) { routeTableID := query var items []*transitGatewayRouteTableAssociationItem pg := ec2.NewGetTransitGatewayRouteTableAssociationsPaginator(client, &ec2.GetTransitGatewayRouteTableAssociationsInput{ TransitGatewayRouteTableId: &routeTableID, }) for pg.HasMorePages() { out, err := pg.NextPage(ctx) if err != nil { return nil, err } for i := range out.Associations { items = append(items, &transitGatewayRouteTableAssociationItem{ RouteTableID: routeTableID, Association: out.Associations[i], }) } } return items, nil } func transitGatewayRouteTableAssociationItemMapper(query, scope string, awsItem *transitGatewayRouteTableAssociationItem) (*sdp.Item, error) { a := &awsItem.Association attrs, err := ToAttributesWithExclude(a, "") if err != nil { return nil, err } attachmentID := "" if a.TransitGatewayAttachmentId != nil { attachmentID = *a.TransitGatewayAttachmentId } uniqueVal := transitGatewayRouteTableAssociationID(awsItem.RouteTableID, attachmentID) if err := attrs.Set("TransitGatewayRouteTableIdWithTransitGatewayAttachmentId", uniqueVal); err != nil { return nil, err } item := &sdp.Item{ Type: "ec2-transit-gateway-route-table-association", UniqueAttribute: "TransitGatewayRouteTableIdWithTransitGatewayAttachmentId", Scope: scope, Attributes: attrs, } // Link to route table item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table", Method: sdp.QueryMethod_GET, Query: awsItem.RouteTableID, Scope: scope, }, }) if a.TransitGatewayAttachmentId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-attachment", Method: sdp.QueryMethod_GET, Query: *a.TransitGatewayAttachmentId, Scope: scope, }, }) } if a.ResourceId != nil && *a.ResourceId != "" { switch a.ResourceType { case types.TransitGatewayAttachmentResourceTypeVpc: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *a.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeVpn: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpn-connection", Method: sdp.QueryMethod_GET, Query: *a.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeDirectConnectGateway: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway", Method: sdp.QueryMethod_GET, Query: *a.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypePeering, types.TransitGatewayAttachmentResourceTypeTgwPeering: // ResourceId is the peer transit gateway ID (e.g. tgw-xxxxx). item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_GET, Query: *a.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeVpnConcentrator, types.TransitGatewayAttachmentResourceTypeConnect, types.TransitGatewayAttachmentResourceTypeNetworkFunction, types.TransitGatewayAttachmentResourceTypeClientVpn: // No Overmind adapter for these resource types; attachment link above is sufficient. } } return item, nil } func NewEC2TransitGatewayRouteTableAssociationAdapter(client *ec2.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*transitGatewayRouteTableAssociationItem, *ec2.Client, *ec2.Options] { return &GetListAdapter[*transitGatewayRouteTableAssociationItem, *ec2.Client, *ec2.Options]{ ItemType: "ec2-transit-gateway-route-table-association", Client: client, AccountID: accountID, Region: region, AdapterMetadata: transitGatewayRouteTableAssociationAdapterMetadata, cache: cache, GetFunc: getTransitGatewayRouteTableAssociation, ListFunc: listTransitGatewayRouteTableAssociations, SearchFunc: searchTransitGatewayRouteTableAssociations, ItemMapper: transitGatewayRouteTableAssociationItemMapper, } } var transitGatewayRouteTableAssociationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-transit-gateway-route-table-association", DescriptiveName: "Transit Gateway Route Table Association", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get by TransitGatewayRouteTableId|TransitGatewayAttachmentId", ListDescription: "List all route table associations", SearchDescription: "Search by TransitGatewayRouteTableId to list associations for that route table", }, PotentialLinks: []string{"ec2-transit-gateway", "ec2-transit-gateway-route-table", "ec2-transit-gateway-attachment", "ec2-vpc", "ec2-vpn-connection", "directconnect-direct-connect-gateway"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ec2_transit_gateway_route_table_association.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route-table-association_test.go ================================================ package adapters import ( "testing" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestParseAssociationQuery(t *testing.T) { rt, att, err := parseAssociationQuery("tgw-rtb-1|tgw-attach-2") if err != nil { t.Fatal(err) } if rt != "tgw-rtb-1" || att != "tgw-attach-2" { t.Errorf("expected tgw-rtb-1, tgw-attach-2 got %q, %q", rt, att) } // Terraform uses underscore as separator rt, att, err = parseAssociationQuery("tgw-rtb-1_tgw-attach-2") if err != nil { t.Fatal(err) } if rt != "tgw-rtb-1" || att != "tgw-attach-2" { t.Errorf("expected tgw-rtb-1, tgw-attach-2 (underscore) got %q, %q", rt, att) } _, _, err = parseAssociationQuery("bad") if err == nil { t.Error("expected error for bad query") } } func TestTransitGatewayRouteTableAssociationItemMapper(t *testing.T) { item := &transitGatewayRouteTableAssociationItem{ RouteTableID: "tgw-rtb-123", Association: types.TransitGatewayRouteTableAssociation{ TransitGatewayAttachmentId: new("tgw-attach-456"), ResourceId: new("vpc-abc"), ResourceType: types.TransitGatewayAttachmentResourceTypeVpc, State: types.TransitGatewayAssociationStateAssociated, }, } sdpItem, err := transitGatewayRouteTableAssociationItemMapper("", "account|region", item) if err != nil { t.Fatal(err) } if err := sdpItem.Validate(); err != nil { t.Error(err) } if sdpItem.GetType() != "ec2-transit-gateway-route-table-association" { t.Errorf("unexpected type %s", sdpItem.GetType()) } uv, _ := sdpItem.GetAttributes().Get("TransitGatewayRouteTableIdWithTransitGatewayAttachmentId") if uv != "tgw-rtb-123|tgw-attach-456" { t.Errorf("unexpected unique value %v", uv) } } func TestNewEC2TransitGatewayRouteTableAssociationAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2TransitGatewayRouteTableAssociationAdapter(client, account, region, sdpcache.NewNoOpCache()) if err := adapter.Validate(); err != nil { t.Fatal(err) } } ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route-table-propagation.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // APIs used: // - DescribeTransitGatewayRouteTables — list route tables (to then fetch propagations per table). // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeTransitGatewayRouteTables.html // - GetTransitGatewayRouteTablePropagations — list propagations for a route table. // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_GetTransitGatewayRouteTablePropagations.html type transitGatewayRouteTablePropagationItem struct { RouteTableID string Propagation types.TransitGatewayRouteTablePropagation } const propagationIDSep = "|" func transitGatewayRouteTablePropagationID(routeTableID, attachmentID string) string { return routeTableID + propagationIDSep + attachmentID } func parsePropagationQuery(query string) (routeTableID, attachmentID string, err error) { if a, b := parseCompositeID(query, propagationIDSep); a != "" { return a, b, nil } if a, b := parseCompositeID(query, "_"); a != "" { return a, b, nil } return "", "", fmt.Errorf("query must be TransitGatewayRouteTableId|TransitGatewayAttachmentId") } func getTransitGatewayRouteTablePropagation(ctx context.Context, client *ec2.Client, _, query string) (*transitGatewayRouteTablePropagationItem, error) { routeTableID, attachmentID, err := parsePropagationQuery(query) if err != nil { return nil, err } pg := ec2.NewGetTransitGatewayRouteTablePropagationsPaginator(client, &ec2.GetTransitGatewayRouteTablePropagationsInput{ TransitGatewayRouteTableId: &routeTableID, }) for pg.HasMorePages() { out, err := pg.NextPage(ctx) if err != nil { return nil, err } for i := range out.TransitGatewayRouteTablePropagations { p := &out.TransitGatewayRouteTablePropagations[i] if p.TransitGatewayAttachmentId != nil && *p.TransitGatewayAttachmentId == attachmentID { return &transitGatewayRouteTablePropagationItem{RouteTableID: routeTableID, Propagation: *p}, nil } } } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("propagation %s not found", query), } } func listTransitGatewayRouteTablePropagations(ctx context.Context, client *ec2.Client, _ string) ([]*transitGatewayRouteTablePropagationItem, error) { rtPaginator := ec2.NewDescribeTransitGatewayRouteTablesPaginator(client, &ec2.DescribeTransitGatewayRouteTablesInput{}) var items []*transitGatewayRouteTablePropagationItem for rtPaginator.HasMorePages() { rtOut, err := rtPaginator.NextPage(ctx) if err != nil { return nil, err } for _, rt := range rtOut.TransitGatewayRouteTables { if rt.TransitGatewayRouteTableId == nil { continue } rtID := *rt.TransitGatewayRouteTableId propPaginator := ec2.NewGetTransitGatewayRouteTablePropagationsPaginator(client, &ec2.GetTransitGatewayRouteTablePropagationsInput{ TransitGatewayRouteTableId: &rtID, }) for propPaginator.HasMorePages() { propOut, err := propPaginator.NextPage(ctx) if err != nil { return nil, err } for i := range propOut.TransitGatewayRouteTablePropagations { items = append(items, &transitGatewayRouteTablePropagationItem{ RouteTableID: rtID, Propagation: propOut.TransitGatewayRouteTablePropagations[i], }) } } } } return items, nil } // searchTransitGatewayRouteTablePropagations returns all propagations for a single route table. // Query must be a TransitGatewayRouteTableId (e.g. tgw-rtb-xxxxx). func searchTransitGatewayRouteTablePropagations(ctx context.Context, client *ec2.Client, _, query string) ([]*transitGatewayRouteTablePropagationItem, error) { routeTableID := query var items []*transitGatewayRouteTablePropagationItem pg := ec2.NewGetTransitGatewayRouteTablePropagationsPaginator(client, &ec2.GetTransitGatewayRouteTablePropagationsInput{ TransitGatewayRouteTableId: &routeTableID, }) for pg.HasMorePages() { out, err := pg.NextPage(ctx) if err != nil { return nil, err } for i := range out.TransitGatewayRouteTablePropagations { items = append(items, &transitGatewayRouteTablePropagationItem{ RouteTableID: routeTableID, Propagation: out.TransitGatewayRouteTablePropagations[i], }) } } return items, nil } func transitGatewayRouteTablePropagationItemMapper(query, scope string, awsItem *transitGatewayRouteTablePropagationItem) (*sdp.Item, error) { p := &awsItem.Propagation attrs, err := ToAttributesWithExclude(p, "") if err != nil { return nil, err } attachmentID := "" if p.TransitGatewayAttachmentId != nil { attachmentID = *p.TransitGatewayAttachmentId } uniqueVal := transitGatewayRouteTablePropagationID(awsItem.RouteTableID, attachmentID) if err := attrs.Set("TransitGatewayRouteTableIdWithTransitGatewayAttachmentId", uniqueVal); err != nil { return nil, err } item := &sdp.Item{ Type: "ec2-transit-gateway-route-table-propagation", UniqueAttribute: "TransitGatewayRouteTableIdWithTransitGatewayAttachmentId", Scope: scope, Attributes: attrs, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table", Method: sdp.QueryMethod_GET, Query: awsItem.RouteTableID, Scope: scope, }, }) // Link to the route table association (same route table + attachment). if p.TransitGatewayAttachmentId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table-association", Method: sdp.QueryMethod_GET, Query: transitGatewayRouteTableAssociationID(awsItem.RouteTableID, *p.TransitGatewayAttachmentId), Scope: scope, }, }) } if p.TransitGatewayAttachmentId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-attachment", Method: sdp.QueryMethod_GET, Query: *p.TransitGatewayAttachmentId, Scope: scope, }, }) } if p.ResourceId != nil && *p.ResourceId != "" { switch p.ResourceType { case types.TransitGatewayAttachmentResourceTypeVpc: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *p.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeVpn: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpn-connection", Method: sdp.QueryMethod_GET, Query: *p.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeDirectConnectGateway: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway", Method: sdp.QueryMethod_GET, Query: *p.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypePeering, types.TransitGatewayAttachmentResourceTypeTgwPeering: // ResourceId is the peer transit gateway ID (e.g. tgw-xxxxx). item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_GET, Query: *p.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeVpnConcentrator, types.TransitGatewayAttachmentResourceTypeConnect, types.TransitGatewayAttachmentResourceTypeNetworkFunction, types.TransitGatewayAttachmentResourceTypeClientVpn: // No Overmind adapter for these resource types; attachment link above is sufficient. } } return item, nil } func NewEC2TransitGatewayRouteTablePropagationAdapter(client *ec2.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*transitGatewayRouteTablePropagationItem, *ec2.Client, *ec2.Options] { return &GetListAdapter[*transitGatewayRouteTablePropagationItem, *ec2.Client, *ec2.Options]{ ItemType: "ec2-transit-gateway-route-table-propagation", Client: client, AccountID: accountID, Region: region, AdapterMetadata: transitGatewayRouteTablePropagationAdapterMetadata, cache: cache, GetFunc: getTransitGatewayRouteTablePropagation, ListFunc: listTransitGatewayRouteTablePropagations, SearchFunc: searchTransitGatewayRouteTablePropagations, ItemMapper: transitGatewayRouteTablePropagationItemMapper, } } var transitGatewayRouteTablePropagationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-transit-gateway-route-table-propagation", DescriptiveName: "Transit Gateway Route Table Propagation", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get by TransitGatewayRouteTableId|TransitGatewayAttachmentId", ListDescription: "List all route table propagations", SearchDescription: "Search by TransitGatewayRouteTableId to list propagations for that route table", }, PotentialLinks: []string{"ec2-transit-gateway", "ec2-transit-gateway-route-table", "ec2-transit-gateway-route-table-association", "ec2-transit-gateway-attachment", "ec2-vpc", "ec2-vpn-connection", "directconnect-direct-connect-gateway"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ec2_transit_gateway_route_table_propagation.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route-table-propagation_test.go ================================================ package adapters import ( "testing" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestParsePropagationQuery(t *testing.T) { rt, att, err := parsePropagationQuery("tgw-rtb-1|tgw-attach-2") if err != nil { t.Fatal(err) } if rt != "tgw-rtb-1" || att != "tgw-attach-2" { t.Errorf("expected tgw-rtb-1, tgw-attach-2 got %q, %q", rt, att) } // Terraform uses underscore as separator rt, att, err = parsePropagationQuery("tgw-rtb-1_tgw-attach-2") if err != nil { t.Fatal(err) } if rt != "tgw-rtb-1" || att != "tgw-attach-2" { t.Errorf("expected tgw-rtb-1, tgw-attach-2 (underscore) got %q, %q", rt, att) } _, _, err = parsePropagationQuery("bad") if err == nil { t.Error("expected error for bad query") } } func TestTransitGatewayRouteTablePropagationItemMapper(t *testing.T) { item := &transitGatewayRouteTablePropagationItem{ RouteTableID: "tgw-rtb-123", Propagation: types.TransitGatewayRouteTablePropagation{ TransitGatewayAttachmentId: new("tgw-attach-456"), ResourceId: new("vpc-abc"), ResourceType: types.TransitGatewayAttachmentResourceTypeVpc, State: types.TransitGatewayPropagationStateEnabled, }, } sdpItem, err := transitGatewayRouteTablePropagationItemMapper("", "account|region", item) if err != nil { t.Fatal(err) } if err := sdpItem.Validate(); err != nil { t.Error(err) } if sdpItem.GetType() != "ec2-transit-gateway-route-table-propagation" { t.Errorf("unexpected type %s", sdpItem.GetType()) } } func TestNewEC2TransitGatewayRouteTablePropagationAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2TransitGatewayRouteTablePropagationAdapter(client, account, region, sdpcache.NewNoOpCache()) if err := adapter.Validate(); err != nil { t.Fatal(err) } } ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route-table.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // APIs used: // - DescribeTransitGatewayRouteTables — list/describe transit gateway route tables. // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeTransitGatewayRouteTables.html func transitGatewayRouteTableInputMapperGet(scope string, query string) (*ec2.DescribeTransitGatewayRouteTablesInput, error) { return &ec2.DescribeTransitGatewayRouteTablesInput{ TransitGatewayRouteTableIds: []string{ query, }, }, nil } func transitGatewayRouteTableInputMapperList(scope string) (*ec2.DescribeTransitGatewayRouteTablesInput, error) { return &ec2.DescribeTransitGatewayRouteTablesInput{}, nil } func transitGatewayRouteTableOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeTransitGatewayRouteTablesInput, output *ec2.DescribeTransitGatewayRouteTablesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, rt := range output.TransitGatewayRouteTables { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(rt, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-transit-gateway-route-table", UniqueAttribute: "TransitGatewayRouteTableId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(rt.Tags), } if rt.TransitGatewayId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_GET, Query: *rt.TransitGatewayId, Scope: scope, }, }) } // Link to route table associations, propagations, and routes (Search by route table ID). if rt.TransitGatewayRouteTableId != nil { rtID := *rt.TransitGatewayRouteTableId for _, linkType := range []string{"ec2-transit-gateway-route-table-association", "ec2-transit-gateway-route-table-propagation", "ec2-transit-gateway-route"} { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: linkType, Method: sdp.QueryMethod_SEARCH, Query: rtID, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2TransitGatewayRouteTableAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeTransitGatewayRouteTablesInput, *ec2.DescribeTransitGatewayRouteTablesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeTransitGatewayRouteTablesInput, *ec2.DescribeTransitGatewayRouteTablesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-transit-gateway-route-table", AdapterMetadata: transitGatewayRouteTableAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeTransitGatewayRouteTablesInput) (*ec2.DescribeTransitGatewayRouteTablesOutput, error) { return client.DescribeTransitGatewayRouteTables(ctx, input) }, InputMapperGet: transitGatewayRouteTableInputMapperGet, InputMapperList: transitGatewayRouteTableInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeTransitGatewayRouteTablesInput) Paginator[*ec2.DescribeTransitGatewayRouteTablesOutput, *ec2.Options] { return ec2.NewDescribeTransitGatewayRouteTablesPaginator(client, params) }, OutputMapper: transitGatewayRouteTableOutputMapper, } } var transitGatewayRouteTableAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-transit-gateway-route-table", DescriptiveName: "Transit Gateway Route Table", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a transit gateway route table by ID", ListDescription: "List all transit gateway route tables", SearchDescription: "Search transit gateway route tables by ARN", }, PotentialLinks: []string{"ec2-transit-gateway", "ec2-transit-gateway-route-table-association", "ec2-transit-gateway-route-table-propagation", "ec2-transit-gateway-route"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ec2_transit_gateway_route_table.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route-table_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestTransitGatewayRouteTableInputMapperGet(t *testing.T) { input, err := transitGatewayRouteTableInputMapperGet("foo", "tgw-rtb-123") if err != nil { t.Error(err) } if len(input.TransitGatewayRouteTableIds) != 1 { t.Fatalf("expected 1 TransitGatewayRouteTable ID, got %v", len(input.TransitGatewayRouteTableIds)) } if input.TransitGatewayRouteTableIds[0] != "tgw-rtb-123" { t.Errorf("expected TransitGatewayRouteTable ID to be tgw-rtb-123, got %v", input.TransitGatewayRouteTableIds[0]) } } func TestTransitGatewayRouteTableInputMapperList(t *testing.T) { input, err := transitGatewayRouteTableInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.TransitGatewayRouteTableIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestTransitGatewayRouteTableOutputMapper(t *testing.T) { output := &ec2.DescribeTransitGatewayRouteTablesOutput{ TransitGatewayRouteTables: []types.TransitGatewayRouteTable{ { TransitGatewayRouteTableId: new("tgw-rtb-0123456789abcdef0"), TransitGatewayId: new("tgw-0abc123"), State: types.TransitGatewayRouteTableStateAvailable, DefaultAssociationRouteTable: new(false), DefaultPropagationRouteTable: new(false), Tags: []types.Tag{ {Key: new("Name"), Value: new("my-route-table")}, }, }, }, } items, err := transitGatewayRouteTableOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } if items[0].GetUniqueAttribute() != "TransitGatewayRouteTableId" { t.Errorf("expected UniqueAttribute TransitGatewayRouteTableId, got %v", items[0].GetUniqueAttribute()) } // Should link to ec2-transit-gateway and to associations, propagations, routes (Search by route table ID) links := items[0].GetLinkedItemQueries() if len(links) != 4 { t.Fatalf("expected 4 linked item queries (ec2-transit-gateway + 3 Search), got %v", len(links)) } if links[0].GetQuery().GetType() != "ec2-transit-gateway" { t.Errorf("expected first link type ec2-transit-gateway, got %v", links[0].GetQuery().GetType()) } searchTypes := map[string]bool{} for _, l := range links[1:] { if l.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("expected Search method for link %s", l.GetQuery().GetType()) } searchTypes[l.GetQuery().GetType()] = true } for _, want := range []string{"ec2-transit-gateway-route-table-association", "ec2-transit-gateway-route-table-propagation", "ec2-transit-gateway-route"} { if !searchTypes[want] { t.Errorf("expected Search link to %s", want) } } } func TestNewEC2TransitGatewayRouteTableAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2TransitGatewayRouteTableAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // APIs used: // - DescribeTransitGatewayRouteTables — list route tables (to then search routes per table). // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeTransitGatewayRouteTables.html // - SearchTransitGatewayRoutes — search routes in a route table. // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_SearchTransitGatewayRoutes.html // // Note: SearchTransitGatewayRoutes does not support NextToken-based pagination. It returns // at most 1000 routes per call; AdditionalRoutesAvailable indicates more exist but there is // no API mechanism to fetch them (route tables can hold up to 10,000 routes). type transitGatewayRouteItem struct { RouteTableID string Route types.TransitGatewayRoute } const routeIDSep = "|" const routeDestPrefixList = "pl:" func transitGatewayRouteDestination(r *types.TransitGatewayRoute) string { if r.PrefixListId != nil && *r.PrefixListId != "" { return routeDestPrefixList + *r.PrefixListId } if r.DestinationCidrBlock != nil { return *r.DestinationCidrBlock } return "" } func transitGatewayRouteID(routeTableID, destination string) string { return routeTableID + routeIDSep + destination } func parseRouteQuery(query string) (routeTableID, destination string, err error) { if a, b := parseCompositeID(query, routeIDSep); a != "" { return a, b, nil } if a, b := parseCompositeID(query, "_"); a != "" { return a, b, nil } return "", "", fmt.Errorf("query must be TransitGatewayRouteTableId|Destination (CIDR or pl:PrefixListId)") } // searchRoutesFilter returns a filter that returns all routes (active and blackhole). func searchRoutesFilter() []types.Filter { return []types.Filter{ {Name: new("state"), Values: []string{"active", "blackhole"}}, } } // maxSearchRoutesResults is the maximum routes SearchTransitGatewayRoutes returns per call. // The API does not support NextToken pagination when AdditionalRoutesAvailable is true. const maxSearchRoutesResults = 1000 func getTransitGatewayRoute(ctx context.Context, client *ec2.Client, _, query string) (*transitGatewayRouteItem, error) { routeTableID, destination, err := parseRouteQuery(query) if err != nil { return nil, err } out, err := client.SearchTransitGatewayRoutes(ctx, &ec2.SearchTransitGatewayRoutesInput{ TransitGatewayRouteTableId: &routeTableID, Filters: searchRoutesFilter(), MaxResults: new(int32(maxSearchRoutesResults)), }) if err != nil { return nil, err } for i := range out.Routes { r := &out.Routes[i] if transitGatewayRouteDestination(r) == destination { return &transitGatewayRouteItem{RouteTableID: routeTableID, Route: *r}, nil } } errStr := fmt.Sprintf("route %s not found", query) if out.AdditionalRoutesAvailable != nil && *out.AdditionalRoutesAvailable { errStr = fmt.Sprintf("route %s not found in first %d routes; route table has additional routes that cannot be retrieved via this API", query, maxSearchRoutesResults) } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: errStr, } } func listTransitGatewayRoutes(ctx context.Context, client *ec2.Client, _ string) ([]*transitGatewayRouteItem, error) { rtPaginator := ec2.NewDescribeTransitGatewayRouteTablesPaginator(client, &ec2.DescribeTransitGatewayRouteTablesInput{}) var items []*transitGatewayRouteItem for rtPaginator.HasMorePages() { rtOut, err := rtPaginator.NextPage(ctx) if err != nil { return nil, err } for _, rt := range rtOut.TransitGatewayRouteTables { if rt.TransitGatewayRouteTableId == nil { continue } rtID := *rt.TransitGatewayRouteTableId // Single call per route table: SearchTransitGatewayRoutes returns at most 1000 routes // and does not support NextToken pagination; AdditionalRoutesAvailable means more // exist but cannot be fetched via this API. routeOut, err := client.SearchTransitGatewayRoutes(ctx, &ec2.SearchTransitGatewayRoutesInput{ TransitGatewayRouteTableId: &rtID, Filters: searchRoutesFilter(), MaxResults: new(int32(maxSearchRoutesResults)), }) if err != nil { return nil, err } for i := range routeOut.Routes { items = append(items, &transitGatewayRouteItem{ RouteTableID: rtID, Route: routeOut.Routes[i], }) } } } return items, nil } // searchTransitGatewayRoutes returns all routes for a single route table. // Query must be a TransitGatewayRouteTableId (e.g. tgw-rtb-xxxxx). func searchTransitGatewayRoutes(ctx context.Context, client *ec2.Client, _, query string) ([]*transitGatewayRouteItem, error) { routeTableID := query routeOut, err := client.SearchTransitGatewayRoutes(ctx, &ec2.SearchTransitGatewayRoutesInput{ TransitGatewayRouteTableId: &routeTableID, Filters: searchRoutesFilter(), MaxResults: new(int32(maxSearchRoutesResults)), }) if err != nil { return nil, err } items := make([]*transitGatewayRouteItem, 0, len(routeOut.Routes)) for i := range routeOut.Routes { items = append(items, &transitGatewayRouteItem{ RouteTableID: routeTableID, Route: routeOut.Routes[i], }) } return items, nil } func transitGatewayRouteItemMapper(query, scope string, awsItem *transitGatewayRouteItem) (*sdp.Item, error) { r := &awsItem.Route attrs, err := ToAttributesWithExclude(r, "") if err != nil { return nil, err } dest := transitGatewayRouteDestination(r) uniqueVal := transitGatewayRouteID(awsItem.RouteTableID, dest) if err := attrs.Set("TransitGatewayRouteTableIdWithDestination", uniqueVal); err != nil { return nil, err } item := &sdp.Item{ Type: "ec2-transit-gateway-route", UniqueAttribute: "TransitGatewayRouteTableIdWithDestination", Scope: scope, Attributes: attrs, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table", Method: sdp.QueryMethod_GET, Query: awsItem.RouteTableID, Scope: scope, }, }) for i := range r.TransitGatewayAttachments { att := &r.TransitGatewayAttachments[i] if att.TransitGatewayAttachmentId != nil && *att.TransitGatewayAttachmentId != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-attachment", Method: sdp.QueryMethod_GET, Query: *att.TransitGatewayAttachmentId, Scope: scope, }, }) // Link to the route table association (same route table + attachment). item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table-association", Method: sdp.QueryMethod_GET, Query: transitGatewayRouteTableAssociationID(awsItem.RouteTableID, *att.TransitGatewayAttachmentId), Scope: scope, }, }) } if att.ResourceId != nil && *att.ResourceId != "" { switch att.ResourceType { case types.TransitGatewayAttachmentResourceTypeVpc: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *att.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeVpn: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpn-connection", Method: sdp.QueryMethod_GET, Query: *att.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeDirectConnectGateway: item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway", Method: sdp.QueryMethod_GET, Query: *att.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypePeering, types.TransitGatewayAttachmentResourceTypeTgwPeering: // ResourceId is the peer transit gateway ID (e.g. tgw-xxxxx). item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_GET, Query: *att.ResourceId, Scope: scope, }, }) case types.TransitGatewayAttachmentResourceTypeVpnConcentrator, types.TransitGatewayAttachmentResourceTypeConnect, types.TransitGatewayAttachmentResourceTypeNetworkFunction, types.TransitGatewayAttachmentResourceTypeClientVpn: // No Overmind adapter for these; attachment link above is sufficient. } } } if r.PrefixListId != nil && *r.PrefixListId != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-managed-prefix-list", Method: sdp.QueryMethod_GET, Query: *r.PrefixListId, Scope: scope, }, }) } if r.TransitGatewayRouteTableAnnouncementId != nil && *r.TransitGatewayRouteTableAnnouncementId != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table-announcement", Method: sdp.QueryMethod_GET, Query: *r.TransitGatewayRouteTableAnnouncementId, Scope: scope, }, }) } return item, nil } func NewEC2TransitGatewayRouteAdapter(client *ec2.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*transitGatewayRouteItem, *ec2.Client, *ec2.Options] { return &GetListAdapter[*transitGatewayRouteItem, *ec2.Client, *ec2.Options]{ ItemType: "ec2-transit-gateway-route", Client: client, AccountID: accountID, Region: region, AdapterMetadata: transitGatewayRouteAdapterMetadata, cache: cache, GetFunc: getTransitGatewayRoute, ListFunc: listTransitGatewayRoutes, SearchFunc: searchTransitGatewayRoutes, ItemMapper: transitGatewayRouteItemMapper, } } var transitGatewayRouteAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-transit-gateway-route", DescriptiveName: "Transit Gateway Route", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get by TransitGatewayRouteTableId|Destination (CIDR or pl:PrefixListId)", ListDescription: "List all transit gateway routes", SearchDescription: "Search by TransitGatewayRouteTableId to list routes for that route table", }, PotentialLinks: []string{"ec2-transit-gateway", "ec2-transit-gateway-route-table", "ec2-transit-gateway-route-table-association", "ec2-transit-gateway-attachment", "ec2-transit-gateway-route-table-announcement", "ec2-vpc", "ec2-vpn-connection", "ec2-managed-prefix-list", "directconnect-direct-connect-gateway"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ec2_transit_gateway_route.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-transit-gateway-route_test.go ================================================ package adapters import ( "testing" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestTransitGatewayRouteDestination(t *testing.T) { if transitGatewayRouteDestination(&types.TransitGatewayRoute{DestinationCidrBlock: new("10.0.0.0/16")}) != "10.0.0.0/16" { t.Error("expected CIDR destination") } if transitGatewayRouteDestination(&types.TransitGatewayRoute{PrefixListId: new("pl-123")}) != "pl:pl-123" { t.Error("expected prefix list destination") } } func TestParseRouteQuery(t *testing.T) { rt, dest, err := parseRouteQuery("tgw-rtb-1|10.0.0.0/16") if err != nil { t.Fatal(err) } if rt != "tgw-rtb-1" || dest != "10.0.0.0/16" { t.Errorf("expected tgw-rtb-1, 10.0.0.0/16 got %q, %q", rt, dest) } // Terraform uses underscore as separator rt, dest, err = parseRouteQuery("tgw-rtb-1_10.0.0.0/16") if err != nil { t.Fatal(err) } if rt != "tgw-rtb-1" || dest != "10.0.0.0/16" { t.Errorf("expected tgw-rtb-1, 10.0.0.0/16 (underscore) got %q, %q", rt, dest) } _, _, err = parseRouteQuery("bad") if err == nil { t.Error("expected error for bad query") } } func TestTransitGatewayRouteItemMapper(t *testing.T) { item := &transitGatewayRouteItem{ RouteTableID: "tgw-rtb-123", Route: types.TransitGatewayRoute{ DestinationCidrBlock: new("10.0.0.0/16"), State: types.TransitGatewayRouteStateActive, Type: types.TransitGatewayRouteTypeStatic, }, } sdpItem, err := transitGatewayRouteItemMapper("", "account|region", item) if err != nil { t.Fatal(err) } if err := sdpItem.Validate(); err != nil { t.Error(err) } if sdpItem.GetType() != "ec2-transit-gateway-route" { t.Errorf("unexpected type %s", sdpItem.GetType()) } } func TestNewEC2TransitGatewayRouteAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2TransitGatewayRouteAdapter(client, account, region, sdpcache.NewNoOpCache()) if err := adapter.Validate(); err != nil { t.Fatal(err) } } ================================================ FILE: aws-source/adapters/ec2-volume-status.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func volumeStatusInputMapperGet(scope string, query string) (*ec2.DescribeVolumeStatusInput, error) { return &ec2.DescribeVolumeStatusInput{ VolumeIds: []string{ query, }, }, nil } func volumeStatusInputMapperList(scope string) (*ec2.DescribeVolumeStatusInput, error) { return &ec2.DescribeVolumeStatusInput{}, nil } func volumeStatusOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeVolumeStatusInput, output *ec2.DescribeVolumeStatusOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, volume := range output.VolumeStatuses { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(volume) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-volume-status", UniqueAttribute: "VolumeId", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ // Always get the volume Type: "ec2-volume", Method: sdp.QueryMethod_GET, Query: *volume.VolumeId, Scope: scope, }, }, }, } if volume.VolumeStatus != nil { switch volume.VolumeStatus.Status { case types.VolumeStatusInfoStatusImpaired: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.VolumeStatusInfoStatusOk: item.Health = sdp.Health_HEALTH_OK.Enum() case types.VolumeStatusInfoStatusInsufficientData: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.VolumeStatusInfoStatusWarning: item.Health = sdp.Health_HEALTH_WARNING.Enum() } } for _, event := range volume.Events { if event.InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *event.InstanceId, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewEC2VolumeStatusAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeVolumeStatusInput, *ec2.DescribeVolumeStatusOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeVolumeStatusInput, *ec2.DescribeVolumeStatusOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-volume-status", AdapterMetadata: volumeStatusAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeVolumeStatusInput) (*ec2.DescribeVolumeStatusOutput, error) { return client.DescribeVolumeStatus(ctx, input) }, InputMapperGet: volumeStatusInputMapperGet, InputMapperList: volumeStatusInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeVolumeStatusInput) Paginator[*ec2.DescribeVolumeStatusOutput, *ec2.Options] { return ec2.NewDescribeVolumeStatusPaginator(client, params) }, OutputMapper: volumeStatusOutputMapper, } } var volumeStatusAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-volume-status", DescriptiveName: "EC2 Volume Status", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a volume status by volume ID", ListDescription: "List all volume statuses", SearchDescription: "Search for volume statuses by ARN", }, PotentialLinks: []string{"ec2-instance"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, }) ================================================ FILE: aws-source/adapters/ec2-volume-status_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestVolumeStatusInputMapperGet(t *testing.T) { input, err := volumeStatusInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.VolumeIds) != 1 { t.Fatalf("expected 1 Volume ID, got %v", len(input.VolumeIds)) } if input.VolumeIds[0] != "bar" { t.Errorf("expected Volume ID to be bar, got %v", input.VolumeIds[0]) } } func TestVolumeStatusInputMapperList(t *testing.T) { input, err := volumeStatusInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.VolumeIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestVolumeStatusOutputMapper(t *testing.T) { output := &ec2.DescribeVolumeStatusOutput{ VolumeStatuses: []types.VolumeStatusItem{ { Actions: []types.VolumeStatusAction{ { Code: new("enable-volume-io"), Description: new("Enable volume I/O"), EventId: new("12"), EventType: new("io-enabled"), }, }, AvailabilityZone: new("eu-west-2c"), Events: []types.VolumeStatusEvent{ { Description: new("The volume is operating normally"), EventId: new("12"), EventType: new("io-enabled"), InstanceId: new("i-0667d3ca802741e30"), // link NotAfter: new(time.Now()), NotBefore: new(time.Now()), }, }, VolumeId: new("vol-0a38796ac85e21c11"), // link VolumeStatus: &types.VolumeStatusInfo{ Details: []types.VolumeStatusDetails{ { Name: types.VolumeStatusNameIoEnabled, Status: new("passed"), }, { Name: types.VolumeStatusNameIoPerformance, Status: new("not-applicable"), }, }, Status: types.VolumeStatusInfoStatusOk, }, }, }, } items, err := volumeStatusOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-0667d3ca802741e30", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-volume", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vol-0a38796ac85e21c11", ExpectedScope: item.GetScope(), }, } tests.Execute(t, item) } func TestNewEC2VolumeStatusAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2VolumeAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-volume.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func volumeInputMapperGet(scope string, query string) (*ec2.DescribeVolumesInput, error) { return &ec2.DescribeVolumesInput{ VolumeIds: []string{ query, }, }, nil } func volumeInputMapperList(scope string) (*ec2.DescribeVolumesInput, error) { return &ec2.DescribeVolumesInput{}, nil } func volumeOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeVolumesInput, output *ec2.DescribeVolumesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, volume := range output.Volumes { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(volume, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-volume", UniqueAttribute: "VolumeId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(volume.Tags), } for _, attachment := range volume.Attachments { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *attachment.InstanceId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2VolumeAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeVolumesInput, *ec2.DescribeVolumesOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeVolumesInput, *ec2.DescribeVolumesOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-volume", AdapterMetadata: volumeAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeVolumesInput) (*ec2.DescribeVolumesOutput, error) { return client.DescribeVolumes(ctx, input) }, InputMapperGet: volumeInputMapperGet, InputMapperList: volumeInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeVolumesInput) Paginator[*ec2.DescribeVolumesOutput, *ec2.Options] { return ec2.NewDescribeVolumesPaginator(client, params) }, OutputMapper: volumeOutputMapper, } } var volumeAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-volume", DescriptiveName: "EC2 Volume", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a volume by ID", ListDescription: "List all volumes", SearchDescription: "Search volumes by ARN", }, PotentialLinks: []string{"ec2-instance"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ebs_volume.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) ================================================ FILE: aws-source/adapters/ec2-volume_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestVolumeInputMapperGet(t *testing.T) { input, err := volumeInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.VolumeIds) != 1 { t.Fatalf("expected 1 Volume ID, got %v", len(input.VolumeIds)) } if input.VolumeIds[0] != "bar" { t.Errorf("expected Volume ID to be bar, got %v", input.VolumeIds[0]) } } func TestVolumeInputMapperList(t *testing.T) { input, err := volumeInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.VolumeIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestVolumeOutputMapper(t *testing.T) { output := &ec2.DescribeVolumesOutput{ Volumes: []types.Volume{ { Attachments: []types.VolumeAttachment{ { AttachTime: new(time.Now()), Device: new("/dev/sdb"), InstanceId: new("i-0667d3ca802741e30"), State: types.VolumeAttachmentStateAttaching, VolumeId: new("vol-0eae6976b359d8825"), DeleteOnTermination: new(false), }, }, AvailabilityZone: new("eu-west-2c"), CreateTime: new(time.Now()), Encrypted: new(false), Size: new(int32(8)), State: types.VolumeStateInUse, VolumeId: new("vol-0eae6976b359d8825"), Iops: new(int32(3000)), VolumeType: types.VolumeTypeGp3, MultiAttachEnabled: new(false), Throughput: new(int32(125)), }, }, } items, err := volumeOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-0667d3ca802741e30", ExpectedScope: item.GetScope(), }, } tests.Execute(t, item) } func TestNewEC2VolumeAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2VolumeAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-vpc-endpoint.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func vpcEndpointInputMapperGet(scope string, query string) (*ec2.DescribeVpcEndpointsInput, error) { return &ec2.DescribeVpcEndpointsInput{ VpcEndpointIds: []string{ query, }, }, nil } func vpcEndpointInputMapperList(scope string) (*ec2.DescribeVpcEndpointsInput, error) { return &ec2.DescribeVpcEndpointsInput{}, nil } func vpcEndpointOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeVpcEndpointsInput, output *ec2.DescribeVpcEndpointsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, endpoint := range output.VpcEndpoints { var err error var attrs *sdp.ItemAttributes // A type that we use to override the PolicyDocument with the parsed // version type endpointParsedPolicy struct { types.VpcEndpoint PolicyDocument *policy.Policy } endpointWithPolicy := endpointParsedPolicy{ VpcEndpoint: endpoint, } // Parse the policy if endpoint.PolicyDocument != nil { parsedPolicy, _ := ParsePolicyDocument(*endpoint.PolicyDocument) endpointWithPolicy.PolicyDocument = parsedPolicy } attrs, err = ToAttributesWithExclude(endpointWithPolicy, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-vpc-endpoint", UniqueAttribute: "VpcEndpointId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(endpoint.Tags), } // Annoyingly the API doesn't follow its own specification here and // returns values in lowercase -_- state := strings.ToLower(string(endpoint.State)) switch state { case strings.ToLower(string(types.StatePendingAcceptance)): item.Health = sdp.Health_HEALTH_PENDING.Enum() case strings.ToLower(string(types.StatePending)): item.Health = sdp.Health_HEALTH_PENDING.Enum() case strings.ToLower(string(types.StateAvailable)): item.Health = sdp.Health_HEALTH_OK.Enum() case strings.ToLower(string(types.StateDeleting)): item.Health = sdp.Health_HEALTH_PENDING.Enum() case strings.ToLower(string(types.StateDeleted)): item.Health = sdp.Health_HEALTH_OK.Enum() case strings.ToLower(string(types.StateRejected)): item.Health = sdp.Health_HEALTH_ERROR.Enum() case strings.ToLower(string(types.StateFailed)): item.Health = sdp.Health_HEALTH_ERROR.Enum() case strings.ToLower(string(types.StateExpired)): item.Health = sdp.Health_HEALTH_ERROR.Enum() } if endpoint.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *endpoint.VpcId, Scope: scope, }, }) } if endpointWithPolicy.PolicyDocument != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, LinksFromPolicy(endpointWithPolicy.PolicyDocument)...) } for _, routeTableID := range endpoint.RouteTableIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-route-table", Method: sdp.QueryMethod_GET, Query: routeTableID, Scope: scope, }, }) } for _, subnetID := range endpoint.SubnetIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: subnetID, Scope: scope, }, }) } for _, group := range endpoint.Groups { if group.GroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *group.GroupId, Scope: scope, }, }) } } for _, dnsEntry := range endpoint.DnsEntries { if dnsEntry.DnsName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *dnsEntry.DnsName, Scope: "global", }, }) } if dnsEntry.HostedZoneId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *dnsEntry.HostedZoneId, Scope: scope, }, }) } } for _, networkInterfaceID := range endpoint.NetworkInterfaceIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: networkInterfaceID, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEC2VpcEndpointAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeVpcEndpointsInput, *ec2.DescribeVpcEndpointsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeVpcEndpointsInput, *ec2.DescribeVpcEndpointsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-vpc-endpoint", AdapterMetadata: vpcEndpointAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeVpcEndpointsInput) (*ec2.DescribeVpcEndpointsOutput, error) { return client.DescribeVpcEndpoints(ctx, input) }, InputMapperGet: vpcEndpointInputMapperGet, InputMapperList: vpcEndpointInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeVpcEndpointsInput) Paginator[*ec2.DescribeVpcEndpointsOutput, *ec2.Options] { return ec2.NewDescribeVpcEndpointsPaginator(client, params) }, OutputMapper: vpcEndpointOutputMapper, } } var vpcEndpointAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-vpc-endpoint", DescriptiveName: "VPC Endpoint", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a VPC Endpoint by ID", ListDescription: "List all VPC Endpoints", SearchDescription: "Search VPC Endpoints by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_vpc_endpoint.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-vpc-endpoint_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestVpcEndpointInputMapperGet(t *testing.T) { input, err := vpcEndpointInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.VpcEndpointIds) != 1 { t.Fatalf("expected 1 VpcEndpoint ID, got %v", len(input.VpcEndpointIds)) } if input.VpcEndpointIds[0] != "bar" { t.Errorf("expected VpcEndpoint ID to be bar, got %v", input.VpcEndpointIds[0]) } } func TestVpcEndpointOutputMapper(t *testing.T) { output := &ec2.DescribeVpcEndpointsOutput{ VpcEndpoints: []types.VpcEndpoint{ { VpcEndpointId: new("vpce-0d7892e00e573e701"), VpcEndpointType: types.VpcEndpointTypeInterface, CreationTimestamp: new(time.Now()), VpcId: new("vpc-0d7892e00e573e701"), // link ServiceName: new("com.amazonaws.us-east-1.s3"), State: types.StateAvailable, PolicyDocument: new("{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"*\",\"Resource\":\"*\",\"Effect\":\"Allow\",\"Principal\":\"*\"},{\"Condition\":{\"StringNotEquals\":{\"aws:PrincipalAccount\":\"944651592624\"}},\"Action\":\"*\",\"Resource\":\"*\",\"Effect\":\"Deny\",\"Principal\":\"*\"}]}"), // parse this RouteTableIds: []string{ "rtb-0d7892e00e573e701", // link }, SubnetIds: []string{ "subnet-0d7892e00e573e701", // link }, Groups: []types.SecurityGroupIdentifier{ { GroupId: new("sg-0d7892e00e573e701"), // link GroupName: new("default"), }, }, IpAddressType: types.IpAddressTypeIpv4, PrivateDnsEnabled: new(true), RequesterManaged: new(false), DnsEntries: []types.DnsEntry{ { DnsName: new("vpce-0d7892e00e573e701-123456789012.us-east-1.vpce.amazonaws.com"), // link HostedZoneId: new("Z2F56UZL2M1ACD"), // link }, }, DnsOptions: &types.DnsOptions{ DnsRecordIpType: types.DnsRecordIpTypeDualstack, PrivateDnsOnlyForInboundResolverEndpoint: new(false), }, LastError: &types.LastError{ Code: new("Client::ValidationException"), Message: new("The security group 'sg-0d7892e00e573e701' does not exist"), }, NetworkInterfaceIds: []string{ "eni-0d7892e00e573e701", // link }, OwnerId: new("052392120703"), Tags: []types.Tag{ { Key: new("Name"), Value: new("my-vpce"), }, }, }, }, } items, err := vpcEndpointOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "ec2-route-table", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rtb-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "vpce-0d7892e00e573e701-123456789012.us-east-1.vpce.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "Z2F56UZL2M1ACD", ExpectedScope: "foo", }, { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "eni-0d7892e00e573e701", ExpectedScope: "foo", }, } tests.Execute(t, items[0]) } func TestNewEC2VpcEndpointAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2VpcEndpointAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-vpc-peering-connection.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func vpcPeeringConnectionOutputMapper(_ context.Context, _ *ec2.Client, scope string, input *ec2.DescribeVpcPeeringConnectionsInput, output *ec2.DescribeVpcPeeringConnectionsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, connection := range output.VpcPeeringConnections { attributes, err := ToAttributesWithExclude(connection, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "ec2-vpc-peering-connection", UniqueAttribute: "VpcPeeringConnectionId", Scope: scope, Attributes: attributes, Tags: ec2TagsToMap(connection.Tags), } if connection.Status != nil { switch connection.Status.Code { case types.VpcPeeringConnectionStateReasonCodeInitiatingRequest: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.VpcPeeringConnectionStateReasonCodePendingAcceptance: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.VpcPeeringConnectionStateReasonCodeActive: item.Health = sdp.Health_HEALTH_OK.Enum() case types.VpcPeeringConnectionStateReasonCodeDeleted: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.VpcPeeringConnectionStateReasonCodeRejected: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.VpcPeeringConnectionStateReasonCodeFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.VpcPeeringConnectionStateReasonCodeExpired: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.VpcPeeringConnectionStateReasonCodeProvisioning: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.VpcPeeringConnectionStateReasonCodeDeleting: item.Health = sdp.Health_HEALTH_WARNING.Enum() } } if connection.AccepterVpcInfo != nil { if connection.AccepterVpcInfo.Region != nil { if connection.AccepterVpcInfo.VpcId != nil && connection.AccepterVpcInfo.OwnerId != nil { pairedScope := FormatScope(*connection.AccepterVpcInfo.OwnerId, *connection.AccepterVpcInfo.Region) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *connection.AccepterVpcInfo.VpcId, Scope: pairedScope, }, }) } } } if connection.RequesterVpcInfo != nil { if connection.RequesterVpcInfo.Region != nil { if connection.RequesterVpcInfo.VpcId != nil && connection.RequesterVpcInfo.OwnerId != nil { pairedScope := FormatScope(*connection.RequesterVpcInfo.OwnerId, *connection.RequesterVpcInfo.Region) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *connection.RequesterVpcInfo.VpcId, Scope: pairedScope, }, }) } } } items = append(items, &item) } return items, nil } func NewEC2VpcPeeringConnectionAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeVpcPeeringConnectionsInput, *ec2.DescribeVpcPeeringConnectionsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeVpcPeeringConnectionsInput, *ec2.DescribeVpcPeeringConnectionsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-vpc-peering-connection", AdapterMetadata: vpcPeeringConnectionAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeVpcPeeringConnectionsInput) (*ec2.DescribeVpcPeeringConnectionsOutput, error) { return client.DescribeVpcPeeringConnections(ctx, input) }, InputMapperGet: func(scope, query string) (*ec2.DescribeVpcPeeringConnectionsInput, error) { return &ec2.DescribeVpcPeeringConnectionsInput{ VpcPeeringConnectionIds: []string{query}, }, nil }, InputMapperList: func(scope string) (*ec2.DescribeVpcPeeringConnectionsInput, error) { return &ec2.DescribeVpcPeeringConnectionsInput{}, nil }, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeVpcPeeringConnectionsInput) Paginator[*ec2.DescribeVpcPeeringConnectionsOutput, *ec2.Options] { return ec2.NewDescribeVpcPeeringConnectionsPaginator(client, params) }, OutputMapper: vpcPeeringConnectionOutputMapper, } } var vpcPeeringConnectionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ec2-vpc-peering-connection", DescriptiveName: "VPC Peering Connection", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a VPC Peering Connection by ID", ListDescription: "List all VPC Peering Connections", SearchDescription: "Search for VPC Peering Connections by their ARN", }, PotentialLinks: []string{"ec2-vpc"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_vpc_peering_connection.id"}, {TerraformQueryMap: "aws_vpc_peering_connection_accepter.id"}, {TerraformQueryMap: "aws_vpc_peering_connection_options.vpc_peering_connection_id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-vpc-peering-connection_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestVpcPeeringConnectionOutputMapper(t *testing.T) { output := &ec2.DescribeVpcPeeringConnectionsOutput{ VpcPeeringConnections: []types.VpcPeeringConnection{ { VpcPeeringConnectionId: new("pcx-1234567890"), Status: &types.VpcPeeringConnectionStateReason{ Code: types.VpcPeeringConnectionStateReasonCodeActive, // health Message: new("message"), }, AccepterVpcInfo: &types.VpcPeeringConnectionVpcInfo{ CidrBlock: new("10.0.0.1/24"), CidrBlockSet: []types.CidrBlock{ { CidrBlock: new("10.0.2.1/24"), }, }, Ipv6CidrBlockSet: []types.Ipv6CidrBlock{ { Ipv6CidrBlock: new("::/64"), }, }, OwnerId: new("123456789012"), Region: new("eu-west-2"), // link VpcId: new("vpc-1234567890"), // link PeeringOptions: &types.VpcPeeringConnectionOptionsDescription{ AllowDnsResolutionFromRemoteVpc: new(true), }, }, RequesterVpcInfo: &types.VpcPeeringConnectionVpcInfo{ CidrBlock: new("10.0.0.1/24"), CidrBlockSet: []types.CidrBlock{ { CidrBlock: new("10.0.2.1/24"), }, }, Ipv6CidrBlockSet: []types.Ipv6CidrBlock{ { Ipv6CidrBlock: new("::/64"), }, }, OwnerId: new("987654321098"), PeeringOptions: &types.VpcPeeringConnectionOptionsDescription{ AllowDnsResolutionFromRemoteVpc: new(true), }, Region: new("eu-west-5"), // link VpcId: new("vpc-9887654321"), // link }, }, }, } items, err := vpcPeeringConnectionOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-1234567890", ExpectedScope: "123456789012.eu-west-2", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-9887654321", ExpectedScope: "987654321098.eu-west-5", }, } tests.Execute(t, item) } func TestNewEC2VpcPeeringConnectionAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2VpcPeeringConnectionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2-vpc.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func vpcInputMapperGet(scope string, query string) (*ec2.DescribeVpcsInput, error) { return &ec2.DescribeVpcsInput{ VpcIds: []string{ query, }, }, nil } func vpcInputMapperList(scope string) (*ec2.DescribeVpcsInput, error) { return &ec2.DescribeVpcsInput{}, nil } func vpcOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeVpcsInput, output *ec2.DescribeVpcsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, vpc := range output.Vpcs { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(vpc, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "ec2-vpc", UniqueAttribute: "VpcId", Scope: scope, Attributes: attrs, Tags: ec2TagsToMap(vpc.Tags), } items = append(items, &item) } return items, nil } func NewEC2VpcAdapter(client *ec2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ec2.DescribeVpcsInput, *ec2.DescribeVpcsOutput, *ec2.Client, *ec2.Options] { return &DescribeOnlyAdapter[*ec2.DescribeVpcsInput, *ec2.DescribeVpcsOutput, *ec2.Client, *ec2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "ec2-vpc", AdapterMetadata: vpcAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeVpcsInput) (*ec2.DescribeVpcsOutput, error) { return client.DescribeVpcs(ctx, input) }, InputMapperGet: vpcInputMapperGet, InputMapperList: vpcInputMapperList, PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeVpcsInput) Paginator[*ec2.DescribeVpcsOutput, *ec2.Options] { return ec2.NewDescribeVpcsPaginator(client, params) }, OutputMapper: vpcOutputMapper, } } var vpcAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "VPC", Type: "ec2-vpc", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get a VPC by ID", ListDescription: "List all VPCs", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_vpc.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/ec2-vpc_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestVpcInputMapperGet(t *testing.T) { input, err := vpcInputMapperGet("foo", "bar") if err != nil { t.Error(err) } if len(input.VpcIds) != 1 { t.Fatalf("expected 1 Vpc ID, got %v", len(input.VpcIds)) } if input.VpcIds[0] != "bar" { t.Errorf("expected Vpc ID to be bar, got %v", input.VpcIds[0]) } } func TestVpcInputMapperList(t *testing.T) { input, err := vpcInputMapperList("foo") if err != nil { t.Error(err) } if len(input.Filters) != 0 || len(input.VpcIds) != 0 { t.Errorf("non-empty input: %v", input) } } func TestVpcOutputMapper(t *testing.T) { output := &ec2.DescribeVpcsOutput{ Vpcs: []types.Vpc{ { CidrBlock: new("172.31.0.0/16"), DhcpOptionsId: new("dopt-0959b838bf4a4c7b8"), State: types.VpcStateAvailable, VpcId: new("vpc-0d7892e00e573e701"), OwnerId: new("052392120703"), InstanceTenancy: types.TenancyDefault, CidrBlockAssociationSet: []types.VpcCidrBlockAssociation{ { AssociationId: new("vpc-cidr-assoc-0b77866f37f500af6"), CidrBlock: new("172.31.0.0/16"), CidrBlockState: &types.VpcCidrBlockState{ State: types.VpcCidrBlockStateCodeAssociated, }, }, }, IsDefault: new(false), Tags: []types.Tag{ { Key: new("aws:cloudformation:logical-id"), Value: new("VPC"), }, { Key: new("aws:cloudformation:stack-id"), Value: new("arn:aws:cloudformation:eu-west-2:052392120703:stack/StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-8c2a9348-a30c-4ac3-94c2-8279157c9243/ccde3240-7afa-11ed-81ff-02845d4c2702"), }, { Key: new("aws:cloudformation:stack-name"), Value: new("StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-8c2a9348-a30c-4ac3-94c2-8279157c9243"), }, { Key: new("Name"), Value: new("aws-controltower-VPC"), }, }, }, }, } items, err := vpcOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } } func TestNewEC2VpcAdapter(t *testing.T) { client, account, region := ec2GetAutoConfig(t) adapter := NewEC2VpcAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ec2.go ================================================ package adapters import "github.com/aws/aws-sdk-go-v2/service/ec2/types" // Converts a slice of tags to a map func ec2TagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } return tagsMap } ================================================ FILE: aws-source/adapters/ec2_test.go ================================================ package adapters import ( "testing" "github.com/aws/aws-sdk-go-v2/service/ec2" ) func ec2GetAutoConfig(t *testing.T) (*ec2.Client, string, string) { t.Helper() config, account, region := GetAutoConfig(t) client := ec2.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/ecs-capacity-provider.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var CapacityProviderIncludeFields = []types.CapacityProviderField{ types.CapacityProviderFieldTags, } func capacityProviderOutputMapper(_ context.Context, _ ECSClient, scope string, _ *ecs.DescribeCapacityProvidersInput, output *ecs.DescribeCapacityProvidersOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, provider := range output.CapacityProviders { attributes, err := ToAttributesWithExclude(provider, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "ecs-capacity-provider", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, Tags: ecsTagsToMap(provider.Tags), } if provider.AutoScalingGroupProvider != nil { if provider.AutoScalingGroupProvider.AutoScalingGroupArn != nil { if a, err := ParseARN(*provider.AutoScalingGroupProvider.AutoScalingGroupArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "autoscaling-auto-scaling-group", Method: sdp.QueryMethod_SEARCH, Query: *provider.AutoScalingGroupProvider.AutoScalingGroupArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } items = append(items, &item) } return items, nil } func NewECSCapacityProviderAdapter(client ECSClient, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ecs.DescribeCapacityProvidersInput, *ecs.DescribeCapacityProvidersOutput, ECSClient, *ecs.Options] { return &DescribeOnlyAdapter[*ecs.DescribeCapacityProvidersInput, *ecs.DescribeCapacityProvidersOutput, ECSClient, *ecs.Options]{ ItemType: "ecs-capacity-provider", Region: region, AccountID: accountID, Client: client, AdapterMetadata: capacityProviderAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client ECSClient, input *ecs.DescribeCapacityProvidersInput) (*ecs.DescribeCapacityProvidersOutput, error) { return client.DescribeCapacityProviders(ctx, input) }, InputMapperGet: func(scope, query string) (*ecs.DescribeCapacityProvidersInput, error) { return &ecs.DescribeCapacityProvidersInput{ CapacityProviders: []string{ query, }, Include: CapacityProviderIncludeFields, }, nil }, InputMapperList: func(scope string) (*ecs.DescribeCapacityProvidersInput, error) { return &ecs.DescribeCapacityProvidersInput{ Include: CapacityProviderIncludeFields, }, nil }, PaginatorBuilder: func(client ECSClient, params *ecs.DescribeCapacityProvidersInput) Paginator[*ecs.DescribeCapacityProvidersOutput, *ecs.Options] { return NewDescribeCapacityProvidersPaginator(client, params) }, OutputMapper: capacityProviderOutputMapper, } } var capacityProviderAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ecs-capacity-provider", DescriptiveName: "Capacity Provider", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a capacity provider by its short name or full Amazon Resource Name (ARN).", List: true, ListDescription: "List capacity providers.", Search: true, SearchDescription: "Search capacity providers by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_ecs_capacity_provider.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"autoscaling-auto-scaling-group"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) // Incredibly annoyingly the go package adapters't provide a paginator builder for // DescribeCapacityProviders despite the fact that it's paginated, so I'm going // to create one myself below // DescribeCapacityProvidersPaginator is a paginator for DescribeCapacityProviders type DescribeCapacityProvidersPaginator struct { client ECSClient params *ecs.DescribeCapacityProvidersInput nextToken *string firstPage bool } // NewDescribeCapacityProvidersPaginator returns a new DescribeCapacityProvidersPaginator func NewDescribeCapacityProvidersPaginator(client ECSClient, params *ecs.DescribeCapacityProvidersInput) *DescribeCapacityProvidersPaginator { if params == nil { params = &ecs.DescribeCapacityProvidersInput{} } return &DescribeCapacityProvidersPaginator{ client: client, params: params, firstPage: true, nextToken: params.NextToken, } } // HasMorePages returns a boolean indicating whether more pages are available func (p *DescribeCapacityProvidersPaginator) HasMorePages() bool { return p.firstPage || (p.nextToken != nil && len(*p.nextToken) != 0) } // NextPage retrieves the next DescribeCapacityProviders page. func (p *DescribeCapacityProvidersPaginator) NextPage(ctx context.Context, optFns ...func(*ecs.Options)) (*ecs.DescribeCapacityProvidersOutput, error) { if !p.HasMorePages() { return nil, fmt.Errorf("no more pages available") } params := *p.params params.NextToken = p.nextToken result, err := p.client.DescribeCapacityProviders(ctx, ¶ms, optFns...) if err != nil { return nil, err } p.firstPage = false prevToken := p.nextToken p.nextToken = result.NextToken if prevToken != nil && p.nextToken != nil && *prevToken == *p.nextToken { p.nextToken = nil } return result, nil } ================================================ FILE: aws-source/adapters/ecs-capacity-provider_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *ecsTestClient) DescribeCapacityProviders(ctx context.Context, params *ecs.DescribeCapacityProvidersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeCapacityProvidersOutput, error) { pages := map[string]*ecs.DescribeCapacityProvidersOutput{ "": { CapacityProviders: []types.CapacityProvider{ { CapacityProviderArn: new("arn:aws:ecs:eu-west-2:052392120703:capacity-provider/FARGATE"), Name: new("FARGATE"), Status: types.CapacityProviderStatusActive, }, }, NextToken: new("one"), }, "one": { CapacityProviders: []types.CapacityProvider{ { CapacityProviderArn: new("arn:aws:ecs:eu-west-2:052392120703:capacity-provider/FARGATE_SPOT"), Name: new("FARGATE_SPOT"), Status: types.CapacityProviderStatusActive, }, }, NextToken: new("two"), }, "two": { CapacityProviders: []types.CapacityProvider{ { CapacityProviderArn: new("arn:aws:ecs:eu-west-2:052392120703:capacity-provider/test"), Name: new("test"), Status: types.CapacityProviderStatusActive, AutoScalingGroupProvider: &types.AutoScalingGroupProvider{ AutoScalingGroupArn: new("arn:aws:autoscaling:eu-west-2:052392120703:autoScalingGroup:9df90815-98c1-4136-a12a-90abef1c4e4e:autoScalingGroupName/ecs-test"), ManagedScaling: &types.ManagedScaling{ Status: types.ManagedScalingStatusEnabled, TargetCapacity: new(int32(80)), MinimumScalingStepSize: new(int32(1)), MaximumScalingStepSize: new(int32(10000)), InstanceWarmupPeriod: new(int32(300)), }, ManagedTerminationProtection: types.ManagedTerminationProtectionDisabled, }, UpdateStatus: types.CapacityProviderUpdateStatusDeleteComplete, UpdateStatusReason: new("reason"), }, }, }, } var page string if params.NextToken != nil { page = *params.NextToken } return pages[page], nil } func TestCapacityProviderOutputMapper(t *testing.T) { items, err := capacityProviderOutputMapper( context.Background(), &ecsTestClient{}, "foo", nil, &ecs.DescribeCapacityProvidersOutput{ CapacityProviders: []types.CapacityProvider{ { CapacityProviderArn: new("arn:aws:ecs:eu-west-2:052392120703:capacity-provider/test"), Name: new("test"), Status: types.CapacityProviderStatusActive, AutoScalingGroupProvider: &types.AutoScalingGroupProvider{ AutoScalingGroupArn: new("arn:aws:autoscaling:eu-west-2:052392120703:autoScalingGroup:9df90815-98c1-4136-a12a-90abef1c4e4e:autoScalingGroupName/ecs-test"), ManagedScaling: &types.ManagedScaling{ Status: types.ManagedScalingStatusEnabled, TargetCapacity: new(int32(80)), MinimumScalingStepSize: new(int32(1)), MaximumScalingStepSize: new(int32(10000)), InstanceWarmupPeriod: new(int32(300)), }, ManagedTerminationProtection: types.ManagedTerminationProtectionDisabled, }, UpdateStatus: types.CapacityProviderUpdateStatusDeleteComplete, UpdateStatusReason: new("reason"), }, }, }, ) if err != nil { t.Error(err) } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } item := items[0] if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "autoscaling-auto-scaling-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:autoscaling:eu-west-2:052392120703:autoScalingGroup:9df90815-98c1-4136-a12a-90abef1c4e4e:autoScalingGroupName/ecs-test", ExpectedScope: "052392120703.eu-west-2", }, } tests.Execute(t, item) } func TestCapacityProviderAdapter(t *testing.T) { adapter := NewECSCapacityProviderAdapter(&ecsTestClient{}, "", "", sdpcache.NewNoOpCache()) stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(context.Background(), "*", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 3 { t.Errorf("expected 3 items, got %v", len(items)) } } func TestNewECSCapacityProviderAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := ecs.NewFromConfig(config) adapter := NewECSCapacityProviderAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ecs-cluster.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // ClusterIncludeFields Fields that we want included by default var ClusterIncludeFields = []types.ClusterField{ types.ClusterFieldAttachments, types.ClusterFieldConfigurations, types.ClusterFieldSettings, types.ClusterFieldStatistics, types.ClusterFieldTags, } func ecsClusterGetFunc(ctx context.Context, client ECSClient, scope string, input *ecs.DescribeClustersInput) (*sdp.Item, error) { out, err := client.DescribeClusters(ctx, input) if err != nil { return nil, err } accountID, _, err := ParseScope(scope) if err != nil { return nil, err } if len(out.Failures) != 0 { failure := out.Failures[0] if failure.Reason != nil && failure.Arn != nil { if *failure.Reason == "MISSING" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("cluster with ARN %v not found", *failure.Arn), } } } return nil, fmt.Errorf("cluster get failure: %v", failure) } if len(out.Clusters) != 1 { return nil, fmt.Errorf("got %v clusters, expected 1", len(out.Clusters)) } cluster := out.Clusters[0] attributes, err := ToAttributesWithExclude(cluster, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "ecs-cluster", UniqueAttribute: "ClusterName", Scope: scope, Attributes: attributes, Tags: ecsTagsToMap(cluster.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ // Search for all container instances on this cluster Type: "ecs-container-instance", Method: sdp.QueryMethod_SEARCH, Query: *cluster.ClusterName, Scope: scope, }, }, { Query: &sdp.Query{ Type: "ecs-service", Method: sdp.QueryMethod_SEARCH, Query: *cluster.ClusterName, Scope: scope, }, }, { Query: &sdp.Query{ Type: "ecs-task", Method: sdp.QueryMethod_SEARCH, Query: *cluster.ClusterName, Scope: scope, }, }, }, } if cluster.Status != nil { switch *cluster.Status { case "ACTIVE": item.Health = sdp.Health_HEALTH_OK.Enum() case "PROVISIONING": item.Health = sdp.Health_HEALTH_PENDING.Enum() case "DEPROVISIONING": item.Health = sdp.Health_HEALTH_WARNING.Enum() case "FAILED": item.Health = sdp.Health_HEALTH_ERROR.Enum() case "INACTIVE": // This means it's a deleted cluster item.Health = nil } } if cluster.Configuration != nil { if cluster.Configuration.ExecuteCommandConfiguration != nil { if cluster.Configuration.ExecuteCommandConfiguration.KmsKeyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_GET, Query: *cluster.Configuration.ExecuteCommandConfiguration.KmsKeyId, Scope: scope, }, }) } if cluster.Configuration.ExecuteCommandConfiguration.LogConfiguration != nil { if cluster.Configuration.ExecuteCommandConfiguration.LogConfiguration.CloudWatchLogGroupName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "logs-log-group", Method: sdp.QueryMethod_GET, Query: *cluster.Configuration.ExecuteCommandConfiguration.LogConfiguration.CloudWatchLogGroupName, Scope: scope, }, }) } if cluster.Configuration.ExecuteCommandConfiguration.LogConfiguration.S3BucketName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "s3-bucket", Method: sdp.QueryMethod_GET, Query: *cluster.Configuration.ExecuteCommandConfiguration.LogConfiguration.S3BucketName, Scope: FormatScope(accountID, ""), }, }) } } } } for _, provider := range cluster.CapacityProviders { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-capacity-provider", Method: sdp.QueryMethod_GET, Query: provider, Scope: scope, }, }) } return &item, nil } func NewECSClusterAdapter(client ECSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*ecs.ListClustersInput, *ecs.ListClustersOutput, *ecs.DescribeClustersInput, *ecs.DescribeClustersOutput, ECSClient, *ecs.Options] { return &AlwaysGetAdapter[*ecs.ListClustersInput, *ecs.ListClustersOutput, *ecs.DescribeClustersInput, *ecs.DescribeClustersOutput, ECSClient, *ecs.Options]{ ItemType: "ecs-cluster", Client: client, AccountID: accountID, Region: region, GetFunc: ecsClusterGetFunc, AdapterMetadata: ecsClusterAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *ecs.DescribeClustersInput { return &ecs.DescribeClustersInput{ Clusters: []string{ query, }, Include: ClusterIncludeFields, } }, ListInput: &ecs.ListClustersInput{}, ListFuncPaginatorBuilder: func(client ECSClient, input *ecs.ListClustersInput) Paginator[*ecs.ListClustersOutput, *ecs.Options] { return ecs.NewListClustersPaginator(client, input) }, ListFuncOutputMapper: func(output *ecs.ListClustersOutput, input *ecs.ListClustersInput) ([]*ecs.DescribeClustersInput, error) { inputs := make([]*ecs.DescribeClustersInput, 0) var a *ARN var err error for _, arn := range output.ClusterArns { a, err = ParseARN(arn) if err != nil { continue } inputs = append(inputs, &ecs.DescribeClustersInput{ Clusters: []string{ a.ResourceID(), // This will be the name of the cluster }, Include: ClusterIncludeFields, }) } return inputs, nil }, } } var ecsClusterAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ecs-cluster", DescriptiveName: "ECS Cluster", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a cluster by name", ListDescription: "List all clusters", SearchDescription: "Search for a cluster by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_ecs_cluster.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"ecs-container-instance", "ecs-service", "ecs-task", "ecs-capacity-provider"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ecs-cluster_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *ecsTestClient) DescribeClusters(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { return &ecs.DescribeClustersOutput{ Clusters: []types.Cluster{ { ClusterArn: new("arn:aws:ecs:eu-west-2:052392120703:cluster/default"), ClusterName: new("default"), Status: new("ACTIVE"), RegisteredContainerInstancesCount: 0, RunningTasksCount: 1, PendingTasksCount: 0, ActiveServicesCount: 1, Statistics: []types.KeyValuePair{ { Name: new("key"), Value: new("value"), }, }, Tags: []types.Tag{}, Settings: []types.ClusterSetting{ { Name: types.ClusterSettingNameContainerInsights, Value: new("ENABLED"), }, }, CapacityProviders: []string{ "test", }, DefaultCapacityProviderStrategy: []types.CapacityProviderStrategyItem{ { CapacityProvider: new("provider"), Base: 10, Weight: 100, }, }, Attachments: []types.Attachment{ { Id: new("1c1f9cf4-461c-4072-aab2-e2dd346c53e1"), Type: new("as_policy"), Status: new("CREATED"), Details: []types.KeyValuePair{ { Name: new("capacityProviderName"), Value: new("test"), }, { Name: new("scalingPolicyName"), Value: new("ECSManagedAutoScalingPolicy-d2f110eb-20a6-4278-9c1c-47d98e21b1ed"), }, }, }, }, AttachmentsStatus: new("UPDATE_COMPLETE"), Configuration: &types.ClusterConfiguration{ ExecuteCommandConfiguration: &types.ExecuteCommandConfiguration{ KmsKeyId: new("id"), LogConfiguration: &types.ExecuteCommandLogConfiguration{ CloudWatchEncryptionEnabled: true, CloudWatchLogGroupName: new("cloud-watch-name"), S3BucketName: new("s3-name"), S3EncryptionEnabled: true, S3KeyPrefix: new("prod"), }, }, }, ServiceConnectDefaults: &types.ClusterServiceConnectDefaults{ Namespace: new("prod"), }, }, }, }, nil } func (t *ecsTestClient) ListClusters(context.Context, *ecs.ListClustersInput, ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { return &ecs.ListClustersOutput{ ClusterArns: []string{ "arn:aws:service:region:account:cluster/name", }, }, nil } func TestECSClusterGetFunc(t *testing.T) { scope := "123456789012.eu-west-2" item, err := ecsClusterGetFunc(context.Background(), &ecsTestClient{}, scope, &ecs.DescribeClustersInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: scope, }, { ExpectedType: "logs-log-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cloud-watch-name", ExpectedScope: scope, }, { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "s3-name", ExpectedScope: "123456789012", }, { ExpectedType: "ecs-capacity-provider", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test", ExpectedScope: scope, }, { ExpectedType: "ecs-container-instance", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "ecs-service", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: scope, }, } tests.Execute(t, item) } func TestECSNewECSClusterAdapter(t *testing.T) { client, account, region := ecsGetAutoConfig(t) adapter := NewECSClusterAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ecs-container-instance.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // ContainerInstanceIncludeFields Fields that we want included by default var ContainerInstanceIncludeFields = []types.ContainerInstanceField{ types.ContainerInstanceFieldTags, types.ContainerInstanceFieldContainerInstanceHealth, } func containerInstanceGetFunc(ctx context.Context, client ECSClient, scope string, input *ecs.DescribeContainerInstancesInput) (*sdp.Item, error) { out, err := client.DescribeContainerInstances(ctx, input) if err != nil { return nil, err } if len(out.ContainerInstances) != 1 { return nil, fmt.Errorf("got %v ContainerInstances, expected 1", len(out.ContainerInstances)) } containerInstance := out.ContainerInstances[0] attributes, err := ToAttributesWithExclude(containerInstance, "tags") if err != nil { return nil, err } // Create an ID param since they don't have anything that uniquely // identifies them. This is {clusterName}/{id} // ecs-template-ECSCluster-8nS0WOLbs3nZ/50e9bf71ed57450ca56293cc5a042886 if a, err := ParseARN(*containerInstance.ContainerInstanceArn); err == nil { attributes.Set("Id", a.Resource) } item := sdp.Item{ Type: "ecs-container-instance", UniqueAttribute: "Id", Scope: scope, Attributes: attributes, Tags: ecsTagsToMap(containerInstance.Tags), } if containerInstance.HealthStatus != nil { switch containerInstance.HealthStatus.OverallStatus { case types.InstanceHealthCheckStateOk: item.Health = sdp.Health_HEALTH_OK.Enum() case types.InstanceHealthCheckStateImpaired: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.InstanceHealthCheckStateInsufficientData: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.InstanceHealthCheckStateInitializing: item.Health = sdp.Health_HEALTH_WARNING.Enum() } } if containerInstance.Ec2InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *containerInstance.Ec2InstanceId, Scope: scope, }, }) } return &item, nil } func containerInstanceListFuncOutputMapper(output *ecs.ListContainerInstancesOutput, input *ecs.ListContainerInstancesInput) ([]*ecs.DescribeContainerInstancesInput, error) { inputs := make([]*ecs.DescribeContainerInstancesInput, 0) var a *ARN var err error for _, arn := range output.ContainerInstanceArns { a, err = ParseARN(arn) if err != nil { continue } sections := strings.Split(a.Resource, "/") if len(sections) != 2 { return nil, fmt.Errorf("could not split into 2 sections on '/': %v", a.Resource) } inputs = append(inputs, &ecs.DescribeContainerInstancesInput{ Cluster: §ions[0], ContainerInstances: []string{ sections[1], }, Include: ContainerInstanceIncludeFields, }) } return inputs, nil } func NewECSContainerInstanceAdapter(client ECSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*ecs.ListContainerInstancesInput, *ecs.ListContainerInstancesOutput, *ecs.DescribeContainerInstancesInput, *ecs.DescribeContainerInstancesOutput, ECSClient, *ecs.Options] { return &AlwaysGetAdapter[*ecs.ListContainerInstancesInput, *ecs.ListContainerInstancesOutput, *ecs.DescribeContainerInstancesInput, *ecs.DescribeContainerInstancesOutput, ECSClient, *ecs.Options]{ ItemType: "ecs-container-instance", Client: client, AccountID: accountID, Region: region, GetFunc: containerInstanceGetFunc, AdapterMetadata: containerInstanceAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *ecs.DescribeContainerInstancesInput { // We are using a custom id of {clusterName}/{id} e.g. // ecs-template-ECSCluster-8nS0WOLbs3nZ/50e9bf71ed57450ca56293cc5a042886 sections := strings.Split(query, "/") if len(sections) != 2 { return nil } return &ecs.DescribeContainerInstancesInput{ ContainerInstances: []string{ sections[1], }, Cluster: §ions[0], Include: ContainerInstanceIncludeFields, } }, ListInput: &ecs.ListContainerInstancesInput{}, DisableList: true, // Tou can't list without a cluster ListFuncPaginatorBuilder: func(client ECSClient, input *ecs.ListContainerInstancesInput) Paginator[*ecs.ListContainerInstancesOutput, *ecs.Options] { return ecs.NewListContainerInstancesPaginator(client, input) }, SearchInputMapper: func(scope, query string) (*ecs.ListContainerInstancesInput, error) { // Custom search by cluster return &ecs.ListContainerInstancesInput{ Cluster: new(query), }, nil }, ListFuncOutputMapper: containerInstanceListFuncOutputMapper, } } var containerInstanceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ecs-container-instance", DescriptiveName: "Container Instance", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a container instance by ID which consists of {clusterName}/{id}", SearchDescription: "Search for container instances by cluster", }, PotentialLinks: []string{"ec2-instance"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ecs-container-instance_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *ecsTestClient) DescribeContainerInstances(ctx context.Context, params *ecs.DescribeContainerInstancesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeContainerInstancesOutput, error) { return &ecs.DescribeContainerInstancesOutput{ ContainerInstances: []types.ContainerInstance{ { ContainerInstanceArn: new("arn:aws:ecs:eu-west-1:052392120703:container-instance/ecs-template-ECSCluster-8nS0WOLbs3nZ/50e9bf71ed57450ca56293cc5a042886"), Ec2InstanceId: new("i-0e778f25705bc0c84"), // link Version: 4, VersionInfo: &types.VersionInfo{ AgentVersion: new("1.47.0"), AgentHash: new("1489adfa"), DockerVersion: new("DockerVersion: 19.03.6-ce"), }, RemainingResources: []types.Resource{ { Name: new("CPU"), Type: new("INTEGER"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 2028, }, { Name: new("MEMORY"), Type: new("INTEGER"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 7474, }, { Name: new("PORTS"), Type: new("STRINGSET"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 0, StringSetValue: []string{ "22", "2376", "2375", "51678", "51679", }, }, { Name: new("PORTS_UDP"), Type: new("STRINGSET"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 0, StringSetValue: []string{}, }, }, RegisteredResources: []types.Resource{ { Name: new("CPU"), Type: new("INTEGER"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 2048, }, { Name: new("MEMORY"), Type: new("INTEGER"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 7974, }, { Name: new("PORTS"), Type: new("STRINGSET"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 0, StringSetValue: []string{ "22", "2376", "2375", "51678", "51679", }, }, { Name: new("PORTS_UDP"), Type: new("STRINGSET"), DoubleValue: 0.0, LongValue: 0, IntegerValue: 0, StringSetValue: []string{}, }, }, Status: new("ACTIVE"), AgentConnected: true, RunningTasksCount: 1, PendingTasksCount: 0, Attributes: []types.Attribute{ { Name: new("ecs.capability.secrets.asm.environment-variables"), }, { Name: new("ecs.capability.branch-cni-plugin-version"), Value: new("a21d3a41-"), }, { Name: new("ecs.ami-id"), Value: new("ami-0c9ef930279337028"), }, { Name: new("ecs.capability.secrets.asm.bootstrap.log-driver"), }, { Name: new("ecs.capability.task-eia.optimized-cpu"), }, { Name: new("com.amazonaws.ecs.capability.logging-driver.none"), }, { Name: new("ecs.capability.ecr-endpoint"), }, { Name: new("ecs.capability.docker-plugin.local"), }, { Name: new("ecs.capability.task-cpu-mem-limit"), }, { Name: new("ecs.capability.secrets.ssm.bootstrap.log-driver"), }, { Name: new("ecs.capability.efsAuth"), }, { Name: new("ecs.capability.full-sync"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.30"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.31"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.32"), }, { Name: new("com.amazonaws.ecs.capability.logging-driver.fluentd"), }, { Name: new("ecs.capability.firelens.options.config.file"), }, { Name: new("ecs.availability-zone"), Value: new("eu-west-1a"), }, { Name: new("ecs.capability.aws-appmesh"), }, { Name: new("com.amazonaws.ecs.capability.logging-driver.awslogs"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.24"), }, { Name: new("ecs.capability.task-eni-trunking"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.25"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.26"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.27"), }, { Name: new("com.amazonaws.ecs.capability.privileged-container"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.28"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.29"), }, { Name: new("ecs.cpu-architecture"), Value: new("x86_64"), }, { Name: new("com.amazonaws.ecs.capability.ecr-auth"), }, { Name: new("ecs.capability.firelens.fluentbit"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.20"), }, { Name: new("ecs.os-type"), Value: new("linux"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.21"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.22"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.23"), }, { Name: new("ecs.capability.task-eia"), }, { Name: new("ecs.capability.private-registry-authentication.secretsmanager"), }, { Name: new("com.amazonaws.ecs.capability.logging-driver.syslog"), }, { Name: new("com.amazonaws.ecs.capability.logging-driver.awsfirelens"), }, { Name: new("ecs.capability.firelens.options.config.s3"), }, { Name: new("com.amazonaws.ecs.capability.logging-driver.json-file"), }, { Name: new("ecs.capability.execution-role-awslogs"), }, { Name: new("ecs.vpc-id"), Value: new("vpc-0e120717a7263de70"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.17"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.18"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.19"), }, { Name: new("ecs.capability.docker-plugin.amazon-ecs-volume-plugin"), }, { Name: new("ecs.capability.task-eni"), }, { Name: new("ecs.capability.firelens.fluentd"), }, { Name: new("ecs.capability.efs"), }, { Name: new("ecs.capability.execution-role-ecr-pull"), }, { Name: new("ecs.capability.task-eni.ipv6"), }, { Name: new("ecs.capability.container-health-check"), }, { Name: new("ecs.subnet-id"), Value: new("subnet-0bfdb717a234c01b3"), }, { Name: new("ecs.instance-type"), Value: new("t2.large"), }, { Name: new("com.amazonaws.ecs.capability.task-iam-role-network-host"), }, { Name: new("ecs.capability.container-ordering"), }, { Name: new("ecs.capability.cni-plugin-version"), Value: new("55b2ae77-2020.09.0"), }, { Name: new("ecs.capability.env-files.s3"), }, { Name: new("ecs.capability.pid-ipc-namespace-sharing"), }, { Name: new("ecs.capability.secrets.ssm.environment-variables"), }, { Name: new("com.amazonaws.ecs.capability.task-iam-role"), }, }, RegisteredAt: new(time.Now()), Attachments: []types.Attachment{}, // There is probably an opportunity for some links here but I don't have example data Tags: []types.Tag{}, AgentUpdateStatus: types.AgentUpdateStatusFailed, CapacityProviderName: new("name"), HealthStatus: &types.ContainerInstanceHealthStatus{ OverallStatus: types.InstanceHealthCheckStateImpaired, }, }, }, }, nil } func (t *ecsTestClient) ListContainerInstances(context.Context, *ecs.ListContainerInstancesInput, ...func(*ecs.Options)) (*ecs.ListContainerInstancesOutput, error) { return &ecs.ListContainerInstancesOutput{ ContainerInstanceArns: []string{ "arn:aws:ecs:eu-west-1:052392120703:container-instance/ecs-template-ECSCluster-8nS0WOLbs3nZ/50e9bf71ed57450ca56293cc5a042886", }, }, nil } func TestContainerInstanceGetFunc(t *testing.T) { item, err := containerInstanceGetFunc(context.Background(), &ecsTestClient{}, "foo", &ecs.DescribeContainerInstancesInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-0e778f25705bc0c84", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewECSContainerInstanceAdapter(t *testing.T) { client, account, region := ecsGetAutoConfig(t) adapter := NewECSContainerInstanceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipNotFoundCheck: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/ecs-service.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // ServiceIncludeFields Fields that we want included by default var ServiceIncludeFields = []types.ServiceField{ types.ServiceFieldTags, } func serviceGetFunc(ctx context.Context, client ECSClient, scope string, input *ecs.DescribeServicesInput) (*sdp.Item, error) { if input == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no input provided", } } out, err := client.DescribeServices(ctx, input) if err != nil { return nil, err } if len(out.Services) != 1 { return nil, fmt.Errorf("got %v Services, expected 1", len(out.Services)) } service := out.Services[0] // Before we convert to attributes we want to extract the task sets to link // to and then delete the info. This because the response embeds the entire // task set which is unnecessary since it'll be returned by ecs-task-set taskSetIds := make([]string, 0) for _, ts := range service.TaskSets { if ts.Id != nil { taskSetIds = append(taskSetIds, *ts.Id) } } service.TaskSets = []types.TaskSet{} attributes, err := ToAttributesWithExclude(service, "tags") if err != nil { return nil, err } if service.ServiceArn != nil { if a, err := ParseARN(*service.ServiceArn); err == nil { attributes.Set("ServiceFullName", a.Resource) } } item := sdp.Item{ Type: "ecs-service", UniqueAttribute: "ServiceFullName", Scope: scope, Attributes: attributes, Tags: ecsTagsToMap(service.Tags), } if service.Status != nil { switch *service.Status { case "ACTIVE": item.Health = sdp.Health_HEALTH_OK.Enum() case "DRAINING": item.Health = sdp.Health_HEALTH_WARNING.Enum() case "INACTIVE": item.Health = nil } } var a *ARN if service.ClusterArn != nil { if a, err = ParseARN(*service.ClusterArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-cluster", Method: sdp.QueryMethod_SEARCH, Query: *service.ClusterArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, lb := range service.LoadBalancers { if lb.TargetGroupArn != nil { if a, err = ParseARN(*lb.TargetGroupArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-target-group", Method: sdp.QueryMethod_SEARCH, Query: *lb.TargetGroupArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } for _, sr := range service.ServiceRegistries { if sr.RegistryArn != nil { if a, err = ParseARN(*sr.RegistryArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "servicediscovery-service", Method: sdp.QueryMethod_SEARCH, Query: *sr.RegistryArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if service.TaskDefinition != nil { if a, err = ParseARN(*service.TaskDefinition); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-task-definition", Method: sdp.QueryMethod_SEARCH, Query: *service.TaskDefinition, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, deployment := range service.Deployments { if deployment.TaskDefinition != nil { if a, err = ParseARN(*deployment.TaskDefinition); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-task-definition", Method: sdp.QueryMethod_SEARCH, Query: *deployment.TaskDefinition, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, strategy := range deployment.CapacityProviderStrategy { if strategy.CapacityProvider != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-capacity-provider", Method: sdp.QueryMethod_GET, Query: *strategy.CapacityProvider, Scope: scope, }, }) } } if deployment.NetworkConfiguration != nil { if deployment.NetworkConfiguration.AwsvpcConfiguration != nil { for _, subnet := range deployment.NetworkConfiguration.AwsvpcConfiguration.Subnets { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: subnet, Scope: scope, }, }) } for _, sg := range deployment.NetworkConfiguration.AwsvpcConfiguration.SecurityGroups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-security-group", Method: sdp.QueryMethod_GET, Query: sg, Scope: scope, }, }) } } } if deployment.ServiceConnectConfiguration != nil { for _, svc := range deployment.ServiceConnectConfiguration.Services { for _, alias := range svc.ClientAliases { if alias.DnsName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *alias.DnsName, Scope: "global", }, }) } } } } for _, cr := range deployment.ServiceConnectResources { if cr.DiscoveryArn != nil { if a, err = ParseARN(*cr.DiscoveryArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "servicediscovery-service", Method: sdp.QueryMethod_SEARCH, Query: *cr.DiscoveryArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } if service.NetworkConfiguration != nil { if service.NetworkConfiguration.AwsvpcConfiguration != nil { for _, subnet := range service.NetworkConfiguration.AwsvpcConfiguration.Subnets { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: subnet, Scope: scope, }, }) } for _, sg := range service.NetworkConfiguration.AwsvpcConfiguration.SecurityGroups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: sg, Scope: scope, }, }) } } } for _, id := range taskSetIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-task-set", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } return &item, nil } func serviceListFuncOutputMapper(output *ecs.ListServicesOutput, input *ecs.ListServicesInput) ([]*ecs.DescribeServicesInput, error) { inputs := make([]*ecs.DescribeServicesInput, 0) var a *ARN var err error for _, arn := range output.ServiceArns { a, err = ParseARN(arn) if err != nil { continue } sections := strings.Split(a.Resource, "/") if len(sections) != 3 { return nil, fmt.Errorf("could not split into 3 sections on '/': %v", a.Resource) } inputs = append(inputs, &ecs.DescribeServicesInput{ Cluster: §ions[1], Services: []string{ sections[2], }, Include: ServiceIncludeFields, }) } return inputs, nil } func NewECSServiceAdapter(client ECSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*ecs.ListServicesInput, *ecs.ListServicesOutput, *ecs.DescribeServicesInput, *ecs.DescribeServicesOutput, ECSClient, *ecs.Options] { return &AlwaysGetAdapter[*ecs.ListServicesInput, *ecs.ListServicesOutput, *ecs.DescribeServicesInput, *ecs.DescribeServicesOutput, ECSClient, *ecs.Options]{ ItemType: "ecs-service", Client: client, AccountID: accountID, Region: region, GetFunc: serviceGetFunc, DisableList: true, AdapterMetadata: ecsServiceAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *ecs.DescribeServicesInput { // We are using a custom id of {clusterName}/{id} e.g. // ecs-template-ECSCluster-8nS0WOLbs3nZ/ecs-template-service-i0mQKzkhDI2C sections := strings.Split(query, "/") if len(sections) != 2 { return nil } return &ecs.DescribeServicesInput{ Services: []string{ sections[1], }, Cluster: §ions[0], Include: ServiceIncludeFields, } }, ListInput: &ecs.ListServicesInput{}, ListFuncPaginatorBuilder: func(client ECSClient, input *ecs.ListServicesInput) Paginator[*ecs.ListServicesOutput, *ecs.Options] { return ecs.NewListServicesPaginator(client, input) }, SearchInputMapper: func(scope, query string) (*ecs.ListServicesInput, error) { // Custom search by cluster return &ecs.ListServicesInput{ Cluster: new(query), }, nil }, ListFuncOutputMapper: serviceListFuncOutputMapper, } } var ecsServiceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ecs-service", DescriptiveName: "ECS Service", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an ECS service by full name ({clusterName}/{id})", SearchDescription: "Search for ECS services by cluster", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "aws_ecs_service.cluster_name", }, }, PotentialLinks: []string{"ecs-cluster", "elbv2-target-group", "servicediscovery-service", "ecs-task-definition", "ecs-capacity-provider", "ec2-subnet", "ecs-security-group", "dns"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ecs-service_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *ecsTestClient) DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { return &ecs.DescribeServicesOutput{ Failures: []types.Failure{}, Services: []types.Service{ { ServiceArn: new("arn:aws:ecs:eu-west-1:052392120703:service/ecs-template-ECSCluster-8nS0WOLbs3nZ/ecs-template-service-i0mQKzkhDI2C"), ServiceName: new("ecs-template-service-i0mQKzkhDI2C"), ClusterArn: new("arn:aws:ecs:eu-west-1:052392120703:cluster/ecs-template-ECSCluster-8nS0WOLbs3nZ"), // link LoadBalancers: []types.LoadBalancer{ { TargetGroupArn: new("arn:aws:elasticloadbalancing:eu-west-1:052392120703:targetgroup/ECSTG/0c44b1cdb3437902"), // link ContainerName: new("simple-app"), ContainerPort: new(int32(80)), }, }, ServiceRegistries: []types.ServiceRegistry{ { ContainerName: new("name"), ContainerPort: new(int32(80)), Port: new(int32(80)), RegistryArn: new("arn:aws:service:region:account:type:name"), // link }, }, Status: new("ACTIVE"), DesiredCount: 1, RunningCount: 1, PendingCount: 0, LaunchType: types.LaunchTypeEc2, TaskDefinition: new("arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1"), // link DeploymentConfiguration: &types.DeploymentConfiguration{ DeploymentCircuitBreaker: &types.DeploymentCircuitBreaker{ Enable: false, Rollback: false, }, MaximumPercent: new(int32(200)), MinimumHealthyPercent: new(int32(100)), Alarms: &types.DeploymentAlarms{ AlarmNames: []string{ "foo", }, Enable: true, Rollback: true, }, }, Deployments: []types.Deployment{ { Id: new("ecs-svc/6893472562508357546"), Status: new("PRIMARY"), TaskDefinition: new("arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1"), // link DesiredCount: 1, PendingCount: 0, RunningCount: 1, FailedTasks: 0, CreatedAt: new(time.Now()), UpdatedAt: new(time.Now()), LaunchType: types.LaunchTypeEc2, RolloutState: types.DeploymentRolloutStateCompleted, RolloutStateReason: new("ECS deployment ecs-svc/6893472562508357546 completed."), CapacityProviderStrategy: []types.CapacityProviderStrategyItem{ { CapacityProvider: new("provider"), // link Base: 10, Weight: 10, }, }, NetworkConfiguration: &types.NetworkConfiguration{ AwsvpcConfiguration: &types.AwsVpcConfiguration{ Subnets: []string{ "subnet", // link }, AssignPublicIp: types.AssignPublicIpEnabled, SecurityGroups: []string{ "sg1", // link }, }, }, PlatformFamily: new("foo"), PlatformVersion: new("LATEST"), ServiceConnectConfiguration: &types.ServiceConnectConfiguration{ Enabled: true, LogConfiguration: &types.LogConfiguration{ LogDriver: types.LogDriverAwslogs, Options: map[string]string{}, SecretOptions: []types.Secret{ { Name: new("something"), ValueFrom: new("somewhere"), }, }, }, Namespace: new("namespace"), Services: []types.ServiceConnectService{ { PortName: new("http"), ClientAliases: []types.ServiceConnectClientAlias{ { Port: new(int32(80)), DnsName: new("www.foo.com"), // link }, }, }, }, }, ServiceConnectResources: []types.ServiceConnectServiceResource{ { DiscoveryArn: new("arn:aws:service:region:account:layer:name:version"), // link DiscoveryName: new("name"), }, }, }, }, RoleArn: new("arn:aws:iam::052392120703:role/ecs-template-ECSServiceRole-1IL5CNMR1600J"), Events: []types.ServiceEvent{ { Id: new("a727ef2a-8a38-4746-905e-b529c952edee"), CreatedAt: new(time.Now()), Message: new("(service ecs-template-service-i0mQKzkhDI2C) has reached a steady state."), }, { Id: new("69489991-f8ee-42a2-94f2-db8ffeda1ee7"), CreatedAt: new(time.Now()), Message: new("(service ecs-template-service-i0mQKzkhDI2C) (deployment ecs-svc/6893472562508357546) deployment completed."), }, { Id: new("9ce65c4b-2993-477d-aa83-dbe98988f90b"), CreatedAt: new(time.Now()), Message: new("(service ecs-template-service-i0mQKzkhDI2C) registered 1 targets in (target-group arn:aws:elasticloadbalancing:eu-west-1:052392120703:targetgroup/ECSTG/0c44b1cdb3437902)"), }, { Id: new("753e988a-9fb9-4907-b801-5f67369bc0de"), CreatedAt: new(time.Now()), Message: new("(service ecs-template-service-i0mQKzkhDI2C) has started 1 tasks: (task 53074e0156204f30a3cea97e7bf32d31)."), }, { Id: new("deb2400b-a776-4ebe-8c97-f94feef2b780"), CreatedAt: new(time.Now()), Message: new("(service ecs-template-service-i0mQKzkhDI2C) was unable to place a task because no container instance met all of its requirements. Reason: No Container Instances were found in your cluster. For more information, see the Troubleshooting section of the Amazon ECS Developer Guide."), }, }, CreatedAt: new(time.Now()), PlacementConstraints: []types.PlacementConstraint{ { Expression: new("expression"), Type: types.PlacementConstraintTypeDistinctInstance, }, }, PlacementStrategy: []types.PlacementStrategy{ { Field: new("field"), Type: types.PlacementStrategyTypeSpread, }, }, HealthCheckGracePeriodSeconds: new(int32(0)), SchedulingStrategy: types.SchedulingStrategyReplica, DeploymentController: &types.DeploymentController{ Type: types.DeploymentControllerTypeEcs, }, CreatedBy: new("arn:aws:iam::052392120703:role/aws-reserved/sso.amazonaws.com/eu-west-2/AWSReservedSSO_AWSAdministratorAccess_c1c3c9c54821c68a"), EnableECSManagedTags: false, PropagateTags: types.PropagateTagsNone, EnableExecuteCommand: false, CapacityProviderStrategy: []types.CapacityProviderStrategyItem{ { CapacityProvider: new("provider"), Base: 10, Weight: 10, }, }, NetworkConfiguration: &types.NetworkConfiguration{ AwsvpcConfiguration: &types.AwsVpcConfiguration{ Subnets: []string{ "subnet2", // link }, AssignPublicIp: types.AssignPublicIpEnabled, SecurityGroups: []string{ "sg2", // link }, }, }, PlatformFamily: new("family"), PlatformVersion: new("LATEST"), Tags: []types.Tag{}, TaskSets: []types.TaskSet{ // This seems to be able to return the *entire* task set, // which is redundant info. We should remove everything // other than the IDs { Id: new("id"), // link, then remove }, }, }, }, }, nil } func (t *ecsTestClient) ListServices(context.Context, *ecs.ListServicesInput, ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) { return &ecs.ListServicesOutput{ ServiceArns: []string{ "arn:aws:ecs:eu-west-1:052392120703:service/ecs-template-ECSCluster-8nS0WOLbs3nZ/ecs-template-service-i0mQKzkhDI2C", }, }, nil } func TestServiceGetFunc(t *testing.T) { item, err := serviceGetFunc(context.Background(), &ecsTestClient{}, "foo", &ecs.DescribeServicesInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "ecs-cluster", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ecs:eu-west-1:052392120703:cluster/ecs-template-ECSCluster-8nS0WOLbs3nZ", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-1:052392120703:targetgroup/ECSTG/0c44b1cdb3437902", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "servicediscovery-service", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type:name", ExpectedScope: "account.region", }, { ExpectedType: "ecs-task-definition", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "ecs-task-definition", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "ecs-capacity-provider", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "provider", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet", ExpectedScope: "foo", }, { ExpectedType: "ecs-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg1", ExpectedScope: "foo", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "www.foo.com", ExpectedScope: "global", }, { ExpectedType: "servicediscovery-service", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:layer:name:version", ExpectedScope: "account.region", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet2", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg2", ExpectedScope: "foo", }, { ExpectedType: "ecs-task-set", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewECSServiceAdapter(t *testing.T) { client, account, region := ecsGetAutoConfig(t) adapter := NewECSServiceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipNotFoundCheck: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/ecs-task-definition.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // TaskDefinitionIncludeFields Fields that we want included by default var TaskDefinitionIncludeFields = []types.TaskDefinitionField{ types.TaskDefinitionFieldTags, } func taskDefinitionGetFunc(ctx context.Context, client ECSClient, scope string, input *ecs.DescribeTaskDefinitionInput) (*sdp.Item, error) { out, err := client.DescribeTaskDefinition(ctx, input) if err != nil { return nil, err } if out.TaskDefinition == nil { return nil, errors.New("task definition is nil") } td := out.TaskDefinition attributes, err := ToAttributesWithExclude(td) if err != nil { return nil, err } // Set a custom attribute that we will use for a unique attribute in the // format: {family}:{revision} if td.Family == nil { return nil, errors.New("task definition family was nil") } item := sdp.Item{ Type: "ecs-task-definition", UniqueAttribute: "Family", Attributes: attributes, Scope: scope, Tags: ecsTagsToMap(out.Tags), } switch td.Status { case types.TaskDefinitionStatusActive: item.Health = sdp.Health_HEALTH_OK.Enum() case types.TaskDefinitionStatusInactive: item.Health = nil case types.TaskDefinitionStatusDeleteInProgress: item.Health = sdp.Health_HEALTH_WARNING.Enum() } var a *ARN var link *sdp.LinkedItemQuery for _, cd := range td.ContainerDefinitions { for _, secret := range cd.Secrets { link = getSecretLinkedItem(secret) if link != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, link) } } if cd.LogConfiguration != nil { for _, secret := range cd.LogConfiguration.SecretOptions { link = getSecretLinkedItem(secret) if link != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, link) } } } newQueries, err := sdp.ExtractLinksFrom(cd.Environment) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, newQueries...) } } if td.ExecutionRoleArn != nil { if a, err = ParseARN(*td.ExecutionRoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *td.ExecutionRoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if td.TaskRoleArn != nil { if a, err = ParseARN(*td.TaskRoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *td.TaskRoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } return &item, nil } // getSecretLinkedItem Converts a `types.Secret` to the linked item that the // secret is related to, if relevant func getSecretLinkedItem(secret types.Secret) *sdp.LinkedItemQuery { if secret.ValueFrom != nil { if a, err := ParseARN(*secret.ValueFrom); err == nil { // The secret can refer to either something from secrets // manager or SSN, so handle this secretScope := FormatScope(a.AccountID, a.Region) switch a.Service { case "secretsmanager": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "secretsmanager-secret", Method: sdp.QueryMethod_SEARCH, Query: *secret.ValueFrom, Scope: secretScope, }, } case "ssm": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ssm-parameter", Method: sdp.QueryMethod_SEARCH, Query: *secret.ValueFrom, Scope: secretScope, }, } } } } return nil } func NewECSTaskDefinitionAdapter(client ECSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*ecs.ListTaskDefinitionsInput, *ecs.ListTaskDefinitionsOutput, *ecs.DescribeTaskDefinitionInput, *ecs.DescribeTaskDefinitionOutput, ECSClient, *ecs.Options] { return &AlwaysGetAdapter[*ecs.ListTaskDefinitionsInput, *ecs.ListTaskDefinitionsOutput, *ecs.DescribeTaskDefinitionInput, *ecs.DescribeTaskDefinitionOutput, ECSClient, *ecs.Options]{ ItemType: "ecs-task-definition", Client: client, AccountID: accountID, Region: region, GetFunc: taskDefinitionGetFunc, ListInput: &ecs.ListTaskDefinitionsInput{}, AdapterMetadata: taskDefinitionAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *ecs.DescribeTaskDefinitionInput { // AWS actually supports "family:revision" format as an input here // so we can just push it in directly return &ecs.DescribeTaskDefinitionInput{ TaskDefinition: new(query), } }, ListFuncPaginatorBuilder: func(client ECSClient, input *ecs.ListTaskDefinitionsInput) Paginator[*ecs.ListTaskDefinitionsOutput, *ecs.Options] { return ecs.NewListTaskDefinitionsPaginator(client, input) }, ListFuncOutputMapper: func(output *ecs.ListTaskDefinitionsOutput, input *ecs.ListTaskDefinitionsInput) ([]*ecs.DescribeTaskDefinitionInput, error) { getInputs := make([](*ecs.DescribeTaskDefinitionInput), 0) for _, arn := range output.TaskDefinitionArns { if a, err := ParseARN(arn); err == nil { getInputs = append(getInputs, &ecs.DescribeTaskDefinitionInput{ TaskDefinition: new(a.ResourceID()), }) } } return getInputs, nil }, } } var taskDefinitionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ecs-task-definition", DescriptiveName: "Task Definition", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a task definition by revision name ({family}:{revision})", ListDescription: "List all task definitions", SearchDescription: "Search for task definitions by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_ecs_task_definition.family"}, }, PotentialLinks: []string{"iam-role", "secretsmanager-secret", "ssm-parameter"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ecs-task-definition_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *ecsTestClient) DescribeTaskDefinition(ctx context.Context, params *ecs.DescribeTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) { return &ecs.DescribeTaskDefinitionOutput{ TaskDefinition: &types.TaskDefinition{ TaskDefinitionArn: new("arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1"), ContainerDefinitions: []types.ContainerDefinition{ { Name: new("simple-app"), Image: new("httpd:2.4"), Cpu: 10, Memory: new(int32(300)), Links: []string{}, PortMappings: []types.PortMapping{ { ContainerPort: new(int32(80)), HostPort: new(int32(0)), Protocol: types.TransportProtocolTcp, AppProtocol: types.ApplicationProtocolHttp, }, }, Essential: new(true), EntryPoint: []string{}, Command: []string{}, Environment: []types.KeyValuePair{ { Name: new("DATABASE_SERVER"), Value: new("database01.my-company.com"), }, }, EnvironmentFiles: []types.EnvironmentFile{}, MountPoints: []types.MountPoint{ { SourceVolume: new("my-vol"), ContainerPath: new("/usr/local/apache2/htdocs"), ReadOnly: new(false), }, }, VolumesFrom: []types.VolumeFrom{ { SourceContainer: new("container"), }, }, Secrets: []types.Secret{ { Name: new("secrets-manager"), ValueFrom: new("arn:aws:secretsmanager:us-west-2:123456789012:secret:my-path/my-secret-name-1a2b3c"), // link }, { Name: new("ssm"), ValueFrom: new("arn:aws:ssm:us-east-2:123456789012:parameter/prod-123"), // link }, }, DnsServers: []string{}, DnsSearchDomains: []string{}, ExtraHosts: []types.HostEntry{ { Hostname: new("host"), IpAddress: new("127.0.0.1"), }, }, DockerSecurityOptions: []string{}, DockerLabels: map[string]string{}, Ulimits: []types.Ulimit{}, LogConfiguration: &types.LogConfiguration{ LogDriver: types.LogDriverAwslogs, Options: map[string]string{ "awslogs-group": "ECSLogGroup-ecs-template", "awslogs-region": "eu-west-1", "awslogs-stream-prefix": "ecs-demo-app", }, SecretOptions: []types.Secret{ { Name: new("secrets-manager"), ValueFrom: new("arn:aws:secretsmanager:us-west-2:123456789012:secret:my-path/my-secret-name-1a2b3c"), // link }, { Name: new("ssm"), ValueFrom: new("arn:aws:ssm:us-east-2:123456789012:parameter/prod-123"), // link }, }, }, SystemControls: []types.SystemControl{}, DependsOn: []types.ContainerDependency{}, DisableNetworking: new(false), FirelensConfiguration: &types.FirelensConfiguration{ Type: types.FirelensConfigurationTypeFluentd, Options: map[string]string{}, }, HealthCheck: &types.HealthCheck{}, Hostname: new("hostname"), Interactive: new(false), LinuxParameters: &types.LinuxParameters{}, MemoryReservation: new(int32(100)), Privileged: new(false), PseudoTerminal: new(false), ReadonlyRootFilesystem: new(false), RepositoryCredentials: &types.RepositoryCredentials{}, // Skipping the link here for now, if you need it, add it in a PR ResourceRequirements: []types.ResourceRequirement{}, StartTimeout: new(int32(1)), StopTimeout: new(int32(1)), User: new("foo"), WorkingDirectory: new("/"), }, { Name: new("busybox"), Image: new("busybox"), Cpu: 10, Memory: new(int32(200)), Essential: new(false), EntryPoint: []string{ "sh", "-c", }, Command: []string{ "/bin/sh -c \"while true; do echo ' Amazon ECS Sample App

Amazon ECS Sample App

Congratulations!

Your application is now running on a container in Amazon ECS.

' > top; /bin/date > date ; echo '
' > bottom; cat top date bottom > /usr/local/apache2/htdocs/index.html ; sleep 1; done\"", }, VolumesFrom: []types.VolumeFrom{ { SourceContainer: new("simple-app"), }, }, DockerLabels: map[string]string{}, LogConfiguration: &types.LogConfiguration{ LogDriver: types.LogDriverAwslogs, Options: map[string]string{ "awslogs-group": "ECSLogGroup-ecs-template", "awslogs-region": "eu-west-1", "awslogs-stream-prefix": "ecs-demo-app", }, }, }, }, Family: new("ecs-template-ecs-demo-app"), Revision: 1, Volumes: []types.Volume{ { Name: new("my-vol"), Host: &types.HostVolumeProperties{ SourcePath: new("/"), }, }, }, Status: types.TaskDefinitionStatusActive, RequiresAttributes: []types.Attribute{ { Name: new("com.amazonaws.ecs.capability.logging-driver.awslogs"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.19"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.17"), }, { Name: new("com.amazonaws.ecs.capability.docker-remote-api.1.18"), }, }, PlacementConstraints: []types.TaskDefinitionPlacementConstraint{}, Compatibilities: []types.Compatibility{ "EXTERNAL", "EC2", }, RegisteredAt: new(time.Now()), RegisteredBy: new("arn:aws:sts::052392120703:assumed-role/AWSReservedSSO_AWSAdministratorAccess_c1c3c9c54821c68a/dylan@overmind.tech"), Cpu: new("cpu"), DeregisteredAt: new(time.Now()), EphemeralStorage: &types.EphemeralStorage{ SizeInGiB: 1, }, ExecutionRoleArn: new("arn:aws:iam:us-east-2:123456789012:role/foo"), // link InferenceAccelerators: []types.InferenceAccelerator{}, IpcMode: types.IpcModeHost, Memory: new("memory"), NetworkMode: types.NetworkModeAwsvpc, PidMode: types.PidModeHost, ProxyConfiguration: nil, RequiresCompatibilities: []types.Compatibility{}, RuntimePlatform: &types.RuntimePlatform{ CpuArchitecture: types.CPUArchitectureX8664, OperatingSystemFamily: types.OSFamilyLinux, }, TaskRoleArn: new("arn:aws:iam:us-east-2:123456789012:role/bar"), // link }, }, nil } func (t *ecsTestClient) ListTaskDefinitions(context.Context, *ecs.ListTaskDefinitionsInput, ...func(*ecs.Options)) (*ecs.ListTaskDefinitionsOutput, error) { return &ecs.ListTaskDefinitionsOutput{ TaskDefinitionArns: []string{ "arn:aws:ecs:eu-west-1:052392120703:task-definition/ecs-template-ecs-demo-app:1", }, }, nil } func TestTaskDefinitionGetFunc(t *testing.T) { item, err := taskDefinitionGetFunc(context.Background(), &ecsTestClient{}, "foo", &ecs.DescribeTaskDefinitionInput{ TaskDefinition: new("ecs-template-ecs-demo-app:1"), }) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "secretsmanager-secret", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:secretsmanager:us-west-2:123456789012:secret:my-path/my-secret-name-1a2b3c", ExpectedScope: "123456789012.us-west-2", }, { ExpectedType: "ssm-parameter", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ssm:us-east-2:123456789012:parameter/prod-123", ExpectedScope: "123456789012.us-east-2", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam:us-east-2:123456789012:role/foo", ExpectedScope: "123456789012.us-east-2", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam:us-east-2:123456789012:role/bar", ExpectedScope: "123456789012.us-east-2", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "database01.my-company.com", ExpectedScope: "global", }, } tests.Execute(t, item) } func TestNewECSTaskDefinitionAdapter(t *testing.T) { client, account, region := ecsGetAutoConfig(t) adapter := NewECSTaskDefinitionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/ecs-task.go ================================================ package adapters import ( "context" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // TaskIncludeFields Fields that we want included by default var TaskIncludeFields = []types.TaskField{ types.TaskFieldTags, } func taskGetFunc(ctx context.Context, client ECSClient, scope string, input *ecs.DescribeTasksInput) (*sdp.Item, error) { out, err := client.DescribeTasks(ctx, input) if err != nil { return nil, err } if len(out.Tasks) != 1 { return nil, fmt.Errorf("expected 1 task, got %v", len(out.Tasks)) } task := out.Tasks[0] attributes, err := ToAttributesWithExclude(task, "tags") if err != nil { return nil, err } if task.TaskArn == nil { return nil, errors.New("task has nil ARN") } a, err := ParseARN(*task.TaskArn) if err != nil { return nil, err } // Create unique attribute in the format {clusterName}/{id} // test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2 attributes.Set("Id", a.ResourceID()) item := sdp.Item{ Type: "ecs-task", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, Tags: ecsTagsToMap(task.Tags), } switch task.HealthStatus { case types.HealthStatusHealthy: item.Health = sdp.Health_HEALTH_OK.Enum() case types.HealthStatusUnhealthy: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.HealthStatusUnknown: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } for _, attachment := range task.Attachments { if attachment.Type != nil { if *attachment.Type == "ElasticNetworkInterface" { if attachment.Id != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: *attachment.Id, Scope: scope, }, }) } } } } if task.ClusterArn != nil { if a, err = ParseARN(*task.ClusterArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-cluster", Method: sdp.QueryMethod_SEARCH, Query: *task.ClusterArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if task.ContainerInstanceArn != nil { if a, err = ParseARN(*task.ContainerInstanceArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-container-instance", Method: sdp.QueryMethod_GET, Query: a.ResourceID(), Scope: scope, }, }) } } for _, container := range task.Containers { for _, ni := range container.NetworkInterfaces { if ni.Ipv6Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ni.Ipv6Address, Scope: "global", }, }) } if ni.PrivateIpv4Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ni.PrivateIpv4Address, Scope: "global", }, }) } } } if task.TaskDefinitionArn != nil { if a, err = ParseARN(*task.TaskDefinitionArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ecs-task-definition", Method: sdp.QueryMethod_SEARCH, Query: *task.TaskDefinitionArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } return &item, nil } func taskGetInputMapper(scope, query string) *ecs.DescribeTasksInput { // `id` is {clusterName}/{id} so split on '/' sections := strings.Split(query, "/") if len(sections) != 2 { return nil } return &ecs.DescribeTasksInput{ Tasks: []string{ sections[1], }, Cluster: new(sections[0]), Include: TaskIncludeFields, } } func tasksListFuncOutputMapper(output *ecs.ListTasksOutput, input *ecs.ListTasksInput) ([]*ecs.DescribeTasksInput, error) { inputs := make([]*ecs.DescribeTasksInput, 0) for _, taskArn := range output.TaskArns { if a, err := ParseARN(taskArn); err == nil { // split the cluster name out sections := strings.Split(a.ResourceID(), "/") if len(sections) != 2 { continue } inputs = append(inputs, &ecs.DescribeTasksInput{ Tasks: []string{ sections[1], }, Cluster: §ions[0], Include: TaskIncludeFields, }) } } return inputs, nil } func NewECSTaskAdapter(client ECSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*ecs.ListTasksInput, *ecs.ListTasksOutput, *ecs.DescribeTasksInput, *ecs.DescribeTasksOutput, ECSClient, *ecs.Options] { return &AlwaysGetAdapter[*ecs.ListTasksInput, *ecs.ListTasksOutput, *ecs.DescribeTasksInput, *ecs.DescribeTasksOutput, ECSClient, *ecs.Options]{ ItemType: "ecs-task", Client: client, AccountID: accountID, Region: region, GetFunc: taskGetFunc, AdapterMetadata: ecsTaskAdapterMetadata, cache: cache, ListInput: &ecs.ListTasksInput{}, GetInputMapper: taskGetInputMapper, DisableList: true, SearchInputMapper: func(scope, query string) (*ecs.ListTasksInput, error) { // Search by cluster return &ecs.ListTasksInput{ Cluster: new(query), }, nil }, ListFuncPaginatorBuilder: func(client ECSClient, input *ecs.ListTasksInput) Paginator[*ecs.ListTasksOutput, *ecs.Options] { return ecs.NewListTasksPaginator(client, input) }, ListFuncOutputMapper: tasksListFuncOutputMapper, } } var ecsTaskAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ecs-task", DescriptiveName: "ECS Task", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an ECS task by ID", SearchDescription: "Search for ECS tasks by cluster", }, PotentialLinks: []string{"ecs-cluster", "ecs-container-instance", "ecs-task-definition", "ec2-network-interface", "ip"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/ecs-task_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *ecsTestClient) DescribeTasks(ctx context.Context, params *ecs.DescribeTasksInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) { return &ecs.DescribeTasksOutput{ Tasks: []types.Task{ { Attachments: []types.Attachment{ { Id: new("id"), // link? Status: new("OK"), Type: new("ElasticNetworkInterface"), }, }, Attributes: []types.Attribute{ { Name: new("ecs.cpu-architecture"), Value: new("x86_64"), }, }, AvailabilityZone: new("eu-west-1c"), ClusterArn: new("arn:aws:ecs:eu-west-1:052392120703:cluster/test-ECSCluster-Bt4SqcM3CURk"), // link Connectivity: types.ConnectivityConnected, ConnectivityAt: new(time.Now()), ContainerInstanceArn: new("arn:aws:ecs:eu-west-1:052392120703:container-instance/test-ECSCluster-Bt4SqcM3CURk/4b5c1d7dbb6746b38ada1b97b1866f6a"), // link Containers: []types.Container{ { ContainerArn: new("arn:aws:ecs:eu-west-1:052392120703:container/test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2/39a3ede1-1b28-472e-967a-d87d691f65e0"), TaskArn: new("arn:aws:ecs:eu-west-1:052392120703:task/test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2"), Name: new("busybox"), Image: new("busybox"), RuntimeId: new("7c158f5c2711416cbb6e653ad90997346489c9722c59d1115ad2121dd040748e"), LastStatus: new("RUNNING"), NetworkBindings: []types.NetworkBinding{}, NetworkInterfaces: []types.NetworkInterface{}, HealthStatus: types.HealthStatusUnknown, Cpu: new("10"), Memory: new("200"), }, { ContainerArn: new("arn:aws:ecs:eu-west-1:052392120703:container/test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2/8f3db814-6b39-4cc0-9d0a-a7d5702175eb"), TaskArn: new("arn:aws:ecs:eu-west-1:052392120703:task/test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2"), Name: new("simple-app"), Image: new("httpd:2.4"), RuntimeId: new("7316b64efb397cececce7cc5f39c6d48ab454f904cc80009aef5ed01ebdb1333"), LastStatus: new("RUNNING"), NetworkBindings: []types.NetworkBinding{ { BindIP: new("0.0.0.0"), // Link? NetworkSocket? ContainerPort: new(int32(80)), HostPort: new(int32(32768)), Protocol: types.TransportProtocolTcp, }, }, NetworkInterfaces: []types.NetworkInterface{ { AttachmentId: new("attachmentId"), Ipv6Address: new("2001:db8:3333:4444:5555:6666:7777:8888"), // link PrivateIpv4Address: new("10.0.0.1"), // link }, }, HealthStatus: types.HealthStatusUnknown, Cpu: new("10"), Memory: new("300"), }, }, Cpu: new("20"), CreatedAt: new(time.Now()), DesiredStatus: new("RUNNING"), EnableExecuteCommand: false, Group: new("service:test-service-lszmaXSqRKuF"), HealthStatus: types.HealthStatusUnknown, LastStatus: new("RUNNING"), LaunchType: types.LaunchTypeEc2, Memory: new("500"), Overrides: &types.TaskOverride{ ContainerOverrides: []types.ContainerOverride{ { Name: new("busybox"), }, { Name: new("simple-app"), }, }, InferenceAcceleratorOverrides: []types.InferenceAcceleratorOverride{}, }, PullStartedAt: new(time.Now()), PullStoppedAt: new(time.Now()), StartedAt: new(time.Now()), StartedBy: new("ecs-svc/0710912874193920929"), Tags: []types.Tag{}, TaskArn: new("arn:aws:ecs:eu-west-1:052392120703:task/test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2"), TaskDefinitionArn: new("arn:aws:ecs:eu-west-1:052392120703:task-definition/test-ecs-demo-app:1"), // link Version: 3, EphemeralStorage: &types.EphemeralStorage{ SizeInGiB: 1, }, }, }, }, nil } func (t *ecsTestClient) ListTasks(context.Context, *ecs.ListTasksInput, ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) { return &ecs.ListTasksOutput{ TaskArns: []string{ "arn:aws:ecs:eu-west-1:052392120703:task/test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2", }, }, nil } func TestTaskGetInputMapper(t *testing.T) { t.Run("test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2", func(t *testing.T) { input := taskGetInputMapper("foo", "test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2") if input == nil { t.Fatal("input is nil") return } if *input.Cluster != "test-ECSCluster-Bt4SqcM3CURk" { t.Errorf("expected cluster to be test-ECSCluster-Bt4SqcM3CURk, got %v", *input.Cluster) } if input.Tasks[0] != "2ffd7ed376c841bcb0e6795ddb6e72e2" { t.Errorf("expected task to be 2ffd7ed376c841bcb0e6795ddb6e72e2, got %v", input.Tasks[0]) } }) t.Run("2ffd7ed376c841bcb0e6795ddb6e72e2", func(t *testing.T) { input := taskGetInputMapper("foo", "2ffd7ed376c841bcb0e6795ddb6e72e2") if input != nil { t.Error("expected input to be nil") } }) t.Run("blah", func(t *testing.T) { input := taskGetInputMapper("foo", "blah") if input != nil { t.Error("expected input to be nil") } }) } func TestTasksListFuncOutputMapper(t *testing.T) { inputs, err := tasksListFuncOutputMapper(&ecs.ListTasksOutput{ TaskArns: []string{ "arn:aws:ecs:eu-west-1:052392120703:task/test-ECSCluster-Bt4SqcM3CURk/2ffd7ed376c841bcb0e6795ddb6e72e2", "bad", }, }, &ecs.ListTasksInput{}) if err != nil { t.Error(err) } if len(inputs) != 1 { t.Fatalf("expected 1 input, got %v", len(inputs)) } if *inputs[0].Cluster != "test-ECSCluster-Bt4SqcM3CURk" { t.Errorf("expected cluster to be test-ECSCluster-Bt4SqcM3CURk, got %v", *inputs[0].Cluster) } if inputs[0].Tasks[0] != "2ffd7ed376c841bcb0e6795ddb6e72e2" { t.Errorf("expected task to be 2ffd7ed376c841bcb0e6795ddb6e72e2, got %v", inputs[0].Tasks[0]) } } func TestTaskGetFunc(t *testing.T) { item, err := taskGetFunc(context.Background(), &ecsTestClient{}, "foo", &ecs.DescribeTasksInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ecs-cluster", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ecs:eu-west-1:052392120703:cluster/test-ECSCluster-Bt4SqcM3CURk", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "ecs-container-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-ECSCluster-Bt4SqcM3CURk/4b5c1d7dbb6746b38ada1b97b1866f6a", ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:db8:3333:4444:5555:6666:7777:8888", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { ExpectedType: "ecs-task-definition", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ecs:eu-west-1:052392120703:task-definition/test-ecs-demo-app:1", ExpectedScope: "052392120703.eu-west-1", }, } tests.Execute(t, item) } func TestNewECSTaskAdapter(t *testing.T) { client, account, region := ecsGetAutoConfig(t) adapter := NewECSTaskAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipNotFoundCheck: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/ecs.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" ) type ECSClient interface { DescribeClusters(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) DescribeCapacityProviders(ctx context.Context, params *ecs.DescribeCapacityProvidersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeCapacityProvidersOutput, error) DescribeContainerInstances(ctx context.Context, params *ecs.DescribeContainerInstancesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeContainerInstancesOutput, error) DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) DescribeTaskDefinition(ctx context.Context, params *ecs.DescribeTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) DescribeTasks(ctx context.Context, params *ecs.DescribeTasksInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) ecs.ListClustersAPIClient ecs.ListContainerInstancesAPIClient ecs.ListServicesAPIClient ecs.ListTaskDefinitionsAPIClient ecs.ListTasksAPIClient } // convertTags converts slice of ecs tags to a map func ecsTagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } return tagsMap } ================================================ FILE: aws-source/adapters/ecs_test.go ================================================ package adapters import ( "testing" "github.com/aws/aws-sdk-go-v2/service/ecs" ) type ecsTestClient struct{} func ecsGetAutoConfig(t *testing.T) (*ecs.Client, string, string) { config, account, region := GetAutoConfig(t) client := ecs.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/efs-access-point.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func AccessPointOutputMapper(_ context.Context, _ *efs.Client, scope string, input *efs.DescribeAccessPointsInput, output *efs.DescribeAccessPointsOutput) ([]*sdp.Item, error) { if output == nil { return nil, errors.New("nil output from AWS") } items := make([]*sdp.Item, 0) for _, ap := range output.AccessPoints { attrs, err := ToAttributesWithExclude(ap, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "efs-access-point", UniqueAttribute: "AccessPointId", Scope: scope, Attributes: attrs, Health: lifeCycleStateToHealth(ap.LifeCycleState), Tags: efsTagsToMap(ap.Tags), } if ap.FileSystemId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "efs-file-system", Method: sdp.QueryMethod_GET, Query: *ap.FileSystemId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEFSAccessPointAdapter(client *efs.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*efs.DescribeAccessPointsInput, *efs.DescribeAccessPointsOutput, *efs.Client, *efs.Options] { return &DescribeOnlyAdapter[*efs.DescribeAccessPointsInput, *efs.DescribeAccessPointsOutput, *efs.Client, *efs.Options]{ ItemType: "efs-access-point", Region: region, Client: client, AccountID: accountID, AdapterMetadata: accessPointAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *efs.Client, input *efs.DescribeAccessPointsInput) (*efs.DescribeAccessPointsOutput, error) { return client.DescribeAccessPoints(ctx, input) }, PaginatorBuilder: func(client *efs.Client, params *efs.DescribeAccessPointsInput) Paginator[*efs.DescribeAccessPointsOutput, *efs.Options] { return efs.NewDescribeAccessPointsPaginator(client, params) }, InputMapperGet: func(scope, query string) (*efs.DescribeAccessPointsInput, error) { return &efs.DescribeAccessPointsInput{ AccessPointId: &query, }, nil }, InputMapperList: func(scope string) (*efs.DescribeAccessPointsInput, error) { return &efs.DescribeAccessPointsInput{}, nil }, OutputMapper: AccessPointOutputMapper, } } var accessPointAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "efs-access-point", DescriptiveName: "EFS Access Point", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an access point by ID", ListDescription: "List all access points", SearchDescription: "Search for an access point by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_efs_access_point.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/efs-access-point_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/aws/aws-sdk-go-v2/service/efs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestAccessPointOutputMapper(t *testing.T) { output := &efs.DescribeAccessPointsOutput{ AccessPoints: []types.AccessPointDescription{ { AccessPointArn: new("arn:aws:elasticfilesystem:eu-west-2:944651592624:access-point/fsap-073b1534eafbc5ee2"), AccessPointId: new("fsap-073b1534eafbc5ee2"), ClientToken: new("pvc-66e4418c-edf5-4a0e-9834-5945598d51fe"), FileSystemId: new("fs-0c6f2f41e957f42a9"), LifeCycleState: types.LifeCycleStateAvailable, Name: new("example access point"), OwnerId: new("944651592624"), PosixUser: &types.PosixUser{ Gid: new(int64(1000)), Uid: new(int64(1000)), SecondaryGids: []int64{ 1002, }, }, RootDirectory: &types.RootDirectory{ CreationInfo: &types.CreationInfo{ OwnerGid: new(int64(1000)), OwnerUid: new(int64(1000)), Permissions: new("700"), }, Path: new("/etc/foo"), }, Tags: []types.Tag{ { Key: new("Name"), Value: new("example access point"), }, }, }, }, } items, err := AccessPointOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "efs-file-system", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "fs-0c6f2f41e957f42a9", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEFSAccessPointAdapter(t *testing.T) { client, account, region := efsGetAutoConfig(t) adapter := NewEFSAccessPointAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/efs-backup-policy.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func BackupPolicyOutputMapper(_ context.Context, _ *efs.Client, scope string, input *efs.DescribeBackupPolicyInput, output *efs.DescribeBackupPolicyOutput) ([]*sdp.Item, error) { if output == nil { return nil, errors.New("nil output from AWS") } if output.BackupPolicy == nil { return nil, errors.New("output contains no backup policy") } if input == nil { return nil, errors.New("nil input") } if input.FileSystemId == nil { return nil, errors.New("nil filesystem ID on input") } attrs, err := ToAttributesWithExclude(output) if err != nil { return nil, err } // Add the filesystem ID as an attribute err = attrs.Set("FileSystemId", *input.FileSystemId) if err != nil { return nil, err } item := sdp.Item{ Type: "efs-backup-policy", UniqueAttribute: "FileSystemId", Scope: scope, Attributes: attrs, } return []*sdp.Item{&item}, nil } func NewEFSBackupPolicyAdapter(client *efs.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*efs.DescribeBackupPolicyInput, *efs.DescribeBackupPolicyOutput, *efs.Client, *efs.Options] { return &DescribeOnlyAdapter[*efs.DescribeBackupPolicyInput, *efs.DescribeBackupPolicyOutput, *efs.Client, *efs.Options]{ ItemType: "efs-backup-policy", Region: region, Client: client, AccountID: accountID, AdapterMetadata: backupPolicyAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *efs.Client, input *efs.DescribeBackupPolicyInput) (*efs.DescribeBackupPolicyOutput, error) { return client.DescribeBackupPolicy(ctx, input) }, InputMapperGet: func(scope, query string) (*efs.DescribeBackupPolicyInput, error) { return &efs.DescribeBackupPolicyInput{ FileSystemId: &query, }, nil }, OutputMapper: BackupPolicyOutputMapper, } } var backupPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "efs-backup-policy", DescriptiveName: "EFS Backup Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an Backup Policy by file system ID", SearchDescription: "Search for an Backup Policy by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_efs_backup_policy.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) ================================================ FILE: aws-source/adapters/efs-backup-policy_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/aws/aws-sdk-go-v2/service/efs/types" ) func TestBackupPolicyOutputMapper(t *testing.T) { output := &efs.DescribeBackupPolicyOutput{ BackupPolicy: &types.BackupPolicy{ Status: types.StatusEnabled, }, } items, err := BackupPolicyOutputMapper(context.Background(), nil, "foo", &efs.DescribeBackupPolicyInput{ FileSystemId: new("fs-1234"), }, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } } ================================================ FILE: aws-source/adapters/efs-file-system.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func FileSystemOutputMapper(_ context.Context, _ *efs.Client, scope string, input *efs.DescribeFileSystemsInput, output *efs.DescribeFileSystemsOutput) ([]*sdp.Item, error) { if output == nil { return nil, errors.New("nil output from AWS") } items := make([]*sdp.Item, 0) for _, fs := range output.FileSystems { attrs, err := ToAttributesWithExclude(fs, "tags") if err != nil { return nil, err } if fs.FileSystemId == nil { return nil, errors.New("filesystem has nil id") } item := sdp.Item{ Type: "efs-file-system", UniqueAttribute: "FileSystemId", Scope: scope, Attributes: attrs, Health: lifeCycleStateToHealth(fs.LifeCycleState), Tags: efsTagsToMap(fs.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "efs-backup-policy", Method: sdp.QueryMethod_GET, Query: *fs.FileSystemId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "efs-mount-target", Method: sdp.QueryMethod_SEARCH, Query: *fs.FileSystemId, Scope: scope, }, }, }, } if fs.KmsKeyId != nil { // KMS key ID is an ARN if arn, err := ParseARN(*fs.KmsKeyId); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *fs.KmsKeyId, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } items = append(items, &item) } return items, nil } func NewEFSFileSystemAdapter(client *efs.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*efs.DescribeFileSystemsInput, *efs.DescribeFileSystemsOutput, *efs.Client, *efs.Options] { return &DescribeOnlyAdapter[*efs.DescribeFileSystemsInput, *efs.DescribeFileSystemsOutput, *efs.Client, *efs.Options]{ ItemType: "efs-file-system", Region: region, Client: client, AccountID: accountID, AdapterMetadata: efsFileSystemAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *efs.Client, input *efs.DescribeFileSystemsInput) (*efs.DescribeFileSystemsOutput, error) { return client.DescribeFileSystems(ctx, input) }, PaginatorBuilder: func(client *efs.Client, params *efs.DescribeFileSystemsInput) Paginator[*efs.DescribeFileSystemsOutput, *efs.Options] { return efs.NewDescribeFileSystemsPaginator(client, params) }, InputMapperGet: func(scope, query string) (*efs.DescribeFileSystemsInput, error) { return &efs.DescribeFileSystemsInput{ FileSystemId: &query, }, nil }, InputMapperList: func(scope string) (*efs.DescribeFileSystemsInput, error) { return &efs.DescribeFileSystemsInput{}, nil }, OutputMapper: FileSystemOutputMapper, } } var efsFileSystemAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "efs-file-system", DescriptiveName: "EFS File System", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a file system by ID", ListDescription: "List file systems", SearchDescription: "Search file systems by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_efs_file_system.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) ================================================ FILE: aws-source/adapters/efs-file-system_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/aws/aws-sdk-go-v2/service/efs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestFileSystemOutputMapper(t *testing.T) { output := &efs.DescribeFileSystemsOutput{ FileSystems: []types.FileSystemDescription{ { CreationTime: new(time.Now()), CreationToken: new("TOKEN"), FileSystemId: new("fs-1231123123"), LifeCycleState: types.LifeCycleStateAvailable, NumberOfMountTargets: 10, OwnerId: new("944651592624"), PerformanceMode: types.PerformanceModeGeneralPurpose, SizeInBytes: &types.FileSystemSize{ Value: 1024, Timestamp: new(time.Now()), ValueInIA: new(int64(2048)), ValueInStandard: new(int64(128)), }, Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, AvailabilityZoneId: new("use1-az1"), AvailabilityZoneName: new("us-east-1"), Encrypted: new(true), FileSystemArn: new("arn:aws:elasticfilesystem:eu-west-2:944651592624:file-system/fs-0c6f2f41e957f42a9"), KmsKeyId: new("arn:aws:kms:eu-west-2:944651592624:key/be76a6fa-d307-41c2-a4e3-cbfba2440747"), Name: new("test"), ProvisionedThroughputInMibps: new(float64(64)), ThroughputMode: types.ThroughputModeBursting, }, }, } items, err := FileSystemOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "efs-backup-policy", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "fs-1231123123", ExpectedScope: "foo", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:eu-west-2:944651592624:key/be76a6fa-d307-41c2-a4e3-cbfba2440747", ExpectedScope: "944651592624.eu-west-2", }, { ExpectedType: "efs-mount-target", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "fs-1231123123", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEFSFileSystemAdapter(t *testing.T) { client, account, region := efsGetAutoConfig(t) adapter := NewEFSFileSystemAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/efs-mount-target.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func MountTargetOutputMapper(_ context.Context, _ *efs.Client, scope string, input *efs.DescribeMountTargetsInput, output *efs.DescribeMountTargetsOutput) ([]*sdp.Item, error) { if output == nil { return nil, errors.New("nil output from AWS") } items := make([]*sdp.Item, 0) for _, mt := range output.MountTargets { attrs, err := ToAttributesWithExclude(mt) if err != nil { return nil, err } if mt.MountTargetId == nil { return nil, errors.New("efs-mount-target has nil id") } if mt.FileSystemId == nil { return nil, errors.New("efs-mount-target has nil file system ID") } item := sdp.Item{ Type: "efs-mount-target", UniqueAttribute: "MountTargetId", Scope: scope, Attributes: attrs, Health: lifeCycleStateToHealth(mt.LifeCycleState), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "efs-file-system", Method: sdp.QueryMethod_GET, Query: *mt.FileSystemId, Scope: scope, }, }, }, } if mt.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *mt.SubnetId, Scope: scope, }, }) } if mt.IpAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *mt.IpAddress, Scope: "global", }, }) } if mt.NetworkInterfaceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-network-interface", Method: sdp.QueryMethod_GET, Query: *mt.NetworkInterfaceId, Scope: scope, }, }) } if mt.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *mt.VpcId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewEFSMountTargetAdapter(client *efs.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*efs.DescribeMountTargetsInput, *efs.DescribeMountTargetsOutput, *efs.Client, *efs.Options] { return &DescribeOnlyAdapter[*efs.DescribeMountTargetsInput, *efs.DescribeMountTargetsOutput, *efs.Client, *efs.Options]{ ItemType: "efs-mount-target", Region: region, Client: client, AccountID: accountID, AdapterMetadata: efsMountTargetAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *efs.Client, input *efs.DescribeMountTargetsInput) (*efs.DescribeMountTargetsOutput, error) { return client.DescribeMountTargets(ctx, input) }, InputMapperGet: func(scope, query string) (*efs.DescribeMountTargetsInput, error) { return &efs.DescribeMountTargetsInput{ MountTargetId: &query, }, nil }, // Search by file system ID InputMapperSearch: func(ctx context.Context, client *efs.Client, scope, query string) (*efs.DescribeMountTargetsInput, error) { return &efs.DescribeMountTargetsInput{ FileSystemId: &query, }, nil }, OutputMapper: MountTargetOutputMapper, } } var efsMountTargetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "efs-mount-target", DescriptiveName: "EFS Mount Target", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an mount target by ID", SearchDescription: "Search for mount targets by file system ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_efs_mount_target.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) ================================================ FILE: aws-source/adapters/efs-mount-target_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/aws/aws-sdk-go-v2/service/efs/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestMountTargetOutputMapper(t *testing.T) { output := &efs.DescribeMountTargetsOutput{ MountTargets: []types.MountTargetDescription{ { FileSystemId: new("fs-1234567890"), LifeCycleState: types.LifeCycleStateAvailable, MountTargetId: new("fsmt-01e86506d8165e43f"), SubnetId: new("subnet-1234567"), AvailabilityZoneId: new("use1-az1"), AvailabilityZoneName: new("us-east-1"), IpAddress: new("10.230.43.1"), NetworkInterfaceId: new("eni-2345"), OwnerId: new("234234"), VpcId: new("vpc-23452345235"), }, }, } items, err := MountTargetOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "efs-file-system", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "fs-1234567890", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-1234567", ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.230.43.1", ExpectedScope: "global", }, { ExpectedType: "ec2-network-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "eni-2345", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-23452345235", ExpectedScope: "foo", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/efs-replication-configuration.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/aws/aws-sdk-go-v2/service/efs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func ReplicationConfigurationOutputMapper(_ context.Context, _ *efs.Client, scope string, input *efs.DescribeReplicationConfigurationsInput, output *efs.DescribeReplicationConfigurationsOutput) ([]*sdp.Item, error) { if output == nil { return nil, errors.New("nil output from AWS") } items := make([]*sdp.Item, 0) for _, replication := range output.Replications { attrs, err := ToAttributesWithExclude(replication) if err != nil { return nil, err } if replication.SourceFileSystemId == nil { return nil, errors.New("efs-replication-configuration has nil SourceFileSystemId") } if replication.SourceFileSystemRegion == nil { return nil, errors.New("efs-replication-configuration has nil SourceFileSystemRegion") } accountID, _, err := ParseScope(scope) if err != nil { return nil, err } item := sdp.Item{ Type: "efs-replication-configuration", UniqueAttribute: "SourceFileSystemId", Scope: scope, Attributes: attrs, Health: sdp.Health_HEALTH_OK.Enum(), // Default to OK LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "efs-file-system", Method: sdp.QueryMethod_GET, Query: *replication.SourceFileSystemId, Scope: FormatScope(accountID, *replication.SourceFileSystemRegion), }, }, }, } for _, destination := range replication.Destinations { if destination.FileSystemId != nil && destination.Region != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "efs-file-system", Method: sdp.QueryMethod_GET, Query: *destination.FileSystemId, Scope: FormatScope(accountID, *destination.Region), }, }) } } // Set the health to the worst of the statuses var hasError bool for _, destination := range replication.Destinations { switch destination.Status { //nolint:exhaustive // handled by default case case types.ReplicationStatusError: item.Health = sdp.Health_HEALTH_ERROR.Enum() hasError = true case types.ReplicationStatusEnabling: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ReplicationStatusDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ReplicationStatusPausing: item.Health = sdp.Health_HEALTH_PENDING.Enum() default: // If there's no error, we don't need to do anything } if hasError { break } } if replication.OriginalSourceFileSystemArn != nil { if arn, err := ParseARN(*replication.OriginalSourceFileSystemArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "efs-file-system", Method: sdp.QueryMethod_SEARCH, Query: *replication.OriginalSourceFileSystemArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } items = append(items, &item) } return items, nil } func NewEFSReplicationConfigurationAdapter(client *efs.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*efs.DescribeReplicationConfigurationsInput, *efs.DescribeReplicationConfigurationsOutput, *efs.Client, *efs.Options] { return &DescribeOnlyAdapter[*efs.DescribeReplicationConfigurationsInput, *efs.DescribeReplicationConfigurationsOutput, *efs.Client, *efs.Options]{ ItemType: "efs-replication-configuration", Region: region, Client: client, AccountID: accountID, AdapterMetadata: replicationConfigurationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *efs.Client, input *efs.DescribeReplicationConfigurationsInput) (*efs.DescribeReplicationConfigurationsOutput, error) { return client.DescribeReplicationConfigurations(ctx, input) }, InputMapperGet: func(scope, query string) (*efs.DescribeReplicationConfigurationsInput, error) { return &efs.DescribeReplicationConfigurationsInput{ FileSystemId: &query, }, nil }, InputMapperList: func(scope string) (*efs.DescribeReplicationConfigurationsInput, error) { return &efs.DescribeReplicationConfigurationsInput{}, nil }, OutputMapper: ReplicationConfigurationOutputMapper, } } var replicationConfigurationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "efs-replication-configuration", DescriptiveName: "EFS Replication Configuration", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a replication configuration by file system ID", ListDescription: "List all replication configurations", SearchDescription: "Search for a replication configuration by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_efs_replication_configuration.source_file_system_id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) ================================================ FILE: aws-source/adapters/efs-replication-configuration_test.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/aws/aws-sdk-go-v2/service/efs/types" "github.com/overmindtech/cli/go/sdp-go" "testing" "time" ) func TestReplicationConfigurationOutputMapper(t *testing.T) { output := &efs.DescribeReplicationConfigurationsOutput{ Replications: []types.ReplicationConfigurationDescription{ { CreationTime: new(time.Now()), Destinations: []types.Destination{ { FileSystemId: new("fs-12345678"), Region: new("eu-west-1"), Status: types.ReplicationStatusEnabled, LastReplicatedTimestamp: new(time.Now()), }, { FileSystemId: new("fs-98765432"), Region: new("us-west-2"), Status: types.ReplicationStatusError, LastReplicatedTimestamp: new(time.Now()), }, }, OriginalSourceFileSystemArn: new("arn:aws:elasticfilesystem:eu-west-2:944651592624:file-system/fs-0c6f2f41e957f42a9"), SourceFileSystemArn: new("arn:aws:elasticfilesystem:eu-west-2:944651592624:file-system/fs-0c6f2f41e957f42a9"), SourceFileSystemId: new("fs-748927493"), SourceFileSystemRegion: new("us-east-1"), }, }, } accountID := "1234" items, err := ReplicationConfigurationOutputMapper(context.Background(), nil, FormatScope(accountID, "eu-west-1"), nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "efs-file-system", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "fs-748927493", ExpectedScope: FormatScope(accountID, "us-east-1"), }, { ExpectedType: "efs-file-system", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "fs-12345678", ExpectedScope: FormatScope(accountID, "eu-west-1"), }, { ExpectedType: "efs-file-system", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "fs-98765432", ExpectedScope: FormatScope(accountID, "us-west-2"), }, { ExpectedType: "efs-file-system", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticfilesystem:eu-west-2:944651592624:file-system/fs-0c6f2f41e957f42a9", ExpectedScope: "944651592624.eu-west-2", }, } tests.Execute(t, item) if item.GetHealth() != sdp.Health_HEALTH_ERROR { t.Errorf("expected health to be ERROR, got %v", item.GetHealth().String()) } } ================================================ FILE: aws-source/adapters/efs.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/efs/types" "github.com/overmindtech/cli/go/sdp-go" ) // lifeCycleStateToHealth Converts a lifecycle state to a health state func lifeCycleStateToHealth(state types.LifeCycleState) *sdp.Health { switch state { case types.LifeCycleStateCreating: return sdp.Health_HEALTH_PENDING.Enum() case types.LifeCycleStateAvailable: return sdp.Health_HEALTH_OK.Enum() case types.LifeCycleStateUpdating: return sdp.Health_HEALTH_PENDING.Enum() case types.LifeCycleStateDeleting: return sdp.Health_HEALTH_PENDING.Enum() case types.LifeCycleStateDeleted: return sdp.Health_HEALTH_WARNING.Enum() case types.LifeCycleStateError: return sdp.Health_HEALTH_ERROR.Enum() } return nil } // Converts a slice of tags to a map func efsTagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } return tagsMap } ================================================ FILE: aws-source/adapters/efs_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/efs" "testing" ) func efsGetAutoConfig(t *testing.T) (*efs.Client, string, string) { config, account, region := GetAutoConfig(t) client := efs.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/eks-addon.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func addonGetFunc(ctx context.Context, client EKSClient, scope string, input *eks.DescribeAddonInput) (*sdp.Item, error) { out, err := client.DescribeAddon(ctx, input) if err != nil { return nil, err } if out.Addon == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "addon was nil", } } attributes, err := ToAttributesWithExclude(out.Addon) if err != nil { return nil, err } // The uniqueAttributeValue for this is a custom field: // {clusterName}:{addonName} attributes.Set("UniqueName", (*out.Addon.ClusterName + ":" + *out.Addon.AddonName)) item := sdp.Item{ Type: "eks-addon", UniqueAttribute: "UniqueName", Attributes: attributes, Scope: scope, } return &item, nil } func NewEKSAddonAdapter(client EKSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*eks.ListAddonsInput, *eks.ListAddonsOutput, *eks.DescribeAddonInput, *eks.DescribeAddonOutput, EKSClient, *eks.Options] { return &AlwaysGetAdapter[*eks.ListAddonsInput, *eks.ListAddonsOutput, *eks.DescribeAddonInput, *eks.DescribeAddonOutput, EKSClient, *eks.Options]{ ItemType: "eks-addon", Client: client, AccountID: accountID, Region: region, AdapterMetadata: eksAddonAdapterMetadata, cache: cache, DisableList: true, SearchInputMapper: func(scope, query string) (*eks.ListAddonsInput, error) { return &eks.ListAddonsInput{ ClusterName: &query, }, nil }, GetInputMapper: func(scope, query string) *eks.DescribeAddonInput { // The uniqueAttributeValue for this is a custom field: // {clusterName}:{addonName} fields := strings.Split(query, ":") var clusterName string var addonName string if len(fields) == 2 { clusterName = fields[0] addonName = fields[1] } return &eks.DescribeAddonInput{ AddonName: &addonName, ClusterName: &clusterName, } }, ListFuncPaginatorBuilder: func(client EKSClient, input *eks.ListAddonsInput) Paginator[*eks.ListAddonsOutput, *eks.Options] { return eks.NewListAddonsPaginator(client, input) }, ListFuncOutputMapper: func(output *eks.ListAddonsOutput, input *eks.ListAddonsInput) ([]*eks.DescribeAddonInput, error) { inputs := make([]*eks.DescribeAddonInput, 0, len(output.Addons)) for i := range output.Addons { inputs = append(inputs, &eks.DescribeAddonInput{ AddonName: &output.Addons[i], ClusterName: input.ClusterName, }) } return inputs, nil }, GetFunc: addonGetFunc, } } var eksAddonAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "eks-addon", DescriptiveName: "EKS Addon", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an addon by unique name ({clusterName}:{addonName})", SearchDescription: "Search addons by cluster name", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_eks_addon.id", }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/eks-addon_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/aws/aws-sdk-go-v2/service/eks/types" "github.com/overmindtech/cli/go/sdpcache" ) var AddonTestClient = EKSTestClient{ DescribeAddonOutput: &eks.DescribeAddonOutput{ Addon: &types.Addon{ AddonName: new("aws-ebs-csi-driver"), ClusterName: new("dylan"), Status: types.AddonStatusActive, AddonVersion: new("v1.13.0-eksbuild.3"), ConfigurationValues: new("values"), MarketplaceInformation: &types.MarketplaceInformation{ ProductId: new("id"), ProductUrl: new("url"), }, Publisher: new("publisher"), Owner: new("owner"), Health: &types.AddonHealth{ Issues: []types.AddonIssue{}, }, AddonArn: new("arn:aws:eks:eu-west-2:801795385023:addon/dylan/aws-ebs-csi-driver/a2c29d0e-72c4-a702-7887-2f739f4fc189"), CreatedAt: new(time.Now()), ModifiedAt: new(time.Now()), ServiceAccountRoleArn: new("arn:aws:iam::801795385023:role/eks-csi-dylan"), }, }, } func TestAddonGetFunc(t *testing.T) { item, err := addonGetFunc(context.Background(), AddonTestClient, "foo", &eks.DescribeAddonInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewEKSAddonAdapter(t *testing.T) { client, account, region := eksGetAutoConfig(t) adapter := NewEKSAddonAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipNotFoundCheck: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/eks-cluster.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/aws/aws-sdk-go-v2/service/eks/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func clusterGetFunc(ctx context.Context, client EKSClient, scope string, input *eks.DescribeClusterInput) (*sdp.Item, error) { output, err := client.DescribeCluster(ctx, input) if err != nil { return nil, err } if output.Cluster == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "cluster response was nil", } } cluster := output.Cluster attributes, err := ToAttributesWithExclude(cluster, "clientRequestToken") if err != nil { return nil, err } item := sdp.Item{ Type: "eks-cluster", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, Tags: cluster.Tags, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "eks-addon", Method: sdp.QueryMethod_SEARCH, Query: *cluster.Name, Scope: scope, }, }, { Query: &sdp.Query{ Type: "eks-fargate-profile", Method: sdp.QueryMethod_SEARCH, Query: *cluster.Name, Scope: scope, }, }, { Query: &sdp.Query{ Type: "eks-nodegroup", Method: sdp.QueryMethod_SEARCH, Query: *cluster.Name, Scope: scope, }, }, }, } switch cluster.Status { case types.ClusterStatusCreating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ClusterStatusActive: item.Health = sdp.Health_HEALTH_OK.Enum() case types.ClusterStatusDeleting: item.Health = sdp.Health_HEALTH_WARNING.Enum() case types.ClusterStatusFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.ClusterStatusUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ClusterStatusPending: item.Health = sdp.Health_HEALTH_PENDING.Enum() } var a *ARN if cluster.ConnectorConfig != nil { if cluster.ConnectorConfig.RoleArn != nil { if a, err = ParseARN(*cluster.ConnectorConfig.RoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *cluster.ConnectorConfig.RoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } for _, conf := range cluster.EncryptionConfig { if conf.Provider != nil { if conf.Provider.KeyArn != nil { if a, err = ParseARN(*conf.Provider.KeyArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *conf.Provider.KeyArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } if cluster.Endpoint != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *cluster.Endpoint, Scope: "global", }, }) } if cluster.ResourcesVpcConfig != nil { if cluster.ResourcesVpcConfig.ClusterSecurityGroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *cluster.ResourcesVpcConfig.ClusterSecurityGroupId, Scope: scope, }, }) } for _, id := range cluster.ResourcesVpcConfig.SecurityGroupIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } for _, id := range cluster.ResourcesVpcConfig.SubnetIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } if cluster.ResourcesVpcConfig.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *cluster.ResourcesVpcConfig.VpcId, Scope: scope, }, }) } } if cluster.RoleArn != nil { if a, err = ParseARN(*cluster.RoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *cluster.RoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } return &item, nil } func NewEKSClusterAdapter(client EKSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*eks.ListClustersInput, *eks.ListClustersOutput, *eks.DescribeClusterInput, *eks.DescribeClusterOutput, EKSClient, *eks.Options] { return &AlwaysGetAdapter[*eks.ListClustersInput, *eks.ListClustersOutput, *eks.DescribeClusterInput, *eks.DescribeClusterOutput, EKSClient, *eks.Options]{ ItemType: "eks-cluster", Client: client, AccountID: accountID, Region: region, AdapterMetadata: eksClusterAdapterMetadata, cache: cache, ListInput: &eks.ListClustersInput{}, GetInputMapper: func(scope, query string) *eks.DescribeClusterInput { return &eks.DescribeClusterInput{ Name: &query, } }, ListFuncPaginatorBuilder: func(client EKSClient, input *eks.ListClustersInput) Paginator[*eks.ListClustersOutput, *eks.Options] { return eks.NewListClustersPaginator(client, input) }, ListFuncOutputMapper: func(output *eks.ListClustersOutput, _ *eks.ListClustersInput) ([]*eks.DescribeClusterInput, error) { inputs := make([]*eks.DescribeClusterInput, 0, len(output.Clusters)) for i := range output.Clusters { inputs = append(inputs, &eks.DescribeClusterInput{ Name: &output.Clusters[i], }) } return inputs, nil }, GetFunc: clusterGetFunc, } } var eksClusterAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "eks-cluster", DescriptiveName: "EKS Cluster", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a cluster by name", ListDescription: "List all clusters", SearchDescription: "Search for clusters by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_eks_cluster.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/eks-cluster_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/aws/aws-sdk-go-v2/service/eks/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var ClusterClient = EKSTestClient{ DescribeClusterOutput: &eks.DescribeClusterOutput{ Cluster: &types.Cluster{ Name: new("dylan"), Arn: new("arn:aws:eks:eu-west-2:801795385023:cluster/dylan"), CreatedAt: new(time.Now()), Version: new("1.24"), Endpoint: new("https://00D3FF4CC48CBAA9BBC070DAA80BD251.gr7.eu-west-2.eks.amazonaws.com"), RoleArn: new("arn:aws:iam::801795385023:role/dylan-cluster-20221222134106992100000001"), ClientRequestToken: new("token"), ConnectorConfig: &types.ConnectorConfigResponse{ ActivationCode: new("code"), ActivationExpiry: new(time.Now()), ActivationId: new("id"), Provider: new("provider"), RoleArn: new("arn:aws:iam::801795385023:role/dylan-cluster-20221222134106992100000002"), }, Health: &types.ClusterHealth{ Issues: []types.ClusterIssue{}, }, Id: new("id"), OutpostConfig: &types.OutpostConfigResponse{ ControlPlaneInstanceType: new("type"), OutpostArns: []string{ "arn1", }, ControlPlanePlacement: &types.ControlPlanePlacementResponse{ GroupName: new("groupName"), }, }, ResourcesVpcConfig: &types.VpcConfigResponse{ SubnetIds: []string{ "subnet-0d1fabfe6794b5543", "subnet-0865943940092d10a", "subnet-00ed8275954eca233", }, SecurityGroupIds: []string{ "sg-0bf38eb7e14777399", }, ClusterSecurityGroupId: new("sg-08df96f08566d4dda"), VpcId: new("vpc-0c9152ce7ed2b7305"), EndpointPublicAccess: true, EndpointPrivateAccess: true, PublicAccessCidrs: []string{ "0.0.0.0/0", }, }, KubernetesNetworkConfig: &types.KubernetesNetworkConfigResponse{ ServiceIpv4Cidr: new("172.20.0.0/16"), IpFamily: types.IpFamilyIpv4, ServiceIpv6Cidr: new("ipv6cidr"), }, Logging: &types.Logging{ ClusterLogging: []types.LogSetup{ { Types: []types.LogType{ "api", "authenticator", "controllerManager", "scheduler", }, Enabled: new(true), }, { Types: []types.LogType{ "audit", }, Enabled: new(false), }, }, }, Identity: &types.Identity{ Oidc: &types.OIDC{ Issuer: new("https://oidc.eks.eu-west-2.amazonaws.com/id/00D3FF4CC48CBAA9BBC070DAA80BD251"), }, }, Status: types.ClusterStatusActive, CertificateAuthority: &types.Certificate{ Data: new("LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMvakNDQWVhZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJeU1USXlNakV6TkRZME5Gb1hEVE15TVRJeE9URXpORFkwTkZvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTC9tCkN6b25QdUZIUXM1a0xudzdCeXMrak9pNWJscEVCN2RhZUYvQzZqaEVTbkcwdVBVRjVWSFUzbmRyZHRKelBaemQKenM4U1pEMzRsKytGWmw0NFQrYWRqMGFYanpmZ0NTeFo4K0MvaWJUOWIzck5jWU9ZZ3FYT1lXc2JVYmpBSjRadgpnakFqdEl3dTBvUHNYT0JSZU5KTDlhRkl6VFFIcy9QL1hONWI5eGRlSHhwOXN4cnlEREYxQVNuQkxwajduUHMrCmgyNUtvd0hQV1luekV6WVd1T3NZbDQ2RjZacHh4aVhya2hnOGozckR4dXRWZGMvQVBFaVhUdHh3OU9CMjFDMkwKK1VpanpxS2RrZm5idVEvOHF0TTRqbFVGTkgzUG03STlkTEdIMTBTOFdhQkhpODNRMklCd3c0eE5RZ04xNC91dgpXWFZOWkxmM1EwbElkdmtxaCtrQ0F3RUFBYU5aTUZjd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0hRWURWUjBPQkJZRUZCa2wvVEJwNVNyMFJrVEk2V1dMVkR4MVdZYUxNQlVHQTFVZEVRUU8KTUF5Q0NtdDFZbVZ5Ym1WMFpYTXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBQ0FCVWtZUWZSQXlRRFVsc2todgp2NTRZN3lFQ1lUSG00OWVtMWoyV2hyN0JPdXdlUkU4M3g1b0NhWEtjK2tMemlvOEVvY2hxOWN1a1FEYm1KNkpoCmRhUUlyaFFwaG5PMHZSd290YXlhWjdlV2IwTm50WmNxN1ZmNkp5ZU5CR3Y1NTJGdlNNcGprWnh0UXVpTTJ5TXoKbjJWWmtxMzJPb0RjTmxCMERhRVBCSjlIM2ZnbG1qcGdWL0NHZFdMNG1wNEpkb3VPNTFtNkJBMm1ET2JWYzh4VgppNFJIWE9KNG9hSGFTd1B6MHBuQUxabkJoUnpxV0Q1cGlycVlucjBxSlFDamJDWXF1TmJTU3d4c2JMYVFjanNFCjhiUXk0aGxXaEJNWno3UldOeDg1UTBZSjhWNEhKdXVCZ09MaVg1REFtNDZIbndWUy95MHJyN2JTWThoTXErM2QKTmtrPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="), }, PlatformVersion: new("eks.3"), Tags: map[string]string{}, EncryptionConfig: []types.EncryptionConfig{ { Resources: []string{ "secrets", }, Provider: &types.Provider{ KeyArn: new("arn:aws:kms:eu-west-2:801795385023:key/3a478539-9717-4c20-83a5-19989154dc32"), }, }, }, }, }, } func TestClusterGetFunc(t *testing.T) { item, err := clusterGetFunc(context.Background(), ClusterClient, "foo", &eks.DescribeClusterInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::801795385023:role/dylan-cluster-20221222134106992100000002", ExpectedScope: "801795385023", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:eu-west-2:801795385023:key/3a478539-9717-4c20-83a5-19989154dc32", ExpectedScope: "801795385023.eu-west-2", }, { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://00D3FF4CC48CBAA9BBC070DAA80BD251.gr7.eu-west-2.eks.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-0bf38eb7e14777399", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-08df96f08566d4dda", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0d1fabfe6794b5543", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0865943940092d10a", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-00ed8275954eca233", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0c9152ce7ed2b7305", ExpectedScope: item.GetScope(), }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::801795385023:role/dylan-cluster-20221222134106992100000001", ExpectedScope: "801795385023", }, { ExpectedType: "eks-fargate-profile", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dylan", ExpectedScope: item.GetScope(), }, { ExpectedType: "eks-addon", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dylan", ExpectedScope: item.GetScope(), }, { ExpectedType: "eks-nodegroup", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dylan", ExpectedScope: item.GetScope(), }, } tests.Execute(t, item) } func TestNewEKSClusterAdapter(t *testing.T) { client, account, region := eksGetAutoConfig(t) adapter := NewEKSClusterAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/eks-fargate-profile.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func fargateProfileGetFunc(ctx context.Context, client EKSClient, scope string, input *eks.DescribeFargateProfileInput) (*sdp.Item, error) { out, err := client.DescribeFargateProfile(ctx, input) if err != nil { return nil, err } if out.FargateProfile == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "fargate profile was nil", } } attributes, err := ToAttributesWithExclude(out.FargateProfile) if err != nil { return nil, err } // The uniqueAttributeValue for this is a custom field: // {clusterName}:{FargateProfileName} attributes.Set("UniqueName", (*out.FargateProfile.ClusterName + ":" + *out.FargateProfile.FargateProfileName)) item := sdp.Item{ Type: "eks-fargate-profile", UniqueAttribute: "UniqueName", Attributes: attributes, Scope: scope, Tags: out.FargateProfile.Tags, } if out.FargateProfile.PodExecutionRoleArn != nil { if a, err := ParseARN(*out.FargateProfile.PodExecutionRoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *out.FargateProfile.PodExecutionRoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, subnet := range out.FargateProfile.Subnets { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: subnet, Scope: scope, }, }) } return &item, nil } func NewEKSFargateProfileAdapter(client EKSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*eks.ListFargateProfilesInput, *eks.ListFargateProfilesOutput, *eks.DescribeFargateProfileInput, *eks.DescribeFargateProfileOutput, EKSClient, *eks.Options] { return &AlwaysGetAdapter[*eks.ListFargateProfilesInput, *eks.ListFargateProfilesOutput, *eks.DescribeFargateProfileInput, *eks.DescribeFargateProfileOutput, EKSClient, *eks.Options]{ ItemType: "eks-fargate-profile", Client: client, AccountID: accountID, Region: region, DisableList: true, AlwaysSearchARNs: true, AdapterMetadata: fargateProfileAdapterMetadata, cache: cache, SearchInputMapper: func(scope, query string) (*eks.ListFargateProfilesInput, error) { return &eks.ListFargateProfilesInput{ ClusterName: &query, }, nil }, GetInputMapper: func(scope, query string) *eks.DescribeFargateProfileInput { // The uniqueAttributeValue for this is a custom field: // {clusterName}/{FargateProfileName} fields := strings.Split(query, ":") var clusterName string var FargateProfileName string if len(fields) == 2 { clusterName = fields[0] FargateProfileName = fields[1] } return &eks.DescribeFargateProfileInput{ FargateProfileName: &FargateProfileName, ClusterName: &clusterName, } }, ListFuncPaginatorBuilder: func(client EKSClient, input *eks.ListFargateProfilesInput) Paginator[*eks.ListFargateProfilesOutput, *eks.Options] { return eks.NewListFargateProfilesPaginator(client, input) }, ListFuncOutputMapper: func(output *eks.ListFargateProfilesOutput, input *eks.ListFargateProfilesInput) ([]*eks.DescribeFargateProfileInput, error) { inputs := make([]*eks.DescribeFargateProfileInput, 0, len(output.FargateProfileNames)) for i := range output.FargateProfileNames { inputs = append(inputs, &eks.DescribeFargateProfileInput{ ClusterName: input.ClusterName, FargateProfileName: &output.FargateProfileNames[i], }) } return inputs, nil }, GetFunc: fargateProfileGetFunc, } } var fargateProfileAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "eks-fargate-profile", DescriptiveName: "Fargate Profile", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a fargate profile by unique name ({clusterName}:{FargateProfileName})", SearchDescription: "Search for fargate profiles by cluster name", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_eks_fargate_profile.id", TerraformMethod: sdp.QueryMethod_GET, }, }, PotentialLinks: []string{"iam-role", "ec2-subnet"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/eks-fargate-profile_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/aws/aws-sdk-go-v2/service/eks/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var FargateTestClient = EKSTestClient{ DescribeFargateProfileOutput: &eks.DescribeFargateProfileOutput{ FargateProfile: &types.FargateProfile{ ClusterName: new("cluster"), CreatedAt: new(time.Now()), FargateProfileArn: new("arn:partition:service:region:account-id:resource-type/resource-id"), FargateProfileName: new("name"), PodExecutionRoleArn: new("arn:partition:service::account-id:resource-type/resource-id"), Selectors: []types.FargateProfileSelector{ { Labels: map[string]string{}, Namespace: new("namespace"), }, }, Status: types.FargateProfileStatusActive, Subnets: []string{ "subnet", }, Tags: map[string]string{}, }, }, } func TestFargateProfileGetFunc(t *testing.T) { item, err := fargateProfileGetFunc(context.Background(), FargateTestClient, "foo", &eks.DescribeFargateProfileInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service::account-id:resource-type/resource-id", ExpectedScope: "account-id", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewEKSFargateProfileAdapter(t *testing.T) { client, account, region := eksGetAutoConfig(t) adapter := NewEKSFargateProfileAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipNotFoundCheck: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/eks-nodegroup.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func nodegroupGetFunc(ctx context.Context, client EKSClient, scope string, input *eks.DescribeNodegroupInput) (*sdp.Item, error) { out, err := client.DescribeNodegroup(ctx, input) if err != nil { return nil, err } if out.Nodegroup == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "Nodegroup was nil", } } attributes, err := ToAttributesWithExclude(out.Nodegroup) if err != nil { return nil, err } ng := out.Nodegroup // The uniqueAttributeValue for this is a custom field: // {clusterName}:{NodegroupName} attributes.Set("UniqueName", (*out.Nodegroup.ClusterName + ":" + *out.Nodegroup.NodegroupName)) item := sdp.Item{ Type: "eks-nodegroup", UniqueAttribute: "UniqueName", Attributes: attributes, Scope: scope, Tags: out.Nodegroup.Tags, } if ng.Health != nil { if len(ng.Health.Issues) > 0 { item.Health = sdp.Health_HEALTH_ERROR.Enum() } else { item.Health = sdp.Health_HEALTH_OK.Enum() } // NOTE: It would be good if we could link to the resource if there is a // health issue, but I can't find any examples of the format that the // `ResourceIds` array is in. If someone can find one, please add it here. } if ng.RemoteAccess != nil { if ng.RemoteAccess.Ec2SshKey != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-key-pair", Method: sdp.QueryMethod_GET, Query: *ng.RemoteAccess.Ec2SshKey, Scope: scope, }, }) } for _, sg := range ng.RemoteAccess.SourceSecurityGroups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: sg, Scope: scope, }, }) } } for _, subnet := range ng.Subnets { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: subnet, Scope: scope, }, }) } if ng.Resources != nil { for _, g := range ng.Resources.AutoScalingGroups { if g.Name != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "autoscaling-auto-scaling-group", Method: sdp.QueryMethod_GET, Query: *g.Name, Scope: scope, }, }) } } if ng.Resources.RemoteAccessSecurityGroup != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *ng.Resources.RemoteAccessSecurityGroup, Scope: scope, }, }) } } if ng.LaunchTemplate != nil { if ng.LaunchTemplate.Id != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-launch-template", Method: sdp.QueryMethod_GET, Query: *ng.LaunchTemplate.Id, Scope: scope, }, }) } } return &item, nil } func NewEKSNodegroupAdapter(client EKSClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*eks.ListNodegroupsInput, *eks.ListNodegroupsOutput, *eks.DescribeNodegroupInput, *eks.DescribeNodegroupOutput, EKSClient, *eks.Options] { return &AlwaysGetAdapter[*eks.ListNodegroupsInput, *eks.ListNodegroupsOutput, *eks.DescribeNodegroupInput, *eks.DescribeNodegroupOutput, EKSClient, *eks.Options]{ ItemType: "eks-nodegroup", Client: client, AccountID: accountID, Region: region, AlwaysSearchARNs: true, AdapterMetadata: nodegroupAdapterMetadata, cache: cache, SearchInputMapper: func(scope, query string) (*eks.ListNodegroupsInput, error) { return &eks.ListNodegroupsInput{ ClusterName: &query, }, nil }, GetInputMapper: func(scope, query string) *eks.DescribeNodegroupInput { // The uniqueAttributeValue for this is a custom field: // {clusterName}:{nodegroupName} fields := strings.Split(query, ":") var clusterName string var nodegroupName string if len(fields) >= 2 { clusterName = fields[0] nodegroupName = fields[1] } return &eks.DescribeNodegroupInput{ NodegroupName: &nodegroupName, ClusterName: &clusterName, } }, ListFuncPaginatorBuilder: func(client EKSClient, input *eks.ListNodegroupsInput) Paginator[*eks.ListNodegroupsOutput, *eks.Options] { return eks.NewListNodegroupsPaginator(client, input) }, // While LIST queries are not supported for this adapter, we do support // SEARCH. Since a Search is handled like this // // Query -> SearchInputMapper -> ListFuncPaginatorBuilder -> // ListFuncOutputMapper // // We still need a ListFuncPaginatorBuilder and ListFuncOutputMapper to // ensure that SEARCH works DisableList: true, ListFuncOutputMapper: func(output *eks.ListNodegroupsOutput, input *eks.ListNodegroupsInput) ([]*eks.DescribeNodegroupInput, error) { inputs := make([]*eks.DescribeNodegroupInput, 0, len(output.Nodegroups)) for i := range output.Nodegroups { inputs = append(inputs, &eks.DescribeNodegroupInput{ ClusterName: input.ClusterName, NodegroupName: &output.Nodegroups[i], }) } return inputs, nil }, GetFunc: nodegroupGetFunc, } } var nodegroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "eks-nodegroup", DescriptiveName: "EKS Nodegroup", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: false, // LIST not supported Search: true, GetDescription: "Get a node group by unique name ({clusterName}:{NodegroupName})", SearchDescription: "Search for node groups by cluster name", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_eks_node_group.id", TerraformMethod: sdp.QueryMethod_GET, }, }, PotentialLinks: []string{"ec2-key-pair", "ec2-security-group", "ec2-subnet", "autoscaling-auto-scaling-group", "ec2-launch-template"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/eks-nodegroup_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/aws/aws-sdk-go-v2/service/eks/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var NodeGroupClient = EKSTestClient{ DescribeNodegroupOutput: &eks.DescribeNodegroupOutput{ Nodegroup: &types.Nodegroup{ NodegroupName: new("default-2022122213523169820000001f"), NodegroupArn: new("arn:aws:eks:eu-west-2:801795385023:nodegroup/dylan/default-2022122213523169820000001f/98c29d0d-b22a-aaa3-445e-ebf71d43f67c"), ClusterName: new("dylan"), Version: new("1.24"), ReleaseVersion: new("1.24.7-20221112"), CreatedAt: new(time.Now()), ModifiedAt: new(time.Now()), Status: types.NodegroupStatusActive, CapacityType: types.CapacityTypesOnDemand, DiskSize: new(int32(100)), RemoteAccess: &types.RemoteAccessConfig{ Ec2SshKey: new("key"), // link SourceSecurityGroups: []string{ "sg1", // link }, }, ScalingConfig: &types.NodegroupScalingConfig{ MinSize: new(int32(1)), MaxSize: new(int32(3)), DesiredSize: new(int32(1)), }, InstanceTypes: []string{ "T3large", }, Subnets: []string{ "subnet0d1fabfe6794b5543", // link }, AmiType: types.AMITypesAl2Arm64, NodeRole: new("arn:aws:iam::801795385023:role/default-eks-node-group-20221222134106992000000003"), Labels: map[string]string{}, Taints: []types.Taint{ { Effect: types.TaintEffectNoSchedule, Key: new("key"), Value: new("value"), }, }, Resources: &types.NodegroupResources{ AutoScalingGroups: []types.AutoScalingGroup{ { Name: new("eks-default-2022122213523169820000001f-98c29d0d-b22a-aaa3-445e-ebf71d43f67c"), // link }, }, RemoteAccessSecurityGroup: new("sg2"), // link }, Health: &types.NodegroupHealth{ Issues: []types.Issue{}, }, UpdateConfig: &types.NodegroupUpdateConfig{ MaxUnavailablePercentage: new(int32(33)), }, LaunchTemplate: &types.LaunchTemplateSpecification{ Name: new("default-2022122213523100410000001d"), // link Version: new("1"), Id: new("lt-097e994ce7e14fcdc"), }, Tags: map[string]string{}, }, }, } func TestNodegroupGetFunc(t *testing.T) { item, err := nodegroupGetFunc(context.Background(), NodeGroupClient, "foo", &eks.DescribeNodegroupInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-key-pair", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "key", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg1", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet0d1fabfe6794b5543", ExpectedScope: item.GetScope(), }, { ExpectedType: "autoscaling-auto-scaling-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "eks-default-2022122213523169820000001f-98c29d0d-b22a-aaa3-445e-ebf71d43f67c", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg2", ExpectedScope: item.GetScope(), }, { ExpectedType: "ec2-launch-template", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "lt-097e994ce7e14fcdc", ExpectedScope: item.GetScope(), }, } tests.Execute(t, item) } func TestNewEKSNodegroupAdapter(t *testing.T) { client, account, region := eksGetAutoConfig(t) adapter := NewEKSNodegroupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipNotFoundCheck: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/eks.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/eks" ) type EKSClient interface { ListClusters(context.Context, *eks.ListClustersInput, ...func(*eks.Options)) (*eks.ListClustersOutput, error) DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) ListAddons(context.Context, *eks.ListAddonsInput, ...func(*eks.Options)) (*eks.ListAddonsOutput, error) DescribeAddon(ctx context.Context, params *eks.DescribeAddonInput, optFns ...func(*eks.Options)) (*eks.DescribeAddonOutput, error) ListFargateProfiles(ctx context.Context, params *eks.ListFargateProfilesInput, optFns ...func(*eks.Options)) (*eks.ListFargateProfilesOutput, error) DescribeFargateProfile(ctx context.Context, params *eks.DescribeFargateProfileInput, optFns ...func(*eks.Options)) (*eks.DescribeFargateProfileOutput, error) ListIdentityProviderConfigs(ctx context.Context, params *eks.ListIdentityProviderConfigsInput, optFns ...func(*eks.Options)) (*eks.ListIdentityProviderConfigsOutput, error) DescribeIdentityProviderConfig(ctx context.Context, params *eks.DescribeIdentityProviderConfigInput, optFns ...func(*eks.Options)) (*eks.DescribeIdentityProviderConfigOutput, error) ListNodegroups(ctx context.Context, params *eks.ListNodegroupsInput, optFns ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) DescribeNodegroup(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) } ================================================ FILE: aws-source/adapters/eks_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/eks" ) type EKSTestClient struct { ListClustersOutput *eks.ListClustersOutput DescribeClusterOutput *eks.DescribeClusterOutput ListAddonsOutput *eks.ListAddonsOutput DescribeAddonOutput *eks.DescribeAddonOutput ListFargateProfilesOutput *eks.ListFargateProfilesOutput DescribeFargateProfileOutput *eks.DescribeFargateProfileOutput ListIdentityProviderConfigsOutput *eks.ListIdentityProviderConfigsOutput DescribeIdentityProviderConfigOutput *eks.DescribeIdentityProviderConfigOutput ListNodegroupsOutput *eks.ListNodegroupsOutput DescribeNodegroupOutput *eks.DescribeNodegroupOutput } func (t EKSTestClient) ListClusters(context.Context, *eks.ListClustersInput, ...func(*eks.Options)) (*eks.ListClustersOutput, error) { return t.ListClustersOutput, nil } func (t EKSTestClient) DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { return t.DescribeClusterOutput, nil } func (t EKSTestClient) ListAddons(context.Context, *eks.ListAddonsInput, ...func(*eks.Options)) (*eks.ListAddonsOutput, error) { return t.ListAddonsOutput, nil } func (t EKSTestClient) DescribeAddon(ctx context.Context, params *eks.DescribeAddonInput, optFns ...func(*eks.Options)) (*eks.DescribeAddonOutput, error) { return t.DescribeAddonOutput, nil } func (t EKSTestClient) ListFargateProfiles(ctx context.Context, params *eks.ListFargateProfilesInput, optFns ...func(*eks.Options)) (*eks.ListFargateProfilesOutput, error) { return t.ListFargateProfilesOutput, nil } func (t EKSTestClient) DescribeFargateProfile(ctx context.Context, params *eks.DescribeFargateProfileInput, optFns ...func(*eks.Options)) (*eks.DescribeFargateProfileOutput, error) { return t.DescribeFargateProfileOutput, nil } func (t EKSTestClient) ListIdentityProviderConfigs(ctx context.Context, params *eks.ListIdentityProviderConfigsInput, optFns ...func(*eks.Options)) (*eks.ListIdentityProviderConfigsOutput, error) { return t.ListIdentityProviderConfigsOutput, nil } func (t EKSTestClient) DescribeIdentityProviderConfig(ctx context.Context, params *eks.DescribeIdentityProviderConfigInput, optFns ...func(*eks.Options)) (*eks.DescribeIdentityProviderConfigOutput, error) { return t.DescribeIdentityProviderConfigOutput, nil } func (t EKSTestClient) ListNodegroups(ctx context.Context, params *eks.ListNodegroupsInput, optFns ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) { return t.ListNodegroupsOutput, nil } func (t EKSTestClient) DescribeNodegroup(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) { return t.DescribeNodegroupOutput, nil } func eksGetAutoConfig(t *testing.T) (*eks.Client, string, string) { config, account, region := GetAutoConfig(t) client := eks.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/elb-instance-health.go ================================================ package adapters import ( "context" "errors" "fmt" "strings" elb "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // InstanceHealthName Structured representation of an instance health's unique // name type InstanceHealthName struct { LoadBalancerName string InstanceId string } func (i InstanceHealthName) String() string { return fmt.Sprintf("%v/%v", i.LoadBalancerName, i.InstanceId) } func ParseInstanceName(name string) (InstanceHealthName, error) { sections := strings.Split(name, "/") if len(sections) != 2 { return InstanceHealthName{}, errors.New("instance health name did not have 2 sections separated by a forward slash") } return InstanceHealthName{ LoadBalancerName: sections[0], InstanceId: sections[1], }, nil } func instanceHealthOutputMapper(_ context.Context, _ *elb.Client, scope string, _ *elb.DescribeInstanceHealthInput, output *elb.DescribeInstanceHealthOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, is := range output.InstanceStates { attrs, err := ToAttributesWithExclude(is) if err != nil { return nil, err } item := sdp.Item{ Type: "elb-instance-health", UniqueAttribute: "InstanceId", Attributes: attrs, Scope: scope, } if is.State != nil { switch *is.State { case "InService": item.Health = sdp.Health_HEALTH_OK.Enum() case "OutOfService": item.Health = sdp.Health_HEALTH_ERROR.Enum() case "Unknown": item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } if is.InstanceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *is.InstanceId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewELBInstanceHealthAdapter(client *elb.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*elb.DescribeInstanceHealthInput, *elb.DescribeInstanceHealthOutput, *elb.Client, *elb.Options] { return &DescribeOnlyAdapter[*elb.DescribeInstanceHealthInput, *elb.DescribeInstanceHealthOutput, *elb.Client, *elb.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "elb-instance-health", AdapterMetadata: instanceHealthAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *elb.Client, input *elb.DescribeInstanceHealthInput) (*elb.DescribeInstanceHealthOutput, error) { return client.DescribeInstanceHealth(ctx, input) }, InputMapperGet: func(scope, query string) (*elb.DescribeInstanceHealthInput, error) { // This has a composite name defined by `InstanceHealthName` name, err := ParseInstanceName(query) if err != nil { return nil, err } return &elb.DescribeInstanceHealthInput{ LoadBalancerName: &name.LoadBalancerName, Instances: []types.Instance{ { InstanceId: &name.InstanceId, }, }, }, nil }, InputMapperList: func(scope string) (*elb.DescribeInstanceHealthInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for elb-instance-health, use search", } }, OutputMapper: instanceHealthOutputMapper, } } var instanceHealthAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "elb-instance-health", DescriptiveName: "ELB Instance Health", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, GetDescription: "Get instance health by ID ({LoadBalancerName}/{InstanceId})", ListDescription: "List all instance healths", }, PotentialLinks: []string{"ec2-instance"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, }) ================================================ FILE: aws-source/adapters/elb-instance-health_test.go ================================================ package adapters import ( "context" "testing" elb "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestInstanceHealthOutputMapper(t *testing.T) { output := elb.DescribeInstanceHealthOutput{ InstanceStates: []types.InstanceState{ { InstanceId: new("i-0337802d908b4a81e"), // link State: new("InService"), ReasonCode: new("N/A"), Description: new("N/A"), }, }, } items, err := instanceHealthOutputMapper(context.Background(), nil, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-0337802d908b4a81e", ExpectedScope: "foo", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/elb-load-balancer.go ================================================ package adapters import ( "context" "sync" elb "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type elbClient interface { DescribeTags(ctx context.Context, params *elb.DescribeTagsInput, optFns ...func(*elb.Options)) (*elb.DescribeTagsOutput, error) DescribeLoadBalancers(ctx context.Context, params *elb.DescribeLoadBalancersInput, optFns ...func(*elb.Options)) (*elb.DescribeLoadBalancersOutput, error) } func elbTagsToMap(tags []types.Tag) map[string]string { m := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { m[*tag.Key] = *tag.Value } } return m } // AWS DescribeTags API limits requests to 20 load balancers per call. // See: https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_DescribeTags.html const elbDescribeTagsMaxItems = 20 func elbGetTagsMap(ctx context.Context, client elbClient, loadBalancerNames []string) map[string][]types.Tag { tagsMap := make(map[string][]types.Tag) if len(loadBalancerNames) == 0 { return tagsMap } var mu sync.Mutex var wg sync.WaitGroup for i := 0; i < len(loadBalancerNames); i += elbDescribeTagsMaxItems { end := min(i+elbDescribeTagsMaxItems, len(loadBalancerNames)) chunk := loadBalancerNames[i:end] wg.Add(1) go func(chunk []string) { defer wg.Done() tagsOut, err := client.DescribeTags(ctx, &elb.DescribeTagsInput{ LoadBalancerNames: chunk, }) mu.Lock() defer mu.Unlock() if err != nil { tags := HandleTagsError(ctx, err) for _, loadBalancerName := range chunk { tagsMap[loadBalancerName] = tagsToELBTags(tags) } return } for _, tagDesc := range tagsOut.TagDescriptions { if tagDesc.LoadBalancerName != nil { tagsMap[*tagDesc.LoadBalancerName] = tagDesc.Tags } } }(chunk) } wg.Wait() return tagsMap } func tagsToELBTags(tags map[string]string) []types.Tag { elbTags := make([]types.Tag, 0, len(tags)) for key, value := range tags { elbTags = append(elbTags, types.Tag{ Key: &key, Value: &value, }) } return elbTags } func elbLoadBalancerOutputMapper(ctx context.Context, client elbClient, scope string, _ *elb.DescribeLoadBalancersInput, output *elb.DescribeLoadBalancersOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) loadBalancerNames := make([]string, 0) for _, desc := range output.LoadBalancerDescriptions { if desc.LoadBalancerName != nil { loadBalancerNames = append(loadBalancerNames, *desc.LoadBalancerName) } } // Map of load balancer name to tags tagsMap := elbGetTagsMap(ctx, client, loadBalancerNames) for _, desc := range output.LoadBalancerDescriptions { attrs, err := ToAttributesWithExclude(desc) if err != nil { return nil, err } var tags map[string]string if desc.LoadBalancerName != nil { m, ok := tagsMap[*desc.LoadBalancerName] if ok { tags = elbTagsToMap(m) } } item := sdp.Item{ Type: "elb-load-balancer", UniqueAttribute: "LoadBalancerName", Attributes: attrs, Scope: scope, Tags: tags, } if desc.DNSName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *desc.DNSName, Scope: "global", }}) } if desc.CanonicalHostedZoneName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *desc.CanonicalHostedZoneName, Scope: "global", }}) } if desc.CanonicalHostedZoneNameID != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *desc.CanonicalHostedZoneNameID, Scope: scope, }}) } for _, subnet := range desc.Subnets { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: subnet, Scope: scope, }}) } if desc.VPCId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *desc.VPCId, Scope: scope, }}) } for _, instance := range desc.Instances { if instance.InstanceId != nil { // The EC2 instance itself item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *instance.InstanceId, Scope: scope, }}) if desc.LoadBalancerName != nil { name := InstanceHealthName{ LoadBalancerName: *desc.LoadBalancerName, InstanceId: *instance.InstanceId, } // The health for that instance item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "elb-instance-health", Method: sdp.QueryMethod_GET, Query: name.String(), Scope: scope, }}) } } } if desc.SourceSecurityGroup != nil { if desc.SourceSecurityGroup.GroupName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_SEARCH, Query: *desc.SourceSecurityGroup.GroupName, Scope: scope, }}) } } for _, sg := range desc.SecurityGroups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: sg, Scope: scope, }}) } items = append(items, &item) } return items, nil } func NewELBLoadBalancerAdapter(client elbClient, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*elb.DescribeLoadBalancersInput, *elb.DescribeLoadBalancersOutput, elbClient, *elb.Options] { return &DescribeOnlyAdapter[*elb.DescribeLoadBalancersInput, *elb.DescribeLoadBalancersOutput, elbClient, *elb.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "elb-load-balancer", AdapterMetadata: elbLoadBalancerAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client elbClient, input *elb.DescribeLoadBalancersInput) (*elb.DescribeLoadBalancersOutput, error) { return client.DescribeLoadBalancers(ctx, input) }, InputMapperGet: func(scope, query string) (*elb.DescribeLoadBalancersInput, error) { return &elb.DescribeLoadBalancersInput{ LoadBalancerNames: []string{query}, }, nil }, InputMapperList: func(scope string) (*elb.DescribeLoadBalancersInput, error) { return &elb.DescribeLoadBalancersInput{}, nil }, PaginatorBuilder: func(client elbClient, params *elb.DescribeLoadBalancersInput) Paginator[*elb.DescribeLoadBalancersOutput, *elb.Options] { return elb.NewDescribeLoadBalancersPaginator(client, params) }, OutputMapper: elbLoadBalancerOutputMapper, } } var elbLoadBalancerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "elb-load-balancer", DescriptiveName: "Classic Load Balancer", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a classic load balancer by name", ListDescription: "List all classic load balancers", SearchDescription: "Search for classic load balancers by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_elb.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"dns", "route53-hosted-zone", "ec2-subnet", "ec2-vpc", "ec2-instance", "elb-instance-health", "ec2-security-group"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/elb-load-balancer_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" elb "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing/types" "github.com/overmindtech/cli/go/sdp-go" ) type mockElbClient struct{} func (m mockElbClient) DescribeTags(ctx context.Context, params *elb.DescribeTagsInput, optFns ...func(*elb.Options)) (*elb.DescribeTagsOutput, error) { if len(params.LoadBalancerNames) > elbDescribeTagsMaxItems { return nil, fmt.Errorf("cannot have more than %v resources described", elbDescribeTagsMaxItems) } tagDescriptions := make([]types.TagDescription, 0, len(params.LoadBalancerNames)) for _, name := range params.LoadBalancerNames { tagDescriptions = append(tagDescriptions, types.TagDescription{ LoadBalancerName: &name, Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, }) } return &elb.DescribeTagsOutput{ TagDescriptions: tagDescriptions, }, nil } func (m mockElbClient) DescribeLoadBalancers(ctx context.Context, params *elb.DescribeLoadBalancersInput, optFns ...func(*elb.Options)) (*elb.DescribeLoadBalancersOutput, error) { return nil, nil } func TestElbGetTagsMapBatching(t *testing.T) { t.Parallel() names := make([]string, 0, 25) for i := range 25 { names = append(names, fmt.Sprintf("load-balancer-%02d", i)) } tagsMap := elbGetTagsMap(context.Background(), mockElbClient{}, names) if len(tagsMap) != 25 { t.Fatalf("expected 25 tag entries, got %v", len(tagsMap)) } for _, name := range names { tags := elbTagsToMap(tagsMap[name]) if tags["foo"] != "bar" { t.Errorf("expected tag foo for %v to be bar, got %q", name, tags["foo"]) } } } func TestELBv2LoadBalancerOutputMapper(t *testing.T) { output := &elb.DescribeLoadBalancersOutput{ LoadBalancerDescriptions: []types.LoadBalancerDescription{ { LoadBalancerName: new("a8c3c8851f0df43fda89797c8e941a91"), DNSName: new("a8c3c8851f0df43fda89797c8e941a91-182843316.eu-west-2.elb.amazonaws.com"), // link CanonicalHostedZoneName: new("a8c3c8851f0df43fda89797c8e941a91-182843316.eu-west-2.elb.amazonaws.com"), // link CanonicalHostedZoneNameID: new("ZHURV8PSTC4K8"), // link ListenerDescriptions: []types.ListenerDescription{ { Listener: &types.Listener{ Protocol: new("TCP"), LoadBalancerPort: 7687, InstanceProtocol: new("TCP"), InstancePort: new(int32(30133)), }, PolicyNames: []string{}, }, { Listener: &types.Listener{ Protocol: new("TCP"), LoadBalancerPort: 7473, InstanceProtocol: new("TCP"), InstancePort: new(int32(31459)), }, PolicyNames: []string{}, }, { Listener: &types.Listener{ Protocol: new("TCP"), LoadBalancerPort: 7474, InstanceProtocol: new("TCP"), InstancePort: new(int32(30761)), }, PolicyNames: []string{}, }, }, Policies: &types.Policies{ AppCookieStickinessPolicies: []types.AppCookieStickinessPolicy{ { CookieName: new("foo"), PolicyName: new("policy"), }, }, LBCookieStickinessPolicies: []types.LBCookieStickinessPolicy{ { CookieExpirationPeriod: new(int64(10)), PolicyName: new("name"), }, }, OtherPolicies: []string{}, }, BackendServerDescriptions: []types.BackendServerDescription{ { InstancePort: new(int32(443)), PolicyNames: []string{}, }, }, AvailabilityZones: []string{ // link "euwest-2b", "euwest-2a", "euwest-2c", }, Subnets: []string{ // link "subnet0960234bbc4edca03", "subnet09d5f6fa75b0b4569", "subnet0e234bef35fc4a9e1", }, VPCId: new("vpc-0c72199250cd479ea"), // link Instances: []types.Instance{ { InstanceId: new("i-0337802d908b4a81e"), // link *2 to ec2-instance and health }, }, HealthCheck: &types.HealthCheck{ Target: new("HTTP:31151/healthz"), Interval: new(int32(10)), Timeout: new(int32(5)), UnhealthyThreshold: new(int32(6)), HealthyThreshold: new(int32(2)), }, SourceSecurityGroup: &types.SourceSecurityGroup{ OwnerAlias: new("944651592624"), GroupName: new("k8s-elb-a8c3c8851f0df43fda89797c8e941a91"), // link }, SecurityGroups: []string{ "sg097e3cfdfc6d53b77", // link }, CreatedTime: new(time.Now()), Scheme: new("internet-facing"), }, }, } items, err := elbLoadBalancerOutputMapper(context.Background(), mockElbClient{}, "foo", nil, output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetTags()["foo"] != "bar" { t.Errorf("expected tag foo to be bar, got %v", item.GetTags()["foo"]) } // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "a8c3c8851f0df43fda89797c8e941a91-182843316.eu-west-2.elb.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "a8c3c8851f0df43fda89797c8e941a91-182843316.eu-west-2.elb.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ZHURV8PSTC4K8", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet0960234bbc4edca03", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet09d5f6fa75b0b4569", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet0e234bef35fc4a9e1", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0c72199250cd479ea", ExpectedScope: "foo", }, { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-0337802d908b4a81e", ExpectedScope: "foo", }, { ExpectedType: "elb-instance-health", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "a8c3c8851f0df43fda89797c8e941a91/i-0337802d908b4a81e", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "k8s-elb-a8c3c8851f0df43fda89797c8e941a91", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg097e3cfdfc6d53b77", ExpectedScope: "foo", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/elbv2-listener.go ================================================ package adapters import ( "context" "crypto/sha256" "encoding/base64" "fmt" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func listenerOutputMapper(ctx context.Context, client elbv2Client, scope string, _ *elbv2.DescribeListenersInput, output *elbv2.DescribeListenersOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) // Get the ARNs so that we can get the tags arns := make([]string, 0) for _, listener := range output.Listeners { if listener.ListenerArn != nil { arns = append(arns, *listener.ListenerArn) } } tagsMap := elbv2GetTagsMap(ctx, client, arns) for _, listener := range output.Listeners { // Redact the client secret and replace with the first 12 characters of // the SHA256 hash so that we can at least tell if it has changed for _, action := range listener.DefaultActions { if action.AuthenticateOidcConfig != nil { if action.AuthenticateOidcConfig.ClientSecret != nil { h := sha256.New() h.Write([]byte(*action.AuthenticateOidcConfig.ClientSecret)) sha := base64.URLEncoding.EncodeToString(h.Sum(nil)) if len(sha) > 12 { action.AuthenticateOidcConfig.ClientSecret = new(fmt.Sprintf("REDACTED (Version: %v)", sha[:11])) } else { action.AuthenticateOidcConfig.ClientSecret = new("[REDACTED]") } } } } attrs, err := ToAttributesWithExclude(listener) if err != nil { return nil, err } var tags map[string]string if listener.ListenerArn != nil { tags = tagsMap[*listener.ListenerArn] } item := sdp.Item{ Type: "elbv2-listener", UniqueAttribute: "ListenerArn", Attributes: attrs, Scope: scope, Tags: tags, } if listener.LoadBalancerArn != nil { if a, err := ParseARN(*listener.LoadBalancerArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-load-balancer", Method: sdp.QueryMethod_SEARCH, Query: *listener.LoadBalancerArn, Scope: FormatScope(a.AccountID, a.Region), }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-rule", Method: sdp.QueryMethod_SEARCH, Query: *listener.ListenerArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, cert := range listener.Certificates { if cert.CertificateArn != nil { if a, err := ParseARN(*cert.CertificateArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_SEARCH, Query: *cert.CertificateArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } var requests []*sdp.LinkedItemQuery for _, action := range listener.DefaultActions { // These types can be returned by `ActionToRequests()` requests = ActionToRequests(action) item.LinkedItemQueries = append(item.LinkedItemQueries, requests...) } items = append(items, &item) } return items, nil } func NewELBv2ListenerAdapter(client elbv2Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*elbv2.DescribeListenersInput, *elbv2.DescribeListenersOutput, elbv2Client, *elbv2.Options] { return &DescribeOnlyAdapter[*elbv2.DescribeListenersInput, *elbv2.DescribeListenersOutput, elbv2Client, *elbv2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "elbv2-listener", AdapterMetadata: elbv2ListenerAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client elbv2Client, input *elbv2.DescribeListenersInput) (*elbv2.DescribeListenersOutput, error) { return client.DescribeListeners(ctx, input) }, InputMapperGet: func(scope, query string) (*elbv2.DescribeListenersInput, error) { return &elbv2.DescribeListenersInput{ ListenerArns: []string{query}, }, nil }, InputMapperList: func(scope string) (*elbv2.DescribeListenersInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for elbv2-listener, use search", } }, InputMapperSearch: func(ctx context.Context, client elbv2Client, scope, query string) (*elbv2.DescribeListenersInput, error) { // Search by LB ARN return &elbv2.DescribeListenersInput{ LoadBalancerArn: &query, }, nil }, PaginatorBuilder: func(client elbv2Client, params *elbv2.DescribeListenersInput) Paginator[*elbv2.DescribeListenersOutput, *elbv2.Options] { return elbv2.NewDescribeListenersPaginator(client, params) }, OutputMapper: listenerOutputMapper, } } var elbv2ListenerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "elbv2-listener", DescriptiveName: "ELB Listener", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get an ELB listener by ARN", Search: true, SearchDescription: "Search for ELB listeners by load balancer ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_alb_listener.arn", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_lb_listener.arn", }, }, PotentialLinks: []string{"elbv2-load-balancer", "acm-certificate", "elbv2-rule", "cognito-idp-user-pool", "http", "elbv2-target-group"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/elbv2-listener_test.go ================================================ package adapters import ( "context" "testing" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestListenerOutputMapper(t *testing.T) { output := elbv2.DescribeListenersOutput{ Listeners: []types.Listener{ { ListenerArn: new("arn:aws:elasticloadbalancing:eu-west-2:944651592624:listener/app/ingress/1bf10920c5bd199d/9d28f512be129134"), LoadBalancerArn: new("arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d"), // link Port: new(int32(443)), Protocol: types.ProtocolEnumHttps, Certificates: []types.Certificate{ { CertificateArn: new("arn:aws:acm:eu-west-2:944651592624:certificate/acd84d34-fb78-4411-bd8a-43684a3477c5"), // link IsDefault: new(true), }, }, SslPolicy: new("ELBSecurityPolicy-2016-08"), AlpnPolicy: []string{ "policy1", }, DefaultActions: []types.Action{ // This is tested in actions.go }, }, }, } items, err := listenerOutputMapper(context.Background(), mockElbv2Client{}, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetTags()["foo"] != "bar" { t.Errorf("expected tag foo to be bar, got %v", item.GetTags()["foo"]) } // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "elbv2-load-balancer", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d", ExpectedScope: "944651592624.eu-west-2", }, { ExpectedType: "acm-certificate", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:acm:eu-west-2:944651592624:certificate/acd84d34-fb78-4411-bd8a-43684a3477c5", ExpectedScope: "944651592624.eu-west-2", }, { ExpectedType: "elbv2-rule", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:listener/app/ingress/1bf10920c5bd199d/9d28f512be129134", ExpectedScope: "944651592624.eu-west-2", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/elbv2-load-balancer.go ================================================ package adapters import ( "context" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func elbv2LoadBalancerOutputMapper(ctx context.Context, client elbv2Client, scope string, _ *elbv2.DescribeLoadBalancersInput, output *elbv2.DescribeLoadBalancersOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) // Get the ARNs so that we can get the tags arns := make([]string, 0) for _, lb := range output.LoadBalancers { if lb.LoadBalancerArn != nil { arns = append(arns, *lb.LoadBalancerArn) } } tagsMap := elbv2GetTagsMap(ctx, client, arns) for _, lb := range output.LoadBalancers { attrs, err := ToAttributesWithExclude(lb) if err != nil { return nil, err } var tags map[string]string if lb.LoadBalancerArn != nil { tags = tagsMap[*lb.LoadBalancerArn] } item := sdp.Item{ Type: "elbv2-load-balancer", UniqueAttribute: "LoadBalancerName", Attributes: attrs, Scope: scope, Tags: tags, } if lb.LoadBalancerArn != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-target-group", Method: sdp.QueryMethod_SEARCH, Query: *lb.LoadBalancerArn, Scope: scope, }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-listener", Method: sdp.QueryMethod_SEARCH, Query: *lb.LoadBalancerArn, Scope: scope, }, }) } if lb.DNSName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *lb.DNSName, Scope: "global", }, }) } if lb.CanonicalHostedZoneId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *lb.CanonicalHostedZoneId, Scope: scope, }, }) } if lb.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *lb.VpcId, Scope: scope, }, }) } for _, az := range lb.AvailabilityZones { if az.SubnetId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *az.SubnetId, Scope: scope, }, }) } for _, address := range az.LoadBalancerAddresses { if address.AllocationId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-address", Method: sdp.QueryMethod_GET, Query: *address.AllocationId, Scope: scope, }, }) } if address.IPv6Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.IPv6Address, Scope: "global", }, }) } if address.IpAddress != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.IpAddress, Scope: "global", }, }) } if address.PrivateIPv4Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *address.PrivateIPv4Address, Scope: "global", }, }) } } } for _, sg := range lb.SecurityGroups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: sg, Scope: scope, }, }) } if lb.CustomerOwnedIpv4Pool != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-coip-pool", Method: sdp.QueryMethod_GET, Query: *lb.CustomerOwnedIpv4Pool, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewELBv2LoadBalancerAdapter(client elbv2Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*elbv2.DescribeLoadBalancersInput, *elbv2.DescribeLoadBalancersOutput, elbv2Client, *elbv2.Options] { return &DescribeOnlyAdapter[*elbv2.DescribeLoadBalancersInput, *elbv2.DescribeLoadBalancersOutput, elbv2Client, *elbv2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "elbv2-load-balancer", AdapterMetadata: loadBalancerAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client elbv2Client, input *elbv2.DescribeLoadBalancersInput) (*elbv2.DescribeLoadBalancersOutput, error) { return client.DescribeLoadBalancers(ctx, input) }, InputMapperGet: func(scope, query string) (*elbv2.DescribeLoadBalancersInput, error) { return &elbv2.DescribeLoadBalancersInput{ Names: []string{query}, }, nil }, InputMapperList: func(scope string) (*elbv2.DescribeLoadBalancersInput, error) { return &elbv2.DescribeLoadBalancersInput{}, nil }, InputMapperSearch: func(ctx context.Context, client elbv2Client, scope, query string) (*elbv2.DescribeLoadBalancersInput, error) { return &elbv2.DescribeLoadBalancersInput{ LoadBalancerArns: []string{query}, }, nil }, PaginatorBuilder: func(client elbv2Client, params *elbv2.DescribeLoadBalancersInput) Paginator[*elbv2.DescribeLoadBalancersOutput, *elbv2.Options] { return elbv2.NewDescribeLoadBalancersPaginator(client, params) }, OutputMapper: elbv2LoadBalancerOutputMapper, } } var loadBalancerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "elbv2-load-balancer", DescriptiveName: "Elastic Load Balancer", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an ELB by name", ListDescription: "List all ELBs", SearchDescription: "Search for ELBs by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_lb.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, { TerraformQueryMap: "aws_lb.id", TerraformMethod: sdp.QueryMethod_GET, }, }, PotentialLinks: []string{"elbv2-target-group", "elbv2-listener", "dns", "route53-hosted-zone", "ec2-vpc", "ec2-subnet", "ec2-address", "ip", "ec2-security-group", "ec2-coip-pool"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/elbv2-load-balancer_test.go ================================================ package adapters import ( "context" "testing" "time" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestLoadBalancerOutputMapper(t *testing.T) { output := elbv2.DescribeLoadBalancersOutput{ LoadBalancers: []types.LoadBalancer{ { LoadBalancerArn: new("arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d"), DNSName: new("ingress-1285969159.eu-west-2.elb.amazonaws.com"), // link CanonicalHostedZoneId: new("ZHURV8PSTC4K8"), // link CreatedTime: new(time.Now()), LoadBalancerName: new("ingress"), Scheme: types.LoadBalancerSchemeEnumInternetFacing, VpcId: new("vpc-0c72199250cd479ea"), // link State: &types.LoadBalancerState{ Code: types.LoadBalancerStateEnumActive, Reason: new("reason"), }, Type: types.LoadBalancerTypeEnumApplication, AvailabilityZones: []types.AvailabilityZone{ { ZoneName: new("eu-west-2b"), // link SubnetId: new("subnet-0960234bbc4edca03"), // link LoadBalancerAddresses: []types.LoadBalancerAddress{ { AllocationId: new("allocation-id"), // link? IPv6Address: new(":::1"), // link IpAddress: new("1.1.1.1"), // link PrivateIPv4Address: new("10.0.0.1"), // link }, }, OutpostId: new("outpost-id"), }, }, SecurityGroups: []string{ "sg-0b21edc8578ea3f93", // link }, IpAddressType: types.IpAddressTypeIpv4, CustomerOwnedIpv4Pool: new("ipv4-pool"), // link }, }, } items, err := elbv2LoadBalancerOutputMapper(context.Background(), mockElbv2Client{}, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] if item.GetTags()["foo"] != "bar" { t.Errorf("expected tag foo to be bar, got %v", item.GetTags()["foo"]) } // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d", ExpectedScope: "foo", }, { ExpectedType: "elbv2-listener", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d", ExpectedScope: "foo", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ingress-1285969159.eu-west-2.elb.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ZHURV8PSTC4K8", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0c72199250cd479ea", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0960234bbc4edca03", ExpectedScope: "foo", }, { ExpectedType: "ec2-address", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "allocation-id", ExpectedScope: "foo", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: ":::1", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "1.1.1.1", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-0b21edc8578ea3f93", ExpectedScope: "foo", }, { ExpectedType: "ec2-coip-pool", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ipv4-pool", ExpectedScope: "foo", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/elbv2-rule.go ================================================ package adapters import ( "context" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func ruleOutputMapper(ctx context.Context, client elbv2Client, scope string, _ *elbv2.DescribeRulesInput, output *elbv2.DescribeRulesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) ruleArns := make([]string, 0) for _, rule := range output.Rules { if rule.RuleArn != nil { ruleArns = append(ruleArns, *rule.RuleArn) } } tagsMap := elbv2GetTagsMap(ctx, client, ruleArns) for _, rule := range output.Rules { attrs, err := ToAttributesWithExclude(rule) if err != nil { return nil, err } var tags map[string]string if rule.RuleArn != nil { tags = tagsMap[*rule.RuleArn] } item := sdp.Item{ Type: "elbv2-rule", UniqueAttribute: "RuleArn", Attributes: attrs, Scope: scope, Tags: tags, } var requests []*sdp.LinkedItemQuery for _, action := range rule.Actions { requests = ActionToRequests(action) item.LinkedItemQueries = append(item.LinkedItemQueries, requests...) } for _, condition := range rule.Conditions { if condition.HostHeaderConfig != nil { for _, value := range condition.HostHeaderConfig.Values { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: value, Scope: "global", }, }) } } } items = append(items, &item) } return items, nil } func NewELBv2RuleAdapter(client elbv2Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*elbv2.DescribeRulesInput, *elbv2.DescribeRulesOutput, elbv2Client, *elbv2.Options] { return &DescribeOnlyAdapter[*elbv2.DescribeRulesInput, *elbv2.DescribeRulesOutput, elbv2Client, *elbv2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "elbv2-rule", AdapterMetadata: ruleAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client elbv2Client, input *elbv2.DescribeRulesInput) (*elbv2.DescribeRulesOutput, error) { return client.DescribeRules(ctx, input) }, InputMapperGet: func(scope, query string) (*elbv2.DescribeRulesInput, error) { return &elbv2.DescribeRulesInput{ RuleArns: []string{query}, }, nil }, InputMapperList: func(scope string) (*elbv2.DescribeRulesInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for elbv2-rule, use search", } }, InputMapperSearch: func(ctx context.Context, client elbv2Client, scope, query string) (*elbv2.DescribeRulesInput, error) { // Search by listener ARN return &elbv2.DescribeRulesInput{ ListenerArn: &query, }, nil }, OutputMapper: ruleOutputMapper, } } var ruleAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "elbv2-rule", DescriptiveName: "ELB Rule", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a rule by ARN", SearchDescription: "Search for rules by listener ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_alb_listener_rule.arn", TerraformMethod: sdp.QueryMethod_GET, }, { TerraformQueryMap: "aws_lb_listener_rule.arn", TerraformMethod: sdp.QueryMethod_GET, }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/elbv2-rule_test.go ================================================ package adapters import ( "context" "fmt" "testing" "time" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestRuleOutputMapper(t *testing.T) { output := elbv2.DescribeRulesOutput{ Rules: []types.Rule{ { RuleArn: new("arn:aws:elasticloadbalancing:eu-west-2:944651592624:listener-rule/app/ingress/1bf10920c5bd199d/9d28f512be129134/0f73a74d21b008f7"), Priority: new("1"), Conditions: []types.RuleCondition{ { Field: new("path-pattern"), Values: []string{ "/api/gateway", }, PathPatternConfig: &types.PathPatternConditionConfig{ Values: []string{ "/api/gateway", }, }, HostHeaderConfig: &types.HostHeaderConditionConfig{ Values: []string{ "foo.bar.com", // link }, }, HttpHeaderConfig: &types.HttpHeaderConditionConfig{ HttpHeaderName: new("SOMETHING"), Values: []string{ "foo", }, }, HttpRequestMethodConfig: &types.HttpRequestMethodConditionConfig{ Values: []string{ "GET", }, }, QueryStringConfig: &types.QueryStringConditionConfig{ Values: []types.QueryStringKeyValuePair{ { Key: new("foo"), Value: new("bar"), }, }, }, SourceIpConfig: &types.SourceIpConditionConfig{ Values: []string{ "1.1.1.1/24", }, }, }, }, Actions: []types.Action{ // Tested in actions.go }, IsDefault: new(false), }, }, } items, err := ruleOutputMapper(context.Background(), mockElbv2Client{}, "foo", nil, &output) if err != nil { t.Error(err) } if len(items) != 1 { t.Error("expected 1 item") } item := items[0] tests := QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "foo.bar.com", ExpectedScope: "global", }, } tests.Execute(t, item) } func TestNewELBv2RuleAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := elbv2.NewFromConfig(config) lbSource := NewELBv2LoadBalancerAdapter(client, account, region, sdpcache.NewNoOpCache()) listenerSource := NewELBv2ListenerAdapter(client, account, region, sdpcache.NewNoOpCache()) ruleSource := NewELBv2RuleAdapter(client, account, region, sdpcache.NewNoOpCache()) stream := discovery.NewRecordingQueryResultStream() lbSource.ListStream(context.Background(), lbSource.Scopes()[0], false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) == 0 { t.Skip("no load balancers found") } lbARN, err := items[0].GetAttributes().Get("LoadBalancerArn") if err != nil { t.Fatal(err) } stream = discovery.NewRecordingQueryResultStream() listenerSource.SearchStream(context.Background(), listenerSource.Scopes()[0], fmt.Sprint(lbARN), false, stream) errs = stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items = stream.GetItems() if len(items) == 0 { t.Skip("no listeners found") } listenerARN, err := items[0].GetAttributes().Get("ListenerArn") if err != nil { t.Fatal(err) } goodSearch := fmt.Sprint(listenerARN) test := E2ETest{ Adapter: ruleSource, Timeout: 10 * time.Second, GoodSearchQuery: &goodSearch, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/elbv2-target-group.go ================================================ package adapters import ( "context" "fmt" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func targetGroupOutputMapper(ctx context.Context, client elbv2Client, scope string, _ *elbv2.DescribeTargetGroupsInput, output *elbv2.DescribeTargetGroupsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) tgArns := make([]string, 0) for _, tg := range output.TargetGroups { if tg.TargetGroupArn != nil { tgArns = append(tgArns, *tg.TargetGroupArn) } } tagsMap := elbv2GetTagsMap(ctx, client, tgArns) for _, tg := range output.TargetGroups { attrs, err := ToAttributesWithExclude(tg) if err != nil { return nil, err } var tags map[string]string if tg.TargetGroupArn != nil { tags = tagsMap[*tg.TargetGroupArn] } item := sdp.Item{ Type: "elbv2-target-group", UniqueAttribute: "TargetGroupName", Attributes: attrs, Scope: scope, Tags: tags, } if tg.TargetGroupArn != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-target-health", Method: sdp.QueryMethod_SEARCH, Query: *tg.TargetGroupArn, Scope: scope, }, }) } if tg.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *tg.VpcId, Scope: scope, }, }) } for _, lbArn := range tg.LoadBalancerArns { if a, err := ParseARN(lbArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-load-balancer", Method: sdp.QueryMethod_SEARCH, Query: lbArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } items = append(items, &item) } return items, nil } func NewELBv2TargetGroupAdapter(client elbv2Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*elbv2.DescribeTargetGroupsInput, *elbv2.DescribeTargetGroupsOutput, elbv2Client, *elbv2.Options] { return &DescribeOnlyAdapter[*elbv2.DescribeTargetGroupsInput, *elbv2.DescribeTargetGroupsOutput, elbv2Client, *elbv2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "elbv2-target-group", AdapterMetadata: targetGroupAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client elbv2Client, input *elbv2.DescribeTargetGroupsInput) (*elbv2.DescribeTargetGroupsOutput, error) { return client.DescribeTargetGroups(ctx, input) }, InputMapperGet: func(scope, query string) (*elbv2.DescribeTargetGroupsInput, error) { return &elbv2.DescribeTargetGroupsInput{ Names: []string{query}, }, nil }, InputMapperList: func(scope string) (*elbv2.DescribeTargetGroupsInput, error) { return &elbv2.DescribeTargetGroupsInput{}, nil }, InputMapperSearch: func(ctx context.Context, client elbv2Client, scope, query string) (*elbv2.DescribeTargetGroupsInput, error) { arn, err := ParseARN(query) if err != nil { return nil, err } switch arn.Type() { case "targetgroup": // Search by target group return &elbv2.DescribeTargetGroupsInput{ TargetGroupArns: []string{ query, }, }, nil case "loadbalancer": // Search by load balancer return &elbv2.DescribeTargetGroupsInput{ LoadBalancerArn: &query, }, nil default: return nil, fmt.Errorf("unsupported resource type: %s", arn.Resource) } }, PaginatorBuilder: func(client elbv2Client, params *elbv2.DescribeTargetGroupsInput) Paginator[*elbv2.DescribeTargetGroupsOutput, *elbv2.Options] { return elbv2.NewDescribeTargetGroupsPaginator(client, params) }, OutputMapper: targetGroupOutputMapper, } } var targetGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "elbv2-target-group", DescriptiveName: "Target Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a target group by name", ListDescription: "List all target groups", SearchDescription: "Search for target groups by load balancer ARN or target group ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_alb_target_group.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, { TerraformQueryMap: "aws_lb_target_group.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"ec2-vpc", "elbv2-load-balancer", "elbv2-target-health"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/elbv2-target-group_test.go ================================================ package adapters import ( "context" "testing" "time" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestTargetGroupOutputMapper(t *testing.T) { output := elbv2.DescribeTargetGroupsOutput{ TargetGroups: []types.TargetGroup{ { TargetGroupArn: new("arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222"), TargetGroupName: new("k8s-default-apiserve-d87e8f7010"), Protocol: types.ProtocolEnumHttp, Port: new(int32(8080)), VpcId: new("vpc-0c72199250cd479ea"), // link HealthCheckProtocol: types.ProtocolEnumHttp, HealthCheckPort: new("traffic-port"), HealthCheckEnabled: new(true), HealthCheckIntervalSeconds: new(int32(10)), HealthCheckTimeoutSeconds: new(int32(10)), HealthyThresholdCount: new(int32(10)), UnhealthyThresholdCount: new(int32(10)), HealthCheckPath: new("/"), Matcher: &types.Matcher{ HttpCode: new("200"), GrpcCode: new("code"), }, LoadBalancerArns: []string{ "arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d", // link }, TargetType: types.TargetTypeEnumIp, ProtocolVersion: new("HTTP1"), IpAddressType: types.TargetGroupIpAddressTypeEnumIpv4, }, }, } items, err := targetGroupOutputMapper(context.Background(), mockElbv2Client{}, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if item.GetTags()["foo"] != "bar" { t.Errorf("expected tag foo to be bar, got %v", item.GetTags()["foo"]) } if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // It doesn't really make sense to test anything other than the linked items // since the attributes are converted automatically tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0c72199250cd479ea", ExpectedScope: "foo", }, { ExpectedType: "elbv2-load-balancer", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d", ExpectedScope: "944651592624.eu-west-2", }, } tests.Execute(t, item) } func TestNewELBv2TargetGroupAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := elbv2.NewFromConfig(config) adapter := NewELBv2TargetGroupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/elbv2-target-health.go ================================================ package adapters import ( "context" "fmt" "net" "strconv" "strings" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type TargetHealthUniqueID struct { TargetGroupArn string Id string AvailabilityZone *string Port *int32 } // String returns a string representation of the TargetHealthUniqueID in the // format: TargetGroupArn|Id|AvailabilityZone|Port func (id TargetHealthUniqueID) String() string { var az string var port string if id.AvailabilityZone != nil { az = *id.AvailabilityZone } if id.Port != nil { port = fmt.Sprint(*id.Port) } return strings.Join([]string{ id.TargetGroupArn, id.Id, az, port, }, "|") } // ToTargetHealthUniqueID converts a string to a TargetHealthUniqueID func ToTargetHealthUniqueID(id string) (TargetHealthUniqueID, error) { sections := strings.Split(id, "|") if len(sections) != 4 { return TargetHealthUniqueID{}, fmt.Errorf("cannot parse TargetHealthUniqueID, must have 4 sections, got %v", len(sections)) } healthId := TargetHealthUniqueID{ TargetGroupArn: sections[0], Id: sections[1], } if sections[2] != "" { healthId.AvailabilityZone = §ions[2] } if sections[3] != "" { port, err := strconv.ParseInt(sections[3], 10, 32) if err != nil { return TargetHealthUniqueID{}, err } pint32 := int32(port) healthId.Port = &pint32 } return healthId, nil } func targetHealthOutputMapper(_ context.Context, _ *elbv2.Client, scope string, input *elbv2.DescribeTargetHealthInput, output *elbv2.DescribeTargetHealthOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, desc := range output.TargetHealthDescriptions { attrs, err := ToAttributesWithExclude(desc) if err != nil { return nil, err } item := sdp.Item{ Type: "elbv2-target-health", UniqueAttribute: "UniqueId", Attributes: attrs, Scope: scope, } if desc.TargetHealth != nil { switch desc.TargetHealth.State { //nolint:exhaustive // handled by default case case types.TargetHealthStateEnumInitial: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.TargetHealthStateEnumHealthy: item.Health = sdp.Health_HEALTH_OK.Enum() case types.TargetHealthStateEnumUnhealthy: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.TargetHealthStateEnumUnused: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.TargetHealthStateEnumDraining: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.TargetHealthStateEnumUnavailable: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() default: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Check that we have an input and not a nil pointer if input == nil { return nil, fmt.Errorf("input cannot be nil") } if input.TargetGroupArn == nil { return nil, fmt.Errorf("target group ARN cannot be nil") } // Make sure there is actually a target in this result, there always // should be but safer to check if desc.Target == nil { continue } if desc.Target.Id == nil { continue } id := TargetHealthUniqueID{ TargetGroupArn: *input.TargetGroupArn, Id: *desc.Target.Id, AvailabilityZone: desc.Target.AvailabilityZone, Port: desc.Target.Port, } item.GetAttributes().Set("UniqueId", id.String()) // See if the ID is an ARN a, err := ParseARN(*desc.Target.Id) if err == nil { switch a.Service { case "lambda": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_SEARCH, Query: *desc.Target.Id, Scope: FormatScope(a.AccountID, a.Region), }, }) case "elasticloadbalancing": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-load-balancer", Method: sdp.QueryMethod_SEARCH, Query: *desc.Target.Id, Scope: FormatScope(a.AccountID, a.Region), }, }) } } else { // In this case it could be an instance ID or an IP. We will check // for IP first if net.ParseIP(*desc.Target.Id) != nil { // This means it's an IP item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *desc.Target.Id, Scope: "global", }, }) } else { // If all else fails it must be an instance ID item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-instance", Method: sdp.QueryMethod_GET, Query: *desc.Target.Id, Scope: scope, }, }) } } items = append(items, &item) } return items, nil } func NewELBv2TargetHealthAdapter(client *elbv2.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*elbv2.DescribeTargetHealthInput, *elbv2.DescribeTargetHealthOutput, *elbv2.Client, *elbv2.Options] { return &DescribeOnlyAdapter[*elbv2.DescribeTargetHealthInput, *elbv2.DescribeTargetHealthOutput, *elbv2.Client, *elbv2.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "elbv2-target-health", AdapterMetadata: targetHealthAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *elbv2.Client, input *elbv2.DescribeTargetHealthInput) (*elbv2.DescribeTargetHealthOutput, error) { return client.DescribeTargetHealth(ctx, input) }, InputMapperGet: func(scope, query string) (*elbv2.DescribeTargetHealthInput, error) { id, err := ToTargetHealthUniqueID(query) if err != nil { return nil, err } return &elbv2.DescribeTargetHealthInput{ TargetGroupArn: &id.TargetGroupArn, Targets: []types.TargetDescription{ { Id: &id.Id, AvailabilityZone: id.AvailabilityZone, Port: id.Port, }, }, }, nil }, InputMapperList: func(scope string) (*elbv2.DescribeTargetHealthInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for elbv2-target-health, use search", } }, InputMapperSearch: func(ctx context.Context, client *elbv2.Client, scope, query string) (*elbv2.DescribeTargetHealthInput, error) { // Search by target group ARN return &elbv2.DescribeTargetHealthInput{ TargetGroupArn: &query, }, nil }, OutputMapper: targetHealthOutputMapper, } } var targetHealthAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "elbv2-target-health", DescriptiveName: "ELB Target Health", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get target health by unique ID ({TargetGroupArn}|{Id}|{AvailabilityZone}|{Port})", SearchDescription: "Search for target health by target group ARN", }, PotentialLinks: []string{"ec2-instance", "lambda-function", "ip", "elbv2-load-balancer"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, }) ================================================ FILE: aws-source/adapters/elbv2-target-health_test.go ================================================ package adapters import ( "context" "testing" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestTargetHealthOutputMapper(t *testing.T) { output := elbv2.DescribeTargetHealthOutput{ TargetHealthDescriptions: []types.TargetHealthDescription{ { Target: &types.TargetDescription{ Id: new("10.0.6.64"), // link Port: new(int32(8080)), AvailabilityZone: new("eu-west-2c"), }, HealthCheckPort: new("8080"), TargetHealth: &types.TargetHealth{ State: types.TargetHealthStateEnumHealthy, Reason: types.TargetHealthReasonEnumDeregistrationInProgress, Description: new("Health checks failed with these codes: [404]"), }, }, { Target: &types.TargetDescription{ Id: new("arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d"), // link Port: new(int32(8080)), AvailabilityZone: new("eu-west-2c"), }, HealthCheckPort: new("8080"), TargetHealth: &types.TargetHealth{ State: types.TargetHealthStateEnumHealthy, Reason: types.TargetHealthReasonEnumDeregistrationInProgress, Description: new("Health checks failed with these codes: [404]"), }, }, { Target: &types.TargetDescription{ Id: new("i-foo"), // link Port: new(int32(8080)), AvailabilityZone: new("eu-west-2c"), }, HealthCheckPort: new("8080"), TargetHealth: &types.TargetHealth{ State: types.TargetHealthStateEnumHealthy, Reason: types.TargetHealthReasonEnumDeregistrationInProgress, Description: new("Health checks failed with these codes: [404]"), }, }, { Target: &types.TargetDescription{ Id: new("arn:aws:lambda:eu-west-2:944651592624:function/foobar"), // link Port: new(int32(8080)), AvailabilityZone: new("eu-west-2c"), }, HealthCheckPort: new("8080"), TargetHealth: &types.TargetHealth{ State: types.TargetHealthStateEnumHealthy, Reason: types.TargetHealthReasonEnumDeregistrationInProgress, Description: new("Health checks failed with these codes: [404]"), }, }, }, } items, err := targetHealthOutputMapper(context.Background(), nil, "foo", &elbv2.DescribeTargetHealthInput{ TargetGroupArn: new("arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222"), }, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 4 { t.Fatalf("expected 4 items, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.6.64", ExpectedScope: "global", }, } tests.Execute(t, item) item = items[1] tests = QueryTests{ { ExpectedType: "elbv2-load-balancer", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:loadbalancer/app/ingress/1bf10920c5bd199d", ExpectedScope: "944651592624.eu-west-2", }, } tests.Execute(t, item) item = items[2] tests = QueryTests{ { ExpectedType: "ec2-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "i-foo", ExpectedScope: "foo", }, } tests.Execute(t, item) item = items[3] tests = QueryTests{ { ExpectedType: "lambda-function", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:lambda:eu-west-2:944651592624:function/foobar", ExpectedScope: "944651592624.eu-west-2", }, } tests.Execute(t, item) } func TestTargetHealthUniqueID(t *testing.T) { t.Run("with an ARN as the ID", func(t *testing.T) { id := TargetHealthUniqueID{ TargetGroupArn: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222", Id: "arn:partition:service:region:account-id:resource-type:resource-id", } expected := "arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222|arn:partition:service:region:account-id:resource-type:resource-id||" if id.String() != expected { t.Errorf("expected string value to be %v\ngot %v", expected, id.String()) } t.Run("converting back", func(t *testing.T) { newID, err := ToTargetHealthUniqueID(expected) if err != nil { t.Error(err) } CompareTargetHealthUniqueID(newID, id, t) }) }) t.Run("with an IP as the ID", func(t *testing.T) { id := TargetHealthUniqueID{ TargetGroupArn: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222", Id: "10.0.0.1", AvailabilityZone: new("eu-west-2"), Port: new(int32(8080)), } expected := "arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222|10.0.0.1|eu-west-2|8080" if id.String() != expected { t.Errorf("expected string value to be %v\ngot %v", expected, id.String()) } t.Run("converting back", func(t *testing.T) { newID, err := ToTargetHealthUniqueID(expected) if err != nil { t.Error(err) } CompareTargetHealthUniqueID(newID, id, t) }) }) t.Run("with an ARN as the ID and a port", func(t *testing.T) { id := TargetHealthUniqueID{ TargetGroupArn: "arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222", Id: "arn:partition:service:region:account-id:resource-type:resource-id", Port: new(int32(8080)), } expected := "arn:aws:elasticloadbalancing:eu-west-2:944651592624:targetgroup/k8s-default-apiserve-d87e8f7010/559d207158e41222|arn:partition:service:region:account-id:resource-type:resource-id||8080" if id.String() != expected { t.Errorf("expected string value to be %v\ngot %v", expected, id.String()) } t.Run("converting back", func(t *testing.T) { newID, err := ToTargetHealthUniqueID(expected) if err != nil { t.Error(err) } CompareTargetHealthUniqueID(newID, id, t) }) }) } func CompareTargetHealthUniqueID(x, y TargetHealthUniqueID, t *testing.T) { if x.AvailabilityZone != nil { if *x.AvailabilityZone != *y.AvailabilityZone { t.Errorf("AvailabilityZone mismatch!\nX: %v\nY: %v", x.AvailabilityZone, y.AvailabilityZone) } } if x.Id != y.Id { t.Errorf("Id mismatch!\nX: %v\nY: %v", x.Id, y.Id) } if x.Port != nil { if *x.Port != *y.Port { t.Errorf("Port mismatch!\nX: %v\nY: %v", x.Port, y.Port) } } if x.TargetGroupArn != y.TargetGroupArn { t.Errorf("TargetGroupArn mismatch!\nX: %v\nY: %v", x.TargetGroupArn, y.TargetGroupArn) } } ================================================ FILE: aws-source/adapters/elbv2.go ================================================ package adapters import ( "context" "fmt" "net/url" "sync" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/sdp-go" ) type elbv2Client interface { DescribeTags(ctx context.Context, params *elbv2.DescribeTagsInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeTagsOutput, error) DescribeLoadBalancers(ctx context.Context, params *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) DescribeListeners(ctx context.Context, params *elbv2.DescribeListenersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeListenersOutput, error) DescribeRules(ctx context.Context, params *elbv2.DescribeRulesInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeRulesOutput, error) DescribeTargetGroups(ctx context.Context, params *elbv2.DescribeTargetGroupsInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeTargetGroupsOutput, error) } func elbv2TagsToMap(tags []types.Tag) map[string]string { m := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { m[*tag.Key] = *tag.Value } } return m } // AWS DescribeTags API limits requests to 20 resources per call. // See: https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_DescribeTags.html const elbv2DescribeTagsMaxItems = 20 // Gets a map of ARN to tags (in map[string]string format) for the given ARNs func elbv2GetTagsMap(ctx context.Context, client elbv2Client, arns []string) map[string]map[string]string { tagsMap := make(map[string]map[string]string) if len(arns) == 0 { return tagsMap } var mu sync.Mutex var wg sync.WaitGroup for i := 0; i < len(arns); i += elbv2DescribeTagsMaxItems { end := min(i+elbv2DescribeTagsMaxItems, len(arns)) chunk := arns[i:end] wg.Add(1) go func(chunk []string) { defer wg.Done() tagsOut, err := client.DescribeTags(ctx, &elbv2.DescribeTagsInput{ ResourceArns: chunk, }) mu.Lock() defer mu.Unlock() if err != nil { tags := HandleTagsError(ctx, err) for _, arn := range chunk { tagsMap[arn] = tags } return } for _, tagDescription := range tagsOut.TagDescriptions { if tagDescription.ResourceArn != nil { tagsMap[*tagDescription.ResourceArn] = elbv2TagsToMap(tagDescription.Tags) } } }(chunk) } wg.Wait() return tagsMap } func ActionToRequests(action types.Action) []*sdp.LinkedItemQuery { requests := make([]*sdp.LinkedItemQuery, 0) if action.AuthenticateCognitoConfig != nil { if action.AuthenticateCognitoConfig.UserPoolArn != nil { if a, err := ParseARN(*action.AuthenticateCognitoConfig.UserPoolArn); err == nil { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cognito-idp-user-pool", Method: sdp.QueryMethod_SEARCH, Query: *action.AuthenticateCognitoConfig.UserPoolArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if action.AuthenticateOidcConfig != nil { if action.AuthenticateOidcConfig.AuthorizationEndpoint != nil { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *action.AuthenticateOidcConfig.AuthorizationEndpoint, Scope: "global", }, }) } if action.AuthenticateOidcConfig.TokenEndpoint != nil { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *action.AuthenticateOidcConfig.TokenEndpoint, Scope: "global", }, }) } if action.AuthenticateOidcConfig.UserInfoEndpoint != nil { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *action.AuthenticateOidcConfig.UserInfoEndpoint, Scope: "global", }, }) } } if action.ForwardConfig != nil { for _, tg := range action.ForwardConfig.TargetGroups { if tg.TargetGroupArn != nil { if a, err := ParseARN(*tg.TargetGroupArn); err == nil { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-target-group", Method: sdp.QueryMethod_SEARCH, Query: *tg.TargetGroupArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } if action.RedirectConfig != nil { u := url.URL{} if action.RedirectConfig.Path != nil { u.Path = *action.RedirectConfig.Path } if action.RedirectConfig.Port != nil { u.Port() } if action.RedirectConfig.Host != nil { u.Host = *action.RedirectConfig.Host if action.RedirectConfig.Port != nil { u.Host = u.Host + fmt.Sprintf(":%v", *action.RedirectConfig.Port) } } if action.RedirectConfig.Protocol != nil { u.Scheme = *action.RedirectConfig.Protocol } if action.RedirectConfig.Query != nil { u.RawQuery = *action.RedirectConfig.Query } if u.Scheme == "http" || u.Scheme == "https" { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: u.String(), Scope: "global", }, }) } } if action.TargetGroupArn != nil { if a, err := ParseARN(*action.TargetGroupArn); err == nil { requests = append(requests, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "elbv2-target-group", Method: sdp.QueryMethod_SEARCH, Query: *action.TargetGroupArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } return requests } ================================================ FILE: aws-source/adapters/elbv2_test.go ================================================ package adapters import ( "context" "fmt" "testing" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/overmindtech/cli/go/sdp-go" ) type mockElbv2Client struct { rejectOver20 bool } func (m mockElbv2Client) DescribeTags(ctx context.Context, params *elbv2.DescribeTagsInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeTagsOutput, error) { if m.rejectOver20 && len(params.ResourceArns) > elbv2DescribeTagsMaxItems { return nil, fmt.Errorf("cannot describe more than %d ELBv2 resources, got %d", elbv2DescribeTagsMaxItems, len(params.ResourceArns)) } tagDescriptions := make([]types.TagDescription, 0, len(params.ResourceArns)) for _, arn := range params.ResourceArns { tagDescriptions = append(tagDescriptions, types.TagDescription{ ResourceArn: &arn, Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, }) } return &elbv2.DescribeTagsOutput{ TagDescriptions: tagDescriptions, }, nil } func (m mockElbv2Client) DescribeLoadBalancers(ctx context.Context, params *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) { return nil, nil } func (m mockElbv2Client) DescribeListeners(ctx context.Context, params *elbv2.DescribeListenersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeListenersOutput, error) { return nil, nil } func (m mockElbv2Client) DescribeRules(ctx context.Context, params *elbv2.DescribeRulesInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeRulesOutput, error) { return nil, nil } func (m mockElbv2Client) DescribeTargetGroups(ctx context.Context, params *elbv2.DescribeTargetGroupsInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeTargetGroupsOutput, error) { return nil, nil } func TestElbv2GetTagsMapBatching(t *testing.T) { client := &mockElbv2Client{rejectOver20: true} arns := []string{ "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-00/0000000000000000", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-01/0000000000000001", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-02/0000000000000002", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-03/0000000000000003", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-04/0000000000000004", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-05/0000000000000005", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-06/0000000000000006", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-07/0000000000000007", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-08/0000000000000008", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-09/0000000000000009", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-10/0000000000000010", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-11/0000000000000011", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-12/0000000000000012", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-13/0000000000000013", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-14/0000000000000014", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-15/0000000000000015", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-16/0000000000000016", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-17/0000000000000017", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-18/0000000000000018", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-19/0000000000000019", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-20/0000000000000020", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-21/0000000000000021", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-22/0000000000000022", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-23/0000000000000023", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/lb-24/0000000000000024", } tagsMap := elbv2GetTagsMap(context.Background(), client, arns) if len(tagsMap) != 25 { t.Fatalf("expected 25 tagged resources, got %d", len(tagsMap)) } for _, arn := range arns { if got := tagsMap[arn]["foo"]; got != "bar" { t.Errorf("expected tag foo=bar for %q, got %q", arn, got) } } } func TestActionToRequests(t *testing.T) { action := types.Action{ Type: types.ActionTypeEnumFixedResponse, Order: new(int32(1)), FixedResponseConfig: &types.FixedResponseActionConfig{ StatusCode: new("404"), ContentType: new("text/plain"), MessageBody: new("not found"), }, AuthenticateCognitoConfig: &types.AuthenticateCognitoActionConfig{ UserPoolArn: new("arn:partition:service:region:account-id:resource-type:resource-id"), // link UserPoolClientId: new("clientID"), UserPoolDomain: new("domain.com"), AuthenticationRequestExtraParams: map[string]string{ "foo": "bar", }, OnUnauthenticatedRequest: types.AuthenticateCognitoActionConditionalBehaviorEnumAuthenticate, Scope: new("foo"), SessionCookieName: new("cookie"), SessionTimeout: new(int64(10)), }, AuthenticateOidcConfig: &types.AuthenticateOidcActionConfig{ AuthorizationEndpoint: new("https://auth.somewhere.com/app1"), // link ClientId: new("CLIENT-ID"), Issuer: new("Someone"), TokenEndpoint: new("https://auth.somewhere.com/app1/tokens"), // link UserInfoEndpoint: new("https://auth.somewhere.com/app1/users"), // link AuthenticationRequestExtraParams: map[string]string{}, ClientSecret: new("secret"), // Redact OnUnauthenticatedRequest: types.AuthenticateOidcActionConditionalBehaviorEnumAllow, Scope: new("foo"), SessionCookieName: new("cookie"), SessionTimeout: new(int64(10)), UseExistingClientSecret: new(true), }, ForwardConfig: &types.ForwardActionConfig{ TargetGroupStickinessConfig: &types.TargetGroupStickinessConfig{ DurationSeconds: new(int32(10)), Enabled: new(true), }, TargetGroups: []types.TargetGroupTuple{ { TargetGroupArn: new("arn:partition:service:region:account-id:resource-type:resource-id1"), // link Weight: new(int32(1)), }, }, }, RedirectConfig: &types.RedirectActionConfig{ StatusCode: types.RedirectActionStatusCodeEnumHttp302, Host: new("somewhere.else.com"), // combine and link Path: new("/login"), // combine and link Port: new("8080"), // combine and link Protocol: new("https"), // combine and link Query: new("foo=bar"), // combine and link }, TargetGroupArn: new("arn:partition:service:region:account-id:resource-type:resource-id2"), // link } item := sdp.Item{ Type: "test", UniqueAttribute: "foo", Attributes: &sdp.ItemAttributes{}, Scope: "foo", LinkedItemQueries: ActionToRequests(action), } tests := QueryTests{ { ExpectedType: "cognito-idp-user-pool", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service:region:account-id:resource-type:resource-id", ExpectedScope: "account-id.region", }, { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://auth.somewhere.com/app1", ExpectedScope: "global", }, { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://auth.somewhere.com/app1/tokens", ExpectedScope: "global", }, { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://auth.somewhere.com/app1/users", ExpectedScope: "global", }, { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service:region:account-id:resource-type:resource-id1", ExpectedScope: "account-id.region", }, { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://somewhere.else.com:8080/login?foo=bar", ExpectedScope: "global", }, { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service:region:account-id:resource-type:resource-id2", ExpectedScope: "account-id.region", }, } tests.Execute(t, &item) } ================================================ FILE: aws-source/adapters/iam-group.go ================================================ package adapters import ( "context" "time" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func groupGetFunc(ctx context.Context, client *iam.Client, _, query string) (*types.Group, error) { out, err := client.GetGroup(ctx, &iam.GetGroupInput{ GroupName: &query, }) if err != nil { return nil, err } return out.Group, nil } func groupItemMapper(_ *string, scope string, awsItem *types.Group) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "iam-group", UniqueAttribute: "GroupName", Attributes: attributes, Scope: scope, } return &item, nil } func NewIAMGroupAdapter(client *iam.Client, accountID string, cache sdpcache.Cache) *GetListAdapterV2[*iam.ListGroupsInput, *iam.ListGroupsOutput, *types.Group, *iam.Client, *iam.Options] { return &GetListAdapterV2[*iam.ListGroupsInput, *iam.ListGroupsOutput, *types.Group, *iam.Client, *iam.Options]{ ItemType: "iam-group", Client: client, CacheDuration: 3 * time.Hour, // IAM has very low rate limits, we need to cache for a long time AccountID: accountID, AdapterMetadata: iamGroupAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *iam.Client, scope, query string) (*types.Group, error) { return groupGetFunc(ctx, client, scope, query) }, InputMapperList: func(scope string) (*iam.ListGroupsInput, error) { return &iam.ListGroupsInput{}, nil }, ListFuncPaginatorBuilder: func(client *iam.Client, params *iam.ListGroupsInput) Paginator[*iam.ListGroupsOutput, *iam.Options] { return iam.NewListGroupsPaginator(client, params) }, ListExtractor: func(_ context.Context, output *iam.ListGroupsOutput, _ *iam.Client) ([]*types.Group, error) { groups := make([]*types.Group, 0, len(output.Groups)) for i := range output.Groups { groups = append(groups, &output.Groups[i]) } return groups, nil }, ItemMapper: groupItemMapper, } } var iamGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "iam-group", DescriptiveName: "IAM Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a group by name", ListDescription: "List all IAM groups", SearchDescription: "Search for a group by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "aws_iam_group.arn", }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/iam-group_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestGroupItemMapper(t *testing.T) { zone := types.Group{ Path: new("/"), GroupName: new("power-users"), GroupId: new("AGPA3VLV2U27T6SSLJMDS"), Arn: new("arn:aws:iam::801795385023:group/power-users"), CreateDate: new(time.Now()), } item, err := groupItemMapper(nil, "foo", &zone) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewIAMGroupAdapter(t *testing.T) { config, account, _ := GetAutoConfig(t) client := iam.NewFromConfig(config, func(o *iam.Options) { o.RetryMode = aws.RetryModeAdaptive o.RetryMaxAttempts = 10 }) adapter := NewIAMGroupAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 30 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/iam-instance-profile.go ================================================ package adapters import ( "context" "time" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func instanceProfileGetFunc(ctx context.Context, client *iam.Client, _, query string) (*types.InstanceProfile, error) { out, err := client.GetInstanceProfile(ctx, &iam.GetInstanceProfileInput{ InstanceProfileName: &query, }) if err != nil { return nil, err } return out.InstanceProfile, nil } func instanceProfileItemMapper(_ *string, scope string, awsItem *types.InstanceProfile) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "iam-instance-profile", UniqueAttribute: "InstanceProfileName", Attributes: attributes, Scope: scope, } for _, role := range awsItem.Roles { if arn, err := ParseARN(*role.Arn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *role.Arn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } if role.PermissionsBoundary != nil { if arn, err := ParseARN(*role.PermissionsBoundary.PermissionsBoundaryArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-policy", Method: sdp.QueryMethod_SEARCH, Query: *role.PermissionsBoundary.PermissionsBoundaryArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } return &item, nil } func instanceProfileListTagsFunc(ctx context.Context, ip *types.InstanceProfile, client *iam.Client) map[string]string { tags := make(map[string]string) paginator := iam.NewListInstanceProfileTagsPaginator(client, &iam.ListInstanceProfileTagsInput{ InstanceProfileName: ip.InstanceProfileName, }) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { return HandleTagsError(ctx, err) } for _, tag := range out.Tags { if tag.Key != nil && tag.Value != nil { tags[*tag.Key] = *tag.Value } } } return tags } func NewIAMInstanceProfileAdapter(client *iam.Client, accountID string, cache sdpcache.Cache) *GetListAdapterV2[*iam.ListInstanceProfilesInput, *iam.ListInstanceProfilesOutput, *types.InstanceProfile, *iam.Client, *iam.Options] { return &GetListAdapterV2[*iam.ListInstanceProfilesInput, *iam.ListInstanceProfilesOutput, *types.InstanceProfile, *iam.Client, *iam.Options]{ ItemType: "iam-instance-profile", Client: client, CacheDuration: 3 * time.Hour, // IAM has very low rate limits, we need to cache for a long time AccountID: accountID, AdapterMetadata: instanceProfileAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *iam.Client, scope, query string) (*types.InstanceProfile, error) { return instanceProfileGetFunc(ctx, client, scope, query) }, InputMapperList: func(scope string) (*iam.ListInstanceProfilesInput, error) { return &iam.ListInstanceProfilesInput{}, nil }, ListFuncPaginatorBuilder: func(client *iam.Client, params *iam.ListInstanceProfilesInput) Paginator[*iam.ListInstanceProfilesOutput, *iam.Options] { return iam.NewListInstanceProfilesPaginator(client, params) }, ListExtractor: func(_ context.Context, output *iam.ListInstanceProfilesOutput, _ *iam.Client) ([]*types.InstanceProfile, error) { profiles := make([]*types.InstanceProfile, 0, len(output.InstanceProfiles)) for i := range output.InstanceProfiles { profiles = append(profiles, &output.InstanceProfiles[i]) } return profiles, nil }, ListTagsFunc: func(ctx context.Context, ip *types.InstanceProfile, c *iam.Client) (map[string]string, error) { return instanceProfileListTagsFunc(ctx, ip, c), nil }, ItemMapper: instanceProfileItemMapper, } } var instanceProfileAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "iam-instance-profile", DescriptiveName: "IAM Instance Profile", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an IAM instance profile by name", ListDescription: "List all IAM instance profiles", SearchDescription: "Search IAM instance profiles by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_iam_instance_profile.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"iam-role", "iam-policy"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/iam-instance-profile_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestInstanceProfileItemMapper(t *testing.T) { profile := types.InstanceProfile{ Arn: new("arn:aws:iam::123456789012:instance-profile/webserver"), CreateDate: new(time.Now()), InstanceProfileId: new("AIDACKCEVSQ6C2EXAMPLE"), InstanceProfileName: new("webserver"), Path: new("/"), Roles: []types.Role{ { Arn: new("arn:aws:iam::123456789012:role/webserver"), // link CreateDate: new(time.Now()), Path: new("/"), RoleId: new("AIDACKCEVSQ6C2EXAMPLE"), RoleName: new("webserver"), AssumeRolePolicyDocument: new(`{}`), Description: new("Allows EC2 instances to call AWS services on your behalf."), MaxSessionDuration: new(int32(3600)), PermissionsBoundary: &types.AttachedPermissionsBoundary{ PermissionsBoundaryArn: new("arn:aws:iam::123456789012:policy/XCompanyBoundaries"), // link PermissionsBoundaryType: types.PermissionsBoundaryAttachmentTypePolicy, }, RoleLastUsed: &types.RoleLastUsed{ LastUsedDate: new(time.Now()), Region: new("us-east-1"), }, }, }, } item, err := instanceProfileItemMapper(nil, "foo", &profile) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewIAMInstanceProfileAdapter(t *testing.T) { config, account, _ := GetAutoConfig(t) client := iam.NewFromConfig(config, func(o *iam.Options) { o.RetryMode = aws.RetryModeAdaptive o.RetryMaxAttempts = 10 }) adapter := NewIAMInstanceProfileAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 30 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/iam-policy.go ================================================ package adapters import ( "context" "errors" "fmt" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws/arn" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/iter" "go.opentelemetry.io/otel/trace" ) type PolicyDetails struct { Policy *types.Policy Document *policy.Policy PolicyGroups []types.PolicyGroup PolicyRoles []types.PolicyRole PolicyUsers []types.PolicyUser } func policyGetFunc(ctx context.Context, client IAMClient, scope, query string) (*PolicyDetails, error) { // Construct the ARN from the name etc. a := ARN{ ARN: arn.ARN{ Partition: "aws", Service: "iam", Region: "", // IAM doesn't have a region AccountID: scope, Resource: "policy/" + query, // The query should policyFullName which is (path + name) }, } out, err := client.GetPolicy(ctx, &iam.GetPolicyInput{ PolicyArn: new(a.String()), }) if err != nil { return nil, err } details := PolicyDetails{ Policy: out.Policy, } if out.Policy != nil { err := addPolicyEntities(ctx, client, &details) if err != nil { return nil, err } err = addPolicyDocument(ctx, client, &details) if err != nil { return nil, err } } return &details, nil } // Gets the current policy version and parses it, adds to the policy details func addPolicyDocument(ctx context.Context, client IAMClient, pol *PolicyDetails) error { if pol.Policy == nil { return errors.New("policy is nil") } if pol.Policy.Arn == nil || pol.Policy.DefaultVersionId == nil { return errors.New("policy ARN or default version ID is nil") } out, err := client.GetPolicyVersion(ctx, &iam.GetPolicyVersionInput{ PolicyArn: pol.Policy.Arn, VersionId: pol.Policy.DefaultVersionId, }) if err != nil { return err } if out.PolicyVersion == nil { return errors.New("policy version is nil") } if out.PolicyVersion.Document == nil { return nil } // Save to the pointer pol.Document, err = ParsePolicyDocument(*out.PolicyVersion.Document) if err != nil { return fmt.Errorf("error parsing policy document: %w", err) } return nil } func addPolicyEntities(ctx context.Context, client IAMClient, details *PolicyDetails) error { var span trace.Span if log.GetLevel() == log.TraceLevel { // Only create new spans on trace level logging ctx, span = tracer.Start(ctx, "addPolicyEntities") defer span.End() } if details == nil { return errors.New("details is nil") } if details.Policy == nil { return errors.New("policy is nil") } paginator := iam.NewListEntitiesForPolicyPaginator(client, &iam.ListEntitiesForPolicyInput{ PolicyArn: details.Policy.Arn, }) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { return err } details.PolicyGroups = append(details.PolicyGroups, out.PolicyGroups...) details.PolicyRoles = append(details.PolicyRoles, out.PolicyRoles...) details.PolicyUsers = append(details.PolicyUsers, out.PolicyUsers...) } return nil } func policyItemMapper(_ *string, scope string, awsItem *PolicyDetails) (*sdp.Item, error) { finalAttributes := struct { *types.Policy Document *policy.Policy }{ Policy: awsItem.Policy, Document: awsItem.Document, } attributes, err := ToAttributesWithExclude(finalAttributes) if err != nil { return nil, err } if awsItem.Policy.Path == nil || awsItem.Policy.PolicyName == nil { return nil, errors.New("policy Path and PolicyName must be populated") } // Combine the path and policy name to create a unique attribute policyFullName := *awsItem.Policy.Path + *awsItem.Policy.PolicyName // Trim the leading slash policyFullName, _ = strings.CutPrefix(policyFullName, "/") // Create a new attribute which is a combination of `path` and `policyName`, // this can then be constructed into an ARN when a user calls GET attributes.Set("PolicyFullName", policyFullName) item := sdp.Item{ Type: "iam-policy", UniqueAttribute: "PolicyFullName", Attributes: attributes, Scope: scope, } for _, group := range awsItem.PolicyGroups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-group", Query: *group.GroupName, Method: sdp.QueryMethod_GET, Scope: scope, }, }) } for _, user := range awsItem.PolicyUsers { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-user", Method: sdp.QueryMethod_GET, Query: *user.UserName, Scope: scope, }, }) } for _, role := range awsItem.PolicyRoles { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_GET, Query: *role.RoleName, Scope: scope, }, }) } if awsItem.Document != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, LinksFromPolicy(awsItem.Document)...) } return &item, nil } func policyListTagsFunc(ctx context.Context, p *PolicyDetails, client IAMClient) (map[string]string, error) { tags := make(map[string]string) paginator := iam.NewListPolicyTagsPaginator(client, &iam.ListPolicyTagsInput{ PolicyArn: p.Policy.Arn, }) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { return HandleTagsError(ctx, err), nil } for _, tag := range out.Tags { if tag.Key != nil && tag.Value != nil { tags[*tag.Key] = *tag.Value } } } return tags, nil } func policyListExtractor(ctx context.Context, output *iam.ListPoliciesOutput, client IAMClient) ([]*PolicyDetails, error) { return iter.MapErr(output.Policies, func(p *types.Policy) (*PolicyDetails, error) { details := PolicyDetails{ Policy: p, } err := addPolicyEntities(ctx, client, &details) if err != nil { return &details, err } err = addPolicyDocument(ctx, client, &details) if err != nil { return &details, err } return &details, nil }) } // NewPolicyAdapter Note that this policy adapter only support polices that are // user-created due to the fact that the AWS-created ones are basically "global" // in scope. In order to get this to work I'd have to change the way the adapter // is implemented so that it was mart enough to handle different scopes. This // has been added to the backlog: // https://github.com/overmindtech/workspace/aws-adapter/issues/68 func NewIAMPolicyAdapter(client IAMClient, accountID string, cache sdpcache.Cache) *GetListAdapterV2[*iam.ListPoliciesInput, *iam.ListPoliciesOutput, *PolicyDetails, IAMClient, *iam.Options] { return &GetListAdapterV2[*iam.ListPoliciesInput, *iam.ListPoliciesOutput, *PolicyDetails, IAMClient, *iam.Options]{ ItemType: "iam-policy", Client: client, AccountID: accountID, Region: "", // IAM policies aren't tied to a region CacheDuration: 3 * time.Hour, // IAM has very low rate limits, we need to cache for a long time AdapterMetadata: policyAdapterMetadata, cache: cache, SupportGlobalResources: true, InputMapperList: func(scope string) (*iam.ListPoliciesInput, error) { var iamScope types.PolicyScopeType if scope == "aws" { iamScope = types.PolicyScopeTypeAws } else { iamScope = types.PolicyScopeTypeLocal } return &iam.ListPoliciesInput{ OnlyAttached: true, Scope: iamScope, }, nil }, ListFuncPaginatorBuilder: func(client IAMClient, params *iam.ListPoliciesInput) Paginator[*iam.ListPoliciesOutput, *iam.Options] { return iam.NewListPoliciesPaginator(client, params) }, ListExtractor: policyListExtractor, GetFunc: policyGetFunc, ItemMapper: policyItemMapper, ListTagsFunc: policyListTagsFunc, } } var policyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "iam-policy", DescriptiveName: "IAM Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an IAM policy by policyFullName ({path} + {policyName})", ListDescription: "List all IAM policies", SearchDescription: "Search for IAM policies by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_iam_policy.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, { TerraformQueryMap: "aws_iam_user_policy_attachment.policy_arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"iam-group", "iam-user", "iam-role"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/iam-policy_test.go ================================================ package adapters import ( "context" "net/url" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *TestIAMClient) GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { return &iam.GetPolicyOutput{ Policy: &types.Policy{ PolicyName: new("AWSControlTowerStackSetRolePolicy"), PolicyId: new("ANPA3VLV2U277MP54R2OV"), Arn: new("arn:aws:iam::801795385023:policy/service-role/AWSControlTowerStackSetRolePolicy"), Path: new("/service-role/"), DefaultVersionId: new("v1"), AttachmentCount: new(int32(1)), PermissionsBoundaryUsageCount: new(int32(0)), IsAttachable: true, CreateDate: new(time.Now()), UpdateDate: new(time.Now()), }, }, nil } func (t *TestIAMClient) ListEntitiesForPolicy(context.Context, *iam.ListEntitiesForPolicyInput, ...func(*iam.Options)) (*iam.ListEntitiesForPolicyOutput, error) { return &iam.ListEntitiesForPolicyOutput{ PolicyGroups: []types.PolicyGroup{ { GroupId: new("groupId"), GroupName: new("groupName"), }, }, PolicyRoles: []types.PolicyRole{ { RoleId: new("roleId"), RoleName: new("roleName"), }, }, PolicyUsers: []types.PolicyUser{ { UserId: new("userId"), UserName: new("userName"), }, }, }, nil } func (t *TestIAMClient) ListPolicies(context.Context, *iam.ListPoliciesInput, ...func(*iam.Options)) (*iam.ListPoliciesOutput, error) { return &iam.ListPoliciesOutput{ Policies: []types.Policy{ { PolicyName: new("AWSControlTowerAdminPolicy"), PolicyId: new("ANPA3VLV2U2745H37HTHN"), Arn: new("arn:aws:iam::801795385023:policy/service-role/AWSControlTowerAdminPolicy"), Path: new("/service-role/"), DefaultVersionId: new("v1"), AttachmentCount: new(int32(1)), PermissionsBoundaryUsageCount: new(int32(0)), IsAttachable: true, CreateDate: new(time.Now()), UpdateDate: new(time.Now()), }, { PolicyName: new("AWSControlTowerCloudTrailRolePolicy"), PolicyId: new("ANPA3VLV2U27UOP7KSM6I"), Arn: new("arn:aws:iam::801795385023:policy/service-role/AWSControlTowerCloudTrailRolePolicy"), Path: new("/service-role/"), DefaultVersionId: new("v1"), AttachmentCount: new(int32(1)), PermissionsBoundaryUsageCount: new(int32(0)), IsAttachable: true, CreateDate: new(time.Now()), UpdateDate: new(time.Now()), }, }, }, nil } func (t *TestIAMClient) ListPolicyTags(ctx context.Context, params *iam.ListPolicyTagsInput, optFns ...func(*iam.Options)) (*iam.ListPolicyTagsOutput, error) { return &iam.ListPolicyTagsOutput{ Tags: []types.Tag{ { Key: new("foo"), Value: new("foo"), }, }, }, nil } const testPolicy = `{ "Version": "2012-10-17", "Statement": { "Effect": "Allow", "Action": [ "iam:AddUserToGroup", "iam:RemoveUserFromGroup", "iam:GetGroup" ], "Resource": [ "arn:aws:iam::609103258633:group/Developers", "arn:aws:iam::609103258633:group/Operators", "arn:aws:iam::609103258633:user/*" ] } }` func (c *TestIAMClient) GetPolicyVersion(ctx context.Context, params *iam.GetPolicyVersionInput, optFns ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) { create := time.Now() document := url.QueryEscape(testPolicy) versionId := "v2" return &iam.GetPolicyVersionOutput{ PolicyVersion: &types.PolicyVersion{ CreateDate: &create, Document: &document, IsDefaultVersion: true, VersionId: &versionId, }, }, nil } func TestGetCurrentPolicyVersion(t *testing.T) { client := &TestIAMClient{} ctx := context.Background() t.Run("with a good query", func(t *testing.T) { arn := "arn:aws:iam::609103258633:policy/DevelopersPolicy" version := "v2" policy := PolicyDetails{ Policy: &types.Policy{ Arn: &arn, DefaultVersionId: &version, }, } err := addPolicyDocument(ctx, client, &policy) if err != nil { t.Fatal(err) } }) t.Run("with empty values", func(t *testing.T) { arn := "" version := "" policy := PolicyDetails{ Policy: &types.Policy{ Arn: &arn, DefaultVersionId: &version, }, } err := addPolicyDocument(ctx, client, &policy) if err != nil { t.Fatal(err) } }) t.Run("with nil", func(t *testing.T) { policy := PolicyDetails{} err := addPolicyDocument(ctx, client, &policy) if err == nil { t.Fatal("expected error, got nil") } }) } func TestPolicyGetFunc(t *testing.T) { policy, err := policyGetFunc(context.Background(), &TestIAMClient{}, "foo", "bar") if err != nil { t.Error(err) } if policy.Policy == nil { t.Error("policy was nil") } if len(policy.PolicyGroups) != 1 { t.Errorf("expected 1 Group, got %v", len(policy.PolicyGroups)) } if len(policy.PolicyRoles) != 1 { t.Errorf("expected 1 Role, got %v", len(policy.PolicyRoles)) } if len(policy.PolicyUsers) != 1 { t.Errorf("expected 1 User, got %v", len(policy.PolicyUsers)) } if policy.Document.Version != "2012-10-17" { t.Errorf("expected version 2012-10-17, got %v", policy.Document.Version) } if len(policy.Document.Statements.Values()) != 1 { t.Errorf("expected 1 statement, got %v", len(policy.Document.Statements.Values())) } } func TestPolicyListTagsFunc(t *testing.T) { tags, err := policyListTagsFunc(context.Background(), &PolicyDetails{ Policy: &types.Policy{ Arn: new("arn:aws:iam::801795385023:policy/service-role/AWSControlTowerAdminPolicy"), }, }, &TestIAMClient{}) if err != nil { t.Error(err) } if len(tags) != 1 { t.Errorf("expected 1 tag, got %v", len(tags)) } } func TestPolicyItemMapper(t *testing.T) { details := &PolicyDetails{ Policy: &types.Policy{ PolicyName: new("AWSControlTowerAdminPolicy"), PolicyId: new("ANPA3VLV2U2745H37HTHN"), Arn: new("arn:aws:iam::801795385023:policy/service-role/AWSControlTowerAdminPolicy"), Path: new("/service-role/"), DefaultVersionId: new("v1"), AttachmentCount: new(int32(1)), PermissionsBoundaryUsageCount: new(int32(0)), IsAttachable: true, CreateDate: new(time.Now()), UpdateDate: new(time.Now()), }, PolicyGroups: []types.PolicyGroup{ { GroupId: new("groupId"), GroupName: new("groupName"), }, }, PolicyRoles: []types.PolicyRole{ { RoleId: new("roleId"), RoleName: new("roleName"), }, }, PolicyUsers: []types.PolicyUser{ { UserId: new("userId"), UserName: new("userName"), }, }, } err := addPolicyDocument(context.Background(), &TestIAMClient{}, details) if err != nil { t.Fatal(err) } item, err := policyItemMapper(nil, "foo", details) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "iam-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "groupName", ExpectedScope: "foo", }, { ExpectedType: "iam-user", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "userName", ExpectedScope: "foo", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "roleName", ExpectedScope: "foo", }, { ExpectedType: "iam-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::609103258633:group/Developers", ExpectedScope: "609103258633", }, { ExpectedType: "iam-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::609103258633:group/Operators", ExpectedScope: "609103258633", }, } tests.Execute(t, item) if item.UniqueAttributeValue() != "service-role/AWSControlTowerAdminPolicy" { t.Errorf("unexpected unique attribute value, got %s", item.UniqueAttributeValue()) } } func TestNewIAMPolicyAdapter(t *testing.T) { config, account, _ := GetAutoConfig(t) client := iam.NewFromConfig(config, func(o *iam.Options) { o.RetryMode = aws.RetryModeAdaptive o.RetryMaxAttempts = 10 }) adapter := NewIAMPolicyAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 30 * time.Second, } test.Run(t) // Test "aws" scoped resources t.Run("aws scoped resources in a specific scope", func(t *testing.T) { ctx, span := tracer.Start(context.Background(), t.Name()) defer span.End() t.Parallel() // This item shouldn't be found since it lives globally _, err := adapter.Get(ctx, FormatScope(account, ""), "ReadOnlyAccess", false) if err == nil { t.Error("expected error, got nil") } }) t.Run("aws scoped resources in the aws scope", func(t *testing.T) { ctx, span := tracer.Start(context.Background(), t.Name()) defer span.End() t.Parallel() // This item shouldn't be found since it lives globally item, err := adapter.Get(ctx, "aws", "ReadOnlyAccess", false) if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "ReadOnlyAccess" { t.Errorf("expected globally unique name to be ReadOnlyAccess, got %v", item.GloballyUniqueName()) } }) t.Run("listing resources in a specific scope", func(t *testing.T) { ctx, span := tracer.Start(context.Background(), t.Name()) defer span.End() stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, FormatScope(account, ""), false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } for _, item := range stream.GetItems() { arnString, err := item.GetAttributes().Get("Arn") if err != nil { t.Errorf("expected item to have an arn attribute, got %v", err) } arn, err := ParseARN(arnString.(string)) if err != nil { t.Error(err) } if arn.AccountID != account { t.Errorf("expected item account to be %v, got %v", account, arn.AccountID) } } if len(stream.GetItems()) == 0 { t.Fatal("no items found") } arn, _ := stream.GetItems()[0].GetAttributes().Get("Arn") t.Run("searching via ARN for a resource in a specific scope", func(t *testing.T) { ctxSearch, spanSearch := tracer.Start(context.Background(), t.Name()) defer spanSearch.End() t.Parallel() streamSearch := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctxSearch, FormatScope(account, ""), arn.(string), false, streamSearch) errsSearch := streamSearch.GetErrors() if len(errsSearch) > 0 { t.Error(errsSearch) } }) t.Run("searching via ARN for a resource in the aws scope", func(t *testing.T) { ctxSearchARN, spanSearchARN := tracer.Start(context.Background(), t.Name()) defer spanSearchARN.End() t.Parallel() streamSearchARN := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctxSearchARN, "aws", arn.(string), false, streamSearchARN) errsSearchARN := streamSearchARN.GetErrors() if len(errsSearchARN) == 0 { t.Error("expected error, got nil") } }) }) t.Run("listing resources in the AWS scope", func(t *testing.T) { ctx, span := tracer.Start(context.Background(), t.Name()) defer span.End() stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, "aws", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) == 0 { t.Fatal("expected items, got none") } for _, item := range items { arnString, err := item.GetAttributes().Get("Arn") if err != nil { t.Errorf("expected item to have an arn attribute, got %v", err) } arn, err := ParseARN(arnString.(string)) if err != nil { t.Error(err) } if arn.AccountID != "aws" { t.Errorf("expected item account to be aws, got %v", arn.AccountID) } } t.Run("searching via ARN for a resource in a specific scope", func(t *testing.T) { ctxSearch, spanSearch := tracer.Start(context.Background(), t.Name()) defer spanSearch.End() t.Parallel() arn, _ := items[0].GetAttributes().Get("Arn") streamSearch := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctxSearch, FormatScope(account, ""), arn.(string), false, streamSearch) errorsStreamSearch := streamSearch.GetErrors() if len(errorsStreamSearch) == 0 { t.Error("expected error, got nil") } }) t.Run("searching via ARN for a resource in the aws scope", func(t *testing.T) { ctxSearchARN, spanSearchARN := tracer.Start(context.Background(), t.Name()) defer spanSearchARN.End() t.Parallel() arn, _ := items[0].GetAttributes().Get("Arn") streamSearchARN := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctxSearchARN, "aws", arn.(string), false, streamSearchARN) errsStreamSearch := streamSearchARN.GetErrors() if len(errsStreamSearch) > 0 { t.Error(errsStreamSearch) } }) }) } ================================================ FILE: aws-source/adapters/iam-role.go ================================================ package adapters import ( "context" "errors" "fmt" "time" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/sourcegraph/conc/iter" ) type RoleDetails struct { Role *types.Role EmbeddedPolicies []embeddedPolicy AttachedPolicies []types.AttachedPolicy } func roleGetFunc(ctx context.Context, client IAMClient, _, query string) (*RoleDetails, error) { out, err := client.GetRole(ctx, &iam.GetRoleInput{ RoleName: &query, }) if err != nil { return nil, err } details := RoleDetails{ Role: out.Role, } err = enrichRole(ctx, client, &details) if err != nil { return nil, err } return &details, nil } func enrichRole(ctx context.Context, client IAMClient, roleDetails *RoleDetails) error { var err error // In this section we want to get the embedded polices, and determine links // to the attached policies // Get embedded policies roleDetails.EmbeddedPolicies, err = getEmbeddedPolicies(ctx, client, *roleDetails.Role.RoleName) if err != nil { return err } // Get the attached policies and create links to these roleDetails.AttachedPolicies, err = getAttachedPolicies(ctx, client, *roleDetails.Role.RoleName) if err != nil { return err } return nil } type embeddedPolicy struct { Name string Document *policy.Policy } // getEmbeddedPolicies returns a list of inline policies embedded in the role func getEmbeddedPolicies(ctx context.Context, client IAMClient, roleName string) ([]embeddedPolicy, error) { policiesPaginator := iam.NewListRolePoliciesPaginator(client, &iam.ListRolePoliciesInput{ RoleName: &roleName, }) ctx, span := tracer.Start(ctx, "getEmbeddedPolicies") defer span.End() policies := make([]embeddedPolicy, 0) for policiesPaginator.HasMorePages() { out, err := policiesPaginator.NextPage(ctx) if err != nil { return nil, err } for _, policyName := range out.PolicyNames { embeddedPolicy, err := getRolePolicyDetails(ctx, client, roleName, policyName) if err != nil { // Ignore these errors continue } policies = append(policies, *embeddedPolicy) err = ctx.Err() if err != nil { // If the context is done, we should stop processing and return an error, as the results are not complete return nil, err } } } return policies, nil } func getRolePolicyDetails(ctx context.Context, client IAMClient, roleName string, policyName string) (*embeddedPolicy, error) { ctx, span := tracer.Start(ctx, "getRolePolicyDetails") defer span.End() policy, err := client.GetRolePolicy(ctx, &iam.GetRolePolicyInput{ RoleName: &roleName, PolicyName: &policyName, }) if err != nil { return nil, err } if policy == nil || policy.PolicyDocument == nil { return nil, errors.New("policy document not found") } policyDoc, err := ParsePolicyDocument(*policy.PolicyDocument) if err != nil { return nil, fmt.Errorf("error parsing policy document: %w", err) } return &embeddedPolicy{ Name: policyName, Document: policyDoc, }, nil } // getAttachedPolicies Gets the attached policies for a role, these are actual // managed policies that can be linked to rather than embedded ones func getAttachedPolicies(ctx context.Context, client IAMClient, roleName string) ([]types.AttachedPolicy, error) { paginator := iam.NewListAttachedRolePoliciesPaginator(client, &iam.ListAttachedRolePoliciesInput{ RoleName: &roleName, }) attachedPolicies := make([]types.AttachedPolicy, 0) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { return nil, err } attachedPolicies = append(attachedPolicies, out.AttachedPolicies...) } return attachedPolicies, nil } func roleItemMapper(_ *string, scope string, awsItem *RoleDetails) (*sdp.Item, error) { enrichedRole := struct { *types.Role EmbeddedPolicies []embeddedPolicy // This is a replacement for the URL-encoded policy document so that the // user can see the policy AssumeRolePolicyDocument *policy.Policy }{ Role: awsItem.Role, EmbeddedPolicies: awsItem.EmbeddedPolicies, } // Parse the encoded policy document if awsItem.Role.AssumeRolePolicyDocument != nil { policyDoc, err := ParsePolicyDocument(*awsItem.Role.AssumeRolePolicyDocument) if err != nil { return nil, err } enrichedRole.AssumeRolePolicyDocument = policyDoc } attributes, err := ToAttributesWithExclude(enrichedRole) if err != nil { return nil, err } item := sdp.Item{ Type: "iam-role", UniqueAttribute: "RoleName", Attributes: attributes, Scope: scope, } for _, policy := range awsItem.AttachedPolicies { if policy.PolicyArn != nil { if a, err := ParseARN(*policy.PolicyArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-policy", Method: sdp.QueryMethod_SEARCH, Query: *policy.PolicyArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } // Extract links from policy documents for _, policy := range awsItem.EmbeddedPolicies { item.LinkedItemQueries = append(item.LinkedItemQueries, LinksFromPolicy(policy.Document)...) } // Extract links from the assume role policy document if enrichedRole.AssumeRolePolicyDocument != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, LinksFromPolicy(enrichedRole.AssumeRolePolicyDocument)...) } return &item, nil } func roleListTagsFunc(ctx context.Context, r *RoleDetails, client IAMClient) (map[string]string, error) { tags := make(map[string]string) paginator := iam.NewListRoleTagsPaginator(client, &iam.ListRoleTagsInput{ RoleName: r.Role.RoleName, }) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { return HandleTagsError(ctx, err), nil } for _, tag := range out.Tags { if tag.Key != nil && tag.Value != nil { tags[*tag.Key] = *tag.Value } } } return tags, nil } func NewIAMRoleAdapter(client IAMClient, accountID string, cache sdpcache.Cache) *GetListAdapterV2[*iam.ListRolesInput, *iam.ListRolesOutput, *RoleDetails, IAMClient, *iam.Options] { return &GetListAdapterV2[*iam.ListRolesInput, *iam.ListRolesOutput, *RoleDetails, IAMClient, *iam.Options]{ ItemType: "iam-role", Client: client, CacheDuration: 3 * time.Hour, // IAM has very low rate limits, we need to cache for a long time cache: cache, AccountID: accountID, GetFunc: func(ctx context.Context, client IAMClient, scope, query string) (*RoleDetails, error) { return roleGetFunc(ctx, client, scope, query) }, InputMapperList: func(scope string) (*iam.ListRolesInput, error) { return &iam.ListRolesInput{}, nil }, ListFuncPaginatorBuilder: func(client IAMClient, input *iam.ListRolesInput) Paginator[*iam.ListRolesOutput, *iam.Options] { return iam.NewListRolesPaginator(client, input) }, ListExtractor: func(ctx context.Context, output *iam.ListRolesOutput, client IAMClient) ([]*RoleDetails, error) { roles := make([]*RoleDetails, 0) mapper := iter.Mapper[types.Role, *RoleDetails]{ MaxGoroutines: 100, } newRoles, err := mapper.MapErr(output.Roles, func(role *types.Role) (*RoleDetails, error) { details := RoleDetails{ Role: role, } err := enrichRole(ctx, client, &details) if err != nil { return nil, err } return &details, nil }) if err != nil { return nil, err } roles = append(roles, newRoles...) return roles, nil }, ItemMapper: roleItemMapper, ListTagsFunc: roleListTagsFunc, AdapterMetadata: roleAdapterMetadata, } } var roleAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "iam-role", DescriptiveName: "IAM Role", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an IAM role by name", ListDescription: "List all IAM roles", SearchDescription: "Search for IAM roles by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_iam_role.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"iam-policy"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/iam-role_test.go ================================================ package adapters import ( "context" "encoding/json" "fmt" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *TestIAMClient) GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) { return &iam.GetRoleOutput{ Role: &types.Role{ Path: new("/service-role/"), RoleName: new("AWSControlTowerConfigAggregatorRoleForOrganizations"), RoleId: new("AROA3VLV2U27YSTBFCGCJ"), Arn: new("arn:aws:iam::801795385023:role/service-role/AWSControlTowerConfigAggregatorRoleForOrganizations"), CreateDate: new(time.Now()), AssumeRolePolicyDocument: new(`{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }`), MaxSessionDuration: new(int32(3600)), }, }, nil } func (t *TestIAMClient) ListRolePolicies(context.Context, *iam.ListRolePoliciesInput, ...func(*iam.Options)) (*iam.ListRolePoliciesOutput, error) { return &iam.ListRolePoliciesOutput{ PolicyNames: []string{ "one", "two", }, }, nil } func (t *TestIAMClient) ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error) { return &iam.ListRolesOutput{ Roles: []types.Role{ { Path: new("/service-role/"), RoleName: new("AWSControlTowerConfigAggregatorRoleForOrganizations"), RoleId: new("AROA3VLV2U27YSTBFCGCJ"), Arn: new("arn:aws:iam::801795385023:role/service-role/AWSControlTowerConfigAggregatorRoleForOrganizations"), CreateDate: new(time.Now()), AssumeRolePolicyDocument: new(`{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }`), MaxSessionDuration: new(int32(3600)), }, }, }, nil } func (t *TestIAMClient) ListRoleTags(ctx context.Context, params *iam.ListRoleTagsInput, optFns ...func(*iam.Options)) (*iam.ListRoleTagsOutput, error) { return &iam.ListRoleTagsOutput{ Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, }, nil } func (t *TestIAMClient) GetRolePolicy(ctx context.Context, params *iam.GetRolePolicyInput, optFns ...func(*iam.Options)) (*iam.GetRolePolicyOutput, error) { return &iam.GetRolePolicyOutput{ PolicyName: params.PolicyName, PolicyDocument: new(`{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*" } ] }`), RoleName: params.RoleName, }, nil } func (t *TestIAMClient) ListAttachedRolePolicies(ctx context.Context, params *iam.ListAttachedRolePoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { return &iam.ListAttachedRolePoliciesOutput{ AttachedPolicies: []types.AttachedPolicy{ { PolicyArn: new("arn:aws:iam::aws:policy/AdministratorAccess"), PolicyName: new("AdministratorAccess"), }, { PolicyArn: new("arn:aws:iam::aws:policy/AmazonS3FullAccess"), PolicyName: new("AmazonS3FullAccess"), }, }, }, nil } func TestRoleGetFunc(t *testing.T) { role, err := roleGetFunc(context.Background(), &TestIAMClient{}, "foo", "bar") if err != nil { t.Error(err) } if role.Role == nil { t.Error("role is nil") } if len(role.EmbeddedPolicies) != 2 { t.Errorf("expected 2 embedded policies, got %v", len(role.EmbeddedPolicies)) } if len(role.AttachedPolicies) != 2 { t.Errorf("expected 2 attached policies, got %v", len(role.AttachedPolicies)) } } func TestRoleListFunc(t *testing.T) { adapter := NewIAMRoleAdapter(&TestIAMClient{}, "foo", sdpcache.NewNoOpCache()) stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(context.Background(), "foo", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 1 { t.Errorf("expected 1 role, got %b", len(items)) } } func TestRoleListTagsFunc(t *testing.T) { tags, err := roleListTagsFunc(context.Background(), &RoleDetails{ Role: &types.Role{ Arn: new("arn:aws:iam::801795385023:role/service-role/AWSControlTowerConfigAggregatorRoleForOrganizations"), }, }, &TestIAMClient{}) if err != nil { t.Error(err) } if len(tags) != 1 { t.Errorf("expected 1 tag, got %v", len(tags)) } } const listBucketsPolicy = `{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*" } ] }` func TestRoleItemMapper(t *testing.T) { policyDoc := policy.Policy{} err := json.Unmarshal([]byte(listBucketsPolicy), &policyDoc) if err != nil { t.Fatal(err) } role := RoleDetails{ Role: &types.Role{ Path: new("/service-role/"), RoleName: new("AWSControlTowerConfigAggregatorRoleForOrganizations"), RoleId: new("AROA3VLV2U27YSTBFCGCJ"), Arn: new("arn:aws:iam::801795385023:role/service-role/AWSControlTowerConfigAggregatorRoleForOrganizations"), CreateDate: new(time.Now()), AssumeRolePolicyDocument: new(`%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22config.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D`), MaxSessionDuration: new(int32(3600)), Description: new("description"), PermissionsBoundary: &types.AttachedPermissionsBoundary{ PermissionsBoundaryArn: new("arn:aws:iam::801795385023:role/service-role/AWSControlTowerConfigAggregatorRoleForOrganizations"), PermissionsBoundaryType: types.PermissionsBoundaryAttachmentTypePolicy, }, RoleLastUsed: &types.RoleLastUsed{ LastUsedDate: new(time.Now()), Region: new("us-east-2"), }, }, EmbeddedPolicies: []embeddedPolicy{ { Name: "foo", Document: &policyDoc, }, }, AttachedPolicies: []types.AttachedPolicy{ { PolicyArn: new("arn:aws:iam::aws:policy/AdministratorAccess"), PolicyName: new("AdministratorAccess"), }, }, } item, err := roleItemMapper(nil, "foo", &role) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "iam-policy", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::aws:policy/AdministratorAccess", ExpectedScope: "aws", }, } tests.Execute(t, item) fmt.Println(item.ToMap()) } func TestNewIAMRoleAdapter(t *testing.T) { config, account, _ := GetAutoConfig(t) client := iam.NewFromConfig(config, func(o *iam.Options) { o.RetryMode = aws.RetryModeAdaptive o.RetryMaxAttempts = 10 }) adapter := NewIAMRoleAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 30 * time.Hour, } test.Run(t) } ================================================ FILE: aws-source/adapters/iam-user.go ================================================ package adapters import ( "context" "fmt" "time" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type UserDetails struct { User *types.User UserGroups []types.Group } func userGetFunc(ctx context.Context, client IAMClient, _, query string) (*UserDetails, error) { out, err := client.GetUser(ctx, &iam.GetUserInput{ UserName: &query, }) if err != nil { return nil, err } details := UserDetails{ User: out.User, } if out.User != nil { err = enrichUser(ctx, client, &details) if err != nil { return nil, fmt.Errorf("failed to enrich user %w", err) } } return &details, nil } // enrichUser Enriches the user with group and tag info func enrichUser(ctx context.Context, client IAMClient, userDetails *UserDetails) error { var err error userDetails.UserGroups, err = getUserGroups(ctx, client, userDetails.User.UserName) if err != nil { return err } return nil } // Gets all of the groups that a user is in func getUserGroups(ctx context.Context, client IAMClient, userName *string) ([]types.Group, error) { var out *iam.ListGroupsForUserOutput var err error groups := make([]types.Group, 0) paginator := iam.NewListGroupsForUserPaginator(client, &iam.ListGroupsForUserInput{ UserName: userName, }) for paginator.HasMorePages() { out, err = paginator.NextPage(ctx) if err != nil { return nil, err } groups = append(groups, out.Groups...) } return groups, nil } func userItemMapper(_ *string, scope string, awsItem *UserDetails) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem.User) if err != nil { return nil, err } item := sdp.Item{ Type: "iam-user", UniqueAttribute: "UserName", Attributes: attributes, Scope: scope, } for _, group := range awsItem.UserGroups { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-group", Method: sdp.QueryMethod_GET, Query: *group.GroupName, Scope: scope, }, }) } return &item, nil } func userListTagsFunc(ctx context.Context, u *UserDetails, client IAMClient) (map[string]string, error) { tags := make(map[string]string) paginator := iam.NewListUserTagsPaginator(client, &iam.ListUserTagsInput{ UserName: u.User.UserName, }) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { return HandleTagsError(ctx, err), nil } for _, tag := range out.Tags { if tag.Key != nil && tag.Value != nil { tags[*tag.Key] = *tag.Value } } } return tags, nil } func NewIAMUserAdapter(client IAMClient, accountID string, cache sdpcache.Cache) *GetListAdapterV2[*iam.ListUsersInput, *iam.ListUsersOutput, *UserDetails, IAMClient, *iam.Options] { return &GetListAdapterV2[*iam.ListUsersInput, *iam.ListUsersOutput, *UserDetails, IAMClient, *iam.Options]{ ItemType: "iam-user", Client: client, cache: cache, CacheDuration: 3 * time.Hour, // IAM has very low rate limits, we need to cache for a long time AccountID: accountID, GetFunc: func(ctx context.Context, client IAMClient, scope, query string) (*UserDetails, error) { return userGetFunc(ctx, client, scope, query) }, InputMapperList: func(scope string) (*iam.ListUsersInput, error) { return &iam.ListUsersInput{}, nil }, ListFuncPaginatorBuilder: func(client IAMClient, input *iam.ListUsersInput) Paginator[*iam.ListUsersOutput, *iam.Options] { return iam.NewListUsersPaginator(client, input) }, ListExtractor: func(ctx context.Context, output *iam.ListUsersOutput, client IAMClient) ([]*UserDetails, error) { userDetails := make([]*UserDetails, 0, len(output.Users)) for i := range output.Users { details := UserDetails{ User: &output.Users[i], } err := enrichUser(ctx, client, &details) if err != nil { return nil, fmt.Errorf("failed to enrich user %s: %w", *details.User.UserName, err) } userDetails = append(userDetails, &details) } return userDetails, nil }, ItemMapper: userItemMapper, ListTagsFunc: userListTagsFunc, AdapterMetadata: iamUserAdapterMetadata, } } var iamUserAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "iam-user", DescriptiveName: "IAM User", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an IAM user by name", ListDescription: "List all IAM users", SearchDescription: "Search for IAM users by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_iam_user.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, { TerraformQueryMap: "aws_iam_user_group_membership.user", TerraformMethod: sdp.QueryMethod_GET, }, }, PotentialLinks: []string{"iam-group"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/iam-user_test.go ================================================ package adapters import ( "context" "fmt" "strconv" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func (t *TestIAMClient) ListGroupsForUser(ctx context.Context, params *iam.ListGroupsForUserInput, optFns ...func(*iam.Options)) (*iam.ListGroupsForUserOutput, error) { isTruncated := true marker := params.Marker if marker == nil { marker = new("0") } // Get the current page markerInt, _ := strconv.Atoi(*marker) // Set the marker to the next page markerInt++ if markerInt >= 3 { isTruncated = false marker = nil } else { marker = new(fmt.Sprint(markerInt)) } return &iam.ListGroupsForUserOutput{ Groups: []types.Group{ { Arn: new("arn:aws:iam::801795385023:Group/something"), CreateDate: new(time.Now()), GroupId: new("id"), GroupName: new(fmt.Sprintf("group-%v", marker)), Path: new("/"), }, }, IsTruncated: isTruncated, Marker: marker, }, nil } func (t *TestIAMClient) GetUser(ctx context.Context, params *iam.GetUserInput, optFns ...func(*iam.Options)) (*iam.GetUserOutput, error) { return &iam.GetUserOutput{ User: &types.User{ Path: new("/"), UserName: new("power-users"), UserId: new("AGPA3VLV2U27T6SSLJMDS"), Arn: new("arn:aws:iam::801795385023:User/power-users"), CreateDate: new(time.Now()), }, }, nil } func (t *TestIAMClient) ListUsers(ctx context.Context, params *iam.ListUsersInput, optFns ...func(*iam.Options)) (*iam.ListUsersOutput, error) { isTruncated := true marker := params.Marker if marker == nil { marker = new("0") } // Get the current page markerInt, _ := strconv.Atoi(*marker) // Set the marker to the next page markerInt++ if markerInt >= 3 { isTruncated = false marker = nil } else { marker = new(fmt.Sprint(markerInt)) } return &iam.ListUsersOutput{ Users: []types.User{ { Path: new("/"), UserName: new(fmt.Sprintf("user-%v", marker)), UserId: new("AGPA3VLV2U27T6SSLJMDS"), Arn: new("arn:aws:iam::801795385023:User/power-users"), CreateDate: new(time.Now()), }, }, IsTruncated: isTruncated, Marker: marker, }, nil } func (t *TestIAMClient) ListUserTags(context.Context, *iam.ListUserTagsInput, ...func(*iam.Options)) (*iam.ListUserTagsOutput, error) { return &iam.ListUserTagsOutput{ Tags: []types.Tag{ { Key: new("foo"), Value: new("bar"), }, }, IsTruncated: false, Marker: nil, }, nil } func TestGetUserGroups(t *testing.T) { groups, err := getUserGroups(context.Background(), &TestIAMClient{}, new("foo")) if err != nil { t.Error(err) } if len(groups) != 3 { t.Errorf("expected 3 groups, got %v", len(groups)) } } func TestUserGetFunc(t *testing.T) { user, err := userGetFunc(context.Background(), &TestIAMClient{}, "foo", "bar") if err != nil { t.Error(err) } if user.User == nil { t.Error("user is nil") } if len(user.UserGroups) != 3 { t.Errorf("expected 3 groups, got %v", len(user.UserGroups)) } } func TestUserListFunc(t *testing.T) { adapter := NewIAMUserAdapter(&TestIAMClient{}, "foo", sdpcache.NewNoOpCache()) stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(context.Background(), "foo", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 3 { t.Errorf("expected 3 items, got %v", len(items)) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } if len(item.GetLinkedItemQueries()) != 3 { t.Errorf("expected 3 linked item queries, got %v", len(item.GetLinkedItemQueries())) } } } func TestUserListTagsFunc(t *testing.T) { tags, err := userListTagsFunc(context.Background(), &UserDetails{ User: &types.User{ UserName: new("foo"), }, }, &TestIAMClient{}) if err != nil { t.Error(err) } if len(tags) != 1 { t.Errorf("expected 1 tag, got %v", len(tags)) } } func TestUserItemMapper(t *testing.T) { details := UserDetails{ User: &types.User{ Path: new("/"), UserName: new("power-users"), UserId: new("AGPA3VLV2U27T6SSLJMDS"), Arn: new("arn:aws:iam::801795385023:User/power-users"), CreateDate: new(time.Now()), }, UserGroups: []types.Group{ { Arn: new("arn:aws:iam::801795385023:Group/something"), CreateDate: new(time.Now()), GroupId: new("id"), GroupName: new("name"), Path: new("/"), }, }, } item, err := userItemMapper(nil, "foo", &details) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "iam-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "name", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewIAMUserAdapter(t *testing.T) { config, account, _ := GetAutoConfig(t) client := iam.NewFromConfig(config, func(o *iam.Options) { o.RetryMode = aws.RetryModeAdaptive o.RetryMaxAttempts = 10 }) adapter := NewIAMUserAdapter(client, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 30 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/iam.go ================================================ package adapters import ( "context" "encoding/json" "fmt" "net/url" "regexp" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/sdp-go" ) type IAMClient interface { GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) GetPolicyVersion(ctx context.Context, params *iam.GetPolicyVersionInput, optFns ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) GetRolePolicy(ctx context.Context, params *iam.GetRolePolicyInput, optFns ...func(*iam.Options)) (*iam.GetRolePolicyOutput, error) GetUser(ctx context.Context, params *iam.GetUserInput, optFns ...func(*iam.Options)) (*iam.GetUserOutput, error) ListPolicyTags(ctx context.Context, params *iam.ListPolicyTagsInput, optFns ...func(*iam.Options)) (*iam.ListPolicyTagsOutput, error) ListRoleTags(ctx context.Context, params *iam.ListRoleTagsInput, optFns ...func(*iam.Options)) (*iam.ListRoleTagsOutput, error) iam.ListAttachedRolePoliciesAPIClient iam.ListEntitiesForPolicyAPIClient iam.ListGroupsForUserAPIClient iam.ListPoliciesAPIClient iam.ListRolePoliciesAPIClient iam.ListRolesAPIClient iam.ListUsersAPIClient iam.ListUserTagsAPIClient } type QueryExtractorFunc func(resource string, actions []string) []*sdp.LinkedItemQuery // This struct extracts linked item queries from an IAM policy. It must provide // a `RelevantResources` regex which will be checked against the resources that // each statement is mapped to. If it matches, the `ExtractorFunc` will be // called with the resource and actions that are allowed to be performed on that // resource type QueryExtractor struct { RelevantResources *regexp.Regexp ExtractorFunc QueryExtractorFunc } var ssmQueryExtractor = QueryExtractor{ RelevantResources: regexp.MustCompile("^arn:aws:ssm:"), ExtractorFunc: func(resource string, actions []string) []*sdp.LinkedItemQuery { // IAM for SSM works in a bit of a strange way: If a user has access to // a path, then the user can access all levels of that path. For // example, if a user has permission to access path /a, then the user // can also access /a/b. Even if a user has explicitly been denied // access in IAM for parameter /a/b, they can still call the // GetParametersByPath API operation recursively for /a and view /a/b. // https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-access.html // // Because of this all ARNs essential with a wildcard for the path a, err := ParseARN(resource) if err != nil { return nil } return []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "ssm-parameter", Method: sdp.QueryMethod_SEARCH, Query: a.String() + "*", // Wildcard at the end Scope: FormatScope(a.AccountID, a.Region), }, }, } }, } var fallbackQueryExtractor = QueryExtractor{ RelevantResources: regexp.MustCompile("^arn:"), ExtractorFunc: func(resource string, actions []string) []*sdp.LinkedItemQuery { arn, err := ParseARN(resource) if err != nil { return nil } // Since this could be an ARN to anything we are going to rely // on the fact that we *usually* have a SEARCH method that // accepts ARNs scope := sdp.WILDCARD if arn.AccountID != "aws" { if arn.AccountID != "*" && arn.Region != "*" { // If we have an account and region, then use those scope = FormatScope(arn.AccountID, arn.Region) } } // We need to convert the item type from ARN format to Overmind // format. Since we follow a pretty strict naming convention // this should *usually* work. Overmind's naming conventions are // based on the AWS CLI, e.g. `aws ec2 describe-instances` would // be `ec2-instance` overmindType := arn.Service + "-" + arn.Type() // It would be good here if we had a way to definitely know what // type a given ARN is, but I don't think the types are 1:1 so // we are going to have to use a wildcard. This will cause a lot // of failed searches which I don't love, but it will work // itemType := sdp.WILDCARD return []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: overmindType, Method: sdp.QueryMethod_SEARCH, Query: arn.String(), Scope: scope, }, }, } }, } // The ordered list of extractors to use. The first one that matches will be // used var extractors = []QueryExtractor{ ssmQueryExtractor, fallbackQueryExtractor, } // Extracts linked item queries from an IAM policy. In this case we only link to // entities that are explicitly mentioned in the policy. If we were to link to // more you'd end up with way too many links since a policy might for example // give read access to everything func LinksFromPolicy(document *policy.Policy) []*sdp.LinkedItemQuery { // We want to link all of the resources in the policy document, as long // as they have a valid ARN queries := make([]*sdp.LinkedItemQuery, 0) if document == nil || document.Statements == nil { return queries } for _, statement := range document.Statements.Values() { if statement.Principal != nil { // If we are referencing a specific IAM user or role as the // principal then we should link them here if awsPrincipal := statement.Principal.AWS(); awsPrincipal != nil { for _, value := range awsPrincipal.Values() { // These are in the format of ARN so we'll parse them if arn, err := ParseARN(value); err == nil { var typ string switch arn.Type() { case "role": typ = "iam-role" case "user": typ = "iam-user" } if typ != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: typ, Method: sdp.QueryMethod_SEARCH, Query: arn.String(), Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } } } } if statement.Resource != nil { for _, resource := range statement.Resource.Values() { // Try to extract links from the references resource using the // configurable extractors for _, extractor := range extractors { if extractor.RelevantResources != nil && extractor.RelevantResources.MatchString(resource) { if statement.Action == nil || len(statement.Action.Values()) == 0 { // If there is no action, then we can't extract // anything from this resource continue } queries = append(queries, extractor.ExtractorFunc(resource, statement.Action.Values())...) // Only use the first one that matches break } } } } } return queries } // Parses an IAM policy in it's URL-encoded embedded form func ParsePolicyDocument(encoded string) (*policy.Policy, error) { // Decode the policy document which is RFC 3986 URL encoded decoded, err := url.QueryUnescape(encoded) if err != nil { return nil, fmt.Errorf("failed to decode policy document: %w", err) } // Unmarshal the JSON policyDocument := policy.Policy{} err = json.Unmarshal([]byte(decoded), &policyDocument) if err != nil { return nil, fmt.Errorf("failed to unmarshal policy document: %w", err) } return &policyDocument, nil } ================================================ FILE: aws-source/adapters/iam_test.go ================================================ package adapters import ( "context" "log" "os" "testing" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/tracing" ) // TestIAMClient Test client that returns three pages type TestIAMClient struct{} func TestMain(m *testing.M) { exitCode := func() int { defer tracing.ShutdownTracer(context.Background()) if err := tracing.InitTracerWithUpstreams("aws-source-tests", os.Getenv("HONEYCOMB_API_KEY"), ""); err != nil { log.Fatal(err) } return m.Run() }() os.Exit(exitCode) } func TestLinksFromPolicy(t *testing.T) { t.Run("with a simple policy that extracts a principal", func(t *testing.T) { action := policy.NewStringOrSlice(true, "sts:AssumeRole") pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Action: action, Effect: "Allow", Principal: policy.NewAWSPrincipal("arn:aws:iam::123456789:role/aws-controltower-AuditAdministratorRole"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 1 { t.Fatalf("expected 1 query got %v", len(queries)) } }) t.Run("with a simple policy that something from the resource using teh fallback extractor", func(t *testing.T) { action := policy.NewStringOrSlice(true, "sts:AssumeRole") pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Action: action, Effect: "Allow", Resource: policy.NewStringOrSlice(true, "arn:aws:iam::123456789:role/aws-controltower-AuditAdministratorRole"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 1 { t.Fatalf("expected 1 query got %v", len(queries)) } }) t.Run("with a simple policy that something from the resource using the SSM extractor", func(t *testing.T) { action := policy.NewStringOrSlice(true, "ssm:GetParameter") pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Action: action, Effect: "Allow", Resource: policy.NewStringOrSlice(true, "arn:aws:ssm:us-west-2:123456789:parameter/foo"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 1 { t.Fatalf("expected 1 query got %v", len(queries)) } // This should have had an asterisk added if queries[0].GetQuery().GetQuery() != "arn:aws:ssm:us-west-2:123456789:parameter/foo*" { t.Errorf("expected query to be 'arn:aws:ssm:us-west-2:123456789:parameter/foo*' got %v", queries[0].GetQuery().GetQuery()) } }) } func TestLinksFromPolicy_EdgeCases(t *testing.T) { t.Run("nil policy returns empty slice", func(t *testing.T) { queries := LinksFromPolicy(nil) if len(queries) != 0 { t.Errorf("expected 0 queries, got %v", len(queries)) } }) t.Run("policy with nil statements returns empty slice", func(t *testing.T) { pol := &policy.Policy{} queries := LinksFromPolicy(pol) if len(queries) != 0 { t.Errorf("expected 0 queries, got %v", len(queries)) } }) t.Run("policy with statement with non-ARN principal", func(t *testing.T) { action := policy.NewStringOrSlice(true, "sts:AssumeRole") pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Action: action, Effect: "Allow", Principal: policy.NewAWSPrincipal("not-an-arn"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 0 { t.Errorf("expected 0 queries, got %v", len(queries)) } }) t.Run("policy with statement with principal of unknown type", func(t *testing.T) { action := policy.NewStringOrSlice(true, "sts:AssumeRole") // This ARN has a made-up type pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Action: action, Effect: "Allow", Principal: policy.NewAWSPrincipal("arn:aws:iam::123456789:foobar/aws-controltower-AuditAdministratorRole"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 0 { t.Errorf("expected 0 queries, got %v", len(queries)) } }) t.Run("policy with statement with resource but no action", func(t *testing.T) { pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Effect: "Allow", Resource: policy.NewStringOrSlice(true, "arn:aws:ssm:us-west-2:123456789:parameter/foo"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 0 { t.Errorf("expected 0 queries, got %v", len(queries)) } }) t.Run("policy with statement with resource that is not an ARN", func(t *testing.T) { action := policy.NewStringOrSlice(true, "ssm:GetParameter") pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Action: action, Effect: "Allow", Resource: policy.NewStringOrSlice(true, "not-an-arn"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 0 { t.Errorf("expected 0 queries, got %v", len(queries)) } }) t.Run("policy with multiple statements and mixed valid/invalid principals and resources", func(t *testing.T) { action := policy.NewStringOrSlice(true, "sts:AssumeRole") ssmAction := policy.NewStringOrSlice(true, "ssm:GetParameter") pol := policy.Policy{ Statements: policy.NewStatementOrSlice( policy.Statement{ Action: action, Effect: "Allow", Principal: policy.NewAWSPrincipal("arn:aws:iam::123456789:role/aws-controltower-AuditAdministratorRole"), }, policy.Statement{ Action: ssmAction, Effect: "Allow", Resource: policy.NewStringOrSlice(true, "arn:aws:ssm:us-west-2:123456789:parameter/foo"), }, policy.Statement{ Action: action, Effect: "Allow", Resource: policy.NewStringOrSlice(true, "not-an-arn"), }, ), } queries := LinksFromPolicy(&pol) if len(queries) != 2 { t.Errorf("expected 2 queries, got %v", len(queries)) } }) } ================================================ FILE: aws-source/adapters/integration/apigateway/apigateway_test.go ================================================ package apigateway import ( "context" "fmt" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func APIGateway(t *testing.T) { ctx := context.Background() var err error testClient, err := apigatewayClient(ctx) if err != nil { t.Fatalf("Failed to create APIGateway client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } accountID := testAWSConfig.AccountID t.Log("Running APIGateway integration test") // Resources ------------------------------------------------------------------------------------------------------ restApiSource := adapters.NewAPIGatewayRestApiAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = restApiSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway restApi adapter: %v", err) } resourceApiSource := adapters.NewAPIGatewayResourceAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = resourceApiSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway resource adapter: %v", err) } methodSource := adapters.NewAPIGatewayMethodAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = methodSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway method adapter: %v", err) } methodResponseSource := adapters.NewAPIGatewayMethodResponseAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = methodResponseSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway method response adapter: %v", err) } integrationSource := adapters.NewAPIGatewayIntegrationAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = integrationSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway integration adapter: %v", err) } apiKeySource := adapters.NewAPIGatewayApiKeyAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = apiKeySource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway API key adapter: %v", err) } authorizerSource := adapters.NewAPIGatewayAuthorizerAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = authorizerSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway authorizer adapter: %v", err) } deploymentSource := adapters.NewAPIGatewayDeploymentAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = deploymentSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway deployment adapter: %v", err) } stageSource := adapters.NewAPIGatewayStageAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = stageSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway stage adapter: %v", err) } modelSource := adapters.NewAPIGatewayModelAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = modelSource.Validate() if err != nil { t.Fatalf("failed to validate APIGateway model adapter: %v", err) } // Tests ---------------------------------------------------------------------------------------------------------- scope := adapters.FormatScope(accountID, testAWSConfig.Region) // List restApis restApis, err := restApiSource.List(ctx, scope, true) if err != nil { t.Fatalf("failed to list APIGateway restApis: %v", err) } if len(restApis) == 0 { t.Fatalf("no restApis found") } restApiUniqueAttribute := restApis[0].GetUniqueAttribute() restApiID, err := integration.GetUniqueAttributeValueByTags( restApiUniqueAttribute, restApis, integration.ResourceTags(integration.APIGateway, restAPISrc), true, ) if err != nil { t.Fatalf("failed to get restApi ID: %v", err) } // Get restApi restApi, err := restApiSource.Get(ctx, scope, restApiID, true) if err != nil { t.Fatalf("failed to get APIGateway restApi: %v", err) } restApiIDFromGet, err := integration.GetUniqueAttributeValueByTags( restApiUniqueAttribute, []*sdp.Item{restApi}, integration.ResourceTags(integration.APIGateway, restAPISrc), true, ) if err != nil { t.Fatalf("failed to get restApi ID from get: %v", err) } if restApiID != restApiIDFromGet { t.Fatalf("expected restApi ID %s, got %s", restApiID, restApiIDFromGet) } // Search restApis restApiName := integration.ResourceName(integration.APIGateway, restAPISrc, integration.TestID()) restApisFromSearch, err := restApiSource.Search(ctx, scope, restApiName, true) if err != nil { t.Fatalf("failed to search APIGateway restApis: %v", err) } if len(restApis) == 0 { t.Fatalf("no restApis found") } restApiIDFromSearch, err := integration.GetUniqueAttributeValueBySignificantAttribute( restApiUniqueAttribute, "Name", integration.ResourceName(integration.APIGateway, restAPISrc, integration.TestID()), restApisFromSearch, true, ) if err != nil { t.Fatalf("failed to get restApi ID from search: %v", err) } if restApiID != restApiIDFromSearch { t.Fatalf("expected restApi ID %s, got %s", restApiID, restApiIDFromSearch) } // Search resources resources, err := resourceApiSource.Search(ctx, scope, restApiID, true) if err != nil { t.Fatalf("failed to search APIGateway resources: %v", err) } if len(resources) == 0 { t.Fatalf("no resources found") } resourceUniqueAttribute := resources[0].GetUniqueAttribute() resourceUniqueAttrFromSearch, err := integration.GetUniqueAttributeValueBySignificantAttribute( resourceUniqueAttribute, "Path", "/test", resources, true, ) if err != nil { t.Fatalf("failed to get resource ID: %v", err) } // Get resource resource, err := resourceApiSource.Get(ctx, scope, resourceUniqueAttrFromSearch, true) if err != nil { t.Fatalf("failed to get APIGateway resource: %v", err) } resourceUniqueAttrFromGet, err := integration.GetUniqueAttributeValueBySignificantAttribute( resourceUniqueAttribute, "Path", "/test", []*sdp.Item{resource}, true, ) if err != nil { t.Fatalf("failed to get resource ID from get: %v", err) } if resourceUniqueAttrFromSearch != resourceUniqueAttrFromGet { t.Fatalf("expected resource ID %s, got %s", resourceUniqueAttrFromSearch, resourceUniqueAttrFromGet) } // Get method methodID := fmt.Sprintf("%s/GET", resourceUniqueAttrFromGet) // resourceUniqueAttribute contains the restApiID method, err := methodSource.Get(ctx, scope, methodID, true) if err != nil { t.Fatalf("failed to get APIGateway method: %v", err) } uniqueMethodAttr, err := method.GetAttributes().Get(method.GetUniqueAttribute()) if err != nil { t.Fatalf("failed to get unique method attribute: %v", err) } if uniqueMethodAttr != methodID { t.Fatalf("expected method ID %s, got %s", methodID, uniqueMethodAttr) } // Get method response methodResponseID := fmt.Sprintf("%s/200", methodID) methodResponse, err := methodResponseSource.Get(ctx, scope, methodResponseID, true) if err != nil { t.Fatalf("failed to get APIGateway method response: %v", err) } uniqueMethodResponseAttr, err := methodResponse.GetAttributes().Get(methodResponse.GetUniqueAttribute()) if err != nil { t.Fatalf("failed to get unique method response attribute: %v", err) } if uniqueMethodResponseAttr != methodResponseID { t.Fatalf("expected method response ID %s, got %s", methodResponseID, uniqueMethodResponseAttr) } // Get integration integrationID := fmt.Sprintf("%s/GET", resourceUniqueAttrFromGet) // resourceUniqueAttribute contains the restApiID itgr, err := integrationSource.Get(ctx, scope, integrationID, true) if err != nil { t.Fatalf("failed to get APIGateway itgr: %v", err) } uniqueIntegrationAttr, err := itgr.GetAttributes().Get(itgr.GetUniqueAttribute()) if err != nil { t.Fatalf("failed to get unique itgr attribute: %v", err) } if uniqueIntegrationAttr != integrationID { t.Fatalf("expected integration ID %s, got %s", integrationID, uniqueIntegrationAttr) } // List API keys apiKeys, err := apiKeySource.List(ctx, scope, true) if err != nil { t.Fatalf("failed to list APIGateway API keys: %v", err) } if len(apiKeys) == 0 { t.Fatalf("no API keys found") } apiKeyUniqueAttribute := apiKeys[0].GetUniqueAttribute() apiKeyID, err := integration.GetUniqueAttributeValueByTags( apiKeyUniqueAttribute, apiKeys, integration.ResourceTags(integration.APIGateway, apiKeySrc), true, ) if err != nil { t.Fatalf("failed to get API key ID: %v", err) } // Get API key apiKey, err := apiKeySource.Get(ctx, scope, apiKeyID, true) if err != nil { t.Fatalf("failed to get APIGateway API key: %v", err) } apiKeyIDFromGet, err := integration.GetUniqueAttributeValueByTags( apiKeyUniqueAttribute, []*sdp.Item{apiKey}, integration.ResourceTags(integration.APIGateway, apiKeySrc), true, ) if err != nil { t.Fatalf("failed to get API key ID from get: %v", err) } if apiKeyID != apiKeyIDFromGet { t.Fatalf("expected API key ID %s, got %s", apiKeyID, apiKeyIDFromGet) } // Search API keys apiKeyName := integration.ResourceName(integration.APIGateway, apiKeySrc, integration.TestID()) apiKeysFromSearch, err := apiKeySource.Search(ctx, scope, apiKeyName, true) if err != nil { t.Fatalf("failed to search APIGateway API keys: %v", err) } if len(apiKeysFromSearch) == 0 { t.Fatalf("no API keys found") } apiKeyIDFromSearch, err := integration.GetUniqueAttributeValueBySignificantAttribute( apiKeyUniqueAttribute, "Name", apiKeyName, apiKeysFromSearch, true, ) if err != nil { t.Fatalf("failed to get API key ID from search: %v", err) } if apiKeyID != apiKeyIDFromSearch { t.Fatalf("expected API key ID %s, got %s", apiKeyID, apiKeyIDFromSearch) } // Search authorizers by restApiID authorizers, err := authorizerSource.Search(ctx, scope, restApiID, true) if err != nil { t.Fatalf("failed to search APIGateway authorizers: %v", err) } authorizerUniqueAttribute := authorizers[0].GetUniqueAttribute() authorizerTestName := integration.ResourceName(integration.APIGateway, authorizerSrc, integration.TestID()) authorizerID, err := integration.GetUniqueAttributeValueBySignificantAttribute( authorizerUniqueAttribute, "Name", authorizerTestName, authorizers, true, ) if err != nil { t.Fatalf("failed to get authorizer ID: %v", err) } // Get authorizer query := fmt.Sprintf("%s/%s", restApiID, authorizerID) authorizer, err := authorizerSource.Get(ctx, scope, query, true) if err != nil { t.Fatalf("failed to get APIGateway authorizer: %v", err) } authorizerIDFromGet, err := integration.GetUniqueAttributeValueBySignificantAttribute( authorizerUniqueAttribute, "Name", authorizerTestName, []*sdp.Item{authorizer}, true, ) if err != nil { t.Fatalf("failed to get authorizer ID from get: %v", err) } if authorizerID != authorizerIDFromGet { t.Fatalf("expected authorizer ID %s, got %s", authorizerID, authorizerIDFromGet) } // Search authorizer by restApiID/name query = fmt.Sprintf("%s/%s", restApiID, authorizerTestName) authorizersFromSearch, err := authorizerSource.Search(ctx, scope, query, true) if err != nil { t.Fatalf("failed to search APIGateway authorizers: %v", err) } if len(authorizersFromSearch) == 0 { t.Fatalf("no authorizers found") } authorizerIDFromSearch, err := integration.GetUniqueAttributeValueBySignificantAttribute( authorizerUniqueAttribute, "Name", authorizerTestName, authorizersFromSearch, true, ) if err != nil { t.Fatalf("failed to get authorizer ID from search: %v", err) } if authorizerID != authorizerIDFromSearch { t.Fatalf("expected authorizer ID %s, got %s", authorizerID, authorizerIDFromSearch) } // Search deployments by restApiID deployments, err := deploymentSource.Search(ctx, scope, restApiID, true) if err != nil { t.Fatalf("failed to search APIGateway deployments: %v", err) } if len(deployments) == 0 { t.Fatalf("no deployments found") } deploymentUniqueAttribute := deployments[0].GetUniqueAttribute() deploymentID, err := integration.GetUniqueAttributeValueBySignificantAttribute( deploymentUniqueAttribute, "Description", "test-deployment", deployments, true, ) if err != nil { t.Fatalf("failed to get deployment ID: %v", err) } // Get deployment query = fmt.Sprintf("%s/%s", restApiID, deploymentID) deployment, err := deploymentSource.Get(ctx, scope, query, true) if err != nil { t.Fatalf("failed to get APIGateway deployment: %v", err) } deploymentIDFromGet, err := integration.GetUniqueAttributeValueBySignificantAttribute( deploymentUniqueAttribute, "Description", "test-deployment", []*sdp.Item{deployment}, true, ) if err != nil { t.Fatalf("failed to get deployment ID from get: %v", err) } if deploymentID != deploymentIDFromGet { t.Fatalf("expected deployment ID %s, got %s", deploymentID, deploymentIDFromGet) } // Search deployment by restApiID/description query = fmt.Sprintf("%s/test-deployment", restApiID) deploymentsFromSearch, err := deploymentSource.Search(ctx, scope, query, true) if err != nil { t.Fatalf("failed to search APIGateway deployments: %v", err) } if len(deploymentsFromSearch) == 0 { t.Fatalf("no deployments found") } deploymentIDFromSearch, err := integration.GetUniqueAttributeValueBySignificantAttribute( deploymentUniqueAttribute, "Description", "test-deployment", deploymentsFromSearch, true, ) if err != nil { t.Fatalf("failed to get deployment ID from search: %v", err) } if deploymentID != deploymentIDFromSearch { t.Fatalf("expected deployment ID %s, got %s", deploymentID, deploymentIDFromSearch) } // Search stages by restApiID stages, err := stageSource.Search(ctx, scope, restApiID, true) if err != nil { t.Fatalf("failed to search APIGateway stages: %v", err) } if len(stages) == 0 { t.Fatalf("no stages found") } stageUniqueAttribute := stages[0].GetUniqueAttribute() stageID, err := integration.GetUniqueAttributeValueBySignificantAttribute( stageUniqueAttribute, "StageName", "dev", stages, true, ) if err != nil { t.Fatalf("failed to get stage ID: %v", err) } // Get stage query = fmt.Sprintf("%s/dev", restApiID) stage, err := stageSource.Get(ctx, scope, query, true) if err != nil { t.Fatalf("failed to get APIGateway stage: %v", err) } stageIDFromGet, err := integration.GetUniqueAttributeValueBySignificantAttribute( stageUniqueAttribute, "StageName", "dev", []*sdp.Item{stage}, true, ) if err != nil { t.Fatalf("failed to get stage ID from get: %v", err) } if stageID != stageIDFromGet { t.Fatalf("expected stage ID %s, got %s", stageID, stageIDFromGet) } // Search stage by restApiID/deploymentID query = fmt.Sprintf("%s/%s", restApiID, deploymentID) stagesFromSearch, err := stageSource.Search(ctx, scope, query, true) if err != nil { t.Fatalf("failed to search APIGateway stages: %v", err) } if len(stagesFromSearch) == 0 { t.Fatalf("no stages found") } stageIDFromSearch, err := integration.GetUniqueAttributeValueBySignificantAttribute( stageUniqueAttribute, "StageName", "dev", stagesFromSearch, true, ) if err != nil { t.Fatalf("failed to get stage ID from search: %v", err) } if stageID != stageIDFromSearch { t.Fatalf("expected stage ID %s, got %s", stageID, stageIDFromSearch) } // Search models by restApiID models, err := modelSource.Search(ctx, scope, restApiID, true) if err != nil { t.Fatalf("failed to search APIGateway models: %v", err) } if len(models) == 0 { t.Fatalf("no models found") } modelUniqueAttribute := models[0].GetUniqueAttribute() modelID, err := integration.GetUniqueAttributeValueBySignificantAttribute( modelUniqueAttribute, "Name", "testModel", models, true, ) if err != nil { t.Fatalf("failed to get model ID: %v", err) } // Get model query = fmt.Sprintf("%s/testModel", restApiID) model, err := modelSource.Get(ctx, scope, query, true) if err != nil { t.Fatalf("failed to get APIGateway model: %v", err) } modelIDFromGet, err := integration.GetUniqueAttributeValueBySignificantAttribute( modelUniqueAttribute, "Name", "testModel", []*sdp.Item{model}, true, ) if err != nil { t.Fatalf("failed to get model ID from get: %v", err) } if modelID != modelIDFromGet { t.Fatalf("expected model ID %s, got %s", modelID, modelIDFromGet) } t.Log("APIGateway integration test completed") } ================================================ FILE: aws-source/adapters/integration/apigateway/create.go ================================================ package apigateway import ( "context" "errors" "log/slog" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func createRestAPI(ctx context.Context, logger *slog.Logger, client *apigateway.Client, testID string) (*string, error) { // check if a resource with the same tags already exists id, err := findRestAPIsByTags(ctx, client) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating Rest API") } else { return nil, err } } if id != nil { logger.InfoContext(ctx, "Rest API already exists") return id, nil } result, err := client.CreateRestApi(ctx, &apigateway.CreateRestApiInput{ Name: new(integration.ResourceName(integration.APIGateway, restAPISrc, testID)), Description: new("Test Rest API"), Tags: resourceTags(restAPISrc, testID), }) if err != nil { return nil, err } return result.Id, nil } func createResource(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID, parentID *string, path string) (*string, error) { // check if a resource with the same path already exists resourceID, err := findResource(ctx, client, restAPIID, path) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating resource") } else { return nil, err } } if resourceID != nil { logger.InfoContext(ctx, "Resource already exists") return resourceID, nil } result, err := client.CreateResource(ctx, &apigateway.CreateResourceInput{ RestApiId: restAPIID, ParentId: parentID, PathPart: new(cleanPath(path)), }) if err != nil { return nil, err } return result.Id, nil } func cleanPath(path string) string { p, ok := strings.CutPrefix(path, "/") if !ok { return path } return p } func createMethod(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID, resourceID *string, method string) error { // check if a method with the same name already exists err := findMethod(ctx, client, restAPIID, resourceID, method) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating method") } else { return err } } if err == nil { logger.InfoContext(ctx, "Method already exists") return nil } _, err = client.PutMethod(ctx, &apigateway.PutMethodInput{ RestApiId: restAPIID, ResourceId: resourceID, HttpMethod: new(method), AuthorizationType: new("NONE"), }) if err != nil { return err } return nil } func createMethodResponse(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID, resourceID *string, method, statusCode string) error { // check if a method response with the same status code already exists err := findMethodResponse(ctx, client, restAPIID, resourceID, method, statusCode) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating method response") } else { return err } } if err == nil { logger.InfoContext(ctx, "Method response already exists") return nil } _, err = client.PutMethodResponse(ctx, &apigateway.PutMethodResponseInput{ RestApiId: restAPIID, ResourceId: resourceID, HttpMethod: new(method), StatusCode: new(statusCode), ResponseModels: map[string]string{ "application/json": "Empty", }, ResponseParameters: map[string]bool{ "method.response.header.Content-Type": true, }, }) if err != nil { return err } return nil } func createIntegration(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID, resourceID *string, method string) error { // check if an integration with the same method already exists err := findIntegration(ctx, client, restAPIID, resourceID, method) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating integration") } else { return err } } if err == nil { logger.InfoContext(ctx, "Integration already exists") return nil } _, err = client.PutIntegration(ctx, &apigateway.PutIntegrationInput{ RestApiId: restAPIID, ResourceId: resourceID, HttpMethod: new(method), Type: "MOCK", }) if err != nil { return err } return nil } func createAPIKey(ctx context.Context, logger *slog.Logger, client *apigateway.Client, testID string) error { // check if an API key with the same name already exists id, err := findAPIKeyByName(ctx, client, integration.ResourceName(integration.APIGateway, apiKeySrc, testID)) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating API key") } else { return err } } if id != nil { logger.InfoContext(ctx, "API key already exists") return nil } _, err = client.CreateApiKey(ctx, &apigateway.CreateApiKeyInput{ Name: new(integration.ResourceName(integration.APIGateway, apiKeySrc, testID)), Tags: resourceTags(apiKeySrc, testID), Enabled: true, }) if err != nil { return err } return nil } func createAuthorizer(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID, testID string) error { // check if an authorizer with the same name already exists id, err := findAuthorizerByName(ctx, client, restAPIID, integration.ResourceName(integration.APIGateway, authorizerSrc, testID)) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating authorizer") } else { return err } } if id != nil { logger.InfoContext(ctx, "Authorizer already exists") return nil } identitySource := "method.request.header.Authorization" _, err = client.CreateAuthorizer(ctx, &apigateway.CreateAuthorizerInput{ RestApiId: &restAPIID, Name: new(integration.ResourceName(integration.APIGateway, authorizerSrc, testID)), Type: types.AuthorizerTypeToken, IdentitySource: &identitySource, AuthorizerUri: new("arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:auth-function/invocations"), }) if err != nil { return err } return nil } func createDeployment(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID string) (*string, error) { // check if a deployment with the same name already exists id, err := findDeploymentByDescription(ctx, client, restAPIID, "test-deployment") if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating deployment") } else { return nil, err } } if id != nil { logger.InfoContext(ctx, "Deployment already exists") return id, nil } resp, err := client.CreateDeployment(ctx, &apigateway.CreateDeploymentInput{ RestApiId: &restAPIID, Description: new("test-deployment"), }) if err != nil { return nil, err } return resp.Id, nil } func createStage(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID, deploymentID string) error { // check if a stage with the same name already exists stgName := "dev" err := findStageByName(ctx, client, restAPIID, stgName) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating stage") } else { return err } } if err == nil { logger.InfoContext(ctx, "Stage already exists") return nil } _, err = client.CreateStage(ctx, &apigateway.CreateStageInput{ RestApiId: &restAPIID, DeploymentId: &deploymentID, StageName: &stgName, }) if err != nil { return err } return nil } func createModel(ctx context.Context, logger *slog.Logger, client *apigateway.Client, restAPIID string) error { modelName := "testModel" // check if a model with the same testID already exists err := findModelByName(ctx, client, restAPIID, modelName) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating model") } else { return err } } if err == nil { logger.InfoContext(ctx, "Model already exists") return nil } _, err = client.CreateModel(ctx, &apigateway.CreateModelInput{ RestApiId: &restAPIID, Name: &modelName, Schema: new("{}"), ContentType: new("application/json"), }) if err != nil { return err } return nil } ================================================ FILE: aws-source/adapters/integration/apigateway/delete.go ================================================ package apigateway import ( "context" "github.com/aws/aws-sdk-go-v2/service/apigateway" ) func deleteRestAPI(ctx context.Context, client *apigateway.Client, restAPIID string) error { _, err := client.DeleteRestApi(ctx, &apigateway.DeleteRestApiInput{ RestApiId: new(restAPIID), }) return err } func deleteAPIKeyByName(ctx context.Context, client *apigateway.Client, id *string) error { _, err := client.DeleteApiKey(ctx, &apigateway.DeleteApiKeyInput{ ApiKey: id, }) if err != nil { return err } return nil } ================================================ FILE: aws-source/adapters/integration/apigateway/find.go ================================================ package apigateway import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func findRestAPIsByTags(ctx context.Context, client *apigateway.Client, additionalAttr ...string) (*string, error) { result, err := client.GetRestApis(ctx, &apigateway.GetRestApisInput{}) if err != nil { return nil, err } for _, api := range result.Items { if hasTags(api.Tags, resourceTags(restAPISrc, integration.TestID(), additionalAttr...)) { return api.Id, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.APIGateway, restAPISrc, additionalAttr...)) } func findResource(ctx context.Context, client *apigateway.Client, restAPIID *string, path string) (*string, error) { result, err := client.GetResources(ctx, &apigateway.GetResourcesInput{ RestApiId: restAPIID, }) if err != nil { return nil, err } for _, resource := range result.Items { if *resource.Path == path { return resource.Id, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.APIGateway, resourceSrc, path)) } func findMethod(ctx context.Context, client *apigateway.Client, restAPIID, resourceID *string, method string) error { _, err := client.GetMethod(ctx, &apigateway.GetMethodInput{ RestApiId: restAPIID, ResourceId: resourceID, HttpMethod: &method, }) if err != nil { var notFoundErr *types.NotFoundException if errors.As(err, ¬FoundErr) { return integration.NewNotFoundError(integration.ResourceName( integration.APIGateway, methodSrc, method, )) } return err } return nil } func findMethodResponse(ctx context.Context, client *apigateway.Client, restAPIID, resourceID *string, method string, statusCode string) error { _, err := client.GetMethodResponse(ctx, &apigateway.GetMethodResponseInput{ RestApiId: restAPIID, ResourceId: resourceID, HttpMethod: &method, StatusCode: &statusCode, }) if err != nil { var notFoundErr *types.NotFoundException if errors.As(err, ¬FoundErr) { return integration.NewNotFoundError(integration.ResourceName( integration.APIGateway, methodResponseSrc, method, statusCode, )) } return err } return nil } func findIntegration(ctx context.Context, client *apigateway.Client, restAPIID, resourceID *string, method string) error { _, err := client.GetIntegration(ctx, &apigateway.GetIntegrationInput{ RestApiId: restAPIID, ResourceId: resourceID, HttpMethod: &method, }) if err != nil { var notFoundErr *types.NotFoundException if errors.As(err, ¬FoundErr) { return integration.NewNotFoundError(integration.ResourceName( integration.APIGateway, integrationSrc, method, )) } return err } return nil } func findAPIKeyByName(ctx context.Context, client *apigateway.Client, name string) (*string, error) { result, err := client.GetApiKeys(ctx, &apigateway.GetApiKeysInput{ NameQuery: &name, }) if err != nil { return nil, err } if len(result.Items) == 0 { return nil, integration.NewNotFoundError(integration.ResourceName(integration.APIGateway, apiKeySrc, name)) } for _, apiKey := range result.Items { if *apiKey.Name == name { return apiKey.Id, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.APIGateway, apiKeySrc, name)) } func findAuthorizerByName(ctx context.Context, client *apigateway.Client, restAPIID, name string) (*string, error) { result, err := client.GetAuthorizers(ctx, &apigateway.GetAuthorizersInput{ RestApiId: &restAPIID, }) if err != nil { return nil, err } if len(result.Items) == 0 { return nil, integration.NewNotFoundError(integration.ResourceName(integration.APIGateway, authorizerSrc, name)) } for _, authorizer := range result.Items { if *authorizer.Name == name { return authorizer.Id, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.APIGateway, authorizerSrc, name)) } func findDeploymentByDescription(ctx context.Context, client *apigateway.Client, restAPIID, description string) (*string, error) { result, err := client.GetDeployments(ctx, &apigateway.GetDeploymentsInput{ RestApiId: &restAPIID, }) if err != nil { return nil, err } for _, deployment := range result.Items { if *deployment.Description == description { return deployment.Id, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.APIGateway, deploymentSrc, description)) } func findStageByName(ctx context.Context, client *apigateway.Client, restAPIID, name string) error { result, err := client.GetStage(ctx, &apigateway.GetStageInput{ RestApiId: &restAPIID, StageName: &name, }) if err != nil { var notFoundErr *types.NotFoundException if errors.As(err, ¬FoundErr) { return integration.NewNotFoundError(integration.ResourceName( integration.APIGateway, stageSrc, name, )) } return err } if result == nil { return integration.NewNotFoundError(integration.ResourceName( integration.APIGateway, stageSrc, name, )) } return nil } func findModelByName(ctx context.Context, client *apigateway.Client, restAPIID, name string) error { result, err := client.GetModel(ctx, &apigateway.GetModelInput{ RestApiId: &restAPIID, ModelName: &name, }) if err != nil { var notFoundErr *types.NotFoundException if errors.As(err, ¬FoundErr) { return integration.NewNotFoundError(integration.ResourceName( integration.APIGateway, stageSrc, name, )) } return err } if result == nil { return integration.NewNotFoundError(integration.ResourceName( integration.APIGateway, stageSrc, name, )) } return nil } ================================================ FILE: aws-source/adapters/integration/apigateway/main_test.go ================================================ package apigateway import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/apigateway" "log/slog" "os" "testing" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func TestMain(m *testing.M) { if integration.ShouldRunIntegrationTests() { fmt.Println("Running apigateway integration tests") os.Exit(m.Run()) } else { fmt.Println("Skipping apigateway integration tests, set RUN_INTEGRATION_TESTS=true to run them") os.Exit(0) } } func TestIntegrationAPIGateway(t *testing.T) { t.Run("Setup", Setup) t.Run("APIGateway", APIGateway) t.Run("Teardown", Teardown) } func Setup(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := apigatewayClient(ctx) if err != nil { t.Fatalf("Failed to create APIGateway client: %v", err) } if err := setup(ctx, logger, testClient); err != nil { t.Fatalf("Failed to setup APIGateway integration tests: %v", err) } } func Teardown(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := apigatewayClient(ctx) if err != nil { t.Fatalf("Failed to create APIGateway client: %v", err) } if err := teardown(ctx, logger, testClient); err != nil { t.Fatalf("Failed to teardown APIGateway integration tests: %v", err) } } func apigatewayClient(ctx context.Context) (*apigateway.Client, error) { testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { return nil, fmt.Errorf("failed to create AWS config: %w", err) } return apigateway.NewFromConfig(testAWSConfig.Config), nil } ================================================ FILE: aws-source/adapters/integration/apigateway/setup.go ================================================ package apigateway import ( "context" "log/slog" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/overmindtech/cli/aws-source/adapters/integration" ) const ( restAPISrc = "rest-api" resourceSrc = "resource" methodSrc = "method" methodResponseSrc = "method-response" integrationSrc = "integration" apiKeySrc = "api-key" authorizerSrc = "authorizer" deploymentSrc = "deployment" stageSrc = "stage" modelSrc = "model" ) func setup(ctx context.Context, logger *slog.Logger, client *apigateway.Client) error { testID := integration.TestID() // Create Rest API restApiID, err := createRestAPI(ctx, logger, client, testID) if err != nil { return err } // Find root resource rootResourceID, err := findResource(ctx, client, restApiID, "/") if err != nil { return err } // Create resource testResourceID, err := createResource(ctx, logger, client, restApiID, rootResourceID, "/test") if err != nil { return err } // Create method err = createMethod(ctx, logger, client, restApiID, testResourceID, "GET") if err != nil { return err } // Create method response err = createMethodResponse(ctx, logger, client, restApiID, testResourceID, "GET", "200") if err != nil { return err } // Create integration err = createIntegration(ctx, logger, client, restApiID, testResourceID, "GET") if err != nil { return err } // Create API Key err = createAPIKey(ctx, logger, client, testID) if err != nil { return err } // Create Authorizer err = createAuthorizer(ctx, logger, client, *restApiID, testID) if err != nil { return err } // Create Deployment deploymentID, err := createDeployment(ctx, logger, client, *restApiID) if err != nil { return err } // Create Stage err = createStage(ctx, logger, client, *restApiID, *deploymentID) if err != nil { return err } // Create Model err = createModel(ctx, logger, client, *restApiID) if err != nil { return err } return nil } ================================================ FILE: aws-source/adapters/integration/apigateway/teardown.go ================================================ package apigateway import ( "context" "errors" "log/slog" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func teardown(ctx context.Context, logger *slog.Logger, client *apigateway.Client) error { restAPIID, err := findRestAPIsByTags(ctx, client) if err != nil { if nf := integration.NewNotFoundError(restAPISrc); errors.As(err, &nf) { logger.WarnContext(ctx, "Rest API not found") } else { return err } } else { err = deleteRestAPI(ctx, client, *restAPIID) if err != nil { return err } } keyName := integration.ResourceName(integration.APIGateway, apiKeySrc, integration.TestID()) apiKeyID, err := findAPIKeyByName(ctx, client, keyName) if err != nil { if nf := integration.NewNotFoundError(apiKeySrc); errors.As(err, &nf) { logger.WarnContext(ctx, "API Key not found", "name", keyName) return nil } else { return err } } else { err = deleteAPIKeyByName(ctx, client, apiKeyID) if err != nil { return err } } return nil } ================================================ FILE: aws-source/adapters/integration/apigateway/util.go ================================================ package apigateway import ( "github.com/overmindtech/cli/aws-source/adapters/integration" ) func resourceTags(resourceName, testID string, nameAdditionalAttr ...string) map[string]string { return map[string]string{ integration.TagTestKey: integration.TagTestValue, integration.TagTestTypeKey: integration.TestName(integration.APIGateway), integration.TagTestIDKey: testID, integration.TagResourceIDKey: integration.ResourceName(integration.APIGateway, resourceName, nameAdditionalAttr...), } } func hasTags(tags map[string]string, requiredTags map[string]string) bool { for k, v := range requiredTags { if tags[k] != v { return false } } return true } ================================================ FILE: aws-source/adapters/integration/ec2/create.go ================================================ package ec2 import ( "context" "errors" "fmt" "log/slog" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func createEC2Instance(ctx context.Context, logger *slog.Logger, client *ec2.Client, testID string) error { // check if a resource with the same tags already exists id, err := findActiveInstanceIDByTags(ctx, client) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating EC2 instance") } else { return err } } if id != nil { logger.InfoContext(ctx, "EC2 instance already exists") return nil } // Search for the latest AMI for Amazon Linux. We can't hardcode this as the // AMI for the same image differs per-region images, err := client.DescribeImages(ctx, &ec2.DescribeImagesInput{ Filters: []types.Filter{ { Name: aws.String("name"), Values: []string{ "amzn2-ami-hvm-2.0.*-x86_64-gp2", }, }, }, }) if err != nil { return fmt.Errorf("failed to describe images: %w", err) } if len(images.Images) == 0 { return errors.New("no images found") } // We need to select a subnet since we can't rely on having a default VPC subnets, err := client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{}) if err != nil { return fmt.Errorf("failed to describe subnets: %w", err) } if len(subnets.Subnets) == 0 { return errors.New("no subnets found") } input := &ec2.RunInstancesInput{ DryRun: aws.Bool(false), // `Subscribe Now` is selected on marketplace UI ImageId: images.Images[0].ImageId, SubnetId: subnets.Subnets[0].SubnetId, InstanceType: types.InstanceTypeT3Nano, MinCount: aws.Int32(1), MaxCount: aws.Int32(1), TagSpecifications: []types.TagSpecification{ { ResourceType: types.ResourceTypeInstance, // TODO: Create a convenience function to add shared tags to the resources Tags: resourceTags(instanceSrc, testID), }, }, } result, err := client.RunInstances(ctx, input) if err != nil { return err } waiter := ec2.NewInstanceRunningWaiter(client) err = waiter.Wait(ctx, &ec2.DescribeInstancesInput{ InstanceIds: []string{*result.Instances[0].InstanceId}, }, 5*time.Minute) if err != nil { return err } return nil } ================================================ FILE: aws-source/adapters/integration/ec2/delete.go ================================================ package ec2 import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" ) func deleteInstance(ctx context.Context, client *ec2.Client, instanceID string) error { input := &ec2.TerminateInstancesInput{ InstanceIds: []string{instanceID}, } _, err := client.TerminateInstances(ctx, input) return err } ================================================ FILE: aws-source/adapters/integration/ec2/find.go ================================================ package ec2 import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) // findActiveInstanceIDByTags finds an instance by tags // additionalAttr is a variadic parameter that allows to specify additional attributes to search for // it ignores terminated instances func findActiveInstanceIDByTags(ctx context.Context, client *ec2.Client, additionalAttr ...string) (*string, error) { result, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}) if err != nil { return nil, err } for _, reservation := range result.Reservations { for _, instance := range reservation.Instances { // ignore terminated or shutting down instances if instance.State.Name == types.InstanceStateNameTerminated || instance.State.Name == types.InstanceStateNameShuttingDown { // ignore terminated instances continue } if hasTags(instance.Tags, resourceTags(instanceSrc, integration.TestID(), additionalAttr...)) { return instance.InstanceId, nil } } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.EC2, instanceSrc, additionalAttr...)) } ================================================ FILE: aws-source/adapters/integration/ec2/instance_test.go ================================================ package ec2 import ( "context" "fmt" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func searchSync(adapter discovery.SearchStreamableAdapter, ctx context.Context, scope, query string, ignoreCache bool) ([]*sdp.Item, error) { stream := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctx, scope, query, ignoreCache, stream) errs := stream.GetErrors() if len(errs) > 0 { return nil, fmt.Errorf("failed to search: %v", errs) } return stream.GetItems(), nil } func listSync(adapter discovery.ListStreamableAdapter, ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, scope, ignoreCache, stream) errs := stream.GetErrors() if len(errs) > 0 { return nil, fmt.Errorf("failed to List: %v", errs) } return stream.GetItems(), nil } func EC2(t *testing.T) { ctx := context.Background() var err error testClient, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } accountID := testAWSConfig.AccountID t.Log("Running EC2 integration test") instanceAdapter := adapters.NewEC2InstanceAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = instanceAdapter.Validate() if err != nil { t.Fatalf("failed to validate EC2 instance adapter: %v", err) } scope := adapters.FormatScope(accountID, testAWSConfig.Region) // List instances sdpListInstances, err := listSync(instanceAdapter, context.Background(), scope, true) if err != nil { t.Fatalf("failed to list EC2 instances: %v", err) } if len(sdpListInstances) == 0 { t.Fatalf("no instances found") } uniqueAttribute := sdpListInstances[0].GetUniqueAttribute() instanceID, err := integration.GetUniqueAttributeValueByTags( uniqueAttribute, sdpListInstances, integration.ResourceTags(integration.EC2, instanceSrc), false, ) if err != nil { t.Fatalf("failed to get instance ID: %v", err) } // Get instance sdpInstance, err := instanceAdapter.Get(context.Background(), scope, instanceID, true) if err != nil { t.Fatalf("failed to get EC2 instance: %v", err) } instanceIDFromGet, err := integration.GetUniqueAttributeValueByTags(uniqueAttribute, []*sdp.Item{sdpInstance}, integration.ResourceTags(integration.EC2, instanceSrc), false) if err != nil { t.Fatalf("failed to get instance ID from get: %v", err) } if instanceIDFromGet != instanceID { t.Fatalf("expected instance ID %v, got %v", instanceID, instanceIDFromGet) } // Search instances instanceARN := fmt.Sprintf("arn:aws:ec2:%s:%s:instance/%s", testAWSConfig.Region, accountID, instanceID) sdpSearchInstances, err := searchSync(instanceAdapter, context.Background(), scope, instanceARN, true) if err != nil { t.Fatalf("failed to search EC2 instances: %v", err) } if len(sdpSearchInstances) == 0 { t.Fatalf("no instances found") } instanceIDFromSearch, err := integration.GetUniqueAttributeValueByTags(uniqueAttribute, sdpSearchInstances, integration.ResourceTags(integration.EC2, instanceSrc), false) if err != nil { t.Fatalf("failed to get instance ID from search: %v", err) } if instanceIDFromSearch != instanceID { t.Fatalf("expected instance ID %v, got %v", instanceID, instanceIDFromSearch) } } ================================================ FILE: aws-source/adapters/integration/ec2/main_test.go ================================================ package ec2 import ( "context" "fmt" "log/slog" "os" "testing" awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func TestMain(m *testing.M) { if integration.ShouldRunIntegrationTests() { fmt.Println("Running integration tests") os.Exit(m.Run()) } else { fmt.Println("Skipping integration tests, set RUN_INTEGRATION_TESTS=true to run them") os.Exit(0) } } func TestIntegrationEC2(t *testing.T) { t.Run("Setup", Setup) t.Run("EC2", EC2) t.Run("Teardown", Teardown) } func Setup(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } if err := setup(ctx, logger, testClient); err != nil { t.Fatalf("Failed to setup EC2 integration tests: %v", err) } } func Teardown(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } if err := teardown(ctx, logger, testClient); err != nil { t.Fatalf("Failed to teardown EC2 integration tests: %v", err) } } func ec2Client(ctx context.Context) (*awsec2.Client, error) { testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { return nil, fmt.Errorf("failed to get AWS settings: %w", err) } return awsec2.NewFromConfig(testAWSConfig.Config), nil } ================================================ FILE: aws-source/adapters/integration/ec2/setup.go ================================================ package ec2 import ( "context" "log/slog" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/aws-source/adapters/integration" ) const instanceSrc = "instance" func setup(ctx context.Context, logger *slog.Logger, client *ec2.Client) error { // Create EC2 instance return createEC2Instance(ctx, logger, client, integration.TestID()) } ================================================ FILE: aws-source/adapters/integration/ec2/teardown.go ================================================ package ec2 import ( "context" "errors" "log/slog" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func teardown(ctx context.Context, logger *slog.Logger, client *ec2.Client) error { instanceID, err := findActiveInstanceIDByTags(ctx, client) if err != nil { nf := integration.NewNotFoundError(instanceSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Instance not found") return nil } else { return err } } return deleteInstance(ctx, client, *instanceID) } ================================================ FILE: aws-source/adapters/integration/ec2/util.go ================================================ package ec2 import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func resourceTags(resourceName, testID string, nameAdditionalAttr ...string) []types.Tag { return []types.Tag{ { Key: aws.String(integration.TagTestKey), Value: aws.String(integration.TagTestValue), }, { Key: aws.String(integration.TagTestTypeKey), Value: aws.String(integration.TestName(integration.EC2)), }, { Key: aws.String(integration.TagTestIDKey), Value: aws.String(testID), }, { Key: aws.String(integration.TagResourceIDKey), Value: aws.String(integration.ResourceName(integration.EC2, resourceName, nameAdditionalAttr...)), }, } } func hasTags(tags []types.Tag, requiredTags []types.Tag) bool { rT := make(map[string]string) for _, t := range requiredTags { rT[*t.Key] = *t.Value } oT := make(map[string]string) for _, t := range tags { oT[*t.Key] = *t.Value } for k, v := range rT { if oT[k] != v { return false } } return true } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/client.go ================================================ package ec2transitgateway import ( "context" "fmt" awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func ec2Client(ctx context.Context) (*awsec2.Client, error) { testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { return nil, fmt.Errorf("failed to get AWS settings: %w", err) } return awsec2.NewFromConfig(testAWSConfig.Config), nil } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/main_test.go ================================================ // Package ec2transitgateway runs integration tests for EC2 Transit Gateway adapters // (transit gateway route table, route table association, route table propagation, // and route). Setup creates a transit gateway, VPC, subnet, TGW VPC attachment, // and a static route so each adapter returns items; Teardown deletes them in order. // // All created resources are tagged with name and test-id "integration-test" so they // are easy to spot in the console and so Teardown can discover them by tag. You can // run Setup once, re-run the test subtests as needed, then run Teardown once; or run // Teardown alone to clean up any stale resources from a previous run. // // Run integration tests only when RUN_INTEGRATION_TESTS=true. Example CLI commands: // // # Setup only (create resources) // RUN_INTEGRATION_TESTS=true go test ./aws-source/adapters/integration/ec2-transit-gateway -v -count=1 -run '^TestIntegrationEC2TransitGateway$/Setup$' // // # Teardown only (delete resources by tag; idempotent) // RUN_INTEGRATION_TESTS=true go test ./aws-source/adapters/integration/ec2-transit-gateway -v -count=1 -run '^TestIntegrationEC2TransitGateway$/Teardown$' // // # Run a single adapter test (e.g. after Setup, re-run as needed) // RUN_INTEGRATION_TESTS=true go test ./aws-source/adapters/integration/ec2-transit-gateway -v -count=1 -run '^TestIntegrationEC2TransitGateway$/TransitGatewayRouteTable$' // // # Run the full suite (Setup, all adapter tests, Teardown) // RUN_INTEGRATION_TESTS=true go test ./aws-source/adapters/integration/ec2-transit-gateway -v -count=1 -run '^TestIntegrationEC2TransitGateway$' // // Cost: a few cents per run. Setup creates a Transit Gateway, a VPC, a subnet, and // one TGW VPC attachment so that association, propagation, and route adapters // return items. AWS charges for the TGW and ~$0.05/hour per VPC attachment; with // teardown within minutes, cost remains low. See https://aws.amazon.com/transit-gateway/pricing/ // // Per-adapter cost: route table, association, propagation, and route tests do not // create additional resources; they list/get from the same TGW and its default // route table (one attachment, one static route), so they add no extra cost. // // To inspect the infrastructure created by the tests: // // - AWS CLI (replace [REGION] and [ROUTE_TABLE_ID] as needed): // // aws ec2 describe-transit-gateways [--region [REGION]] // aws ec2 describe-transit-gateway-route-tables [--region [REGION]] // aws ec2 get-transit-gateway-route-table-associations --transit-gateway-route-table-id [ROUTE_TABLE_ID] [--region [REGION]] // aws ec2 get-transit-gateway-route-table-propagations --transit-gateway-route-table-id [ROUTE_TABLE_ID] [--region [REGION]] // aws ec2 search-transit-gateway-routes --transit-gateway-route-table-id [ROUTE_TABLE_ID] --filters "Name=state,Values=active,blackhole" [--region [REGION]] // // - AWS Console: EC2 → Network & Security → Transit gateways → select a transit gateway // `https://eu-west-2.console.aws.amazon.com/vpcconsole/home?region=eu-west-2#TransitGateways:` other resources are displayed on the left hand pane. package ec2transitgateway import ( "context" "fmt" "os" "testing" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" ) func TestMain(m *testing.M) { if integration.ShouldRunIntegrationTests() { fmt.Println("Running EC2 Transit Gateway integration tests") os.Exit(m.Run()) } else { fmt.Println("Skipping EC2 Transit Gateway integration tests, set RUN_INTEGRATION_TESTS=true to run them") os.Exit(0) } } func TestIntegrationEC2TransitGateway(t *testing.T) { // Setup creates resources tagged integration-test; Teardown is idempotent and discovers by tag. t.Run("Setup", Setup) t.Run("TransitGatewayRouteTable", TransitGatewayRouteTable) t.Run("TransitGatewayRouteTableAssociation", TransitGatewayRouteTableAssociation) t.Run("TransitGatewayRouteTablePropagation", TransitGatewayRouteTablePropagation) t.Run("TransitGatewayRoute", TransitGatewayRoute) t.Run("Teardown", Teardown) } func listSync(adapter discovery.ListStreamableAdapter, ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, scope, ignoreCache, stream) if errs := stream.GetErrors(); len(errs) > 0 { return nil, fmt.Errorf("failed to list: %v", errs) } return stream.GetItems(), nil } func searchSync(adapter discovery.SearchStreamableAdapter, ctx context.Context, scope, query string, ignoreCache bool) ([]*sdp.Item, error) { stream := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctx, scope, query, ignoreCache, stream) if errs := stream.GetErrors(); len(errs) > 0 { return nil, fmt.Errorf("failed to search: %v", errs) } return stream.GetItems(), nil } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/setup.go ================================================ package ec2transitgateway import ( "context" "errors" "log/slog" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) // integrationTestName is the fixed tag value and name for all resources created by // this suite. Teardown discovers and deletes resources by tag test-id=, so // it can be run alone to clean stale resources from previous runs. const integrationTestName = "integration-test" // Package-level state set by Setup and used by tests and Teardown. var ( createdTransitGatewayID string createdRouteTableID string createdVpcID string createdSubnetID string createdAttachmentID string createdRouteDestination = "10.88.0.0/16" // static route we create (distinct from VPC CIDR) ) func Setup(t *testing.T) { ctx := context.Background() logger := slog.Default() client, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } if err := setup(ctx, logger, client); err != nil { t.Fatalf("Setup failed: %v", err) } } func setup(ctx context.Context, logger *slog.Logger, client *ec2.Client) error { out, err := client.CreateTransitGateway(ctx, &ec2.CreateTransitGatewayInput{ Description: new("Overmind " + integrationTestName), TagSpecifications: []types.TagSpecification{ { ResourceType: types.ResourceTypeTransitGateway, Tags: []types.Tag{ {Key: new(integration.TagTestKey), Value: new(integration.TagTestValue)}, {Key: new(integration.TagTestIDKey), Value: new(integrationTestName)}, {Key: new("Name"), Value: new(integrationTestName)}, }, }, }, }) if err != nil { return err } if out.TransitGateway == nil || out.TransitGateway.TransitGatewayId == nil { return errors.New("CreateTransitGateway returned nil transit gateway or id") } tgwID := *out.TransitGateway.TransitGatewayId createdTransitGatewayID = tgwID logger.InfoContext(ctx, "Created transit gateway, waiting for available", "id", tgwID) // Wait for transit gateway to become available (creates default route table). const waitTimeout = 5 * time.Minute deadline := time.Now().Add(waitTimeout) tgwAvailable := false for time.Now().Before(deadline) { desc, err := client.DescribeTransitGateways(ctx, &ec2.DescribeTransitGatewaysInput{ TransitGatewayIds: []string{tgwID}, }) if err != nil { return err } if len(desc.TransitGateways) == 0 { time.Sleep(10 * time.Second) continue } state := desc.TransitGateways[0].State if state == types.TransitGatewayStateAvailable { tgwAvailable = true break } if state == types.TransitGatewayStateDeleted || state == types.TransitGatewayStateDeleting { return errors.New("transit gateway entered deleted/deleting state") } time.Sleep(10 * time.Second) } if !tgwAvailable { return errors.New("timeout waiting for transit gateway to become available") } // Resolve default route table for this TGW (needed for attachment and static route). rtOut, err := client.DescribeTransitGatewayRouteTables(ctx, &ec2.DescribeTransitGatewayRouteTablesInput{ Filters: []types.Filter{ {Name: new("transit-gateway-id"), Values: []string{tgwID}}, }, }) if err != nil { return err } for i := range rtOut.TransitGatewayRouteTables { rt := &rtOut.TransitGatewayRouteTables[i] if rt.TransitGatewayRouteTableId != nil && rt.DefaultAssociationRouteTable != nil && *rt.DefaultAssociationRouteTable { createdRouteTableID = *rt.TransitGatewayRouteTableId break } } if createdRouteTableID == "" { return errors.New("could not find default route table for transit gateway") } // Create VPC and subnet so we can create a VPC attachment (association + propagation + route target). vpcOut, err := client.CreateVpc(ctx, &ec2.CreateVpcInput{ CidrBlock: new("10.99.0.0/16"), TagSpecifications: []types.TagSpecification{ { ResourceType: types.ResourceTypeVpc, Tags: []types.Tag{ {Key: new(integration.TagTestKey), Value: new(integration.TagTestValue)}, {Key: new(integration.TagTestIDKey), Value: new(integrationTestName)}, {Key: new("Name"), Value: new(integrationTestName)}, }, }, }, }) if err != nil { return err } if vpcOut.Vpc == nil || vpcOut.Vpc.VpcId == nil { return errors.New("CreateVpc returned nil vpc or id") } createdVpcID = *vpcOut.Vpc.VpcId logger.InfoContext(ctx, "Created VPC for TGW attachment", "id", createdVpcID) // Pick one AZ for the subnet. azOut, err := client.DescribeAvailabilityZones(ctx, &ec2.DescribeAvailabilityZonesInput{ Filters: []types.Filter{ {Name: new("state"), Values: []string{"available"}}, }, }) if err != nil || len(azOut.AvailabilityZones) == 0 { return errors.New("could not describe availability zones") } az := azOut.AvailabilityZones[0].ZoneName subOut, err := client.CreateSubnet(ctx, &ec2.CreateSubnetInput{ VpcId: &createdVpcID, CidrBlock: new("10.99.1.0/24"), AvailabilityZone: az, TagSpecifications: []types.TagSpecification{ { ResourceType: types.ResourceTypeSubnet, Tags: []types.Tag{ {Key: new(integration.TagTestKey), Value: new(integration.TagTestValue)}, {Key: new(integration.TagTestIDKey), Value: new(integrationTestName)}, {Key: new("Name"), Value: new(integrationTestName)}, }, }, }, }) if err != nil { return err } if subOut.Subnet == nil || subOut.Subnet.SubnetId == nil { return errors.New("CreateSubnet returned nil subnet or id") } createdSubnetID = *subOut.Subnet.SubnetId logger.InfoContext(ctx, "Created subnet for TGW attachment", "id", createdSubnetID) attachOut, err := client.CreateTransitGatewayVpcAttachment(ctx, &ec2.CreateTransitGatewayVpcAttachmentInput{ TransitGatewayId: &tgwID, VpcId: &createdVpcID, SubnetIds: []string{createdSubnetID}, TagSpecifications: []types.TagSpecification{ { ResourceType: types.ResourceTypeTransitGatewayAttachment, Tags: []types.Tag{ {Key: new(integration.TagTestKey), Value: new(integration.TagTestValue)}, {Key: new(integration.TagTestIDKey), Value: new(integrationTestName)}, {Key: new("Name"), Value: new(integrationTestName)}, }, }, }, }) if err != nil { return err } if attachOut.TransitGatewayVpcAttachment == nil || attachOut.TransitGatewayVpcAttachment.TransitGatewayAttachmentId == nil { return errors.New("CreateTransitGatewayVpcAttachment returned nil attachment or id") } createdAttachmentID = *attachOut.TransitGatewayVpcAttachment.TransitGatewayAttachmentId logger.InfoContext(ctx, "Created TGW VPC attachment, waiting for available", "id", createdAttachmentID) // Wait for attachment to become available so we can create a route and so associations/propagations appear. attachDeadline := time.Now().Add(waitTimeout) attachmentAvailable := false for time.Now().Before(attachDeadline) { desc, err := client.DescribeTransitGatewayVpcAttachments(ctx, &ec2.DescribeTransitGatewayVpcAttachmentsInput{ TransitGatewayAttachmentIds: []string{createdAttachmentID}, }) if err != nil { return err } if len(desc.TransitGatewayVpcAttachments) == 0 { time.Sleep(10 * time.Second) continue } state := desc.TransitGatewayVpcAttachments[0].State if state == types.TransitGatewayAttachmentStateAvailable { attachmentAvailable = true break } if state == types.TransitGatewayAttachmentStateDeleted || state == types.TransitGatewayAttachmentStateDeleting { return errors.New("transit gateway VPC attachment entered deleted/deleting state") } time.Sleep(10 * time.Second) } if !attachmentAvailable { return errors.New("timeout waiting for transit gateway VPC attachment to become available") } // Add a static route so the route adapter returns at least one item. _, err = client.CreateTransitGatewayRoute(ctx, &ec2.CreateTransitGatewayRouteInput{ TransitGatewayRouteTableId: &createdRouteTableID, DestinationCidrBlock: &createdRouteDestination, TransitGatewayAttachmentId: &createdAttachmentID, }) if err != nil { return err } logger.InfoContext(ctx, "Created static TGW route", "destination", createdRouteDestination) return nil } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/teardown.go ================================================ package ec2transitgateway import ( "context" "errors" "log/slog" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) // integrationTestTagFilters returns filters to discover resources created by this suite. func integrationTestTagFilters() []types.Filter { return []types.Filter{ {Name: new("tag:" + integration.TagTestKey), Values: []string{integration.TagTestValue}}, {Name: new("tag:" + integration.TagTestIDKey), Values: []string{integrationTestName}}, } } // getIntegrationTestTransitGatewayID returns the transit gateway ID for the integration-test // resources. If Setup ran in this process, it uses the package-level ID; otherwise it // discovers the TGW by tag so tests work when run after a separate Setup (e.g. a day later). // Returns an error if no tagged TGW is found (e.g. after Teardown). func getIntegrationTestTransitGatewayID(ctx context.Context, client *ec2.Client) (string, error) { if createdTransitGatewayID != "" { return createdTransitGatewayID, nil } tgwOut, err := client.DescribeTransitGateways(ctx, &ec2.DescribeTransitGatewaysInput{ Filters: integrationTestTagFilters(), }) if err != nil { return "", err } for _, tgw := range tgwOut.TransitGateways { if tgw.TransitGatewayId != nil && tgw.State != types.TransitGatewayStateDeleted && tgw.State != types.TransitGatewayStateDeleting { return *tgw.TransitGatewayId, nil } } return "", errors.New("no transit gateway found with integration-test tag (run Setup first or ensure Teardown has not deleted resources)") } func Teardown(t *testing.T) { ctx := context.Background() logger := slog.Default() client, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } if err := teardown(ctx, logger, client); err != nil { t.Fatalf("Teardown failed: %v", err) } } func teardown(ctx context.Context, logger *slog.Logger, client *ec2.Client) error { tagFilters := integrationTestTagFilters() // 1. Discover transit gateways by tag. tgwOut, err := client.DescribeTransitGateways(ctx, &ec2.DescribeTransitGatewaysInput{ Filters: tagFilters, }) if err != nil { return err } if len(tgwOut.TransitGateways) == 0 { logger.InfoContext(ctx, "No transit gateways found with integration-test tag") clearPackageState() return nil } // 2. For each TGW: delete static route, then VPC attachments, and wait for attachments to be deleted. for _, tgw := range tgwOut.TransitGateways { if tgw.TransitGatewayId == nil || tgw.State == types.TransitGatewayStateDeleted || tgw.State == types.TransitGatewayStateDeleting { continue } tgwID := *tgw.TransitGatewayId // Resolve default route table and delete our static route. rtOut, err := client.DescribeTransitGatewayRouteTables(ctx, &ec2.DescribeTransitGatewayRouteTablesInput{ Filters: []types.Filter{{Name: new("transit-gateway-id"), Values: []string{tgwID}}}, }) if err != nil { return err } var defaultRouteTableID string for i := range rtOut.TransitGatewayRouteTables { rt := &rtOut.TransitGatewayRouteTables[i] if rt.TransitGatewayRouteTableId != nil && rt.DefaultAssociationRouteTable != nil && *rt.DefaultAssociationRouteTable { defaultRouteTableID = *rt.TransitGatewayRouteTableId break } } if defaultRouteTableID != "" { _, _ = client.DeleteTransitGatewayRoute(ctx, &ec2.DeleteTransitGatewayRouteInput{ TransitGatewayRouteTableId: &defaultRouteTableID, DestinationCidrBlock: &createdRouteDestination, }) } // List VPC attachments for this TGW and delete each. attachOut, err := client.DescribeTransitGatewayVpcAttachments(ctx, &ec2.DescribeTransitGatewayVpcAttachmentsInput{ Filters: []types.Filter{{Name: new("transit-gateway-id"), Values: []string{tgwID}}}, }) if err != nil { return err } for _, att := range attachOut.TransitGatewayVpcAttachments { if att.TransitGatewayAttachmentId == nil || att.State == types.TransitGatewayAttachmentStateDeleted || att.State == types.TransitGatewayAttachmentStateDeleting { continue } attID := *att.TransitGatewayAttachmentId _, _ = client.DeleteTransitGatewayVpcAttachment(ctx, &ec2.DeleteTransitGatewayVpcAttachmentInput{ TransitGatewayAttachmentId: &attID, }) logger.InfoContext(ctx, "Deleted TGW VPC attachment, waiting for deleted", "id", attID) deadline := time.Now().Add(5 * time.Minute) for time.Now().Before(deadline) { desc, err := client.DescribeTransitGatewayVpcAttachments(ctx, &ec2.DescribeTransitGatewayVpcAttachmentsInput{ TransitGatewayAttachmentIds: []string{attID}, }) if err != nil || len(desc.TransitGatewayVpcAttachments) == 0 { break } if desc.TransitGatewayVpcAttachments[0].State == types.TransitGatewayAttachmentStateDeleted { break } time.Sleep(10 * time.Second) } } } // 3. Delete subnets by tag. subOut, err := client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{Filters: tagFilters}) if err != nil { return err } for _, sub := range subOut.Subnets { if sub.SubnetId != nil { _, _ = client.DeleteSubnet(ctx, &ec2.DeleteSubnetInput{SubnetId: sub.SubnetId}) } } // 4. Delete VPCs by tag. vpcOut, err := client.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{Filters: tagFilters}) if err != nil { return err } for _, vpc := range vpcOut.Vpcs { if vpc.VpcId != nil { _, _ = client.DeleteVpc(ctx, &ec2.DeleteVpcInput{VpcId: vpc.VpcId}) } } // 5. Delete transit gateways. for _, tgw := range tgwOut.TransitGateways { if tgw.TransitGatewayId == nil || tgw.State == types.TransitGatewayStateDeleted || tgw.State == types.TransitGatewayStateDeleting { continue } tgwID := *tgw.TransitGatewayId _, err := client.DeleteTransitGateway(ctx, &ec2.DeleteTransitGatewayInput{TransitGatewayId: &tgwID}) if err != nil { return err } logger.InfoContext(ctx, "Deleted transit gateway", "id", tgwID) } clearPackageState() return nil } func clearPackageState() { createdTransitGatewayID = "" createdRouteTableID = "" createdVpcID = "" createdSubnetID = "" createdAttachmentID = "" } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/transit_gateway_route_table_association_test.go ================================================ package ec2transitgateway import ( "context" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/sdpcache" ) // TransitGatewayRouteTableAssociation runs the integration test for the route table association adapter. // Setup creates a TGW VPC attachment, so the default route table has at least one association. func TransitGatewayRouteTableAssociation(t *testing.T) { ctx := context.Background() testClient, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } scope := adapters.FormatScope(testAWSConfig.AccountID, testAWSConfig.Region) adapter := adapters.NewEC2TransitGatewayRouteTableAssociationAdapter(testClient, testAWSConfig.AccountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) if err := adapter.Validate(); err != nil { t.Fatalf("failed to validate adapter: %v", err) } items, err := adapter.List(ctx, scope, true) if err != nil { t.Fatalf("failed to list transit gateway route table associations: %v", err) } if len(items) == 0 { t.Fatalf("expected at least one association (Setup creates a TGW VPC attachment); got 0") } query := items[0].UniqueAttributeValue() got, err := adapter.Get(ctx, scope, query, true) if err != nil { t.Fatalf("failed to get association %s: %v", query, err) } if got.UniqueAttributeValue() != query { t.Fatalf("expected %s, got %s", query, got.UniqueAttributeValue()) } // Search by route table ID (used by route table → association link). if createdRouteTableID != "" { searchItems, err := adapter.Search(ctx, scope, createdRouteTableID, true) if err != nil { t.Fatalf("failed to search associations by route table ID %s: %v", createdRouteTableID, err) } if len(searchItems) == 0 { t.Fatalf("expected at least one association for route table %s (Setup creates one); got 0", createdRouteTableID) } } } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/transit_gateway_route_table_propagation_test.go ================================================ package ec2transitgateway import ( "context" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/sdpcache" ) // TransitGatewayRouteTablePropagation runs the integration test for the route table propagation adapter. // Setup creates a TGW VPC attachment (propagated to the default route table), so we get at least one propagation. func TransitGatewayRouteTablePropagation(t *testing.T) { ctx := context.Background() testClient, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } scope := adapters.FormatScope(testAWSConfig.AccountID, testAWSConfig.Region) adapter := adapters.NewEC2TransitGatewayRouteTablePropagationAdapter(testClient, testAWSConfig.AccountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) if err := adapter.Validate(); err != nil { t.Fatalf("failed to validate adapter: %v", err) } items, err := adapter.List(ctx, scope, true) if err != nil { t.Fatalf("failed to list transit gateway route table propagations: %v", err) } if len(items) == 0 { t.Fatalf("expected at least one propagation (Setup creates a TGW VPC attachment); got 0") } query := items[0].UniqueAttributeValue() got, err := adapter.Get(ctx, scope, query, true) if err != nil { t.Fatalf("failed to get propagation %s: %v", query, err) } if got.UniqueAttributeValue() != query { t.Fatalf("expected %s, got %s", query, got.UniqueAttributeValue()) } // Search by route table ID (used by route table → propagation link). if createdRouteTableID != "" { searchItems, err := adapter.Search(ctx, scope, createdRouteTableID, true) if err != nil { t.Fatalf("failed to search propagations by route table ID %s: %v", createdRouteTableID, err) } if len(searchItems) == 0 { t.Fatalf("expected at least one propagation for route table %s (Setup creates one); got 0", createdRouteTableID) } } } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/transit_gateway_route_table_test.go ================================================ package ec2transitgateway import ( "context" "fmt" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/sdpcache" ) // TransitGatewayRouteTable runs the integration test for the transit gateway route table adapter. // // AWS CLI – list route tables (same data this test lists/gets/searches): // // aws ec2 describe-transit-gateway-route-tables [--region REGION] // // AWS Console – Transit Gateway route tables: // // https://[REGION].console.aws.amazon.com/ec2/home?region=[REGION]#TransitGatewayRouteTables: // // Overmind – In the app, open your AWS source and search for type ec2-transit-gateway-route-table // or navigate to the resource type in the source. func TransitGatewayRouteTable(t *testing.T) { ctx := context.Background() testClient, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } accountID := testAWSConfig.AccountID scope := adapters.FormatScope(accountID, testAWSConfig.Region) adapter := adapters.NewEC2TransitGatewayRouteTableAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) if err := adapter.Validate(); err != nil { t.Fatalf("failed to validate transit gateway route table adapter: %v", err) } items, err := listSync(adapter, ctx, scope, true) if err != nil { t.Fatalf("failed to list transit gateway route tables: %v", err) } tgwID, err := getIntegrationTestTransitGatewayID(ctx, testClient) if err != nil { t.Fatalf("failed to get integration-test transit gateway ID: %v", err) } // Find the route table for the transit gateway created in Setup (or discovered by tag). var routeTableID string for _, item := range items { tgwIDVal, _ := item.GetAttributes().Get("TransitGatewayId") if tgwIDVal != nil { if id, ok := tgwIDVal.(string); ok && id == tgwID { routeTableID = item.UniqueAttributeValue() break } } } if routeTableID == "" { t.Fatalf("no route table found for transit gateway %s (created in Setup)", tgwID) } got, err := adapter.Get(ctx, scope, routeTableID, true) if err != nil { t.Fatalf("failed to get transit gateway route table %s: %v", routeTableID, err) } if got.UniqueAttributeValue() != routeTableID { t.Fatalf("expected route table ID %s from Get, got %s", routeTableID, got.UniqueAttributeValue()) } arn := fmt.Sprintf("arn:aws:ec2:%s:%s:transit-gateway-route-table/%s", testAWSConfig.Region, accountID, routeTableID) searchItems, err := searchSync(adapter, ctx, scope, arn, true) if err != nil { t.Fatalf("failed to search transit gateway route table by ARN: %v", err) } if len(searchItems) == 0 { t.Fatalf("search by ARN returned no items") } if searchItems[0].UniqueAttributeValue() != routeTableID { t.Fatalf("expected route table ID %s from Search, got %s", routeTableID, searchItems[0].UniqueAttributeValue()) } // Route table links to associations, propagations, and routes (Search by route table ID). links := got.GetLinkedItemQueries() if len(links) < 4 { t.Fatalf("expected at least 4 linked item queries (ec2-transit-gateway + 3 Search links); got %d", len(links)) } linkTypes := make(map[string]bool) for _, l := range links { if l.GetQuery() != nil { linkTypes[l.GetQuery().GetType()] = true } } for _, want := range []string{"ec2-transit-gateway", "ec2-transit-gateway-route-table-association", "ec2-transit-gateway-route-table-propagation", "ec2-transit-gateway-route"} { if !linkTypes[want] { t.Errorf("expected route table to link to %s", want) } } } ================================================ FILE: aws-source/adapters/integration/ec2-transit-gateway/transit_gateway_route_test.go ================================================ package ec2transitgateway import ( "context" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/sdpcache" ) // TransitGatewayRoute runs the integration test for the transit gateway route adapter. // Setup creates a static route in the default route table, so we get at least one route. func TransitGatewayRoute(t *testing.T) { ctx := context.Background() testClient, err := ec2Client(ctx) if err != nil { t.Fatalf("Failed to create EC2 client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } scope := adapters.FormatScope(testAWSConfig.AccountID, testAWSConfig.Region) adapter := adapters.NewEC2TransitGatewayRouteAdapter(testClient, testAWSConfig.AccountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) if err := adapter.Validate(); err != nil { t.Fatalf("failed to validate adapter: %v", err) } items, err := adapter.List(ctx, scope, true) if err != nil { t.Fatalf("failed to list transit gateway routes: %v", err) } if len(items) == 0 { t.Fatalf("expected at least one route (Setup creates a static TGW route); got 0") } query := items[0].UniqueAttributeValue() got, err := adapter.Get(ctx, scope, query, true) if err != nil { t.Fatalf("failed to get route %s: %v", query, err) } if got.UniqueAttributeValue() != query { t.Fatalf("expected %s, got %s", query, got.UniqueAttributeValue()) } // Search by route table ID (used by route table → route link). if createdRouteTableID != "" { searchItems, err := adapter.Search(ctx, scope, createdRouteTableID, true) if err != nil { t.Fatalf("failed to search routes by route table ID %s: %v", createdRouteTableID, err) } if len(searchItems) == 0 { t.Fatalf("expected at least one route for route table %s (Setup creates a static route); got 0", createdRouteTableID) } } } ================================================ FILE: aws-source/adapters/integration/errors.go ================================================ package integration import "fmt" type NotFoundError struct { ResourceName string } func (e NotFoundError) Error() string { return fmt.Sprintf("Resource not found: %s", e.ResourceName) } func NewNotFoundError(resourceName string) NotFoundError { return NotFoundError{ResourceName: resourceName} } ================================================ FILE: aws-source/adapters/integration/kms/create.go ================================================ package kms import ( "context" "errors" "fmt" "log/slog" "slices" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func createKey(ctx context.Context, logger *slog.Logger, client *kms.Client, testID string) (*string, error) { // check if a resource with the same tags already exists id, err := findActiveKeyIDByTags(ctx, client) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating KMS key") } else { return nil, err } } if id != nil { logger.InfoContext(ctx, "KMS key already exists") return id, nil } response, err := client.CreateKey(ctx, &kms.CreateKeyInput{ Tags: resourceTags(keySrc, testID), }) if err != nil { return nil, err } return response.KeyMetadata.KeyId, nil } func createAlias(ctx context.Context, logger *slog.Logger, client *kms.Client, keyID string) error { aliasName := genAliasName() aliasNames, err := findAliasesByTargetKey(ctx, client, keyID) if err != nil { if nf := integration.NewNotFoundError(aliasSrc); errors.As(err, &nf) { logger.WarnContext(ctx, "Creating alias for the key", "keyID", keyID) } else { return err } } if slices.Contains(aliasNames, aliasName) { logger.InfoContext(ctx, "KMS alias already exists", "alias", aliasName, "keyID", keyID) return nil } _, err = client.CreateAlias(ctx, &kms.CreateAliasInput{ AliasName: &aliasName, TargetKeyId: &keyID, }) if err != nil { return err } return nil } func genAliasName() string { return fmt.Sprintf("alias/%s", integration.TestID()) } func createGrant(ctx context.Context, logger *slog.Logger, client *kms.Client, keyID, principal string) error { grantID, err := findGrant(ctx, client, keyID, principal) if err != nil { if nf := integration.NewNotFoundError(grantSrc); errors.As(err, &nf) { logger.WarnContext(ctx, "Creating grant for the key", "keyID", keyID, "principal", principal) } else { return err } } if grantID != nil { logger.InfoContext(ctx, "KMS grant already exists", "grantID", *grantID, "keyID", keyID, "principal", principal) return nil } _, err = client.CreateGrant(ctx, &kms.CreateGrantInput{ GranteePrincipal: &principal, KeyId: &keyID, Operations: []types.GrantOperation{types.GrantOperationDecrypt}, }) if err != nil { return err } return nil } func putKeyPolicy(ctx context.Context, logger *slog.Logger, client *kms.Client, keyID, principal string) error { keyPolicy, err := findKeyPolicy(ctx, client, keyID) if err != nil { if nf := integration.NewNotFoundError(keyPolicySrc); errors.As(err, &nf) { logger.WarnContext(ctx, "Creating key policy for the key", "keyID", keyID) } else { return err } } if keyPolicy != nil { logger.InfoContext(ctx, "KMS key policy already exists", "keyID", keyID) return nil } policy := fmt.Sprintf( `{ "Sid": "Allow access for Key Administrators", "Effect": "Allow", "Principal": {"AWS":"%s"}, "Action": [ "kms:Create*", "kms:Describe*", "kms:Enable*", "kms:List*", "kms:Put*", "kms:Update*", "kms:Revoke*", "kms:Disable*", "kms:Get*", "kms:Delete*", "kms:TagResource", "kms:UntagResource", "kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion", "kms:RotateKeyOnDemand" ], "Resource": "*" }`, principal) _, err = client.PutKeyPolicy(ctx, &kms.PutKeyPolicyInput{ KeyId: &keyID, Policy: &policy, }) if err != nil { return err } return nil } ================================================ FILE: aws-source/adapters/integration/kms/delete.go ================================================ package kms import ( "context" "github.com/aws/aws-sdk-go-v2/service/kms" ) func deleteKey(ctx context.Context, client *kms.Client, keyID string) error { seven := int32(7) _, err := client.ScheduleKeyDeletion(ctx, &kms.ScheduleKeyDeletionInput{ KeyId: &keyID, PendingWindowInDays: &seven, // it can be minimum 7 days }) return err } func deleteAlias(ctx context.Context, client *kms.Client, aliasName string) error { _, err := client.DeleteAlias(ctx, &kms.DeleteAliasInput{ AliasName: &aliasName, }) return err } func deleteGrant(ctx context.Context, client *kms.Client, keyID, grantID string) error { _, err := client.RevokeGrant(ctx, &kms.RevokeGrantInput{ KeyId: &keyID, GrantId: &grantID, }) return err } ================================================ FILE: aws-source/adapters/integration/kms/find.go ================================================ package kms import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/aws/smithy-go" "github.com/overmindtech/cli/aws-source/adapters/integration" ) // findActiveKeyIDByTags finds a key by tags // additionalAttr is a variadic parameter that allows to specify additional attributes to search for func findActiveKeyIDByTags(ctx context.Context, client *kms.Client, additionalAttr ...string) (*string, error) { result, err := client.ListKeys(ctx, &kms.ListKeysInput{}) if err != nil { return nil, err } for _, keyListEntry := range result.Keys { key, err := client.DescribeKey(ctx, &kms.DescribeKeyInput{ KeyId: keyListEntry.KeyId, }) if err != nil { return nil, err } if key.KeyMetadata.KeyState != types.KeyStateEnabled { continue } tags, err := client.ListResourceTags(ctx, &kms.ListResourceTagsInput{ KeyId: keyListEntry.KeyId, }) // There are some keys that even admins can't list the tags of. Not sure // why but they seem to exist, we will ignore permissions errors here. var awsErr *smithy.GenericAPIError if errors.As(err, &awsErr) && awsErr.ErrorCode() == "AccessDeniedException" { continue } if err != nil { return nil, err } if hasTags(tags.Tags, resourceTags(keySrc, integration.TestID(), additionalAttr...)) { return keyListEntry.KeyId, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.KMS, keySrc, additionalAttr...)) } func findAliasesByTargetKey(ctx context.Context, client *kms.Client, keyID string) ([]string, error) { aliases, err := client.ListAliases(ctx, &kms.ListAliasesInput{ KeyId: &keyID, }) if err != nil { return nil, err } var aliasNames []string for _, alias := range aliases.Aliases { aliasNames = append(aliasNames, *alias.AliasName) } if len(aliasNames) == 0 { return nil, integration.NewNotFoundError(integration.ResourceName(integration.KMS, aliasSrc)) } return aliasNames, nil } func findGrant(ctx context.Context, client *kms.Client, keyID, principal string) (*string, error) { // Get grants for the key grants, err := client.ListGrants(ctx, &kms.ListGrantsInput{ KeyId: &keyID, }) if err != nil { return nil, err } for _, grant := range grants.Grants { if *grant.GranteePrincipal == principal { return grant.GrantId, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.KMS, grantSrc)) } func findKeyPolicy(ctx context.Context, client *kms.Client, keyID string) (*string, error) { policy, err := client.GetKeyPolicy(ctx, &kms.GetKeyPolicyInput{ KeyId: &keyID, }) if err != nil { return nil, err } if policy.Policy == nil { return nil, integration.NewNotFoundError(integration.ResourceName(integration.KMS, keyPolicySrc)) } return policy.Policy, nil } ================================================ FILE: aws-source/adapters/integration/kms/kms_test.go ================================================ package kms import ( "context" "fmt" "strings" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func searchSync(adapter discovery.SearchStreamableAdapter, ctx context.Context, scope, query string, ignoreCache bool) ([]*sdp.Item, error) { stream := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctx, scope, query, ignoreCache, stream) errs := stream.GetErrors() if len(errs) > 0 { return nil, fmt.Errorf("failed to search: %v", errs) } return stream.GetItems(), nil } func listSync(adapter discovery.ListStreamableAdapter, ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, scope, ignoreCache, stream) errs := stream.GetErrors() if len(errs) > 0 { return nil, fmt.Errorf("failed to List: %v", errs) } return stream.GetItems(), nil } func KMS(t *testing.T) { ctx := context.Background() var err error testClient, err := kmsClient(ctx) if err != nil { t.Fatalf("Failed to create KMS client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } accountID := testAWSConfig.AccountID t.Log("Running KMS integration test") keySource := adapters.NewKMSKeyAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) aliasSource := adapters.NewKMSAliasAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) grantSource := adapters.NewKMSGrantAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) keyPolicySource := adapters.NewKMSKeyPolicyAdapter(testClient, accountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) err = keySource.Validate() if err != nil { t.Fatalf("failed to validate KMS key adapter: %v", err) } err = aliasSource.Validate() if err != nil { t.Fatalf("failed to validate KMS alias adapter: %v", err) } err = grantSource.Validate() if err != nil { t.Fatalf("failed to validate KMS grant adapter: %v", err) } err = keyPolicySource.Validate() if err != nil { t.Fatalf("failed to validate KMS key policy adapter: %v", err) } scope := adapters.FormatScope(accountID, testAWSConfig.Region) // List keys sdpListKeys, err := listSync(keySource, context.Background(), scope, true) if err != nil { t.Fatalf("failed to list KMS keys: %v", err) } if len(sdpListKeys) == 0 { t.Fatalf("no keys found") } keyUniqueAttribute := sdpListKeys[0].GetUniqueAttribute() keyID, err := integration.GetUniqueAttributeValueByTags(keyUniqueAttribute, sdpListKeys, integration.ResourceTags(integration.KMS, keySrc), false) if err != nil { t.Fatalf("failed to get key ID: %v", err) } // Get key sdpKey, err := keySource.Get(context.Background(), scope, keyID, true) if err != nil { t.Fatalf("failed to get KMS key: %v", err) } keyIDFromGet, err := integration.GetUniqueAttributeValueByTags(keyUniqueAttribute, []*sdp.Item{sdpKey}, integration.ResourceTags(integration.KMS, keySrc), false) if err != nil { t.Fatalf("failed to get key ID from get: %v", err) } if keyIDFromGet != keyID { t.Fatalf("expected key ID %v, got %v", keyID, keyIDFromGet) } // Search keys keyARN := fmt.Sprintf("arn:aws:kms:%s:%s:key/%s", testAWSConfig.Region, accountID, keyID) sdpSearchKeys, err := searchSync(keySource, context.Background(), scope, keyARN, true) if err != nil { t.Fatalf("failed to search KMS keys: %v", err) } if len(sdpSearchKeys) == 0 { t.Fatalf("no keys found") } keyIDFromSearch, err := integration.GetUniqueAttributeValueByTags(keyUniqueAttribute, sdpSearchKeys, integration.ResourceTags(integration.KMS, keySrc), false) if err != nil { t.Fatalf("failed to get key ID from search: %v", err) } if keyIDFromSearch != keyID { t.Fatalf("expected key ID %v, got %v", keyID, keyIDFromSearch) } // List aliases sdpListAliases, err := listSync(aliasSource, context.Background(), scope, true) if err != nil { t.Fatalf("failed to list KMS aliases: %v", err) } if len(sdpListAliases) == 0 { t.Fatalf("no aliases found") } // Get the alias for this key var aliasUniqueAttributeValue any for _, alias := range sdpListAliases { // Check if the alias is for the key for _, query := range alias.GetLinkedItemQueries() { if query.GetQuery().GetQuery() == keyID { aliasUniqueAttributeValue, err = alias.GetAttributes().Get(alias.GetUniqueAttribute()) if err != nil { t.Fatalf("failed to get alias unique attribute values: %v", err) } break } } } if aliasUniqueAttributeValue == nil { t.Fatalf("no alias found for key %v", keyID) } sdpAlias, err := aliasSource.Get(context.Background(), scope, aliasUniqueAttributeValue.(string), true) if err != nil { t.Fatalf("failed to get KMS alias: %v", err) } aliasName, err := sdpAlias.GetAttributes().Get("aliasName") if err != nil { t.Fatalf("failed to get alias name: %v", err) } if aliasName != genAliasName() { t.Fatalf("expected alias %v, got %v", genAliasName(), aliasName) } // Search aliases sdpSearchAliases, err := searchSync(aliasSource, context.Background(), scope, keyID, true) if err != nil { t.Fatalf("failed to search KMS aliases: %v", err) } if len(sdpSearchAliases) == 0 { t.Fatalf("no aliases found") } searchAliasName, err := sdpSearchAliases[0].GetAttributes().Get("aliasName") if err != nil { t.Fatalf("failed to get alias name: %v", err) } if searchAliasName != genAliasName() { t.Fatalf("expected alias %v, got %v", genAliasName(), searchAliasName) } // List grants is not supported sdpListGrants, err := listSync(grantSource, context.Background(), scope, true) if err == nil { t.Fatal("expected error but got nil") } if len(sdpListGrants) != 0 { t.Fatalf("expected 0 grants, got %v", len(sdpListGrants)) } // Search grants sdpSearchGrants, err := searchSync(grantSource, context.Background(), scope, keyID, true) if err != nil { t.Fatalf("failed to search KMS grants: %v", err) } if len(sdpSearchGrants) == 0 { t.Fatal("no grants found") } searchGrantID, err := sdpSearchGrants[0].GetAttributes().Get("grantId") if err != nil { t.Fatalf("failed to get grant ID: %v", err) } // Get grant grantUniqueAttribute := sdpSearchGrants[0].GetUniqueAttribute() grantUniqueAttributeValue, err := sdpSearchGrants[0].GetAttributes().Get(grantUniqueAttribute) if err != nil { t.Fatalf("failed to get grant unique attribute values: %v", err) } sdpGrant, err := grantSource.Get(context.Background(), scope, grantUniqueAttributeValue.(string), true) if err != nil { t.Fatalf("failed to get KMS grant: %v", err) } grantID, err := sdpGrant.GetAttributes().Get("grantId") if err != nil { t.Fatalf("failed to get grant ID: %v", err) } expectedGrantID := strings.Split(grantUniqueAttributeValue.(string), "/")[1] if grantID != expectedGrantID { t.Fatalf("expected grant ID %v, got %v", expectedGrantID, grantID) } if searchGrantID != expectedGrantID { t.Fatalf("expected grant ID %v, got %v", expectedGrantID, searchGrantID) } // Search key policy by key ID sdpSearchKeyPolicies, err := searchSync(keyPolicySource, context.Background(), scope, keyID, true) if err != nil { t.Fatalf("failed to search KMS key policies: %v", err) } if len(sdpSearchKeyPolicies) == 0 { t.Fatalf("no key policies found") } searchKeyPolicyKeyID, err := sdpSearchKeyPolicies[0].GetAttributes().Get("keyId") if err != nil { t.Fatalf("failed to get key ID: %v", err) } if searchKeyPolicyKeyID != keyID { t.Fatalf("expected key ID %v, got %v", keyID, searchKeyPolicyKeyID) } // Get key policy keyPolicyUniqueAttribute := sdpSearchKeyPolicies[0].GetUniqueAttribute() keyPolicyUniqueAttributeValue, err := sdpSearchKeyPolicies[0].GetAttributes().Get(keyPolicyUniqueAttribute) if err != nil { t.Fatalf("failed to get key policy unique attribute values: %v", err) } sdpKeyPolicy, err := keyPolicySource.Get(context.Background(), scope, keyPolicyUniqueAttributeValue.(string), true) if err != nil { t.Fatalf("failed to get KMS key policy: %v", err) } keyPolicyKeyID, err := sdpKeyPolicy.GetAttributes().Get("keyId") if err != nil { t.Fatalf("failed to get key ID: %v", err) } if keyPolicyKeyID != keyID { t.Fatalf("expected key ID %v, got %v", keyID, keyPolicyKeyID) } } ================================================ FILE: aws-source/adapters/integration/kms/main_test.go ================================================ package kms import ( "context" "fmt" "log/slog" "os" "testing" awskms "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func TestMain(m *testing.M) { if integration.ShouldRunIntegrationTests() { fmt.Println("Running integration tests") os.Exit(m.Run()) } else { fmt.Println("Skipping integration tests, set RUN_INTEGRATION_TESTS=true to run them") os.Exit(0) } } func TestIntegrationKMS(t *testing.T) { t.Run("Setup", Setup) t.Run("KMS", KMS) t.Run("Teardown", Teardown) } func Setup(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := kmsClient(ctx) if err != nil { t.Fatalf("Failed to create KMS client: %v", err) } if err := setup(ctx, logger, testClient); err != nil { t.Fatalf("Failed to setup KMS integration tests: %v", err) } } func Teardown(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := kmsClient(ctx) if err != nil { t.Fatalf("Failed to create KMS client: %v", err) } if err := teardown(ctx, logger, testClient); err != nil { t.Fatalf("Failed to teardown KMS integration tests: %v", err) } } func kmsClient(ctx context.Context) (*awskms.Client, error) { testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { return nil, fmt.Errorf("failed to get AWS settings: %w", err) } return awskms.NewFromConfig(testAWSConfig.Config), nil } ================================================ FILE: aws-source/adapters/integration/kms/setup.go ================================================ package kms import ( "context" "fmt" "log/slog" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/aws-source/adapters/integration" ) const ( keySrc = "key" aliasSrc = "alias" grantSrc = "grant" keyPolicySrc = "key-policy" ) func setup(ctx context.Context, logger *slog.Logger, client *kms.Client) error { testID := integration.TestID() // Create KMS key keyID, err := createKey(ctx, logger, client, testID) if err != nil { return err } // Create KMS alias err = createAlias(ctx, logger, client, *keyID) if err != nil { return err } principal, err := integration.GetCallerIdentityARN(ctx) if err != nil { return fmt.Errorf("failed to get caller identity: %w", err) } // Create KMS grant err = createGrant(ctx, logger, client, *keyID, *principal) if err != nil { return err } // Create KMS key policy return putKeyPolicy(ctx, logger, client, *keyID, *principal) } ================================================ FILE: aws-source/adapters/integration/kms/teardown.go ================================================ package kms import ( "context" "errors" "fmt" "log/slog" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func teardown(ctx context.Context, logger *slog.Logger, client *kms.Client) error { keyID, err := findActiveKeyIDByTags(ctx, client) if err != nil { if nf := integration.NewNotFoundError(keySrc); errors.As(err, &nf) { logger.WarnContext(ctx, "Key not found") return nil } else { return err } } principal, err := integration.GetCallerIdentityARN(ctx) if err != nil { return fmt.Errorf("failed to get caller identity: %w", err) } grantID, err := findGrant(ctx, client, *keyID, *principal) if err != nil { if nf := integration.NewNotFoundError(grantSrc); errors.As(err, &nf) { logger.WarnContext(ctx, "Grant not found") } else { return err } } err = deleteGrant(ctx, client, *keyID, *grantID) if err != nil { return err } aliasNames, err := findAliasesByTargetKey(ctx, client, *keyID) if err != nil { if nf := integration.NewNotFoundError(aliasSrc); errors.As(err, &nf) { logger.WarnContext(ctx, "Alias not found") } else { return err } } for _, aliasName := range aliasNames { err = deleteAlias(ctx, client, aliasName) if err != nil { return err } } return deleteKey(ctx, client, *keyID) } ================================================ FILE: aws-source/adapters/integration/kms/util.go ================================================ package kms import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func resourceTags(resourceName, testID string, nameAdditionalAttr ...string) []types.Tag { return []types.Tag{ { TagKey: aws.String(integration.TagTestKey), TagValue: aws.String(integration.TagTestValue), }, { TagKey: aws.String(integration.TagTestTypeKey), TagValue: aws.String(integration.TestName(integration.KMS)), }, { TagKey: aws.String(integration.TagTestIDKey), TagValue: aws.String(testID), }, { TagKey: aws.String(integration.TagResourceIDKey), TagValue: aws.String(integration.ResourceName(integration.KMS, resourceName, nameAdditionalAttr...)), }, } } func hasTags(tags []types.Tag, requiredTags []types.Tag) bool { rT := make(map[string]string) for _, t := range requiredTags { rT[*t.TagKey] = *t.TagValue } oT := make(map[string]string) for _, t := range tags { oT[*t.TagKey] = *t.TagValue } for k, v := range rT { if oT[k] != v { return false } } return true } ================================================ FILE: aws-source/adapters/integration/networkmanager/create.go ================================================ package networkmanager import ( "context" "errors" "log/slog" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func createGlobalNetwork(ctx context.Context, logger *slog.Logger, client *networkmanager.Client, testID string) (*string, error) { tags := resourceTags(globalNetworkSrc, testID) id, err := findGlobalNetworkIDByTags(ctx, client, tags) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating global network") } else { return nil, err } } if id != nil { logger.InfoContext(ctx, "Global network already exists") return id, nil } input := &networkmanager.CreateGlobalNetworkInput{ Description: aws.String("Integration test global network"), Tags: tags, } response, err := client.CreateGlobalNetwork(ctx, input) if err != nil { return nil, err } return response.GlobalNetwork.GlobalNetworkId, nil } func createSite(ctx context.Context, logger *slog.Logger, client *networkmanager.Client, testID string, globalNetworkID *string) (*string, error) { tags := resourceTags(siteSrc, testID) id, err := findSiteIDByTags(ctx, client, globalNetworkID, tags) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating site") } else { return nil, err } } if id != nil { logger.InfoContext(ctx, "Site already exists") return id, nil } input := &networkmanager.CreateSiteInput{ GlobalNetworkId: globalNetworkID, Description: aws.String("Integration test site"), Tags: tags, } response, err := client.CreateSite(ctx, input) if err != nil { return nil, err } return response.Site.SiteId, nil } func createLink(ctx context.Context, logger *slog.Logger, client *networkmanager.Client, testID string, globalNetworkID, siteID *string) (*string, error) { tags := resourceTags(linkSrc, testID) id, err := findLinkIDByTags(ctx, client, globalNetworkID, siteID, tags) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating link") } else { return nil, err } } if id != nil { logger.InfoContext(ctx, "Link already exists") return id, nil } input := &networkmanager.CreateLinkInput{ GlobalNetworkId: globalNetworkID, SiteId: siteID, Description: aws.String("Integration test link"), Bandwidth: &types.Bandwidth{ UploadSpeed: aws.Int32(50), DownloadSpeed: aws.Int32(50), }, Tags: tags, } response, err := client.CreateLink(ctx, input) if err != nil { return nil, err } return response.Link.LinkId, nil } func createDevice(ctx context.Context, logger *slog.Logger, client *networkmanager.Client, testID string, globalNetworkID, siteID *string, deviceName string) (*string, error) { tags := resourceTags(deviceSrc, testID, deviceName) id, err := findDeviceIDByTags(ctx, client, globalNetworkID, siteID, tags) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating device", "name", deviceName) } else { return nil, err } } if id != nil { logger.InfoContext(ctx, "Device already exists", "name", deviceName) return id, nil } input := &networkmanager.CreateDeviceInput{ GlobalNetworkId: globalNetworkID, SiteId: siteID, Tags: tags, } response, err := client.CreateDevice(ctx, input) if err != nil { return nil, err } return response.Device.DeviceId, nil } func createLinkAssociation(ctx context.Context, logger *slog.Logger, client *networkmanager.Client, globalNetworkID, deviceID, linkID *string) error { id, err := findLinkAssociationID(ctx, client, globalNetworkID, linkID, deviceID) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating link association") } else { return err } } if id != nil { logger.InfoContext(ctx, "Link association already exists") return nil } input := &networkmanager.AssociateLinkInput{ DeviceId: deviceID, GlobalNetworkId: globalNetworkID, LinkId: linkID, } _, err = client.AssociateLink(ctx, input) if err != nil { return err } return nil } func createConnection(ctx context.Context, logger *slog.Logger, client *networkmanager.Client, globalNetworkID, deviceID, connectedDeviceID *string) error { id, err := findConnectionID(ctx, client, globalNetworkID, deviceID) if err != nil { if errors.As(err, new(integration.NotFoundError)) { logger.InfoContext(ctx, "Creating connection") } else { return err } } if id != nil { logger.InfoContext(ctx, "Connection already exists") return nil } input := &networkmanager.CreateConnectionInput{ GlobalNetworkId: globalNetworkID, DeviceId: deviceID, ConnectedDeviceId: connectedDeviceID, } _, err = client.CreateConnection(ctx, input) if err != nil { return err } return nil } ================================================ FILE: aws-source/adapters/integration/networkmanager/delete.go ================================================ package networkmanager import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/aws/smithy-go" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/networkmanager" ) func deleteGlobalNetwork(ctx context.Context, client *networkmanager.Client, globalNetworkID string) error { input := &networkmanager.DeleteGlobalNetworkInput{ GlobalNetworkId: aws.String(globalNetworkID), } _, err := client.DeleteGlobalNetwork(ctx, input) if err != nil { var apiErr smithy.APIError notFoundException := types.ResourceNotFoundException{} if errors.As(err, &apiErr) && apiErr.ErrorCode() == notFoundException.ErrorCode() { return integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, globalNetworkSrc)) } else { return err } } return nil } func deleteSite(ctx context.Context, client *networkmanager.Client, globalNetworkID, siteID *string) error { input := &networkmanager.DeleteSiteInput{ GlobalNetworkId: globalNetworkID, SiteId: siteID, } _, err := client.DeleteSite(ctx, input) if err != nil { var apiErr smithy.APIError notFoundException := types.ResourceNotFoundException{} if errors.As(err, &apiErr) && apiErr.ErrorCode() == notFoundException.ErrorCode() { return integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, siteSrc)) } else { return err } } return nil } func deleteLink(ctx context.Context, client *networkmanager.Client, globalNetworkID, linkID *string) error { input := &networkmanager.DeleteLinkInput{ GlobalNetworkId: globalNetworkID, LinkId: linkID, } _, err := client.DeleteLink(ctx, input) if err != nil { var apiErr smithy.APIError notFoundException := types.ResourceNotFoundException{} if errors.As(err, &apiErr) && apiErr.ErrorCode() == notFoundException.ErrorCode() { return integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, linkSrc)) } else { return err } } return nil } func deleteDevice(ctx context.Context, client *networkmanager.Client, globalNetworkID, deviceID *string) error { input := &networkmanager.DeleteDeviceInput{ GlobalNetworkId: globalNetworkID, DeviceId: deviceID, } _, err := client.DeleteDevice(ctx, input) if err != nil { var apiErr smithy.APIError notFoundException := types.ResourceNotFoundException{} if errors.As(err, &apiErr) && apiErr.ErrorCode() == notFoundException.ErrorCode() { return integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, deviceSrc)) } else { return err } } return nil } func deleteLinkAssociation(ctx context.Context, client *networkmanager.Client, globalNetworkID, deviceID, linkID *string) error { input := &networkmanager.DisassociateLinkInput{ GlobalNetworkId: globalNetworkID, DeviceId: deviceID, LinkId: linkID, } _, err := client.DisassociateLink(ctx, input) if err != nil { var apiErr smithy.APIError notFoundException := types.ResourceNotFoundException{} if errors.As(err, &apiErr) && apiErr.ErrorCode() == notFoundException.ErrorCode() { return integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, linkAssociationSrc)) } else { return err } } return nil } func deleteConnection(ctx context.Context, client *networkmanager.Client, globalNetworkID, connectionID *string) error { input := &networkmanager.DeleteConnectionInput{ GlobalNetworkId: globalNetworkID, ConnectionId: connectionID, } _, err := client.DeleteConnection(ctx, input) if err != nil { var apiErr smithy.APIError notFoundException := types.ResourceNotFoundException{} if errors.As(err, &apiErr) && apiErr.ErrorCode() == notFoundException.ErrorCode() { return integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, connectionSrc)) } else { return err } } return nil } ================================================ FILE: aws-source/adapters/integration/networkmanager/find.go ================================================ package networkmanager import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func findGlobalNetworkIDByTags(ctx context.Context, client *networkmanager.Client, requiredTags []types.Tag) (*string, error) { result, err := client.DescribeGlobalNetworks(ctx, &networkmanager.DescribeGlobalNetworksInput{}) if err != nil { return nil, err } for _, globalNetwork := range result.GlobalNetworks { if hasTags(globalNetwork.Tags, requiredTags) { return globalNetwork.GlobalNetworkId, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, globalNetworkSrc)) } func findSiteIDByTags(ctx context.Context, client *networkmanager.Client, globalNetworkID *string, requiredTags []types.Tag) (*string, error) { result, err := client.GetSites(ctx, &networkmanager.GetSitesInput{ GlobalNetworkId: globalNetworkID, }) if err != nil { return nil, err } for _, site := range result.Sites { if hasTags(site.Tags, requiredTags) { return site.SiteId, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, siteSrc)) } func findLinkIDByTags(ctx context.Context, client *networkmanager.Client, globalNetworkID, siteID *string, requiredTags []types.Tag) (*string, error) { result, err := client.GetLinks(ctx, &networkmanager.GetLinksInput{ GlobalNetworkId: globalNetworkID, SiteId: siteID, }) if err != nil { return nil, err } for _, link := range result.Links { if hasTags(link.Tags, requiredTags) { return link.LinkId, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, linkSrc)) } func findDeviceIDByTags(ctx context.Context, client *networkmanager.Client, globalNetworkID, sideID *string, requiredTags []types.Tag) (*string, error) { result, err := client.GetDevices(ctx, &networkmanager.GetDevicesInput{ GlobalNetworkId: globalNetworkID, SiteId: sideID, }) if err != nil { return nil, err } for _, device := range result.Devices { if hasTags(device.Tags, requiredTags) { return device.DeviceId, nil } } return nil, integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, deviceSrc)) } func findLinkAssociationID(ctx context.Context, client *networkmanager.Client, globalNetworkID, linkID, deviceID *string) (*string, error) { result, err := client.GetLinkAssociations(ctx, &networkmanager.GetLinkAssociationsInput{ GlobalNetworkId: globalNetworkID, LinkId: linkID, DeviceId: deviceID, }) if err != nil { return nil, err } if len(result.LinkAssociations) != 1 { if len(result.LinkAssociations) == 0 { return nil, integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, linkAssociationSrc)) } return nil, fmt.Errorf("expected 1 link association, got %d", len(result.LinkAssociations)) } compositeKey := fmt.Sprintf("%s|%s|%s", *globalNetworkID, *linkID, *deviceID) return &compositeKey, nil } func findConnectionID(ctx context.Context, client *networkmanager.Client, globalNetworkID, deviceID *string) (*string, error) { result, err := client.GetConnections(ctx, &networkmanager.GetConnectionsInput{ GlobalNetworkId: globalNetworkID, DeviceId: deviceID, }) if err != nil { return nil, err } if len(result.Connections) != 1 { if len(result.Connections) == 0 { return nil, integration.NewNotFoundError(integration.ResourceName(integration.NetworkManager, connectionSrc)) } return nil, fmt.Errorf("expected 1 connection, got %d", len(result.Connections)) } return result.Connections[0].ConnectionId, nil } ================================================ FILE: aws-source/adapters/integration/networkmanager/main_test.go ================================================ package networkmanager import ( "context" "fmt" "log/slog" "os" "testing" awsnetworkmanager "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func TestMain(m *testing.M) { if integration.ShouldRunIntegrationTests() { fmt.Println("Running integration tests") os.Exit(m.Run()) } else { fmt.Println("Skipping integration tests, set RUN_INTEGRATION_TESTS=true to run them") os.Exit(0) } } func TestIntegrationNetworkManager(t *testing.T) { t.Run("Setup", Setup) t.Run("NetworkManager", NetworkManager) t.Run("Teardown", Teardown) } func Setup(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := networkManagerClient(ctx) if err != nil { t.Fatalf("Failed to create NetworkManager client: %v", err) } if err := setup(ctx, logger, testClient); err != nil { t.Fatalf("Failed to setup NetworkManager integration tests: %v", err) } } func Teardown(t *testing.T) { ctx := context.Background() logger := slog.Default() var err error testClient, err := networkManagerClient(ctx) if err != nil { t.Fatalf("Failed to create NetworkManager client: %v", err) } if err := teardown(ctx, logger, testClient); err != nil { t.Fatalf("Failed to teardown NetworkManager integration tests: %v", err) } } func networkManagerClient(ctx context.Context) (*awsnetworkmanager.Client, error) { testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { return nil, fmt.Errorf("failed to get AWS settings: %w", err) } return awsnetworkmanager.NewFromConfig(testAWSConfig.Config), nil } ================================================ FILE: aws-source/adapters/integration/networkmanager/networkmanager_test.go ================================================ package networkmanager import ( "context" "fmt" "strings" "testing" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func searchSync(adapter discovery.SearchStreamableAdapter, ctx context.Context, scope, query string, ignoreCache bool) ([]*sdp.Item, error) { stream := discovery.NewRecordingQueryResultStream() adapter.SearchStream(ctx, scope, query, ignoreCache, stream) errs := stream.GetErrors() if len(errs) > 0 { return nil, fmt.Errorf("failed to search: %v", errs) } return stream.GetItems(), nil } func NetworkManager(t *testing.T) { ctx := context.Background() var err error testClient, err := networkManagerClient(ctx) if err != nil { t.Fatalf("Failed to create NetworkManager client: %v", err) } testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } accountID := testAWSConfig.AccountID t.Logf("Running NetworkManager integration tests") globalNetworkSource := adapters.NewNetworkManagerGlobalNetworkAdapter(testClient, accountID, sdpcache.NewNoOpCache()) if err := globalNetworkSource.Validate(); err != nil { t.Fatalf("failed to validate NetworkManager global network adapter: %v", err) } siteSource := adapters.NewNetworkManagerSiteAdapter(testClient, accountID, sdpcache.NewNoOpCache()) if err := siteSource.Validate(); err != nil { t.Fatalf("failed to validate NetworkManager site adapter: %v", err) } linkSource := adapters.NewNetworkManagerLinkAdapter(testClient, accountID, sdpcache.NewNoOpCache()) if err := linkSource.Validate(); err != nil { t.Fatalf("failed to validate NetworkManager link adapter: %v", err) } linkAssociationSource := adapters.NewNetworkManagerLinkAssociationAdapter(testClient, accountID, sdpcache.NewNoOpCache()) if err := linkAssociationSource.Validate(); err != nil { t.Fatalf("failed to validate NetworkManager link association adapter: %v", err) } connectionSource := adapters.NewNetworkManagerConnectionAdapter(testClient, accountID, sdpcache.NewNoOpCache()) if err := connectionSource.Validate(); err != nil { t.Fatalf("failed to validate NetworkManager connection adapter: %v", err) } deviceSource := adapters.NewNetworkManagerDeviceAdapter(testClient, accountID, sdpcache.NewNoOpCache()) if err := deviceSource.Validate(); err != nil { t.Fatalf("failed to validate NetworkManager device adapter: %v", err) } globalScope := adapters.FormatScope(accountID, "") t.Run("Global Network", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() globalNetworkSource.ListStream(ctx, globalScope, false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("failed to list NetworkManager global networks: %v", errs) } items := stream.GetItems() if len(items) == 0 { t.Fatalf("no global networks found") } globalNetworkUniqueAttribute := items[0].GetUniqueAttribute() globalNetworkID, err := integration.GetUniqueAttributeValueByTags(globalNetworkUniqueAttribute, items, integration.ResourceTags(integration.NetworkManager, globalNetworkSrc), false) if err != nil { t.Fatalf("failed to get global network ID: %v", err) } // Get global network globalNetwork, err := globalNetworkSource.Get(ctx, globalScope, globalNetworkID, true) if err != nil { t.Fatalf("failed to get NetworkManager global network: %v", err) } globalNetworkIDFromGet, err := integration.GetUniqueAttributeValueByTags(globalNetworkUniqueAttribute, []*sdp.Item{globalNetwork}, integration.ResourceTags(integration.NetworkManager, globalNetworkSrc), false) if err != nil { t.Fatalf("failed to get global network ID from get: %v", err) } if globalNetworkID != globalNetworkIDFromGet { t.Fatalf("expected global network ID %s, got %s", globalNetworkID, globalNetworkIDFromGet) } // Search global network by ARN globalNetworkARN, err := globalNetwork.GetAttributes().Get("GlobalNetworkArn") if err != nil { t.Fatalf("failed to get global network ARN: %v", err) } if globalScope != globalNetwork.GetScope() { t.Fatalf("expected global scope %s, got %s", globalScope, globalNetwork.GetScope()) } items, err = searchSync(globalNetworkSource, ctx, globalScope, globalNetworkARN.(string), true) if err != nil { t.Fatalf("failed to search NetworkManager global networks: %v", err) } if len(items) == 0 { t.Fatalf("no global networks found") } globalNetworkIDFromSearch, err := integration.GetUniqueAttributeValueByTags(globalNetworkUniqueAttribute, items, integration.ResourceTags(integration.NetworkManager, globalNetworkSrc), false) if err != nil { t.Fatalf("failed to get global network ID from search: %v", err) } if globalNetworkID != globalNetworkIDFromSearch { t.Fatalf("expected global network ID %s, got %s", globalNetworkID, globalNetworkIDFromSearch) } t.Run("Site", func(t *testing.T) { // Search sites by the global network ID that they are created on sites, err := searchSync(siteSource, ctx, globalScope, globalNetworkID, true) if err != nil { t.Fatalf("failed to search for site: %v", err) } if len(sites) == 0 { t.Fatalf("no sites found") } siteUniqueAttribute := sites[0].GetUniqueAttribute() // composite site id is in the format of {globalNetworkID}|{siteID} compositeSiteID, err := integration.GetUniqueAttributeValueByTags(siteUniqueAttribute, sites, integration.ResourceTags(integration.NetworkManager, siteSrc), false) if err != nil { t.Fatalf("failed to get site ID from search: %v", err) } // Get site: query format = globalNetworkID|siteID site, err := siteSource.Get(ctx, globalScope, compositeSiteID, true) if err != nil { t.Fatalf("failed to get site: %v", err) } siteIDFromGet, err := integration.GetUniqueAttributeValueByTags(siteUniqueAttribute, []*sdp.Item{site}, integration.ResourceTags(integration.NetworkManager, siteSrc), false) if err != nil { t.Fatalf("failed to get site ID from get: %v", err) } if compositeSiteID != siteIDFromGet { t.Fatalf("expected site ID %s, got %s", compositeSiteID, siteIDFromGet) } siteID := strings.Split(compositeSiteID, "|")[1] t.Run("Link", func(t *testing.T) { // Search links by the global network ID that they are created on links, err := searchSync(linkSource, ctx, globalScope, globalNetworkID, true) if err != nil { t.Fatalf("failed to search for link: %v", err) } if len(links) == 0 { t.Fatalf("no links found") } linkUniqueAttribute := links[0].GetUniqueAttribute() compositeLinkID, err := integration.GetUniqueAttributeValueByTags(linkUniqueAttribute, links, integration.ResourceTags(integration.NetworkManager, linkSrc), false) if err != nil { t.Fatalf("failed to get link ID from search: %v", err) } // Get link: query format = globalNetworkID|linkID link, err := linkSource.Get(ctx, globalScope, compositeLinkID, true) if err != nil { t.Fatalf("failed to get link: %v", err) } linkIDFromGet, err := integration.GetUniqueAttributeValueByTags(linkUniqueAttribute, []*sdp.Item{link}, integration.ResourceTags(integration.NetworkManager, linkSrc), false) if err != nil { t.Fatalf("failed to get link ID from get: %v", err) } if compositeLinkID != linkIDFromGet { t.Fatalf("expected link ID %s, got %s", compositeLinkID, linkIDFromGet) } linkID := strings.Split(compositeLinkID, "|")[1] t.Run("Device", func(t *testing.T) { // Search devices by the global network ID and site ID // query format = globalNetworkID|siteID queryDevice := fmt.Sprintf("%s|%s", globalNetworkID, siteID) devices, err := searchSync(deviceSource, ctx, globalScope, queryDevice, true) if err != nil { t.Fatalf("failed to search for device: %v", err) } if len(devices) == 0 { t.Fatalf("no devices found") } deviceUniqueAttribute := devices[0].GetUniqueAttribute() // composite device id is in the format of: {globalNetworkID}|{deviceID} deviceOneCompositeID, err := integration.GetUniqueAttributeValueByTags(deviceUniqueAttribute, devices, integration.ResourceTags(integration.NetworkManager, deviceSrc, deviceOneName), false) if err != nil { t.Fatalf("failed to get device ID from search: %v", err) } // Get device: query format = globalNetworkID|deviceID device, err := deviceSource.Get(ctx, globalScope, deviceOneCompositeID, true) if err != nil { t.Fatalf("failed to get device: %v", err) } deviceOneCompositeIDFromGet, err := integration.GetUniqueAttributeValueByTags(deviceUniqueAttribute, []*sdp.Item{device}, integration.ResourceTags(integration.NetworkManager, deviceSrc, deviceOneName), false) if err != nil { t.Fatalf("failed to get device ID from get: %v", err) } if deviceOneCompositeID != deviceOneCompositeIDFromGet { t.Fatalf("expected device ID %s, got %s", deviceOneCompositeID, deviceOneCompositeIDFromGet) } deviceOneID := strings.Split(deviceOneCompositeID, "|")[1] // Search devices by the global network ID devicesByGlobalNetwork, err := searchSync(deviceSource, ctx, globalScope, globalNetworkID, true) if err != nil { t.Fatalf("failed to search for device by global network: %v", err) } integration.AssertEqualItems(t, devices, devicesByGlobalNetwork, deviceUniqueAttribute) t.Run("Link Association", func(t *testing.T) { // Search link associations by the global network ID, link ID queryLALink := fmt.Sprintf("%s|link|%s", globalNetworkID, linkID) linkAssociations, err := searchSync(linkAssociationSource, ctx, globalScope, queryLALink, true) if err != nil { t.Fatalf("failed to search for link association: %v", err) } if len(linkAssociations) == 0 { t.Fatalf("no link associations found") } linkAssociationUniqueAttribute := linkAssociations[0].GetUniqueAttribute() // composite link association id is in the format of: {globalNetworkID}|{linkID}|{deviceID} compositeLinkAssociationID, err := integration.GetUniqueAttributeValueByTags(linkAssociationUniqueAttribute, linkAssociations, nil, false) if err != nil { t.Fatalf("failed to get link association ID from search: %v", err) } // Get link association: query format = globalNetworkID|linkID|deviceID linkAssociation, err := linkAssociationSource.Get(ctx, globalScope, compositeLinkAssociationID, true) if err != nil { t.Fatalf("failed to get link association: %v", err) } compositeLinkAssociationIDFromGet, err := integration.GetUniqueAttributeValueByTags(linkAssociationUniqueAttribute, []*sdp.Item{linkAssociation}, nil, false) if err != nil { t.Fatalf("failed to get link association ID from get: %v", err) } if compositeLinkAssociationID != compositeLinkAssociationIDFromGet { t.Fatalf("expected link association ID %s, got %s", compositeLinkAssociationID, compositeLinkAssociationIDFromGet) } // Search link associations by the global network ID searchLinkAssociationsByGlobalNetwork, err := searchSync(linkAssociationSource, ctx, globalScope, globalNetworkID, true) if err != nil { t.Fatalf("failed to search for link association by global network: %v", err) } integration.AssertEqualItems(t, linkAssociations, searchLinkAssociationsByGlobalNetwork, linkAssociationUniqueAttribute) // Search link associations by the global network ID and device ID queryLADevice := fmt.Sprintf("%s|device|%s", globalNetworkID, deviceOneID) linkAssociationsByDevice, err := searchSync(linkAssociationSource, ctx, globalScope, queryLADevice, true) if err != nil { t.Fatalf("failed to search for link association by device: %v", err) } integration.AssertEqualItems(t, linkAssociations, linkAssociationsByDevice, linkAssociationUniqueAttribute) }) t.Run("Connection", func(t *testing.T) { // Search connections by the global network ID connections, err := searchSync(connectionSource, ctx, globalScope, globalNetworkID, true) if err != nil { t.Fatalf("failed to search for connection: %v", err) } if len(connections) == 0 { t.Fatalf("no connections found") } connectionUniqueAttribute := connections[0].GetUniqueAttribute() // composite connection id is in the format of: {globalNetworkID}|{connectionID} compositeConnectionID, err := integration.GetUniqueAttributeValueByTags(connectionUniqueAttribute, connections, nil, false) if err != nil { t.Fatalf("failed to get connection ID from search: %v", err) } // Get connection: query format = globalNetworkID|connectionID connection, err := connectionSource.Get(ctx, globalScope, compositeConnectionID, true) if err != nil { t.Fatalf("failed to get connection: %v", err) } compositeConnectionIDFromGet, err := integration.GetUniqueAttributeValueByTags(connectionUniqueAttribute, []*sdp.Item{connection}, nil, false) if err != nil { t.Fatalf("failed to get connection ID from get: %v", err) } if compositeConnectionID != compositeConnectionIDFromGet { t.Fatalf("expected connection ID %s, got %s", compositeConnectionID, compositeConnectionIDFromGet) } // Search connections by global network ID and device ID queryCon := fmt.Sprintf("%s|%s", globalNetworkID, deviceOneID) connectionsByDevice, err := searchSync(connectionSource, ctx, globalScope, queryCon, true) if err != nil { t.Fatalf("failed to search for connection by device: %v", err) } integration.AssertEqualItems(t, connections, connectionsByDevice, connectionUniqueAttribute) }) }) }) }) }) } ================================================ FILE: aws-source/adapters/integration/networkmanager/setup.go ================================================ package networkmanager import ( "context" "log/slog" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/overmindtech/cli/aws-source/adapters/integration" ) const ( globalNetworkSrc = "global-network" siteSrc = "site" linkSrc = "link" deviceSrc = "device" linkAssociationSrc = "link-association" connectionSrc = "connection" ) const ( deviceOneName = "device-1" deviceTwoName = "device-2" ) func setup(ctx context.Context, logger *slog.Logger, networkmanagerClient *networkmanager.Client) error { testID := integration.TestID() // Create a global network globalNetworkID, err := createGlobalNetwork(ctx, logger, networkmanagerClient, testID) if err != nil { return err } // Create a site in the global network siteID, err := createSite(ctx, logger, networkmanagerClient, testID, globalNetworkID) if err != nil { return err } // Create a link in the global network for the site linkID, err := createLink(ctx, logger, networkmanagerClient, testID, globalNetworkID, siteID) if err != nil { return err } // Create a device in the global network for the site deviceOneID, err := createDevice(ctx, logger, networkmanagerClient, testID, globalNetworkID, siteID, deviceOneName) if err != nil { return err } // Create a link association in the global network for the device err = createLinkAssociation(ctx, logger, networkmanagerClient, globalNetworkID, deviceOneID, linkID) if err != nil { return err } // Create another device in the global network for the site deviceTwoID, err := createDevice(ctx, logger, networkmanagerClient, testID, globalNetworkID, siteID, deviceTwoName) if err != nil { return err } // Create a connection between the devices err = createConnection(ctx, logger, networkmanagerClient, globalNetworkID, deviceOneID, deviceTwoID) if err != nil { return err } return nil } ================================================ FILE: aws-source/adapters/integration/networkmanager/tags.go ================================================ package networkmanager import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func resourceTags(resourceName, testID string, additionalAttr ...string) []types.Tag { return []types.Tag{ { Key: aws.String(integration.TagTestKey), Value: aws.String(integration.TagTestValue), }, { Key: aws.String(integration.TagTestTypeKey), Value: aws.String(integration.TestName(integration.NetworkManager)), }, { Key: aws.String(integration.TagTestIDKey), Value: aws.String(testID), }, { Key: aws.String(integration.TagResourceIDKey), Value: aws.String(integration.ResourceName(integration.NetworkManager, resourceName, additionalAttr...)), }, } } func hasTags(tags []types.Tag, requiredTags []types.Tag) bool { rT := make(map[string]string) for _, t := range requiredTags { rT[*t.Key] = *t.Value } oT := make(map[string]string) for _, t := range tags { oT[*t.Key] = *t.Value } for k, v := range rT { if oT[k] != v { return false } } return true } ================================================ FILE: aws-source/adapters/integration/networkmanager/teardown.go ================================================ package networkmanager import ( "context" "errors" "log/slog" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/overmindtech/cli/aws-source/adapters/integration" ) func teardown(ctx context.Context, logger *slog.Logger, client *networkmanager.Client) error { globalNetworkID, err := findGlobalNetworkIDByTags(ctx, client, resourceTags(globalNetworkSrc, integration.TestID())) if err != nil { nf := integration.NewNotFoundError(globalNetworkSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Global network not found") return nil } else { return err } } siteID, err := findSiteIDByTags(ctx, client, globalNetworkID, resourceTags(siteSrc, integration.TestID())) if err != nil { nf := integration.NewNotFoundError(siteSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Site not found") return nil } else { return err } } linkID, err := findLinkIDByTags(ctx, client, globalNetworkID, siteID, resourceTags(linkSrc, integration.TestID())) if err != nil { nf := integration.NewNotFoundError(linkSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Link not found") return nil } else { return err } } deviceOneID, err := findDeviceIDByTags(ctx, client, globalNetworkID, siteID, resourceTags(deviceSrc, integration.TestID(), deviceOneName)) if err != nil { nf := integration.NewNotFoundError(deviceSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Device not found", "name", deviceOneName) return nil } else { return err } } err = deleteLinkAssociation(ctx, client, globalNetworkID, deviceOneID, linkID) if err != nil { nf := integration.NewNotFoundError(linkAssociationSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Link association not found.. ignoring") } else { return err } } connectionID, err := findConnectionID(ctx, client, globalNetworkID, deviceOneID) if err != nil { nf := integration.NewNotFoundError(connectionSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Connection not found") return nil } else { return err } } err = deleteConnection(ctx, client, globalNetworkID, connectionID) if err != nil { nf := integration.NewNotFoundError(connectionSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Connection not found.. ignoring", "id", connectionID) } else { return err } } err = deleteDevice(ctx, client, globalNetworkID, deviceOneID) if err != nil { nf := integration.NewNotFoundError(deviceSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Device not found.. ignoring", "id", deviceOneID) } else { return err } } deviceTwoID, err := findDeviceIDByTags(ctx, client, globalNetworkID, siteID, resourceTags(deviceSrc, integration.TestID(), deviceTwoName)) if err != nil { nf := integration.NewNotFoundError(deviceSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Device not found") return nil } else { return err } } err = deleteDevice(ctx, client, globalNetworkID, deviceTwoID) if err != nil { nf := integration.NewNotFoundError(deviceSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Device not found.. ignoring", "id", deviceTwoID) } else { return err } } err = deleteLink(ctx, client, globalNetworkID, linkID) if err != nil { nf := integration.NewNotFoundError(linkSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Link not found.. ignoring", "id", linkID) } else { return err } } err = deleteSite(ctx, client, globalNetworkID, siteID) if err != nil { nf := integration.NewNotFoundError(siteSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Site not found.. ignoring", "id", siteID) } else { return err } } err = deleteGlobalNetwork(ctx, client, *globalNetworkID) if err != nil { nf := integration.NewNotFoundError(globalNetworkSrc) if errors.As(err, &nf) { logger.WarnContext(ctx, "Global network not found.. ignoring", "id", globalNetworkID) } else { return err } } return nil } ================================================ FILE: aws-source/adapters/integration/ssm/main_test.go ================================================ package ssm import ( "context" "errors" "fmt" "log" "os" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/aws-source/adapters/integration" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/go/tracing" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" ) func TestMain(m *testing.M) { if integration.ShouldRunIntegrationTests() { fmt.Println("Running SSM integration tests") exitCode := func() int { defer tracing.ShutdownTracer(context.Background()) if err := tracing.InitTracerWithUpstreams("ssm-integration-tests", os.Getenv("HONEYCOMB_API_KEY"), ""); err != nil { log.Fatal(err) } return m.Run() }() os.Exit(exitCode) } else { fmt.Println("Skipping SSM integration tests") os.Exit(0) } } var tracer = otel.GetTracerProvider().Tracer( "SSMIntegrationTests", ) func TestIntegrationSSM(t *testing.T) { t.Run("Setup", func(t *testing.T) { ctx := context.Background() testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } client := ssm.NewFromConfig(testAWSConfig.Config) testID := integration.TestID() // Define hierarchy levels environments := []string{"prod", "stage"} regions := []string{"us-east-1", "eu-west-1"} services := []string{"api", "web", "worker"} components := []string{"database", "cache"} configs := []string{"connection", "auth", "monitoring"} // Create parameters with balanced hierarchy for _, env := range environments { for _, region := range regions { for _, service := range services { for _, component := range components { for _, config := range configs { for i := range 1 { path := fmt.Sprintf("/integration-test/%s/%s/%s/%s/%s/param%d", env, region, service, component, config, i) _, err = client.PutParameter(ctx, &ssm.PutParameterInput{ Name: aws.String(path), Type: types.ParameterTypeString, Value: aws.String(fmt.Sprintf("test-value-%s-%d", config, i)), Tags: []types.Tag{ { Key: aws.String(integration.TagTestKey), Value: aws.String(integration.TagTestValue), }, { Key: aws.String(integration.TagTestIDKey), Value: aws.String(testID), }, }, }) if err != nil { var alreadyExistsErr *types.ParameterAlreadyExists if errors.As(err, &alreadyExistsErr) { // Skip if parameter already exists continue } else { t.Fatalf("Failed to create test parameter %s: %v", path, err) } } } // Log progress for each leaf node completion t.Logf("Created parameters for %s/%s/%s/%s/%s", env, region, service, component, config) } } } } } t.Log("Successfully created all test parameters") }) t.Run("SSM", func(t *testing.T) { ctx := context.Background() testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } client := ssm.NewFromConfig(testAWSConfig.Config) scope := testAWSConfig.AccountID + "." + testAWSConfig.Region adapter := adapters.NewSSMParameterAdapter(client, testAWSConfig.AccountID, testAWSConfig.Region, sdpcache.NewNoOpCache()) ctx, span := tracer.Start(ctx, "SSM.List") defer span.End() start := time.Now() stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(ctx, scope, false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() t.Logf("Listed %d SSM parameters in %v", len(items), time.Since(start)) span.SetAttributes( attribute.Int("ssm.parameters", len(items)), ) }) t.Run("Teardown", func(t *testing.T) { ctx := context.Background() testAWSConfig, err := integration.AWSSettings(ctx) if err != nil { t.Fatalf("Failed to get AWS settings: %v", err) } client := ssm.NewFromConfig(testAWSConfig.Config) testID := integration.TestID() var nextToken *string deleted := 0 for { // Get parameters by path recursively input := &ssm.GetParametersByPathInput{ Path: aws.String("/integration-test"), Recursive: aws.Bool(true), NextToken: nextToken, ParameterFilters: []types.ParameterStringFilter{ { Key: aws.String("tag:" + integration.TagTestIDKey), Values: []string{ testID, }, }, }, } output, err := client.GetParametersByPath(ctx, input) if err != nil { t.Fatalf("Failed to get parameters for deletion: %v", err) } if len(output.Parameters) == 0 { break } // Delete parameters in batches of 100 for i := 0; i < len(output.Parameters); i += 100 { end := min(i+100, len(output.Parameters)) batch := output.Parameters[i:end] names := make([]string, len(batch)) for j, param := range batch { names[j] = *param.Name } _, err := client.DeleteParameters(ctx, &ssm.DeleteParametersInput{ Names: names, }) if err != nil { t.Fatalf("Failed to delete parameters: %v", err) } deleted += len(names) t.Logf("Deleted %d parameters...", deleted) } if output.NextToken == nil { break } nextToken = output.NextToken } t.Logf("Successfully deleted %d test parameters", deleted) }) } ================================================ FILE: aws-source/adapters/integration/util.go ================================================ package integration import ( "context" "fmt" "os" "strconv" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/retry" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" ) const ( TagTestKey = "test" TagTestValue = "true" TagTestIDKey = "test-id" TagTestTypeKey = "test-type" TagResourceIDKey = "resource-id" ) type resourceGroup int const ( NetworkManager resourceGroup = iota EC2 KMS APIGateway ) func (rg resourceGroup) String() string { switch rg { case NetworkManager: return "network-manager" case EC2: return "ec2" case KMS: return "kms" case APIGateway: return "api-gateway" default: return "unknown" } } func ShouldRunIntegrationTests() bool { run, found := os.LookupEnv("RUN_INTEGRATION_TESTS") if !found { return false } shouldRun, err := strconv.ParseBool(run) if err != nil { return false } return shouldRun } func TestID() string { tagTestID, found := os.LookupEnv("INTEGRATION_TEST_ID") if !found { var err error tagTestID, err = os.Hostname() if err != nil { panic("failed to get hostname") } } return tagTestID } func TestName(resourceGroup resourceGroup) string { return fmt.Sprintf("%s-integration-tests", resourceGroup.String()) } type AWSCfg struct { AccountID string Region string Config aws.Config } func AWSSettings(ctx context.Context) (*AWSCfg, error) { newRetryer := func() aws.Retryer { var r aws.Retryer r = retry.NewAdaptiveMode() r = retry.AddWithMaxAttempts(r, 10) r = retry.AddWithMaxBackoffDelay(r, 1*time.Second) return r } cfg, err := config.LoadDefaultConfig(ctx, config.WithRetryer(newRetryer), config.WithClientLogMode(aws.LogRetries), config.WithHTTPClient(tracing.HTTPClient()), ) if err != nil { return nil, err } callerIdentity, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { return nil, err } accountID := aws.ToString(callerIdentity.Account) return &AWSCfg{ AccountID: accountID, Region: cfg.Region, Config: cfg, }, nil } func removeUnhealthy(sdpInstances []*sdp.Item) []*sdp.Item { var filteredInstances []*sdp.Item for _, instance := range sdpInstances { if instance.GetHealth() != sdp.Health_HEALTH_OK { continue } filteredInstances = append(filteredInstances, instance) } return filteredInstances } func GetUniqueAttributeValueByTags(uniqueAttrKey string, items []*sdp.Item, filterTags map[string]string, ignoreHealthCheck bool) (string, error) { var filteredItems []*sdp.Item if !ignoreHealthCheck { items = removeUnhealthy(items) } for _, item := range items { if hasTags(item.GetTags(), filterTags) { filteredItems = append(filteredItems, item) } } if len(filteredItems) != 1 { return "", fmt.Errorf("expected 1 item, got %v", len(filteredItems)) } uniqueAttrValue, err := filteredItems[0].GetAttributes().Get(uniqueAttrKey) if err != nil { return "", fmt.Errorf("failed to get %s: %w", uniqueAttrKey, err) } uniqueAttrValueStr := uniqueAttrValue.(string) if uniqueAttrValueStr == "" { return "", fmt.Errorf("%s is empty", uniqueAttrKey) } return uniqueAttrValueStr, nil } func GetUniqueAttributeValueBySignificantAttribute(uniqueAttrkey, significantAttrKey, significantAttrVal string, items []*sdp.Item, ignoreHealthCheck bool) (string, error) { var filteredItems []*sdp.Item if !ignoreHealthCheck { items = removeUnhealthy(items) } for _, item := range items { if val, err := item.GetAttributes().Get(significantAttrKey); err == nil && val == significantAttrVal { filteredItems = append(filteredItems, item) } } if len(filteredItems) != 1 { return "", fmt.Errorf("expected 1 item, got %v", len(filteredItems)) } uniqueAttrValue, err := filteredItems[0].GetAttributes().Get(uniqueAttrkey) if err != nil { return "", fmt.Errorf("failed to get %s: %w", uniqueAttrkey, err) } uniqueAttrValueStr := uniqueAttrValue.(string) if uniqueAttrValueStr == "" { return "", fmt.Errorf("%s is empty", uniqueAttrkey) } return uniqueAttrValueStr, nil } // ResourceName returns a unique resource name for integration tests // I.e., integration-test-networkmanager-global-network-1 func ResourceName(resourceGroup resourceGroup, resourceName string, additionalAttr ...string) string { name := []string{"integration-test", resourceGroup.String(), resourceName} name = append(name, additionalAttr...) return strings.Join(name, "-") } func ResourceTags(resourceGroup resourceGroup, resourceName string, additionalAttr ...string) map[string]string { return map[string]string{ TagTestKey: TagTestValue, TagTestTypeKey: TestName(resourceGroup), TagTestIDKey: TestID(), TagResourceIDKey: ResourceName(resourceGroup, resourceName, additionalAttr...), } } func hasTags(tags map[string]string, requiredTags map[string]string) bool { for k, v := range requiredTags { if tags[k] != v { return false } } return true } func AssertEqualItems(t *testing.T, expected, actual []*sdp.Item, uniqueAttrKey string) { if len(expected) != len(actual) { t.Fatalf("expected %d items, got %d", len(expected), len(actual)) } expectedUnqAttrValSet, err := uniqueAttributeValueSet(expected, uniqueAttrKey) if err != nil { t.Fatalf("failed to get unique attribute value set: %v", err) } actualUnqAttrValSet, err := uniqueAttributeValueSet(actual, uniqueAttrKey) if err != nil { t.Fatalf("failed to get unique attribute value set: %v", err) } if len(expectedUnqAttrValSet) != len(actualUnqAttrValSet) { t.Fatalf("expected %d unique values, got %d", len(expectedUnqAttrValSet), len(actualUnqAttrValSet)) } for val := range expectedUnqAttrValSet { if _, ok := actualUnqAttrValSet[val]; !ok { t.Fatalf("expected value %v not found in actual", val) } } } func uniqueAttributeValueSet(items []*sdp.Item, key string) (map[any]bool, error) { uniqueValues := make(map[any]bool) for _, item := range items { value, err := item.GetAttributes().Get(key) if err != nil { return nil, fmt.Errorf("failed to get %s: %w", key, err) } uniqueValues[value] = true } return uniqueValues, nil } func GetCallerIdentityARN(ctx context.Context) (*string, error) { cfg, err := AWSSettings(ctx) if err != nil { return nil, err } callerIdentity, err := sts.NewFromConfig(cfg.Config).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { return nil, err } return callerIdentity.Arn, nil } ================================================ FILE: aws-source/adapters/integration/util_test.go ================================================ package integration import ( "os" "testing" ) func Test_testID(t *testing.T) { t.Run("test id is given via env var", func(t *testing.T) { err := os.Setenv("INTEGRATION_TEST_ID", "test-id") if err != nil { t.Error(err) } defer func() { err := os.Unsetenv("INTEGRATION_TEST_ID") if err != nil { t.Error(err) } }() if got := TestID(); got != "test-id" { t.Errorf("TestID() = %v, want %v", got, "test-id") } }) t.Run("test id is not given via env var - defaults to host name", func(t *testing.T) { err := os.Unsetenv("INTEGRATION_TEST_ID") if err != nil { t.Error(err) } if got := TestID(); got == "" { t.Errorf("TestID() = %v, want not empty", got) } }) } ================================================ FILE: aws-source/adapters/kms-alias.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func aliasOutputMapper(_ context.Context, _ *kms.Client, scope string, _ *kms.ListAliasesInput, output *kms.ListAliasesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, alias := range output.Aliases { attributes, err := ToAttributesWithExclude(alias, "tags") if err != nil { return nil, err } // This should never happen. if alias.AliasName == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "aliasName is nil", } } // Ignore AWS managed keys, they are predefined and might not have a target key ID if strings.HasPrefix(*alias.AliasName, "alias/aws/") { // AWS managed keys continue } // This should never happen except for AWS managed keys. if alias.TargetKeyId == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "targetKeyId is nil", } } // The uniqueAttributeValue for this is the combination of the keyID and aliasName // i.e., "cf68415c-f4ae-48f2-87a7-3b52ce/alias/test-key" err = attributes.Set("UniqueName", fmt.Sprintf("%s/%s", *alias.TargetKeyId, *alias.AliasName)) if err != nil { return nil, err } item := sdp.Item{ Type: "kms-alias", UniqueAttribute: "UniqueName", Attributes: attributes, Scope: scope, } if alias.TargetKeyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_GET, Query: *alias.TargetKeyId, Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewKMSAliasAdapter(client *kms.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*kms.ListAliasesInput, *kms.ListAliasesOutput, *kms.Client, *kms.Options] { return &DescribeOnlyAdapter[*kms.ListAliasesInput, *kms.ListAliasesOutput, *kms.Client, *kms.Options]{ ItemType: "kms-alias", Client: client, AccountID: accountID, Region: region, AdapterMetadata: kmsAliasAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *kms.Client, input *kms.ListAliasesInput) (*kms.ListAliasesOutput, error) { return client.ListAliases(ctx, input) }, InputMapperGet: func(_, query string) (*kms.ListAliasesInput, error) { // query must be in the format of: the keyID/aliasName // note that the aliasName will have a forward slash in it // i.e., "cf68415c-f4ae-48f2-87a7-3b52ce/alias/test-key" tmp := strings.Split(query, "/") if len(tmp) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the keyID/aliasName, but found: %s", query), } } return &kms.ListAliasesInput{ KeyId: &tmp[0], // keyID }, nil }, UseListForGet: true, InputMapperList: func(_ string) (*kms.ListAliasesInput, error) { return &kms.ListAliasesInput{}, nil }, InputMapperSearch: func(_ context.Context, _ *kms.Client, _, query string) (*kms.ListAliasesInput, error) { return &kms.ListAliasesInput{ KeyId: &query, }, nil }, OutputMapper: aliasOutputMapper, } } var kmsAliasAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "kms-alias", DescriptiveName: "KMS Alias", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an alias by keyID/aliasName", ListDescription: "List all aliases", SearchDescription: "Search aliases by keyID", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_kms_alias.arn", }, }, PotentialLinks: []string{"kms-key"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/kms-alias_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestAliasOutputMapper(t *testing.T) { output := &kms.ListAliasesOutput{ Aliases: []types.AliasListEntry{ { AliasName: new("alias/test-key"), TargetKeyId: new("cf68415c-f4ae-48f2-87a7-3b52ce"), AliasArn: new("arn:aws:kms:us-west-2:123456789012:alias/test-key"), CreationDate: new(time.Now()), LastUpdatedDate: new(time.Now()), }, }, } items, err := aliasOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cf68415c-f4ae-48f2-87a7-3b52ce", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewKMSAliasAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := kms.NewFromConfig(config) adapter := NewKMSAliasAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/kms-custom-key-store.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func customKeyStoreOutputMapper(_ context.Context, _ *kms.Client, scope string, _ *kms.DescribeCustomKeyStoresInput, output *kms.DescribeCustomKeyStoresOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, customKeyStore := range output.CustomKeyStores { attributes, err := ToAttributesWithExclude(customKeyStore, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "kms-custom-key-store", UniqueAttribute: "CustomKeyStoreId", Attributes: attributes, Scope: scope, } switch customKeyStore.ConnectionState { case types.ConnectionStateTypeConnected: item.Health = sdp.Health_HEALTH_OK.Enum() case types.ConnectionStateTypeConnecting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ConnectionStateTypeDisconnected: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.ConnectionStateTypeFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.ConnectionStateTypeDisconnecting: item.Health = sdp.Health_HEALTH_PENDING.Enum() default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "unknown Connection State", } } if customKeyStore.CloudHsmClusterId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudhsmv2-cluster", Method: sdp.QueryMethod_GET, Query: *customKeyStore.CloudHsmClusterId, Scope: scope, }, }) } if customKeyStore.XksProxyConfiguration != nil && customKeyStore.XksProxyConfiguration.VpcEndpointServiceName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc-endpoint-service", Method: sdp.QueryMethod_SEARCH, Query: fmt.Sprintf("name|%s", *customKeyStore.XksProxyConfiguration.VpcEndpointServiceName), Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewKMSCustomKeyStoreAdapter(client *kms.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*kms.DescribeCustomKeyStoresInput, *kms.DescribeCustomKeyStoresOutput, *kms.Client, *kms.Options] { return &DescribeOnlyAdapter[*kms.DescribeCustomKeyStoresInput, *kms.DescribeCustomKeyStoresOutput, *kms.Client, *kms.Options]{ Region: region, Client: client, AccountID: accountID, ItemType: "kms-custom-key-store", AdapterMetadata: customKeyStoreAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *kms.Client, input *kms.DescribeCustomKeyStoresInput) (*kms.DescribeCustomKeyStoresOutput, error) { return client.DescribeCustomKeyStores(ctx, input) }, InputMapperGet: func(_, query string) (*kms.DescribeCustomKeyStoresInput, error) { return &kms.DescribeCustomKeyStoresInput{ CustomKeyStoreId: &query, }, nil }, InputMapperList: func(string) (*kms.DescribeCustomKeyStoresInput, error) { return &kms.DescribeCustomKeyStoresInput{}, nil }, OutputMapper: customKeyStoreOutputMapper, } } var customKeyStoreAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "kms-custom-key-store", DescriptiveName: "Custom Key Store", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a custom key store by its ID", ListDescription: "List all custom key stores", SearchDescription: "Search custom key store by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_kms_custom_key_store.id", }, }, PotentialLinks: []string{"cloudhsmv2-cluster", "ec2-vpc-endpoint-service"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) ================================================ FILE: aws-source/adapters/kms-custom-key-store_test.go ================================================ package adapters import ( "context" "errors" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestCustomKeyStoreOutputMapper(t *testing.T) { output := &kms.DescribeCustomKeyStoresOutput{ CustomKeyStores: []types.CustomKeyStoresListEntry{ { CustomKeyStoreId: new("custom-key-store-1"), CreationDate: new(time.Now()), CloudHsmClusterId: new("cloud-hsm-cluster-1"), ConnectionState: types.ConnectionStateTypeConnected, TrustAnchorCertificate: new("-----BEGIN CERTIFICATE-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwJ1z\n-----END CERTIFICATE-----"), CustomKeyStoreName: new("key-store-1"), }, }, } items, err := customKeyStoreOutputMapper(context.TODO(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "cloudhsmv2-cluster", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cloud-hsm-cluster-1", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewKMSCustomKeyStoreAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := kms.NewFromConfig(config) adapter := NewKMSCustomKeyStoreAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } func TestHealthState(t *testing.T) { tests := []struct { name string output *kms.DescribeCustomKeyStoresOutput expectedHealth sdp.Health expectedError error }{ { name: "HealthyResourceReturnsHealthOK", output: &kms.DescribeCustomKeyStoresOutput{ CustomKeyStores: []types.CustomKeyStoresListEntry{ { CustomKeyStoreId: new("custom-key-store-1"), ConnectionState: types.ConnectionStateTypeConnected, }, }, }, expectedHealth: sdp.Health_HEALTH_OK, }, { name: "PendingResourceReturnsHealthPending", output: &kms.DescribeCustomKeyStoresOutput{ CustomKeyStores: []types.CustomKeyStoresListEntry{ { CustomKeyStoreId: new("custom-key-store-1"), ConnectionState: types.ConnectionStateTypeConnecting, }, }, }, expectedHealth: sdp.Health_HEALTH_PENDING, }, { name: "DisconnectedResourceReturnsHealthUnknown", output: &kms.DescribeCustomKeyStoresOutput{ CustomKeyStores: []types.CustomKeyStoresListEntry{ { CustomKeyStoreId: new("custom-key-store-1"), ConnectionState: types.ConnectionStateTypeDisconnected, }, }, }, expectedHealth: sdp.Health_HEALTH_UNKNOWN, }, { name: "FailedResourceReturnsHealthError", output: &kms.DescribeCustomKeyStoresOutput{ CustomKeyStores: []types.CustomKeyStoresListEntry{ { CustomKeyStoreId: new("custom-key-store-1"), ConnectionState: types.ConnectionStateTypeFailed, }, }, }, expectedHealth: sdp.Health_HEALTH_ERROR, }, { name: "UnknownConnectionStateReturnsError", output: &kms.DescribeCustomKeyStoresOutput{ CustomKeyStores: []types.CustomKeyStoresListEntry{ { CustomKeyStoreId: new("custom-key-store-1"), ConnectionState: "unknown-state", }, }, }, expectedError: &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "unknown Connection State", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { items, err := customKeyStoreOutputMapper(context.TODO(), nil, "foo", nil, tt.output) if tt.expectedError != nil { if err == nil { t.Fatalf("expected an error, got nil") } if !errors.As(err, new(*sdp.QueryError)) { t.Errorf("expected %v, got %v", tt.expectedError, err.Error()) } } else { if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } if items[0].GetHealth() != tt.expectedHealth { t.Errorf("expected health %v, got %v", tt.expectedHealth, items[0].GetHealth()) } } }) } } ================================================ FILE: aws-source/adapters/kms-grant.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" log "github.com/sirupsen/logrus" ) func grantOutputMapper(ctx context.Context, _ *kms.Client, scope string, _ *kms.ListGrantsInput, output *kms.ListGrantsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, grant := range output.Grants { attributes, err := ToAttributesWithExclude(grant, "tags") if err != nil { return nil, err } // This should never happen. if grant.GrantId == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "grantId is nil", } } arn, errA := ParseARN(*grant.KeyId) if errA != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to parse keyID: %s", *grant.KeyId), } } keyID := arn.ResourceID() // The uniqueAttributeValue for this is the combination of the keyID and grantId // i.e., "cf68415c-f4ae-48f2-87a7-3b52ce/grant-id" err = attributes.Set("UniqueName", fmt.Sprintf("%s/%s", keyID, *grant.GrantId)) if err != nil { return nil, err } item := sdp.Item{ Type: "kms-grant", UniqueAttribute: "UniqueName", Attributes: attributes, Scope: scope, } scope = FormatScope(arn.AccountID, arn.Region) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_GET, Query: keyID, Scope: scope, }, }) var principals []string if grant.GranteePrincipal != nil { principals = append(principals, *grant.GranteePrincipal) } if grant.RetiringPrincipal != nil { principals = append(principals, *grant.RetiringPrincipal) } // Valid principals include // - Amazon Web Services accounts // - IAM users, // - IAM roles, // - federated users, // - assumed role users. // principals: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html#identifiers-arns /* arn:aws:iam::account:root arn:aws:iam::account:user/user-name-with-path arn:aws:iam::account:role/role-name-with-path arn:aws:sts::account:federated-user/user-name arn:aws:sts::account:assumed-role/role-name/role-session-name arn:aws:sts::account:self dynamodb.us-west-2.amazonaws.com The following are not supported (we skip them silently): - arn:aws:iam::account:root - arn:aws:sts::account:federated-user/user-name - arn:aws:sts::account:assumed-role/role-name/role-session-name - arn:aws:sts::account:self - Service principals like dynamodb.us-west-2.amazonaws.com (not ARNs, not linkable) */ for _, principal := range principals { // Skip AWS service principals (e.g. "rds.eu-west-2.amazonaws.com", // "dynamodb.us-west-2.amazonaws.com"). These are DNS-style identifiers // for AWS services, not ARNs, and are not linkable to other items. if isAWSServicePrincipal(principal) { log.WithFields(log.Fields{ "input": principal, "scope": scope, }).Debug("Skipping AWS service principal (not linkable)") continue } lIQ := &sdp.LinkedItemQuery{ Query: &sdp.Query{ Method: sdp.QueryMethod_GET, Scope: scope, }, } arn, errA := ParseARN(principal) if errA != nil { log.WithFields(log.Fields{ "error": errA, "input": principal, "scope": scope, }).Warn("Error parsing principal ARN") continue } switch arn.Service { case "iam": adapter, query := iamSourceAndQuery(arn.Resource) switch adapter { case "user": lIQ.Query.Type = "iam-user" lIQ.Query.Query = query case "role": lIQ.Query.Type = "iam-role" lIQ.Query.Query = query default: log.WithFields(log.Fields{ "input": principal, "scope": scope, }).Warn("Error unsupported iam adapter") continue } default: log.WithFields(log.Fields{ "input": principal, "scope": scope, }).Warn("Error ARN service not supported") continue } item.LinkedItemQueries = append(item.LinkedItemQueries, lIQ) } items = append(items, &item) } return items, nil } func NewKMSGrantAdapter(client *kms.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*kms.ListGrantsInput, *kms.ListGrantsOutput, *kms.Client, *kms.Options] { return &DescribeOnlyAdapter[*kms.ListGrantsInput, *kms.ListGrantsOutput, *kms.Client, *kms.Options]{ ItemType: "kms-grant", Client: client, AccountID: accountID, Region: region, AdapterMetadata: grantAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *kms.Client, input *kms.ListGrantsInput) (*kms.ListGrantsOutput, error) { return client.ListGrants(ctx, input) }, InputMapperGet: func(_, query string) (*kms.ListGrantsInput, error) { // query must be in the format of: the keyID/grantId // i.e., "cf68415c-f4ae-48f2-87a7-3b52ce/grant-id" tmp := strings.Split(query, "/") // [keyID, grantId] if len(tmp) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("query must be in the format of: the keyID/grantId, but found: %s", query), } } return &kms.ListGrantsInput{ KeyId: &tmp[0], // keyID GrantId: new(strings.Join(tmp[1:], "/")), // grantId }, nil }, UseListForGet: true, InputMapperList: func(_ string) (*kms.ListGrantsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for kms-grant, use search", } }, InputMapperSearch: func(_ context.Context, _ *kms.Client, _, query string) (*kms.ListGrantsInput, error) { return &kms.ListGrantsInput{ KeyId: &query, }, nil }, OutputMapper: grantOutputMapper, } } var grantAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "kms-grant", DescriptiveName: "KMS Grant", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a grant by keyID/grantId", SearchDescription: "Search grants by keyID", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_kms_grant.grant_id", }, }, PotentialLinks: []string{"kms-key", "iam-user", "iam-role"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) // example: user/user-name-with-path func iamSourceAndQuery(resource string) (string, string) { tmp := strings.Split(resource, "/") // [user, user-name-with-path] adapter := tmp[0] query := strings.Join(tmp[1:], "/") return adapter, query // user, user-name-with-path } // isAWSServicePrincipal returns true if the principal is an AWS service // principal (e.g. "rds.eu-west-2.amazonaws.com", "dynamodb.us-west-2.amazonaws.com"). // These are DNS-style identifiers used by AWS services to assume roles or access // resources, and are not ARNs. func isAWSServicePrincipal(principal string) bool { // Service principals don't start with "arn:" and end with a partition-specific // DNS suffix. if strings.HasPrefix(principal, "arn:") { return false } // Check all AWS partition DNS suffixes using the shared list for _, suffix := range GetAllAWSPartitionDNSSuffixes() { if strings.HasSuffix(principal, "."+suffix) { return true } } return false } ================================================ FILE: aws-source/adapters/kms-grant_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) /* An example list grants response: { "Grants": [ { "Constraints": { "EncryptionContextSubset": { "aws:dynamodb:subscriberId": "123456789012", "aws:dynamodb:tableName": "Services" } }, "IssuingAccount": "arn:aws:iam::123456789012:root", "Name": "8276b9a6-6cf0-46f1-b2f0-7993a7f8c89a", "Operations": [ "Decrypt", "Encrypt", "GenerateDataKey", "ReEncryptFrom", "ReEncryptTo", "RetireGrant", "DescribeKey" ], "GrantId": "1667b97d27cf748cf05b487217dd4179526c949d14fb3903858e25193253fe59", "KeyId": "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab", "RetiringPrincipal": "dynamodb.us-west-2.amazonaws.com", "GranteePrincipal": "dynamodb.us-west-2.amazonaws.com", "CreationDate": "2021-05-13T18:32:45.144000+00:00" } ] } */ func TestIsAWSServicePrincipal(t *testing.T) { t.Parallel() tests := []struct { name string principal string expected bool }{ { name: "RDS service principal", principal: "rds.eu-west-2.amazonaws.com", expected: true, }, { name: "DynamoDB service principal", principal: "dynamodb.us-west-2.amazonaws.com", expected: true, }, { name: "EC2 service principal", principal: "ec2.amazonaws.com", expected: true, }, { name: "China region service principal (aws-cn)", principal: "rds.cn-north-1.amazonaws.com.cn", expected: true, }, { name: "EU partition service principal (aws-eu)", principal: "rds.eu-central-1.amazonaws.eu", expected: true, }, { name: "ISO partition service principal (aws-iso)", principal: "rds.us-iso-east-1.c2s.ic.gov", expected: true, }, { name: "ISO-B partition service principal (aws-iso-b)", principal: "rds.us-isob-east-1.sc2s.sgov.gov", expected: true, }, { name: "IAM role ARN", principal: "arn:aws:iam::123456789012:role/MyRole", expected: false, }, { name: "IAM user ARN", principal: "arn:aws:iam::123456789012:user/MyUser", expected: false, }, { name: "Account root ARN", principal: "arn:aws:iam::123456789012:root", expected: false, }, { name: "Random string", principal: "not-a-principal", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := isAWSServicePrincipal(tt.principal) if result != tt.expected { t.Errorf("isAWSServicePrincipal(%q) = %v, expected %v", tt.principal, result, tt.expected) } }) } } func TestGrantOutputMapper(t *testing.T) { output := &kms.ListGrantsOutput{ Grants: []types.GrantListEntry{ { Constraints: &types.GrantConstraints{ EncryptionContextSubset: map[string]string{ "aws:dynamodb:subscriberId": "123456789012", "aws:dynamodb:tableName": "Services", }, }, IssuingAccount: new("arn:aws:iam::123456789012:root"), Name: new("8276b9a6-6cf0-46f1-b2f0-7993a7f8c89a"), Operations: []types.GrantOperation{"Decrypt", "Encrypt", "GenerateDataKey", "ReEncryptFrom", "ReEncryptTo", "RetireGrant", "DescribeKey"}, GrantId: new("1667b97d27cf748cf05b487217dd4179526c949d14fb3903858e25193253fe59"), KeyId: new("arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab"), RetiringPrincipal: new("arn:aws:iam::account:role/role-name-with-path"), GranteePrincipal: new("arn:aws:iam::account:user/user-name-with-path"), CreationDate: new(time.Now()), }, }, } items, err := grantOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] scope := FormatScope("123456789012", "us-west-2") tests := QueryTests{ { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "1234abcd-12ab-34cd-56ef-1234567890ab", ExpectedScope: scope, }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "role-name-with-path", ExpectedScope: scope, }, { ExpectedType: "iam-user", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "user-name-with-path", ExpectedScope: scope, }, } tests.Execute(t, item) } func TestGrantOutputMapperWithServicePrincipal(t *testing.T) { // Test that service principals (like dynamodb.us-west-2.amazonaws.com) are // properly skipped and don't cause errors or generate linked item queries output := &kms.ListGrantsOutput{ Grants: []types.GrantListEntry{ { Constraints: &types.GrantConstraints{ EncryptionContextSubset: map[string]string{ "aws:dynamodb:subscriberId": "123456789012", "aws:dynamodb:tableName": "Services", }, }, IssuingAccount: new("arn:aws:iam::123456789012:root"), Name: new("8276b9a6-6cf0-46f1-b2f0-7993a7f8c89a"), Operations: []types.GrantOperation{"Decrypt", "Encrypt"}, GrantId: new("1667b97d27cf748cf05b487217dd4179526c949d14fb3903858e25193253fe59"), KeyId: new("arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab"), // These are service principals, not ARNs - they should be skipped RetiringPrincipal: new("dynamodb.us-west-2.amazonaws.com"), GranteePrincipal: new("rds.eu-west-2.amazonaws.com"), CreationDate: new(time.Now()), }, }, } items, err := grantOutputMapper(context.Background(), nil, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Should only have the kms-key link, not the service principals if len(item.GetLinkedItemQueries()) != 1 { t.Errorf("expected 1 linked item query (kms-key only), got %v", len(item.GetLinkedItemQueries())) for i, liq := range item.GetLinkedItemQueries() { t.Logf(" [%d] type=%s query=%s", i, liq.GetQuery().GetType(), liq.GetQuery().GetQuery()) } } if item.GetLinkedItemQueries()[0].GetQuery().GetType() != "kms-key" { t.Errorf("expected linked item query to be kms-key, got %s", item.GetLinkedItemQueries()[0].GetQuery().GetType()) } } func TestNewKMSGrantAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := kms.NewFromConfig(config) adapter := NewKMSGrantAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/kms-key-policy.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/micahhausler/aws-iam-policy/policy" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" log "github.com/sirupsen/logrus" ) type keyPolicyClient interface { GetKeyPolicy(ctx context.Context, params *kms.GetKeyPolicyInput, optFns ...func(*kms.Options)) (*kms.GetKeyPolicyOutput, error) ListKeyPolicies(ctx context.Context, params *kms.ListKeyPoliciesInput, optFns ...func(*kms.Options)) (*kms.ListKeyPoliciesOutput, error) } func getKeyPolicyFunc(ctx context.Context, client keyPolicyClient, scope string, input *kms.GetKeyPolicyInput) (*sdp.Item, error) { output, err := client.GetKeyPolicy(ctx, input) if err != nil { return nil, err } if output.Policy == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "get key policy response was nil", Scope: scope, } } type keyParsedPolicy struct { *kms.GetKeyPolicyOutput PolicyDocument *policy.Policy } parsedPolicy := keyParsedPolicy{ GetKeyPolicyOutput: output, } parsedPolicy.PolicyDocument, err = ParsePolicyDocument(*output.Policy) if err != nil { log.WithFields(log.Fields{ "error": err, "input": input, "scope": scope, }).Error("Error parsing policy document") return nil, nil //nolint:nilerr } attributes, err := ToAttributesWithExclude(parsedPolicy) if err != nil { return nil, err } err = attributes.Set("KeyId", *input.KeyId) if err != nil { return nil, err } item := &sdp.Item{ Type: "kms-key-policy", UniqueAttribute: "KeyId", Attributes: attributes, Scope: scope, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_GET, Query: *input.KeyId, Scope: scope, }, }) return item, nil } func NewKMSKeyPolicyAdapter(client keyPolicyClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*kms.ListKeyPoliciesInput, *kms.ListKeyPoliciesOutput, *kms.GetKeyPolicyInput, *kms.GetKeyPolicyOutput, keyPolicyClient, *kms.Options] { return &AlwaysGetAdapter[*kms.ListKeyPoliciesInput, *kms.ListKeyPoliciesOutput, *kms.GetKeyPolicyInput, *kms.GetKeyPolicyOutput, keyPolicyClient, *kms.Options]{ ItemType: "kms-key-policy", Client: client, AccountID: accountID, Region: region, DisableList: true, // This adapter only supports listing by Key ID AdapterMetadata: keyPolicyAdapterMetadata, cache: cache, SearchInputMapper: func(scope, query string) (*kms.ListKeyPoliciesInput, error) { return &kms.ListKeyPoliciesInput{ KeyId: &query, }, nil }, GetInputMapper: func(scope, query string) *kms.GetKeyPolicyInput { return &kms.GetKeyPolicyInput{ KeyId: &query, } }, ListFuncPaginatorBuilder: func(client keyPolicyClient, input *kms.ListKeyPoliciesInput) Paginator[*kms.ListKeyPoliciesOutput, *kms.Options] { return kms.NewListKeyPoliciesPaginator(client, input) }, ListFuncOutputMapper: func(output *kms.ListKeyPoliciesOutput, input *kms.ListKeyPoliciesInput) ([]*kms.GetKeyPolicyInput, error) { var inputs []*kms.GetKeyPolicyInput for _, policyName := range output.PolicyNames { inputs = append(inputs, &kms.GetKeyPolicyInput{ KeyId: input.KeyId, PolicyName: &policyName, }) } return inputs, nil }, GetFunc: getKeyPolicyFunc, } } var keyPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "kms-key-policy", DescriptiveName: "KMS Key Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a KMS key policy by its Key ID", SearchDescription: "Search KMS key policies by Key ID", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_kms_key_policy.key_id"}, }, PotentialLinks: []string{"kms-key"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/kms-key-policy_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) /* Example key policy values { "Version" : "2012-10-17", "Id" : "key-default-1", "Statement" : [ { "Sid" : "Enable IAM User Permissions", "Effect" : "Allow", "Principal" : { "AWS" : "arn:aws:iam::111122223333:root" }, "Action" : "kms:*", "Resource" : "*" }, { "Sid" : "Allow Use of Key", "Effect" : "Allow", "Principal" : { "AWS" : "arn:aws:iam::111122223333:user/test-user" }, "Action" : [ "kms:Describe", "kms:List" ], "Resource" : "*" } ] } */ type mockKeyPolicyClient struct{} func (m *mockKeyPolicyClient) GetKeyPolicy(ctx context.Context, params *kms.GetKeyPolicyInput, optFns ...func(*kms.Options)) (*kms.GetKeyPolicyOutput, error) { return &kms.GetKeyPolicyOutput{ Policy: new(`{ "Version" : "2012-10-17", "Id" : "key-default-1", "Statement" : [ { "Sid" : "Enable IAM User Permissions", "Effect" : "Allow", "Principal" : { "AWS" : "arn:aws:iam::111122223333:root" }, "Action" : "kms:*", "Resource" : "*" }, { "Sid" : "Allow Use of Key", "Effect" : "Allow", "Principal" : { "AWS" : "arn:aws:iam::111122223333:user/test-user" }, "Action" : [ "kms:Describe", "kms:List" ], "Resource" : "*" } ] }`), PolicyName: new("default"), }, nil } func (m *mockKeyPolicyClient) ListKeyPolicies(ctx context.Context, params *kms.ListKeyPoliciesInput, optFns ...func(*kms.Options)) (*kms.ListKeyPoliciesOutput, error) { return &kms.ListKeyPoliciesOutput{ PolicyNames: []string{"default"}, }, nil } func TestGetKeyPolicyFunc(t *testing.T) { ctx := context.Background() cli := &mockKeyPolicyClient{} item, err := getKeyPolicyFunc(ctx, cli, "scope", &kms.GetKeyPolicyInput{ KeyId: new("1234abcd-12ab-34cd-56ef-1234567890ab"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "1234abcd-12ab-34cd-56ef-1234567890ab", ExpectedScope: "scope", }, } tests.Execute(t, item) } func TestNewKMSKeyPolicyAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := kms.NewFromConfig(config) adapter := NewKMSKeyPolicyAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/kms-key.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type kmsClient interface { DescribeKey(ctx context.Context, params *kms.DescribeKeyInput, optFns ...func(*kms.Options)) (*kms.DescribeKeyOutput, error) ListKeys(context.Context, *kms.ListKeysInput, ...func(*kms.Options)) (*kms.ListKeysOutput, error) ListResourceTags(context.Context, *kms.ListResourceTagsInput, ...func(*kms.Options)) (*kms.ListResourceTagsOutput, error) } func kmsKeyGetFunc(ctx context.Context, client kmsClient, scope string, input *kms.DescribeKeyInput) (*sdp.Item, error) { output, err := client.DescribeKey(ctx, input) if err != nil { return nil, err } if output.KeyMetadata == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "describe key response was nil", Scope: scope, } } attributes, err := ToAttributesWithExclude(output.KeyMetadata) if err != nil { return nil, err } // Some keys can be accessed, but not their tags, even if you have full // admin access. No clue how this is possible but seems to be an // inconsistency in the AWS API. In this case, we will ignore the error and // embed it in a tag so that you can see that they are missing var resourceTags map[string]string resourceTags, err = kmsTags(ctx, client, *input.KeyId) if err != nil { resourceTags = HandleTagsError(ctx, err) } item := &sdp.Item{ Type: "kms-key", UniqueAttribute: "KeyId", Attributes: attributes, Scope: scope, Tags: resourceTags, } if output.KeyMetadata.CustomKeyStoreId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-custom-key-store", Method: sdp.QueryMethod_GET, Query: *output.KeyMetadata.CustomKeyStoreId, Scope: scope, }, }) } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key-policy", Method: sdp.QueryMethod_SEARCH, Query: *input.KeyId, Scope: scope, }, }) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-grant", Method: sdp.QueryMethod_SEARCH, Query: *input.KeyId, Scope: scope, }, }) switch output.KeyMetadata.KeyState { case types.KeyStateEnabled: item.Health = sdp.Health_HEALTH_OK.Enum() case types.KeyStateUnavailable, types.KeyStateDisabled: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case types.KeyStateCreating, types.KeyStatePendingDeletion, types.KeyStatePendingReplicaDeletion, types.KeyStatePendingImport, types.KeyStateUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "unknown Key State", } } return item, nil } func NewKMSKeyAdapter(client kmsClient, accountID, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*kms.ListKeysInput, *kms.ListKeysOutput, *kms.DescribeKeyInput, *kms.DescribeKeyOutput, kmsClient, *kms.Options] { return &AlwaysGetAdapter[*kms.ListKeysInput, *kms.ListKeysOutput, *kms.DescribeKeyInput, *kms.DescribeKeyOutput, kmsClient, *kms.Options]{ ItemType: "kms-key", Client: client, AccountID: accountID, Region: region, ListInput: &kms.ListKeysInput{}, AdapterMetadata: kmsKeyAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *kms.DescribeKeyInput { return &kms.DescribeKeyInput{ KeyId: &query, } }, ListFuncPaginatorBuilder: func(client kmsClient, input *kms.ListKeysInput) Paginator[*kms.ListKeysOutput, *kms.Options] { return kms.NewListKeysPaginator(client, input) }, ListFuncOutputMapper: func(output *kms.ListKeysOutput, _ *kms.ListKeysInput) ([]*kms.DescribeKeyInput, error) { var inputs []*kms.DescribeKeyInput for _, key := range output.Keys { inputs = append(inputs, &kms.DescribeKeyInput{ KeyId: key.KeyId, }) } return inputs, nil }, GetFunc: kmsKeyGetFunc, } } var kmsKeyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "kms-key", DescriptiveName: "KMS Key", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a KMS Key by its ID", ListDescription: "List all KMS Keys", SearchDescription: "Search for KMS Keys by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_kms_key.key_id", }, }, PotentialLinks: []string{"kms-custom-key-store", "kms-key-policy", "kms-grant"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/kms-key_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/overmindtech/cli/go/sdpcache" ) type kmsTestClient struct{} func (t kmsTestClient) DescribeKey(ctx context.Context, params *kms.DescribeKeyInput, optFns ...func(*kms.Options)) (*kms.DescribeKeyOutput, error) { return &kms.DescribeKeyOutput{ KeyMetadata: &types.KeyMetadata{ AWSAccountId: new("846764612917"), KeyId: new("b8a9477d-836c-491f-857e-07937918959b"), Arn: new("arn:aws:kms:us-west-2:846764612917:key/b8a9477d-836c-491f-857e-07937918959b"), CreationDate: new(time.Date(2017, 6, 30, 21, 44, 32, 140000000, time.UTC)), Enabled: true, Description: new("Default KMS key that protects my S3 objects when no other key is defined"), KeyUsage: types.KeyUsageTypeEncryptDecrypt, KeyState: types.KeyStateEnabled, Origin: types.OriginTypeAwsKms, KeyManager: types.KeyManagerTypeAws, CustomerMasterKeySpec: types.CustomerMasterKeySpecSymmetricDefault, EncryptionAlgorithms: []types.EncryptionAlgorithmSpec{ types.EncryptionAlgorithmSpecSymmetricDefault, }, }, }, nil } func (t kmsTestClient) ListKeys(context.Context, *kms.ListKeysInput, ...func(*kms.Options)) (*kms.ListKeysOutput, error) { return &kms.ListKeysOutput{ Keys: []types.KeyListEntry{ { KeyArn: new("arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"), KeyId: new("1234abcd-12ab-34cd-56ef-1234567890ab"), }, { KeyArn: new("arn:aws:kms:us-west-2:111122223333:key/0987dcba-09fe-87dc-65ba-ab0987654321"), KeyId: new("0987dcba-09fe-87dc-65ba-ab0987654321"), }, { KeyArn: new("arn:aws:kms:us-east-2:111122223333:key/1a2b3c4d-5e6f-1a2b-3c4d-5e6f1a2b3c4d"), KeyId: new("1a2b3c4d-5e6f-1a2b-3c4d-5e6f1a2b3c4d"), }, }, }, nil } func (t kmsTestClient) ListResourceTags(context.Context, *kms.ListResourceTagsInput, ...func(*kms.Options)) (*kms.ListResourceTagsOutput, error) { return &kms.ListResourceTagsOutput{ Tags: []types.Tag{ { TagKey: new("Dept"), TagValue: new("IT"), }, { TagKey: new("Purpose"), TagValue: new("Test"), }, { TagKey: new("Name"), TagValue: new("Test"), }, }, }, nil } func TestKMSGetFunc(t *testing.T) { ctx := context.Background() cli := kmsTestClient{} item, err := kmsKeyGetFunc(ctx, cli, "scope", &kms.DescribeKeyInput{ KeyId: new("1234abcd-12ab-34cd-56ef-1234567890ab"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } } func TestNewKMSKeyAdapter(t *testing.T) { t.Skip("This test is currently failing due to a key that none of us can read, even with admin permissions. I think we will need to speak with AWS support to work out how to delete it") config, account, region := GetAutoConfig(t) client := kms.NewFromConfig(config) adapter := NewKMSKeyAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/kms.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" ) func kmsTags(ctx context.Context, cli kmsClient, keyID string) (map[string]string, error) { if cli == nil { return nil, nil } output, err := cli.ListResourceTags(ctx, &kms.ListResourceTagsInput{ KeyId: &keyID, }) if err != nil { return nil, err } return kmsTagsToMap(output.Tags), nil } func kmsTagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.TagKey != nil && tag.TagValue != nil { tagsMap[*tag.TagKey] = *tag.TagValue } } return tagsMap } ================================================ FILE: aws-source/adapters/lambda-event-source-mapping.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type lambdaEventSourceMappingClient interface { ListEventSourceMappings(ctx context.Context, params *lambda.ListEventSourceMappingsInput, optFns ...func(*lambda.Options)) (*lambda.ListEventSourceMappingsOutput, error) GetEventSourceMapping(ctx context.Context, params *lambda.GetEventSourceMappingInput, optFns ...func(*lambda.Options)) (*lambda.GetEventSourceMappingOutput, error) } func eventSourceMappingListFunc(ctx context.Context, client lambdaEventSourceMappingClient, _ string) ([]*types.EventSourceMappingConfiguration, error) { out, err := client.ListEventSourceMappings(ctx, &lambda.ListEventSourceMappingsInput{}) if err != nil { return nil, err } var items []*types.EventSourceMappingConfiguration for _, mapping := range out.EventSourceMappings { items = append(items, &mapping) } return items, nil } // convertGetEventSourceMappingOutputToConfiguration converts a GetEventSourceMappingOutput to EventSourceMappingConfiguration func convertGetEventSourceMappingOutputToConfiguration(output *lambda.GetEventSourceMappingOutput) *types.EventSourceMappingConfiguration { return &types.EventSourceMappingConfiguration{ AmazonManagedKafkaEventSourceConfig: output.AmazonManagedKafkaEventSourceConfig, BatchSize: output.BatchSize, BisectBatchOnFunctionError: output.BisectBatchOnFunctionError, DestinationConfig: output.DestinationConfig, DocumentDBEventSourceConfig: output.DocumentDBEventSourceConfig, EventSourceArn: output.EventSourceArn, EventSourceMappingArn: output.EventSourceMappingArn, FilterCriteria: output.FilterCriteria, FilterCriteriaError: output.FilterCriteriaError, FunctionArn: output.FunctionArn, FunctionResponseTypes: output.FunctionResponseTypes, KMSKeyArn: output.KMSKeyArn, LastModified: output.LastModified, LastProcessingResult: output.LastProcessingResult, MaximumBatchingWindowInSeconds: output.MaximumBatchingWindowInSeconds, MaximumRecordAgeInSeconds: output.MaximumRecordAgeInSeconds, MaximumRetryAttempts: output.MaximumRetryAttempts, MetricsConfig: output.MetricsConfig, ParallelizationFactor: output.ParallelizationFactor, ProvisionedPollerConfig: output.ProvisionedPollerConfig, Queues: output.Queues, ScalingConfig: output.ScalingConfig, SelfManagedEventSource: output.SelfManagedEventSource, SelfManagedKafkaEventSourceConfig: output.SelfManagedKafkaEventSourceConfig, SourceAccessConfigurations: output.SourceAccessConfigurations, StartingPosition: output.StartingPosition, StartingPositionTimestamp: output.StartingPositionTimestamp, State: output.State, StateTransitionReason: output.StateTransitionReason, Topics: output.Topics, TumblingWindowInSeconds: output.TumblingWindowInSeconds, UUID: output.UUID, } } func eventSourceMappingOutputMapper(query, scope string, awsItem *types.EventSourceMappingConfiguration) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } // Set the unique attribute (UUID) if awsItem.UUID != nil { err = attributes.Set("UUID", *awsItem.UUID) if err != nil { return nil, err } } item := sdp.Item{ Type: "lambda-event-source-mapping", UniqueAttribute: "UUID", Attributes: attributes, Scope: scope, } // Link to the Lambda function if FunctionArn is present if awsItem.FunctionArn != nil { parsedARN, err := ParseARN(*awsItem.FunctionArn) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.FunctionArn, Scope: FormatScope(parsedARN.AccountID, parsedARN.Region), }, }) } } // Link to the event source if EventSourceArn is present if awsItem.EventSourceArn != nil { parsedARN, err := ParseARN(*awsItem.EventSourceArn) if err == nil { var queryType string switch parsedARN.Service { case "dynamodb": queryType = "dynamodb-table" case "kinesis": queryType = "kinesis-stream" case "sqs": queryType = "sqs-queue" case "kafka": queryType = "kafka-cluster" case "mq": queryType = "mq-broker" // Note: DocumentDB clusters use the RDS service identifier ("rds") in their ARNs. // Therefore, we map both RDS and DocumentDB clusters to "rds-db-cluster" here. case "rds": queryType = "rds-db-cluster" default: // Skip creating links for unknown services queryType = "" } // Only create link if we have a valid queryType if queryType != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: queryType, Method: sdp.QueryMethod_SEARCH, Query: *awsItem.EventSourceArn, Scope: FormatScope(parsedARN.AccountID, parsedARN.Region), }, }) } } } // Set health status based on state if awsItem.State != nil { switch *awsItem.State { case "Enabled": item.Health = sdp.Health_HEALTH_OK.Enum() case "Creating": item.Health = sdp.Health_HEALTH_PENDING.Enum() case "Deleting": item.Health = sdp.Health_HEALTH_PENDING.Enum() case "Disabled": item.Health = nil case "Enabling": item.Health = sdp.Health_HEALTH_PENDING.Enum() case "Updating": item.Health = sdp.Health_HEALTH_PENDING.Enum() case "Disabling": item.Health = sdp.Health_HEALTH_PENDING.Enum() } } return &item, nil } func NewLambdaEventSourceMappingAdapter(client lambdaEventSourceMappingClient, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.EventSourceMappingConfiguration, lambdaEventSourceMappingClient, *lambda.Options] { return &GetListAdapter[*types.EventSourceMappingConfiguration, lambdaEventSourceMappingClient, *lambda.Options]{ ItemType: "lambda-event-source-mapping", Client: client, AccountID: accountID, Region: region, AdapterMetadata: lambdaEventSourceMappingAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client lambdaEventSourceMappingClient, scope, query string) (*types.EventSourceMappingConfiguration, error) { out, err := client.GetEventSourceMapping(ctx, &lambda.GetEventSourceMappingInput{ UUID: &query, }) if err != nil { return nil, err } return convertGetEventSourceMappingOutputToConfiguration(out), nil }, ListFunc: eventSourceMappingListFunc, SearchFunc: func(ctx context.Context, client lambdaEventSourceMappingClient, scope string, query string) ([]*types.EventSourceMappingConfiguration, error) { // Use the query directly as event source ARN input to ListEventSourceMappings out, err := client.ListEventSourceMappings(ctx, &lambda.ListEventSourceMappingsInput{ EventSourceArn: &query, }) if err != nil { return nil, err } response := make([]*types.EventSourceMappingConfiguration, 0, len(out.EventSourceMappings)) for _, mapping := range out.EventSourceMappings { response = append(response, &mapping) } return response, nil }, ItemMapper: func(query, scope string, awsItem *types.EventSourceMappingConfiguration) (*sdp.Item, error) { return eventSourceMappingOutputMapper(query, scope, awsItem) }, } } var lambdaEventSourceMappingAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "lambda-event-source-mapping", DescriptiveName: "Lambda Event Source Mapping", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, List: true, GetDescription: "Get a Lambda event source mapping by UUID", SearchDescription: "Search for Lambda event source mappings by Event Source ARN (SQS, DynamoDB, Kinesis, etc.)", ListDescription: "List all Lambda event source mappings", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_lambda_event_source_mapping.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{ "lambda-function", "dynamodb-table", "kinesis-stream", "sqs-queue", "kafka-cluster", "mq-broker", "rds-db-cluster", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/lambda-event-source-mapping_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type TestLambdaEventSourceMappingClient struct{} func (t *TestLambdaEventSourceMappingClient) ListEventSourceMappings(ctx context.Context, params *lambda.ListEventSourceMappingsInput, optFns ...func(*lambda.Options)) (*lambda.ListEventSourceMappingsOutput, error) { allMappings := []types.EventSourceMappingConfiguration{ { UUID: new("test-uuid-1"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function"), EventSourceArn: new("arn:aws:sqs:us-east-1:123456789012:test-queue"), State: new("Enabled"), }, { UUID: new("test-uuid-2"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function-2"), EventSourceArn: new("arn:aws:dynamodb:us-east-1:123456789012:table/test-table"), State: new("Creating"), }, { UUID: new("test-uuid-3"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function-3"), EventSourceArn: new("arn:aws:rds:us-east-1:123456789012:cluster:test-docdb-cluster"), State: new("Enabled"), }, } // If EventSourceArn is specified, filter by it if params.EventSourceArn != nil { filtered := []types.EventSourceMappingConfiguration{} for _, mapping := range allMappings { if mapping.EventSourceArn != nil && *mapping.EventSourceArn == *params.EventSourceArn { filtered = append(filtered, mapping) } } return &lambda.ListEventSourceMappingsOutput{ EventSourceMappings: filtered, }, nil } return &lambda.ListEventSourceMappingsOutput{ EventSourceMappings: allMappings, }, nil } func (t *TestLambdaEventSourceMappingClient) GetEventSourceMapping(ctx context.Context, params *lambda.GetEventSourceMappingInput, optFns ...func(*lambda.Options)) (*lambda.GetEventSourceMappingOutput, error) { if params.UUID == nil { return nil, &types.ResourceNotFoundException{} } switch *params.UUID { case "test-uuid-1": return &lambda.GetEventSourceMappingOutput{ UUID: new("test-uuid-1"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function"), EventSourceArn: new("arn:aws:sqs:us-east-1:123456789012:test-queue"), State: new("Enabled"), }, nil case "test-uuid-2": return &lambda.GetEventSourceMappingOutput{ UUID: new("test-uuid-2"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function-2"), EventSourceArn: new("arn:aws:dynamodb:us-east-1:123456789012:table/test-table"), State: new("Creating"), }, nil case "test-uuid-3": return &lambda.GetEventSourceMappingOutput{ UUID: new("test-uuid-3"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function-3"), EventSourceArn: new("arn:aws:rds:us-east-1:123456789012:cluster:test-docdb-cluster"), State: new("Enabled"), }, nil default: return nil, &types.ResourceNotFoundException{} } } func TestLambdaEventSourceMappingAdapter(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test adapter metadata if adapter.Type() != "lambda-event-source-mapping" { t.Errorf("Expected adapter type to be 'lambda-event-source-mapping', got %s", adapter.Type()) } if adapter.Name() != "lambda-event-source-mapping-adapter" { t.Errorf("Expected adapter name to be 'lambda-event-source-mapping-adapter', got %s", adapter.Name()) } // Test scopes scopes := adapter.Scopes() if len(scopes) != 1 { t.Errorf("Expected 1 scope, got %d", len(scopes)) } if scopes[0] != "123456789012.us-east-1" { t.Errorf("Expected scope to be '123456789012.us-east-1', got %s", scopes[0]) } } func TestLambdaEventSourceMappingGetFunc(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test getting existing event source mapping item, err := adapter.Get(context.Background(), "123456789012.us-east-1", "test-uuid-1", false) if err != nil { t.Errorf("Expected no error, got %v", err) } if item == nil { t.Error("Expected item, got nil") return } if item.GetType() != "lambda-event-source-mapping" { t.Errorf("Expected item type to be 'lambda-event-source-mapping', got %s", item.GetType()) } if uuid, _ := item.GetAttributes().Get("UUID"); uuid != "test-uuid-1" { t.Errorf("Expected UUID to be 'test-uuid-1', got %s", uuid) } // Test getting non-existent event source mapping _, err = adapter.Get(context.Background(), "123456789012.us-east-1", "non-existent-uuid", false) if err == nil { t.Error("Expected error for non-existent UUID, got nil") } // Test wrong scope _, err = adapter.Get(context.Background(), "wrong-scope", "test-uuid-1", false) if err == nil { t.Error("Expected error for wrong scope, got nil") } } func TestLambdaEventSourceMappingItemMapper(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test mapping with SQS event source awsItem := &types.EventSourceMappingConfiguration{ UUID: new("test-uuid-1"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function"), EventSourceArn: new("arn:aws:sqs:us-east-1:123456789012:test-queue"), State: new("Enabled"), } item, err := adapter.ItemMapper("test-uuid-1", "123456789012.us-east-1", awsItem) if err != nil { t.Errorf("Expected no error, got %v", err) } if item.GetType() != "lambda-event-source-mapping" { t.Errorf("Expected item type to be 'lambda-event-source-mapping', got %s", item.GetType()) } if uuid, _ := item.GetAttributes().Get("UUID"); uuid != "test-uuid-1" { t.Errorf("Expected UUID to be 'test-uuid-1', got %s", uuid) } if functionArn, _ := item.GetAttributes().Get("FunctionArn"); functionArn != "arn:aws:lambda:us-east-1:123456789012:function:test-function" { t.Errorf("Expected FunctionArn to match, got %s", functionArn) } if eventSourceArn, _ := item.GetAttributes().Get("EventSourceArn"); eventSourceArn != "arn:aws:sqs:us-east-1:123456789012:test-queue" { t.Errorf("Expected EventSourceArn to match, got %s", eventSourceArn) } // Check health status if item.Health == nil { t.Error("Expected health to be set") } else if item.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health to be HEALTH_OK, got %v", item.GetHealth()) } // Check linked items if len(item.GetLinkedItemQueries()) != 2 { t.Errorf("Expected 2 linked items, got %d", len(item.GetLinkedItemQueries())) } // Check Lambda function link lambdaLink := item.GetLinkedItemQueries()[0] if lambdaLink.GetQuery().GetType() != "lambda-function" { t.Errorf("Expected Lambda function link type to be 'lambda-function', got %s", lambdaLink.GetQuery().GetType()) } if lambdaLink.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected Lambda function link method to be SEARCH, got %v", lambdaLink.GetQuery().GetMethod()) } // Check SQS queue link sqsLink := item.GetLinkedItemQueries()[1] if sqsLink.GetQuery().GetType() != "sqs-queue" { t.Errorf("Expected SQS queue link type to be 'sqs-queue', got %s", sqsLink.GetQuery().GetType()) } if sqsLink.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected SQS queue link method to be SEARCH, got %v", sqsLink.GetQuery().GetMethod()) } } func TestLambdaEventSourceMappingItemMapperWithDynamoDB(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test mapping with DynamoDB event source awsItem := &types.EventSourceMappingConfiguration{ UUID: new("test-uuid-2"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function-2"), EventSourceArn: new("arn:aws:dynamodb:us-east-1:123456789012:table/test-table"), State: new("Creating"), } item, err := adapter.ItemMapper("test-uuid-2", "123456789012.us-east-1", awsItem) if err != nil { t.Errorf("Expected no error, got %v", err) } // Check DynamoDB table link dynamoLink := item.GetLinkedItemQueries()[1] if dynamoLink.GetQuery().GetType() != "dynamodb-table" { t.Errorf("Expected DynamoDB table link type to be 'dynamodb-table', got %s", dynamoLink.GetQuery().GetType()) } if dynamoLink.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected DynamoDB table link method to be SEARCH, got %v", dynamoLink.GetQuery().GetMethod()) } // Check health status for Creating state if item.Health == nil { t.Error("Expected health to be set") } else if item.GetHealth() != sdp.Health_HEALTH_PENDING { t.Errorf("Expected health to be HEALTH_PENDING, got %v", item.GetHealth()) } } func TestLambdaEventSourceMappingItemMapperWithRDS(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test mapping with RDS/DocumentDB event source awsItem := &types.EventSourceMappingConfiguration{ UUID: new("test-uuid-3"), FunctionArn: new("arn:aws:lambda:us-east-1:123456789012:function:test-function-3"), EventSourceArn: new("arn:aws:rds:us-east-1:123456789012:cluster:test-docdb-cluster"), State: new("Enabled"), } item, err := adapter.ItemMapper("test-uuid-3", "123456789012.us-east-1", awsItem) if err != nil { t.Errorf("Expected no error, got %v", err) } // Check RDS cluster link rdsLink := item.GetLinkedItemQueries()[1] if rdsLink.GetQuery().GetType() != "rds-db-cluster" { t.Errorf("Expected RDS cluster link type to be 'rds-db-cluster', got %s", rdsLink.GetQuery().GetType()) } if rdsLink.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected RDS cluster link method to be SEARCH, got %v", rdsLink.GetQuery().GetMethod()) } // Check health status if item.Health == nil { t.Error("Expected health to be set") } else if item.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health to be HEALTH_OK, got %v", item.GetHealth()) } } func TestLambdaEventSourceMappingSearchByEventSourceARN(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test search by SQS queue ARN sqsQueueARN := "arn:aws:sqs:us-east-1:123456789012:test-queue" items, err := adapter.Search(context.Background(), "123456789012.us-east-1", sqsQueueARN, false) if err != nil { t.Errorf("Expected no error, got %v", err) } if len(items) != 1 { t.Errorf("Expected 1 item, got %d", len(items)) } // The item should have the correct event source ARN if eventSourceArn, _ := items[0].GetAttributes().Get("EventSourceArn"); eventSourceArn != sqsQueueARN { t.Errorf("Expected EventSourceArn '%s', got '%s'", sqsQueueARN, eventSourceArn) } } func TestLambdaEventSourceMappingSearchWrongScope(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test search with wrong scope _, err := adapter.Search(context.Background(), "wrong-scope", "arn:aws:sqs:us-east-1:123456789012:test-queue", false) if err == nil { t.Error("Expected error for wrong scope, got nil") } } func TestLambdaEventSourceMappingAdapterList(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test List items, err := adapter.List(context.Background(), "123456789012.us-east-1", false) if err != nil { t.Errorf("Expected no error, got %v", err) } if len(items) != 3 { t.Errorf("Expected 3 items, got %d", len(items)) } // Verify we get all the expected items expectedUUIDs := []string{"test-uuid-1", "test-uuid-2", "test-uuid-3"} foundUUIDs := make(map[string]bool) for _, item := range items { if uuid, _ := item.GetAttributes().Get("UUID"); uuid != nil { foundUUIDs[uuid.(string)] = true } if item.GetType() != "lambda-event-source-mapping" { t.Errorf("Expected item type to be 'lambda-event-source-mapping', got %s", item.GetType()) } } for _, expectedUUID := range expectedUUIDs { if !foundUUIDs[expectedUUID] { t.Errorf("Expected to find UUID %s in list results", expectedUUID) } } } func TestLambdaEventSourceMappingAdapterListWrongScope(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test List with wrong scope _, err := adapter.List(context.Background(), "wrong-scope", false) if err == nil { t.Error("Expected error for wrong scope, got nil") } } func TestLambdaEventSourceMappingAdapterIntegration(t *testing.T) { adapter := NewLambdaEventSourceMappingAdapter(&TestLambdaEventSourceMappingClient{}, "123456789012", "us-east-1", sdpcache.NewNoOpCache()) // Test Get item, err := adapter.Get(context.Background(), "123456789012.us-east-1", "test-uuid-1", false) if err != nil { t.Errorf("Get failed: %v", err) } if item == nil { t.Error("Get returned nil item") } // Test List items, err := adapter.List(context.Background(), "123456789012.us-east-1", false) if err != nil { t.Errorf("List failed: %v", err) } if len(items) != 3 { t.Errorf("Expected 3 items from list, got %d", len(items)) } // Test Search by event source ARN sqsQueueARN := "arn:aws:sqs:us-east-1:123456789012:test-queue" searchItems, err := adapter.Search(context.Background(), "123456789012.us-east-1", sqsQueueARN, false) if err != nil { t.Errorf("Search by event source ARN failed: %v", err) } if len(searchItems) != 1 { t.Errorf("Expected 1 item from search, got %d", len(searchItems)) } } ================================================ FILE: aws-source/adapters/lambda-function.go ================================================ package adapters import ( "context" "encoding/json" "errors" "net/url" "strings" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type FunctionDetails struct { Code *types.FunctionCodeLocation Concurrency *types.Concurrency Configuration *types.FunctionConfiguration UrlConfigs []*types.FunctionUrlConfig EventInvokeConfigs []*types.FunctionEventInvokeConfig Policy *PolicyDocument Tags map[string]string } // FunctionGetFunc Gets the details of a specific lambda function func functionGetFunc(ctx context.Context, client LambdaClient, scope string, input *lambda.GetFunctionInput) (*sdp.Item, error) { out, err := client.GetFunction(ctx, input) if err != nil { return nil, err } if out.Configuration == nil { return nil, errors.New("function has nil configuration") } if out.Configuration.FunctionName == nil { return nil, errors.New("function has empty name") } function := FunctionDetails{ Code: out.Code, Concurrency: out.Concurrency, Configuration: out.Configuration, Tags: out.Tags, } // Get details of all URL configs urlConfigs := lambda.NewListFunctionUrlConfigsPaginator(client, &lambda.ListFunctionUrlConfigsInput{ FunctionName: out.Configuration.FunctionName, }) for urlConfigs.HasMorePages() { urlOut, err := urlConfigs.NextPage(ctx) if err != nil { return nil, err } for _, config := range urlOut.FunctionUrlConfigs { function.UrlConfigs = append(function.UrlConfigs, &config) } err = ctx.Err() if err != nil { // If the context is done, we should stop processing and return an error, as the results are not complete return nil, err } } // Get details of event configs eventConfigs := lambda.NewListFunctionEventInvokeConfigsPaginator(client, &lambda.ListFunctionEventInvokeConfigsInput{ FunctionName: out.Configuration.FunctionName, }) for eventConfigs.HasMorePages() { eventOut, err := eventConfigs.NextPage(ctx) if err != nil { return nil, err } for _, event := range eventOut.FunctionEventInvokeConfigs { function.EventInvokeConfigs = append(function.EventInvokeConfigs, &event) } err = ctx.Err() if err != nil { // If the context is done, we should stop processing and return an error, as the results are not complete return nil, err } } // Get policies as this is often where triggers are stored policyResponse, err := client.GetPolicy(ctx, &lambda.GetPolicyInput{ FunctionName: out.Configuration.FunctionName, }) var linkedItemQueries []*sdp.LinkedItemQuery if err == nil && policyResponse != nil && policyResponse.Policy != nil { // Try to parse the policy policy := PolicyDocument{} err := json.Unmarshal([]byte(*policyResponse.Policy), &policy) if err == nil { linkedItemQueries = ExtractLinksFromPolicy(&policy) } } attributes, err := ToAttributesWithExclude(function, "resultMetadata") if err != nil { return nil, err } err = attributes.Set("Name", *out.Configuration.FunctionName) if err != nil { return nil, err } item := sdp.Item{ Type: "lambda-function", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, Tags: out.Tags, LinkedItemQueries: linkedItemQueries, } if function.Code != nil { if function.Code.Location != nil { u, err := url.Parse(*function.Code.Location) if err == nil { qps := u.Query() for k := range qps { if strings.HasPrefix(k, "X-Amz-") { qps.Del(k) } } u.RawQuery = qps.Encode() item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: u.String(), Scope: "global", }, }) } } if function.Code.ImageUri != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *function.Code.ImageUri, Scope: "global", }, }) } if function.Code.ResolvedImageUri != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *function.Code.ResolvedImageUri, Scope: "global", }, }) } } var a *ARN if function.Configuration != nil { switch function.Configuration.State { case types.StatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.StateActive: item.Health = sdp.Health_HEALTH_OK.Enum() case types.StateInactive: item.Health = nil case types.StateFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.StateDeactivating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.StateDeactivated: item.Health = nil case types.StateActiveNonInvocable: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.StateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() } if function.Configuration.Role != nil { if a, err = ParseARN(*function.Configuration.Role); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *function.Configuration.Role, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if function.Configuration.DeadLetterConfig != nil { if function.Configuration.DeadLetterConfig.TargetArn != nil { if req, err := GetEventLinkedItem(*function.Configuration.DeadLetterConfig.TargetArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, req) } } } if function.Configuration.Environment != nil { // Automatically extract links from the environment variables newQueries, err := sdp.ExtractLinksFrom(function.Configuration.Environment.Variables) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, newQueries...) } } for _, fsConfig := range function.Configuration.FileSystemConfigs { if fsConfig.Arn != nil { if a, err = ParseARN(*fsConfig.Arn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "efs-access-point", Method: sdp.QueryMethod_SEARCH, Query: *fsConfig.Arn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if function.Configuration.KMSKeyArn != nil { if a, err = ParseARN(*function.Configuration.KMSKeyArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *function.Configuration.KMSKeyArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, layer := range function.Configuration.Layers { if layer.Arn != nil { if a, err = ParseARN(*layer.Arn); err == nil { // Strip the leading "layer:" name := strings.TrimPrefix(a.Resource, "layer:") item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-layer-version", Method: sdp.QueryMethod_GET, Query: name, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if layer.SigningJobArn != nil { if a, err = ParseARN(*layer.SigningJobArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "signer-signing-job", Method: sdp.QueryMethod_SEARCH, Query: *layer.SigningJobArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if layer.SigningProfileVersionArn != nil { if a, err = ParseARN(*layer.SigningProfileVersionArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "signer-signing-profile", Method: sdp.QueryMethod_SEARCH, Query: *layer.SigningProfileVersionArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if function.Configuration.MasterArn != nil { if a, err = ParseARN(*function.Configuration.MasterArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_SEARCH, Query: *function.Configuration.MasterArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if function.Configuration.SigningJobArn != nil { if a, err = ParseARN(*function.Configuration.SigningJobArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "signer-signing-job", Method: sdp.QueryMethod_SEARCH, Query: *function.Configuration.SigningJobArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if function.Configuration.SigningProfileVersionArn != nil { if a, err = ParseARN(*function.Configuration.SigningProfileVersionArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "signer-signing-profile", Method: sdp.QueryMethod_SEARCH, Query: *function.Configuration.SigningProfileVersionArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if function.Configuration.VpcConfig != nil { for _, id := range function.Configuration.VpcConfig.SecurityGroupIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } for _, id := range function.Configuration.VpcConfig.SubnetIds { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: id, Scope: scope, }, }) } if function.Configuration.VpcConfig.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *function.Configuration.VpcConfig.VpcId, Scope: scope, }, }) } } } for _, config := range function.UrlConfigs { if config.FunctionUrl != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *config.FunctionUrl, Scope: "global", }, }) } } for _, config := range function.EventInvokeConfigs { if config.DestinationConfig != nil { if config.DestinationConfig.OnFailure != nil { if config.DestinationConfig.OnFailure.Destination != nil { // Possible links from `GetEventLinkedItem()` lir, err := GetEventLinkedItem(*config.DestinationConfig.OnFailure.Destination) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, lir) } } } if config.DestinationConfig.OnSuccess != nil { if config.DestinationConfig.OnSuccess.Destination != nil { lir, err := GetEventLinkedItem(*config.DestinationConfig.OnSuccess.Destination) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, lir) } } } } } return &item, nil } func ExtractLinksFromPolicy(policy *PolicyDocument) []*sdp.LinkedItemQuery { links := make([]*sdp.LinkedItemQuery, 0) for _, statement := range policy.Statement { var queryType string var scope string method := sdp.QueryMethod_SEARCH switch statement.Principal.Service { case "sns.amazonaws.com": queryType = "sns-topic" method = sdp.QueryMethod_GET case "elasticloadbalancing.amazonaws.com": queryType = "elbv2-target-group" case "vpc-lattice.amazonaws.com": queryType = "vpc-lattice-target-group" case "logs.amazonaws.com": queryType = "logs-log-group" case "events.amazonaws.com": queryType = "events-rule" case "s3.amazonaws.com": // S3 is global and runs in an account scope so we need to extract // that from the policy as the ARN doesn't contain the account that // the bucket is in queryType = "s3-bucket" scope = FormatScope(statement.Condition.StringEquals.AWSSourceAccount, "") default: continue } if scope == "" { // If we don't have a scope set then extract it from the target ARN parsedARN, err := ParseARN(statement.Condition.ArnLike.AWSSourceArn) if err != nil { continue } scope = FormatScope(parsedARN.AccountID, parsedARN.Region) } links = append(links, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: queryType, Method: method, Query: statement.Condition.ArnLike.AWSSourceArn, Scope: scope, }, }) } return links } // GetEventLinkedItem Gets the linked item request for a given destination ARN func GetEventLinkedItem(destinationARN string) (*sdp.LinkedItemQuery, error) { parsed, err := ParseARN(destinationARN) if err != nil { return nil, err } scope := FormatScope(parsed.AccountID, parsed.Region) switch parsed.Service { case "sns": // In this case it's an SNS topic return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sns-topic", Method: sdp.QueryMethod_SEARCH, Query: destinationARN, Scope: scope, }, }, nil case "sqs": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sqs-queue", Method: sdp.QueryMethod_SEARCH, Query: destinationARN, Scope: scope, }, }, nil case "lambda": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_SEARCH, Query: destinationARN, Scope: scope, }, }, nil case "events": return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "events-event-bus", Method: sdp.QueryMethod_SEARCH, Query: destinationARN, Scope: scope, }, }, nil } return nil, errors.New("could not find matching request") } func NewLambdaFunctionAdapter(client LambdaClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*lambda.ListFunctionsInput, *lambda.ListFunctionsOutput, *lambda.GetFunctionInput, *lambda.GetFunctionOutput, LambdaClient, *lambda.Options] { return &AlwaysGetAdapter[*lambda.ListFunctionsInput, *lambda.ListFunctionsOutput, *lambda.GetFunctionInput, *lambda.GetFunctionOutput, LambdaClient, *lambda.Options]{ ItemType: "lambda-function", Client: client, AccountID: accountID, Region: region, ListInput: &lambda.ListFunctionsInput{}, GetFunc: functionGetFunc, AdapterMetadata: lambdaFunctionAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *lambda.GetFunctionInput { return &lambda.GetFunctionInput{ FunctionName: &query, } }, ListFuncPaginatorBuilder: func(client LambdaClient, input *lambda.ListFunctionsInput) Paginator[*lambda.ListFunctionsOutput, *lambda.Options] { return lambda.NewListFunctionsPaginator(client, input) }, ListFuncOutputMapper: func(output *lambda.ListFunctionsOutput, input *lambda.ListFunctionsInput) ([]*lambda.GetFunctionInput, error) { inputs := make([]*lambda.GetFunctionInput, 0, len(output.Functions)) for i := range output.Functions { inputs = append(inputs, &lambda.GetFunctionInput{ FunctionName: output.Functions[i].FunctionName, }) } return inputs, nil }, } } var lambdaFunctionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "lambda-function", DescriptiveName: "Lambda Function", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a lambda function by name", ListDescription: "List all lambda functions", SearchDescription: "Search for lambda functions by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_lambda_function.arn"}, {TerraformQueryMap: "aws_lambda_function_event_invoke_config.id"}, {TerraformQueryMap: "aws_lambda_function_url.function_arn"}, }, PotentialLinks: []string{"iam-role", "s3-bucket", "sns-topic", "sqs-queue", "lambda-function", "events-event-bus", "elbv2-target-group", "vpc-lattice-target-group", "logs-log-group"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/lambda-function_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var testFuncConfig = &types.FunctionConfiguration{ FunctionName: new("aws-controltower-NotificationForwarder"), FunctionArn: new("arn:aws:lambda:eu-west-2:052392120703:function:aws-controltower-NotificationForwarder"), Runtime: types.RuntimePython39, Role: new("arn:aws:iam::052392120703:role/aws-controltower-ForwardSnsNotificationRole"), // link Handler: new("index.lambda_handler"), CodeSize: 473, Description: new("SNS message forwarding function for aggregating account notifications."), Timeout: new(int32(60)), MemorySize: new(int32(128)), LastModified: new("2022-12-13T15:22:48.157+0000"), CodeSha256: new("3zU7iYiZektHRaog6qOFvv34ggadB56rd/UMjnYms6A="), Version: new("$LATEST"), Environment: &types.EnvironmentResponse{ Variables: map[string]string{ "sns_arn": "arn:aws:sns:eu-west-2:347195421325:aws-controltower-AggregateSecurityNotifications", }, }, TracingConfig: &types.TracingConfigResponse{ Mode: types.TracingModePassThrough, }, RevisionId: new("b00dd2e6-eec3-48b0-abf1-f84406e00a3e"), State: types.StateActive, LastUpdateStatus: types.LastUpdateStatusSuccessful, PackageType: types.PackageTypeZip, Architectures: []types.Architecture{ types.ArchitectureX8664, }, EphemeralStorage: &types.EphemeralStorage{ Size: new(int32(512)), }, DeadLetterConfig: &types.DeadLetterConfig{ TargetArn: new("arn:aws:sns:us-east-2:444455556666:MyTopic"), // links }, FileSystemConfigs: []types.FileSystemConfig{ { Arn: new("arn:aws:service:region:account:type/id"), // links LocalMountPath: new("/config"), }, }, ImageConfigResponse: &types.ImageConfigResponse{ Error: &types.ImageConfigError{ ErrorCode: new("500"), Message: new("borked"), }, ImageConfig: &types.ImageConfig{ Command: []string{"echo", "foo"}, EntryPoint: []string{"/bin"}, WorkingDirectory: new("/"), }, }, KMSKeyArn: new("arn:aws:service:region:account:type/id"), // link LastUpdateStatusReason: new("reason"), LastUpdateStatusReasonCode: types.LastUpdateStatusReasonCodeDisabledKMSKey, Layers: []types.Layer{ { Arn: new("arn:aws:service:region:account:layer:name:version"), // link CodeSize: 128, SigningJobArn: new("arn:aws:service:region:account:type/id"), // link SigningProfileVersionArn: new("arn:aws:service:region:account:type/id"), // link }, }, MasterArn: new("arn:aws:service:region:account:type/id"), // link SigningJobArn: new("arn:aws:service:region:account:type/id"), // link SigningProfileVersionArn: new("arn:aws:service:region:account:type/id"), // link SnapStart: &types.SnapStartResponse{ ApplyOn: types.SnapStartApplyOnPublishedVersions, OptimizationStatus: types.SnapStartOptimizationStatusOn, }, StateReason: new("reason"), StateReasonCode: types.StateReasonCodeCreating, VpcConfig: &types.VpcConfigResponse{ SecurityGroupIds: []string{ "id", // link }, SubnetIds: []string{ "id", // link }, VpcId: new("id"), // link }, } var testFuncCode = &types.FunctionCodeLocation{ RepositoryType: new("S3"), Location: new("https://awslambda-eu-west-2-tasks.s3.eu-west-2.amazonaws.com/snapshots/052392120703/aws-controltower-NotificationForwarder-bcea303b-7721-4cf0-b8db-7a0e6dca76dd?versionId=3Lk06tjGEoY451GYYupIohtTV96CkVKC&X-Amz-Security-Token=IQoJb3JpZ2l&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Etc=etcetcetc"), // link ImageUri: new("https://foo"), // link ResolvedImageUri: new("https://foo"), // link } func (t *TestLambdaClient) GetFunction(ctx context.Context, params *lambda.GetFunctionInput, optFns ...func(*lambda.Options)) (*lambda.GetFunctionOutput, error) { return &lambda.GetFunctionOutput{ Configuration: testFuncConfig, Code: testFuncCode, Tags: map[string]string{ "aws:cloudformation:stack-name": "StackSet-AWSControlTowerBP-BASELINE-CLOUDWATCH-6e84f2e0-f223-4b38-ac9c-d7a7ac2e8ef4", "aws:cloudformation:stack-id": "arn:aws:cloudformation:eu-west-2:052392120703:stack/StackSet-AWSControlTowerBP-BASELINE-CLOUDWATCH-6e84f2e0-f223-4b38-ac9c-d7a7ac2e8ef4/f61d15a0-7af9-11ed-a39d-068d53de7052", "aws:cloudformation:logical-id": "ForwardSnsNotification", }, }, nil } func (t *TestLambdaClient) ListFunctionEventInvokeConfigs(context.Context, *lambda.ListFunctionEventInvokeConfigsInput, ...func(*lambda.Options)) (*lambda.ListFunctionEventInvokeConfigsOutput, error) { return &lambda.ListFunctionEventInvokeConfigsOutput{ FunctionEventInvokeConfigs: []types.FunctionEventInvokeConfig{ { DestinationConfig: &types.DestinationConfig{ OnFailure: &types.OnFailure{ Destination: new("arn:aws:events:region:account:event-bus/event-bus-name"), // link }, OnSuccess: &types.OnSuccess{ Destination: new("arn:aws:events:region:account:event-bus/event-bus-name"), // link }, }, FunctionArn: new("arn:aws:service:region:account:type/id"), LastModified: new(time.Now()), MaximumEventAgeInSeconds: new(int32(10)), MaximumRetryAttempts: new(int32(20)), }, }, }, nil } func (t *TestLambdaClient) ListFunctionUrlConfigs(context.Context, *lambda.ListFunctionUrlConfigsInput, ...func(*lambda.Options)) (*lambda.ListFunctionUrlConfigsOutput, error) { return &lambda.ListFunctionUrlConfigsOutput{ FunctionUrlConfigs: []types.FunctionUrlConfig{ { AuthType: types.FunctionUrlAuthTypeNone, CreationTime: new("recently"), FunctionArn: new("arn:aws:service:region:account:type/id"), FunctionUrl: new("https://bar"), // link LastModifiedTime: new("recently"), Cors: &types.Cors{ AllowCredentials: new(true), AllowHeaders: []string{"X-Forwarded-For"}, AllowMethods: []string{"GET"}, AllowOrigins: []string{"https://bar"}, ExposeHeaders: []string{"X-Authentication"}, MaxAge: new(int32(10)), }, }, }, }, nil } func (t *TestLambdaClient) ListFunctions(context.Context, *lambda.ListFunctionsInput, ...func(*lambda.Options)) (*lambda.ListFunctionsOutput, error) { return &lambda.ListFunctionsOutput{ Functions: []types.FunctionConfiguration{ *testFuncConfig, }, }, nil } func (t *TestLambdaClient) GetPolicy(ctx context.Context, params *lambda.GetPolicyInput, optFns ...func(*lambda.Options)) (*lambda.GetPolicyOutput, error) { return &lambda.GetPolicyOutput{ Policy: &testPolicyJSON, }, nil } func TestFunctionGetFunc(t *testing.T) { item, err := functionGetFunc(context.Background(), &TestLambdaClient{}, "foo", &lambda.GetFunctionInput{}) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://awslambda-eu-west-2-tasks.s3.eu-west-2.amazonaws.com/snapshots/052392120703/aws-controltower-NotificationForwarder-bcea303b-7721-4cf0-b8db-7a0e6dca76dd?versionId=3Lk06tjGEoY451GYYupIohtTV96CkVKC", ExpectedScope: "global", }, { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://foo", ExpectedScope: "global", }, { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://foo", ExpectedScope: "global", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::052392120703:role/aws-controltower-ForwardSnsNotificationRole", ExpectedScope: "052392120703", }, { ExpectedType: "sns-topic", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:sns:us-east-2:444455556666:MyTopic", ExpectedScope: "444455556666.us-east-2", }, { ExpectedType: "efs-access-point", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "lambda-layer-version", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "name:version", ExpectedScope: "account.region", }, { ExpectedType: "signer-signing-job", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "signer-signing-profile", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "lambda-function", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "signer-signing-job", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "signer-signing-profile", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "sns-topic", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "arn:aws:sns:eu-west-2:540044833068:example-topic", ExpectedScope: "540044833068.eu-west-2", }, { ExpectedType: "elbv2-target-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:540044833068:targetgroup/lambda-rvaaio9n3auuhnvvvjmp/6f23de9c63bd4653", ExpectedScope: "540044833068.eu-west-2", }, { ExpectedType: "vpc-lattice-target-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:vpc-lattice:eu-west-2:540044833068:targetgroup/tg-0510fc8a1fef35ef0", ExpectedScope: "540044833068.eu-west-2", }, { ExpectedType: "logs-log-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:logs:eu-west-2:540044833068:log-group:/aws/ecs/example:*", ExpectedScope: "540044833068.eu-west-2", }, { ExpectedType: "events-rule", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:events:eu-west-2:540044833068:rule/test", ExpectedScope: "540044833068.eu-west-2", }, { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:s3:::second-example-profound-lamb", ExpectedScope: "540044833068", }, } tests.Execute(t, item) } func TestGetEventLinkedItem(t *testing.T) { type EventLinkedItemTest struct { ARN string ExpectedType string ExpectError bool } tests := []EventLinkedItemTest{ { ARN: "arn:aws:events:region:account:event-bus/event-bus-name", ExpectedType: "events-event-bus", ExpectError: false, }, { ARN: "arn:aws:sqs:us-east-2:444455556666:MyQueue", ExpectedType: "sqs-queue", ExpectError: false, }, { ARN: "arn:aws:sns:us-east-2:444455556666:MyTopic", ExpectedType: "sns-topic", ExpectError: false, }, { ARN: "arn:aws:lambda:eu-west-2:052392120703:function:aws-controltower-NotificationForwarder", ExpectedType: "lambda-function", ExpectError: false, }, { ARN: "something-bad", ExpectError: true, }, } for _, test := range tests { t.Run(test.ARN, func(t *testing.T) { req, err := GetEventLinkedItem(test.ARN) if test.ExpectError { if err == nil { t.Error("expected error but got nil") } } else { if err != nil { t.Error(err) } if req.GetQuery().GetType() != test.ExpectedType { t.Errorf("expected request type to be %v, got %v", test.ExpectedType, req.GetQuery().GetType()) } } }) } } func TestNewLambdaFunctionAdapter(t *testing.T) { client, account, region := lambdaGetAutoConfig(t) adapter := NewLambdaFunctionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/lambda-layer-version.go ================================================ package adapters import ( "context" "fmt" "strconv" "strings" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func layerVersionGetInputMapper(scope, query string) *lambda.GetLayerVersionInput { sections := strings.Split(query, ":") if len(sections) < 2 { return nil } version := sections[len(sections)-1] name := strings.Join(sections[0:len(sections)-1], ":") versionInt, err := strconv.Atoi(version) if err != nil { return nil } return &lambda.GetLayerVersionInput{ LayerName: &name, VersionNumber: new(int64(versionInt)), } } func layerVersionGetFunc(ctx context.Context, client LambdaClient, scope string, input *lambda.GetLayerVersionInput) (*sdp.Item, error) { if input == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "nil input provided to query", Scope: scope, } } out, err := client.GetLayerVersion(ctx, input) if err != nil { return nil, err } attributes, err := ToAttributesWithExclude(out, "resultMetadata") if err != nil { return nil, err } err = attributes.Set("FullName", fmt.Sprintf("%v:%v", *input.LayerName, input.VersionNumber)) if err != nil { return nil, err } item := sdp.Item{ Type: "lambda-layer-version", UniqueAttribute: "FullName", Attributes: attributes, Scope: scope, } var a *ARN if out.Content != nil { if out.Content.SigningJobArn != nil { if a, err = ParseARN(*out.Content.SigningJobArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "signer-signing-job", Method: sdp.QueryMethod_SEARCH, Query: *out.Content.SigningJobArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if out.Content.SigningProfileVersionArn != nil { if a, err = ParseARN(*out.Content.SigningProfileVersionArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "signer-signing-profile", Method: sdp.QueryMethod_SEARCH, Query: *out.Content.SigningProfileVersionArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } return &item, nil } func NewLambdaLayerVersionAdapter(client LambdaClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*lambda.ListLayerVersionsInput, *lambda.ListLayerVersionsOutput, *lambda.GetLayerVersionInput, *lambda.GetLayerVersionOutput, LambdaClient, *lambda.Options] { return &AlwaysGetAdapter[*lambda.ListLayerVersionsInput, *lambda.ListLayerVersionsOutput, *lambda.GetLayerVersionInput, *lambda.GetLayerVersionOutput, LambdaClient, *lambda.Options]{ ItemType: "lambda-layer-version", Client: client, AccountID: accountID, Region: region, DisableList: true, GetInputMapper: layerVersionGetInputMapper, GetFunc: layerVersionGetFunc, ListInput: &lambda.ListLayerVersionsInput{}, AdapterMetadata: layerVersionAdapterMetadata, cache: cache, ListFuncOutputMapper: func(output *lambda.ListLayerVersionsOutput, input *lambda.ListLayerVersionsInput) ([]*lambda.GetLayerVersionInput, error) { return []*lambda.GetLayerVersionInput{}, nil }, ListFuncPaginatorBuilder: func(client LambdaClient, input *lambda.ListLayerVersionsInput) Paginator[*lambda.ListLayerVersionsOutput, *lambda.Options] { return lambda.NewListLayerVersionsPaginator(client, input) }, } } var layerVersionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "lambda-layer-version", DescriptiveName: "Lambda Layer Version", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a layer version by full name ({layerName}:{versionNumber})", SearchDescription: "Search for layer versions by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_lambda_layer_version.arn"}, }, PotentialLinks: []string{"signer-signing-job", "signer-signing-profile"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/lambda-layer-version_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestLayerVersionGetInputMapper(t *testing.T) { tests := []struct { Query string ExpectNil bool }{ { Query: "foo:1", ExpectNil: false, }, { Query: "foo:1:2", ExpectNil: false, }, { Query: "", ExpectNil: true, }, { Query: "bar", ExpectNil: true, }, { Query: ":", ExpectNil: true, }, } for _, test := range tests { t.Run(test.Query, func(t *testing.T) { input := layerVersionGetInputMapper("foo", test.Query) if input == nil && !test.ExpectNil { t.Error("input was nil unexpectedly") } if input != nil && test.ExpectNil { t.Error("input was non-nil when expected to be nil") } }) } } func (t *TestLambdaClient) GetLayerVersion(ctx context.Context, params *lambda.GetLayerVersionInput, optFns ...func(*lambda.Options)) (*lambda.GetLayerVersionOutput, error) { return &lambda.GetLayerVersionOutput{ CompatibleArchitectures: []types.Architecture{ types.ArchitectureArm64, }, CompatibleRuntimes: []types.Runtime{ types.RuntimeDotnet6, }, Content: &types.LayerVersionContentOutput{ CodeSha256: new("sha"), CodeSize: 100, Location: new("somewhere"), SigningJobArn: new("arn:aws:service:region:account:type/id"), SigningProfileVersionArn: new("arn:aws:service:region:account:type/id"), }, CreatedDate: new("YYYY-MM-DDThh:mm:ss.sTZD"), Description: new("description"), LayerArn: new("arn:aws:service:region:account:type/id"), LayerVersionArn: new("arn:aws:service:region:account:type/id"), LicenseInfo: new("info"), Version: *params.VersionNumber, }, nil } func (t *TestLambdaClient) ListLayerVersions(context.Context, *lambda.ListLayerVersionsInput, ...func(*lambda.Options)) (*lambda.ListLayerVersionsOutput, error) { return &lambda.ListLayerVersionsOutput{}, nil } func TestLayerVersionGetFunc(t *testing.T) { item, err := layerVersionGetFunc(context.Background(), &TestLambdaClient{}, "foo", &lambda.GetLayerVersionInput{ LayerName: new("layer"), VersionNumber: new(int64(999)), }) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "signer-signing-job", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "signer-signing-profile", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, } tests.Execute(t, item) } func TestNewLambdaLayerVersionAdapter(t *testing.T) { client, account, region := lambdaGetAutoConfig(t) adapter := NewLambdaLayerVersionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/lambda-layer.go ================================================ package adapters import ( "context" "errors" "fmt" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func layerListFunc(ctx context.Context, client *lambda.Client, scope string) ([]*types.LayersListItem, error) { paginator := lambda.NewListLayersPaginator(client, &lambda.ListLayersInput{}) layers := make([]*types.LayersListItem, 0) for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { return nil, err } for _, layer := range out.Layers { layers = append(layers, &layer) } } return layers, nil } func layerItemMapper(_, scope string, awsItem *types.LayersListItem) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "lambda-layer", UniqueAttribute: "LayerName", Attributes: attributes, Scope: scope, } if awsItem.LatestMatchingVersion != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-layer-version", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("%v:%v", *awsItem.LayerName, awsItem.LatestMatchingVersion.Version), Scope: scope, }, }) } return &item, nil } func NewLambdaLayerAdapter(client *lambda.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.LayersListItem, *lambda.Client, *lambda.Options] { return &GetListAdapter[*types.LayersListItem, *lambda.Client, *lambda.Options]{ ItemType: "lambda-layer", Client: client, AccountID: accountID, Region: region, AdapterMetadata: lambdaLayerAdapterMetadata, cache: cache, GetFunc: func(_ context.Context, _ *lambda.Client, _, _ string) (*types.LayersListItem, error) { // Layers can only be listed return nil, errors.New("get is not supported for lambda-layers") }, ListFunc: layerListFunc, ItemMapper: layerItemMapper, } } var lambdaLayerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "lambda-layer", DescriptiveName: "Lambda Layer", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ List: true, ListDescription: "List all lambda layers", }, PotentialLinks: []string{"lambda-layer-version"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/lambda-layer_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestLayerItemMapper(t *testing.T) { layer := types.LayersListItem{ LatestMatchingVersion: &types.LayerVersionsListItem{ CompatibleArchitectures: []types.Architecture{ types.ArchitectureArm64, types.ArchitectureX8664, }, CompatibleRuntimes: []types.Runtime{ types.RuntimeJava11, }, CreatedDate: new("2018-11-27T15:10:45.123+0000"), Description: new("description"), LayerVersionArn: new("arn:aws:service:region:account:type/id"), LicenseInfo: new("info"), Version: 10, }, LayerArn: new("arn:aws:service:region:account:type/id"), LayerName: new("name"), } item, err := layerItemMapper("", "foo", &layer) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "lambda-layer-version", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "name:10", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewLambdaLayerAdapter(t *testing.T) { client, account, region := lambdaGetAutoConfig(t) adapter := NewLambdaLayerAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipGet: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/lambda.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/lambda" ) // LambdaClient Represents the client we need to talk to Lambda, usually this is // *lambda.Client type LambdaClient interface { GetFunction(ctx context.Context, params *lambda.GetFunctionInput, optFns ...func(*lambda.Options)) (*lambda.GetFunctionOutput, error) GetLayerVersion(ctx context.Context, params *lambda.GetLayerVersionInput, optFns ...func(*lambda.Options)) (*lambda.GetLayerVersionOutput, error) GetPolicy(ctx context.Context, params *lambda.GetPolicyInput, optFns ...func(*lambda.Options)) (*lambda.GetPolicyOutput, error) lambda.ListFunctionEventInvokeConfigsAPIClient lambda.ListFunctionUrlConfigsAPIClient lambda.ListFunctionsAPIClient lambda.ListLayerVersionsAPIClient } // This is derived from the AWS example: // https://github.com/awsdocs/aws-doc-sdk-examples/blob/main/gov2/iam/actions/policies.go#L21C1-L32C2 // and represents the structure of an IAM policy document type PolicyDocument struct { Version string `json:""` Statement []PolicyStatement `json:""` } // PolicyStatement defines a statement in a policy document. type PolicyStatement struct { Action string Principal Principal Condition Condition } type Principal struct { Service string `json:",omitempty"` } type Condition struct { ArnLike ArnLikeCondition StringEquals StringEqualsCondition } type StringEqualsCondition struct { AWSSourceAccount string `json:"AWS:SourceAccount,omitempty"` } type ArnLikeCondition struct { AWSSourceArn string `json:"AWS:SourceArn,omitempty"` } ================================================ FILE: aws-source/adapters/lambda_test.go ================================================ package adapters import ( "encoding/json" "testing" "github.com/aws/aws-sdk-go-v2/service/lambda" ) type TestLambdaClient struct{} func lambdaGetAutoConfig(t *testing.T) (*lambda.Client, string, string) { config, account, region := GetAutoConfig(t) client := lambda.NewFromConfig(config) return client, account, region } var testPolicyJSON string = `{ "Version": "2012-10-17", "Id": "default", "Statement": [ { "Sid": "lambda-191096b5-9db0-4ff2-87ce-d90c8869cb93", "Effect": "Allow", "Principal": { "Service": "sns.amazonaws.com" }, "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:eu-west-2:540044833068:function:example_lambda_function", "Condition": { "ArnLike": { "AWS:SourceArn": "arn:aws:sns:eu-west-2:540044833068:example-topic" } } }, { "Sid": "lambda-e881f390-21ed-4d5a-9e64-50ddb5562873", "Effect": "Allow", "Principal": { "Service": "elasticloadbalancing.amazonaws.com" }, "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:eu-west-2:540044833068:function:test", "Condition": { "ArnLike": { "AWS:SourceArn": "arn:aws:elasticloadbalancing:eu-west-2:540044833068:targetgroup/lambda-rvaaio9n3auuhnvvvjmp/6f23de9c63bd4653" } } }, { "Sid": "lambda-e137420e-640f-47bf-a37f-3f3c3134c110", "Effect": "Allow", "Principal": { "Service": "vpc-lattice.amazonaws.com" }, "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:eu-west-2:540044833068:function:test", "Condition": { "ArnLike": { "AWS:SourceArn": "arn:aws:vpc-lattice:eu-west-2:540044833068:targetgroup/tg-0510fc8a1fef35ef0" } } }, { "Sid": "lambda-945e8a2a-f5d2-4b32-869e-bca6227133b6", "Effect": "Allow", "Principal": { "Service": "logs.amazonaws.com" }, "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:eu-west-2:540044833068:function:test", "Condition": { "StringEquals": { "AWS:SourceAccount": "540044833068" }, "ArnLike": { "AWS:SourceArn": "arn:aws:logs:eu-west-2:540044833068:log-group:/aws/ecs/example:*" } } }, { "Sid": "lambda-1b87395a-6f9a-406d-bc4c-4366044c1a06", "Effect": "Allow", "Principal": { "Service": "events.amazonaws.com" }, "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:eu-west-2:540044833068:function:test", "Condition": { "ArnLike": { "AWS:SourceArn": "arn:aws:events:eu-west-2:540044833068:rule/test" } } }, { "Sid": "lambda-e0070e15-19c9-4e75-8705-075d618113a4", "Effect": "Allow", "Principal": { "Service": "s3.amazonaws.com" }, "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:eu-west-2:540044833068:function:test", "Condition": { "StringEquals": { "AWS:SourceAccount": "540044833068" }, "ArnLike": { "AWS:SourceArn": "arn:aws:s3:::second-example-profound-lamb" } } } ] }` func TestParsePolicy(t *testing.T) { policy := PolicyDocument{} err := json.Unmarshal([]byte(testPolicyJSON), &policy) if err != nil { t.Error(err) } if policy.Version != "2012-10-17" { t.Errorf("Expected Version to be 2012-10-17, got %s", policy.Version) } if len(policy.Statement) != 6 { t.Errorf("Expected 6 statements, got %d", len(policy.Statement)) } if policy.Statement[0].Principal.Service != "sns.amazonaws.com" { t.Errorf("Expected Principal.Service to be sns.amazonaws.com, got %s", policy.Statement[0].Principal.Service) } if policy.Statement[0].Condition.ArnLike.AWSSourceArn != "arn:aws:sns:eu-west-2:540044833068:example-topic" { t.Errorf("Expected Condition.ArnLike.AWSSourceArn to be arn:aws:sns:eu-west-2:540044833068:example-topic, got %s", policy.Statement[0].Condition.ArnLike.AWSSourceArn) } if policy.Statement[1].Principal.Service != "elasticloadbalancing.amazonaws.com" { t.Errorf("Expected Principal.Service to be elasticloadbalancing.amazonaws.com, got %s", policy.Statement[1].Principal.Service) } if policy.Statement[1].Condition.ArnLike.AWSSourceArn != "arn:aws:elasticloadbalancing:eu-west-2:540044833068:targetgroup/lambda-rvaaio9n3auuhnvvvjmp/6f23de9c63bd4653" { t.Errorf("Expected Condition.ArnLike.AWSSourceArn to be arn:aws:elasticloadbalancing:eu-west-2:540044833068:targetgroup/lambda-rvaaio9n3auuhnvvvjmp/6f23de9c63bd4653, got %s", policy.Statement[1].Condition.ArnLike.AWSSourceArn) } if policy.Statement[2].Principal.Service != "vpc-lattice.amazonaws.com" { t.Errorf("Expected Principal.Service to be vpc-lattice.amazonaws.com, got %s", policy.Statement[2].Principal.Service) } if policy.Statement[2].Condition.ArnLike.AWSSourceArn != "arn:aws:vpc-lattice:eu-west-2:540044833068:targetgroup/tg-0510fc8a1fef35ef0" { t.Errorf("Expected Condition.ArnLike.AWSSourceArn to be arn:aws:vpc-lattice:eu-west-2:540044833068:targetgroup/tg-0510fc8a1fef35ef0, got %s", policy.Statement[2].Condition.ArnLike.AWSSourceArn) } if policy.Statement[3].Principal.Service != "logs.amazonaws.com" { t.Errorf("Expected Principal.Service to be logs.amazonaws.com, got %s", policy.Statement[3].Principal.Service) } if policy.Statement[3].Condition.ArnLike.AWSSourceArn != "arn:aws:logs:eu-west-2:540044833068:log-group:/aws/ecs/example:*" { t.Errorf("Expected Condition.ArnLike.AWSSourceArn to be arn:aws:logs:eu-west-2:540044833068:log-group:/aws/ecs/example:*, got %s", policy.Statement[3].Condition.ArnLike.AWSSourceArn) } if policy.Statement[4].Principal.Service != "events.amazonaws.com" { t.Errorf("Expected Principal.Service to be events.amazonaws.com, got %s", policy.Statement[4].Principal.Service) } if policy.Statement[4].Condition.ArnLike.AWSSourceArn != "arn:aws:events:eu-west-2:540044833068:rule/test" { t.Errorf("Expected Condition.ArnLike.AWSSourceArn to be arn:aws:events:eu-west-2:540044833068:rule/test, got %s", policy.Statement[4].Condition.ArnLike.AWSSourceArn) } if policy.Statement[5].Principal.Service != "s3.amazonaws.com" { t.Errorf("Expected Principal.Service to be s3.amazonaws.com, got %s", policy.Statement[5].Principal.Service) } if policy.Statement[5].Condition.ArnLike.AWSSourceArn != "arn:aws:s3:::second-example-profound-lamb" { t.Errorf("Expected Condition.ArnLike.AWSSourceArn to be arn:aws:s3:::second-example-profound-lamb, got %s", policy.Statement[5].Condition.ArnLike.AWSSourceArn) } if policy.Statement[5].Condition.StringEquals.AWSSourceAccount != "540044833068" { t.Errorf("Expected Condition.StringEquals.AWSSourceAccount to be 540044833068, got %s", policy.Statement[5].Condition.StringEquals.AWSSourceAccount) } } ================================================ FILE: aws-source/adapters/main.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" ) var Metadata = sdp.AdapterMetadataList{} ================================================ FILE: aws-source/adapters/network-firewall-firewall-policy.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type unifiedFirewallPolicy struct { types.FirewallPolicyResponse FirewallPolicy *types.FirewallPolicy } func firewallPolicyGetFunc(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeFirewallPolicyInput) (*sdp.Item, error) { resp, err := client.DescribeFirewallPolicy(ctx, input) if err != nil { return nil, err } ufp := unifiedFirewallPolicy{ FirewallPolicyResponse: *resp.FirewallPolicyResponse, FirewallPolicy: resp.FirewallPolicy, } attributes, err := ToAttributesWithExclude(ufp) if err != nil { return nil, err } tags := make(map[string]string) for _, tag := range resp.FirewallPolicyResponse.Tags { tags[*tag.Key] = *tag.Value } var health *sdp.Health if resp.FirewallPolicyResponse != nil { switch resp.FirewallPolicyResponse.FirewallPolicyStatus { case types.ResourceStatusActive: health = sdp.Health_HEALTH_OK.Enum() case types.ResourceStatusDeleting: health = sdp.Health_HEALTH_PENDING.Enum() case types.ResourceStatusError: health = sdp.Health_HEALTH_ERROR.Enum() } } item := sdp.Item{ Type: "network-firewall-firewall-policy", UniqueAttribute: "FirewallPolicyName", Attributes: attributes, Scope: scope, Tags: tags, Health: health, } //+overmind:link kms-key item.LinkedItemQueries = append(item.LinkedItemQueries, encryptionConfigurationLink(ufp.EncryptionConfiguration, scope)) ruleGroupArns := make([]string, 0) for _, ruleGroup := range resp.FirewallPolicy.StatelessRuleGroupReferences { if ruleGroup.ResourceArn != nil { ruleGroupArns = append(ruleGroupArns, *ruleGroup.ResourceArn) } } for _, ruleGroup := range resp.FirewallPolicy.StatefulRuleGroupReferences { if ruleGroup.ResourceArn != nil { ruleGroupArns = append(ruleGroupArns, *ruleGroup.ResourceArn) } } for _, arn := range ruleGroupArns { if a, err := ParseARN(arn); err == nil { //+overmind:link network-firewall-rule-group item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "network-firewall-rule-group", Query: arn, Method: sdp.QueryMethod_SEARCH, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if resp.FirewallPolicy.TLSInspectionConfigurationArn != nil { if a, err := ParseARN(*resp.FirewallPolicy.TLSInspectionConfigurationArn); err == nil { //+overmind:link network-firewall-tls-inspection-configuration item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "network-firewall-tls-inspection-configuration", Method: sdp.QueryMethod_SEARCH, Query: *resp.FirewallPolicy.TLSInspectionConfigurationArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } return &item, nil } func NewNetworkFirewallFirewallPolicyAdapter(client networkFirewallClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*networkfirewall.ListFirewallPoliciesInput, *networkfirewall.ListFirewallPoliciesOutput, *networkfirewall.DescribeFirewallPolicyInput, *networkfirewall.DescribeFirewallPolicyOutput, networkFirewallClient, *networkfirewall.Options] { return &AlwaysGetAdapter[*networkfirewall.ListFirewallPoliciesInput, *networkfirewall.ListFirewallPoliciesOutput, *networkfirewall.DescribeFirewallPolicyInput, *networkfirewall.DescribeFirewallPolicyOutput, networkFirewallClient, *networkfirewall.Options]{ ItemType: "network-firewall-firewall-policy", Client: client, AccountID: accountID, Region: region, ListInput: &networkfirewall.ListFirewallPoliciesInput{}, AdapterMetadata: firewallPolicyAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *networkfirewall.DescribeFirewallPolicyInput { return &networkfirewall.DescribeFirewallPolicyInput{ FirewallPolicyName: &query, } }, SearchGetInputMapper: func(scope, query string) (*networkfirewall.DescribeFirewallPolicyInput, error) { return &networkfirewall.DescribeFirewallPolicyInput{ FirewallPolicyArn: &query, }, nil }, ListFuncPaginatorBuilder: func(client networkFirewallClient, input *networkfirewall.ListFirewallPoliciesInput) Paginator[*networkfirewall.ListFirewallPoliciesOutput, *networkfirewall.Options] { return networkfirewall.NewListFirewallPoliciesPaginator(client, input) }, ListFuncOutputMapper: func(output *networkfirewall.ListFirewallPoliciesOutput, input *networkfirewall.ListFirewallPoliciesInput) ([]*networkfirewall.DescribeFirewallPolicyInput, error) { var inputs []*networkfirewall.DescribeFirewallPolicyInput for _, firewall := range output.FirewallPolicies { inputs = append(inputs, &networkfirewall.DescribeFirewallPolicyInput{ FirewallPolicyArn: firewall.Arn, }) } return inputs, nil }, GetFunc: func(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeFirewallPolicyInput) (*sdp.Item, error) { return firewallPolicyGetFunc(ctx, client, scope, input) }, } } var firewallPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "network-firewall-firewall-policy", DescriptiveName: "Network Firewall Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Network Firewall Policy by name", ListDescription: "List Network Firewall Policies", SearchDescription: "Search for Network Firewall Policies by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkfirewall_firewall_policy.name"}, }, PotentialLinks: []string{"network-firewall-rule-group", "network-firewall-tls-inspection-configuration", "kms-key"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/network-firewall-firewall-policy_test.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" "testing" "time" ) func (c testNetworkFirewallClient) DescribeFirewallPolicy(ctx context.Context, params *networkfirewall.DescribeFirewallPolicyInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeFirewallPolicyOutput, error) { now := time.Now() return &networkfirewall.DescribeFirewallPolicyOutput{ FirewallPolicyResponse: &types.FirewallPolicyResponse{ FirewallPolicyArn: new("arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3"), FirewallPolicyId: new("test"), FirewallPolicyName: new("test"), ConsumedStatefulRuleCapacity: new(int32(1)), ConsumedStatelessRuleCapacity: new(int32(1)), Description: new("test"), EncryptionConfiguration: &types.EncryptionConfiguration{ Type: types.EncryptionTypeAwsOwnedKmsKey, KeyId: new("arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"), // link (this can be an ARN or ID) }, FirewallPolicyStatus: types.ResourceStatusActive, // health LastModifiedTime: &now, NumberOfAssociations: new(int32(1)), Tags: []types.Tag{ { Key: new("test"), Value: new("test"), }, }, }, FirewallPolicy: &types.FirewallPolicy{ StatelessDefaultActions: []string{}, StatelessFragmentDefaultActions: []string{}, PolicyVariables: &types.PolicyVariables{ RuleVariables: map[string]types.IPSet{ "test": { Definition: []string{}, }, }, }, StatefulDefaultActions: []string{}, StatefulEngineOptions: &types.StatefulEngineOptions{ RuleOrder: types.RuleOrderDefaultActionOrder, StreamExceptionPolicy: types.StreamExceptionPolicyContinue, }, StatefulRuleGroupReferences: []types.StatefulRuleGroupReference{ { ResourceArn: new("arn:aws:network-firewall:us-east-1:123456789012:stateful-rulegroup/aws-network-firewall-DefaultStatefulRuleGroup-1J3Z3W2ZQXV3"), // link Override: &types.StatefulRuleGroupOverride{ Action: types.OverrideActionDropToAlert, }, Priority: new(int32(1)), }, }, StatelessCustomActions: []types.CustomAction{ { ActionDefinition: &types.ActionDefinition{ PublishMetricAction: &types.PublishMetricAction{ Dimensions: []types.Dimension{}, }, }, ActionName: new("test"), }, }, StatelessRuleGroupReferences: []types.StatelessRuleGroupReference{ { Priority: new(int32(1)), ResourceArn: new("arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3"), // link }, }, TLSInspectionConfigurationArn: new("arn:aws:network-firewall:us-east-1:123456789012:tls-inspection-configuration/aws-network-firewall-DefaultTlsInspectionConfiguration-1J3Z3W2ZQXV3"), // link }, }, nil } func (c testNetworkFirewallClient) ListFirewallPolicies(context.Context, *networkfirewall.ListFirewallPoliciesInput, ...func(*networkfirewall.Options)) (*networkfirewall.ListFirewallPoliciesOutput, error) { return &networkfirewall.ListFirewallPoliciesOutput{ FirewallPolicies: []types.FirewallPolicyMetadata{ { Arn: new("arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3"), }, }, }, nil } func TestFirewallPolicyGetFunc(t *testing.T) { item, err := firewallPolicyGetFunc(context.Background(), testNetworkFirewallClient{}, "test", &networkfirewall.DescribeFirewallPolicyInput{}) if err != nil { t.Fatal(err) } if err := item.Validate(); err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "network-firewall-rule-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:network-firewall:us-east-1:123456789012:stateful-rulegroup/aws-network-firewall-DefaultStatefulRuleGroup-1J3Z3W2ZQXV3", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "network-firewall-rule-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "network-firewall-tls-inspection-configuration", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:network-firewall:us-east-1:123456789012:tls-inspection-configuration/aws-network-firewall-DefaultTlsInspectionConfiguration-1J3Z3W2ZQXV3", ExpectedScope: "123456789012.us-east-1", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/network-firewall-firewall.go ================================================ package adapters import ( "context" "sync" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type unifiedFirewall struct { Name string Properties *types.Firewall Status *types.FirewallStatus LoggingConfiguration *types.LoggingConfiguration ResourcePolicy *string } func firewallGetFunc(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeFirewallInput) (*sdp.Item, error) { response, err := client.DescribeFirewall(ctx, input) if err != nil { return nil, err } if response == nil || response.Firewall == nil || response.Firewall.FirewallName == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "Firewall was nil", Scope: scope, } } uf := unifiedFirewall{ Name: *response.Firewall.FirewallName, Properties: response.Firewall, Status: response.FirewallStatus, } // Enrich with more info var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() resp, _ := client.DescribeLoggingConfiguration(ctx, &networkfirewall.DescribeLoggingConfigurationInput{ FirewallArn: response.Firewall.FirewallArn, }) if resp != nil { uf.LoggingConfiguration = resp.LoggingConfiguration } }() go func() { defer wg.Done() resp, _ := client.DescribeResourcePolicy(ctx, &networkfirewall.DescribeResourcePolicyInput{ ResourceArn: response.Firewall.FirewallArn, }) if resp != nil { uf.ResourcePolicy = resp.Policy } }() wg.Wait() attributes, err := ToAttributesWithExclude(uf) if err != nil { return nil, err } var health *sdp.Health if response.FirewallStatus != nil { switch response.FirewallStatus.Status { case types.FirewallStatusValueDeleting: health = sdp.Health_HEALTH_PENDING.Enum() case types.FirewallStatusValueProvisioning: health = sdp.Health_HEALTH_PENDING.Enum() case types.FirewallStatusValueReady: health = sdp.Health_HEALTH_OK.Enum() } } tags := make(map[string]string) for _, tag := range response.Firewall.Tags { tags[*tag.Key] = *tag.Value } item := sdp.Item{ Type: "network-firewall-firewall", UniqueAttribute: "Name", Scope: scope, Attributes: attributes, Health: health, Tags: tags, } config := response.Firewall if uf.LoggingConfiguration != nil { for _, config := range uf.LoggingConfiguration.LogDestinationConfigs { switch config.LogDestinationType { case types.LogDestinationTypeCloudwatchLogs: logGroup, ok := config.LogDestination["logGroup"] if ok { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "logs-log-group", Method: sdp.QueryMethod_GET, Query: logGroup, Scope: scope, }, }) } case types.LogDestinationTypeS3: bucketName, ok := config.LogDestination["bucketName"] if ok { //+overmind:link s3-bucket item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "s3-bucket", Method: sdp.QueryMethod_GET, Query: bucketName, Scope: scope, }, }) } case types.LogDestinationTypeKinesisDataFirehose: deliveryStream, ok := config.LogDestination["deliveryStream"] if ok { //+overmind:link firehose-delivery-stream item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "firehose-delivery-stream", Method: sdp.QueryMethod_GET, Query: deliveryStream, Scope: scope, }, }) } } } } if uf.ResourcePolicy != nil { //+overmind:link iam-policy item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-policy", Method: sdp.QueryMethod_GET, Query: *uf.ResourcePolicy, Scope: scope, }, }) } if config.FirewallPolicyArn != nil { if a, err := ParseARN(*config.FirewallPolicyArn); err == nil { //+overmind:link network-firewall-firewall-policy item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "network-firewall-firewall-policy", Method: sdp.QueryMethod_SEARCH, Query: *config.FirewallPolicyArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, mapping := range config.SubnetMappings { if mapping.SubnetId != nil { //+overmind:link ec2-subnet item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *mapping.SubnetId, Scope: scope, }, }) } } if config.VpcId != nil { //+overmind:link ec2-vpc item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *config.VpcId, Scope: scope, }, }) } //+overmind:link kms-key item.LinkedItemQueries = append(item.LinkedItemQueries, encryptionConfigurationLink(config.EncryptionConfiguration, scope)) for _, state := range response.FirewallStatus.SyncStates { if state.Attachment != nil && state.Attachment.SubnetId != nil { //+overmind:link ec2-subnet item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *state.Attachment.SubnetId, Scope: scope, }, }) } } return &item, nil } func NewNetworkFirewallFirewallAdapter(client networkFirewallClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*networkfirewall.ListFirewallsInput, *networkfirewall.ListFirewallsOutput, *networkfirewall.DescribeFirewallInput, *networkfirewall.DescribeFirewallOutput, networkFirewallClient, *networkfirewall.Options] { return &AlwaysGetAdapter[*networkfirewall.ListFirewallsInput, *networkfirewall.ListFirewallsOutput, *networkfirewall.DescribeFirewallInput, *networkfirewall.DescribeFirewallOutput, networkFirewallClient, *networkfirewall.Options]{ ItemType: "network-firewall-firewall", Client: client, AccountID: accountID, Region: region, ListInput: &networkfirewall.ListFirewallsInput{}, AdapterMetadata: networkFirewallFirewallAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *networkfirewall.DescribeFirewallInput { return &networkfirewall.DescribeFirewallInput{ FirewallName: &query, } }, SearchGetInputMapper: func(scope, query string) (*networkfirewall.DescribeFirewallInput, error) { return &networkfirewall.DescribeFirewallInput{ FirewallArn: &query, }, nil }, ListFuncPaginatorBuilder: func(client networkFirewallClient, input *networkfirewall.ListFirewallsInput) Paginator[*networkfirewall.ListFirewallsOutput, *networkfirewall.Options] { return networkfirewall.NewListFirewallsPaginator(client, input) }, ListFuncOutputMapper: func(output *networkfirewall.ListFirewallsOutput, input *networkfirewall.ListFirewallsInput) ([]*networkfirewall.DescribeFirewallInput, error) { var inputs []*networkfirewall.DescribeFirewallInput for _, firewall := range output.Firewalls { inputs = append(inputs, &networkfirewall.DescribeFirewallInput{ FirewallArn: firewall.FirewallArn, }) } return inputs, nil }, GetFunc: func(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeFirewallInput) (*sdp.Item, error) { return firewallGetFunc(ctx, client, scope, input) }, } } var networkFirewallFirewallAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "network-firewall-firewall", DescriptiveName: "Network Firewall", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Network Firewall by name", ListDescription: "List Network Firewalls", SearchDescription: "Search for Network Firewalls by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkfirewall_firewall.name"}, }, PotentialLinks: []string{"network-firewall-firewall-policy", "ec2-subnet", "ec2-vpc", "logs-log-group", "s3-bucket", "firehose-delivery-stream", "iam-policy", "kms-key"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/network-firewall-firewall_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" ) func (c testNetworkFirewallClient) DescribeFirewall(ctx context.Context, params *networkfirewall.DescribeFirewallInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeFirewallOutput, error) { return &networkfirewall.DescribeFirewallOutput{ Firewall: &types.Firewall{ FirewallId: new("test"), FirewallPolicyArn: new("arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3"), // link SubnetMappings: []types.SubnetMapping{ { SubnetId: new("subnet-12345678901234567"), // link IPAddressType: types.IPAddressTypeIpv4, }, }, VpcId: new("vpc-12345678901234567"), // link DeleteProtection: false, Description: new("test"), EncryptionConfiguration: &types.EncryptionConfiguration{ Type: types.EncryptionTypeAwsOwnedKmsKey, KeyId: new("arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"), // link (this can be an ARN or ID) }, FirewallArn: new("arn:aws:network-firewall:us-east-1:123456789012:firewall/aws-network-firewall-DefaultFirewall-1J3Z3W2ZQXV3"), FirewallName: new("test"), FirewallPolicyChangeProtection: false, SubnetChangeProtection: false, Tags: []types.Tag{ { Key: new("test"), Value: new("test"), }, }, }, FirewallStatus: &types.FirewallStatus{ ConfigurationSyncStateSummary: types.ConfigurationSyncStateInSync, Status: types.FirewallStatusValueDeleting, CapacityUsageSummary: &types.CapacityUsageSummary{ CIDRs: &types.CIDRSummary{ AvailableCIDRCount: new(int32(1)), IPSetReferences: map[string]types.IPSetMetadata{ "test": { ResolvedCIDRCount: new(int32(1)), }, }, UtilizedCIDRCount: new(int32(1)), }, }, SyncStates: map[string]types.SyncState{ "test": { Attachment: &types.Attachment{ EndpointId: new("test"), Status: types.AttachmentStatusCreating, StatusMessage: new("test"), SubnetId: new("test"), // link, }, }, }, }, }, nil } func (c testNetworkFirewallClient) DescribeLoggingConfiguration(ctx context.Context, params *networkfirewall.DescribeLoggingConfigurationInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeLoggingConfigurationOutput, error) { return &networkfirewall.DescribeLoggingConfigurationOutput{ FirewallArn: new("arn:aws:network-firewall:us-east-1:123456789012:firewall/aws-network-firewall-DefaultFirewall-1J3Z3W2ZQXV3"), LoggingConfiguration: &types.LoggingConfiguration{ LogDestinationConfigs: []types.LogDestinationConfig{ { LogDestination: map[string]string{ "bucketName": "DOC-EXAMPLE-BUCKET", // link "prefix": "alerts", }, LogDestinationType: types.LogDestinationTypeS3, LogType: types.LogTypeAlert, }, { LogDestinationType: types.LogDestinationTypeCloudwatchLogs, LogDestination: map[string]string{ "logGroup": "alert-log-group", // link }, LogType: types.LogTypeAlert, }, { LogDestinationType: types.LogDestinationTypeKinesisDataFirehose, LogDestination: map[string]string{ "deliveryStream": "alert-delivery-stream", // link }, LogType: types.LogTypeAlert, }, }, }, }, nil } func (c testNetworkFirewallClient) DescribeResourcePolicy(ctx context.Context, params *networkfirewall.DescribeResourcePolicyInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeResourcePolicyOutput, error) { return &networkfirewall.DescribeResourcePolicyOutput{ Policy: new("test"), // link }, nil } func (c testNetworkFirewallClient) ListFirewalls(context.Context, *networkfirewall.ListFirewallsInput, ...func(*networkfirewall.Options)) (*networkfirewall.ListFirewallsOutput, error) { return &networkfirewall.ListFirewallsOutput{ Firewalls: []types.FirewallMetadata{ { FirewallArn: new("arn:aws:network-firewall:us-east-1:123456789012:firewall/aws-network-firewall-DefaultFirewall-1J3Z3W2ZQXV3"), }, }, }, nil } func TestFirewallGetFunc(t *testing.T) { item, err := firewallGetFunc(context.Background(), testNetworkFirewallClient{}, "test", &networkfirewall.DescribeFirewallInput{}) if err != nil { t.Fatal(err) } if err := item.Validate(); err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-12345678901234567", ExpectedScope: "test", }, { ExpectedType: "network-firewall-firewall-policy", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-12345678901234567", ExpectedScope: "test", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test", ExpectedScope: "test", }, { ExpectedType: "logs-log-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "alert-log-group", ExpectedScope: "test", }, { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "DOC-EXAMPLE-BUCKET", ExpectedScope: "test", }, { ExpectedType: "firehose-delivery-stream", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "alert-delivery-stream", ExpectedScope: "test", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/network-firewall-rule-group.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type unifiedRuleGroup struct { Name string Properties *types.RuleGroupResponse RuleGroup *types.RuleGroup } func ruleGroupGetFunc(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeRuleGroupInput) (*sdp.Item, error) { resp, err := client.DescribeRuleGroup(ctx, input) if err != nil { return nil, err } if resp.RuleGroupResponse == nil || resp.RuleGroup == nil { return nil, errors.New("empty response") } urg := unifiedRuleGroup{ Name: *resp.RuleGroupResponse.RuleGroupName, Properties: resp.RuleGroupResponse, RuleGroup: resp.RuleGroup, } attributes, err := ToAttributesWithExclude(urg) if err != nil { return nil, err } tags := make(map[string]string) for _, tag := range resp.RuleGroupResponse.Tags { tags[*tag.Key] = *tag.Value } var health *sdp.Health switch resp.RuleGroupResponse.RuleGroupStatus { case types.ResourceStatusActive: health = sdp.Health_HEALTH_OK.Enum() case types.ResourceStatusDeleting: health = sdp.Health_HEALTH_PENDING.Enum() case types.ResourceStatusError: health = sdp.Health_HEALTH_ERROR.Enum() } item := sdp.Item{ Type: "network-firewall-rule-group", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, Tags: tags, Health: health, } //+overmind:link kms-key item.LinkedItemQueries = append(item.LinkedItemQueries, encryptionConfigurationLink(resp.RuleGroupResponse.EncryptionConfiguration, scope)) if resp.RuleGroupResponse.SnsTopic != nil { if a, err := ParseARN(*resp.RuleGroupResponse.SnsTopic); err == nil { //+overmind:link sns-topic item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sns-topic", Method: sdp.QueryMethod_SEARCH, Query: *resp.RuleGroupResponse.SnsTopic, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if resp.RuleGroupResponse.SourceMetadata != nil && resp.RuleGroupResponse.SourceMetadata.SourceArn != nil { if a, err := ParseARN(*resp.RuleGroupResponse.SourceMetadata.SourceArn); err == nil { //+overmind:link network-firewall-rule-group item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "network-firewall-rule-group", Method: sdp.QueryMethod_SEARCH, Query: *resp.RuleGroupResponse.SourceMetadata.SourceArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } return &item, nil } func NewNetworkFirewallRuleGroupAdapter(client networkFirewallClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*networkfirewall.ListRuleGroupsInput, *networkfirewall.ListRuleGroupsOutput, *networkfirewall.DescribeRuleGroupInput, *networkfirewall.DescribeRuleGroupOutput, networkFirewallClient, *networkfirewall.Options] { return &AlwaysGetAdapter[*networkfirewall.ListRuleGroupsInput, *networkfirewall.ListRuleGroupsOutput, *networkfirewall.DescribeRuleGroupInput, *networkfirewall.DescribeRuleGroupOutput, networkFirewallClient, *networkfirewall.Options]{ ItemType: "network-firewall-rule-group", Client: client, AccountID: accountID, Region: region, ListInput: &networkfirewall.ListRuleGroupsInput{}, AdapterMetadata: ruleGroupAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *networkfirewall.DescribeRuleGroupInput { return &networkfirewall.DescribeRuleGroupInput{ RuleGroupName: &query, } }, SearchGetInputMapper: func(scope, query string) (*networkfirewall.DescribeRuleGroupInput, error) { return &networkfirewall.DescribeRuleGroupInput{ RuleGroupArn: &query, }, nil }, ListFuncPaginatorBuilder: func(client networkFirewallClient, input *networkfirewall.ListRuleGroupsInput) Paginator[*networkfirewall.ListRuleGroupsOutput, *networkfirewall.Options] { return networkfirewall.NewListRuleGroupsPaginator(client, input) }, ListFuncOutputMapper: func(output *networkfirewall.ListRuleGroupsOutput, input *networkfirewall.ListRuleGroupsInput) ([]*networkfirewall.DescribeRuleGroupInput, error) { var inputs []*networkfirewall.DescribeRuleGroupInput for _, rg := range output.RuleGroups { inputs = append(inputs, &networkfirewall.DescribeRuleGroupInput{ RuleGroupArn: rg.Arn, }) } return inputs, nil }, GetFunc: func(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeRuleGroupInput) (*sdp.Item, error) { return ruleGroupGetFunc(ctx, client, scope, input) }, } } var ruleGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "network-firewall-rule-group", DescriptiveName: "Network Firewall Rule Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Network Firewall Rule Group by name", ListDescription: "List Network Firewall Rule Groups", SearchDescription: "Search for Network Firewall Rule Groups by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkfirewall_rule_group.name"}, }, PotentialLinks: []string{"kms-key", "sns-topic", "network-firewall-rule-group"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) ================================================ FILE: aws-source/adapters/network-firewall-rule-group_test.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" "testing" "time" ) func (c testNetworkFirewallClient) DescribeRuleGroup(ctx context.Context, params *networkfirewall.DescribeRuleGroupInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeRuleGroupOutput, error) { now := time.Now() return &networkfirewall.DescribeRuleGroupOutput{ RuleGroupResponse: &types.RuleGroupResponse{ RuleGroupArn: new("arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3"), RuleGroupId: new("test"), RuleGroupName: new("test"), AnalysisResults: []types.AnalysisResult{ { AnalysisDetail: new("test"), IdentifiedRuleIds: []string{ "test", }, IdentifiedType: types.IdentifiedTypeStatelessRuleContainsTcpFlags, }, }, Capacity: new(int32(1)), ConsumedCapacity: new(int32(1)), Description: new("test"), EncryptionConfiguration: &types.EncryptionConfiguration{ Type: types.EncryptionTypeAwsOwnedKmsKey, KeyId: new("arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"), // link (this can be an ARN or ID) }, LastModifiedTime: &now, NumberOfAssociations: new(int32(1)), RuleGroupStatus: types.ResourceStatusActive, // health SnsTopic: new("arn:aws:sns:us-east-1:123456789012:aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3"), // link SourceMetadata: &types.SourceMetadata{ SourceArn: new("arn:aws:network-firewall:us-east-1:123456789012:firewall/aws-network-firewall-DefaultFirewall-1J3Z3W2ZQXV3"), // link SourceUpdateToken: new("test"), }, Tags: []types.Tag{ { Key: new("test"), Value: new("test"), }, }, Type: types.RuleGroupTypeStateless, }, RuleGroup: &types.RuleGroup{ RulesSource: &types.RulesSource{ RulesSourceList: &types.RulesSourceList{ GeneratedRulesType: types.GeneratedRulesTypeAllowlist, TargetTypes: []types.TargetType{ types.TargetTypeHttpHost, }, Targets: []string{ "foo.bar.com", // link }, }, RulesString: new("test"), StatefulRules: []types.StatefulRule{ { Action: types.StatefulActionAlert, Header: &types.Header{ Destination: new("1.1.1.1"), DestinationPort: new("8080"), Direction: types.StatefulRuleDirectionForward, Protocol: types.StatefulRuleProtocolDcerpc, Source: new("test"), SourcePort: new("8080"), }, }, }, StatelessRulesAndCustomActions: &types.StatelessRulesAndCustomActions{ StatelessRules: []types.StatelessRule{ { Priority: new(int32(1)), RuleDefinition: &types.RuleDefinition{ Actions: []string{}, MatchAttributes: &types.MatchAttributes{ DestinationPorts: []types.PortRange{ { FromPort: 1, ToPort: 1, }, }, Destinations: []types.Address{ { AddressDefinition: new("1.1.1.1/1"), }, }, Protocols: []int32{1}, SourcePorts: []types.PortRange{ { FromPort: 1, ToPort: 1, }, }, Sources: []types.Address{}, TCPFlags: []types.TCPFlagField{ { Flags: []types.TCPFlag{ types.TCPFlagAck, }, Masks: []types.TCPFlag{ types.TCPFlagEce, }, }, }, }, }, }, }, CustomActions: []types.CustomAction{ { ActionDefinition: &types.ActionDefinition{ PublishMetricAction: &types.PublishMetricAction{ Dimensions: []types.Dimension{ { Value: new("test"), }, }, }, }, ActionName: new("test"), }, }, }, }, }, }, nil } func (c testNetworkFirewallClient) ListRuleGroups(ctx context.Context, params *networkfirewall.ListRuleGroupsInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.ListRuleGroupsOutput, error) { return &networkfirewall.ListRuleGroupsOutput{ RuleGroups: []types.RuleGroupMetadata{ { Arn: new("arn:aws:network-firewall:us-east-1:123456789012:stateless-rulegroup/aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3"), }, }, }, nil } func TestRuleGroupGetFunc(t *testing.T) { item, err := ruleGroupGetFunc(context.Background(), testNetworkFirewallClient{}, "test", &networkfirewall.DescribeRuleGroupInput{}) if err != nil { t.Fatal(err) } if err := item.Validate(); err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "sns-topic", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:sns:us-east-1:123456789012:aws-network-firewall-DefaultStatelessRuleGroup-1J3Z3W2ZQXV3", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "network-firewall-rule-group", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:network-firewall:us-east-1:123456789012:firewall/aws-network-firewall-DefaultFirewall-1J3Z3W2ZQXV3", ExpectedScope: "123456789012.us-east-1", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/network-firewall-tls-inspection-configuration.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type unifiedTLSInspectionConfiguration struct { Name string Properties *types.TLSInspectionConfigurationResponse TLSInspectionConfiguration *types.TLSInspectionConfiguration } func tlsInspectionConfigurationGetFunc(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeTLSInspectionConfigurationInput) (*sdp.Item, error) { resp, err := client.DescribeTLSInspectionConfiguration(ctx, input) if err != nil { return nil, err } if resp == nil || resp.TLSInspectionConfiguration == nil || resp.TLSInspectionConfigurationResponse == nil || resp.TLSInspectionConfigurationResponse.TLSInspectionConfigurationName == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "TLSInspectionConfiguration was nil", Scope: scope, } } utic := unifiedTLSInspectionConfiguration{ Name: *resp.TLSInspectionConfigurationResponse.TLSInspectionConfigurationName, Properties: resp.TLSInspectionConfigurationResponse, TLSInspectionConfiguration: resp.TLSInspectionConfiguration, } attributes, err := ToAttributesWithExclude(utic) if err != nil { return nil, err } tags := make(map[string]string) for _, tag := range resp.TLSInspectionConfigurationResponse.Tags { tags[*tag.Key] = *tag.Value } var health *sdp.Health switch resp.TLSInspectionConfigurationResponse.TLSInspectionConfigurationStatus { case types.ResourceStatusActive: health = sdp.Health_HEALTH_OK.Enum() case types.ResourceStatusDeleting: health = sdp.Health_HEALTH_PENDING.Enum() case types.ResourceStatusError: health = sdp.Health_HEALTH_ERROR.Enum() } item := sdp.Item{ Type: "network-firewall-tls-inspection-configuration", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, Tags: tags, Health: health, } if utic.Properties.CertificateAuthority != nil { if utic.Properties.CertificateAuthority.CertificateArn != nil { if a, err := ParseARN(*utic.Properties.CertificateAuthority.CertificateArn); err == nil { //+overmind:link acm-pca-certificate-authority-certificate item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-pca-certificate-authority-certificate", Method: sdp.QueryMethod_SEARCH, Query: *utic.Properties.CertificateAuthority.CertificateArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } for _, cert := range utic.Properties.Certificates { if cert.CertificateArn != nil { if a, err := ParseARN(*cert.CertificateArn); err == nil { //+overmind:link acm-certificate item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_SEARCH, Query: *cert.CertificateArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } item.LinkedItemQueries = append(item.LinkedItemQueries, encryptionConfigurationLink(utic.Properties.EncryptionConfiguration, scope)) for _, config := range utic.TLSInspectionConfiguration.ServerCertificateConfigurations { if config.CertificateAuthorityArn != nil { if a, err := ParseARN(*config.CertificateAuthorityArn); err == nil { //+overmind:link acm-pca-certificate-authority item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-pca-certificate-authority", Method: sdp.QueryMethod_SEARCH, Query: *config.CertificateAuthorityArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, serverCert := range config.ServerCertificates { if serverCert.ResourceArn != nil { if a, err := ParseARN(*serverCert.ResourceArn); err == nil { //+overmind:link acm-certificate item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "acm-certificate", Method: sdp.QueryMethod_SEARCH, Query: *serverCert.ResourceArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } return &item, nil } func NewNetworkFirewallTLSInspectionConfigurationAdapter(client networkFirewallClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*networkfirewall.ListTLSInspectionConfigurationsInput, *networkfirewall.ListTLSInspectionConfigurationsOutput, *networkfirewall.DescribeTLSInspectionConfigurationInput, *networkfirewall.DescribeTLSInspectionConfigurationOutput, networkFirewallClient, *networkfirewall.Options] { return &AlwaysGetAdapter[*networkfirewall.ListTLSInspectionConfigurationsInput, *networkfirewall.ListTLSInspectionConfigurationsOutput, *networkfirewall.DescribeTLSInspectionConfigurationInput, *networkfirewall.DescribeTLSInspectionConfigurationOutput, networkFirewallClient, *networkfirewall.Options]{ ItemType: "network-firewall-tls-inspection-configuration", Client: client, AccountID: accountID, Region: region, ListInput: &networkfirewall.ListTLSInspectionConfigurationsInput{}, AdapterMetadata: tlsInspectionConfigurationAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *networkfirewall.DescribeTLSInspectionConfigurationInput { return &networkfirewall.DescribeTLSInspectionConfigurationInput{ TLSInspectionConfigurationName: &query, } }, SearchGetInputMapper: func(scope, query string) (*networkfirewall.DescribeTLSInspectionConfigurationInput, error) { return &networkfirewall.DescribeTLSInspectionConfigurationInput{ TLSInspectionConfigurationArn: &query, }, nil }, ListFuncPaginatorBuilder: func(client networkFirewallClient, input *networkfirewall.ListTLSInspectionConfigurationsInput) Paginator[*networkfirewall.ListTLSInspectionConfigurationsOutput, *networkfirewall.Options] { return networkfirewall.NewListTLSInspectionConfigurationsPaginator(client, input) }, ListFuncOutputMapper: func(output *networkfirewall.ListTLSInspectionConfigurationsOutput, input *networkfirewall.ListTLSInspectionConfigurationsInput) ([]*networkfirewall.DescribeTLSInspectionConfigurationInput, error) { var inputs []*networkfirewall.DescribeTLSInspectionConfigurationInput for _, rg := range output.TLSInspectionConfigurations { inputs = append(inputs, &networkfirewall.DescribeTLSInspectionConfigurationInput{ TLSInspectionConfigurationArn: rg.Arn, }) } return inputs, nil }, GetFunc: func(ctx context.Context, client networkFirewallClient, scope string, input *networkfirewall.DescribeTLSInspectionConfigurationInput) (*sdp.Item, error) { return tlsInspectionConfigurationGetFunc(ctx, client, scope, input) }, } } var tlsInspectionConfigurationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "network-firewall-tls-inspection-configuration", DescriptiveName: "Network Firewall TLS Inspection Configuration", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Network Firewall TLS Inspection Configuration by name", ListDescription: "List Network Firewall TLS Inspection Configurations", SearchDescription: "Search for Network Firewall TLS Inspection Configurations by ARN", }, PotentialLinks: []string{"acm-certificate", "acm-pca-certificate-authority", "acm-pca-certificate-authority-certificate", "network-firewall-encryption-configuration"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/network-firewall-tls-inspection-configuration_test.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" "testing" "time" ) func (c testNetworkFirewallClient) DescribeTLSInspectionConfiguration(ctx context.Context, params *networkfirewall.DescribeTLSInspectionConfigurationInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeTLSInspectionConfigurationOutput, error) { now := time.Now() return &networkfirewall.DescribeTLSInspectionConfigurationOutput{ TLSInspectionConfigurationResponse: &types.TLSInspectionConfigurationResponse{ TLSInspectionConfigurationArn: new("arn:aws:network-firewall:us-east-1:123456789012:tls-inspection-configuration/aws-network-firewall-DefaultTLSInspectionConfiguration-1J3Z3W2ZQXV3"), TLSInspectionConfigurationId: new("test"), TLSInspectionConfigurationName: new("test"), CertificateAuthority: &types.TlsCertificateData{ CertificateArn: new("arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"), // link CertificateSerial: new("test"), Status: new("OK"), StatusMessage: new("test"), }, Certificates: []types.TlsCertificateData{ { CertificateArn: new("arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"), // link CertificateSerial: new("test"), Status: new("OK"), StatusMessage: new("test"), }, }, Description: new("test"), EncryptionConfiguration: &types.EncryptionConfiguration{ Type: types.EncryptionTypeAwsOwnedKmsKey, KeyId: new("arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"), // link (this can be an ARN or ID) }, LastModifiedTime: &now, NumberOfAssociations: new(int32(1)), TLSInspectionConfigurationStatus: types.ResourceStatusActive, // health Tags: []types.Tag{ { Key: new("test"), Value: new("test"), }, }, }, TLSInspectionConfiguration: &types.TLSInspectionConfiguration{ ServerCertificateConfigurations: []types.ServerCertificateConfiguration{ { CertificateAuthorityArn: new("arn:aws:acm:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012"), // link CheckCertificateRevocationStatus: &types.CheckCertificateRevocationStatusActions{ RevokedStatusAction: types.RevocationCheckActionPass, UnknownStatusAction: types.RevocationCheckActionPass, }, Scopes: []types.ServerCertificateScope{ { DestinationPorts: []types.PortRange{ { FromPort: 1, ToPort: 1, }, }, Destinations: []types.Address{ { AddressDefinition: new("test"), }, }, Protocols: []int32{1}, SourcePorts: []types.PortRange{ { FromPort: 1, ToPort: 1, }, }, Sources: []types.Address{ { AddressDefinition: new("test"), }, }, }, }, ServerCertificates: []types.ServerCertificate{ { ResourceArn: new("arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"), // link }, }, }, }, }, }, nil } func (c testNetworkFirewallClient) ListTLSInspectionConfigurations(ctx context.Context, params *networkfirewall.ListTLSInspectionConfigurationsInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.ListTLSInspectionConfigurationsOutput, error) { return &networkfirewall.ListTLSInspectionConfigurationsOutput{ TLSInspectionConfigurations: []types.TLSInspectionConfigurationMetadata{ { Arn: new("arn:aws:network-firewall:us-east-1:123456789012:tls-inspection-configuration/aws-network-firewall-DefaultTLSInspectionConfiguration-1J3Z3W2ZQXV3"), }, }, }, nil } func TestTLSInspectionConfigurationGetFunc(t *testing.T) { item, err := tlsInspectionConfigurationGetFunc(context.Background(), testNetworkFirewallClient{}, "test", &networkfirewall.DescribeTLSInspectionConfigurationInput{}) if err != nil { t.Fatal(err) } if err := item.Validate(); err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "acm-pca-certificate-authority-certificate", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "acm-certificate", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "acm-pca-certificate-authority", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:acm:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", ExpectedScope: "123456789012.us-east-1", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", ExpectedScope: "123456789012.us-east-1", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkfirewall.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" "github.com/overmindtech/cli/go/sdp-go" ) type networkFirewallClient interface { DescribeFirewall(ctx context.Context, params *networkfirewall.DescribeFirewallInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeFirewallOutput, error) ListFirewalls(context.Context, *networkfirewall.ListFirewallsInput, ...func(*networkfirewall.Options)) (*networkfirewall.ListFirewallsOutput, error) DescribeFirewallPolicy(ctx context.Context, params *networkfirewall.DescribeFirewallPolicyInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeFirewallPolicyOutput, error) ListFirewallPolicies(context.Context, *networkfirewall.ListFirewallPoliciesInput, ...func(*networkfirewall.Options)) (*networkfirewall.ListFirewallPoliciesOutput, error) DescribeRuleGroup(ctx context.Context, params *networkfirewall.DescribeRuleGroupInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeRuleGroupOutput, error) ListRuleGroups(ctx context.Context, params *networkfirewall.ListRuleGroupsInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.ListRuleGroupsOutput, error) DescribeTLSInspectionConfiguration(ctx context.Context, params *networkfirewall.DescribeTLSInspectionConfigurationInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeTLSInspectionConfigurationOutput, error) ListTLSInspectionConfigurations(ctx context.Context, params *networkfirewall.ListTLSInspectionConfigurationsInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.ListTLSInspectionConfigurationsOutput, error) DescribeLoggingConfiguration(ctx context.Context, params *networkfirewall.DescribeLoggingConfigurationInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeLoggingConfigurationOutput, error) DescribeResourcePolicy(ctx context.Context, params *networkfirewall.DescribeResourcePolicyInput, optFns ...func(*networkfirewall.Options)) (*networkfirewall.DescribeResourcePolicyOutput, error) } func encryptionConfigurationLink(config *types.EncryptionConfiguration, scope string) *sdp.LinkedItemQuery { // This can be an ARN or an ID if it's in the same account if a, err := ParseARN(*config.KeyId); err == nil { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *config.KeyId, Scope: FormatScope(a.AccountID, a.Region), }, } } else { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_GET, Query: *config.KeyId, Scope: scope, }, } } } ================================================ FILE: aws-source/adapters/networkfirewall_test.go ================================================ package adapters type testNetworkFirewallClient struct{} ================================================ FILE: aws-source/adapters/networkmanager-connect-attachment.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func connectAttachmentGetFunc(ctx context.Context, client *networkmanager.Client, _, query string) (*types.ConnectAttachment, error) { out, err := client.GetConnectAttachment(ctx, &networkmanager.GetConnectAttachmentInput{ AttachmentId: &query, }) if err != nil { return nil, err } return out.ConnectAttachment, nil } func connectAttachmentItemMapper(_, scope string, ca *types.ConnectAttachment) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(ca) if err != nil { return nil, err } if ca == nil || ca.Attachment == nil { return nil, sdp.NewQueryError(errors.New("attachment is nil for connect attachment")) } // The uniqueAttributeValue for this is a nested value of AttachmentId: attributes.Set("AttachmentId", *ca.Attachment.AttachmentId) item := sdp.Item{ Type: "networkmanager-connect-attachment", UniqueAttribute: "AttachmentId", Attributes: attributes, Scope: scope, } if ca.Attachment.CoreNetworkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-core-network", Method: sdp.QueryMethod_GET, Query: *ca.Attachment.CoreNetworkId, Scope: scope, }, }) } if ca.Attachment.CoreNetworkArn != nil { if arn, err := ParseARN(*ca.Attachment.CoreNetworkArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-core-network", Method: sdp.QueryMethod_SEARCH, Query: *ca.Attachment.CoreNetworkArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } item.Tags = networkmanagerTagsToMap(ca.Attachment.Tags) return &item, nil } func NewNetworkManagerConnectAttachmentAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*types.ConnectAttachment, *networkmanager.Client, *networkmanager.Options] { return &GetListAdapter[*types.ConnectAttachment, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-connect-attachment", AdapterMetadata: connectAttachmentAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *networkmanager.Client, scope string, query string) (*types.ConnectAttachment, error) { return connectAttachmentGetFunc(ctx, client, scope, query) }, ItemMapper: connectAttachmentItemMapper, ListFunc: func(ctx context.Context, client *networkmanager.Client, scope string) ([]*types.ConnectAttachment, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-connect-attachment, use get", Scope: scope, } }, } } var connectAttachmentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-connect-attachment", DescriptiveName: "Networkmanager Connect Attachment", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_core_network.id"}, }, PotentialLinks: []string{"networkmanager-core-network"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-connect-attachment_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestConnectAttachmentItemMapper(t *testing.T) { scope := "123456789012.eu-west-2" item, err := connectAttachmentItemMapper("", scope, &types.ConnectAttachment{ Attachment: &types.Attachment{ AttachmentId: new("att-1"), CoreNetworkId: new("cn-1"), CoreNetworkArn: new("arn:aws:networkmanager:eu-west-2:123456789012:core-network/cn-1"), }, }) if err != nil { t.Error(err) } // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "att-1" { t.Fatalf("expected att-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cn-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:networkmanager:eu-west-2:123456789012:core-network/cn-1", ExpectedScope: "123456789012.eu-west-2", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager-connect-peer-association.go ================================================ package adapters import ( "context" "errors" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func connectPeerAssociationsOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetConnectPeerAssociationsInput, output *networkmanager.GetConnectPeerAssociationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, a := range output.ConnectPeerAssociations { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(a) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if a.GlobalNetworkId == nil || a.ConnectPeerId == nil { return nil, sdp.NewQueryError(errors.New("globalNetworkId or connectPeerId is nil for connect peer association")) } attrs.Set("GlobalNetworkIdConnectPeerId", idWithGlobalNetwork(*a.GlobalNetworkId, *a.ConnectPeerId)) item := sdp.Item{ Type: "networkmanager-connect-peer-association", UniqueAttribute: "GlobalNetworkIdConnectPeerId", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *a.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-connect-peer", Method: sdp.QueryMethod_GET, Query: *a.ConnectPeerId, Scope: scope, }, }, }, } switch a.State { case types.ConnectPeerAssociationStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ConnectPeerAssociationStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.ConnectPeerAssociationStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ConnectPeerAssociationStateDeleted: item.Health = sdp.Health_HEALTH_PENDING.Enum() } if a.DeviceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*a.GlobalNetworkId, *a.DeviceId), Scope: scope, }, }) } if a.LinkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*a.GlobalNetworkId, *a.LinkId), Scope: scope, }, }) } items = append(items, &item) } return items, nil } func NewNetworkManagerConnectPeerAssociationAdapter(client *networkmanager.Client, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetConnectPeerAssociationsInput, *networkmanager.GetConnectPeerAssociationsOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetConnectPeerAssociationsInput, *networkmanager.GetConnectPeerAssociationsOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-connect-peer-association", AdapterMetadata: connectPeerAssociationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetConnectPeerAssociationsInput) (*networkmanager.GetConnectPeerAssociationsOutput, error) { return client.GetConnectPeerAssociations(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.GetConnectPeerAssociationsInput, error) { // We are using a custom id of {globalNetworkId}|{connectPeerId} sections := strings.Split(query, "|") if len(sections) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-connect-peer-association get function", Scope: scope, } } return &networkmanager.GetConnectPeerAssociationsInput{ GlobalNetworkId: §ions[0], ConnectPeerIds: []string{ sections[1], }, }, nil }, InputMapperList: func(scope string) (*networkmanager.GetConnectPeerAssociationsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-connect-peer-association, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetConnectPeerAssociationsInput) Paginator[*networkmanager.GetConnectPeerAssociationsOutput, *networkmanager.Options] { return networkmanager.NewGetConnectPeerAssociationsPaginator(client, params) }, OutputMapper: connectPeerAssociationsOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetConnectPeerAssociationsInput, error) { // Search by GlobalNetworkId return &networkmanager.GetConnectPeerAssociationsInput{ GlobalNetworkId: &query, }, nil }, } } var connectPeerAssociationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-connect-peer-association", DescriptiveName: "Networkmanager Connect Peer Association", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Networkmanager Connect Peer Association", ListDescription: "List all Networkmanager Connect Peer Associations", SearchDescription: "Search for Networkmanager ConnectPeerAssociations by GlobalNetworkId", }, PotentialLinks: []string{"networkmanager-global-network", "networkmanager-connect-peer", "networkmanager-device", "networkmanager-link"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-connect-peer-association_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestConnectPeerAssociationsOutputMapper(t *testing.T) { output := networkmanager.GetConnectPeerAssociationsOutput{ ConnectPeerAssociations: []types.ConnectPeerAssociation{ { ConnectPeerId: new("cp-1"), DeviceId: new("dvc-1"), GlobalNetworkId: new("default"), LinkId: new("link-1"), }, }, } scope := "123456789012.eu-west-2" items, err := connectPeerAssociationsOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetConnectPeerAssociationsInput{}, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "default|cp-1" { t.Fatalf("expected default|cp-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "networkmanager-connect-peer", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cp-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|link-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|dvc-1", ExpectedScope: scope, }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager-connect-peer.go ================================================ package adapters import ( "context" "strconv" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func connectPeerGetFunc(ctx context.Context, client NetworkManagerClient, scope string, input *networkmanager.GetConnectPeerInput) (*sdp.Item, error) { out, err := client.GetConnectPeer(ctx, input) if err != nil { return nil, err } cn := out.ConnectPeer attributes, err := ToAttributesWithExclude(cn, "tags") if err != nil { return nil, err } item := sdp.Item{ Type: "networkmanager-connect-peer", UniqueAttribute: "ConnectPeerId", Attributes: attributes, Scope: scope, Tags: networkmanagerTagsToMap(cn.Tags), } if cn.Configuration != nil { if cn.Configuration.CoreNetworkAddress != nil { //+overmind:link ip item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *cn.Configuration.CoreNetworkAddress, Scope: "global", }, }) } if cn.Configuration.PeerAddress != nil { //+overmind:link ip item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *cn.Configuration.PeerAddress, Scope: "global", }, }) } for _, config := range cn.Configuration.BgpConfigurations { if config.CoreNetworkAddress != nil { //+overmind:link ip item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *config.CoreNetworkAddress, Scope: "global", }, }) if config.PeerAddress != nil { //+overmind:link ip item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *config.PeerAddress, Scope: "global", }, }) } if config.CoreNetworkAsn != nil { //+overmind:link rdap-asn item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-asn", Method: sdp.QueryMethod_GET, Query: strconv.FormatInt(*config.CoreNetworkAsn, 10), Scope: "global", }, }) } if config.PeerAsn != nil { //+overmind:link rdap-asn item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-asn", Method: sdp.QueryMethod_GET, Query: strconv.FormatInt(*config.PeerAsn, 10), Scope: "global", }, }) } } } } if cn.CoreNetworkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-core-network", Method: sdp.QueryMethod_GET, Query: *cn.CoreNetworkId, Scope: scope, }, }) } if cn.SubnetArn != nil { if arn, err := ParseARN(*cn.SubnetArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ //+overmind:link ec2-subnet Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_SEARCH, Query: *cn.SubnetArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } if cn.ConnectAttachmentId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ //+overmind:link networkmanager-connect-attachment Query: &sdp.Query{ Type: "networkmanager-connect-attachment", Method: sdp.QueryMethod_GET, Query: *cn.ConnectAttachmentId, Scope: scope, }, }) } switch cn.State { case types.ConnectPeerStateCreating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ConnectPeerStateFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() case types.ConnectPeerStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.ConnectPeerStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() } return &item, nil } func NewNetworkManagerConnectPeerAdapter(client NetworkManagerClient, accountID, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*networkmanager.ListConnectPeersInput, *networkmanager.ListConnectPeersOutput, *networkmanager.GetConnectPeerInput, *networkmanager.GetConnectPeerOutput, NetworkManagerClient, *networkmanager.Options] { return &AlwaysGetAdapter[*networkmanager.ListConnectPeersInput, *networkmanager.ListConnectPeersOutput, *networkmanager.GetConnectPeerInput, *networkmanager.GetConnectPeerOutput, NetworkManagerClient, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-connect-peer", ListInput: &networkmanager.ListConnectPeersInput{}, AdapterMetadata: connectPeerAdapterMetadata, cache: cache, SearchInputMapper: func(scope, query string) (*networkmanager.ListConnectPeersInput, error) { // Search by CoreNetworkId return &networkmanager.ListConnectPeersInput{ CoreNetworkId: &query, }, nil }, GetInputMapper: func(scope, query string) *networkmanager.GetConnectPeerInput { return &networkmanager.GetConnectPeerInput{ ConnectPeerId: &query, } }, ListFuncPaginatorBuilder: func(client NetworkManagerClient, input *networkmanager.ListConnectPeersInput) Paginator[*networkmanager.ListConnectPeersOutput, *networkmanager.Options] { return networkmanager.NewListConnectPeersPaginator(client, input) }, ListFuncOutputMapper: func(output *networkmanager.ListConnectPeersOutput, input *networkmanager.ListConnectPeersInput) ([]*networkmanager.GetConnectPeerInput, error) { var inputs []*networkmanager.GetConnectPeerInput for _, connectPeer := range output.ConnectPeers { inputs = append(inputs, &networkmanager.GetConnectPeerInput{ ConnectPeerId: connectPeer.ConnectPeerId, }) } return inputs, nil }, GetFunc: connectPeerGetFunc, } } var connectPeerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-connect-peer", DescriptiveName: "Networkmanager Connect Peer", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Networkmanager Connect Peer by id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_connect_peer.id"}, }, PotentialLinks: []string{"networkmanager-core-network", "networkmanager-connect-attachment", "ip", "rdap-asn", "ec2-subnet"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-connect-peer_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func (n NetworkManagerTestClient) GetConnectPeer(ctx context.Context, params *networkmanager.GetConnectPeerInput, optFns ...func(*networkmanager.Options)) (*networkmanager.GetConnectPeerOutput, error) { return &networkmanager.GetConnectPeerOutput{ ConnectPeer: &types.ConnectPeer{ Configuration: &types.ConnectPeerConfiguration{ BgpConfigurations: []types.ConnectPeerBgpConfiguration{ { CoreNetworkAddress: new("1.4.2.4"), // link CoreNetworkAsn: new(int64(64512)), // link PeerAddress: new("123.123.123.123"), // link PeerAsn: new(int64(64513)), // link }, }, CoreNetworkAddress: new("1.1.1.3"), // link PeerAddress: new("1.1.1.45"), // link }, ConnectAttachmentId: new("ca-1"), // link ConnectPeerId: new("cp-1"), CoreNetworkId: new("cn-1"), // link EdgeLocation: new("us-west-2"), State: types.ConnectPeerStateAvailable, SubnetArn: new("arn:aws:ec2:us-west-2:123456789012:subnet/subnet-1"), // link }, }, nil } func (n NetworkManagerTestClient) ListConnectPeers(context.Context, *networkmanager.ListConnectPeersInput, ...func(*networkmanager.Options)) (*networkmanager.ListConnectPeersOutput, error) { return nil, nil } func TestConnectPeerGetFunc(t *testing.T) { item, err := connectPeerGetFunc(context.Background(), NetworkManagerTestClient{}, "test", &networkmanager.GetConnectPeerInput{}) if err != nil { t.Fatal(err) } // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "cp-1" { t.Fatalf("expected cp-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "1.4.2.4", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "123.123.123.123", ExpectedScope: "global", }, { ExpectedType: "rdap-asn", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "64512", ExpectedScope: "global", }, { ExpectedType: "rdap-asn", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "64513", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "1.1.1.3", ExpectedScope: "global", }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "1.1.1.45", ExpectedScope: "global", }, { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cn-1", ExpectedScope: "test", }, { ExpectedType: "networkmanager-connect-attachment", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ca-1", ExpectedScope: "test", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-west-2:123456789012:subnet/subnet-1", ExpectedScope: "123456789012.us-west-2", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager-connection.go ================================================ package adapters import ( "context" "errors" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func connectionOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetConnectionsInput, output *networkmanager.GetConnectionsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, s := range output.Connections { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(s, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if s.GlobalNetworkId == nil || s.ConnectionId == nil { return nil, sdp.NewQueryError(errors.New("globalNetworkId or connectionId is nil for connection")) } attrs.Set("GlobalNetworkIdConnectionId", idWithGlobalNetwork(*s.GlobalNetworkId, *s.ConnectionId)) item := sdp.Item{ Type: "networkmanager-connection", UniqueAttribute: "GlobalNetworkIdConnectionId", Scope: scope, Attributes: attrs, Tags: networkmanagerTagsToMap(s.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *s.GlobalNetworkId, Scope: scope, }, }, }, } if s.LinkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.LinkId), Scope: scope, }, }) } if s.ConnectedLinkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.ConnectedLinkId), Scope: scope, }, }) } if s.DeviceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.DeviceId), Scope: scope, }, }) } if s.ConnectedDeviceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.ConnectedDeviceId), Scope: scope, }, }) } switch s.State { case types.ConnectionStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ConnectionStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.ConnectionStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.ConnectionStateUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() } items = append(items, &item) } return items, nil } func NewNetworkManagerConnectionAdapter(client *networkmanager.Client, accountID string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetConnectionsInput, *networkmanager.GetConnectionsOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetConnectionsInput, *networkmanager.GetConnectionsOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, ItemType: "networkmanager-connection", DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetConnectionsInput) (*networkmanager.GetConnectionsOutput, error) { return client.GetConnections(ctx, input) }, AdapterMetadata: networkmanagerConnectionAdapterMetadata, cache: cache, InputMapperGet: func(scope, query string) (*networkmanager.GetConnectionsInput, error) { // We are using a custom id of {globalNetworkId}|{connectionId} sections := strings.Split(query, "|") if len(sections) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-connection get function", Scope: scope, } } return &networkmanager.GetConnectionsInput{ GlobalNetworkId: §ions[0], ConnectionIds: []string{ sections[1], }, }, nil }, InputMapperList: func(scope string) (*networkmanager.GetConnectionsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-connection, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetConnectionsInput) Paginator[*networkmanager.GetConnectionsOutput, *networkmanager.Options] { return networkmanager.NewGetConnectionsPaginator(client, params) }, OutputMapper: connectionOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetConnectionsInput, error) { // Try to parse as ARN first arn, err := ParseARN(query) if err == nil { // Check if it's a networkmanager ARN if arn.Service == "networkmanager" { switch arn.Type() { case "device": // Parse the resource part which can be: // 1. device/global-network-{id}/device-{id} (for device ARNs) // 2. device/global-network-{id}/connection-{id} (for connection ARNs) resourceParts := strings.Split(arn.Resource, "/") if len(resourceParts) == 3 && resourceParts[0] == "device" && strings.HasPrefix(resourceParts[1], "global-network-") { globalNetworkId := resourceParts[1] // Keep full ID including "global-network-" prefix if strings.HasPrefix(resourceParts[2], "connection-") { // This is a connection ARN: device/global-network-{id}/connection-{id} connectionId := resourceParts[2] // Keep full ID including "connection-" prefix return &networkmanager.GetConnectionsInput{ GlobalNetworkId: &globalNetworkId, ConnectionIds: []string{connectionId}, }, nil } else if strings.HasPrefix(resourceParts[2], "device-") { // This is a device ARN: device/global-network-{id}/device-{id} deviceId := resourceParts[2] // Keep full ID including "device-" prefix return &networkmanager.GetConnectionsInput{ GlobalNetworkId: &globalNetworkId, DeviceId: &deviceId, }, nil } } } } // If it's not a valid networkmanager ARN, return an error return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not a valid networkmanager-connection or networkmanager-device ARN", Scope: scope, } } // If not an ARN, fall back to the original logic // We may search by only globalNetworkId or by using a custom id of {globalNetworkId}|{deviceId} sections := strings.Split(query, "|") switch len(sections) { case 1: // globalNetworkId return &networkmanager.GetConnectionsInput{ GlobalNetworkId: §ions[0], }, nil case 2: // {globalNetworkId}|{deviceId} return &networkmanager.GetConnectionsInput{ GlobalNetworkId: §ions[0], DeviceId: §ions[1], }, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-connection get function", Scope: scope, } } }, } } var networkmanagerConnectionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-connection", DescriptiveName: "Networkmanager Connection", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Networkmanager Connection", SearchDescription: "Search for Networkmanager Connections by GlobalNetworkId, Device ARN, or Connection ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_networkmanager_connection.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"networkmanager-global-network", "networkmanager-link", "networkmanager-device"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-connection_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestConnectionOutputMapper(t *testing.T) { output := networkmanager.GetConnectionsOutput{ Connections: []types.Connection{ { GlobalNetworkId: new("default"), ConnectionId: new("conn-1"), DeviceId: new("dvc-1"), ConnectedDeviceId: new("dvc-2"), LinkId: new("link-1"), ConnectedLinkId: new("link-2"), }, }, } scope := "123456789012.eu-west-2" items, err := connectionOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetConnectionsInput{}, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "default|conn-1" { t.Fatalf("expected default|conn-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|dvc-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|dvc-2", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|link-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|link-2", ExpectedScope: scope, }, } tests.Execute(t, item) } func TestConnectionInputMapperSearch(t *testing.T) { adapter := NewNetworkManagerConnectionAdapter(&networkmanager.Client{}, "123456789012", sdpcache.NewNoOpCache()) tests := []struct { name string query string expectedInput *networkmanager.GetConnectionsInput expectError bool }{ { name: "Valid networkmanager-connection ARN", query: "arn:aws:networkmanager::123456789012:device/global-network-0d47f6t230mz46dy4/connection-07f6fd08867abc123", expectedInput: &networkmanager.GetConnectionsInput{ GlobalNetworkId: new("global-network-0d47f6t230mz46dy4"), ConnectionIds: []string{"connection-07f6fd08867abc123"}, }, expectError: false, }, { name: "Valid networkmanager-device ARN", query: "arn:aws:networkmanager::123456789012:device/global-network-01231231231231231/device-07f6fd08867abc123", expectedInput: &networkmanager.GetConnectionsInput{ GlobalNetworkId: new("global-network-01231231231231231"), DeviceId: new("device-07f6fd08867abc123"), }, expectError: false, }, { name: "Global Network ID only", query: "global-network-123456789", expectedInput: &networkmanager.GetConnectionsInput{ GlobalNetworkId: new("global-network-123456789"), }, expectError: false, }, { name: "Global Network ID and Device ID", query: "global-network-123456789|device-987654321", expectedInput: &networkmanager.GetConnectionsInput{ GlobalNetworkId: new("global-network-123456789"), DeviceId: new("device-987654321"), }, expectError: false, }, { name: "Invalid ARN - wrong service", query: "arn:aws:ec2::123456789012:instance/i-1234567890abcdef0", expectError: true, }, { name: "Invalid ARN - wrong resource type", query: "arn:aws:networkmanager::123456789012:site/global-network-01231231231231231/site-444555aaabbb11223", expectError: true, }, { name: "Invalid connection ARN - malformed resource", query: "arn:aws:networkmanager::123456789012:device/invalid-format", expectError: true, }, { name: "Invalid device ARN - malformed resource", query: "arn:aws:networkmanager::123456789012:device/global-network-123/invalid-prefix-123", expectError: true, }, { name: "Invalid query - too many sections", query: "section1|section2|section3", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input, err := adapter.InputMapperSearch(context.Background(), &networkmanager.Client{}, "123456789012.us-east-1", tt.query) if tt.expectError { if err == nil { t.Errorf("Expected error for query %s, but got none", tt.query) } return } if err != nil { t.Errorf("Unexpected error for query %s: %v", tt.query, err) return } if input == nil { t.Errorf("Expected input but got nil for query %s", tt.query) return } // Compare GlobalNetworkId if (input.GlobalNetworkId == nil) != (tt.expectedInput.GlobalNetworkId == nil) { t.Errorf("GlobalNetworkId nil mismatch for query %s", tt.query) return } if input.GlobalNetworkId != nil && tt.expectedInput.GlobalNetworkId != nil { if *input.GlobalNetworkId != *tt.expectedInput.GlobalNetworkId { t.Errorf("Expected GlobalNetworkId %s, got %s for query %s", *tt.expectedInput.GlobalNetworkId, *input.GlobalNetworkId, tt.query) } } // Compare DeviceId if (input.DeviceId == nil) != (tt.expectedInput.DeviceId == nil) { t.Errorf("DeviceId nil mismatch for query %s", tt.query) return } if input.DeviceId != nil && tt.expectedInput.DeviceId != nil { if *input.DeviceId != *tt.expectedInput.DeviceId { t.Errorf("Expected DeviceId %s, got %s for query %s", *tt.expectedInput.DeviceId, *input.DeviceId, tt.query) } } // Compare ConnectionIds if len(input.ConnectionIds) != len(tt.expectedInput.ConnectionIds) { t.Errorf("Expected %d ConnectionIds, got %d for query %s", len(tt.expectedInput.ConnectionIds), len(input.ConnectionIds), tt.query) return } for i, connectionId := range input.ConnectionIds { if connectionId != tt.expectedInput.ConnectionIds[i] { t.Errorf("Expected ConnectionId %s, got %s at index %d for query %s", tt.expectedInput.ConnectionIds[i], connectionId, i, tt.query) } } }) } } ================================================ FILE: aws-source/adapters/networkmanager-core-network-policy.go ================================================ package adapters import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func coreNetworkPolicyGetFunc(ctx context.Context, client *networkmanager.Client, _, query string) (*types.CoreNetworkPolicy, error) { out, err := client.GetCoreNetworkPolicy(ctx, &networkmanager.GetCoreNetworkPolicyInput{ CoreNetworkId: &query, }) if err != nil { return nil, err } return out.CoreNetworkPolicy, nil } func coreNetworkPolicyItemMapper(_, scope string, cn *types.CoreNetworkPolicy) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(cn) if err != nil { return nil, err } if cn.CoreNetworkId == nil { return nil, sdp.NewQueryError(errors.New("coreNetworkId is nil for core network policy")) } item := sdp.Item{ Type: "networkmanager-core-network-policy", UniqueAttribute: "CoreNetworkId", Attributes: attributes, Scope: scope, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-core-network", Method: sdp.QueryMethod_GET, Query: *cn.CoreNetworkId, Scope: scope, }, }, }, } return &item, nil } func NewNetworkManagerCoreNetworkPolicyAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*types.CoreNetworkPolicy, *networkmanager.Client, *networkmanager.Options] { return &GetListAdapter[*types.CoreNetworkPolicy, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-core-network-policy", AdapterMetadata: coreNetworkPolicyAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *networkmanager.Client, scope string, query string) (*types.CoreNetworkPolicy, error) { return coreNetworkPolicyGetFunc(ctx, client, scope, query) }, ItemMapper: coreNetworkPolicyItemMapper, ListFunc: func(ctx context.Context, client *networkmanager.Client, scope string) ([]*types.CoreNetworkPolicy, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-core-network-policy, use get", Scope: scope, } }, } } var coreNetworkPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-core-network-policy", DescriptiveName: "Networkmanager Core Network Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Networkmanager Core Network Policy by Core Network id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_core_network_policy.core_network_id"}, }, PotentialLinks: []string{"networkmanager-core-network"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-core-network-policy_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestCoreNetworkPolicyItemMapper(t *testing.T) { scope := "123456789012.eu-west-2" item, err := coreNetworkPolicyItemMapper("", scope, &types.CoreNetworkPolicy{ CoreNetworkId: new("cn-1"), PolicyVersionId: new(int32(1)), }) if err != nil { t.Error(err) } // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "cn-1" { t.Fatalf("expected cn-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cn-1", ExpectedScope: scope, }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager-core-network.go ================================================ package adapters import ( "context" "errors" "strconv" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func coreNetworkGetFunc(ctx context.Context, client NetworkManagerClient, scope string, input *networkmanager.GetCoreNetworkInput) (*sdp.Item, error) { out, err := client.GetCoreNetwork(ctx, input) if err != nil { return nil, err } if out.CoreNetwork == nil { return nil, sdp.NewQueryError(errors.New("coreNetwork is nil for core network")) } cn := out.CoreNetwork attributes, err := ToAttributesWithExclude(cn) if err != nil { return nil, err } item := sdp.Item{ Type: "networkmanager-core-network", UniqueAttribute: "CoreNetworkId", Attributes: attributes, Scope: scope, Tags: networkmanagerTagsToMap(cn.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-core-network-policy", Method: sdp.QueryMethod_GET, Query: *cn.CoreNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-connect-peer", Method: sdp.QueryMethod_SEARCH, Query: *cn.CoreNetworkId, Scope: scope, }, }, }, } if cn.GlobalNetworkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *cn.GlobalNetworkId, Scope: scope, }, }) } for _, edge := range cn.Edges { if edge.Asn != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-asn", Method: sdp.QueryMethod_GET, Query: strconv.FormatInt(*edge.Asn, 10), Scope: "global", }, }) } } switch cn.State { case types.CoreNetworkStateCreating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.CoreNetworkStateUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.CoreNetworkStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.CoreNetworkStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() } return &item, nil } func NewNetworkManagerCoreNetworkAdapter(client NetworkManagerClient, accountID, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*networkmanager.ListCoreNetworksInput, *networkmanager.ListCoreNetworksOutput, *networkmanager.GetCoreNetworkInput, *networkmanager.GetCoreNetworkOutput, NetworkManagerClient, *networkmanager.Options] { return &AlwaysGetAdapter[*networkmanager.ListCoreNetworksInput, *networkmanager.ListCoreNetworksOutput, *networkmanager.GetCoreNetworkInput, *networkmanager.GetCoreNetworkOutput, NetworkManagerClient, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, GetFunc: coreNetworkGetFunc, ItemType: "networkmanager-core-network", ListInput: &networkmanager.ListCoreNetworksInput{}, AdapterMetadata: coreNetworkAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *networkmanager.GetCoreNetworkInput { return &networkmanager.GetCoreNetworkInput{ CoreNetworkId: &query, } }, ListFuncPaginatorBuilder: func(client NetworkManagerClient, input *networkmanager.ListCoreNetworksInput) Paginator[*networkmanager.ListCoreNetworksOutput, *networkmanager.Options] { return networkmanager.NewListCoreNetworksPaginator(client, input) }, ListFuncOutputMapper: func(output *networkmanager.ListCoreNetworksOutput, input *networkmanager.ListCoreNetworksInput) ([]*networkmanager.GetCoreNetworkInput, error) { queries := make([]*networkmanager.GetCoreNetworkInput, 0, len(output.CoreNetworks)) for i := range output.CoreNetworks { queries = append(queries, &networkmanager.GetCoreNetworkInput{ CoreNetworkId: output.CoreNetworks[i].CoreNetworkId, }) } return queries, nil }, } } var coreNetworkAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-core-network", DescriptiveName: "Networkmanager Core Network", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Networkmanager Core Network by id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_core_network.id"}, }, PotentialLinks: []string{"networkmanager-core-network-policy", "networkmanager-connect-peer"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-core-network_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func (n NetworkManagerTestClient) GetCoreNetwork(ctx context.Context, params *networkmanager.GetCoreNetworkInput, optFns ...func(*networkmanager.Options)) (*networkmanager.GetCoreNetworkOutput, error) { return &networkmanager.GetCoreNetworkOutput{ CoreNetwork: &types.CoreNetwork{ CoreNetworkArn: new("arn:aws:networkmanager:us-west-2:123456789012:core-network/cn-1"), CoreNetworkId: new("cn-1"), GlobalNetworkId: new("default"), Description: new("core network description"), State: types.CoreNetworkStateAvailable, Edges: []types.CoreNetworkEdge{ { Asn: new(int64(64512)), // link EdgeLocation: new("us-west-2"), }, }, Segments: []types.CoreNetworkSegment{ { EdgeLocations: []string{"us-west-2"}, Name: new("segment-1"), }, }, }, }, nil } func (n NetworkManagerTestClient) ListCoreNetworks(context.Context, *networkmanager.ListCoreNetworksInput, ...func(*networkmanager.Options)) (*networkmanager.ListCoreNetworksOutput, error) { return nil, nil } func TestCoreNetworkItemMapper(t *testing.T) { item, err := coreNetworkGetFunc(context.Background(), NetworkManagerTestClient{}, "test", &networkmanager.GetCoreNetworkInput{}) if err != nil { t.Fatal(err) } // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "cn-1" { t.Fatalf("expected cn-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: "test", }, { ExpectedType: "networkmanager-core-network-policy", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cn-1", ExpectedScope: "test", }, { ExpectedType: "networkmanager-connect-peer", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "cn-1", ExpectedScope: "test", }, { ExpectedType: "rdap-asn", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "64512", ExpectedScope: "global", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager-device.go ================================================ package adapters import ( "context" "errors" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func deviceOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetDevicesInput, output *networkmanager.GetDevicesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, s := range output.Devices { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(s, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if s.GlobalNetworkId == nil || s.DeviceId == nil { return nil, sdp.NewQueryError(errors.New("globalNetworkId or deviceId is nil for device")) } attrs.Set("GlobalNetworkIdDeviceId", idWithGlobalNetwork(*s.GlobalNetworkId, *s.DeviceId)) item := sdp.Item{ Type: "networkmanager-device", UniqueAttribute: "GlobalNetworkIdDeviceId", Scope: scope, Attributes: attrs, Tags: networkmanagerTagsToMap(s.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *s.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-link-association", Method: sdp.QueryMethod_SEARCH, Query: idWithTypeAndGlobalNetwork(*s.GlobalNetworkId, "device", *s.DeviceId), Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-connection", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.DeviceId), Scope: scope, }, }, }, } if s.SiteId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-site", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.SiteId), Scope: scope, }, }) } if s.DeviceArn != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-network-resource-relationship", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.DeviceArn), Scope: scope, }, }) } switch s.State { case types.DeviceStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.DeviceStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.DeviceStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.DeviceStateUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() } items = append(items, &item) } return items, nil } func NewNetworkManagerDeviceAdapter(client *networkmanager.Client, accountID string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetDevicesInput, *networkmanager.GetDevicesOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetDevicesInput, *networkmanager.GetDevicesOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, ItemType: "networkmanager-device", DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetDevicesInput) (*networkmanager.GetDevicesOutput, error) { return client.GetDevices(ctx, input) }, AdapterMetadata: networkmanagerDeviceAdapterMetadata, cache: cache, InputMapperGet: func(scope, query string) (*networkmanager.GetDevicesInput, error) { // We are using a custom id of {globalNetworkId}|{deviceId} sections := strings.Split(query, "|") if len(sections) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-device get function", Scope: scope, } } return &networkmanager.GetDevicesInput{ GlobalNetworkId: §ions[0], DeviceIds: []string{ sections[1], }, }, nil }, InputMapperList: func(scope string) (*networkmanager.GetDevicesInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-device, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetDevicesInput) Paginator[*networkmanager.GetDevicesOutput, *networkmanager.Options] { return networkmanager.NewGetDevicesPaginator(client, params) }, OutputMapper: deviceOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetDevicesInput, error) { // Try to parse as ARN first arn, err := ParseARN(query) if err == nil { // Check if it's a networkmanager-device ARN if arn.Service == "networkmanager" && arn.Type() == "device" { // Parse the resource part: device/global-network-{id}/device-{id} // Expected format: device/global-network-01231231231231231/device-07f6fd08867abc123 resourceParts := strings.Split(arn.Resource, "/") if len(resourceParts) == 3 && resourceParts[0] == "device" && strings.HasPrefix(resourceParts[1], "global-network-") && strings.HasPrefix(resourceParts[2], "device-") { globalNetworkId := resourceParts[1] // Keep full ID including "global-network-" prefix deviceId := resourceParts[2] // Keep full ID including "device-" prefix return &networkmanager.GetDevicesInput{ GlobalNetworkId: &globalNetworkId, DeviceIds: []string{deviceId}, }, nil } } // If it's not a valid networkmanager-device ARN, return an error return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not a valid networkmanager-device ARN", Scope: scope, } } // If not an ARN, fall back to the original logic // We may search by only globalNetworkId or by using a custom id of {globalNetworkId}|{siteId} sections := strings.Split(query, "|") switch len(sections) { case 1: // globalNetworkId return &networkmanager.GetDevicesInput{ GlobalNetworkId: §ions[0], }, nil case 2: // {globalNetworkId}|{siteId} return &networkmanager.GetDevicesInput{ GlobalNetworkId: §ions[0], SiteId: §ions[1], }, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-device get function", Scope: scope, } } }, } } var networkmanagerDeviceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-device", DescriptiveName: "Networkmanager Device", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Networkmanager Device", SearchDescription: "Search for Networkmanager Devices by GlobalNetworkId, {GlobalNetworkId|SiteId} or ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_networkmanager_device.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"networkmanager-global-network", "networkmanager-site", "networkmanager-link-association", "networkmanager-connection", "networkmanager-network-resource-relationship"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-device_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDeviceOutputMapper(t *testing.T) { output := networkmanager.GetDevicesOutput{ Devices: []types.Device{ { DeviceId: new("dvc-1"), GlobalNetworkId: new("default"), SiteId: new("site-1"), DeviceArn: new("arn:aws:networkmanager:us-west-2:123456789012:device/dvc-1"), }, }, } scope := "123456789012.eu-west-2" items, err := deviceOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetDevicesInput{}, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "default|dvc-1" { t.Fatalf("expected default|dvc-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "networkmanager-site", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|site-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-network-resource-relationship", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|arn:aws:networkmanager:us-west-2:123456789012:device/dvc-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link-association", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|device|dvc-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-connection", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|dvc-1", ExpectedScope: scope, }, } tests.Execute(t, item) } func TestDeviceInputMapperSearch(t *testing.T) { adapter := NewNetworkManagerDeviceAdapter(&networkmanager.Client{}, "123456789012", sdpcache.NewNoOpCache()) tests := []struct { name string query string expectedInput *networkmanager.GetDevicesInput expectError bool }{ { name: "Valid networkmanager-device ARN", query: "arn:aws:networkmanager::123456789012:device/global-network-01231231231231231/device-07f6fd08867abc123", expectedInput: &networkmanager.GetDevicesInput{ GlobalNetworkId: new("global-network-01231231231231231"), DeviceIds: []string{"device-07f6fd08867abc123"}, }, expectError: false, }, { name: "Global Network ID only", query: "global-network-123456789", expectedInput: &networkmanager.GetDevicesInput{ GlobalNetworkId: new("global-network-123456789"), }, expectError: false, }, { name: "Global Network ID and Site ID", query: "global-network-123456789|site-987654321", expectedInput: &networkmanager.GetDevicesInput{ GlobalNetworkId: new("global-network-123456789"), SiteId: new("site-987654321"), }, expectError: false, }, { name: "Invalid ARN - wrong service", query: "arn:aws:ec2::123456789012:instance/i-1234567890abcdef0", expectError: true, }, { name: "Invalid ARN - wrong resource type", query: "arn:aws:networkmanager::123456789012:site/global-network-01231231231231231/site-444555aaabbb11223", expectError: true, }, { name: "Invalid ARN - malformed resource", query: "arn:aws:networkmanager::123456789012:device/invalid-format", expectError: true, }, { name: "Invalid query - too many sections", query: "section1|section2|section3", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input, err := adapter.InputMapperSearch(context.Background(), &networkmanager.Client{}, "123456789012.us-east-1", tt.query) if tt.expectError { if err == nil { t.Errorf("Expected error for query %s, but got none", tt.query) } return } if err != nil { t.Errorf("Unexpected error for query %s: %v", tt.query, err) return } if input == nil { t.Errorf("Expected input but got nil for query %s", tt.query) return } // Compare GlobalNetworkId if (input.GlobalNetworkId == nil) != (tt.expectedInput.GlobalNetworkId == nil) { t.Errorf("GlobalNetworkId nil mismatch for query %s", tt.query) return } if input.GlobalNetworkId != nil && tt.expectedInput.GlobalNetworkId != nil { if *input.GlobalNetworkId != *tt.expectedInput.GlobalNetworkId { t.Errorf("Expected GlobalNetworkId %s, got %s for query %s", *tt.expectedInput.GlobalNetworkId, *input.GlobalNetworkId, tt.query) } } // Compare SiteId if (input.SiteId == nil) != (tt.expectedInput.SiteId == nil) { t.Errorf("SiteId nil mismatch for query %s", tt.query) return } if input.SiteId != nil && tt.expectedInput.SiteId != nil { if *input.SiteId != *tt.expectedInput.SiteId { t.Errorf("Expected SiteId %s, got %s for query %s", *tt.expectedInput.SiteId, *input.SiteId, tt.query) } } // Compare DeviceIds if len(input.DeviceIds) != len(tt.expectedInput.DeviceIds) { t.Errorf("Expected %d DeviceIds, got %d for query %s", len(tt.expectedInput.DeviceIds), len(input.DeviceIds), tt.query) return } for i, deviceId := range input.DeviceIds { if deviceId != tt.expectedInput.DeviceIds[i] { t.Errorf("Expected DeviceId %s, got %s at index %d for query %s", tt.expectedInput.DeviceIds[i], deviceId, i, tt.query) } } }) } } ================================================ FILE: aws-source/adapters/networkmanager-global-network.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func globalNetworkOutputMapper(_ context.Context, client *networkmanager.Client, scope string, _ *networkmanager.DescribeGlobalNetworksInput, output *networkmanager.DescribeGlobalNetworksOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, gn := range output.GlobalNetworks { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(gn, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } item := sdp.Item{ Type: "networkmanager-global-network", UniqueAttribute: "GlobalNetworkId", Scope: scope, Attributes: attrs, Tags: networkmanagerTagsToMap(gn.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-site", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-transit-gateway-registration", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-connect-peer-association", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-transit-gateway-connect-peer-association", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-network-resource", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-network-resource-relationship", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-connection", Method: sdp.QueryMethod_SEARCH, Query: *gn.GlobalNetworkId, Scope: scope, }, }, }, } switch gn.State { case types.GlobalNetworkStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.GlobalNetworkStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.GlobalNetworkStateUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.GlobalNetworkStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() } items = append(items, &item) } return items, nil } func NewNetworkManagerGlobalNetworkAdapter(client *networkmanager.Client, accountID string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.DescribeGlobalNetworksInput, *networkmanager.DescribeGlobalNetworksOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.DescribeGlobalNetworksInput, *networkmanager.DescribeGlobalNetworksOutput, *networkmanager.Client, *networkmanager.Options]{ ItemType: "networkmanager-global-network", Client: client, AccountID: accountID, AdapterMetadata: globalNetworkAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.DescribeGlobalNetworksInput) (*networkmanager.DescribeGlobalNetworksOutput, error) { return client.DescribeGlobalNetworks(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.DescribeGlobalNetworksInput, error) { return &networkmanager.DescribeGlobalNetworksInput{ GlobalNetworkIds: []string{query}, }, nil }, InputMapperList: func(scope string) (*networkmanager.DescribeGlobalNetworksInput, error) { return &networkmanager.DescribeGlobalNetworksInput{}, nil }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.DescribeGlobalNetworksInput) Paginator[*networkmanager.DescribeGlobalNetworksOutput, *networkmanager.Options] { return networkmanager.NewDescribeGlobalNetworksPaginator(client, params) }, OutputMapper: globalNetworkOutputMapper, } } var globalNetworkAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-global-network", DescriptiveName: "Network Manager Global Network", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a global network by id", ListDescription: "List all global networks", SearchDescription: "Search for a global network by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_networkmanager_global_network.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"networkmanager-site", "networkmanager-transit-gateway-registration", "networkmanager-connect-peer-association", "networkmanager-transit-gateway-connect-peer-association", "networkmanager-network-resource-relationship", "networkmanager-link", "networkmanager-device", "networkmanager-connection"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) // idWithGlobalNetwork makes custom ID of given entity with global network ID and this entity ID/ARN func idWithGlobalNetwork(gn, idOrArn string) string { return fmt.Sprintf("%s|%s", gn, idOrArn) } // idWithTypeAndGlobalNetwork makes custom ID of given entity with global network ID and this entity type and ID/ARN func idWithTypeAndGlobalNetwork(gb, rType, idOrArn string) string { return fmt.Sprintf("%s|%s|%s", gb, rType, idOrArn) } ================================================ FILE: aws-source/adapters/networkmanager-global-network_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestGlobalNetworkOutputMapper(t *testing.T) { output := networkmanager.DescribeGlobalNetworksOutput{ GlobalNetworks: []types.GlobalNetwork{ { GlobalNetworkArn: new("arn:aws:networkmanager:eu-west-2:052392120703:networkmanager/global-network/default"), GlobalNetworkId: new("default"), }, }, } items, err := globalNetworkOutputMapper(context.Background(), &networkmanager.Client{}, "foo", nil, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] tests := QueryTests{ { ExpectedType: "networkmanager-site", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-transit-gateway-registration", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-connect-peer-association", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-transit-gateway-connect-peer-association", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-network-resource", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-network-resource-relationship", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, { ExpectedType: "networkmanager-connection", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default", ExpectedScope: "foo", }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager-link-association.go ================================================ package adapters import ( "context" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func linkAssociationOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetLinkAssociationsInput, output *networkmanager.GetLinkAssociationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, s := range output.LinkAssociations { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(s, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if s.GlobalNetworkId == nil || s.LinkId == nil || s.DeviceId == nil { return nil, sdp.NewQueryError(errors.New("globalNetworkId, linkId or deviceId is nil for link association")) } attrs.Set("GlobalNetworkIdLinkIdDeviceId", fmt.Sprintf("%s|%s|%s", *s.GlobalNetworkId, *s.LinkId, *s.DeviceId)) item := sdp.Item{ Type: "networkmanager-link-association", UniqueAttribute: "GlobalNetworkIdLinkIdDeviceId", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *s.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.LinkId), Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.DeviceId), Scope: scope, }, }, }, } switch s.LinkAssociationState { case types.LinkAssociationStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.LinkAssociationStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.LinkAssociationStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.LinkAssociationStateDeleted: item.Health = sdp.Health_HEALTH_ERROR.Enum() default: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } items = append(items, &item) } return items, nil } func NewNetworkManagerLinkAssociationAdapter(client *networkmanager.Client, accountID string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetLinkAssociationsInput, *networkmanager.GetLinkAssociationsOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetLinkAssociationsInput, *networkmanager.GetLinkAssociationsOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, ItemType: "networkmanager-link-association", AdapterMetadata: linkAssociationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetLinkAssociationsInput) (*networkmanager.GetLinkAssociationsOutput, error) { return client.GetLinkAssociations(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.GetLinkAssociationsInput, error) { // We are using a custom id of "{globalNetworkId}|{linkId}|{deviceId}" sections := strings.Split(query, "|") if len(sections) != 3 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-link-association get function", Scope: scope, } } // "default|link-1|device-1" return &networkmanager.GetLinkAssociationsInput{ GlobalNetworkId: §ions[0], LinkId: §ions[1], DeviceId: §ions[2], }, nil }, InputMapperList: func(scope string) (*networkmanager.GetLinkAssociationsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-link-association, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetLinkAssociationsInput) Paginator[*networkmanager.GetLinkAssociationsOutput, *networkmanager.Options] { return networkmanager.NewGetLinkAssociationsPaginator(client, params) }, OutputMapper: linkAssociationOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetLinkAssociationsInput, error) { // We may search by only globalNetworkId or by using a custom id of {globalNetworkId}|recourceType|recourceId f.e.: // default|link|link-1 // default|device|dvc-1 sections := strings.Split(query, "|") switch len(sections) { case 1: // globalNetworkId return &networkmanager.GetLinkAssociationsInput{ GlobalNetworkId: §ions[0], }, nil case 3: switch sections[1] { case "link": // default|link|link-1 return &networkmanager.GetLinkAssociationsInput{ GlobalNetworkId: §ions[0], LinkId: §ions[2], }, nil case "device": // default|device|dvc-1 return &networkmanager.GetLinkAssociationsInput{ GlobalNetworkId: §ions[0], DeviceId: §ions[2], }, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("invalid query for networkmanager-link-association get function, unknown resource type: %v", sections[1]), Scope: scope, } } default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-link-association get function", Scope: scope, } } }, } } var linkAssociationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-link-association", DescriptiveName: "Networkmanager LinkAssociation", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Networkmanager Link Association", SearchDescription: "Search for Networkmanager Link Associations by GlobalNetworkId and DeviceId or LinkId", }, PotentialLinks: []string{"networkmanager-global-network", "networkmanager-link", "networkmanager-device"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-link-association_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestLinkAssociationOutputMapper(t *testing.T) { output := networkmanager.GetLinkAssociationsOutput{ LinkAssociations: []types.LinkAssociation{ { LinkId: new("link-1"), GlobalNetworkId: new("default"), DeviceId: new("dvc-1"), }, }, } scope := "123456789012.eu-west-2" items, err := linkAssociationOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetLinkAssociationsInput{}, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "default|link-1|dvc-1" { t.Fatalf("expected default|link-1|dvc-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|link-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|dvc-1", ExpectedScope: scope, }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager-link.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func linkOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetLinksInput, output *networkmanager.GetLinksOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, s := range output.Links { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(s, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } attrs.Set("GlobalNetworkIdLinkId", idWithGlobalNetwork(*s.GlobalNetworkId, *s.LinkId)) item := sdp.Item{ Type: "networkmanager-link", UniqueAttribute: "GlobalNetworkIdLinkId", Scope: scope, Attributes: attrs, Tags: networkmanagerTagsToMap(s.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *s.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-link-association", Method: sdp.QueryMethod_SEARCH, Query: idWithTypeAndGlobalNetwork(*s.GlobalNetworkId, "link", *s.LinkId), Scope: scope, }, }, }, } if s.SiteId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-site", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.SiteId), Scope: scope, }, }) } if s.LinkArn != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-network-resource-relationship", Method: sdp.QueryMethod_GET, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.LinkArn), Scope: scope, }, }) } switch s.State { case types.LinkStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.LinkStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.LinkStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.LinkStateUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() } items = append(items, &item) } return items, nil } func NewNetworkManagerLinkAdapter(client *networkmanager.Client, accountID string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetLinksInput, *networkmanager.GetLinksOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetLinksInput, *networkmanager.GetLinksOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, ItemType: "networkmanager-link", AdapterMetadata: linkAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetLinksInput) (*networkmanager.GetLinksOutput, error) { return client.GetLinks(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.GetLinksInput, error) { // We are using a custom id of {globalNetworkId}|{linkId} sections := strings.Split(query, "|") if len(sections) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-link get function", Scope: scope, } } return &networkmanager.GetLinksInput{ GlobalNetworkId: §ions[0], LinkIds: []string{ sections[1], }, }, nil }, InputMapperList: func(scope string) (*networkmanager.GetLinksInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-link, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetLinksInput) Paginator[*networkmanager.GetLinksOutput, *networkmanager.Options] { return networkmanager.NewGetLinksPaginator(client, params) }, OutputMapper: linkOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetLinksInput, error) { // Try to parse as ARN first arn, err := ParseARN(query) if err == nil { // Check if it's a networkmanager-link ARN if arn.Service == "networkmanager" && arn.Type() == "link" { // Parse the resource part: link/global-network-{id}/link-{id} // Expected format: link/global-network-01231231231231231/link-11112222aaaabbbb1 resourceParts := strings.Split(arn.Resource, "/") if len(resourceParts) == 3 && resourceParts[0] == "link" && strings.HasPrefix(resourceParts[1], "global-network-") && strings.HasPrefix(resourceParts[2], "link-") { globalNetworkId := resourceParts[1] // Keep full ID including "global-network-" prefix linkId := resourceParts[2] // Keep full ID including "link-" prefix return &networkmanager.GetLinksInput{ GlobalNetworkId: &globalNetworkId, LinkIds: []string{linkId}, }, nil } } // If it's not a valid networkmanager-link ARN, return an error return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not a valid networkmanager-link ARN", Scope: scope, } } // If not an ARN, fall back to the original logic // We may search by only globalNetworkId or by using a custom id of {globalNetworkId}|{siteId} sections := strings.Split(query, "|") switch len(sections) { case 1: // globalNetworkId return &networkmanager.GetLinksInput{ GlobalNetworkId: §ions[0], }, nil case 2: // {globalNetworkId}|{siteId} return &networkmanager.GetLinksInput{ GlobalNetworkId: §ions[0], SiteId: §ions[1], }, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-link get function", Scope: scope, } } }, } } var linkAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-link", DescriptiveName: "Networkmanager Link", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Networkmanager Link", SearchDescription: "Search for Networkmanager Links by GlobalNetworkId, GlobalNetworkId with SiteId, or ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_networkmanager_link.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"networkmanager-global-network", "networkmanager-link-association", "networkmanager-site", "networkmanager-network-resource-relationship"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-link_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestLinkOutputMapper(t *testing.T) { output := networkmanager.GetLinksOutput{ Links: []types.Link{ { LinkId: new("link-1"), GlobalNetworkId: new("default"), SiteId: new("site-1"), LinkArn: new("arn:aws:networkmanager:us-west-2:123456789012:link/link-1"), }, }, } scope := "123456789012.eu-west-2" items, err := linkOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetLinksInput{}, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "default|link-1" { t.Fatalf("expected default|link-1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "networkmanager-site", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|site-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-network-resource-relationship", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default|arn:aws:networkmanager:us-west-2:123456789012:link/link-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link-association", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|link|link-1", ExpectedScope: scope, }, } tests.Execute(t, item) } func TestLinkInputMapperSearch(t *testing.T) { adapter := NewNetworkManagerLinkAdapter(&networkmanager.Client{}, "123456789012", sdpcache.NewNoOpCache()) tests := []struct { name string query string expectedInput *networkmanager.GetLinksInput expectError bool }{ { name: "Valid networkmanager-link ARN", query: "arn:aws:networkmanager::123456789012:link/global-network-01231231231231231/link-11112222aaaabbbb1", expectedInput: &networkmanager.GetLinksInput{ GlobalNetworkId: new("global-network-01231231231231231"), LinkIds: []string{"link-11112222aaaabbbb1"}, }, expectError: false, }, { name: "Global Network ID only", query: "global-network-123456789", expectedInput: &networkmanager.GetLinksInput{ GlobalNetworkId: new("global-network-123456789"), }, expectError: false, }, { name: "Global Network ID and Site ID", query: "global-network-123456789|site-987654321", expectedInput: &networkmanager.GetLinksInput{ GlobalNetworkId: new("global-network-123456789"), SiteId: new("site-987654321"), }, expectError: false, }, { name: "Invalid ARN - wrong service", query: "arn:aws:ec2::123456789012:instance/i-1234567890abcdef0", expectError: true, }, { name: "Invalid ARN - wrong resource type", query: "arn:aws:networkmanager::123456789012:device/global-network-01231231231231231/device-444555aaabbb11223", expectError: true, }, { name: "Invalid ARN - malformed resource", query: "arn:aws:networkmanager::123456789012:link/invalid-format", expectError: true, }, { name: "Invalid ARN - wrong prefixes", query: "arn:aws:networkmanager::123456789012:link/global-network-123/invalid-prefix-123", expectError: true, }, { name: "Invalid query - too many sections", query: "section1|section2|section3", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input, err := adapter.InputMapperSearch(context.Background(), &networkmanager.Client{}, "123456789012.us-east-1", tt.query) if tt.expectError { if err == nil { t.Errorf("Expected error for query %s, but got none", tt.query) } return } if err != nil { t.Errorf("Unexpected error for query %s: %v", tt.query, err) return } if input == nil { t.Errorf("Expected input but got nil for query %s", tt.query) return } // Compare GlobalNetworkId if (input.GlobalNetworkId == nil) != (tt.expectedInput.GlobalNetworkId == nil) { t.Errorf("GlobalNetworkId nil mismatch for query %s", tt.query) return } if input.GlobalNetworkId != nil && tt.expectedInput.GlobalNetworkId != nil { if *input.GlobalNetworkId != *tt.expectedInput.GlobalNetworkId { t.Errorf("Expected GlobalNetworkId %s, got %s for query %s", *tt.expectedInput.GlobalNetworkId, *input.GlobalNetworkId, tt.query) } } // Compare SiteId if (input.SiteId == nil) != (tt.expectedInput.SiteId == nil) { t.Errorf("SiteId nil mismatch for query %s", tt.query) return } if input.SiteId != nil && tt.expectedInput.SiteId != nil { if *input.SiteId != *tt.expectedInput.SiteId { t.Errorf("Expected SiteId %s, got %s for query %s", *tt.expectedInput.SiteId, *input.SiteId, tt.query) } } // Compare LinkIds if len(input.LinkIds) != len(tt.expectedInput.LinkIds) { t.Errorf("Expected %d LinkIds, got %d for query %s", len(tt.expectedInput.LinkIds), len(input.LinkIds), tt.query) return } for i, linkId := range input.LinkIds { if linkId != tt.expectedInput.LinkIds[i] { t.Errorf("Expected LinkId %s, got %s at index %d for query %s", tt.expectedInput.LinkIds[i], linkId, i, tt.query) } } }) } } ================================================ FILE: aws-source/adapters/networkmanager-network-resource-relationship.go ================================================ package adapters import ( "context" "crypto/sha256" "encoding/base64" "errors" "fmt" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func networkResourceRelationshipOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, input *networkmanager.GetNetworkResourceRelationshipsInput, output *networkmanager.GetNetworkResourceRelationshipsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) // Connecting networkmanager-global-network with internal or external resources happening in // networkmanager-network-resource source // No point to double-link same resources to networkmanager-global-network here again // Instead here we will create connections between these resources itself for _, relationship := range output.Relationships { if relationship.From == nil || relationship.To == nil { continue } // Parse the ARNs fromArn, err := ParseARN(*relationship.From) if err != nil { return nil, err } toArn, err := ParseARN(*relationship.To) if err != nil { return nil, err } // We need to create a unique attribute for each item so we'll create a // hash to avoid it being too long hasher := sha256.New() hasher.Write([]byte(fromArn.String())) hasher.Write([]byte(toArn.String())) sha := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) attrs, err := sdp.ToAttributes(map[string]any{ "Hash": sha, "From": fromArn.String(), "To": toArn.String(), }) if err != nil { return nil, err } item := sdp.Item{ Type: "networkmanager-network-resource-relationship", UniqueAttribute: "Hash", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{}, } toResourceType := fmt.Sprintf("%s-%s", toArn.Service, toArn.Type()) // For each linked item we must define +overmind:link comment section switch toResourceType { case "networkmanager-connection": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-connection", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*input.GlobalNetworkId, toArn.ResourceID()), Scope: scope, }, }) case "networkmanager-device": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*input.GlobalNetworkId, toArn.ResourceID()), Scope: scope, }, }) case "networkmanager-link": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*input.GlobalNetworkId, toArn.ResourceID()), Scope: scope, }, }) case "networkmanager-site": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-site", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*input.GlobalNetworkId, toArn.ResourceID()), Scope: scope, }, }) case "directconnect-connection": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-connection", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "directconnect-direct-connect-gateway": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-direct-connect-gateway", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "directconnect-virtual-interface": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "directconnect-virtual-interface", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "ec2-customer-gateway": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-customer-gateway", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "ec2-transit-gateway": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "ec2-transit-gateway-attachment": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-attachment", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "ec2-transit-gateway-connect-peer": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-connect-peer", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "ec2-transit-gateway-route-table": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) case "ec2-vpn-connection": item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpn-connection", Method: sdp.QueryMethod_GET, Query: toArn.ResourceID(), Scope: scope, }, }) default: // skip unknown item types continue } items = append(items, &item) } return items, nil } func NewNetworkManagerNetworkResourceRelationshipsAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetNetworkResourceRelationshipsInput, *networkmanager.GetNetworkResourceRelationshipsOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetNetworkResourceRelationshipsInput, *networkmanager.GetNetworkResourceRelationshipsOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-network-resource-relationship", AdapterMetadata: networkResourceRelationshipAdapterMetadata, cache: cache, OutputMapper: networkResourceRelationshipOutputMapper, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetNetworkResourceRelationshipsInput) (*networkmanager.GetNetworkResourceRelationshipsOutput, error) { return client.GetNetworkResourceRelationships(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.GetNetworkResourceRelationshipsInput, error) { return nil, sdp.NewQueryError(errors.New("get not supported for networkmanager-network-resource-relationship, use search")) }, InputMapperList: func(scope string) (*networkmanager.GetNetworkResourceRelationshipsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-network-resource-relationship, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetNetworkResourceRelationshipsInput) Paginator[*networkmanager.GetNetworkResourceRelationshipsOutput, *networkmanager.Options] { return networkmanager.NewGetNetworkResourceRelationshipsPaginator(client, params) }, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetNetworkResourceRelationshipsInput, error) { // Search by GlobalNetworkId return &networkmanager.GetNetworkResourceRelationshipsInput{ GlobalNetworkId: &query, }, nil }, } } var networkResourceRelationshipAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-network-resource-relationship", DescriptiveName: "Networkmanager Network Resource Relationships", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Search: true, SearchDescription: "Search for Networkmanager NetworkResourceRelationships by GlobalNetworkId", }, PotentialLinks: []string{"networkmanager-connection", "networkmanager-device", "networkmanager-link", "networkmanager-site", "directconnect-connection", "directconnect-direct-connect-gateway", "directconnect-virtual-interface", "ec2-customer"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-network-resource-relationship_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestNetworkResourceRelationshipOutputMapper(t *testing.T) { scope := "123456789012.eu-west-2" tests := []struct { name string input networkmanager.GetNetworkResourceRelationshipsInput output networkmanager.GetNetworkResourceRelationshipsOutput tests []QueryTests }{ { name: "ok, one entity", input: networkmanager.GetNetworkResourceRelationshipsInput{ GlobalNetworkId: new("default"), }, output: networkmanager.GetNetworkResourceRelationshipsOutput{ Relationships: []types.Relationship{ // connection, device { From: new("arn:aws:networkmanager:us-west-2:123456789012:connection/conn-1"), To: new("arn:aws:networkmanager:us-west-2:123456789012:device/d-1"), }, { To: new("arn:aws:networkmanager:us-west-2:123456789012:connection/conn-1"), From: new("arn:aws:networkmanager:us-west-2:123456789012:device/d-1"), }, // link, site { From: new("arn:aws:networkmanager:us-west-2:123456789012:link/link-1"), To: new("arn:aws:networkmanager:us-west-2:123456789012:site/site-1"), }, { To: new("arn:aws:networkmanager:us-west-2:123456789012:link/link-1"), From: new("arn:aws:networkmanager:us-west-2:123456789012:site/site-1"), }, // directconnect-connection, directconnect-direct-connect-gateway { From: new("arn:aws:directconnect:us-west-2:123456789012:connection/dxconn-1"), To: new("arn:aws:directconnect:us-west-2:123456789012:direct-connect-gateway/gw-1"), }, { To: new("arn:aws:directconnect:us-west-2:123456789012:connection/dxconn-1"), From: new("arn:aws:directconnect:us-west-2:123456789012:direct-connect-gateway/gw-1"), }, // directconnect-virtual-interface, ec2-customer-gateway { From: new("arn:aws:directconnect:us-west-2:123456789012:virtual-interface/vif-1"), To: new("arn:aws:ec2:us-west-2:123456789012:customer-gateway/gw-1"), }, { To: new("arn:aws:directconnect:us-west-2:123456789012:virtual-interface/vif-1"), From: new("arn:aws:ec2:us-west-2:123456789012:customer-gateway/gw-1"), }, // ec2-transit-gateway, ec2-transit-gateway-attachment { From: new("arn:aws:ec2:us-east-2:986543144159:transit-gateway/tgw-06910e97a1fbdf66a"), To: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway-attachment/tgwa-1"), }, { To: new("arn:aws:ec2:us-east-2:986543144159:transit-gateway/tgw-06910e97a1fbdf66a"), From: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway-attachment/tgwa-1"), }, // ec2-transit-gateway-route-table, ec2-transit-gateway-connect-peer { From: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway-connect-peer/tgw-cnp-1"), To: new("arn:aws:ec2:us-east-2:986543144159:transit-gateway-route-table/tgw-rtb-043b7b4c0db1e4833"), }, { To: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway-connect-peer/tgw-cnp-1"), From: new("arn:aws:ec2:us-east-2:986543144159:transit-gateway-route-table/tgw-rtb-043b7b4c0db1e4833"), }, // connection, ec2-vpn-connection { From: new("arn:aws:networkmanager:us-west-2:123456789012:connection/conn-1"), To: new("arn:aws:ec2:us-west-2:123456789012:vpn-connection/conn-1"), }, { To: new("arn:aws:networkmanager:us-west-2:123456789012:connection/conn-1"), From: new("arn:aws:ec2:us-west-2:123456789012:vpn-connection/conn-1"), }, }, }, tests: []QueryTests{ // connection to device { { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|d-1", ExpectedScope: scope, }, }, // device to connection { { ExpectedType: "networkmanager-connection", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|conn-1", ExpectedScope: scope, }, }, // link to site { { ExpectedType: "networkmanager-site", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|site-1", ExpectedScope: scope, }, }, // site to link { { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|link-1", ExpectedScope: scope, }, }, // directconnect-connection to directconnect-direct-connect-gateway { { ExpectedType: "directconnect-direct-connect-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gw-1", ExpectedScope: scope, }, }, // directconnect-direct-connect-gateway to directconnect-connection { { ExpectedType: "directconnect-connection", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dxconn-1", ExpectedScope: scope, }, }, // directconnect-virtual-interface to ec2-customer-gateway { { ExpectedType: "ec2-customer-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gw-1", ExpectedScope: scope, }, }, // ec2-customer-gateway to directconnect-virtual-interface { { ExpectedType: "directconnect-virtual-interface", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vif-1", ExpectedScope: scope, }, }, // ec2-transit-gateway to ec2-transit-gateway-attachment { { ExpectedType: "ec2-transit-gateway-attachment", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "tgwa-1", ExpectedScope: scope, }, }, // ec2-transit-gateway-attachment to ec2-transit-gateway { { ExpectedType: "ec2-transit-gateway", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "tgw-06910e97a1fbdf66a", ExpectedScope: scope, }, }, // ec2-transit-gateway-connect-peer to ec2-transit-gateway-route-table { { ExpectedType: "ec2-transit-gateway-route-table", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "tgw-rtb-043b7b4c0db1e4833", ExpectedScope: scope, }, }, // ec2-transit-gateway-route-table to ec2-transit-gateway-connect-peer { { ExpectedType: "ec2-transit-gateway-connect-peer", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "tgw-cnp-1", ExpectedScope: scope, }, }, // connection to ec2-vpn-connection { { ExpectedType: "ec2-vpn-connection", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "conn-1", ExpectedScope: scope, }, }, // ec2-vpn-connection to connection { { ExpectedType: "networkmanager-connection", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|conn-1", ExpectedScope: scope, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { items, err := networkResourceRelationshipOutputMapper(context.Background(), &networkmanager.Client{}, scope, &tt.input, &tt.output) if err != nil { t.Error(err) } for i := range items { if err := items[i].Validate(); err != nil { t.Error(err) } tt.tests[i].Execute(t, items[i]) } }) } } ================================================ FILE: aws-source/adapters/networkmanager-site-to-site-vpn-attachment.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func getSiteToSiteVpnAttachmentGetFunc(ctx context.Context, client *networkmanager.Client, _, query string) (*types.SiteToSiteVpnAttachment, error) { out, err := client.GetSiteToSiteVpnAttachment(ctx, &networkmanager.GetSiteToSiteVpnAttachmentInput{ AttachmentId: &query, }) if err != nil { return nil, err } return out.SiteToSiteVpnAttachment, nil } func siteToSiteVpnAttachmentItemMapper(_, scope string, awsItem *types.SiteToSiteVpnAttachment) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } // The uniqueAttributeValue for this is a nested value of peeringId: if awsItem != nil && awsItem.Attachment != nil { attributes.Set("AttachmentId", *awsItem.Attachment.AttachmentId) } item := sdp.Item{ Type: "networkmanager-site-to-site-vpn-attachment", UniqueAttribute: "AttachmentId", Attributes: attributes, Scope: scope, } if awsItem.Attachment != nil { if awsItem.Attachment.CoreNetworkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ // Search for core network Type: "networkmanager-core-network", Method: sdp.QueryMethod_GET, Query: *awsItem.Attachment.CoreNetworkId, Scope: scope, }, }) } switch awsItem.Attachment.State { //nolint:exhaustive case types.AttachmentStateCreating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.AttachmentStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.AttachmentStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.AttachmentStateFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() default: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } if awsItem.VpnConnectionArn != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpn-connection", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.VpnConnectionArn, Scope: scope, }, }) } return &item, nil } func NewNetworkManagerSiteToSiteVpnAttachmentAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*types.SiteToSiteVpnAttachment, *networkmanager.Client, *networkmanager.Options] { return &GetListAdapter[*types.SiteToSiteVpnAttachment, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-site-to-site-vpn-attachment", AdapterMetadata: siteToSiteVpnAttachmentAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *networkmanager.Client, scope string, query string) (*types.SiteToSiteVpnAttachment, error) { return getSiteToSiteVpnAttachmentGetFunc(ctx, client, scope, query) }, ItemMapper: siteToSiteVpnAttachmentItemMapper, ListFunc: func(ctx context.Context, client *networkmanager.Client, scope string) ([]*types.SiteToSiteVpnAttachment, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-site-to-site-vpn-attachment, use get", Scope: scope, } }, } } var siteToSiteVpnAttachmentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-site-to-site-vpn-attachment", DescriptiveName: "Networkmanager Site To Site Vpn Attachment", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Networkmanager Site To Site Vpn Attachment by id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_site_to_site_vpn_attachment.id"}, }, PotentialLinks: []string{"networkmanager-core-network", "ec2-vpn-connection"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-site-to-site-vpn-attachment_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestSiteToSiteVpnAttachmentOutputMapper(t *testing.T) { scope := "123456789012.eu-west-2" tests := []struct { name string item *types.SiteToSiteVpnAttachment expectedHealth sdp.Health expectedAttr string tests QueryTests }{ { name: "ok", item: &types.SiteToSiteVpnAttachment{ Attachment: &types.Attachment{ AttachmentId: new("stsa-1"), CoreNetworkId: new("cn-1"), State: types.AttachmentStateAvailable, }, VpnConnectionArn: new("arn:aws:ec2:us-west-2:123456789012:vpn-connection/vpn-1234"), }, expectedHealth: sdp.Health_HEALTH_OK, expectedAttr: "stsa-1", tests: QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cn-1", ExpectedScope: scope, }, { ExpectedType: "ec2-vpn-connection", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-west-2:123456789012:vpn-connection/vpn-1234", ExpectedScope: scope, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { item, err := siteToSiteVpnAttachmentItemMapper("", scope, tt.item) if err != nil { t.Error(err) } if item.UniqueAttributeValue() != tt.expectedAttr { t.Fatalf("want %s, got %s", tt.expectedAttr, item.UniqueAttributeValue()) } if tt.expectedHealth != item.GetHealth() { t.Fatalf("want %d, got %d", tt.expectedHealth, item.GetHealth()) } tt.tests.Execute(t, item) }) } } ================================================ FILE: aws-source/adapters/networkmanager-site.go ================================================ package adapters import ( "context" "errors" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func siteOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetSitesInput, output *networkmanager.GetSitesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, s := range output.Sites { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(s, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if s.GlobalNetworkId == nil || s.SiteId == nil { return nil, sdp.NewQueryError(errors.New("globalNetworkId or siteId is nil for site")) } attrs.Set("GlobalNetworkIdSiteId", idWithGlobalNetwork(*s.GlobalNetworkId, *s.SiteId)) item := sdp.Item{ Type: "networkmanager-site", UniqueAttribute: "GlobalNetworkIdSiteId", Scope: scope, Attributes: attrs, Tags: networkmanagerTagsToMap(s.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *s.GlobalNetworkId, Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.SiteId), Scope: scope, }, }, { Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*s.GlobalNetworkId, *s.SiteId), Scope: scope, }, }, }, } switch s.State { case types.SiteStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.SiteStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.SiteStateUpdating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.SiteStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() } items = append(items, &item) } return items, nil } func NewNetworkManagerSiteAdapter(client *networkmanager.Client, accountID string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetSitesInput, *networkmanager.GetSitesOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetSitesInput, *networkmanager.GetSitesOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, ItemType: "networkmanager-site", AdapterMetadata: siteAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetSitesInput) (*networkmanager.GetSitesOutput, error) { return client.GetSites(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.GetSitesInput, error) { // We are using a custom id of {globalNetworkId}|{siteId} sections := strings.Split(query, "|") if len(sections) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-site get function", Scope: scope, } } return &networkmanager.GetSitesInput{ GlobalNetworkId: §ions[0], SiteIds: []string{ sections[1], }, }, nil }, InputMapperList: func(scope string) (*networkmanager.GetSitesInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-site, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetSitesInput) Paginator[*networkmanager.GetSitesOutput, *networkmanager.Options] { return networkmanager.NewGetSitesPaginator(client, params) }, OutputMapper: siteOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetSitesInput, error) { // Try to parse as ARN first arn, err := ParseARN(query) if err == nil { // Check if it's a networkmanager-site ARN if arn.Service == "networkmanager" && arn.Type() == "site" { // Parse the resource part: site/global-network-{id}/site-{id} // Expected format: site/global-network-01231231231231231/site-444555aaabbb11223 resourceParts := strings.Split(arn.Resource, "/") if len(resourceParts) == 3 && resourceParts[0] == "site" && strings.HasPrefix(resourceParts[1], "global-network-") && strings.HasPrefix(resourceParts[2], "site-") { globalNetworkId := resourceParts[1] // Keep full ID including "global-network-" prefix siteId := resourceParts[2] // Keep full ID including "site-" prefix return &networkmanager.GetSitesInput{ GlobalNetworkId: &globalNetworkId, SiteIds: []string{siteId}, }, nil } } // If it's not a valid networkmanager-site ARN, return an error return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not a valid networkmanager-site ARN", Scope: scope, } } // If not an ARN, treat as GlobalNetworkId for backward compatibility return &networkmanager.GetSitesInput{ GlobalNetworkId: &query, }, nil }, } } var siteAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-site", DescriptiveName: "Networkmanager Site", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Networkmanager Site", SearchDescription: "Search for Networkmanager Sites by GlobalNetworkId or Site ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_networkmanager_site.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"networkmanager-global-network", "networkmanager-link", "networkmanager-device"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-site_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestSiteOutputMapper(t *testing.T) { output := networkmanager.GetSitesOutput{ Sites: []types.Site{ { SiteId: new("site1"), GlobalNetworkId: new("default"), }, }, } scope := "123456789012.eu-west-2" items, err := siteOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetSitesInput{}, &output) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != "default|site1" { t.Fatalf("expected default|site1, got %v", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|site1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|site1", ExpectedScope: scope, }, } tests.Execute(t, item) } func TestSiteInputMapperSearch(t *testing.T) { adapter := NewNetworkManagerSiteAdapter(&networkmanager.Client{}, "123456789012", sdpcache.NewNoOpCache()) tests := []struct { name string query string expectedInput *networkmanager.GetSitesInput expectError bool }{ { name: "Valid networkmanager-site ARN", query: "arn:aws:networkmanager::123456789012:site/global-network-01231231231231231/site-444555aaabbb11223", expectedInput: &networkmanager.GetSitesInput{ GlobalNetworkId: new("global-network-01231231231231231"), SiteIds: []string{"site-444555aaabbb11223"}, }, expectError: false, }, { name: "Global Network ID (backward compatibility)", query: "global-network-123456789", expectedInput: &networkmanager.GetSitesInput{ GlobalNetworkId: new("global-network-123456789"), }, expectError: false, }, { name: "Invalid ARN - wrong service", query: "arn:aws:ec2::123456789012:instance/i-1234567890abcdef0", expectError: true, }, { name: "Invalid ARN - wrong resource type", query: "arn:aws:networkmanager::123456789012:device/global-network-01231231231231231/device-444555aaabbb11223", expectError: true, }, { name: "Invalid ARN - malformed resource", query: "arn:aws:networkmanager::123456789012:site/invalid-format", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input, err := adapter.InputMapperSearch(context.Background(), &networkmanager.Client{}, "123456789012.us-east-1", tt.query) if tt.expectError { if err == nil { t.Errorf("Expected error for query %s, but got none", tt.query) } return } if err != nil { t.Errorf("Unexpected error for query %s: %v", tt.query, err) return } if input == nil { t.Errorf("Expected input but got nil for query %s", tt.query) return } // Compare GlobalNetworkId if (input.GlobalNetworkId == nil) != (tt.expectedInput.GlobalNetworkId == nil) { t.Errorf("GlobalNetworkId nil mismatch for query %s", tt.query) return } if input.GlobalNetworkId != nil && tt.expectedInput.GlobalNetworkId != nil { if *input.GlobalNetworkId != *tt.expectedInput.GlobalNetworkId { t.Errorf("Expected GlobalNetworkId %s, got %s for query %s", *tt.expectedInput.GlobalNetworkId, *input.GlobalNetworkId, tt.query) } } // Compare SiteIds if len(input.SiteIds) != len(tt.expectedInput.SiteIds) { t.Errorf("Expected %d SiteIds, got %d for query %s", len(tt.expectedInput.SiteIds), len(input.SiteIds), tt.query) return } for i, siteId := range input.SiteIds { if siteId != tt.expectedInput.SiteIds[i] { t.Errorf("Expected SiteId %s, got %s at index %d for query %s", tt.expectedInput.SiteIds[i], siteId, i, tt.query) } } }) } } ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-connect-peer-association.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func transitGatewayConnectPeerAssociationsOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetTransitGatewayConnectPeerAssociationsInput, output *networkmanager.GetTransitGatewayConnectPeerAssociationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, a := range output.TransitGatewayConnectPeerAssociations { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(a, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } attrs.Set("GlobalNetworkIdWithTransitGatewayConnectPeerArn", idWithGlobalNetwork(*a.GlobalNetworkId, *a.TransitGatewayConnectPeerArn)) item := sdp.Item{ Type: "networkmanager-transit-gateway-connect-peer-association", UniqueAttribute: "GlobalNetworkIdWithTransitGatewayConnectPeerArn", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *a.GlobalNetworkId, Scope: scope, }, }, }, } if a.DeviceId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-device", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*a.GlobalNetworkId, *a.DeviceId), Scope: scope, }, }) } if a.LinkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-link", Method: sdp.QueryMethod_SEARCH, Query: idWithGlobalNetwork(*a.GlobalNetworkId, *a.LinkId), Scope: scope, }, }) } switch a.State { case types.TransitGatewayConnectPeerAssociationStatePending: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.TransitGatewayConnectPeerAssociationStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.TransitGatewayConnectPeerAssociationStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.TransitGatewayConnectPeerAssociationStateDeleted: item.Health = sdp.Health_HEALTH_PENDING.Enum() } items = append(items, &item) } return items, nil } func NewNetworkManagerTransitGatewayConnectPeerAssociationAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetTransitGatewayConnectPeerAssociationsInput, *networkmanager.GetTransitGatewayConnectPeerAssociationsOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetTransitGatewayConnectPeerAssociationsInput, *networkmanager.GetTransitGatewayConnectPeerAssociationsOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-transit-gateway-connect-peer-association", AdapterMetadata: transitGatewayConnectPeerAssociationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetTransitGatewayConnectPeerAssociationsInput) (*networkmanager.GetTransitGatewayConnectPeerAssociationsOutput, error) { return client.GetTransitGatewayConnectPeerAssociations(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.GetTransitGatewayConnectPeerAssociationsInput, error) { sections := strings.Split(query, "|") if len(sections) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "invalid query for networkmanager-transit-gateway-connect-peer-association. Use {GlobalNetworkId}|{TransitGatewayConnectPeerArn} format", Scope: scope, } } // we are using a custom id of {globalNetworkId}|{networkmanager-connect-peer.ID} // e.g. searching from networkmanager-connect-peer return &networkmanager.GetTransitGatewayConnectPeerAssociationsInput{ GlobalNetworkId: §ions[0], TransitGatewayConnectPeerArns: []string{ sections[1], }, }, nil }, InputMapperList: func(scope string) (*networkmanager.GetTransitGatewayConnectPeerAssociationsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "list not supported for networkmanager-transit-gateway-connect-peer-association, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetTransitGatewayConnectPeerAssociationsInput) Paginator[*networkmanager.GetTransitGatewayConnectPeerAssociationsOutput, *networkmanager.Options] { return networkmanager.NewGetTransitGatewayConnectPeerAssociationsPaginator(client, params) }, OutputMapper: transitGatewayConnectPeerAssociationsOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetTransitGatewayConnectPeerAssociationsInput, error) { // Search by GlobalNetworkId return &networkmanager.GetTransitGatewayConnectPeerAssociationsInput{ GlobalNetworkId: &query, }, nil }, } } var transitGatewayConnectPeerAssociationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-transit-gateway-connect-peer-association", DescriptiveName: "Networkmanager Transit Gateway Connect Peer Association", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Networkmanager Transit Gateway Connect Peer Association by id", ListDescription: "List all Networkmanager Transit Gateway Connect Peer Associations", SearchDescription: "Search for Networkmanager Transit Gateway Connect Peer Associations by GlobalNetworkId", }, PotentialLinks: []string{"networkmanager-global-network", "networkmanager-device", "networkmanager-link"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-connect-peer-association_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestTransitGatewayConnectPeerAssociationsOutputMapper(t *testing.T) { scope := "123456789012.eu-west-2" tests := []struct { name string out networkmanager.GetTransitGatewayConnectPeerAssociationsOutput expectedHealth sdp.Health expectedAttr string tests QueryTests }{ { name: "ok", out: networkmanager.GetTransitGatewayConnectPeerAssociationsOutput{ TransitGatewayConnectPeerAssociations: []types.TransitGatewayConnectPeerAssociation{ { GlobalNetworkId: new("default"), TransitGatewayConnectPeerArn: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway-connect-peer-association/tgw-1234"), State: types.TransitGatewayConnectPeerAssociationStateAvailable, DeviceId: new("device-1"), LinkId: new("link-1"), }, }, }, expectedHealth: sdp.Health_HEALTH_OK, expectedAttr: "default|arn:aws:ec2:us-west-2:123456789012:transit-gateway-connect-peer-association/tgw-1234", tests: QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "networkmanager-device", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|device-1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-link", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "default|link-1", ExpectedScope: scope, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { items, err := transitGatewayConnectPeerAssociationsOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetTransitGatewayConnectPeerAssociationsInput{}, &tt.out) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != tt.expectedAttr { t.Fatalf("want %s, got %s", tt.expectedAttr, item.UniqueAttributeValue()) } if tt.expectedHealth != item.GetHealth() { t.Fatalf("want %d, got %d", tt.expectedHealth, item.GetHealth()) } tt.tests.Execute(t, item) }) } } ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-peering.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func getTransitGatewayPeeringGetFunc(ctx context.Context, client *networkmanager.Client, _, query string) (*types.TransitGatewayPeering, error) { out, err := client.GetTransitGatewayPeering(ctx, &networkmanager.GetTransitGatewayPeeringInput{ PeeringId: &query, }) if err != nil { return nil, err } return out.TransitGatewayPeering, nil } func transitGatewayPeeringItemMapper(_, scope string, awsItem *types.TransitGatewayPeering) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } // The uniqueAttributeValue for this is a nested value of peeringId: if awsItem != nil && awsItem.Peering != nil { attributes.Set("PeeringId", *awsItem.Peering.PeeringId) } item := sdp.Item{ Type: "networkmanager-transit-gateway-peering", UniqueAttribute: "PeeringId", Attributes: attributes, Scope: scope, Tags: networkmanagerTagsToMap(awsItem.Peering.Tags), } if awsItem.Peering != nil { if awsItem.Peering.CoreNetworkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-core-network", Method: sdp.QueryMethod_GET, Query: *awsItem.Peering.CoreNetworkId, Scope: scope, }, }) } switch awsItem.Peering.State { case types.PeeringStateCreating: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.PeeringStateAvailable: item.Health = sdp.Health_HEALTH_OK.Enum() case types.PeeringStateDeleting: item.Health = sdp.Health_HEALTH_PENDING.Enum() case types.PeeringStateFailed: item.Health = sdp.Health_HEALTH_ERROR.Enum() } } if awsItem.TransitGatewayPeeringAttachmentId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-peering-attachment", Method: sdp.QueryMethod_GET, Query: *awsItem.TransitGatewayPeeringAttachmentId, Scope: scope, }, }) } // ARN example: "arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234" if awsItem.TransitGatewayArn != nil { if arn, err := ParseARN(*awsItem.TransitGatewayArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.TransitGatewayArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } return &item, nil } func NewNetworkManagerTransitGatewayPeeringAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*types.TransitGatewayPeering, *networkmanager.Client, *networkmanager.Options] { return &GetListAdapter[*types.TransitGatewayPeering, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-transit-gateway-peering", AdapterMetadata: transitGatewayPeeringAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *networkmanager.Client, scope string, query string) (*types.TransitGatewayPeering, error) { return getTransitGatewayPeeringGetFunc(ctx, client, scope, query) }, ItemMapper: transitGatewayPeeringItemMapper, ListFunc: func(ctx context.Context, client *networkmanager.Client, scope string) ([]*types.TransitGatewayPeering, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-transit-gateway-peering, use get", Scope: scope, } }, } } var transitGatewayPeeringAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-transit-gateway-peering", DescriptiveName: "Networkmanager Transit Gateway Peering", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Networkmanager Transit Gateway Peering by id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_transit_gateway_peering.id"}, }, PotentialLinks: []string{"networkmanager-core-network", "ec2-transit-gateway-peering-attachment", "ec2-transit-gateway"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-peering_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestTransitGatewayPeeringOutputMapper(t *testing.T) { scope := "123456789012.eu-west-2" tests := []struct { name string item *types.TransitGatewayPeering expectedHealth sdp.Health expectedAttr string tests QueryTests }{ { name: "ok", item: &types.TransitGatewayPeering{ Peering: &types.Peering{ PeeringId: new("tgp-1"), CoreNetworkId: new("cn-1"), State: types.PeeringStateAvailable, }, TransitGatewayArn: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234"), TransitGatewayPeeringAttachmentId: new("gpa-1"), }, expectedHealth: sdp.Health_HEALTH_OK, expectedAttr: "tgp-1", tests: QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cn-1", ExpectedScope: scope, }, { ExpectedType: "ec2-transit-gateway", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234", ExpectedScope: "123456789012.us-west-2", }, { ExpectedType: "ec2-transit-gateway-peering-attachment", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gpa-1", ExpectedScope: scope, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { item, err := transitGatewayPeeringItemMapper("", scope, tt.item) if err != nil { t.Error(err) } if item.UniqueAttributeValue() != tt.expectedAttr { t.Fatalf("want %s, got %s", tt.expectedAttr, item.UniqueAttributeValue()) } if tt.expectedHealth != item.GetHealth() { t.Fatalf("want %d, got %d", tt.expectedHealth, item.GetHealth()) } tt.tests.Execute(t, item) }) } } ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-registration.go ================================================ package adapters import ( "context" "errors" "strings" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func transitGatewayRegistrationOutputMapper(_ context.Context, _ *networkmanager.Client, scope string, _ *networkmanager.GetTransitGatewayRegistrationsInput, output *networkmanager.GetTransitGatewayRegistrationsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, r := range output.TransitGatewayRegistrations { var err error var attrs *sdp.ItemAttributes attrs, err = ToAttributesWithExclude(r) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if r.GlobalNetworkId == nil || r.TransitGatewayArn == nil { return nil, sdp.NewQueryError(errors.New("globalNetworkId or transitGatewayArn is nil for transit gateway registration")) } attrs.Set("GlobalNetworkIdWithTransitGatewayARN", idWithGlobalNetwork(*r.GlobalNetworkId, *r.TransitGatewayArn)) item := sdp.Item{ Type: "networkmanager-transit-gateway-registration", UniqueAttribute: "GlobalNetworkIdWithTransitGatewayARN", Scope: scope, Attributes: attrs, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "networkmanager-global-network", Method: sdp.QueryMethod_GET, Query: *r.GlobalNetworkId, Scope: scope, }, }, }, } // ARN example: "arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234" if r.TransitGatewayArn != nil { if arn, err := ParseARN(*r.TransitGatewayArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway", Method: sdp.QueryMethod_SEARCH, Query: *r.TransitGatewayArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } items = append(items, &item) } return items, nil } func NewNetworkManagerTransitGatewayRegistrationAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*networkmanager.GetTransitGatewayRegistrationsInput, *networkmanager.GetTransitGatewayRegistrationsOutput, *networkmanager.Client, *networkmanager.Options] { return &DescribeOnlyAdapter[*networkmanager.GetTransitGatewayRegistrationsInput, *networkmanager.GetTransitGatewayRegistrationsOutput, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-transit-gateway-registration", AdapterMetadata: transitGatewayRegistrationAdapterMetadata, cache: cache, DescribeFunc: func(ctx context.Context, client *networkmanager.Client, input *networkmanager.GetTransitGatewayRegistrationsInput) (*networkmanager.GetTransitGatewayRegistrationsOutput, error) { return client.GetTransitGatewayRegistrations(ctx, input) }, InputMapperGet: func(scope, query string) (*networkmanager.GetTransitGatewayRegistrationsInput, error) { sections := strings.Split(query, "|") if len(sections) != 2 { return nil, sdp.NewQueryError(errors.New("invalid query for networkmanager-transit-gateway-registration get function, must be in the format {globalNetworkId}|{transitGatewayARN}")) } // we are using a custom id of {globalNetworkId}|{transitGatewayARN} // e.g. searching from ec2-transit-gateway return &networkmanager.GetTransitGatewayRegistrationsInput{ GlobalNetworkId: §ions[0], TransitGatewayArns: []string{ sections[1], }, }, nil }, InputMapperList: func(scope string) (*networkmanager.GetTransitGatewayRegistrationsInput, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-transit-gateway-registration, use search", Scope: scope, } }, PaginatorBuilder: func(client *networkmanager.Client, params *networkmanager.GetTransitGatewayRegistrationsInput) Paginator[*networkmanager.GetTransitGatewayRegistrationsOutput, *networkmanager.Options] { return networkmanager.NewGetTransitGatewayRegistrationsPaginator(client, params) }, OutputMapper: transitGatewayRegistrationOutputMapper, InputMapperSearch: func(ctx context.Context, client *networkmanager.Client, scope, query string) (*networkmanager.GetTransitGatewayRegistrationsInput, error) { // Search by GlobalNetworkId return &networkmanager.GetTransitGatewayRegistrationsInput{ GlobalNetworkId: &query, }, nil }, } } var transitGatewayRegistrationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-transit-gateway-registration", DescriptiveName: "Networkmanager Transit Gateway Registrations", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a Networkmanager Transit Gateway Registrations", ListDescription: "List all Networkmanager Transit Gateway Registrations", SearchDescription: "Search for Networkmanager Transit Gateway Registrations by GlobalNetworkId", }, PotentialLinks: []string{"networkmanager-global-network", "ec2-transit-gateway"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-registration_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" ) func TestTransitGatewayRegistrationOutputMapper(t *testing.T) { scope := "123456789012.eu-west-2" tests := []struct { name string out networkmanager.GetTransitGatewayRegistrationsOutput expectedAttr string tests QueryTests }{ { name: "ok", out: networkmanager.GetTransitGatewayRegistrationsOutput{ TransitGatewayRegistrations: []types.TransitGatewayRegistration{ { GlobalNetworkId: new("default"), TransitGatewayArn: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234"), State: &types.TransitGatewayRegistrationStateReason{ Code: types.TransitGatewayRegistrationStateAvailable, }, }, }, }, expectedAttr: "default|arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234", tests: QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "ec2-transit-gateway", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234", ExpectedScope: "123456789012.us-west-2", }, }, }, { name: "ok, deleting", out: networkmanager.GetTransitGatewayRegistrationsOutput{ TransitGatewayRegistrations: []types.TransitGatewayRegistration{ { GlobalNetworkId: new("default"), TransitGatewayArn: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234"), State: &types.TransitGatewayRegistrationStateReason{ Code: types.TransitGatewayRegistrationStateDeleting, }, }, }, }, expectedAttr: "default|arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234", tests: QueryTests{ { ExpectedType: "networkmanager-global-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: scope, }, { ExpectedType: "ec2-transit-gateway", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-west-2:123456789012:transit-gateway/tgw-1234", ExpectedScope: "123456789012.us-west-2", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { items, err := transitGatewayRegistrationOutputMapper(context.Background(), &networkmanager.Client{}, scope, &networkmanager.GetTransitGatewayRegistrationsInput{}, &tt.out) if err != nil { t.Error(err) } for _, item := range items { if err := item.Validate(); err != nil { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v", len(items)) } item := items[0] // Ensure unique attribute err = item.Validate() if err != nil { t.Error(err) } if item.UniqueAttributeValue() != tt.expectedAttr { t.Fatalf("want %s, got %s", tt.expectedAttr, item.UniqueAttributeValue()) } tt.tests.Execute(t, item) }) } } ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-route-table-attachment.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func getTransitGatewayRouteTableAttachmentGetFunc(ctx context.Context, client *networkmanager.Client, _, query string) (*types.TransitGatewayRouteTableAttachment, error) { out, err := client.GetTransitGatewayRouteTableAttachment(ctx, &networkmanager.GetTransitGatewayRouteTableAttachmentInput{ AttachmentId: &query, }) if err != nil { return nil, err } return out.TransitGatewayRouteTableAttachment, nil } func transitGatewayRouteTableAttachmentItemMapper(_, scope string, awsItem *types.TransitGatewayRouteTableAttachment) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } // The uniqueAttributeValue for this is a nested value of AttachmentId: if awsItem != nil && awsItem.Attachment != nil { attributes.Set("AttachmentId", *awsItem.Attachment.AttachmentId) } item := sdp.Item{ Type: "networkmanager-transit-gateway-route-table-attachment", UniqueAttribute: "AttachmentId", Attributes: attributes, Scope: scope, Tags: networkmanagerTagsToMap(awsItem.Attachment.Tags), } if awsItem.Attachment != nil && awsItem.Attachment.CoreNetworkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-core-network", Method: sdp.QueryMethod_GET, Query: *awsItem.Attachment.CoreNetworkId, Scope: scope, }, }) } if awsItem.PeeringId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-transit-gateway-peering", Method: sdp.QueryMethod_GET, Query: *awsItem.PeeringId, Scope: scope, }, }) } // ARN example: "arn:aws:ec2:us-west-2:123456789012:transit-gateway-route-table/tgw-rtb-9876543210123456" if awsItem.TransitGatewayRouteTableArn != nil { if arn, err := ParseARN(*awsItem.TransitGatewayRouteTableArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-transit-gateway-route-table", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.TransitGatewayRouteTableArn, Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } return &item, nil } func NewNetworkManagerTransitGatewayRouteTableAttachmentAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*types.TransitGatewayRouteTableAttachment, *networkmanager.Client, *networkmanager.Options] { return &GetListAdapter[*types.TransitGatewayRouteTableAttachment, *networkmanager.Client, *networkmanager.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "networkmanager-transit-gateway-route-table-attachment", AdapterMetadata: transitGatewayRouteTableAttachmentAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *networkmanager.Client, scope string, query string) (*types.TransitGatewayRouteTableAttachment, error) { return getTransitGatewayRouteTableAttachmentGetFunc(ctx, client, scope, query) }, ItemMapper: transitGatewayRouteTableAttachmentItemMapper, ListFunc: func(ctx context.Context, client *networkmanager.Client, scope string) ([]*types.TransitGatewayRouteTableAttachment, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-transit-gateway-route-table-attachment, use get", Scope: scope, } }, } } var transitGatewayRouteTableAttachmentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-transit-gateway-route-table-attachment", DescriptiveName: "Networkmanager Transit Gateway Route Table Attachment", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Networkmanager Transit Gateway Route Table Attachment by id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_transit_gateway_route_table_attachment.id"}, }, PotentialLinks: []string{"networkmanager-core-network", "networkmanager-transit-gateway-peering", "ec2-transit-gateway-route-table"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-transit-gateway-route-table-attachment_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestTransitGatewayRouteTableAttachmentItemMapper(t *testing.T) { scope := "123456789012.eu-west-2" tests := []struct { name string input types.TransitGatewayRouteTableAttachment expectedAttr string tests QueryTests }{ { name: "ok", input: types.TransitGatewayRouteTableAttachment{ Attachment: &types.Attachment{ AttachmentId: new("attachment1"), CoreNetworkId: new("corenetwork1"), }, TransitGatewayRouteTableArn: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway-route-table/tgw-rtb-9876543210123456"), PeeringId: new("peer1"), }, expectedAttr: "attachment1", tests: QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "corenetwork1", ExpectedScope: scope, }, { ExpectedType: "networkmanager-transit-gateway-peering", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "peer1", ExpectedScope: scope, }, { ExpectedType: "ec2-transit-gateway-route-table", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:ec2:us-west-2:123456789012:transit-gateway-route-table/tgw-rtb-9876543210123456", ExpectedScope: "123456789012.us-west-2", }, }, }, { name: "missing ec2-transit-gateway-route-table", input: types.TransitGatewayRouteTableAttachment{ Attachment: &types.Attachment{ AttachmentId: new("attachment1"), CoreNetworkId: new("corenetwork1"), }, }, expectedAttr: "attachment1", tests: QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "corenetwork1", ExpectedScope: scope, }, }, }, { name: "invalid ec2-transit-gateway-route-table", input: types.TransitGatewayRouteTableAttachment{ Attachment: &types.Attachment{ AttachmentId: new("attachment1"), CoreNetworkId: new("corenetwork1"), }, TransitGatewayRouteTableArn: new("arn:aws:ec2:us-west-2:123456789012:transit-gateway-route-table-tgw-rtb-9876543210123456"), }, expectedAttr: "attachment1", tests: QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "corenetwork1", ExpectedScope: scope, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { item, err := transitGatewayRouteTableAttachmentItemMapper("", scope, &tt.input) if err != nil { t.Error(err) } if err := item.Validate(); err != nil { t.Error(err) } // Ensure unique attribute if item.UniqueAttributeValue() != tt.expectedAttr { t.Fatalf("expected %s, got %s", tt.expectedAttr, item.UniqueAttributeValue()) } tt.tests.Execute(t, item) }) } } ================================================ FILE: aws-source/adapters/networkmanager-vpc-attachment.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func vpcAttachmentGetFunc(ctx context.Context, client *networkmanager.Client, _, query string) (*types.VpcAttachment, error) { out, err := client.GetVpcAttachment(ctx, &networkmanager.GetVpcAttachmentInput{ AttachmentId: &query, }) if err != nil { return nil, err } return out.VpcAttachment, nil } func vpcAttachmentItemMapper(_, scope string, awsItem *types.VpcAttachment) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } // The uniqueAttributeValue for this is a nested value of AttachmentId: if awsItem != nil && awsItem.Attachment != nil { attributes.Set("AttachmentId", *awsItem.Attachment.AttachmentId) } item := sdp.Item{ Type: "networkmanager-vpc-attachment", UniqueAttribute: "AttachmentId", Attributes: attributes, Scope: scope, Tags: networkmanagerTagsToMap(awsItem.Attachment.Tags), } if awsItem.Attachment != nil && awsItem.Attachment.CoreNetworkId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "networkmanager-core-network", Method: sdp.QueryMethod_GET, Query: *awsItem.Attachment.CoreNetworkId, Scope: scope, }, }) } return &item, nil } func NewNetworkManagerVPCAttachmentAdapter(client *networkmanager.Client, accountID, region string, cache sdpcache.Cache) *GetListAdapter[*types.VpcAttachment, *networkmanager.Client, *networkmanager.Options] { return &GetListAdapter[*types.VpcAttachment, *networkmanager.Client, *networkmanager.Options]{ Client: client, Region: region, AccountID: accountID, ItemType: "networkmanager-vpc-attachment", AdapterMetadata: vpcAttachmentAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client *networkmanager.Client, scope string, query string) (*types.VpcAttachment, error) { return vpcAttachmentGetFunc(ctx, client, scope, query) }, ItemMapper: vpcAttachmentItemMapper, ListFunc: func(ctx context.Context, client *networkmanager.Client, scope string) ([]*types.VpcAttachment, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list not supported for networkmanager-vpc-attachment, use get", Scope: scope, } }, } } var vpcAttachmentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "networkmanager-vpc-attachment", DescriptiveName: "Networkmanager VPC Attachment", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a Networkmanager VPC Attachment by id", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_networkmanager_vpc_attachment.id"}, }, PotentialLinks: []string{"networkmanager-core-network"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/networkmanager-vpc-attachment_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestVPCAttachmentItemMapper(t *testing.T) { input := types.VpcAttachment{ Attachment: &types.Attachment{ AttachmentId: new("attachment1"), CoreNetworkId: new("corenetwork1"), }, } scope := "123456789012.eu-west-2" item, err := vpcAttachmentItemMapper("", scope, &input) if err != nil { t.Error(err) } if err := item.Validate(); err != nil { t.Error(err) } // Ensure unique attribute if item.UniqueAttributeValue() != "attachment1" { t.Fatalf("expected %v, got %v", "attachment1", item.UniqueAttributeValue()) } tests := QueryTests{ { ExpectedType: "networkmanager-core-network", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "corenetwork1", ExpectedScope: scope, }, } tests.Execute(t, item) } ================================================ FILE: aws-source/adapters/networkmanager.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/networkmanager" "github.com/aws/aws-sdk-go-v2/service/networkmanager/types" ) type NetworkManagerClient interface { networkmanager.ListConnectPeersAPIClient networkmanager.ListCoreNetworksAPIClient GetConnectPeer(ctx context.Context, params *networkmanager.GetConnectPeerInput, optFns ...func(*networkmanager.Options)) (*networkmanager.GetConnectPeerOutput, error) GetCoreNetwork(ctx context.Context, params *networkmanager.GetCoreNetworkInput, optFns ...func(*networkmanager.Options)) (*networkmanager.GetCoreNetworkOutput, error) } // convertTags converts slice of ecs tags to a map func networkmanagerTagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } return tagsMap } ================================================ FILE: aws-source/adapters/networkmanager_test.go ================================================ package adapters type NetworkManagerTestClient struct{} ================================================ FILE: aws-source/adapters/rds-db-cluster-parameter-group.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type ClusterParameterGroup struct { types.DBClusterParameterGroup Parameters []types.Parameter } func dBClusterParameterGroupItemMapper(_, scope string, awsItem *ClusterParameterGroup) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "rds-db-cluster-parameter-group", UniqueAttribute: "DBClusterParameterGroupName", Attributes: attributes, Scope: scope, } return &item, nil } func NewRDSDBClusterParameterGroupAdapter(client rdsClient, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*ClusterParameterGroup, rdsClient, *rds.Options] { return &GetListAdapter[*ClusterParameterGroup, rdsClient, *rds.Options]{ ItemType: "rds-db-cluster-parameter-group", Client: client, AccountID: accountID, Region: region, AdapterMetadata: dbClusterParameterGroupAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client rdsClient, scope, query string) (*ClusterParameterGroup, error) { out, err := client.DescribeDBClusterParameterGroups(ctx, &rds.DescribeDBClusterParameterGroupsInput{ DBClusterParameterGroupName: &query, }) if err != nil { return nil, err } if len(out.DBClusterParameterGroups) != 1 { return nil, fmt.Errorf("expected 1 group, got %v", len(out.DBClusterParameterGroups)) } paramsOut, err := client.DescribeDBClusterParameters(ctx, &rds.DescribeDBClusterParametersInput{ DBClusterParameterGroupName: out.DBClusterParameterGroups[0].DBClusterParameterGroupName, }) if err != nil { return nil, err } return &ClusterParameterGroup{ Parameters: paramsOut.Parameters, DBClusterParameterGroup: out.DBClusterParameterGroups[0], }, nil }, ListFunc: func(ctx context.Context, client rdsClient, scope string) ([]*ClusterParameterGroup, error) { out, err := client.DescribeDBClusterParameterGroups(ctx, &rds.DescribeDBClusterParameterGroupsInput{}) if err != nil { return nil, err } groups := make([]*ClusterParameterGroup, 0) for _, group := range out.DBClusterParameterGroups { paramsOut, err := client.DescribeDBClusterParameters(ctx, &rds.DescribeDBClusterParametersInput{ DBClusterParameterGroupName: group.DBClusterParameterGroupName, }) if err != nil { return nil, err } groups = append(groups, &ClusterParameterGroup{ Parameters: paramsOut.Parameters, DBClusterParameterGroup: group, }) } return groups, nil }, ListTagsFunc: func(ctx context.Context, cpg *ClusterParameterGroup, c rdsClient) (map[string]string, error) { out, err := c.ListTagsForResource(ctx, &rds.ListTagsForResourceInput{ ResourceName: cpg.DBClusterParameterGroupArn, }) if err != nil { return nil, err } return rdsTagsToMap(out.TagList), nil }, ItemMapper: dBClusterParameterGroupItemMapper, } } var dbClusterParameterGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "rds-db-cluster-parameter-group", DescriptiveName: "RDS Cluster Parameter Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a parameter group by name", ListDescription: "List all RDS parameter groups", SearchDescription: "Search for a parameter group by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_rds_cluster_parameter_group.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, }) ================================================ FILE: aws-source/adapters/rds-db-cluster-parameter-group_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestDBClusterParameterGroupOutputMapper(t *testing.T) { group := ClusterParameterGroup{ DBClusterParameterGroup: types.DBClusterParameterGroup{ DBClusterParameterGroupName: new("default.aurora-mysql5.7"), DBParameterGroupFamily: new("aurora-mysql5.7"), Description: new("Default cluster parameter group for aurora-mysql5.7"), DBClusterParameterGroupArn: new("arn:aws:rds:eu-west-1:052392120703:cluster-pg:default.aurora-mysql5.7"), }, Parameters: []types.Parameter{ { ParameterName: new("activate_all_roles_on_login"), ParameterValue: new("0"), Description: new("Automatically set all granted roles as active after the user has authenticated successfully."), Source: new("engine-default"), ApplyType: new("dynamic"), DataType: new("boolean"), AllowedValues: new("0,1"), IsModifiable: new(true), ApplyMethod: types.ApplyMethodPendingReboot, SupportedEngineModes: []string{ "provisioned", }, }, { ParameterName: new("allow-suspicious-udfs"), Description: new("Controls whether user-defined functions that have only an xxx symbol for the main function can be loaded"), Source: new("engine-default"), ApplyType: new("static"), DataType: new("boolean"), AllowedValues: new("0,1"), IsModifiable: new(false), ApplyMethod: types.ApplyMethodPendingReboot, SupportedEngineModes: []string{ "provisioned", }, }, { ParameterName: new("aurora_binlog_replication_max_yield_seconds"), Description: new("Controls the number of seconds that binary log dump thread waits up to for the current binlog file to be filled by transactions. This wait period avoids contention that can arise from replicating each binlog event individually."), Source: new("engine-default"), ApplyType: new("dynamic"), DataType: new("integer"), AllowedValues: new("0-36000"), IsModifiable: new(true), ApplyMethod: types.ApplyMethodPendingReboot, SupportedEngineModes: []string{ "provisioned", }, }, { ParameterName: new("aurora_enable_staggered_replica_restart"), Description: new("Allow Aurora replicas to follow a staggered restart schedule to increase cluster availability."), Source: new("system"), ApplyType: new("dynamic"), DataType: new("boolean"), AllowedValues: new("0,1"), IsModifiable: new(true), ApplyMethod: types.ApplyMethodImmediate, SupportedEngineModes: []string{ "provisioned", }, }, }, } item, err := dBClusterParameterGroupItemMapper("", "foo", &group) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewRDSDBClusterParameterGroupAdapter(t *testing.T) { client, account, region := rdsGetAutoConfig(t) adapter := NewRDSDBClusterParameterGroupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/rds-db-cluster.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func dBClusterOutputMapper(ctx context.Context, client rdsClient, scope string, _ *rds.DescribeDBClustersInput, output *rds.DescribeDBClustersOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, cluster := range output.DBClusters { var tags map[string]string // Get tags for the cluster tagsOut, err := client.ListTagsForResource(ctx, &rds.ListTagsForResourceInput{ ResourceName: cluster.DBClusterArn, }) if err == nil { tags = rdsTagsToMap(tagsOut.TagList) } else { tags = HandleTagsError(ctx, err) } attributes, err := ToAttributesWithExclude(cluster) if err != nil { return nil, err } item := sdp.Item{ Type: "rds-db-cluster", UniqueAttribute: "DBClusterIdentifier", Attributes: attributes, Scope: scope, Tags: tags, } var a *ARN if cluster.DBSubnetGroup != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-subnet-group", Method: sdp.QueryMethod_GET, Query: *cluster.DBSubnetGroup, Scope: scope, }, }) } for _, endpoint := range []*string{cluster.Endpoint, cluster.ReaderEndpoint} { if endpoint != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *endpoint, Scope: "global", }, }) } } for _, replica := range cluster.ReadReplicaIdentifiers { if a, err = ParseARN(replica); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-cluster", Method: sdp.QueryMethod_SEARCH, Query: replica, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, member := range cluster.DBClusterMembers { if member.DBInstanceIdentifier != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-instance", Method: sdp.QueryMethod_GET, Query: *member.DBInstanceIdentifier, Scope: scope, }, }) } } for _, sg := range cluster.VpcSecurityGroups { if sg.VpcSecurityGroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *sg.VpcSecurityGroupId, Scope: scope, }, }) } } if cluster.HostedZoneId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *cluster.HostedZoneId, Scope: scope, }, }) } if cluster.KmsKeyId != nil { if a, err = ParseARN(*cluster.KmsKeyId); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *cluster.KmsKeyId, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if cluster.ActivityStreamKinesisStreamName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kinesis-stream", Method: sdp.QueryMethod_GET, Query: *cluster.ActivityStreamKinesisStreamName, Scope: scope, }, }) } for _, endpoint := range cluster.CustomEndpoints { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: endpoint, Scope: "global", }, }) } for _, optionGroup := range cluster.DBClusterOptionGroupMemberships { if optionGroup.DBClusterOptionGroupName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-option-group", Method: sdp.QueryMethod_GET, Query: *optionGroup.DBClusterOptionGroupName, Scope: scope, }, }) } } if cluster.MasterUserSecret != nil { if cluster.MasterUserSecret.KmsKeyId != nil { if a, err = ParseARN(*cluster.MasterUserSecret.KmsKeyId); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *cluster.MasterUserSecret.KmsKeyId, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if cluster.MasterUserSecret.SecretArn != nil { if a, err = ParseARN(*cluster.MasterUserSecret.SecretArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "secretsmanager-secret", Method: sdp.QueryMethod_SEARCH, Query: *cluster.MasterUserSecret.SecretArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if cluster.MonitoringRoleArn != nil { if a, err = ParseARN(*cluster.MonitoringRoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *cluster.MonitoringRoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if cluster.PerformanceInsightsKMSKeyId != nil { // This is an ARN if a, err = ParseARN(*cluster.PerformanceInsightsKMSKeyId); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *cluster.PerformanceInsightsKMSKeyId, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if cluster.ReplicationSourceIdentifier != nil { if a, err = ParseARN(*cluster.ReplicationSourceIdentifier); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-cluster", Method: sdp.QueryMethod_SEARCH, Query: *cluster.ReplicationSourceIdentifier, Scope: FormatScope(a.AccountID, a.Region), }, }) } } items = append(items, &item) } return items, nil } func NewRDSDBClusterAdapter(client rdsClient, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*rds.DescribeDBClustersInput, *rds.DescribeDBClustersOutput, rdsClient, *rds.Options] { return &DescribeOnlyAdapter[*rds.DescribeDBClustersInput, *rds.DescribeDBClustersOutput, rdsClient, *rds.Options]{ ItemType: "rds-db-cluster", Region: region, AccountID: accountID, Client: client, AdapterMetadata: dbClusterAdapterMetadata, cache: cache, PaginatorBuilder: func(client rdsClient, params *rds.DescribeDBClustersInput) Paginator[*rds.DescribeDBClustersOutput, *rds.Options] { return rds.NewDescribeDBClustersPaginator(client, params) }, DescribeFunc: func(ctx context.Context, client rdsClient, input *rds.DescribeDBClustersInput) (*rds.DescribeDBClustersOutput, error) { return client.DescribeDBClusters(ctx, input) }, InputMapperGet: func(scope, query string) (*rds.DescribeDBClustersInput, error) { return &rds.DescribeDBClustersInput{ DBClusterIdentifier: &query, }, nil }, InputMapperList: func(scope string) (*rds.DescribeDBClustersInput, error) { return &rds.DescribeDBClustersInput{}, nil }, OutputMapper: dBClusterOutputMapper, } } var dbClusterAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "rds-db-cluster", DescriptiveName: "RDS Cluster", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a parameter group by name", ListDescription: "List all RDS parameter groups", SearchDescription: "Search for a parameter group by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_rds_cluster.cluster_identifier"}, }, PotentialLinks: []string{"rds-db-subnet-group", "dns", "rds-db-cluster", "ec2-security-group", "route53-hosted-zone", "kms-key", "kinesis-stream", "rds-option-group", "secretsmanager-secret", "iam-role"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, }) ================================================ FILE: aws-source/adapters/rds-db-cluster_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDBClusterOutputMapper(t *testing.T) { output := rds.DescribeDBClustersOutput{ DBClusters: []types.DBCluster{ { AllocatedStorage: new(int32(100)), AvailabilityZones: []string{ "eu-west-2c", // link }, BackupRetentionPeriod: new(int32(7)), DBClusterIdentifier: new("database-2"), DBClusterParameterGroup: new("default.postgres13"), DBSubnetGroup: new("default-vpc-0d7892e00e573e701"), // link Status: new("available"), EarliestRestorableTime: new(time.Now()), Endpoint: new("database-2.cluster-camcztjohmlj.eu-west-2.rds.amazonaws.com"), // link ReaderEndpoint: new("database-2.cluster-ro-camcztjohmlj.eu-west-2.rds.amazonaws.com"), // link MultiAZ: new(true), Engine: new("postgres"), EngineVersion: new("13.7"), LatestRestorableTime: new(time.Now()), Port: new(int32(5432)), // link MasterUsername: new("postgres"), PreferredBackupWindow: new("04:48-05:18"), PreferredMaintenanceWindow: new("fri:04:05-fri:04:35"), ReadReplicaIdentifiers: []string{ "arn:aws:rds:eu-west-1:052392120703:cluster:read-replica", // link }, DBClusterMembers: []types.DBClusterMember{ { DBInstanceIdentifier: new("database-2-instance-3"), // link IsClusterWriter: new(false), DBClusterParameterGroupStatus: new("in-sync"), PromotionTier: new(int32(1)), }, }, VpcSecurityGroups: []types.VpcSecurityGroupMembership{ { VpcSecurityGroupId: new("sg-094e151c9fc5da181"), // link Status: new("active"), }, }, HostedZoneId: new("Z1TTGA775OQIYO"), // link StorageEncrypted: new(true), KmsKeyId: new("arn:aws:kms:eu-west-2:052392120703:key/9653cbdd-1590-464a-8456-67389cef6933"), // link DbClusterResourceId: new("cluster-2EW4PDVN7F7V57CUJPYOEAA74M"), DBClusterArn: new("arn:aws:rds:eu-west-2:052392120703:cluster:database-2"), IAMDatabaseAuthenticationEnabled: new(false), ClusterCreateTime: new(time.Now()), EngineMode: new("provisioned"), DeletionProtection: new(false), HttpEndpointEnabled: new(false), ActivityStreamStatus: types.ActivityStreamStatusStopped, CopyTagsToSnapshot: new(false), CrossAccountClone: new(false), DomainMemberships: []types.DomainMembership{}, TagList: []types.Tag{}, DBClusterInstanceClass: new("db.m5d.large"), StorageType: new("io1"), Iops: new(int32(1000)), PubliclyAccessible: new(true), AutoMinorVersionUpgrade: new(true), MonitoringInterval: new(int32(0)), PerformanceInsightsEnabled: new(false), NetworkType: new("IPV4"), ActivityStreamKinesisStreamName: new("aws-rds-das-db-AB1CDEFG23GHIJK4LMNOPQRST"), // link ActivityStreamKmsKeyId: new("ab12345e-1111-2bc3-12a3-ab1cd12345e"), // Not linking at the moment because there are too many possible formats. If you want to change this, submit a PR ActivityStreamMode: types.ActivityStreamModeAsync, AutomaticRestartTime: new(time.Now()), AssociatedRoles: []types.DBClusterRole{}, // EC2 classic roles, ignore BacktrackConsumedChangeRecords: new(int64(1)), BacktrackWindow: new(int64(2)), Capacity: new(int32(2)), CharacterSetName: new("english"), CloneGroupId: new("id"), CustomEndpoints: []string{ "endpoint1", // link dns }, DBClusterOptionGroupMemberships: []types.DBClusterOptionGroupStatus{ { DBClusterOptionGroupName: new("optionGroupName"), // link Status: new("good"), }, }, DBSystemId: new("systemId"), DatabaseName: new("databaseName"), EarliestBacktrackTime: new(time.Now()), EnabledCloudwatchLogsExports: []string{ "logExport1", }, GlobalWriteForwardingRequested: new(true), GlobalWriteForwardingStatus: types.WriteForwardingStatusDisabled, MasterUserSecret: &types.MasterUserSecret{ KmsKeyId: new("arn:aws:kms:eu-west-2:052392120703:key/something"), // link SecretArn: new("arn:aws:service:region:account:type/id"), // link SecretStatus: new("okay"), }, MonitoringRoleArn: new("arn:aws:service:region:account:type/id"), // link PendingModifiedValues: &types.ClusterPendingModifiedValues{}, PercentProgress: new("99"), PerformanceInsightsKMSKeyId: new("arn:aws:service:region:account:type/id"), // link, assuming it's an ARN PerformanceInsightsRetentionPeriod: new(int32(99)), ReplicationSourceIdentifier: new("arn:aws:rds:eu-west-2:052392120703:cluster:database-1"), // link ScalingConfigurationInfo: &types.ScalingConfigurationInfo{ AutoPause: new(true), MaxCapacity: new(int32(10)), MinCapacity: new(int32(1)), SecondsBeforeTimeout: new(int32(10)), SecondsUntilAutoPause: new(int32(10)), TimeoutAction: new("error"), }, ServerlessV2ScalingConfiguration: &types.ServerlessV2ScalingConfigurationInfo{ MaxCapacity: new(float64(10)), MinCapacity: new(float64(1)), }, }, }, } items, err := dBClusterOutputMapper(context.Background(), mockRdsClient{}, "foo", nil, &output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("got %v items, expected 1", len(items)) } item := items[0] if err = item.Validate(); err != nil { t.Error(err) } if item.GetTags()["key"] != "value" { t.Errorf("expected tag key to be value, got %v", item.GetTags()["key"]) } tests := QueryTests{ { ExpectedType: "rds-db-subnet-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default-vpc-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "database-2.cluster-ro-camcztjohmlj.eu-west-2.rds.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "database-2.cluster-camcztjohmlj.eu-west-2.rds.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "rds-db-cluster", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:rds:eu-west-1:052392120703:cluster:read-replica", ExpectedScope: "052392120703.eu-west-1", }, { ExpectedType: "rds-db-instance", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "database-2-instance-3", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-094e151c9fc5da181", ExpectedScope: "foo", }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "Z1TTGA775OQIYO", ExpectedScope: "foo", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:eu-west-2:052392120703:key/9653cbdd-1590-464a-8456-67389cef6933", ExpectedScope: "052392120703.eu-west-2", }, { ExpectedType: "kinesis-stream", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "aws-rds-das-db-AB1CDEFG23GHIJK4LMNOPQRST", ExpectedScope: "foo", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "endpoint1", ExpectedScope: "global", }, { ExpectedType: "rds-option-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "optionGroupName", ExpectedScope: "foo", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:eu-west-2:052392120703:key/something", ExpectedScope: "052392120703.eu-west-2", }, { ExpectedType: "secretsmanager-secret", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "rds-db-cluster", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:rds:eu-west-2:052392120703:cluster:database-1", ExpectedScope: "052392120703.eu-west-2", }, } tests.Execute(t, item) } func TestNewRDSDBClusterAdapter(t *testing.T) { client, account, region := rdsGetAutoConfig(t) adapter := NewRDSDBClusterAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/rds-db-instance.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func statusToHealth(status string) *sdp.Health { switch status { case "Available": return sdp.Health_HEALTH_OK.Enum() case "Backing-up": return sdp.Health_HEALTH_OK.Enum() case "Configuring-enhanced-monitoring": return sdp.Health_HEALTH_PENDING.Enum() case "Configuring-iam-database-auth": return sdp.Health_HEALTH_PENDING.Enum() case "Configuring-log-exports": return sdp.Health_HEALTH_PENDING.Enum() case "Converting-to-vpc": return sdp.Health_HEALTH_PENDING.Enum() case "Creating": return sdp.Health_HEALTH_PENDING.Enum() case "Deleting": return sdp.Health_HEALTH_WARNING.Enum() case "Failed": return sdp.Health_HEALTH_ERROR.Enum() case "Inaccessible-encryption-credentials": return sdp.Health_HEALTH_ERROR.Enum() case "Inaccessible-encryption-credentials-recoverable": return sdp.Health_HEALTH_ERROR.Enum() case "Incompatible-network": return sdp.Health_HEALTH_ERROR.Enum() case "Incompatible-option-group": return sdp.Health_HEALTH_ERROR.Enum() case "Incompatible-parameters": return sdp.Health_HEALTH_ERROR.Enum() case "Incompatible-restore": return sdp.Health_HEALTH_ERROR.Enum() case "Maintenance": return sdp.Health_HEALTH_PENDING.Enum() case "Modifying": return sdp.Health_HEALTH_PENDING.Enum() case "Moving-to-vpc": return sdp.Health_HEALTH_PENDING.Enum() case "Rebooting": return sdp.Health_HEALTH_PENDING.Enum() case "Resetting-master-credentials": return sdp.Health_HEALTH_PENDING.Enum() case "Renaming": return sdp.Health_HEALTH_PENDING.Enum() case "Restore-error": return sdp.Health_HEALTH_ERROR.Enum() case "Starting": return sdp.Health_HEALTH_PENDING.Enum() case "Stopped": return nil case "Stopping": return sdp.Health_HEALTH_PENDING.Enum() case "Storage-full": return sdp.Health_HEALTH_ERROR.Enum() case "Storage-optimization": return sdp.Health_HEALTH_OK.Enum() case "Upgrading": return sdp.Health_HEALTH_PENDING.Enum() } return nil } func dBInstanceOutputMapper(ctx context.Context, client rdsClient, scope string, _ *rds.DescribeDBInstancesInput, output *rds.DescribeDBInstancesOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, instance := range output.DBInstances { var tags map[string]string // Get the tags for the instance tagsOut, err := client.ListTagsForResource(ctx, &rds.ListTagsForResourceInput{ ResourceName: instance.DBInstanceArn, }) if err == nil { tags = rdsTagsToMap(tagsOut.TagList) } else { tags = HandleTagsError(ctx, err) } var dbSubnetGroup *string if instance.DBSubnetGroup != nil && instance.DBSubnetGroup.DBSubnetGroupName != nil { // Extract the subnet group so we can create a link dbSubnetGroup = instance.DBSubnetGroup.DBSubnetGroupName // Remove the data since this will come from a separate item instance.DBSubnetGroup = nil } attributes, err := ToAttributesWithExclude(instance) if err != nil { return nil, err } item := sdp.Item{ Type: "rds-db-instance", UniqueAttribute: "DBInstanceIdentifier", Attributes: attributes, Scope: scope, Tags: tags, } if instance.DBInstanceStatus != nil { item.Health = statusToHealth(*instance.DBInstanceStatus) } var a *ARN if instance.Endpoint != nil { if instance.Endpoint.Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *instance.Endpoint.Address, Scope: "global", }, }) } if instance.Endpoint.HostedZoneId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *instance.Endpoint.HostedZoneId, Scope: scope, }, }) } } for _, sg := range instance.VpcSecurityGroups { if sg.VpcSecurityGroupId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-security-group", Method: sdp.QueryMethod_GET, Query: *sg.VpcSecurityGroupId, Scope: scope, }, }) } } for _, paramGroup := range instance.DBParameterGroups { if paramGroup.DBParameterGroupName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-parameter-group", Method: sdp.QueryMethod_GET, Query: *paramGroup.DBParameterGroupName, Scope: scope, }, }) } } if dbSubnetGroup != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-subnet-group", Method: sdp.QueryMethod_GET, Query: *dbSubnetGroup, Scope: scope, }, }) } if instance.DBClusterIdentifier != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-cluster", Method: sdp.QueryMethod_GET, Query: *instance.DBClusterIdentifier, Scope: scope, }, }) } if instance.KmsKeyId != nil { // This actually uses the ARN not the id if a, err = ParseARN(*instance.KmsKeyId); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *instance.KmsKeyId, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if instance.EnhancedMonitoringResourceArn != nil { if a, err = ParseARN(*instance.EnhancedMonitoringResourceArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "logs-log-stream", Method: sdp.QueryMethod_SEARCH, Query: *instance.EnhancedMonitoringResourceArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if instance.MonitoringRoleArn != nil { if a, err = ParseARN(*instance.MonitoringRoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *instance.MonitoringRoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if instance.PerformanceInsightsKMSKeyId != nil { // This is an ARN if a, err = ParseARN(*instance.PerformanceInsightsKMSKeyId); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: *instance.PerformanceInsightsKMSKeyId, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, role := range instance.AssociatedRoles { if role.RoleArn != nil { if a, err = ParseARN(*role.RoleArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: *role.RoleArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if instance.ActivityStreamKinesisStreamName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kinesis-stream", Method: sdp.QueryMethod_GET, Query: *instance.ActivityStreamKinesisStreamName, Scope: scope, }, }) } if instance.AwsBackupRecoveryPointArn != nil { if a, err = ParseARN(*instance.AwsBackupRecoveryPointArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "backup-recovery-point", Method: sdp.QueryMethod_SEARCH, Query: *instance.AwsBackupRecoveryPointArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } if instance.CustomIamInstanceProfile != nil { // This is almost certainly an ARN since IAM basically always is if a, err = ParseARN(*instance.CustomIamInstanceProfile); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-instance-profile", Method: sdp.QueryMethod_SEARCH, Query: *instance.CustomIamInstanceProfile, Scope: FormatScope(a.AccountID, a.Region), }, }) } } for _, replication := range instance.DBInstanceAutomatedBackupsReplications { if replication.DBInstanceAutomatedBackupsArn != nil { if a, err = ParseARN(*replication.DBInstanceAutomatedBackupsArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rds-db-instance-automated-backup", Method: sdp.QueryMethod_SEARCH, Query: *replication.DBInstanceAutomatedBackupsArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if instance.ListenerEndpoint != nil { if instance.ListenerEndpoint.Address != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *instance.ListenerEndpoint.Address, Scope: "global", }, }) } if instance.ListenerEndpoint.HostedZoneId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-hosted-zone", Method: sdp.QueryMethod_GET, Query: *instance.ListenerEndpoint.HostedZoneId, Scope: scope, }, }) } } if instance.MasterUserSecret != nil { if instance.MasterUserSecret.KmsKeyId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_GET, Query: *instance.MasterUserSecret.KmsKeyId, Scope: scope, }, }) } if instance.MasterUserSecret.SecretArn != nil { if a, err = ParseARN(*instance.MasterUserSecret.SecretArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "secretsmanager-secret", Method: sdp.QueryMethod_SEARCH, Query: *instance.MasterUserSecret.SecretArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } items = append(items, &item) } return items, nil } func NewRDSDBInstanceAdapter(client rdsClient, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*rds.DescribeDBInstancesInput, *rds.DescribeDBInstancesOutput, rdsClient, *rds.Options] { return &DescribeOnlyAdapter[*rds.DescribeDBInstancesInput, *rds.DescribeDBInstancesOutput, rdsClient, *rds.Options]{ ItemType: "rds-db-instance", Region: region, AccountID: accountID, Client: client, AdapterMetadata: dbInstanceAdapterMetadata, cache: cache, PaginatorBuilder: func(client rdsClient, params *rds.DescribeDBInstancesInput) Paginator[*rds.DescribeDBInstancesOutput, *rds.Options] { return rds.NewDescribeDBInstancesPaginator(client, params) }, DescribeFunc: func(ctx context.Context, client rdsClient, input *rds.DescribeDBInstancesInput) (*rds.DescribeDBInstancesOutput, error) { return client.DescribeDBInstances(ctx, input) }, InputMapperGet: func(scope, query string) (*rds.DescribeDBInstancesInput, error) { return &rds.DescribeDBInstancesInput{ DBInstanceIdentifier: &query, }, nil }, InputMapperList: func(scope string) (*rds.DescribeDBInstancesInput, error) { return &rds.DescribeDBInstancesInput{}, nil }, OutputMapper: dBInstanceOutputMapper, } } var dbInstanceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "rds-db-instance", DescriptiveName: "RDS Instance", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an instance by ID", ListDescription: "List all instances", SearchDescription: "Search for instances by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_db_instance.identifier"}, {TerraformQueryMap: "aws_db_instance_role_association.db_instance_identifier"}, }, PotentialLinks: []string{"dns", "route53-hosted-zone", "ec2-security-group", "rds-db-parameter-group", "rds-db-subnet-group", "rds-db-cluster", "kms-key", "logs-log-stream", "iam-role", "kinesis-stream", "backup-recovery-point", "iam-instance-profile", "rds-db-instance-automated-backup", "secretsmanager-secret"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, }) ================================================ FILE: aws-source/adapters/rds-db-instance_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDBInstanceOutputMapper(t *testing.T) { output := &rds.DescribeDBInstancesOutput{ DBInstances: []types.DBInstance{ { DBInstanceIdentifier: new("database-1-instance-1"), DBInstanceClass: new("db.r6g.large"), Engine: new("aurora-mysql"), DBInstanceStatus: new("available"), MasterUsername: new("admin"), Endpoint: &types.Endpoint{ Address: new("database-1-instance-1.camcztjohmlj.eu-west-2.rds.amazonaws.com"), // link Port: new(int32(3306)), // link HostedZoneId: new("Z1TTGA775OQIYO"), // link }, AllocatedStorage: new(int32(1)), InstanceCreateTime: new(time.Now()), PreferredBackupWindow: new("00:05-00:35"), BackupRetentionPeriod: new(int32(1)), DBSecurityGroups: []types.DBSecurityGroupMembership{ { DBSecurityGroupName: new("name"), // This is EC2Classic only so we're skipping this }, }, VpcSecurityGroups: []types.VpcSecurityGroupMembership{ { VpcSecurityGroupId: new("sg-094e151c9fc5da181"), // link Status: new("active"), }, }, DBParameterGroups: []types.DBParameterGroupStatus{ { DBParameterGroupName: new("default.aurora-mysql8.0"), // link ParameterApplyStatus: new("in-sync"), }, }, AvailabilityZone: new("eu-west-2a"), // link DBSubnetGroup: &types.DBSubnetGroup{ DBSubnetGroupName: new("default-vpc-0d7892e00e573e701"), // link DBSubnetGroupDescription: new("Created from the RDS Management Console"), VpcId: new("vpc-0d7892e00e573e701"), // link SubnetGroupStatus: new("Complete"), Subnets: []types.Subnet{ { SubnetIdentifier: new("subnet-0d8ae4b4e07647efa"), // lnk SubnetAvailabilityZone: &types.AvailabilityZone{ Name: new("eu-west-2b"), }, SubnetOutpost: &types.Outpost{ Arn: new("arn:aws:service:region:account:type/id"), // link }, SubnetStatus: new("Active"), }, }, }, PreferredMaintenanceWindow: new("fri:04:49-fri:05:19"), PendingModifiedValues: &types.PendingModifiedValues{}, MultiAZ: new(false), EngineVersion: new("8.0.mysql_aurora.3.02.0"), AutoMinorVersionUpgrade: new(true), ReadReplicaDBInstanceIdentifiers: []string{ "read", }, LicenseModel: new("general-public-license"), OptionGroupMemberships: []types.OptionGroupMembership{ { OptionGroupName: new("default:aurora-mysql-8-0"), Status: new("in-sync"), }, }, PubliclyAccessible: new(false), StorageType: new("aurora"), DbInstancePort: new(int32(0)), DBClusterIdentifier: new("database-1"), // link StorageEncrypted: new(true), KmsKeyId: new("arn:aws:kms:eu-west-2:052392120703:key/9653cbdd-1590-464a-8456-67389cef6933"), // link DbiResourceId: new("db-ET7CE5D5TQTK7MXNJGJNFQD52E"), CACertificateIdentifier: new("rds-ca-2019"), DomainMemberships: []types.DomainMembership{ { Domain: new("domain"), FQDN: new("fqdn"), IAMRoleName: new("role"), Status: new("enrolled"), }, }, CopyTagsToSnapshot: new(false), MonitoringInterval: new(int32(60)), EnhancedMonitoringResourceArn: new("arn:aws:logs:eu-west-2:052392120703:log-group:RDSOSMetrics:log-stream:db-ET7CE5D5TQTK7MXNJGJNFQD52E"), // link MonitoringRoleArn: new("arn:aws:iam::052392120703:role/rds-monitoring-role"), // link PromotionTier: new(int32(1)), DBInstanceArn: new("arn:aws:rds:eu-west-2:052392120703:db:database-1-instance-1"), IAMDatabaseAuthenticationEnabled: new(false), PerformanceInsightsEnabled: new(true), PerformanceInsightsKMSKeyId: new("arn:aws:kms:eu-west-2:052392120703:key/9653cbdd-1590-464a-8456-67389cef6933"), // link PerformanceInsightsRetentionPeriod: new(int32(7)), DeletionProtection: new(false), AssociatedRoles: []types.DBInstanceRole{ { FeatureName: new("something"), RoleArn: new("arn:aws:service:region:account:type/id"), // link Status: new("associated"), }, }, TagList: []types.Tag{}, CustomerOwnedIpEnabled: new(false), BackupTarget: new("region"), NetworkType: new("IPV4"), StorageThroughput: new(int32(0)), ActivityStreamEngineNativeAuditFieldsIncluded: new(true), ActivityStreamKinesisStreamName: new("aws-rds-das-db-AB1CDEFG23GHIJK4LMNOPQRST"), // link ActivityStreamKmsKeyId: new("ab12345e-1111-2bc3-12a3-ab1cd12345e"), // Not linking at the moment because there are too many possible formats. If you want to change this, submit a PR ActivityStreamMode: types.ActivityStreamModeAsync, ActivityStreamPolicyStatus: types.ActivityStreamPolicyStatusLocked, ActivityStreamStatus: types.ActivityStreamStatusStarted, AutomaticRestartTime: new(time.Now()), AutomationMode: types.AutomationModeAllPaused, AwsBackupRecoveryPointArn: new("arn:aws:service:region:account:type/id"), // link CertificateDetails: &types.CertificateDetails{ CAIdentifier: new("id"), ValidTill: new(time.Now()), }, CharacterSetName: new("something"), CustomIamInstanceProfile: new("arn:aws:service:region:account:type/id"), // link? DBInstanceAutomatedBackupsReplications: []types.DBInstanceAutomatedBackupsReplication{ { DBInstanceAutomatedBackupsArn: new("arn:aws:service:region:account:type/id"), // link }, }, DBName: new("name"), DBSystemId: new("id"), EnabledCloudwatchLogsExports: []string{}, Iops: new(int32(10)), LatestRestorableTime: new(time.Now()), ListenerEndpoint: &types.Endpoint{ Address: new("foo.bar.com"), // link HostedZoneId: new("id"), // link Port: new(int32(5432)), // link }, MasterUserSecret: &types.MasterUserSecret{ KmsKeyId: new("id"), // link SecretArn: new("arn:aws:service:region:account:type/id"), // link SecretStatus: new("okay"), }, MaxAllocatedStorage: new(int32(10)), NcharCharacterSetName: new("english"), ProcessorFeatures: []types.ProcessorFeature{}, ReadReplicaDBClusterIdentifiers: []string{}, ReadReplicaSourceDBInstanceIdentifier: new("id"), ReplicaMode: types.ReplicaModeMounted, ResumeFullAutomationModeTime: new(time.Now()), SecondaryAvailabilityZone: new("eu-west-1"), // link StatusInfos: []types.DBInstanceStatusInfo{}, TdeCredentialArn: new("arn:aws:service:region:account:type/id"), // I don't have a good example for this so skipping for now. PR if required Timezone: new("GB"), }, }, } items, err := dBInstanceOutputMapper(context.Background(), mockRdsClient{}, "foo", nil, output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("got %v items, expected 1", len(items)) } item := items[0] if err = item.Validate(); err != nil { t.Error(err) } if item.GetTags()["key"] != "value" { t.Errorf("got %v, expected %v", item.GetTags()["key"], "value") } tests := QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "database-1-instance-1.camcztjohmlj.eu-west-2.rds.amazonaws.com", ExpectedScope: "global", }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "Z1TTGA775OQIYO", ExpectedScope: "foo", }, { ExpectedType: "ec2-security-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "sg-094e151c9fc5da181", ExpectedScope: "foo", }, { ExpectedType: "rds-db-parameter-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default.aurora-mysql8.0", ExpectedScope: "foo", }, { ExpectedType: "rds-db-subnet-group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default-vpc-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "rds-db-cluster", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "database-1", ExpectedScope: "foo", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:eu-west-2:052392120703:key/9653cbdd-1590-464a-8456-67389cef6933", ExpectedScope: "052392120703.eu-west-2", }, { ExpectedType: "logs-log-stream", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:logs:eu-west-2:052392120703:log-group:RDSOSMetrics:log-stream:db-ET7CE5D5TQTK7MXNJGJNFQD52E", ExpectedScope: "052392120703.eu-west-2", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:iam::052392120703:role/rds-monitoring-role", ExpectedScope: "052392120703", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:kms:eu-west-2:052392120703:key/9653cbdd-1590-464a-8456-67389cef6933", ExpectedScope: "052392120703.eu-west-2", }, { ExpectedType: "iam-role", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "kinesis-stream", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "aws-rds-das-db-AB1CDEFG23GHIJK4LMNOPQRST", ExpectedScope: "foo", }, { ExpectedType: "backup-recovery-point", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "iam-instance-profile", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "rds-db-instance-automated-backup", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "foo.bar.com", ExpectedScope: "global", }, { ExpectedType: "route53-hosted-zone", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "kms-key", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, { ExpectedType: "secretsmanager-secret", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, } tests.Execute(t, item) } func TestNewRDSDBInstanceAdapter(t *testing.T) { client, account, region := rdsGetAutoConfig(t) adapter := NewRDSDBInstanceAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/rds-db-parameter-group.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type ParameterGroup struct { types.DBParameterGroup Parameters []types.Parameter } func dBParameterGroupItemMapper(_, scope string, awsItem *ParameterGroup) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "rds-db-parameter-group", UniqueAttribute: "DBParameterGroupName", Attributes: attributes, Scope: scope, } return &item, nil } func NewRDSDBParameterGroupAdapter(client rdsClient, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*ParameterGroup, rdsClient, *rds.Options] { return &GetListAdapter[*ParameterGroup, rdsClient, *rds.Options]{ ItemType: "rds-db-parameter-group", Client: client, AccountID: accountID, Region: region, AdapterMetadata: dbParameterGroupAdapterMetadata, cache: cache, GetFunc: func(ctx context.Context, client rdsClient, scope, query string) (*ParameterGroup, error) { out, err := client.DescribeDBParameterGroups(ctx, &rds.DescribeDBParameterGroupsInput{ DBParameterGroupName: &query, }) if err != nil { return nil, err } if len(out.DBParameterGroups) != 1 { return nil, fmt.Errorf("expected 1 group, got %v", len(out.DBParameterGroups)) } paramsOut, err := client.DescribeDBParameters(ctx, &rds.DescribeDBParametersInput{ DBParameterGroupName: out.DBParameterGroups[0].DBParameterGroupName, }) if err != nil { return nil, err } return &ParameterGroup{ Parameters: paramsOut.Parameters, DBParameterGroup: out.DBParameterGroups[0], }, nil }, ListFunc: func(ctx context.Context, client rdsClient, scope string) ([]*ParameterGroup, error) { out, err := client.DescribeDBParameterGroups(ctx, &rds.DescribeDBParameterGroupsInput{}) if err != nil { return nil, err } groups := make([]*ParameterGroup, 0) for _, group := range out.DBParameterGroups { paramsOut, err := client.DescribeDBParameters(ctx, &rds.DescribeDBParametersInput{ DBParameterGroupName: group.DBParameterGroupName, }) if err != nil { return nil, err } groups = append(groups, &ParameterGroup{ Parameters: paramsOut.Parameters, DBParameterGroup: group, }) } return groups, nil }, ListTagsFunc: func(ctx context.Context, pg *ParameterGroup, c rdsClient) (map[string]string, error) { out, err := c.ListTagsForResource(ctx, &rds.ListTagsForResourceInput{ ResourceName: pg.DBParameterGroupArn, }) if err != nil { return nil, err } return rdsTagsToMap(out.TagList), nil }, ItemMapper: dBParameterGroupItemMapper, } } var dbParameterGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "rds-db-parameter-group", DescriptiveName: "RDS Parameter Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a parameter group by name", ListDescription: "List all parameter groups", SearchDescription: "Search for a parameter group by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "aws_db_parameter_group.arn", }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, }) ================================================ FILE: aws-source/adapters/rds-db-parameter-group_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/overmindtech/cli/go/sdpcache" ) func TestDBParameterGroupOutputMapper(t *testing.T) { group := ParameterGroup{ DBParameterGroup: types.DBParameterGroup{ DBParameterGroupName: new("default.aurora-mysql5.7"), DBParameterGroupFamily: new("aurora-mysql5.7"), Description: new("Default parameter group for aurora-mysql5.7"), DBParameterGroupArn: new("arn:aws:rds:eu-west-1:052392120703:pg:default.aurora-mysql5.7"), }, Parameters: []types.Parameter{ { ParameterName: new("activate_all_roles_on_login"), ParameterValue: new("0"), Description: new("Automatically set all granted roles as active after the user has authenticated successfully."), Source: new("engine-default"), ApplyType: new("dynamic"), DataType: new("boolean"), AllowedValues: new("0,1"), IsModifiable: new(true), ApplyMethod: types.ApplyMethodPendingReboot, }, { ParameterName: new("allow-suspicious-udfs"), Description: new("Controls whether user-defined functions that have only an xxx symbol for the main function can be loaded"), Source: new("engine-default"), ApplyType: new("static"), DataType: new("boolean"), AllowedValues: new("0,1"), IsModifiable: new(false), ApplyMethod: types.ApplyMethodPendingReboot, }, { ParameterName: new("aurora_parallel_query"), Description: new("This parameter can be used to enable and disable Aurora Parallel Query."), Source: new("engine-default"), ApplyType: new("dynamic"), DataType: new("boolean"), AllowedValues: new("0,1"), IsModifiable: new(true), ApplyMethod: types.ApplyMethodPendingReboot, }, { ParameterName: new("autocommit"), Description: new("Sets the autocommit mode"), Source: new("engine-default"), ApplyType: new("dynamic"), DataType: new("boolean"), AllowedValues: new("0,1"), IsModifiable: new(true), ApplyMethod: types.ApplyMethodPendingReboot, }, }, } item, err := dBParameterGroupItemMapper("", "foo", &group) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } } func TestNewRDSDBParameterGroupAdapter(t *testing.T) { client, account, region := rdsGetAutoConfig(t) adapter := NewRDSDBParameterGroupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/rds-db-subnet-group.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func dBSubnetGroupOutputMapper(ctx context.Context, client rdsClient, scope string, _ *rds.DescribeDBSubnetGroupsInput, output *rds.DescribeDBSubnetGroupsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, sg := range output.DBSubnetGroups { var tags map[string]string // Get tags tagsOut, err := client.ListTagsForResource(ctx, &rds.ListTagsForResourceInput{ ResourceName: sg.DBSubnetGroupArn, }) if err == nil { tags = rdsTagsToMap(tagsOut.TagList) } else { tags = HandleTagsError(ctx, err) } attributes, err := ToAttributesWithExclude(sg) if err != nil { return nil, err } item := sdp.Item{ Type: "rds-db-subnet-group", UniqueAttribute: "DBSubnetGroupName", Attributes: attributes, Scope: scope, Tags: tags, } var a *ARN if sg.VpcId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-vpc", Method: sdp.QueryMethod_GET, Query: *sg.VpcId, Scope: scope, }, }) } for _, subnet := range sg.Subnets { if subnet.SubnetIdentifier != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-subnet", Method: sdp.QueryMethod_GET, Query: *subnet.SubnetIdentifier, Scope: scope, }, }) } if subnet.SubnetOutpost != nil { if subnet.SubnetOutpost.Arn != nil { if a, err = ParseARN(*subnet.SubnetOutpost.Arn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "outposts-outpost", Method: sdp.QueryMethod_SEARCH, Query: *subnet.SubnetOutpost.Arn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } items = append(items, &item) } return items, nil } func NewRDSDBSubnetGroupAdapter(client rdsClient, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*rds.DescribeDBSubnetGroupsInput, *rds.DescribeDBSubnetGroupsOutput, rdsClient, *rds.Options] { return &DescribeOnlyAdapter[*rds.DescribeDBSubnetGroupsInput, *rds.DescribeDBSubnetGroupsOutput, rdsClient, *rds.Options]{ ItemType: "rds-db-subnet-group", Region: region, AccountID: accountID, Client: client, AdapterMetadata: dbSubnetGroupAdapterMetadata, cache: cache, PaginatorBuilder: func(client rdsClient, params *rds.DescribeDBSubnetGroupsInput) Paginator[*rds.DescribeDBSubnetGroupsOutput, *rds.Options] { return rds.NewDescribeDBSubnetGroupsPaginator(client, params) }, DescribeFunc: func(ctx context.Context, client rdsClient, input *rds.DescribeDBSubnetGroupsInput) (*rds.DescribeDBSubnetGroupsOutput, error) { return client.DescribeDBSubnetGroups(ctx, input) }, InputMapperGet: func(scope, query string) (*rds.DescribeDBSubnetGroupsInput, error) { return &rds.DescribeDBSubnetGroupsInput{ DBSubnetGroupName: &query, }, nil }, InputMapperList: func(scope string) (*rds.DescribeDBSubnetGroupsInput, error) { return &rds.DescribeDBSubnetGroupsInput{}, nil }, OutputMapper: dBSubnetGroupOutputMapper, } } var dbSubnetGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "rds-db-subnet-group", DescriptiveName: "RDS Subnet Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a subnet group by name", ListDescription: "List all subnet groups", SearchDescription: "Search for subnet groups by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_db_subnet_group.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, PotentialLinks: []string{"ec2-vpc", "ec2-subnet", "outposts-outpost"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/rds-db-subnet-group_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestDBSubnetGroupOutputMapper(t *testing.T) { output := rds.DescribeDBSubnetGroupsOutput{ DBSubnetGroups: []types.DBSubnetGroup{ { DBSubnetGroupName: new("default-vpc-0d7892e00e573e701"), DBSubnetGroupDescription: new("Created from the RDS Management Console"), VpcId: new("vpc-0d7892e00e573e701"), // link SubnetGroupStatus: new("Complete"), Subnets: []types.Subnet{ { SubnetIdentifier: new("subnet-0450a637af9984235"), // link SubnetAvailabilityZone: &types.AvailabilityZone{ Name: new("eu-west-2c"), // link }, SubnetOutpost: &types.Outpost{ Arn: new("arn:aws:service:region:account:type/id"), // link }, SubnetStatus: new("Active"), }, }, DBSubnetGroupArn: new("arn:aws:rds:eu-west-2:052392120703:subgrp:default-vpc-0d7892e00e573e701"), SupportedNetworkTypes: []string{ "IPV4", }, }, }, } items, err := dBSubnetGroupOutputMapper(context.Background(), mockRdsClient{}, "foo", nil, &output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("got %v items, expected 1", len(items)) } item := items[0] if err = item.Validate(); err != nil { t.Error(err) } if item.GetTags()["key"] != "value" { t.Errorf("expected key to be value, got %v", item.GetTags()["key"]) } tests := QueryTests{ { ExpectedType: "ec2-vpc", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vpc-0d7892e00e573e701", ExpectedScope: "foo", }, { ExpectedType: "ec2-subnet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "subnet-0450a637af9984235", ExpectedScope: "foo", }, { ExpectedType: "outposts-outpost", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:service:region:account:type/id", ExpectedScope: "account.region", }, } tests.Execute(t, item) } func TestNewRDSDBSubnetGroupAdapter(t *testing.T) { client, account, region := rdsGetAutoConfig(t) adapter := NewRDSDBSubnetGroupAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/rds-option-group.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func optionGroupOutputMapper(ctx context.Context, client rdsClient, scope string, _ *rds.DescribeOptionGroupsInput, output *rds.DescribeOptionGroupsOutput) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) for _, group := range output.OptionGroupsList { var tags map[string]string // Get tags tagsOut, err := client.ListTagsForResource(ctx, &rds.ListTagsForResourceInput{ ResourceName: group.OptionGroupArn, }) if err == nil { tags = rdsTagsToMap(tagsOut.TagList) } else { tags = HandleTagsError(ctx, err) } attributes, err := ToAttributesWithExclude(group) if err != nil { return nil, err } item := sdp.Item{ Type: "rds-option-group", UniqueAttribute: "OptionGroupName", Attributes: attributes, Scope: scope, Tags: tags, } items = append(items, &item) } return items, nil } func NewRDSOptionGroupAdapter(client rdsClient, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*rds.DescribeOptionGroupsInput, *rds.DescribeOptionGroupsOutput, rdsClient, *rds.Options] { return &DescribeOnlyAdapter[*rds.DescribeOptionGroupsInput, *rds.DescribeOptionGroupsOutput, rdsClient, *rds.Options]{ ItemType: "rds-option-group", Region: region, AccountID: accountID, Client: client, AdapterMetadata: optionGroupAdapterMetadata, cache: cache, PaginatorBuilder: func(client rdsClient, params *rds.DescribeOptionGroupsInput) Paginator[*rds.DescribeOptionGroupsOutput, *rds.Options] { return rds.NewDescribeOptionGroupsPaginator(client, params) }, DescribeFunc: func(ctx context.Context, client rdsClient, input *rds.DescribeOptionGroupsInput) (*rds.DescribeOptionGroupsOutput, error) { return client.DescribeOptionGroups(ctx, input) }, InputMapperGet: func(scope, query string) (*rds.DescribeOptionGroupsInput, error) { return &rds.DescribeOptionGroupsInput{ OptionGroupName: &query, }, nil }, InputMapperList: func(scope string) (*rds.DescribeOptionGroupsInput, error) { return &rds.DescribeOptionGroupsInput{}, nil }, OutputMapper: optionGroupOutputMapper, } } var optionGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "rds-option-group", DescriptiveName: "RDS Option Group", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an option group by name", ListDescription: "List all RDS option groups", SearchDescription: "Search for an option group by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformQueryMap: "aws_db_option_group.arn", TerraformMethod: sdp.QueryMethod_SEARCH, }, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, }) ================================================ FILE: aws-source/adapters/rds-option-group_test.go ================================================ package adapters import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" ) func TestOptionGroupOutputMapper(t *testing.T) { output := rds.DescribeOptionGroupsOutput{ OptionGroupsList: []types.OptionGroup{ { OptionGroupName: new("default:aurora-mysql-8-0"), OptionGroupDescription: new("Default option group for aurora-mysql 8.0"), EngineName: new("aurora-mysql"), MajorEngineVersion: new("8.0"), Options: []types.Option{}, AllowsVpcAndNonVpcInstanceMemberships: new(true), OptionGroupArn: new("arn:aws:rds:eu-west-2:052392120703:og:default:aurora-mysql-8-0"), }, }, } items, err := optionGroupOutputMapper(context.Background(), mockRdsClient{}, "foo", nil, &output) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("got %v items, expected 1", len(items)) } item := items[0] if err = item.Validate(); err != nil { t.Error(err) } if item.GetTags()["key"] != "value" { t.Errorf("expected key to be value, got %v", item.GetTags()["key"]) } } ================================================ FILE: aws-source/adapters/rds.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/rds/types" ) type rdsClient interface { DescribeDBClusterParameterGroups(ctx context.Context, params *rds.DescribeDBClusterParameterGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClusterParameterGroupsOutput, error) DescribeDBClusterParameters(ctx context.Context, params *rds.DescribeDBClusterParametersInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClusterParametersOutput, error) DescribeDBParameterGroups(ctx context.Context, params *rds.DescribeDBParameterGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeDBParameterGroupsOutput, error) DescribeDBParameters(ctx context.Context, params *rds.DescribeDBParametersInput, optFns ...func(*rds.Options)) (*rds.DescribeDBParametersOutput, error) ListTagsForResource(ctx context.Context, params *rds.ListTagsForResourceInput, optFns ...func(*rds.Options)) (*rds.ListTagsForResourceOutput, error) DescribeDBClusters(ctx context.Context, params *rds.DescribeDBClustersInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClustersOutput, error) DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) DescribeDBSubnetGroups(ctx context.Context, params *rds.DescribeDBSubnetGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeDBSubnetGroupsOutput, error) DescribeOptionGroups(ctx context.Context, params *rds.DescribeOptionGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeOptionGroupsOutput, error) } type mockRdsClient struct{} func (m mockRdsClient) DescribeDBClusterParameterGroups(ctx context.Context, params *rds.DescribeDBClusterParameterGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClusterParameterGroupsOutput, error) { return nil, nil } func (m mockRdsClient) DescribeDBClusterParameters(ctx context.Context, params *rds.DescribeDBClusterParametersInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClusterParametersOutput, error) { return nil, nil } func (m mockRdsClient) ListTagsForResource(ctx context.Context, params *rds.ListTagsForResourceInput, optFns ...func(*rds.Options)) (*rds.ListTagsForResourceOutput, error) { return &rds.ListTagsForResourceOutput{ TagList: []types.Tag{ { Key: new("key"), Value: new("value"), }, }, }, nil } func (m mockRdsClient) DescribeDBClusters(ctx context.Context, params *rds.DescribeDBClustersInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClustersOutput, error) { return nil, nil } func (m mockRdsClient) DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) { return nil, nil } func (m mockRdsClient) DescribeDBSubnetGroups(ctx context.Context, params *rds.DescribeDBSubnetGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeDBSubnetGroupsOutput, error) { return nil, nil } func (m mockRdsClient) DescribeOptionGroups(ctx context.Context, params *rds.DescribeOptionGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeOptionGroupsOutput, error) { return nil, nil } func (m mockRdsClient) DescribeDBParameterGroups(ctx context.Context, params *rds.DescribeDBParameterGroupsInput, optFns ...func(*rds.Options)) (*rds.DescribeDBParameterGroupsOutput, error) { return nil, nil } func (m mockRdsClient) DescribeDBParameters(ctx context.Context, params *rds.DescribeDBParametersInput, optFns ...func(*rds.Options)) (*rds.DescribeDBParametersOutput, error) { return nil, nil } func rdsTagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } return tagsMap } ================================================ FILE: aws-source/adapters/rds_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/rds" "testing" ) func rdsGetAutoConfig(t *testing.T) (*rds.Client, string, string) { config, account, region := GetAutoConfig(t) client := rds.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/route53-health-check.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type HealthCheck struct { types.HealthCheck HealthCheckObservations []types.HealthCheckObservation } func healthCheckGetFunc(ctx context.Context, client *route53.Client, scope, query string) (*HealthCheck, error) { out, err := client.GetHealthCheck(ctx, &route53.GetHealthCheckInput{ HealthCheckId: &query, }) if err != nil { return nil, err } status, err := client.GetHealthCheckStatus(ctx, &route53.GetHealthCheckStatusInput{ HealthCheckId: &query, }) if err != nil { return nil, err } return &HealthCheck{ HealthCheck: *out.HealthCheck, HealthCheckObservations: status.HealthCheckObservations, }, nil } func healthCheckListFunc(ctx context.Context, client *route53.Client, scope string) ([]*HealthCheck, error) { out, err := client.ListHealthChecks(ctx, &route53.ListHealthChecksInput{}) if err != nil { return nil, err } healthChecks := make([]*HealthCheck, 0, len(out.HealthChecks)) for _, healthCheck := range out.HealthChecks { status, err := client.GetHealthCheckStatus(ctx, &route53.GetHealthCheckStatusInput{ HealthCheckId: healthCheck.Id, }) if err != nil { return nil, err } healthChecks = append(healthChecks, &HealthCheck{ HealthCheck: healthCheck, HealthCheckObservations: status.HealthCheckObservations, }) } return healthChecks, nil } func healthCheckItemMapper(_, scope string, awsItem *HealthCheck) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "route53-health-check", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, } // Link to the cloudwatch metric that tracks this health check query, err := ToQueryString(&cloudwatch.DescribeAlarmsForMetricInput{ Namespace: aws.String("AWS/Route53"), MetricName: aws.String("HealthCheckStatus"), Dimensions: []cwtypes.Dimension{ { Name: aws.String("HealthCheckId"), Value: awsItem.Id, }, }, }) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "cloudwatch-alarm", Query: query, Method: sdp.QueryMethod_SEARCH, Scope: scope, }, }) } healthy := true for _, observation := range awsItem.HealthCheckObservations { if observation.StatusReport != nil && observation.StatusReport.Status != nil { if strings.HasPrefix(*observation.StatusReport.Status, "Failure") { healthy = false } } } if healthy { item.Health = sdp.Health_HEALTH_OK.Enum() } else { item.Health = sdp.Health_HEALTH_ERROR.Enum() } return &item, nil } func NewRoute53HealthCheckAdapter(client *route53.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*HealthCheck, *route53.Client, *route53.Options] { return &GetListAdapter[*HealthCheck, *route53.Client, *route53.Options]{ ItemType: "route53-health-check", Client: client, AccountID: accountID, Region: region, GetFunc: healthCheckGetFunc, ListFunc: healthCheckListFunc, ItemMapper: healthCheckItemMapper, AdapterMetadata: healthCheckAdapterMetadata, cache: cache, ListTagsFunc: func(ctx context.Context, hc *HealthCheck, c *route53.Client) (map[string]string, error) { if hc.Id == nil { return nil, nil } // Strip the prefix id := strings.TrimPrefix(*hc.Id, "/healthcheck/") out, err := c.ListTagsForResource(ctx, &route53.ListTagsForResourceInput{ ResourceId: &id, ResourceType: types.TagResourceTypeHealthcheck, }) if err != nil { return nil, err } return route53TagsToMap(out.ResourceTagSet.Tags), nil }, } } var healthCheckAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "route53-health-check", DescriptiveName: "Route53 Health Check", PotentialLinks: []string{"cloudwatch-alarm"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get health check by ID", ListDescription: "List all health checks", SearchDescription: "Search for health checks by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_route53_health_check.id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, }) ================================================ FILE: aws-source/adapters/route53-health-check_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestHealthCheckItemMapper(t *testing.T) { hc := HealthCheck{ HealthCheck: types.HealthCheck{ Id: new("d7ce5d72-6d1f-4147-8246-d0ca3fb505d6"), CallerReference: new("85d56b3f-873c-498b-a2dd-554ec13c5289"), HealthCheckConfig: &types.HealthCheckConfig{ IPAddress: new("1.1.1.1"), Port: new(int32(443)), Type: types.HealthCheckTypeHttps, FullyQualifiedDomainName: new("one.one.one.one"), RequestInterval: new(int32(30)), FailureThreshold: new(int32(3)), MeasureLatency: new(false), Inverted: new(false), Disabled: new(false), EnableSNI: new(true), }, HealthCheckVersion: new(int64(1)), }, HealthCheckObservations: []types.HealthCheckObservation{ { Region: types.HealthCheckRegionApNortheast1, IPAddress: new("15.177.62.21"), StatusReport: &types.StatusReport{ Status: new("Success: HTTP Status Code 200, OK"), CheckedTime: new(time.Now()), }, }, { Region: types.HealthCheckRegionEuWest1, IPAddress: new("15.177.10.21"), StatusReport: &types.StatusReport{ Status: new("Failure: Connection timed out. The endpoint or the internet connection is down, or requests are being blocked by your firewall. See https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-router-firewall-rules.html"), CheckedTime: new(time.Now()), }, }, }, } item, err := healthCheckItemMapper("", "foo", &hc) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "cloudwatch-alarm", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "{\"MetricName\":\"HealthCheckStatus\",\"Namespace\":\"AWS/Route53\",\"Dimensions\":[{\"Name\":\"HealthCheckId\",\"Value\":\"d7ce5d72-6d1f-4147-8246-d0ca3fb505d6\"}],\"ExtendedStatistic\":null,\"Period\":null,\"Statistic\":\"\",\"Unit\":\"\"}", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewRoute53HealthCheckAdapter(t *testing.T) { client, account, region := route53GetAutoConfig(t) adapter := NewRoute53HealthCheckAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/route53-hosted-zone.go ================================================ package adapters import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func hostedZoneGetFunc(ctx context.Context, client *route53.Client, scope, query string) (*types.HostedZone, error) { out, err := client.GetHostedZone(ctx, &route53.GetHostedZoneInput{ Id: &query, }) if err != nil { return nil, err } return out.HostedZone, nil } func hostedZoneListFunc(ctx context.Context, client *route53.Client, scope string) ([]*types.HostedZone, error) { out, err := client.ListHostedZones(ctx, &route53.ListHostedZonesInput{}) if err != nil { return nil, err } zones := make([]*types.HostedZone, 0, len(out.HostedZones)) for _, zone := range out.HostedZones { zones = append(zones, &zone) } return zones, nil } func hostedZoneItemMapper(_, scope string, awsItem *types.HostedZone) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "route53-hosted-zone", UniqueAttribute: "Id", Attributes: attributes, Scope: scope, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "route53-resource-record-set", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.Id, Scope: scope, }, }, }, } return &item, nil } func NewRoute53HostedZoneAdapter(client *route53.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.HostedZone, *route53.Client, *route53.Options] { return &GetListAdapter[*types.HostedZone, *route53.Client, *route53.Options]{ ItemType: "route53-hosted-zone", Client: client, AccountID: accountID, Region: region, GetFunc: hostedZoneGetFunc, ListFunc: hostedZoneListFunc, ItemMapper: hostedZoneItemMapper, AdapterMetadata: hostedZoneAdapterMetadata, cache: cache, ListTagsFunc: func(ctx context.Context, hz *types.HostedZone, c *route53.Client) (map[string]string, error) { if hz.Id == nil { return nil, nil } // Strip the initial prefix id := strings.TrimPrefix(*hz.Id, "/hostedzone/") out, err := c.ListTagsForResource(ctx, &route53.ListTagsForResourceInput{ ResourceId: &id, ResourceType: types.TagResourceTypeHostedzone, }) if err != nil { return nil, err } return route53TagsToMap(out.ResourceTagSet.Tags), nil }, } } var hostedZoneAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "route53-hosted-zone", DescriptiveName: "Hosted Zone", PotentialLinks: []string{"route53-resource-record-set"}, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get a hosted zone by ID", ListDescription: "List all hosted zones", SearchDescription: "Search for a hosted zone by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_route53_hosted_zone_dnssec.id"}, {TerraformQueryMap: "aws_route53_zone.zone_id"}, {TerraformQueryMap: "aws_route53_zone_association.zone_id"}, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) ================================================ FILE: aws-source/adapters/route53-hosted-zone_test.go ================================================ package adapters import ( "testing" "time" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestHostedZoneItemMapper(t *testing.T) { zone := types.HostedZone{ Id: new("/hostedzone/Z08416862SZP5DJXIDB29"), Name: new("overmind-demo.com."), CallerReference: new("RISWorkflow-RD:144d3779-1574-42bf-9e75-f309838ea0a1"), Config: &types.HostedZoneConfig{ Comment: new("HostedZone created by Route53 Registrar"), PrivateZone: false, }, ResourceRecordSetCount: new(int64(3)), LinkedService: &types.LinkedService{ Description: new("service description"), ServicePrincipal: new("principal"), }, } item, err := hostedZoneItemMapper("", "foo", &zone) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "route53-resource-record-set", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "/hostedzone/Z08416862SZP5DJXIDB29", ExpectedScope: "foo", }, } tests.Execute(t, item) } func TestNewRoute53HostedZoneAdapter(t *testing.T) { client, account, region := route53GetAutoConfig(t) adapter := NewRoute53HostedZoneAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/route53-resource-record-set.go ================================================ package adapters import ( "context" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func resourceRecordSetGetFunc(ctx context.Context, client *route53.Client, scope, query string) (*types.ResourceRecordSet, error) { return nil, errors.New("get is not supported for route53-resource-record-set. Use search") } // constructRecordFQDN constructs the full FQDN for a Route53 record based on // the record name and the hosted zone name. This handles the various edge cases // where the record name might already contain the full domain. func constructRecordFQDN(recordName, hostedZoneName string) string { // If the name is the same as the FQDN of the hosted zone, we don't have // to append it otherwise it'll be in there twice. It seems that NS and // MX records sometimes have the full FQDN in the name zoneFQDN := strings.TrimSuffix(hostedZoneName, ".") if recordName == zoneFQDN { return recordName } else if strings.HasSuffix(recordName, "."+zoneFQDN) || strings.HasSuffix(recordName, hostedZoneName) { // Record name already contains the full domain return recordName } else { // Calculate the full FQDN based on the hosted zone name and the record name return recordName + "." + hostedZoneName } } // ResourceRecordSetSearchFunc Search func that accepts a hosted zone or a // terraform ID in the format {hostedZone}_{recordName}_{type}. Unfortunately // the "name" means the record name within the scope of the hosted zone, not the // full FQDN. This is something that Terraform does to match the AWS GUI, where // you specify a name like "foo" and then you end up with a record like // "foo.example.com.". That record has a "name" attribute, but it's set to // "foo.example.com.". // // Because of this behaviour we need to construct the full name, rather than // just the half-name. You can see that the terraform provider itself also does // this in `findResourceRecordSetByFourPartKey`: // https://github.com/hashicorp/terraform-provider-aws/blob/main/internal/service/route53/record.go#L786-L825 func resourceRecordSetSearchFunc(ctx context.Context, client *route53.Client, scope, query string) ([]*types.ResourceRecordSet, error) { splits := strings.Split(query, "_") var out *route53.ListResourceRecordSetsOutput var err error if len(splits) == 3 { hostedZoneID := splits[0] recordName := splits[1] recordType := splits[2] var zoneResp *route53.GetHostedZoneOutput // In this case we have a terraform ID. We have to get the details of the hosted zone first zoneResp, err = client.GetHostedZone(ctx, &route53.GetHostedZoneInput{ Id: &hostedZoneID, }) if err != nil { return nil, err } if zoneResp.HostedZone == nil { return nil, fmt.Errorf("hosted zone %s not found", hostedZoneID) } fullName := constructRecordFQDN(recordName, *zoneResp.HostedZone.Name) var maxItems int32 = 1 req := route53.ListResourceRecordSetsInput{ HostedZoneId: &hostedZoneID, StartRecordName: &fullName, StartRecordType: types.RRType(recordType), MaxItems: &maxItems, } out, err = client.ListResourceRecordSets(ctx, &req) } else { // In this case we have a hosted zone ID out, err = client.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{ HostedZoneId: &query, }) } if err != nil { return nil, err } records := make([]*types.ResourceRecordSet, 0, len(out.ResourceRecordSets)) for _, record := range out.ResourceRecordSets { records = append(records, &record) } return records, nil } func resourceRecordSetItemMapper(_, scope string, awsItem *types.ResourceRecordSet) (*sdp.Item, error) { attributes, err := ToAttributesWithExclude(awsItem) if err != nil { return nil, err } item := sdp.Item{ Type: "route53-resource-record-set", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, } if awsItem.Name != nil { recordName := strings.TrimSuffix(*awsItem.Name, ".") if recordName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: recordName, Scope: "global", }, }) } } if awsItem.AliasTarget != nil { if awsItem.AliasTarget.DNSName != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *awsItem.AliasTarget.DNSName, Scope: "global", }, }) } } for _, record := range awsItem.ResourceRecords { if record.Value != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *record.Value, Scope: "global", }, }) } } if awsItem.HealthCheckId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "route53-health-check", Method: sdp.QueryMethod_GET, Query: *awsItem.HealthCheckId, Scope: scope, }, }) } return &item, nil } func NewRoute53ResourceRecordSetAdapter(client *route53.Client, accountID string, region string, cache sdpcache.Cache) *GetListAdapter[*types.ResourceRecordSet, *route53.Client, *route53.Options] { return &GetListAdapter[*types.ResourceRecordSet, *route53.Client, *route53.Options]{ ItemType: "route53-resource-record-set", Client: client, DisableList: true, AccountID: accountID, Region: region, GetFunc: resourceRecordSetGetFunc, ItemMapper: resourceRecordSetItemMapper, SearchFunc: resourceRecordSetSearchFunc, AdapterMetadata: resourceRecordSetAdapterMetadata, cache: cache} } var resourceRecordSetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "route53-resource-record-set", DescriptiveName: "Route53 Record Set", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get a Route53 record Set by name", SearchDescription: "Search for a record set by hosted zone ID in the format \"/hostedzone/JJN928734JH7HV\" or \"JJN928734JH7HV\" or by terraform ID in the format \"{hostedZone}_{recordName}_{type}\"", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"dns", "route53-health-check"}, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_route53_record.arn", TerraformMethod: sdp.QueryMethod_SEARCH}, {TerraformQueryMap: "aws_route53_record.id", TerraformMethod: sdp.QueryMethod_SEARCH}, }, }) ================================================ FILE: aws-source/adapters/route53-resource-record-set_test.go ================================================ package adapters import ( "context" "fmt" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestResourceRecordSetItemMapper(t *testing.T) { recordSet := types.ResourceRecordSet{ Name: new("overmind-demo.com."), Type: types.RRTypeNs, TTL: new(int64(172800)), GeoProximityLocation: &types.GeoProximityLocation{ AWSRegion: new("us-east-1"), Bias: new(int32(100)), Coordinates: &types.Coordinates{}, LocalZoneGroup: new("group"), }, ResourceRecords: []types.ResourceRecord{ { Value: new("ns-1673.awsdns-17.co.uk."), // link }, { Value: new("ns-1505.awsdns-60.org."), // link }, { Value: new("ns-955.awsdns-55.net."), // link }, { Value: new("ns-276.awsdns-34.com."), // link }, }, AliasTarget: &types.AliasTarget{ DNSName: new("foo.bar.com"), // link EvaluateTargetHealth: true, HostedZoneId: new("id"), }, CidrRoutingConfig: &types.CidrRoutingConfig{ CollectionId: new("id"), LocationName: new("somewhere"), }, Failover: types.ResourceRecordSetFailoverPrimary, GeoLocation: &types.GeoLocation{ ContinentCode: new("GB"), CountryCode: new("GB"), SubdivisionCode: new("ENG"), }, HealthCheckId: new("id"), // link MultiValueAnswer: new(true), Region: types.ResourceRecordSetRegionApEast1, SetIdentifier: new("identifier"), TrafficPolicyInstanceId: new("id"), Weight: new(int64(100)), } item, err := resourceRecordSetItemMapper("", "foo", &recordSet) if err != nil { t.Error(err) } if err = item.Validate(); err != nil { t.Error(err) } tests := QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "overmind-demo.com", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "foo.bar.com", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ns-1673.awsdns-17.co.uk.", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ns-1505.awsdns-60.org.", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ns-955.awsdns-55.net.", ExpectedScope: "global", }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ns-276.awsdns-34.com.", ExpectedScope: "global", }, { ExpectedType: "route53-health-check", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "id", ExpectedScope: "foo", }, } tests.Execute(t, item) } // TestConstructRecordFQDN tests the FQDN construction logic // for various record name formats func TestConstructRecordFQDN(t *testing.T) { type testCase struct { name string hostedZoneName string recordName string expectedFQDN string description string } testCases := []testCase{ { name: "simple_subdomain", hostedZoneName: "example.com.", recordName: "www", expectedFQDN: "www.example.com.", description: "Simple subdomain record", }, { name: "already_full_fqdn_with_trailing_dot", hostedZoneName: "example.com.", recordName: "subdomain.example.com.", expectedFQDN: "subdomain.example.com.", description: "Record name already contains full FQDN with trailing dot", }, { name: "already_full_fqdn_without_trailing_dot", hostedZoneName: "example.com.", recordName: "subdomain.example.com", expectedFQDN: "subdomain.example.com", description: "Record name already contains full FQDN without trailing dot", }, { name: "apex_record_matches_zone", hostedZoneName: "example.com.", recordName: "example.com", expectedFQDN: "example.com", description: "Apex record where name matches zone FQDN (without trailing dot)", }, { name: "complex_subdomain_case", hostedZoneName: "a2d-dev.tv.", recordName: "davidtest-other.a2d-dev.tv", expectedFQDN: "davidtest-other.a2d-dev.tv", description: "Complex case from the bug report - prevents double domain concatenation", }, { name: "nested_subdomain", hostedZoneName: "example.com.", recordName: "deep.nested.subdomain", expectedFQDN: "deep.nested.subdomain.example.com.", description: "Nested subdomain that needs zone appended", }, { name: "ns_record_with_full_domain", hostedZoneName: "example.com.", recordName: "ns.example.com.", expectedFQDN: "ns.example.com.", description: "NS record with full domain (common pattern)", }, { name: "zone_without_trailing_dot", hostedZoneName: "example.com", recordName: "www", expectedFQDN: "www.example.com", description: "Hosted zone name without trailing dot", }, { name: "record_already_ends_with_zone_no_dot", hostedZoneName: "example.com", recordName: "subdomain.example.com", expectedFQDN: "subdomain.example.com", description: "Record already ends with zone name (no trailing dots)", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := constructRecordFQDN(tc.recordName, tc.hostedZoneName) if result != tc.expectedFQDN { t.Errorf("Expected FQDN %q but got %q. %s", tc.expectedFQDN, result, tc.description) } }) } } func TestNewRoute53ResourceRecordSetAdapter(t *testing.T) { client, account, region := route53GetAutoConfig(t) zoneSource := NewRoute53HostedZoneAdapter(client, account, region, sdpcache.NewNoOpCache()) zones, err := zoneSource.List(context.Background(), zoneSource.Scopes()[0], true) if err != nil { t.Fatal(err) } if len(zones) == 0 { t.Skip("no zones found") } adapter := NewRoute53ResourceRecordSetAdapter(client, account, region, sdpcache.NewNoOpCache()) search := zones[0].UniqueAttributeValue() test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipGet: true, GoodSearchQuery: &search, } test.Run(t) items, err := adapter.Search(context.Background(), zoneSource.Scopes()[0], search, true) if err != nil { t.Fatal(err) } numItems := len(items) rawZone := strings.TrimPrefix(search, "/hostedzone/") items, err = adapter.Search(context.Background(), zoneSource.Scopes()[0], rawZone, true) if err != nil { t.Fatal(err) } if len(items) != numItems { t.Errorf("expected %d items, got %d", numItems, len(items)) } for _, item := range items { // Only use CNAME records typ, _ := item.GetAttributes().Get("Type") if typ != "CNAME" { continue } // Construct a terraform style ID fqdn, _ := item.GetAttributes().Get("Name") sections := strings.Split(fqdn.(string), ".") name := sections[0] search = fmt.Sprintf("%s_%s_%s", rawZone, name, typ) items, err := adapter.Search(context.Background(), zoneSource.Scopes()[0], search, true) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Errorf("expected 1 item, got %d", len(items)) } // Only need to test this once break } } ================================================ FILE: aws-source/adapters/route53.go ================================================ package adapters import "github.com/aws/aws-sdk-go-v2/service/route53/types" func route53TagsToMap(tags []types.Tag) map[string]string { m := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { m[*tag.Key] = *tag.Value } } return m } ================================================ FILE: aws-source/adapters/route53_test.go ================================================ package adapters import ( "github.com/aws/aws-sdk-go-v2/service/route53" "testing" ) func route53GetAutoConfig(t *testing.T) (*route53.Client, string, string) { config, account, region := GetAutoConfig(t) client := route53.NewFromConfig(config) return client, account, region } ================================================ FILE: aws-source/adapters/s3.go ================================================ package adapters import ( "context" "errors" "fmt" "sync" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) const CacheDuration = 10 * time.Minute // NewS3Source Creates a new S3 adapter func NewS3Adapter(config aws.Config, accountID string, cache sdpcache.Cache) *S3Source { return &S3Source{ config: config, accountID: accountID, AdapterMetadata: s3Metadata, cache: cache, } } var s3Metadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "s3-bucket", DescriptiveName: "S3 Bucket", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an S3 bucket by name", ListDescription: "List all S3 buckets", SearchDescription: "Search for S3 buckets by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_s3_bucket_acl.bucket"}, {TerraformQueryMap: "aws_s3_bucket_analytics_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_cors_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_intelligent_tiering_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_inventory.bucket"}, {TerraformQueryMap: "aws_s3_bucket_lifecycle_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_logging.bucket"}, {TerraformQueryMap: "aws_s3_bucket_metric.bucket"}, {TerraformQueryMap: "aws_s3_bucket_notification.bucket"}, {TerraformQueryMap: "aws_s3_bucket_object_lock_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_object.bucket"}, {TerraformQueryMap: "aws_s3_bucket_ownership_controls.bucket"}, {TerraformQueryMap: "aws_s3_bucket_policy.bucket"}, {TerraformQueryMap: "aws_s3_bucket_public_access_block.bucket"}, {TerraformQueryMap: "aws_s3_bucket_replication_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_request_payment_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_server_side_encryption_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket_versioning.bucket"}, {TerraformQueryMap: "aws_s3_bucket_website_configuration.bucket"}, {TerraformQueryMap: "aws_s3_bucket.id"}, {TerraformQueryMap: "aws_s3_object_copy.bucket"}, {TerraformQueryMap: "aws_s3_object.bucket"}, }, PotentialLinks: []string{"lambda-function", "sqs-queue", "sns-topic", "s3-bucket"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, }) type S3Source struct { // AWS Config including region and credentials config aws.Config // AccountID The id of the account that is being used. This is used by // sources as the first element in the scope accountID string // client The AWS client to use when making requests client *s3.Client clientCreated bool clientMutex sync.Mutex AdapterMetadata *sdp.AdapterMetadata CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // The cache for this adapter (set during creation, can be nil for tests) } func (s *S3Source) Client() *s3.Client { s.clientMutex.Lock() defer s.clientMutex.Unlock() // If the client already exists then return it if s.clientCreated { return s.client } // Otherwise create a new client from the config s.client = s3.NewFromConfig(s.config) s.clientCreated = true return s.client } // Type The type of items that this adapter is capable of finding func (s *S3Source) Type() string { return "s3-bucket" } // Descriptive name for the adapter, used in logging and metadata func (s *S3Source) Name() string { return "aws-s3-adapter" } func (s *S3Source) Metadata() *sdp.AdapterMetadata { return s.AdapterMetadata } // List of scopes that this adapter is capable of find items for. This will be // in the format {accountID} since S3 endpoint is global func (s *S3Source) Scopes() []string { return []string{ FormatScope(s.accountID, ""), } } // S3Client A client that can get data about S3 buckets type S3Client interface { ListBuckets(ctx context.Context, params *s3.ListBucketsInput, optFns ...func(*s3.Options)) (*s3.ListBucketsOutput, error) GetBucketAcl(ctx context.Context, params *s3.GetBucketAclInput, optFns ...func(*s3.Options)) (*s3.GetBucketAclOutput, error) GetBucketAnalyticsConfiguration(ctx context.Context, params *s3.GetBucketAnalyticsConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketAnalyticsConfigurationOutput, error) GetBucketCors(ctx context.Context, params *s3.GetBucketCorsInput, optFns ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) GetBucketEncryption(ctx context.Context, params *s3.GetBucketEncryptionInput, optFns ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) GetBucketIntelligentTieringConfiguration(ctx context.Context, params *s3.GetBucketIntelligentTieringConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketIntelligentTieringConfigurationOutput, error) GetBucketInventoryConfiguration(ctx context.Context, params *s3.GetBucketInventoryConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketInventoryConfigurationOutput, error) GetBucketLifecycleConfiguration(ctx context.Context, params *s3.GetBucketLifecycleConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLifecycleConfigurationOutput, error) GetBucketLocation(ctx context.Context, params *s3.GetBucketLocationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) GetBucketLogging(ctx context.Context, params *s3.GetBucketLoggingInput, optFns ...func(*s3.Options)) (*s3.GetBucketLoggingOutput, error) GetBucketMetricsConfiguration(ctx context.Context, params *s3.GetBucketMetricsConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketMetricsConfigurationOutput, error) GetBucketNotificationConfiguration(ctx context.Context, params *s3.GetBucketNotificationConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketNotificationConfigurationOutput, error) GetBucketOwnershipControls(ctx context.Context, params *s3.GetBucketOwnershipControlsInput, optFns ...func(*s3.Options)) (*s3.GetBucketOwnershipControlsOutput, error) GetBucketPolicy(ctx context.Context, params *s3.GetBucketPolicyInput, optFns ...func(*s3.Options)) (*s3.GetBucketPolicyOutput, error) GetBucketPolicyStatus(ctx context.Context, params *s3.GetBucketPolicyStatusInput, optFns ...func(*s3.Options)) (*s3.GetBucketPolicyStatusOutput, error) GetBucketReplication(ctx context.Context, params *s3.GetBucketReplicationInput, optFns ...func(*s3.Options)) (*s3.GetBucketReplicationOutput, error) GetBucketRequestPayment(ctx context.Context, params *s3.GetBucketRequestPaymentInput, optFns ...func(*s3.Options)) (*s3.GetBucketRequestPaymentOutput, error) GetBucketTagging(ctx context.Context, params *s3.GetBucketTaggingInput, optFns ...func(*s3.Options)) (*s3.GetBucketTaggingOutput, error) GetBucketVersioning(ctx context.Context, params *s3.GetBucketVersioningInput, optFns ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) GetBucketWebsite(ctx context.Context, params *s3.GetBucketWebsiteInput, optFns ...func(*s3.Options)) (*s3.GetBucketWebsiteOutput, error) } // Bucket represents an actual s3 bucket, with all of the extra requests // resolved and all information added type Bucket struct { // ListBuckets types.Bucket s3.GetBucketAclOutput s3.GetBucketAnalyticsConfigurationOutput s3.GetBucketCorsOutput s3.GetBucketEncryptionOutput s3.GetBucketIntelligentTieringConfigurationOutput s3.GetBucketInventoryConfigurationOutput s3.GetBucketLifecycleConfigurationOutput s3.GetBucketLocationOutput s3.GetBucketLoggingOutput s3.GetBucketMetricsConfigurationOutput s3.GetBucketNotificationConfigurationOutput s3.GetBucketOwnershipControlsOutput s3.GetBucketPolicyOutput s3.GetBucketPolicyStatusOutput s3.GetBucketReplicationOutput s3.GetBucketRequestPaymentOutput s3.GetBucketVersioningOutput s3.GetBucketWebsiteOutput } // Get Get a single item with a given scope and query. The item returned // should have a UniqueAttributeValue that matches the `query` parameter. The // ctx parameter contains a golang context object which should be used to allow // this adapter to timeout or be cancelled when executing potentially // long-running actions func (s *S3Source) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != s.Scopes()[0] { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), Scope: scope, } } return getImpl(ctx, s.cache, s.Client(), scope, query, ignoreCache) } func getImpl(ctx context.Context, cache sdpcache.Cache, client S3Client, scope string, query string, ignoreCache bool) (*sdp.Item, error) { var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError cacheHit, ck, cachedItems, qErr, done := cache.Lookup(ctx, "aws-s3-adapter", sdp.QueryMethod_GET, scope, "s3-bucket", query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) > 0 { return cachedItems[0], nil } else { return nil, nil } } var location *s3.GetBucketLocationOutput var wg sync.WaitGroup var err error bucketName := new(query) location, err = client.GetBucketLocation(ctx, &s3.GetBucketLocationInput{ Bucket: bucketName, }) if err != nil { err = WrapAWSError(err) var queryErr *sdp.QueryError if errors.As(err, &queryErr) { // Cache not-found errors and other non-retryable errors if queryErr.GetErrorType() == sdp.QueryError_NOTFOUND || !CanRetry(queryErr) { cache.StoreUnavailableItem(ctx, err, CacheDuration, ck) } } return nil, err } bucket := Bucket{ Bucket: types.Bucket{ Name: bucketName, }, GetBucketLocationOutput: *location, } // We want to execute all of these requests in parallel so we're not // crippled by latency. This API is really stupid but there's not much I can // do about it var tagging *s3.GetBucketTaggingOutput wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if acl, err := client.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: bucketName}); err == nil { bucket.GetBucketAclOutput = *acl } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if analyticsConfiguration, err := client.GetBucketAnalyticsConfiguration(ctx, &s3.GetBucketAnalyticsConfigurationInput{Bucket: bucketName}); err == nil { bucket.GetBucketAnalyticsConfigurationOutput = *analyticsConfiguration } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if cors, err := client.GetBucketCors(ctx, &s3.GetBucketCorsInput{Bucket: bucketName}); err == nil { bucket.GetBucketCorsOutput = *cors } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if encryption, err := client.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: bucketName}); err == nil { bucket.GetBucketEncryptionOutput = *encryption } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if intelligentTieringConfiguration, err := client.GetBucketIntelligentTieringConfiguration(ctx, &s3.GetBucketIntelligentTieringConfigurationInput{Bucket: bucketName}); err == nil { bucket.GetBucketIntelligentTieringConfigurationOutput = *intelligentTieringConfiguration } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if inventoryConfiguration, err := client.GetBucketInventoryConfiguration(ctx, &s3.GetBucketInventoryConfigurationInput{Bucket: bucketName}); err == nil { bucket.GetBucketInventoryConfigurationOutput = *inventoryConfiguration } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if lifecycleConfiguration, err := client.GetBucketLifecycleConfiguration(ctx, &s3.GetBucketLifecycleConfigurationInput{Bucket: bucketName}); err == nil { bucket.GetBucketLifecycleConfigurationOutput = *lifecycleConfiguration } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if logging, err := client.GetBucketLogging(ctx, &s3.GetBucketLoggingInput{Bucket: bucketName}); err == nil { bucket.GetBucketLoggingOutput = *logging } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if metricsConfiguration, err := client.GetBucketMetricsConfiguration(ctx, &s3.GetBucketMetricsConfigurationInput{Bucket: bucketName}); err == nil { bucket.GetBucketMetricsConfigurationOutput = *metricsConfiguration } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if notificationConfiguration, err := client.GetBucketNotificationConfiguration(ctx, &s3.GetBucketNotificationConfigurationInput{Bucket: bucketName}); err == nil { bucket.GetBucketNotificationConfigurationOutput = *notificationConfiguration } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if ownershipControls, err := client.GetBucketOwnershipControls(ctx, &s3.GetBucketOwnershipControlsInput{Bucket: bucketName}); err == nil { bucket.GetBucketOwnershipControlsOutput = *ownershipControls } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if policy, err := client.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{Bucket: bucketName}); err == nil { bucket.GetBucketPolicyOutput = *policy } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if policyStatus, err := client.GetBucketPolicyStatus(ctx, &s3.GetBucketPolicyStatusInput{Bucket: bucketName}); err == nil { bucket.GetBucketPolicyStatusOutput = *policyStatus } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if replication, err := client.GetBucketReplication(ctx, &s3.GetBucketReplicationInput{Bucket: bucketName}); err == nil { bucket.GetBucketReplicationOutput = *replication } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if requestPayment, err := client.GetBucketRequestPayment(ctx, &s3.GetBucketRequestPaymentInput{Bucket: bucketName}); err == nil { bucket.GetBucketRequestPaymentOutput = *requestPayment } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if out, err := client.GetBucketTagging(ctx, &s3.GetBucketTaggingInput{Bucket: bucketName}); err == nil { tagging = out } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if versioning, err := client.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: bucketName}); err == nil { bucket.GetBucketVersioningOutput = *versioning } }() wg.Add(1) go func() { defer sentry.Recover() defer wg.Done() if website, err := client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{Bucket: bucketName}); err == nil { bucket.GetBucketWebsiteOutput = *website } }() // Wait for all requests to complete wg.Wait() attributes, err := ToAttributesWithExclude(bucket) if err != nil { err = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } cache.StoreUnavailableItem(ctx, err, CacheDuration, ck) return nil, err } // Convert tags tags := make(map[string]string) if tagging != nil { for _, tag := range tagging.TagSet { if tag.Key != nil && tag.Value != nil { tags[*tag.Key] = *tag.Value } } } item := sdp.Item{ Type: "s3-bucket", UniqueAttribute: "Name", Attributes: attributes, Scope: scope, Tags: tags, } if bucket.RedirectAllRequestsTo != nil { if bucket.RedirectAllRequestsTo.HostName != nil { var url string switch bucket.RedirectAllRequestsTo.Protocol { case types.ProtocolHttp: url = "https://" + *bucket.RedirectAllRequestsTo.HostName case types.ProtocolHttps: url = "https://" + *bucket.RedirectAllRequestsTo.HostName } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: url, Scope: "global", }, }) } } var a *ARN for _, lambdaConfig := range bucket.LambdaFunctionConfigurations { if lambdaConfig.LambdaFunctionArn != nil { if a, err = ParseARN(*lambdaConfig.LambdaFunctionArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-function", Method: sdp.QueryMethod_SEARCH, Query: *lambdaConfig.LambdaFunctionArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } for _, q := range bucket.QueueConfigurations { if q.QueueArn != nil { if a, err = ParseARN(*q.QueueArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sqs-queue", Method: sdp.QueryMethod_SEARCH, Query: *q.QueueArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } for _, topic := range bucket.TopicConfigurations { if topic.TopicArn != nil { if a, err = ParseARN(*topic.TopicArn); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sns-topic", Method: sdp.QueryMethod_SEARCH, Query: *topic.TopicArn, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } if bucket.LoggingEnabled != nil { if bucket.LoggingEnabled.TargetBucket != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "s3-bucket", Method: sdp.QueryMethod_GET, Query: *bucket.LoggingEnabled.TargetBucket, Scope: scope, }, }) } } if bucket.InventoryConfiguration != nil { if bucket.InventoryConfiguration.Destination != nil { if bucket.InventoryConfiguration.Destination.S3BucketDestination != nil { if bucket.InventoryConfiguration.Destination.S3BucketDestination.Bucket != nil { if a, err = ParseARN(*bucket.InventoryConfiguration.Destination.S3BucketDestination.Bucket); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "s3-bucket", Method: sdp.QueryMethod_SEARCH, Query: *bucket.InventoryConfiguration.Destination.S3BucketDestination.Bucket, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } } // Dear god there has to be a better way to do this? Should we just let it // panic and then deal with it? if bucket.AnalyticsConfiguration != nil { if bucket.AnalyticsConfiguration.StorageClassAnalysis != nil { if bucket.AnalyticsConfiguration.StorageClassAnalysis.DataExport != nil { if bucket.AnalyticsConfiguration.StorageClassAnalysis.DataExport.Destination != nil { if bucket.AnalyticsConfiguration.StorageClassAnalysis.DataExport.Destination.S3BucketDestination != nil { if bucket.AnalyticsConfiguration.StorageClassAnalysis.DataExport.Destination.S3BucketDestination.Bucket != nil { if a, err = ParseARN(*bucket.AnalyticsConfiguration.StorageClassAnalysis.DataExport.Destination.S3BucketDestination.Bucket); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "s3-bucket", Method: sdp.QueryMethod_SEARCH, Query: *bucket.AnalyticsConfiguration.StorageClassAnalysis.DataExport.Destination.S3BucketDestination.Bucket, Scope: FormatScope(a.AccountID, a.Region), }, }) } } } } } } } cache.StoreItem(ctx, &item, CacheDuration, ck) return &item, nil } // List Lists all items in a given scope func (s *S3Source) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != s.Scopes()[0] { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), Scope: scope, } } return listImpl(ctx, s.cache, s.Client(), scope, ignoreCache) } func listImpl(ctx context.Context, cache sdpcache.Cache, client S3Client, scope string, ignoreCache bool) ([]*sdp.Item, error) { var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError cacheHit, ck, cachedItems, qErr, done := cache.Lookup(ctx, "aws-s3-adapter", sdp.QueryMethod_LIST, scope, "s3-bucket", "", ignoreCache) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } return nil, qErr } if cacheHit { if len(cachedItems) > 0 { return cachedItems, nil } else { return nil, nil } } items := make([]*sdp.Item, 0) buckets, err := client.ListBuckets(ctx, &s3.ListBucketsInput{}) if err != nil { err = sdp.NewQueryError(err) cache.StoreUnavailableItem(ctx, err, CacheDuration, ck) return nil, err } hadErrors := false for _, bucket := range buckets.Buckets { item, err := getImpl(ctx, cache, client, scope, *bucket.Name, ignoreCache) if err != nil { hadErrors = true continue } if item != nil { items = append(items, item) } } // Cache not-found only when no buckets were returned AND no errors occurred // If we had errors, buckets may exist but we couldn't fetch them if len(items) == 0 && !hadErrors && len(buckets.Buckets) == 0 { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no s3-bucket found in scope " + scope, Scope: scope, SourceName: "aws-s3-adapter", ItemType: "s3-bucket", ResponderName: "aws-s3-adapter", } cache.StoreUnavailableItem(ctx, notFoundErr, CacheDuration, ck) return items, nil } for _, item := range items { cache.StoreItem(ctx, item, CacheDuration, ck) } return items, nil } // Search Searches for an S3 bucket by ARN rather than name func (s *S3Source) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != s.Scopes()[0] { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match adapter scope %v", scope, s.Scopes()[0]), Scope: scope, } } return searchImpl(ctx, s.cache, s.Client(), scope, query, ignoreCache) } func searchImpl(ctx context.Context, cache sdpcache.Cache, client S3Client, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { // Parse the ARN a, err := ParseARN(query) if err != nil { return nil, sdp.NewQueryError(err) } // For S3 bucket ARNs, account ID and region are empty, so we skip scope validation // and use the adapter's scope (which is account-scoped) // If the ARN does have an account ID, validate it matches the adapter scope if a.AccountID != "" { if arnScope := FormatScope(a.AccountID, a.Region); arnScope != scope { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("ARN scope %v does not match adapters scope %v", arnScope, scope), Scope: scope, } } } // If the ARN was parsed we can just ask Get for the item item, err := getImpl(ctx, cache, client, scope, a.ResourceID(), ignoreCache) if err != nil { return nil, err } if item != nil { return []*sdp.Item{item}, nil } return []*sdp.Item{}, nil } // Weight Returns the priority weighting of items returned by this adapter. // This is used to resolve conflicts where two sources of the same type // return an item for a GET request. In this instance only one item can be // seen on, so the one with the higher weight value will win. func (s *S3Source) Weight() int { return 100 } ================================================ FILE: aws-source/adapters/s3_test.go ================================================ package adapters import ( "context" "errors" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestS3SearchImpl(t *testing.T) { cache := sdpcache.NewNoOpCache() t.Run("with S3 bucket ARN format (empty account ID and region)", func(t *testing.T) { // This test verifies that S3 bucket ARNs with empty account ID and region work correctly // Format: arn:aws:s3:::bucket-name // When parsed, AccountID="", Region="", so FormatScope("", "") returns sdp.WILDCARD // The adapter skips scope validation when accountID is empty and uses its own scope // // EXPECTED BEHAVIOR: Search should succeed because S3 bucket ARNs don't include account/region // (S3 is global), and the adapter should use its own scope since it knows the account ID. bucketName := "test-bucket-name" s3ARN := "arn:aws:s3:::" + bucketName adapterScope := "account-id" // S3 scopes are account-only (no region) items, err := searchImpl(context.Background(), cache, TestS3Client{}, adapterScope, s3ARN, false) // We EXPECT this to succeed, but it currently fails with NOSCOPE error // This test demonstrates the bug existing if err != nil { var ire *sdp.QueryError if errors.As(err, &ire) { if ire.GetErrorType() == sdp.QueryError_NOSCOPE && strings.Contains(ire.GetErrorString(), "ARN scope") { // This is the bug - the search fails when it should succeed t.Errorf("BUG REPRODUCED: Search failed with NOSCOPE error when it should succeed. "+ "Error: %v. S3 bucket ARNs don't include account/region, so the adapter should use its own scope.", ire.GetErrorString()) t.Logf("Expected: Search succeeds and returns bucket item") t.Logf("Actual: Search fails with NOSCOPE error: %v", ire.GetErrorString()) } else { t.Errorf("unexpected error: %v", err) } } else { t.Errorf("unexpected error type: %T: %v", err, err) } return } // If we get here, the search succeeded (expected behavior) if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } if items[0] == nil { t.Error("expected non-nil item") } }) } func TestS3ListImpl(t *testing.T) { cache := sdpcache.NewNoOpCache() items, err := listImpl(context.Background(), cache, TestS3Client{}, "foo", false) if err != nil { t.Error(err) } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } } func TestS3GetImpl(t *testing.T) { cache := sdpcache.NewNoOpCache() item, err := getImpl(context.Background(), cache, TestS3Client{}, "foo", "bar", false) if err != nil { t.Fatal(err) } tests := QueryTests{ { ExpectedType: "http", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://hostname", ExpectedScope: "global", }, { ExpectedType: "lambda-function", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service:region:account-id:resource-type:resource-id", ExpectedScope: "account-id.region", }, { ExpectedType: "sqs-queue", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service:region:account-id:resource-type:resource-id", ExpectedScope: "account-id.region", }, { ExpectedType: "sns-topic", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:partition:service:region:account-id:resource-type:resource-id", ExpectedScope: "account-id.region", }, { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "bucket", ExpectedScope: "foo", }, { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:s3:::amzn-s3-demo-bucket", ExpectedScope: sdp.WILDCARD, }, { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:s3:::amzn-s3-demo-bucket", ExpectedScope: sdp.WILDCARD, }, } tests.Execute(t, item) } func TestS3SourceCaching(t *testing.T) { cache := sdpcache.NewMemoryCache() first, err := getImpl(context.Background(), cache, TestS3Client{}, "foo", "bar", false) if err != nil { t.Fatal(err) } if first == nil { t.Fatal("expected first item") } second, err := getImpl(context.Background(), cache, TestS3FailClient{}, "foo", "bar", false) if err != nil { t.Fatal(err) } if second == nil { t.Fatal("expected second item") } third, err := getImpl(context.Background(), cache, TestS3Client{}, "foo", "bar", true) if err != nil { t.Fatal(err) } if third == nil { t.Fatal("expected third item") } if third == second { t.Errorf("expected third item (%v) to be different to second item (%v)", third, second) } } var owner = types.Owner{ DisplayName: new("dylan"), ID: new("id"), } // TestS3Client A client that returns example data type TestS3Client struct{} func (t TestS3Client) ListBuckets(ctx context.Context, params *s3.ListBucketsInput, optFns ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { return &s3.ListBucketsOutput{ Buckets: []types.Bucket{ { CreationDate: new(time.Now()), Name: new("foo"), }, }, Owner: &owner, }, nil } func (t TestS3Client) GetBucketAcl(ctx context.Context, params *s3.GetBucketAclInput, optFns ...func(*s3.Options)) (*s3.GetBucketAclOutput, error) { return &s3.GetBucketAclOutput{ Grants: []types.Grant{ { Grantee: &types.Grantee{ Type: types.TypeAmazonCustomerByEmail, DisplayName: new("dylan"), EmailAddress: new("dylan@company.com"), ID: new("id"), URI: new("uri"), }, }, }, Owner: &owner, }, nil } func (t TestS3Client) GetBucketAnalyticsConfiguration(ctx context.Context, params *s3.GetBucketAnalyticsConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketAnalyticsConfigurationOutput, error) { return &s3.GetBucketAnalyticsConfigurationOutput{ AnalyticsConfiguration: &types.AnalyticsConfiguration{ Id: new("id"), StorageClassAnalysis: &types.StorageClassAnalysis{ DataExport: &types.StorageClassAnalysisDataExport{ Destination: &types.AnalyticsExportDestination{ S3BucketDestination: &types.AnalyticsS3BucketDestination{ Bucket: new("arn:aws:s3:::amzn-s3-demo-bucket"), Format: types.AnalyticsS3ExportFileFormatCsv, BucketAccountId: new("id"), Prefix: new("pre"), }, }, OutputSchemaVersion: types.StorageClassAnalysisSchemaVersionV1, }, }, }, }, nil } func (t TestS3Client) GetBucketCors(ctx context.Context, params *s3.GetBucketCorsInput, optFns ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { return &s3.GetBucketCorsOutput{ CORSRules: []types.CORSRule{ { AllowedMethods: []string{ "GET", }, AllowedOrigins: []string{ "amazon.com", }, AllowedHeaders: []string{ "Authorization", }, ExposeHeaders: []string{ "foo", }, ID: new("id"), MaxAgeSeconds: new(int32(10)), }, }, }, nil } func (t TestS3Client) GetBucketEncryption(ctx context.Context, params *s3.GetBucketEncryptionInput, optFns ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) { return &s3.GetBucketEncryptionOutput{ ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{ Rules: []types.ServerSideEncryptionRule{ { ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{ SSEAlgorithm: types.ServerSideEncryptionAes256, KMSMasterKeyID: new("id"), }, BucketKeyEnabled: new(true), }, }, }, }, nil } func (t TestS3Client) GetBucketIntelligentTieringConfiguration(ctx context.Context, params *s3.GetBucketIntelligentTieringConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketIntelligentTieringConfigurationOutput, error) { return &s3.GetBucketIntelligentTieringConfigurationOutput{ IntelligentTieringConfiguration: &types.IntelligentTieringConfiguration{ Id: new("id"), Status: types.IntelligentTieringStatusEnabled, Tierings: []types.Tiering{ { AccessTier: types.IntelligentTieringAccessTierDeepArchiveAccess, Days: new(int32(100)), }, }, Filter: &types.IntelligentTieringFilter{}, }, }, nil } func (t TestS3Client) GetBucketInventoryConfiguration(ctx context.Context, params *s3.GetBucketInventoryConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketInventoryConfigurationOutput, error) { return &s3.GetBucketInventoryConfigurationOutput{ InventoryConfiguration: &types.InventoryConfiguration{ Destination: &types.InventoryDestination{ S3BucketDestination: &types.InventoryS3BucketDestination{ Bucket: new("arn:aws:s3:::amzn-s3-demo-bucket"), Format: types.InventoryFormatCsv, AccountId: new("id"), Encryption: &types.InventoryEncryption{ SSEKMS: &types.SSEKMS{ KeyId: new("key"), }, }, Prefix: new("pre"), }, }, Id: new("id"), IncludedObjectVersions: types.InventoryIncludedObjectVersionsAll, IsEnabled: new(true), Schedule: &types.InventorySchedule{ Frequency: types.InventoryFrequencyDaily, }, }, }, nil } func (t TestS3Client) GetBucketLifecycleConfiguration(ctx context.Context, params *s3.GetBucketLifecycleConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLifecycleConfigurationOutput, error) { return &s3.GetBucketLifecycleConfigurationOutput{ Rules: []types.LifecycleRule{ { Status: types.ExpirationStatusEnabled, AbortIncompleteMultipartUpload: &types.AbortIncompleteMultipartUpload{ DaysAfterInitiation: new(int32(1)), }, Expiration: &types.LifecycleExpiration{ Date: new(time.Now()), Days: new(int32(3)), ExpiredObjectDeleteMarker: new(true), }, ID: new("id"), NoncurrentVersionExpiration: &types.NoncurrentVersionExpiration{ NewerNoncurrentVersions: new(int32(3)), NoncurrentDays: new(int32(1)), }, NoncurrentVersionTransitions: []types.NoncurrentVersionTransition{ { NewerNoncurrentVersions: new(int32(1)), NoncurrentDays: new(int32(1)), StorageClass: types.TransitionStorageClassGlacierIr, }, }, Prefix: new("pre"), Transitions: []types.Transition{ { Date: new(time.Now()), Days: new(int32(12)), StorageClass: types.TransitionStorageClassGlacierIr, }, }, }, }, }, nil } func (t TestS3Client) GetBucketLocation(ctx context.Context, params *s3.GetBucketLocationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) { return &s3.GetBucketLocationOutput{ LocationConstraint: types.BucketLocationConstraintAfSouth1, }, nil } func (t TestS3Client) GetBucketLogging(ctx context.Context, params *s3.GetBucketLoggingInput, optFns ...func(*s3.Options)) (*s3.GetBucketLoggingOutput, error) { return &s3.GetBucketLoggingOutput{ LoggingEnabled: &types.LoggingEnabled{ TargetBucket: new("bucket"), TargetPrefix: new("pre"), TargetGrants: []types.TargetGrant{ { Grantee: &types.Grantee{ Type: types.TypeGroup, ID: new("id"), }, }, }, }, }, nil } func (t TestS3Client) GetBucketMetricsConfiguration(ctx context.Context, params *s3.GetBucketMetricsConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketMetricsConfigurationOutput, error) { return &s3.GetBucketMetricsConfigurationOutput{ MetricsConfiguration: &types.MetricsConfiguration{ Id: new("id"), }, }, nil } func (t TestS3Client) GetBucketNotificationConfiguration(ctx context.Context, params *s3.GetBucketNotificationConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketNotificationConfigurationOutput, error) { return &s3.GetBucketNotificationConfigurationOutput{ LambdaFunctionConfigurations: []types.LambdaFunctionConfiguration{ { Events: []types.Event{}, LambdaFunctionArn: new("arn:partition:service:region:account-id:resource-type:resource-id"), Id: new("id"), }, }, EventBridgeConfiguration: &types.EventBridgeConfiguration{}, QueueConfigurations: []types.QueueConfiguration{ { Events: []types.Event{}, QueueArn: new("arn:partition:service:region:account-id:resource-type:resource-id"), Filter: &types.NotificationConfigurationFilter{ Key: &types.S3KeyFilter{ FilterRules: []types.FilterRule{ { Name: types.FilterRuleNamePrefix, Value: new("foo"), }, }, }, }, Id: new("id"), }, }, TopicConfigurations: []types.TopicConfiguration{ { Events: []types.Event{}, TopicArn: new("arn:partition:service:region:account-id:resource-type:resource-id"), Filter: &types.NotificationConfigurationFilter{ Key: &types.S3KeyFilter{ FilterRules: []types.FilterRule{ { Name: types.FilterRuleNameSuffix, Value: new("fix"), }, }, }, }, Id: new("id"), }, }, }, nil } func (t TestS3Client) GetBucketOwnershipControls(ctx context.Context, params *s3.GetBucketOwnershipControlsInput, optFns ...func(*s3.Options)) (*s3.GetBucketOwnershipControlsOutput, error) { return &s3.GetBucketOwnershipControlsOutput{ OwnershipControls: &types.OwnershipControls{ Rules: []types.OwnershipControlsRule{ { ObjectOwnership: types.ObjectOwnershipBucketOwnerPreferred, }, }, }, }, nil } func (t TestS3Client) GetBucketPolicy(ctx context.Context, params *s3.GetBucketPolicyInput, optFns ...func(*s3.Options)) (*s3.GetBucketPolicyOutput, error) { return &s3.GetBucketPolicyOutput{ Policy: new("policy"), }, nil } func (t TestS3Client) GetBucketPolicyStatus(ctx context.Context, params *s3.GetBucketPolicyStatusInput, optFns ...func(*s3.Options)) (*s3.GetBucketPolicyStatusOutput, error) { return &s3.GetBucketPolicyStatusOutput{ PolicyStatus: &types.PolicyStatus{ IsPublic: new(true), }, }, nil } func (t TestS3Client) GetBucketReplication(ctx context.Context, params *s3.GetBucketReplicationInput, optFns ...func(*s3.Options)) (*s3.GetBucketReplicationOutput, error) { return &s3.GetBucketReplicationOutput{ ReplicationConfiguration: &types.ReplicationConfiguration{ Role: new("role"), Rules: []types.ReplicationRule{ { Destination: &types.Destination{ Bucket: new("bucket"), AccessControlTranslation: &types.AccessControlTranslation{ Owner: types.OwnerOverrideDestination, }, Account: new("account"), EncryptionConfiguration: &types.EncryptionConfiguration{ ReplicaKmsKeyID: new("keyId"), }, Metrics: &types.Metrics{ Status: types.MetricsStatusEnabled, EventThreshold: &types.ReplicationTimeValue{ Minutes: new(int32(1)), }, }, ReplicationTime: &types.ReplicationTime{ Status: types.ReplicationTimeStatusEnabled, Time: &types.ReplicationTimeValue{ Minutes: new(int32(1)), }, }, StorageClass: types.StorageClassGlacier, }, }, }, }, }, nil } func (t TestS3Client) GetBucketRequestPayment(ctx context.Context, params *s3.GetBucketRequestPaymentInput, optFns ...func(*s3.Options)) (*s3.GetBucketRequestPaymentOutput, error) { return &s3.GetBucketRequestPaymentOutput{ Payer: types.PayerRequester, }, nil } func (t TestS3Client) GetBucketTagging(ctx context.Context, params *s3.GetBucketTaggingInput, optFns ...func(*s3.Options)) (*s3.GetBucketTaggingOutput, error) { return &s3.GetBucketTaggingOutput{ TagSet: []types.Tag{}, }, nil } func (t TestS3Client) GetBucketVersioning(ctx context.Context, params *s3.GetBucketVersioningInput, optFns ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { return &s3.GetBucketVersioningOutput{ MFADelete: types.MFADeleteStatusEnabled, Status: types.BucketVersioningStatusSuspended, }, nil } func (t TestS3Client) GetBucketWebsite(ctx context.Context, params *s3.GetBucketWebsiteInput, optFns ...func(*s3.Options)) (*s3.GetBucketWebsiteOutput, error) { return &s3.GetBucketWebsiteOutput{ ErrorDocument: &types.ErrorDocument{ Key: new("key"), }, IndexDocument: &types.IndexDocument{ Suffix: new("html"), }, RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ HostName: new("hostname"), Protocol: types.ProtocolHttps, }, RoutingRules: []types.RoutingRule{ { Redirect: &types.Redirect{ HostName: new("hostname"), HttpRedirectCode: new("303"), Protocol: types.ProtocolHttp, ReplaceKeyPrefixWith: new("pre"), ReplaceKeyWith: new("key"), }, }, }, }, nil } type TestS3FailClient struct{} func (t TestS3FailClient) ListBuckets(ctx context.Context, params *s3.ListBucketsInput, optFns ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { return nil, errors.New("failed to list buckets") } func (t TestS3FailClient) GetBucketAcl(ctx context.Context, params *s3.GetBucketAclInput, optFns ...func(*s3.Options)) (*s3.GetBucketAclOutput, error) { return nil, errors.New("failed to get bucket ACL") } func (t TestS3FailClient) GetBucketAnalyticsConfiguration(ctx context.Context, params *s3.GetBucketAnalyticsConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketAnalyticsConfigurationOutput, error) { return nil, errors.New("failed to get bucket ACL") } func (t TestS3FailClient) GetBucketCors(ctx context.Context, params *s3.GetBucketCorsInput, optFns ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { return nil, errors.New("failed to get bucket CORS") } func (t TestS3FailClient) GetBucketEncryption(ctx context.Context, params *s3.GetBucketEncryptionInput, optFns ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) { return nil, errors.New("failed to get bucket CORS") } func (t TestS3FailClient) GetBucketIntelligentTieringConfiguration(ctx context.Context, params *s3.GetBucketIntelligentTieringConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketIntelligentTieringConfigurationOutput, error) { return nil, errors.New("failed to get bucket CORS") } func (t TestS3FailClient) GetBucketInventoryConfiguration(ctx context.Context, params *s3.GetBucketInventoryConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketInventoryConfigurationOutput, error) { return nil, errors.New("failed to get bucket CORS") } func (t TestS3FailClient) GetBucketLifecycleConfiguration(ctx context.Context, params *s3.GetBucketLifecycleConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLifecycleConfigurationOutput, error) { return nil, errors.New("failed to get bucket lifecycle configuration") } func (t TestS3FailClient) GetBucketLocation(ctx context.Context, params *s3.GetBucketLocationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) { return nil, errors.New("failed to get bucket location") } func (t TestS3FailClient) GetBucketLogging(ctx context.Context, params *s3.GetBucketLoggingInput, optFns ...func(*s3.Options)) (*s3.GetBucketLoggingOutput, error) { return nil, errors.New("failed to get bucket logging") } func (t TestS3FailClient) GetBucketMetricsConfiguration(ctx context.Context, params *s3.GetBucketMetricsConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketMetricsConfigurationOutput, error) { return nil, errors.New("failed to get bucket logging") } func (t TestS3FailClient) GetBucketNotificationConfiguration(ctx context.Context, params *s3.GetBucketNotificationConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketNotificationConfigurationOutput, error) { return nil, errors.New("failed to get bucket notification configuration") } func (t TestS3FailClient) GetBucketOwnershipControls(ctx context.Context, params *s3.GetBucketOwnershipControlsInput, optFns ...func(*s3.Options)) (*s3.GetBucketOwnershipControlsOutput, error) { return nil, errors.New("failed to get bucket policy") } func (t TestS3FailClient) GetBucketPolicy(ctx context.Context, params *s3.GetBucketPolicyInput, optFns ...func(*s3.Options)) (*s3.GetBucketPolicyOutput, error) { return nil, errors.New("failed to get bucket policy") } func (t TestS3FailClient) GetBucketPolicyStatus(ctx context.Context, params *s3.GetBucketPolicyStatusInput, optFns ...func(*s3.Options)) (*s3.GetBucketPolicyStatusOutput, error) { return nil, errors.New("failed to get bucket policy") } func (t TestS3FailClient) GetBucketReplication(ctx context.Context, params *s3.GetBucketReplicationInput, optFns ...func(*s3.Options)) (*s3.GetBucketReplicationOutput, error) { return nil, errors.New("failed to get bucket replication") } func (t TestS3FailClient) GetBucketRequestPayment(ctx context.Context, params *s3.GetBucketRequestPaymentInput, optFns ...func(*s3.Options)) (*s3.GetBucketRequestPaymentOutput, error) { return nil, errors.New("failed to get bucket request payment") } func (t TestS3FailClient) GetBucketTagging(ctx context.Context, params *s3.GetBucketTaggingInput, optFns ...func(*s3.Options)) (*s3.GetBucketTaggingOutput, error) { return nil, errors.New("failed to get bucket tagging") } func (t TestS3FailClient) GetBucketVersioning(ctx context.Context, params *s3.GetBucketVersioningInput, optFns ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { return nil, errors.New("failed to get bucket versioning") } func (t TestS3FailClient) GetBucketWebsite(ctx context.Context, params *s3.GetBucketWebsiteInput, optFns ...func(*s3.Options)) (*s3.GetBucketWebsiteOutput, error) { return nil, errors.New("failed to get bucket website") } func (t TestS3FailClient) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { return nil, errors.New("failed to get object") } func (t TestS3FailClient) HeadBucket(ctx context.Context, params *s3.HeadBucketInput, optFns ...func(*s3.Options)) (*s3.HeadBucketOutput, error) { return nil, errors.New("failed to head bucket") } func (t TestS3FailClient) HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { return nil, errors.New("failed to head object") } func (t TestS3FailClient) PutBucketAcl(ctx context.Context, params *s3.PutBucketAclInput, optFns ...func(*s3.Options)) (*s3.PutBucketAclOutput, error) { return nil, errors.New("failed to put bucket ACL") } func (t TestS3FailClient) PutBucketCors(ctx context.Context, params *s3.PutBucketCorsInput, optFns ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error) { return nil, errors.New("failed to put bucket CORS") } func (t TestS3FailClient) PutBucketLifecycleConfiguration(ctx context.Context, params *s3.PutBucketLifecycleConfigurationInput, optFns ...func(*s3.Options)) (*s3.PutBucketLifecycleConfigurationOutput, error) { return nil, errors.New("failed to put bucket lifecycle configuration") } func (t TestS3FailClient) PutBucketLogging(ctx context.Context, params *s3.PutBucketLoggingInput, optFns ...func(*s3.Options)) (*s3.PutBucketLoggingOutput, error) { return nil, errors.New("failed to put bucket logging") } func (t TestS3FailClient) PutBucketNotificationConfiguration(ctx context.Context, params *s3.PutBucketNotificationConfigurationInput, optFns ...func(*s3.Options)) (*s3.PutBucketNotificationConfigurationOutput, error) { return nil, errors.New("failed to put bucket notification configuration") } func (t TestS3FailClient) PutBucketPolicy(ctx context.Context, params *s3.PutBucketPolicyInput, optFns ...func(*s3.Options)) (*s3.PutBucketPolicyOutput, error) { return nil, errors.New("failed to put bucket policy") } func (t TestS3FailClient) PutBucketReplication(ctx context.Context, params *s3.PutBucketReplicationInput, optFns ...func(*s3.Options)) (*s3.PutBucketReplicationOutput, error) { return nil, errors.New("failed to put bucket replication") } func (t TestS3FailClient) PutBucketRequestPayment(ctx context.Context, params *s3.PutBucketRequestPaymentInput, optFns ...func(*s3.Options)) (*s3.PutBucketRequestPaymentOutput, error) { return nil, errors.New("failed to put bucket request payment") } func (t TestS3FailClient) PutBucketTagging(ctx context.Context, params *s3.PutBucketTaggingInput, optFns ...func(*s3.Options)) (*s3.PutBucketTaggingOutput, error) { return nil, errors.New("failed to put bucket tagging") } func (t TestS3FailClient) PutBucketVersioning(ctx context.Context, params *s3.PutBucketVersioningInput, optFns ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) { return nil, errors.New("failed to put bucket versioning") } func (t TestS3FailClient) PutBucketWebsite(ctx context.Context, params *s3.PutBucketWebsiteInput, optFns ...func(*s3.Options)) (*s3.PutBucketWebsiteOutput, error) { return nil, errors.New("failed to put bucket website") } func (t TestS3FailClient) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { return nil, errors.New("failed to put object") } func TestNewS3Adapter(t *testing.T) { config, account, _ := GetAutoConfig(t) adapter := NewS3Adapter(config, account, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } func TestS3SearchWithARNFormat(t *testing.T) { // This E2E test reproduces the customer issue: // - Get works with bucket name: harness-sample-three-qa-us-west-2-20251022151048279100000001 // - Search fails with ARN: arn:aws:s3:::harness-sample-three-qa-us-west-2-20251022151048279100000001 // // EXPECTED BEHAVIOR: Both Get and Search should work // CURRENT BEHAVIOR: Get works, Search fails with NOSCOPE error - THIS IS THE BUG config, account, _ := GetAutoConfig(t) adapter := NewS3Adapter(config, account, sdpcache.NewNoOpCache()) scope := adapter.Scopes()[0] bucketName := "harness-sample-three-qa-us-west-2-20251022151048279100000001" s3ARN := "arn:aws:s3:::" + bucketName ctx := context.Background() // First, verify that Get works with the bucket name directly t.Run("Get with bucket name", func(t *testing.T) { item, err := adapter.Get(ctx, scope, bucketName, false) if err != nil { t.Logf("Get failed (this is OK if bucket doesn't exist): %v", err) } else if item != nil { t.Logf("Get succeeded: found bucket %v", bucketName) } }) // Then, test Search with ARN format - this SHOULD succeed, but currently fails with NOSCOPE error t.Run("Search with S3 ARN format", func(t *testing.T) { items, err := adapter.Search(ctx, scope, s3ARN, false) // EXPECTED: Search succeeds because S3 bucket ARNs don't include account/region // (S3 is global), and the adapter should use its own scope since it knows the account ID. // CURRENT: Search fails with NOSCOPE error - THIS IS THE BUG if err != nil { var ire *sdp.QueryError if errors.As(err, &ire) { if ire.GetErrorType() == sdp.QueryError_NOSCOPE && strings.Contains(ire.GetErrorString(), "ARN scope") { // This is the bug - the search fails when it should succeed t.Errorf("BUG REPRODUCED: Search failed with NOSCOPE error when it should succeed. "+ "Error: %v. S3 bucket ARNs don't include account/region, so the adapter should use its own scope.", ire.GetErrorString()) t.Logf("Expected: Search succeeds and returns bucket item (like Get does)") t.Logf("Actual: Search fails with NOSCOPE error: %v", ire.GetErrorString()) } else { // Other errors (like bucket not found) are acceptable t.Logf("Search failed with error (may be expected if bucket doesn't exist): %v", err) } } else { t.Errorf("unexpected error type: %T: %v", err, err) } return } // If we get here, the search succeeded (expected behavior) if len(items) == 0 { t.Error("expected at least 1 item from Search") } else { t.Logf("Search succeeded: found %v item(s)", len(items)) } }) } ================================================ FILE: aws-source/adapters/sns-data-protection-policy.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type dataProtectionPolicyClient interface { GetDataProtectionPolicy(ctx context.Context, params *sns.GetDataProtectionPolicyInput, optFns ...func(*sns.Options)) (*sns.GetDataProtectionPolicyOutput, error) } func getDataProtectionPolicyFunc(ctx context.Context, client dataProtectionPolicyClient, scope string, input *sns.GetDataProtectionPolicyInput) (*sdp.Item, error) { output, err := client.GetDataProtectionPolicy(ctx, input) if err != nil { return nil, err } if output.DataProtectionPolicy == nil || *output.DataProtectionPolicy == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "get data protection policy response was nil/empty", Scope: scope, } } // ResourceArn is the topic ARN that the policy is associated with attr := map[string]any{ "TopicArn": *input.ResourceArn, } attributes, err := ToAttributesWithExclude(attr) if err != nil { return nil, err } item := &sdp.Item{ Type: "sns-data-protection-policy", UniqueAttribute: "TopicArn", Attributes: attributes, Scope: scope, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sns-topic", Method: sdp.QueryMethod_GET, Query: *input.ResourceArn, Scope: scope, }, }) return item, nil } func NewSNSDataProtectionPolicyAdapter(client dataProtectionPolicyClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[any, any, *sns.GetDataProtectionPolicyInput, *sns.GetDataProtectionPolicyOutput, dataProtectionPolicyClient, *sns.Options] { return &AlwaysGetAdapter[any, any, *sns.GetDataProtectionPolicyInput, *sns.GetDataProtectionPolicyOutput, dataProtectionPolicyClient, *sns.Options]{ ItemType: "sns-data-protection-policy", Client: client, AccountID: accountID, Region: region, DisableList: true, AdapterMetadata: dataProtectionPolicyAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *sns.GetDataProtectionPolicyInput { return &sns.GetDataProtectionPolicyInput{ ResourceArn: &query, } }, GetFunc: getDataProtectionPolicyFunc, } } var dataProtectionPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "sns-data-protection-policy", DescriptiveName: "SNS Data Protection Policy", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an SNS data protection policy by associated topic ARN", SearchDescription: "Search SNS data protection policies by its ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_sns_topic_data_protection_policy.arn"}, }, PotentialLinks: []string{"sns-topic"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/sns-data-protection-policy_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/overmindtech/cli/go/sdpcache" ) type mockDataProtectionPolicyClient struct{} func (m mockDataProtectionPolicyClient) GetDataProtectionPolicy(ctx context.Context, params *sns.GetDataProtectionPolicyInput, optFns ...func(*sns.Options)) (*sns.GetDataProtectionPolicyOutput, error) { return &sns.GetDataProtectionPolicyOutput{ DataProtectionPolicy: new("{\"Name\":\"data_protection_policy\",\"Description\":\"Example data protection policy\",\"Version\":\"2021-06-01\",\"Statement\":[{\"DataDirection\":\"Inbound\",\"Principal\":[\"*\"],\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/CreditCardNumber\"],\"Operation\":{\"Deny\":{}}}]}"), }, nil } func TestGetDataProtectionPolicyFunc(t *testing.T) { ctx := context.Background() cli := &mockDataProtectionPolicyClient{} item, err := getDataProtectionPolicyFunc(ctx, cli, "scope", &sns.GetDataProtectionPolicyInput{ ResourceArn: new("arn:aws:sns:us-east-1:123456789012:mytopic"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } } func TestNewSNSDataProtectionPolicyAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := sns.NewFromConfig(config) adapter := NewSNSDataProtectionPolicyAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, SkipGet: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/sns-endpoint.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type endpointClient interface { ListEndpointsByPlatformApplication(ctx context.Context, params *sns.ListEndpointsByPlatformApplicationInput, optFns ...func(*sns.Options)) (*sns.ListEndpointsByPlatformApplicationOutput, error) GetEndpointAttributes(ctx context.Context, params *sns.GetEndpointAttributesInput, optFns ...func(*sns.Options)) (*sns.GetEndpointAttributesOutput, error) ListTagsForResource(context.Context, *sns.ListTagsForResourceInput, ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) } func getEndpointFunc(ctx context.Context, client endpointClient, scope string, input *sns.GetEndpointAttributesInput) (*sdp.Item, error) { output, err := client.GetEndpointAttributes(ctx, input) if err != nil { return nil, err } if output.Attributes == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "get endpoint attributes response was nil", Scope: scope, } } attributes, err := ToAttributesWithExclude(output.Attributes) if err != nil { return nil, err } err = attributes.Set("EndpointArn", *input.EndpointArn) if err != nil { return nil, err } item := &sdp.Item{ Type: "sns-endpoint", UniqueAttribute: "EndpointArn", Attributes: attributes, Scope: scope, } if resourceTags, err := tagsByResourceARN(ctx, client, *input.EndpointArn); err == nil { item.Tags = tagsToMap(resourceTags) } return item, nil } func NewSNSEndpointAdapter(client endpointClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*sns.ListEndpointsByPlatformApplicationInput, *sns.ListEndpointsByPlatformApplicationOutput, *sns.GetEndpointAttributesInput, *sns.GetEndpointAttributesOutput, endpointClient, *sns.Options] { return &AlwaysGetAdapter[*sns.ListEndpointsByPlatformApplicationInput, *sns.ListEndpointsByPlatformApplicationOutput, *sns.GetEndpointAttributesInput, *sns.GetEndpointAttributesOutput, endpointClient, *sns.Options]{ ItemType: "sns-endpoint", Client: client, AccountID: accountID, Region: region, DisableList: true, // This source only supports listing by platform application ARN AdapterMetadata: snsEndpointAdapterMetadata, cache: cache, SearchInputMapper: func(scope, query string) (*sns.ListEndpointsByPlatformApplicationInput, error) { return &sns.ListEndpointsByPlatformApplicationInput{ PlatformApplicationArn: &query, }, nil }, GetInputMapper: func(scope, query string) *sns.GetEndpointAttributesInput { return &sns.GetEndpointAttributesInput{ EndpointArn: &query, } }, ListFuncPaginatorBuilder: func(client endpointClient, input *sns.ListEndpointsByPlatformApplicationInput) Paginator[*sns.ListEndpointsByPlatformApplicationOutput, *sns.Options] { return sns.NewListEndpointsByPlatformApplicationPaginator(client, input) }, ListFuncOutputMapper: func(output *sns.ListEndpointsByPlatformApplicationOutput, input *sns.ListEndpointsByPlatformApplicationInput) ([]*sns.GetEndpointAttributesInput, error) { var inputs []*sns.GetEndpointAttributesInput for _, endpoint := range output.Endpoints { inputs = append(inputs, &sns.GetEndpointAttributesInput{ EndpointArn: endpoint.EndpointArn, }) } return inputs, nil }, GetFunc: getEndpointFunc, } } var snsEndpointAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "sns-endpoint", DescriptiveName: "SNS Endpoint", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an SNS endpoint by its ARN", SearchDescription: "Search SNS endpoints by associated Platform Application ARN", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/sns-endpoint_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sns/types" "github.com/overmindtech/cli/go/sdpcache" ) type mockEndpointClient struct{} func (m *mockEndpointClient) ListTagsForResource(ctx context.Context, input *sns.ListTagsForResourceInput, f ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) { // intentionally returns nil to test the nil case return nil, nil } func (m *mockEndpointClient) GetEndpointAttributes(ctx context.Context, params *sns.GetEndpointAttributesInput, optFns ...func(*sns.Options)) (*sns.GetEndpointAttributesOutput, error) { return &sns.GetEndpointAttributesOutput{ Attributes: map[string]string{ "Enabled": "true", "Token": "EXAMPLE12345...", }, }, nil } func (m *mockEndpointClient) ListEndpointsByPlatformApplication(ctx context.Context, params *sns.ListEndpointsByPlatformApplicationInput, optFns ...func(*sns.Options)) (*sns.ListEndpointsByPlatformApplicationOutput, error) { return &sns.ListEndpointsByPlatformApplicationOutput{ Endpoints: []types.Endpoint{ { Attributes: map[string]string{ "Token": "EXAMPLE12345...", "Enabled": "true", }, }, }, }, nil } func TestGetEndpointFunc(t *testing.T) { ctx := context.Background() cli := &mockEndpointClient{} item, err := getEndpointFunc(ctx, cli, "scope", &sns.GetEndpointAttributesInput{ EndpointArn: new("arn:aws:sns:us-west-2:123456789012:endpoint/GCM/MyApplication/12345678-abcd-9012-efgh-345678901234"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } } func TestNewSNSEndpointAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := sns.NewFromConfig(config) adapter := NewSNSEndpointAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, SkipList: true, } test.Run(t) } ================================================ FILE: aws-source/adapters/sns-platform-application.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type platformApplicationClient interface { ListPlatformApplications(ctx context.Context, params *sns.ListPlatformApplicationsInput, optFns ...func(*sns.Options)) (*sns.ListPlatformApplicationsOutput, error) GetPlatformApplicationAttributes(ctx context.Context, params *sns.GetPlatformApplicationAttributesInput, optFns ...func(*sns.Options)) (*sns.GetPlatformApplicationAttributesOutput, error) ListTagsForResource(context.Context, *sns.ListTagsForResourceInput, ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) } func getPlatformApplicationFunc(ctx context.Context, client platformApplicationClient, scope string, input *sns.GetPlatformApplicationAttributesInput) (*sdp.Item, error) { output, err := client.GetPlatformApplicationAttributes(ctx, input) if err != nil { return nil, err } if output.Attributes == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "get platform application attributes response was nil", Scope: scope, } } attributes, err := ToAttributesWithExclude(output.Attributes) if err != nil { return nil, err } err = attributes.Set("PlatformApplicationArn", *input.PlatformApplicationArn) if err != nil { return nil, err } item := &sdp.Item{ Type: "sns-platform-application", UniqueAttribute: "PlatformApplicationArn", Attributes: attributes, Scope: scope, } if resourceTags, err := tagsByResourceARN(ctx, client, *input.PlatformApplicationArn); err == nil { item.Tags = tagsToMap(resourceTags) } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sns-endpoint", Method: sdp.QueryMethod_SEARCH, Query: *input.PlatformApplicationArn, Scope: scope, }, }) return item, nil } func NewSNSPlatformApplicationAdapter(client platformApplicationClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*sns.ListPlatformApplicationsInput, *sns.ListPlatformApplicationsOutput, *sns.GetPlatformApplicationAttributesInput, *sns.GetPlatformApplicationAttributesOutput, platformApplicationClient, *sns.Options] { return &AlwaysGetAdapter[*sns.ListPlatformApplicationsInput, *sns.ListPlatformApplicationsOutput, *sns.GetPlatformApplicationAttributesInput, *sns.GetPlatformApplicationAttributesOutput, platformApplicationClient, *sns.Options]{ ItemType: "sns-platform-application", Client: client, AccountID: accountID, Region: region, ListInput: &sns.ListPlatformApplicationsInput{}, AdapterMetadata: platformApplicationAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *sns.GetPlatformApplicationAttributesInput { return &sns.GetPlatformApplicationAttributesInput{ PlatformApplicationArn: &query, } }, ListFuncPaginatorBuilder: func(client platformApplicationClient, input *sns.ListPlatformApplicationsInput) Paginator[*sns.ListPlatformApplicationsOutput, *sns.Options] { return sns.NewListPlatformApplicationsPaginator(client, input) }, ListFuncOutputMapper: func(output *sns.ListPlatformApplicationsOutput, input *sns.ListPlatformApplicationsInput) ([]*sns.GetPlatformApplicationAttributesInput, error) { var inputs []*sns.GetPlatformApplicationAttributesInput for _, platformApplication := range output.PlatformApplications { inputs = append(inputs, &sns.GetPlatformApplicationAttributesInput{ PlatformApplicationArn: platformApplication.PlatformApplicationArn, }) } return inputs, nil }, GetFunc: getPlatformApplicationFunc, } } var platformApplicationAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "sns-platform-application", DescriptiveName: "SNS Platform Application", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an SNS platform application by its ARN", ListDescription: "List all SNS platform applications", SearchDescription: "Search SNS platform applications by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_sns_platform_application.id"}, }, PotentialLinks: []string{"sns-endpoint"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/sns-platform-application_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sns/types" "github.com/overmindtech/cli/go/sdpcache" ) type mockPlatformApplicationClient struct{} func (m mockPlatformApplicationClient) ListTagsForResource(ctx context.Context, input *sns.ListTagsForResourceInput, f ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) { return &sns.ListTagsForResourceOutput{ Tags: []types.Tag{ {Key: new("tag1"), Value: new("value1")}, {Key: new("tag2"), Value: new("value2")}, }, }, nil } func (m mockPlatformApplicationClient) GetPlatformApplicationAttributes(ctx context.Context, params *sns.GetPlatformApplicationAttributesInput, optFns ...func(*sns.Options)) (*sns.GetPlatformApplicationAttributesOutput, error) { return &sns.GetPlatformApplicationAttributesOutput{ Attributes: map[string]string{ "Enabled": "true", "SuccessFeedbackSampleRate": "100", }, }, nil } func (m mockPlatformApplicationClient) ListPlatformApplications(ctx context.Context, params *sns.ListPlatformApplicationsInput, optFns ...func(*sns.Options)) (*sns.ListPlatformApplicationsOutput, error) { return &sns.ListPlatformApplicationsOutput{ PlatformApplications: []types.PlatformApplication{ { PlatformApplicationArn: new("arn:aws:sns:us-west-2:123456789012:app/ADM/MyApplication"), Attributes: map[string]string{ "SuccessFeedbackSampleRate": "100", "Enabled": "true", }, }, { PlatformApplicationArn: new("arn:aws:sns:us-west-2:123456789012:app/MPNS/MyOtherApplication"), Attributes: map[string]string{ "SuccessFeedbackSampleRate": "100", "Enabled": "true", }, }, }, }, nil } func TestGetPlatformApplicationFunc(t *testing.T) { ctx := context.Background() cli := mockPlatformApplicationClient{} item, err := getPlatformApplicationFunc(ctx, cli, "scope", &sns.GetPlatformApplicationAttributesInput{ PlatformApplicationArn: new("arn:aws:sns:us-west-2:123456789012:my-topic"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } } func TestNewSNSPlatformApplicationAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := sns.NewFromConfig(config) adapter := NewSNSPlatformApplicationAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/sns-subscription.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type subsCli interface { GetSubscriptionAttributes(ctx context.Context, params *sns.GetSubscriptionAttributesInput, optFns ...func(*sns.Options)) (*sns.GetSubscriptionAttributesOutput, error) ListSubscriptions(context.Context, *sns.ListSubscriptionsInput, ...func(*sns.Options)) (*sns.ListSubscriptionsOutput, error) ListTagsForResource(context.Context, *sns.ListTagsForResourceInput, ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) } func getSubsFunc(ctx context.Context, client subsCli, scope string, input *sns.GetSubscriptionAttributesInput) (*sdp.Item, error) { output, err := client.GetSubscriptionAttributes(ctx, input) if err != nil { return nil, err } if output.Attributes == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "get subscription attributes response was nil", Scope: scope, } } attributes, err := ToAttributesWithExclude(output.Attributes) if err != nil { return nil, err } item := &sdp.Item{ Type: "sns-subscription", UniqueAttribute: "SubscriptionArn", Attributes: attributes, Scope: scope, } if resourceTags, err := tagsByResourceARN(ctx, client, *input.SubscriptionArn); err == nil { item.Tags = tagsToMap(resourceTags) } if topicArn, err := attributes.Get("topicArn"); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "sns-topic", Method: sdp.QueryMethod_GET, Query: topicArn.(string), Scope: scope, }, }) } if subsRoleArn, err := attributes.Get("subscriptionRoleArn"); err == nil { if arn, err := ParseARN(fmt.Sprint(subsRoleArn)); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_GET, Query: arn.ResourceID(), Scope: FormatScope(arn.AccountID, arn.Region), }, }) } } return item, nil } func NewSNSSubscriptionAdapter(client subsCli, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*sns.ListSubscriptionsInput, *sns.ListSubscriptionsOutput, *sns.GetSubscriptionAttributesInput, *sns.GetSubscriptionAttributesOutput, subsCli, *sns.Options] { return &AlwaysGetAdapter[*sns.ListSubscriptionsInput, *sns.ListSubscriptionsOutput, *sns.GetSubscriptionAttributesInput, *sns.GetSubscriptionAttributesOutput, subsCli, *sns.Options]{ ItemType: "sns-subscription", Client: client, AccountID: accountID, Region: region, ListInput: &sns.ListSubscriptionsInput{}, AdapterMetadata: snsSubscriptionAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *sns.GetSubscriptionAttributesInput { return &sns.GetSubscriptionAttributesInput{ SubscriptionArn: &query, } }, ListFuncPaginatorBuilder: func(client subsCli, input *sns.ListSubscriptionsInput) Paginator[*sns.ListSubscriptionsOutput, *sns.Options] { return sns.NewListSubscriptionsPaginator(client, input) }, ListFuncOutputMapper: func(output *sns.ListSubscriptionsOutput, _ *sns.ListSubscriptionsInput) ([]*sns.GetSubscriptionAttributesInput, error) { var inputs []*sns.GetSubscriptionAttributesInput for _, subs := range output.Subscriptions { inputs = append(inputs, &sns.GetSubscriptionAttributesInput{ SubscriptionArn: subs.SubscriptionArn, }) } return inputs, nil }, GetFunc: getSubsFunc, } } var snsSubscriptionAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "sns-subscription", DescriptiveName: "SNS Subscription", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an SNS subscription by its ARN", SearchDescription: "Search SNS subscription by ARN", ListDescription: "List all SNS subscriptions", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_sns_topic_subscription.id"}, }, PotentialLinks: []string{"sns-topic", "iam-role"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/sns-subscription_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sns/types" "github.com/overmindtech/cli/go/sdpcache" ) type snsTestClient struct{} func (t snsTestClient) GetSubscriptionAttributes(ctx context.Context, params *sns.GetSubscriptionAttributesInput, optFns ...func(*sns.Options)) (*sns.GetSubscriptionAttributesOutput, error) { return &sns.GetSubscriptionAttributesOutput{Attributes: map[string]string{ "Endpoint": "my-email@example.com", "Protocol": "email", "RawMessageDelivery": "false", "ConfirmationWasAuthenticated": "false", "Owner": "123456789012", "SubscriptionArn": "arn:aws:sns:us-west-2:123456789012:my-topic:8a21d249-4329-4871-acc6-7be709c6ea7f", "TopicArn": "arn:aws:sns:us-west-2:123456789012:my-topic", "SubscriptionRoleArn": "arn:aws:iam::123456789012:role/my-role", }}, nil } func (t snsTestClient) ListSubscriptions(context.Context, *sns.ListSubscriptionsInput, ...func(*sns.Options)) (*sns.ListSubscriptionsOutput, error) { return &sns.ListSubscriptionsOutput{ Subscriptions: []types.Subscription{ { Owner: new("123456789012"), Endpoint: new("my-email@example.com"), Protocol: new("email"), TopicArn: new("arn:aws:sns:us-west-2:123456789012:my-topic"), SubscriptionArn: new("arn:aws:sns:us-west-2:123456789012:my-topic:8a21d249-4329-4871-acc6-7be709c6ea7f"), }, }, }, nil } func (t snsTestClient) ListTagsForResource(context.Context, *sns.ListTagsForResourceInput, ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) { return &sns.ListTagsForResourceOutput{ Tags: []types.Tag{ {Key: new("tag1"), Value: new("value1")}, {Key: new("tag2"), Value: new("value2")}, }, }, nil } func TestSNSGetFunc(t *testing.T) { ctx := context.Background() cli := snsTestClient{} item, err := getSubsFunc(ctx, cli, "scope", &sns.GetSubscriptionAttributesInput{ SubscriptionArn: new("arn:aws:sns:us-west-2:123456789012:my-topic:8a21d249-4329-4871-acc6-7be709c6ea7f"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } } func TestNewSNSSubscriptionAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := sns.NewFromConfig(config) adapter := NewSNSSubscriptionAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/sns-topic.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type topicClient interface { GetTopicAttributes(ctx context.Context, params *sns.GetTopicAttributesInput, optFns ...func(*sns.Options)) (*sns.GetTopicAttributesOutput, error) ListTopics(context.Context, *sns.ListTopicsInput, ...func(*sns.Options)) (*sns.ListTopicsOutput, error) ListTagsForResource(context.Context, *sns.ListTagsForResourceInput, ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) } func getTopicFunc(ctx context.Context, client topicClient, scope string, input *sns.GetTopicAttributesInput) (*sdp.Item, error) { output, err := client.GetTopicAttributes(ctx, input) if err != nil { return nil, err } if output.Attributes == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "get topic attributes response was nil", Scope: scope, } } attributes, err := ToAttributesWithExclude(output.Attributes) if err != nil { return nil, err } item := &sdp.Item{ Type: "sns-topic", UniqueAttribute: "TopicArn", Attributes: attributes, Scope: scope, } if resourceTags, err := tagsByResourceARN(ctx, client, *input.TopicArn); err == nil { item.Tags = tagsToMap(resourceTags) } if kmsMasterKeyID, err := attributes.Get("kmsMasterKeyId"); err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_GET, Query: fmt.Sprint(kmsMasterKeyID), Scope: scope, }, }) } return item, nil } func NewSNSTopicAdapter(client topicClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*sns.ListTopicsInput, *sns.ListTopicsOutput, *sns.GetTopicAttributesInput, *sns.GetTopicAttributesOutput, topicClient, *sns.Options] { return &AlwaysGetAdapter[*sns.ListTopicsInput, *sns.ListTopicsOutput, *sns.GetTopicAttributesInput, *sns.GetTopicAttributesOutput, topicClient, *sns.Options]{ ItemType: "sns-topic", Client: client, AccountID: accountID, Region: region, ListInput: &sns.ListTopicsInput{}, AdapterMetadata: snsTopicAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *sns.GetTopicAttributesInput { return &sns.GetTopicAttributesInput{ TopicArn: &query, } }, ListFuncPaginatorBuilder: func(client topicClient, input *sns.ListTopicsInput) Paginator[*sns.ListTopicsOutput, *sns.Options] { return sns.NewListTopicsPaginator(client, input) }, ListFuncOutputMapper: func(output *sns.ListTopicsOutput, input *sns.ListTopicsInput) ([]*sns.GetTopicAttributesInput, error) { var inputs []*sns.GetTopicAttributesInput for _, topic := range output.Topics { inputs = append(inputs, &sns.GetTopicAttributesInput{ TopicArn: topic.TopicArn, }) } return inputs, nil }, GetFunc: getTopicFunc, } } var snsTopicAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "sns-topic", DescriptiveName: "SNS Topic", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an SNS topic by its ARN", SearchDescription: "Search SNS topic by ARN", ListDescription: "List all SNS topics", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_sns_topic.id"}, }, PotentialLinks: []string{"kms-key"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, }) ================================================ FILE: aws-source/adapters/sns-topic_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sns/types" "github.com/overmindtech/cli/go/sdpcache" ) type testTopicClient struct{} func (t testTopicClient) GetTopicAttributes(ctx context.Context, params *sns.GetTopicAttributesInput, optFns ...func(*sns.Options)) (*sns.GetTopicAttributesOutput, error) { return &sns.GetTopicAttributesOutput{Attributes: map[string]string{ "SubscriptionsConfirmed": "1", "DisplayName": "my-topic", "SubscriptionsDeleted": "0", "EffectiveDeliveryPolicy": "{\"http\":{\"defaultHealthyRetryPolicy\":{\"minDelayTarget\":20,\"maxDelayTarget\":20,\"numRetries\":3,\"numMaxDelayRetries\":0,\"numNoDelayRetries\":0,\"numMinDelayRetries\":0,\"backoffFunction\":\"linear\"},\"disableSubscriptionOverrides\":false}}", "Owner": "123456789012", "Policy": "{\"Version\":\"2008-10-17\",\"Id\":\"__default_policy_ID\",\"Statement\":[{\"Sid\":\"__default_statement_ID\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":[\"SNS:Subscribe\",\"SNS:ListSubscriptionsByTopic\",\"SNS:DeleteTopic\",\"SNS:GetTopicAttributes\",\"SNS:Publish\",\"SNS:RemovePermission\",\"SNS:AddPermission\",\"SNS:SetTopicAttributes\"],\"Resource\":\"arn:aws:sns:us-west-2:123456789012:my-topic\",\"Condition\":{\"StringEquals\":{\"AWS:SourceOwner\":\"0123456789012\"}}}]}", "TopicArn": "arn:aws:sns:us-west-2:123456789012:my-topic", "SubscriptionsPending": "0", "KmsMasterKeyId": "alias/aws/sns", }}, nil } func (t testTopicClient) ListTopics(context.Context, *sns.ListTopicsInput, ...func(*sns.Options)) (*sns.ListTopicsOutput, error) { return &sns.ListTopicsOutput{ Topics: []types.Topic{ { TopicArn: new("arn:aws:sns:us-west-2:123456789012:my-topic"), }, }, }, nil } func (t testTopicClient) ListTagsForResource(context.Context, *sns.ListTagsForResourceInput, ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) { return &sns.ListTagsForResourceOutput{ Tags: []types.Tag{ {Key: new("tag1"), Value: new("value1")}, {Key: new("tag2"), Value: new("value2")}, }, }, nil } func TestGetTopicFunc(t *testing.T) { ctx := context.Background() cli := testTopicClient{} item, err := getTopicFunc(ctx, cli, "scope", &sns.GetTopicAttributesInput{ TopicArn: new("arn:aws:sns:us-west-2:123456789012:my-topic"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Fatal(err) } } func TestNewSNSTopicAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := sns.NewFromConfig(config) adapter := NewSNSTopicAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/sns.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sns/types" ) type tagLister interface { ListTagsForResource(ctx context.Context, params *sns.ListTagsForResourceInput, optFns ...func(*sns.Options)) (*sns.ListTagsForResourceOutput, error) } // tagsByResourceARN returns the tags for a given resource ARN func tagsByResourceARN(ctx context.Context, cli tagLister, resourceARN string) ([]types.Tag, error) { if cli == nil { return nil, nil } output, err := cli.ListTagsForResource(ctx, &sns.ListTagsForResourceInput{ ResourceArn: &resourceARN, }) if err != nil { return nil, err } if output != nil && output.Tags != nil { return output.Tags, nil } return nil, nil } // tagsToMap converts a slice of tags to a map func tagsToMap(tags []types.Tag) map[string]string { tagsMap := make(map[string]string) for _, tag := range tags { if tag.Key != nil && tag.Value != nil { tagsMap[*tag.Key] = *tag.Value } } return tagsMap } ================================================ FILE: aws-source/adapters/sqs-queue.go ================================================ package adapters import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/sqs/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type sqsClient interface { GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) ListQueueTags(ctx context.Context, params *sqs.ListQueueTagsInput, optFns ...func(*sqs.Options)) (*sqs.ListQueueTagsOutput, error) ListQueues(context.Context, *sqs.ListQueuesInput, ...func(*sqs.Options)) (*sqs.ListQueuesOutput, error) } func getFunc(ctx context.Context, client sqsClient, scope string, input *sqs.GetQueueAttributesInput) (*sdp.Item, error) { output, err := client.GetQueueAttributes(ctx, input) if err != nil { return nil, err } if output.Attributes == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "get queue attributes response was nil", Scope: scope, } } attributes, err := ToAttributesWithExclude(output.Attributes) if err != nil { return nil, err } err = attributes.Set("QueueURL", input.QueueUrl) if err != nil { return nil, err } resourceTags, err := tags(ctx, client, *input.QueueUrl) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), Scope: scope, } } linkedItemQueries := []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: *input.QueueUrl, Scope: "global", }, }, } // Get the Queue ARN for linking if arn, exists := output.Attributes["QueueArn"]; exists { linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "lambda-event-source-mapping", Method: sdp.QueryMethod_SEARCH, Query: arn, Scope: scope, }, }) } return &sdp.Item{ Type: "sqs-queue", UniqueAttribute: "QueueURL", Attributes: attributes, Scope: scope, Tags: resourceTags, LinkedItemQueries: linkedItemQueries, }, nil } func sqsQueueSearchInputMapper(scope string, query string) (*sqs.GetQueueAttributesInput, error) { arn, err := ParseARN(query) if err != nil { return nil, err } if arn.Service != "sqs" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "ARN is not a valid SQS ARN", Scope: scope, } } return &sqs.GetQueueAttributesInput{ QueueUrl: new(fmt.Sprintf("https://sqs.%s.%s/%s/%s", arn.Region, GetPartitionDNSSuffix(arn.Partition), arn.AccountID, arn.Resource)), AttributeNames: []types.QueueAttributeName{"All"}, }, nil } func NewSQSQueueAdapter(client sqsClient, accountID string, region string, cache sdpcache.Cache) *AlwaysGetAdapter[*sqs.ListQueuesInput, *sqs.ListQueuesOutput, *sqs.GetQueueAttributesInput, *sqs.GetQueueAttributesOutput, sqsClient, *sqs.Options] { return &AlwaysGetAdapter[*sqs.ListQueuesInput, *sqs.ListQueuesOutput, *sqs.GetQueueAttributesInput, *sqs.GetQueueAttributesOutput, sqsClient, *sqs.Options]{ ItemType: "sqs-queue", Client: client, AccountID: accountID, Region: region, ListInput: &sqs.ListQueuesInput{}, AdapterMetadata: sqsQueueAdapterMetadata, cache: cache, GetInputMapper: func(scope, query string) *sqs.GetQueueAttributesInput { return &sqs.GetQueueAttributesInput{ QueueUrl: &query, // Providing All will return all attributes. AttributeNames: []types.QueueAttributeName{"All"}, } }, ListFuncPaginatorBuilder: func(client sqsClient, input *sqs.ListQueuesInput) Paginator[*sqs.ListQueuesOutput, *sqs.Options] { return sqs.NewListQueuesPaginator(client, input) }, ListFuncOutputMapper: func(output *sqs.ListQueuesOutput, _ *sqs.ListQueuesInput) ([]*sqs.GetQueueAttributesInput, error) { var inputs []*sqs.GetQueueAttributesInput for _, url := range output.QueueUrls { inputs = append(inputs, &sqs.GetQueueAttributesInput{ QueueUrl: &url, AttributeNames: []types.QueueAttributeName{"All"}, }) } return inputs, nil }, SearchGetInputMapper: sqsQueueSearchInputMapper, GetFunc: getFunc, } } var sqsQueueAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "sqs-queue", DescriptiveName: "SQS Queue", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, GetDescription: "Get an SQS queue attributes by its URL", ListDescription: "List all SQS queue URLs", SearchDescription: "Search SQS queue by ARN", }, TerraformMappings: []*sdp.TerraformMapping{ {TerraformQueryMap: "aws_sqs_queue.id"}, }, PotentialLinks: []string{ "http", "lambda-event-source-mapping", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, }) ================================================ FILE: aws-source/adapters/sqs-queue_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type testClient struct{} func (t testClient) GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) { return &sqs.GetQueueAttributesOutput{ Attributes: map[string]string{ "ApproximateNumberOfMessages": "0", "ApproximateNumberOfMessagesDelayed": "0", "ApproximateNumberOfMessagesNotVisible": "0", "CreatedTimestamp": "1631616000", "DelaySeconds": "0", "LastModifiedTimestamp": "1631616000", "MaximumMessageSize": "262144", "MessageRetentionPeriod": "345600", "QueueArn": "arn:aws:sqs:us-west-2:123456789012:MyQueue", "ReceiveMessageWaitTimeSeconds": "0", "VisibilityTimeout": "30", "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:80398EXAMPLE:MyDeadLetterQueue\",\"maxReceiveCount\":1000}", }, }, nil } func (t testClient) ListQueueTags(ctx context.Context, params *sqs.ListQueueTagsInput, optFns ...func(*sqs.Options)) (*sqs.ListQueueTagsOutput, error) { return &sqs.ListQueueTagsOutput{ Tags: map[string]string{ "tag1": "value1", "tag2": "value2", }, }, nil } func (t testClient) ListQueues(ctx context.Context, input *sqs.ListQueuesInput, f ...func(*sqs.Options)) (*sqs.ListQueuesOutput, error) { return &sqs.ListQueuesOutput{ QueueUrls: []string{ "https://sqs.us-west-2.amazonaws.com/123456789012/MyQueue", "https://sqs.us-west-2.amazonaws.com/123456789012/MyQueue2", }, }, nil } func TestGetFunc(t *testing.T) { ctx := context.Background() cli := testClient{} item, err := getFunc(ctx, cli, "scope", &sqs.GetQueueAttributesInput{ QueueUrl: new("https://sqs.us-west-2.amazonaws.com/123456789012/MyQueue"), }) if err != nil { t.Fatal(err) } if err = item.Validate(); err != nil { t.Error(err) } // Test linked item queries if len(item.GetLinkedItemQueries()) != 2 { t.Errorf("Expected 2 linked item queries, got %d", len(item.GetLinkedItemQueries())) } // Test HTTP link httpLink := item.GetLinkedItemQueries()[0] if httpLink.GetQuery().GetType() != "http" { t.Errorf("Expected first link type to be 'http', got %s", httpLink.GetQuery().GetType()) } if httpLink.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected HTTP link method to be SEARCH, got %v", httpLink.GetQuery().GetMethod()) } // Test Lambda Event Source Mapping link lambdaLink := item.GetLinkedItemQueries()[1] if lambdaLink.GetQuery().GetType() != "lambda-event-source-mapping" { t.Errorf("Expected second link type to be 'lambda-event-source-mapping', got %s", lambdaLink.GetQuery().GetType()) } if lambdaLink.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected Lambda link method to be SEARCH, got %v", lambdaLink.GetQuery().GetMethod()) } if lambdaLink.GetQuery().GetQuery() != "arn:aws:sqs:us-west-2:123456789012:MyQueue" { t.Errorf("Expected Lambda link query to be the Queue ARN, got %s", lambdaLink.GetQuery().GetQuery()) } } func TestSqsQueueSearchInputMapper(t *testing.T) { tests := []struct { name string arn string expectedURL string }{ { name: "aws partition", arn: "arn:aws:sqs:eu-west-2:540044833068:-tfc-notifications-from-s3", expectedURL: "https://sqs.eu-west-2.amazonaws.com/540044833068/-tfc-notifications-from-s3", }, { name: "aws-cn partition", arn: "arn:aws-cn:sqs:cn-north-1:540044833068:my-queue", expectedURL: "https://sqs.cn-north-1.amazonaws.com.cn/540044833068/my-queue", }, { name: "aws-us-gov partition", arn: "arn:aws-us-gov:sqs:us-gov-west-1:540044833068:gov-queue", expectedURL: "https://sqs.us-gov-west-1.amazonaws.com/540044833068/gov-queue", }, { name: "aws-iso partition", arn: "arn:aws-iso:sqs:us-iso-east-1:540044833068:iso-queue", expectedURL: "https://sqs.us-iso-east-1.c2s.ic.gov/540044833068/iso-queue", }, { name: "aws-iso-b partition", arn: "arn:aws-iso-b:sqs:us-isob-east-1:540044833068:isob-queue", expectedURL: "https://sqs.us-isob-east-1.sc2s.sgov.gov/540044833068/isob-queue", }, { name: "aws-eu partition", arn: "arn:aws-eu:sqs:eu-central-1:540044833068:eu-queue", expectedURL: "https://sqs.eu-central-1.amazonaws.eu/540044833068/eu-queue", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { inputs, err := sqsQueueSearchInputMapper("scope", tt.arn) if err != nil { t.Fatalf("sqsQueueSearchInputMapper() error = %v", err) } if inputs.QueueUrl == nil { t.Fatal("QueueUrl is nil") } if *inputs.QueueUrl != tt.expectedURL { t.Errorf("Expected QueueUrl to be %s, got %s", tt.expectedURL, *inputs.QueueUrl) } }) } } func TestNewQueueAdapter(t *testing.T) { config, account, region := GetAutoConfig(t) client := sqs.NewFromConfig(config) adapter := NewSQSQueueAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/sqs.go ================================================ package adapters import ( "context" "github.com/aws/aws-sdk-go-v2/service/sqs" ) func tags(ctx context.Context, cli sqsClient, queURL string) (map[string]string, error) { if cli == nil { return nil, nil } output, err := cli.ListQueueTags(ctx, &sqs.ListQueueTagsInput{ QueueUrl: &queURL, }) if err != nil { return nil, err } return output.Tags, nil } ================================================ FILE: aws-source/adapters/ssm-parameter.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/sourcegraph/conc/iter" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) type ssmClient interface { DescribeParameters(context.Context, *ssm.DescribeParametersInput, ...func(*ssm.Options)) (*ssm.DescribeParametersOutput, error) ListTagsForResource(ctx context.Context, params *ssm.ListTagsForResourceInput, optFns ...func(*ssm.Options)) (*ssm.ListTagsForResourceOutput, error) GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) } func ssmParameterInputMapperSearch(ctx context.Context, client ssmClient, scope, query string) (*ssm.DescribeParametersInput, error) { // According to the docs here: // https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-access.html // it's common to use wildcards in SSM parameter ARNS in policies, an // example might look like this: // // { // "Sid": "ParameterStoreActions", // "Effect": "Allow", // "Action": [ // "ssm:GetParametersByPath" // ], // "Resource": [ // "arn:aws:ssm:us-east-1:1234567890:parameter/prod/service/example-service", // "arn:aws:ssm:us-east-1:1234567890:parameter/prod/*/service/example-service" // ] // } // // This means that we can't just use a simple "Equals" filter, we need to be // smarter than that. When we're filtering by name, we can use "Equals", // "BeginsWith" and "Contains". The other issue is that in the above // example, the user is allowed to run "GetParametersByPath" which allows // them request recursive results. This will mean that there is an implicit // asterisk (*) at the end of the path, whereas if the "Action" was // "GetParameter" then the user would have to specify the exact path. They'd // still be able to use IAM wildcards, but the path would need to be // complete // // I think to make this really accurate we would need to take this into // account, however maybe to begin with we can at least start by trying to // replicate the asterisk behaviour both at the end and inside the path. // // I was thinking that we should re-implement the IAM wildcard parsing logic // from the docs: // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html#reference_policies_elements_resource_wildcards // however I don't know if this will be worth doing as it'll only be able to // be applied *after* we have queried the data // Parse the ARN parsedArn, err := ParseARN(query) if err != nil { return nil, fmt.Errorf("invalid ARN format: %w", err) } // For SSM parameters, the resource part starts with "parameter/" if !strings.HasPrefix(parsedArn.Resource, "parameter/") { return nil, fmt.Errorf("invalid SSM parameter ARN: resource must start with 'parameter/'") } // Extract the parameter name (everything after "parameter/") parameterPath := strings.TrimPrefix(parsedArn.Resource, "parameter/") // Handle wildcards in the path if strings.Contains(parameterPath, "*") || strings.Contains(parameterPath, "?") { // Se need to be smart about this in order to make efficient queries. // The options we have are "BeginsWith" and "Contains" so I think we // should pick the longest substring we can, then query based on that. // We will need to split on all the possible wildcards (* and ?), then // work out the longest segment, then use that in a "Contains" query // Split on both * and ? to get all segments segments := strings.FieldsFunc(parameterPath, func(r rune) bool { return r == '*' || r == '?' }) // Find the longest segment longestSegment := "" for _, segment := range segments { if len(segment) > len(longestSegment) { longestSegment = segment } } // If we have no valid segments after splitting (e.g. "***") if longestSegment == "" { // If it's all wildcards then search for everything return &ssm.DescribeParametersInput{}, nil } // Use Contains with the longest segment for most efficient filtering return &ssm.DescribeParametersInput{ ParameterFilters: []types.ParameterStringFilter{ { Key: aws.String("Name"), Option: aws.String("Contains"), Values: []string{longestSegment}, }, }, }, nil } // If no wildcards, do an exact match return &ssm.DescribeParametersInput{ ParameterFilters: []types.ParameterStringFilter{ { Key: aws.String("Name"), Option: aws.String("Equals"), Values: []string{parameterPath}, }, }, }, nil } func ssmParameterPostSearchFilter(ctx context.Context, query string, items []*sdp.Item) ([]*sdp.Item, error) { arn, err := ParseARN(query) if err != nil { return nil, fmt.Errorf("invalid ARN format: %w", err) } // Filter out any items that don't match the ARN wildcard format filteredItems := make([]*sdp.Item, 0) for _, item := range items { itemArn, err := item.GetAttributes().Get("ARN") if err != nil { return nil, fmt.Errorf("missing ARN attribute: %w for item: %v", err, item.GloballyUniqueName()) } if arn.IAMWildcardMatches(fmt.Sprint(itemArn)) { filteredItems = append(filteredItems, item) } } return filteredItems, nil } func ssmParameterOutputMapper(ctx context.Context, client ssmClient, scope string, input *ssm.DescribeParametersInput, output *ssm.DescribeParametersOutput) ([]*sdp.Item, error) { items, err := iter.MapErr(output.Parameters, func(parameter *types.ParameterMetadata) (*sdp.Item, error) { attrs, err := ToAttributesWithExclude(parameter) if err != nil { return nil, err } item := sdp.Item{ Type: "ssm-parameter", UniqueAttribute: "Name", Attributes: attrs, Scope: scope, } // Next thing we want to is try to add tags to this item by running ListTagsForResource var tags map[string]string tagsOut, err := client.ListTagsForResource(ctx, &ssm.ListTagsForResourceInput{ ResourceId: parameter.Name, ResourceType: types.ResourceTypeForTaggingParameter, }) if err != nil { // If we can't get the tags we don't want to do anything drastic // since it's not a critical error tags = HandleTagsError(ctx, err) } else { tags = make(map[string]string) for _, tag := range tagsOut.TagList { if tag.Key != nil && tag.Value != nil { tags[*tag.Key] = *tag.Value } } } item.Tags = tags // Now we need to try to get the actual value and link from it. However // we don't want to see any secrets so we'll skip those if parameter.Type != types.ParameterTypeSecureString { request := &ssm.GetParameterInput{ Name: parameter.Name, WithDecryption: new(false), // let's be double sure we don't get any secrets } paramResp, err := client.GetParameter(ctx, request) if err != nil { // Attach an event in the span span := trace.SpanFromContext(ctx) span.AddEvent("Error getting parameter value", trace.WithAttributes( attribute.String("error", err.Error()), attribute.String("parameter_name", *parameter.Name), attribute.String("item", item.GloballyUniqueName()), )) return nil, err } if paramResp.Parameter != nil && paramResp.Parameter.Value != nil { // Add the value to the item item.GetAttributes().Set("Value", *paramResp.Parameter.Value) // Extract links from the value newLinks, err := sdp.ExtractLinksFrom(*paramResp.Parameter.Value) if err == nil { item.LinkedItemQueries = append(item.LinkedItemQueries, newLinks...) } } } return &item, nil }) return items, err } func NewSSMParameterAdapter(client ssmClient, accountID string, region string, cache sdpcache.Cache) *DescribeOnlyAdapter[*ssm.DescribeParametersInput, *ssm.DescribeParametersOutput, ssmClient, *ssm.Options] { return &DescribeOnlyAdapter[*ssm.DescribeParametersInput, *ssm.DescribeParametersOutput, ssmClient, *ssm.Options]{ Client: client, AccountID: accountID, Region: region, ItemType: "ssm-parameter", AdapterMetadata: ssmParameterAdapterMetadata, cache: cache, InputMapperGet: func(scope, query string) (*ssm.DescribeParametersInput, error) { return &ssm.DescribeParametersInput{ ParameterFilters: []types.ParameterStringFilter{ { Key: new("Name"), Option: new("Equals"), Values: []string{query}, }, }, }, nil }, InputMapperList: func(scope string) (*ssm.DescribeParametersInput, error) { return &ssm.DescribeParametersInput{}, nil }, OutputMapper: ssmParameterOutputMapper, InputMapperSearch: ssmParameterInputMapperSearch, PostSearchFilter: ssmParameterPostSearchFilter, PaginatorBuilder: func(client ssmClient, params *ssm.DescribeParametersInput) Paginator[*ssm.DescribeParametersOutput, *ssm.Options] { return ssm.NewDescribeParametersPaginator(client, params, func(dppo *ssm.DescribeParametersPaginatorOptions) { dppo.Limit = 50 }) }, DescribeFunc: func(ctx context.Context, client ssmClient, input *ssm.DescribeParametersInput) (*ssm.DescribeParametersOutput, error) { return client.DescribeParameters(ctx, input) }, } } var ssmParameterAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ssm-parameter", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, DescriptiveName: "SSM Parameter", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get an SSM parameter by name", List: true, ListDescription: "List all SSM parameters", Search: true, SearchDescription: "Search for SSM parameters by ARN. This supports ARNs from IAM policies that contain wildcards", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_ssm_parameter.name", }, { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "aws_ssm_parameter.arn", }, }, PotentialLinks: []string{ "ip", "http", "dns", }, }) ================================================ FILE: aws-source/adapters/ssm-parameter_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" ) type mockSSMClient struct{} func (m *mockSSMClient) DescribeParameters(ctx context.Context, input *ssm.DescribeParametersInput, opts ...func(*ssm.Options)) (*ssm.DescribeParametersOutput, error) { return &ssm.DescribeParametersOutput{ Parameters: []types.ParameterMetadata{ { ARN: aws.String("arn:aws:ssm:us-west-2:123456789012:parameter/test"), AllowedPattern: aws.String(".*"), DataType: aws.String("text"), Description: aws.String("test"), KeyId: aws.String("test"), LastModifiedDate: aws.Time(time.Now()), LastModifiedUser: aws.String("test"), Name: aws.String("test"), Policies: []types.ParameterInlinePolicy{ { PolicyStatus: aws.String("Pending"), PolicyText: aws.String("test"), PolicyType: aws.String("ExpirationNotification"), }, }, Tier: types.ParameterTierStandard, Type: types.ParameterTypeString, Version: 1, }, }, }, nil } func (m *mockSSMClient) ListTagsForResource(ctx context.Context, input *ssm.ListTagsForResourceInput, opts ...func(*ssm.Options)) (*ssm.ListTagsForResourceOutput, error) { return &ssm.ListTagsForResourceOutput{ TagList: []types.Tag{ { Key: aws.String("foo"), Value: aws.String("bar"), }, }, }, nil } func (m *mockSSMClient) GetParameter(ctx context.Context, input *ssm.GetParameterInput, opts ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { return &ssm.GetParameterOutput{ Parameter: &types.Parameter{ ARN: aws.String("arn:aws:ssm:us-west-2:123456789012:parameter/test"), DataType: aws.String("text"), LastModifiedDate: aws.Time(time.Now()), Name: aws.String("test"), Selector: aws.String("test"), SourceResult: aws.String("test"), Type: types.ParameterTypeString, Value: aws.String("https://www.google.com"), Version: 1, }, }, nil } func TestSSMParameterAdapter(t *testing.T) { adapter := NewSSMParameterAdapter(&mockSSMClient{}, "123456789", "us-east-1", sdpcache.NewNoOpCache()) t.Run("Get", func(t *testing.T) { item, err := adapter.Get(context.Background(), "123456789.us-east-1", "test", false) if err != nil { t.Fatal(err) } err = item.Validate() if err != nil { t.Error(err) } }) t.Run("List", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() adapter.ListStream(context.Background(), "123456789.us-east-1", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 1 { t.Errorf("expected 1 item, got %d", len(items)) } err := items[0].Validate() if err != nil { t.Error(err) } }) t.Run("Search", func(t *testing.T) { stream := discovery.NewRecordingQueryResultStream() adapter.SearchStream(context.Background(), "123456789.us-east-1", "arn:aws:ssm:us-east-1:1234567890:parameter/prod/*/service/example-service", false, stream) errs := stream.GetErrors() if len(errs) > 0 { t.Error(errs) } items := stream.GetItems() if len(items) != 0 { t.Errorf("expected 0 item, got %d", len(items)) } }) } func TestSSMParameterAdapterE2E(t *testing.T) { config, account, region := GetAutoConfig(t) client := ssm.NewFromConfig(config) adapter := NewSSMParameterAdapter(client, account, region, sdpcache.NewNoOpCache()) test := E2ETest{ Adapter: adapter, Timeout: 10 * time.Second, } test.Run(t) } ================================================ FILE: aws-source/adapters/tracing.go ================================================ package adapters import ( "go.opentelemetry.io/otel" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "go.opentelemetry.io/otel/trace" ) const ( instrumentationName = "github.com/overmindtech/cli/aws-source/adapters" instrumentationVersion = "0.0.1" ) var tracer = otel.GetTracerProvider().Tracer( instrumentationName, trace.WithInstrumentationVersion(instrumentationVersion), trace.WithSchemaURL(semconv.SchemaURL), ) ================================================ FILE: aws-source/build/package/Dockerfile ================================================ # Build the source binary FROM golang:1.26.2-alpine3.23 AS builder ARG TARGETOS ARG TARGETARCH ARG BUILD_VERSION ARG BUILD_COMMIT # required for generating the version descriptor RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg \ go mod download COPY go/ go/ COPY aws-source/ aws-source/ # Build RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source aws-source/main.go FROM alpine:3.23.4 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 ENTRYPOINT ["/source"] ================================================ FILE: aws-source/cmd/root.go ================================================ package cmd import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/aws-source/proc" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/logging" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" ) var cfgFile string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "aws-source", Short: "Remote primary source for AWS", SilenceUsage: true, Long: `This sources looks for AWS resources in your account. `, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() defer tracing.LogRecoverToReturn(ctx, "aws-source.root") healthCheckPort := viper.GetInt("health-check-port") engineConfig, err := discovery.EngineConfigFromViper("aws", tracing.Version()) if err != nil { log.WithError(err).Error("Could not create engine config") return fmt.Errorf("could not create engine config: %w", err) } // Create a basic engine first so we can serve health probes and heartbeats even if init fails e, err := discovery.NewEngine(engineConfig) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not create engine") return fmt.Errorf("could not create engine: %w", err) } // Serve health probes before initialization so they're available even on failure e.ServeHealthProbes(healthCheckPort) // Start the engine (NATS connection) before adapter init so heartbeats work err = e.Start(ctx) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not start engine") return fmt.Errorf("could not start engine: %w", err) } // Config validation (permanent errors — no retry, just idle with error) configs, cfgErr := proc.ConfigFromViper() if cfgErr != nil { log.WithError(cfgErr).Error("AWS source config error - pod will stay running with error status") e.SetInitError(cfgErr) sentry.CaptureException(cfgErr) } else { log.WithFields(log.Fields{ "aws-regions": len(configs), "health-check-port": healthCheckPort, }).Info("Got config") // Adapter init (retryable errors — backoff capped at 5 min) e.InitialiseAdapters(ctx, func(ctx context.Context) error { return proc.InitializeAwsSourceAdapters(ctx, e, configs...) }) } <-ctx.Done() log.Info("Stopping engine") err = e.Stop() if err != nil { log.WithFields(log.Fields{ "error": err, }).Error("Could not stop engine") return fmt.Errorf("could not stop engine: %w", err) } log.Info("Stopped") return nil }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } func init() { cobra.OnInitialize(initConfig) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. var logLevel string // add engine flags discovery.AddEngineFlags(rootCmd) // General config options rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "/etc/srcman/config/source.yaml", "config file path") rootCmd.PersistentFlags().StringVar(&logLevel, "log", "info", "Set the log level. Valid values: panic, fatal, error, warn, info, debug, trace") // Custom flags for this source rootCmd.PersistentFlags().String("aws-access-strategy", "defaults", "The strategy to use to access this customer's AWS account. Valid values: 'access-key', 'external-id', 'sso-profile', 'defaults'. Default: 'defaults'.") rootCmd.PersistentFlags().String("aws-access-key-id", "", "The ID of the access key to use") rootCmd.PersistentFlags().String("aws-secret-access-key", "", "The secret access key to use for auth") rootCmd.PersistentFlags().String("aws-external-id", "", "The external ID to use when assuming the customer's role") rootCmd.PersistentFlags().String("aws-target-role-arn", "", "The role to assume in the customer's account") rootCmd.PersistentFlags().String("aws-profile", "", "The AWS SSO Profile to use. Defaults to $AWS_PROFILE, then whatever the AWS SDK's SSO config defaults to") rootCmd.PersistentFlags().String("aws-regions", "", "Comma-separated list of AWS regions that this source should operate in") rootCmd.PersistentFlags().BoolP("auto-config", "a", false, "Use the local AWS config, the same as the AWS CLI could use. This can be set up with \"aws configure\"") rootCmd.PersistentFlags().IntP("health-check-port", "", 8080, "The port that the health check should run on") // tracing rootCmd.PersistentFlags().String("honeycomb-api-key", "", "If specified, configures opentelemetry libraries to submit traces to honeycomb") rootCmd.PersistentFlags().String("sentry-dsn", "", "If specified, configures sentry libraries to capture errors") rootCmd.PersistentFlags().String("run-mode", "release", "Set the run mode for this service, 'release', 'debug' or 'test'. Defaults to 'release'.") rootCmd.PersistentFlags().Bool("json-log", true, "Set to false to emit logs as text for easier reading in development.") cobra.CheckErr(viper.BindEnv("json-log", "AWS_SOURCE_JSON_LOG", "JSON_LOG")) // Bind these to viper cobra.CheckErr(viper.BindPFlags(rootCmd.PersistentFlags())) // Run this before we do anything to set up the loglevel rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if lvl, err := log.ParseLevel(logLevel); err == nil { log.SetLevel(lvl) } else { log.SetLevel(log.InfoLevel) log.WithFields(log.Fields{ "error": err, }).Error("Could not parse log level") } log.AddHook(TerminationLogHook{}) // Bind flags that haven't been set to the values from viper of we have them var bindErr error cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { // Bind the flag to viper only if it has a non-empty default if f.DefValue != "" || f.Changed { if err := viper.BindPFlag(f.Name, f); err != nil { bindErr = err } } }) if bindErr != nil { log.WithError(bindErr).Error("could not bind flag to viper") return fmt.Errorf("could not bind flag to viper: %w", bindErr) } if viper.GetBool("json-log") { logging.ConfigureLogrusJSON(log.StandardLogger()) } if err := tracing.InitTracerWithUpstreams("aws-source", viper.GetString("honeycomb-api-key"), viper.GetString("sentry-dsn")); err != nil { log.WithError(err).Error("could not init tracer") return fmt.Errorf("could not init tracer: %w", err) } return nil } // shut down tracing at the end of the process rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { tracing.ShutdownTracer(context.Background()) } } // initConfig reads in config file and ENV variables if set. func initConfig() { viper.SetConfigFile(cfgFile) replacer := strings.NewReplacer("-", "_") viper.SetEnvKeyReplacer(replacer) viper.AutomaticEnv() // read in environment variables that match // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil { log.Infof("Using config file: %v", viper.ConfigFileUsed()) } } // TerminationLogHook A hook that logs fatal errors to the termination log type TerminationLogHook struct{} func (t TerminationLogHook) Levels() []log.Level { return []log.Level{log.FatalLevel} } func (t TerminationLogHook) Fire(e *log.Entry) error { // shutdown tracing first to ensure all spans are flushed tracing.ShutdownTracer(context.Background()) tLog, err := os.OpenFile("/dev/termination-log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } var message string message = e.Message for k, v := range e.Data { message = fmt.Sprintf("%v %v=%v", message, k, v) } _, err = tLog.WriteString(message) return err } ================================================ FILE: aws-source/docker-compose.yml ================================================ version: "3" services: nats: image: nats command: "-c /etc/nats/nats.conf -DV" #-c /etc/nats/nats.conf --cluster nats://0.0.0.0:6222 --routes=nats://ruser:T0pS3cr3t@nats:6222 ports: - "4222:4222" - "8222:8222" - "6222:6222" - "4433:4433" volumes: - ./acceptance/nats-server.conf:/etc/nats/nats.conf # nats-1: # image: nats # command: "-c nats-server.conf --routes=nats-route://ruser:T0pS3cr3t@nats:6222 -DV" #link: # # Will build from a local copy # build: ../redacted_link # environment: # - REDACTED_NATS_URLS=nats # - REDACTED_VERBOSITY=debug networks: default: external: name: nats ================================================ FILE: aws-source/main.go ================================================ /* Copyright © 2021 {AUTHOR} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "github.com/overmindtech/cli/aws-source/cmd" _ "go.uber.org/automaxprocs" ) func main() { cmd.Execute() } ================================================ FILE: aws-source/module/provider/.github/workflows/finalize-copybara-sync.yml ================================================ name: Finalize Copybara Sync on: push: branches: - 'copybara/v*' concurrency: group: copybara-sync-${{ github.ref }} cancel-in-progress: true jobs: finalize: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Extract version from branch name id: version run: | VERSION=$(echo "$GITHUB_REF" | sed 's|refs/heads/copybara/||') echo "version=$VERSION" >> $GITHUB_OUTPUT - uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Configure Git run: | git config user.name "GitHub Actions Bot" git config user.email "actions@github.com" - name: Run go mod tidy run: go mod tidy - name: Commit and push go mod tidy changes env: HEAD_BRANCH: ${{ github.ref_name }} run: | if ! git diff --quiet go.mod go.sum; then git add go.mod go.sum git commit -m "Run go mod tidy" git push origin "$HEAD_BRANCH" else echo "No changes from go mod tidy" fi - name: Extract original commit author id: author run: | AUTHOR_EMAIL=$(git log -1 --format='%ae' --author='^(?!.*actions@github.com)' --perl-regexp 2>/dev/null || git log -1 --format='%ae') AUTHOR_NAME=$(git log -1 --format='%an' --author='^(?!.*actions@github.com)' --perl-regexp 2>/dev/null || git log -1 --format='%an') echo "email=$AUTHOR_EMAIL" >> $GITHUB_OUTPUT echo "name=$AUTHOR_NAME" >> $GITHUB_OUTPUT if [[ "$AUTHOR_EMAIL" =~ ^([^@]+)@users\.noreply\.github\.com$ ]]; then GITHUB_USER=$(echo "${BASH_REMATCH[1]}" | sed 's/^[0-9]*+//') echo "github_user=$GITHUB_USER" >> $GITHUB_OUTPUT else echo "github_user=" >> $GITHUB_OUTPUT fi - name: Create Pull Request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} AUTHOR_NAME: ${{ steps.author.outputs.name }} AUTHOR_EMAIL: ${{ steps.author.outputs.email }} GITHUB_USER: ${{ steps.author.outputs.github_user }} HEAD_BRANCH: ${{ github.ref_name }} run: | PR_BODY="## Copybara Sync - Release ${VERSION} This PR was automatically created by Copybara, syncing changes from the [overmindtech/workspace](https://github.com/overmindtech/workspace) monorepo. **Original author:** ${AUTHOR_NAME} (${AUTHOR_EMAIL}) ### What happens when this PR is merged? 1. The \`tag-on-merge\` workflow will automatically create the \`${VERSION}\` tag on main 2. This tag will trigger the release workflow, which will: - Build provider binaries for all platforms via GoReleaser - Sign checksums with GPG - Create a GitHub release - Terraform Registry will detect the release and publish the provider ### Review Checklist - [ ] Changes look correct and match the expected monorepo sync - [ ] CI checks pass " PR_URL=$(gh pr create \ --base main \ --head "$HEAD_BRANCH" \ --title "Release ${VERSION}" \ --body "$PR_BODY") echo "Created PR: $PR_URL" if [ -n "$GITHUB_USER" ]; then echo "Requesting review from original author: $GITHUB_USER" gh pr edit "$PR_URL" --add-reviewer "$GITHUB_USER" || true fi echo "Requesting review from Engineering team" gh pr edit "$PR_URL" --add-reviewer "overmindtech/Engineering" || true ================================================ FILE: aws-source/module/provider/.github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' permissions: contents: write jobs: release: runs-on: depot-ubuntu-24.04-8 steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Install 1Password CLI uses: 1password/install-cli-action@v3.0.0 - name: Load GPG secrets from 1Password uses: 1password/load-secrets-action@v4.0.0 with: export-env: true env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_RO_TOKEN }} GPG_PRIVATE_KEY: 'op://global/Terraform Provider GPG Key/private-key' PASSPHRASE: 'op://global/Terraform Provider GPG Key/passphrase' GPG_FINGERPRINT: 'op://global/Terraform Provider GPG Key/fingerprint' - name: Import GPG key uses: crazy-max/ghaction-import-gpg@v7 id: import_gpg with: gpg_private_key: ${{ env.GPG_PRIVATE_KEY }} passphrase: ${{ env.PASSPHRASE }} - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: # renovate: datasource=github-releases depName=goreleaser/goreleaser version: "v2.15.4" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} ================================================ FILE: aws-source/module/provider/.github/workflows/tag-on-merge.yml ================================================ name: Tag Release on Merge on: pull_request: types: - closed branches: - main jobs: tag-release: if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'copybara/v') runs-on: ubuntu-latest permissions: contents: write steps: - name: Extract version from branch name id: version env: BRANCH: ${{ github.event.pull_request.head.ref }} run: | VERSION=$(echo "$BRANCH" | sed 's|copybara/||') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Extracted version: $VERSION" - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} - name: Configure Git run: | git config user.name "GitHub Actions Bot" git config user.email "actions@github.com" - name: Create and push tag env: VERSION: ${{ steps.version.outputs.version }} run: | echo "Creating tag: $VERSION" git tag "$VERSION" git push origin "$VERSION" echo "Successfully pushed tag $VERSION" - name: Delete copybara branch env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: ${{ github.event.pull_request.head.ref }} run: | echo "Deleting branch: $BRANCH" git push origin --delete "$BRANCH" || echo "Branch may have already been deleted" ================================================ FILE: aws-source/module/provider/.goreleaser.yml ================================================ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 builds: - binary: "{{ .ProjectName }}_v{{ .Version }}" env: - CGO_ENABLED=0 flags: - -trimpath ldflags: - -s -w -X main.version={{ .Version }} goos: - linux - darwin - windows goarch: - amd64 - arm64 - "386" ignore: - goos: darwin goarch: "386" archives: - formats: [zip] name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" checksum: name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" algorithm: sha256 extra_files: - glob: terraform-registry-manifest.json name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" signs: - artifacts: checksum args: - "--batch" - "--local-user" - "{{ .Env.GPG_FINGERPRINT }}" - "--output" - "${signature}" - "--detach-sign" - "${artifact}" release: extra_files: - glob: terraform-registry-manifest.json name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" changelog: sort: asc filters: exclude: - "^docs:" - "^test:" ================================================ FILE: aws-source/module/provider/LICENSE ================================================ # Functional Source License, Version 1.1, Apache 2.0 Future License ## Abbreviation FSL-1.1-Apache-2.0 ## Notice Copyright 2024 Overmind Technology Inc. ## Terms and Conditions ### Licensor ("We") The party offering the Software under these Terms and Conditions. ### The Software The "Software" is each version of the software that we make available under these Terms and Conditions, as indicated by our inclusion of these Terms and Conditions with the Software. ### License Grant Subject to your compliance with this License Grant and the Patents, Redistribution and Trademark clauses below, we hereby grant you the right to use, copy, modify, create derivative works, publicly perform, publicly display and redistribute the Software for any Permitted Purpose identified below. ### Permitted Purpose A Permitted Purpose is any purpose other than a Competing Use. A Competing Use means making the Software available to others in a commercial product or service that: 1. substitutes for the Software; 2. substitutes for any other product or service we offer using the Software that exists as of the date we make the Software available; or 3. offers the same or substantially similar functionality as the Software. Permitted Purposes specifically include using the Software: 1. for your internal use and access; 2. for non-commercial education; 3. for non-commercial research; and 4. in connection with professional services that you provide to a licensee using the Software in accordance with these Terms and Conditions. ### Patents To the extent your use for a Permitted Purpose would necessarily infringe our patents, the license grant above includes a license under our patents. If you make a claim against any party that the Software infringes or contributes to the infringement of any patent, then your patent license to the Software ends immediately. ### Redistribution The Terms and Conditions apply to all copies, modifications and derivatives of the Software. If you redistribute any copies, modifications or derivatives of the Software, you must include a copy of or a link to these Terms and Conditions and not remove any copyright notices provided in or with the Software. ### Disclaimer THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. ### Trademarks Except for displaying the License Details and identifying us as the origin of the Software, you have no right under these Terms and Conditions to use our trademarks, trade names, service marks or product names. ## Grant of Future License We hereby irrevocably grant you an additional license to use the Software under the Apache License, Version 2.0 that is effective on the second anniversary of the date we make the Software available. On or after that date, you may use the Software under the Apache License, Version 2.0, in which case the following will apply: Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: aws-source/module/provider/datasource_aws_external_id.go ================================================ package main import ( "context" "fmt" "connectrpc.com/connect" "github.com/hashicorp/terraform-plugin-framework/datasource" dsschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" ) var _ datasource.DataSource = (*awsExternalIdDataSource)(nil) type awsExternalIdDataSource struct { mgmt sdpconnect.ManagementServiceClient } type awsExternalIdDataSourceModel struct { ExternalID types.String `tfsdk:"external_id"` } func NewAWSExternalIdDataSource() datasource.DataSource { return &awsExternalIdDataSource{} } func (d *awsExternalIdDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_aws_external_id" } func (d *awsExternalIdDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = dsschema.Schema{ Description: "Retrieves the stable AWS STS external ID for the current Overmind account. " + "Use this to configure the trust policy on an IAM role before creating the source.", Attributes: map[string]dsschema.Attribute{ "external_id": dsschema.StringAttribute{ Description: "AWS STS external ID, stable per Overmind account.", Computed: true, }, }, } } func (d *awsExternalIdDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { if req.ProviderData == nil { return } mgmt, ok := req.ProviderData.(sdpconnect.ManagementServiceClient) if !ok { resp.Diagnostics.AddError("Unexpected DataSource Configure Type", fmt.Sprintf("Expected sdpconnect.ManagementServiceClient, got %T", req.ProviderData)) return } d.mgmt = mgmt } func (d *awsExternalIdDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { ctx, span := tracing.Tracer().Start(ctx, "AWSExternalId Read") defer span.End() extIDResp, err := d.mgmt.GetOrCreateAWSExternalId(ctx, connect.NewRequest(&sdp.GetOrCreateAWSExternalIdRequest{})) if err != nil { resp.Diagnostics.AddError("Failed to get AWS external ID", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "GetOrCreateAWSExternalId failed") return } externalID := extIDResp.Msg.GetAwsExternalId() span.SetAttributes(attribute.String("ovm.externalId", externalID)) resp.Diagnostics.Append(resp.State.Set(ctx, &awsExternalIdDataSourceModel{ ExternalID: types.StringValue(externalID), })...) } ================================================ FILE: aws-source/module/provider/main.go ================================================ package main import ( "context" "fmt" "os" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/uptrace/opentelemetry-go-extra/otellogrus" ) var version = "dev" //nolint:gochecknoglobals // injected by GoReleaser ldflags const defaultHoneycombAPIKey = "hcaik_01j03qe0exnn2jxpj2vxkqb7yrqtr083kyk9rxxt2wzjamz8be94znqmwa" //nolint:gosec // public ingest key, same as CLI func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) //nolint:gocritic // os.Exit in main after deferred cleanup is the only option os.Exit(1) } } func run() error { formatter := new(log.TextFormatter) formatter.DisableTimestamp = true log.SetFormatter(formatter) log.SetOutput(os.Stderr) log.SetLevel(log.ErrorLevel) honeycombAPIKey := defaultHoneycombAPIKey if v, ok := os.LookupEnv("HONEYCOMB_API_KEY"); ok { honeycombAPIKey = v } if honeycombAPIKey != "" { if err := tracing.InitTracerWithUpstreams("overmind-terraform-provider", honeycombAPIKey, ""); err != nil { return fmt.Errorf("initialising tracing: %w", err) } defer tracing.ShutdownTracer(context.Background()) log.AddHook(otellogrus.NewHook(otellogrus.WithLevels( log.AllLevels[:log.GetLevel()+1]..., ))) } return providerserver.Serve(context.Background(), NewProvider(version), providerserver.ServeOpts{ Address: "registry.terraform.io/overmindtech/overmind", }) } ================================================ FILE: aws-source/module/provider/provider.go ================================================ package main import ( "context" "fmt" "os" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/overmindtech/cli/go/auth" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "golang.org/x/oauth2" ) var _ provider.Provider = (*overmindProvider)(nil) type overmindProvider struct { version string } type overmindProviderModel struct { AppURL types.String `tfsdk:"app_url"` APIKey types.String `tfsdk:"api_key"` } func NewProvider(version string) func() provider.Provider { return func() provider.Provider { return &overmindProvider{version: version} } } func (p *overmindProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { resp.TypeName = "overmind" resp.Version = p.version } func (p *overmindProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{ Description: "The Overmind provider manages infrastructure sources via the Overmind API. " + "Configuration is read from the OVERMIND_API_KEY and OVERMIND_APP_URL environment variables by default.", Attributes: map[string]schema.Attribute{ "api_key": schema.StringAttribute{ Description: "Overmind API key. Can also be set via the OVERMIND_API_KEY environment variable.", Optional: true, Sensitive: true, }, "app_url": schema.StringAttribute{ Description: "Overmind application URL (e.g. https://app.overmind.tech). " + "Can also be set via the OVERMIND_APP_URL environment variable.", Optional: true, }, }, } } func (p *overmindProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { ctx, span := tracing.Tracer().Start(ctx, "Provider Configure") defer span.End() var config overmindProviderModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } apiKey := os.Getenv("OVERMIND_API_KEY") if !config.APIKey.IsNull() { apiKey = config.APIKey.ValueString() } appURL := os.Getenv("OVERMIND_APP_URL") if !config.AppURL.IsNull() { appURL = config.AppURL.ValueString() } if appURL == "" { appURL = "https://app.overmind.tech" } span.SetAttributes(attribute.String("ovm.provider.appUrl", appURL)) if apiKey == "" { resp.Diagnostics.AddError( "Missing API Key", "An Overmind API key must be provided via the api_key provider attribute or the OVERMIND_API_KEY environment variable.", ) span.SetStatus(codes.Error, "missing API key") return } oi, err := sdp.NewOvermindInstance(ctx, appURL) if err != nil { resp.Diagnostics.AddError("Failed to resolve Overmind instance", fmt.Sprintf("Could not resolve instance data from %s: %s", appURL, err)) span.RecordError(err) span.SetStatus(codes.Error, "instance resolution failed") return } apiURL := oi.ApiUrl.String() span.SetAttributes(attribute.String("ovm.provider.apiUrl", apiURL)) tokenSource := auth.NewAPIKeyTokenSource(apiKey, apiURL) httpClient := tracing.HTTPClient() httpClient.Transport = &oauth2.Transport{ Source: tokenSource, Base: httpClient.Transport, } mgmtClient := sdpconnect.NewManagementServiceClient(httpClient, apiURL) resp.DataSourceData = mgmtClient resp.ResourceData = mgmtClient } func (p *overmindProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ NewAWSSourceResource, } } func (p *overmindProvider) DataSources(_ context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewAWSExternalIdDataSource, } } ================================================ FILE: aws-source/module/provider/provider_test.go ================================================ package main import ( "context" "net/http" "net/http/httptest" "regexp" "sync" "testing" "connectrpc.com/connect" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-go/tfprotov6" tfresource "github.com/hashicorp/terraform-plugin-testing/helper/resource" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "golang.org/x/oauth2" ) // --- mock ManagementService handler --- type mockMgmtHandler struct { sdpconnect.UnimplementedManagementServiceHandler mu sync.Mutex sources map[string]*sdp.Source externalID string } func newMockMgmtHandler() *mockMgmtHandler { return &mockMgmtHandler{ sources: make(map[string]*sdp.Source), externalID: "test-external-id-12345", } } func (m *mockMgmtHandler) GetOrCreateAWSExternalId(_ context.Context, _ *connect.Request[sdp.GetOrCreateAWSExternalIdRequest]) (*connect.Response[sdp.GetOrCreateAWSExternalIdResponse], error) { return connect.NewResponse(&sdp.GetOrCreateAWSExternalIdResponse{ AwsExternalId: m.externalID, }), nil } func (m *mockMgmtHandler) CreateSource(_ context.Context, req *connect.Request[sdp.CreateSourceRequest]) (*connect.Response[sdp.CreateSourceResponse], error) { m.mu.Lock() defer m.mu.Unlock() id := uuid.New() source := &sdp.Source{ Metadata: &sdp.SourceMetadata{UUID: id[:]}, Properties: req.Msg.GetProperties(), } m.sources[id.String()] = source return connect.NewResponse(&sdp.CreateSourceResponse{Source: source}), nil } func (m *mockMgmtHandler) GetSource(_ context.Context, req *connect.Request[sdp.GetSourceRequest]) (*connect.Response[sdp.GetSourceResponse], error) { m.mu.Lock() defer m.mu.Unlock() id, err := uuid.FromBytes(req.Msg.GetUUID()) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } source, ok := m.sources[id.String()] if !ok { return nil, connect.NewError(connect.CodeNotFound, nil) } return connect.NewResponse(&sdp.GetSourceResponse{Source: source}), nil } func (m *mockMgmtHandler) UpdateSource(_ context.Context, req *connect.Request[sdp.UpdateSourceRequest]) (*connect.Response[sdp.UpdateSourceResponse], error) { m.mu.Lock() defer m.mu.Unlock() id, err := uuid.FromBytes(req.Msg.GetUUID()) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } source, ok := m.sources[id.String()] if !ok { return nil, connect.NewError(connect.CodeNotFound, nil) } source.Properties = req.Msg.GetProperties() return connect.NewResponse(&sdp.UpdateSourceResponse{Source: source}), nil } func (m *mockMgmtHandler) DeleteSource(_ context.Context, req *connect.Request[sdp.DeleteSourceRequest]) (*connect.Response[sdp.DeleteSourceResponse], error) { m.mu.Lock() defer m.mu.Unlock() id, err := uuid.FromBytes(req.Msg.GetUUID()) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } if _, ok := m.sources[id.String()]; !ok { return nil, connect.NewError(connect.CodeNotFound, nil) } delete(m.sources, id.String()) return connect.NewResponse(&sdp.DeleteSourceResponse{}), nil } // --- test provider that bypasses auth --- // testProvider wraps the real provider but overrides Configure to inject a // pre-built client backed by the mock server. This avoids needing the // instance-data endpoint, ApiKeyService, or real JWTs in unit tests. type testProvider struct { overmindProvider serverURL string } var _ provider.Provider = (*testProvider)(nil) func (p *testProvider) Configure(ctx context.Context, _ provider.ConfigureRequest, resp *provider.ConfigureResponse) { httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test"})) mgmtClient := sdpconnect.NewManagementServiceClient(httpClient, p.serverURL) resp.DataSourceData = mgmtClient resp.ResourceData = mgmtClient } func (p *testProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{}, } } func (p *testProvider) Resources(ctx context.Context) []func() resource.Resource { return p.overmindProvider.Resources(ctx) } func (p *testProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return p.overmindProvider.DataSources(ctx) } // --- test helpers --- func startTestServer(t *testing.T) string { t.Helper() handler := newMockMgmtHandler() path, h := sdpconnect.NewManagementServiceHandler(handler) mux := http.NewServeMux() mux.Handle(path, h) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv.URL } func unitTestProviderFactories(serverURL string) map[string]func() (tfprotov6.ProviderServer, error) { return map[string]func() (tfprotov6.ProviderServer, error){ "overmind": providerserver.NewProtocol6WithError(&testProvider{ overmindProvider: overmindProvider{version: "test"}, serverURL: serverURL, }), } } func accTestProviderFactories() map[string]func() (tfprotov6.ProviderServer, error) { return map[string]func() (tfprotov6.ProviderServer, error){ "overmind": providerserver.NewProtocol6WithError(NewProvider("test")()), } } // --- unit tests (mock server, always run) --- func TestAWSSourceResource_CRUD(t *testing.T) { serverURL := startTestServer(t) tfresource.UnitTest(t, tfresource.TestCase{ ProtoV6ProviderFactories: unitTestProviderFactories(serverURL), Steps: []tfresource.TestStep{ { Config: testAccAWSSourceConfig("test-source", "arn:aws:iam::123456789012:role/test", `["us-east-1", "eu-west-1"]`), Check: tfresource.ComposeAggregateTestCheckFunc( tfresource.TestCheckResourceAttrSet("overmind_aws_source.test", "id"), tfresource.TestCheckResourceAttr("overmind_aws_source.test", "name", "test-source"), tfresource.TestCheckResourceAttr("overmind_aws_source.test", "aws_role_arn", "arn:aws:iam::123456789012:role/test"), tfresource.TestCheckResourceAttr("overmind_aws_source.test", "external_id", "test-external-id-12345"), tfresource.TestCheckResourceAttr("overmind_aws_source.test", "aws_regions.#", "2"), ), }, { Config: testAccAWSSourceConfig("updated-source", "arn:aws:iam::123456789012:role/test", `["us-west-2"]`), Check: tfresource.ComposeAggregateTestCheckFunc( tfresource.TestCheckResourceAttr("overmind_aws_source.test", "name", "updated-source"), tfresource.TestCheckResourceAttr("overmind_aws_source.test", "aws_regions.#", "1"), tfresource.TestCheckResourceAttr("overmind_aws_source.test", "aws_regions.0", "us-west-2"), ), }, { ResourceName: "overmind_aws_source.test", ImportState: true, ImportStateVerify: true, }, }, }) } func TestProviderConfigure_MissingAPIKey(t *testing.T) { t.Setenv("OVERMIND_API_KEY", "") t.Setenv("OVERMIND_APP_URL", "") tfresource.UnitTest(t, tfresource.TestCase{ ProtoV6ProviderFactories: accTestProviderFactories(), Steps: []tfresource.TestStep{ { Config: ` resource "overmind_aws_source" "test" { name = "x" aws_role_arn = "arn" aws_regions = ["us-east-1"] } `, ExpectError: regexp.MustCompile(`Missing API Key`), }, }, }) } func TestAWSExternalIdDataSource_Read(t *testing.T) { serverURL := startTestServer(t) tfresource.UnitTest(t, tfresource.TestCase{ ProtoV6ProviderFactories: unitTestProviderFactories(serverURL), Steps: []tfresource.TestStep{ { Config: `data "overmind_aws_external_id" "test" {}`, Check: tfresource.ComposeAggregateTestCheckFunc( tfresource.TestCheckResourceAttr( "data.overmind_aws_external_id.test", "external_id", "test-external-id-12345"), ), }, }, }) } func testAccAWSSourceConfig(name, roleARN, regions string) string { return `resource "overmind_aws_source" "test" { name = "` + name + `" aws_role_arn = "` + roleARN + `" aws_regions = ` + regions + ` }` } ================================================ FILE: aws-source/module/provider/resource_aws_source.go ================================================ package main import ( "context" "fmt" "connectrpc.com/connect" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "google.golang.org/protobuf/types/known/structpb" ) var ( _ resource.Resource = (*awsSourceResource)(nil) _ resource.ResourceWithImportState = (*awsSourceResource)(nil) ) type awsSourceResource struct { mgmt sdpconnect.ManagementServiceClient } type awsSourceResourceModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` AWSRoleARN types.String `tfsdk:"aws_role_arn"` AWSRegions types.List `tfsdk:"aws_regions"` ExternalID types.String `tfsdk:"external_id"` } func NewAWSSourceResource() resource.Resource { return &awsSourceResource{} } func (r *awsSourceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_aws_source" } func (r *awsSourceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: "Manages an Overmind AWS infrastructure source.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Description: "Source UUID assigned by the Overmind API.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "name": schema.StringAttribute{ Description: "Human-readable name for this source.", Required: true, }, "aws_role_arn": schema.StringAttribute{ Description: "ARN of the IAM role to assume in the customer's AWS account.", Required: true, }, "aws_regions": schema.ListAttribute{ Description: "AWS regions this source should discover resources in.", Required: true, ElementType: types.StringType, }, "external_id": schema.StringAttribute{ Description: "AWS STS external ID for the IAM trust policy, stable per Overmind account.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, }, } } func (r *awsSourceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } mgmt, ok := req.ProviderData.(sdpconnect.ManagementServiceClient) if !ok { resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected sdpconnect.ManagementServiceClient, got %T", req.ProviderData)) return } r.mgmt = mgmt } func (r *awsSourceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { ctx, span := tracing.Tracer().Start(ctx, "AWSSource Create") defer span.End() var plan awsSourceResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } span.SetAttributes( attribute.String("ovm.source.name", plan.Name.ValueString()), attribute.String("ovm.source.roleArn", plan.AWSRoleARN.ValueString()), ) extIDResp, err := r.mgmt.GetOrCreateAWSExternalId(ctx, connect.NewRequest(&sdp.GetOrCreateAWSExternalIdRequest{})) if err != nil { resp.Diagnostics.AddError("Failed to get AWS external ID", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "GetOrCreateAWSExternalId failed") return } externalID := extIDResp.Msg.GetAwsExternalId() regions, diags := regionsFromList(ctx, plan.AWSRegions) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } sourceConfig, err := structpb.NewStruct(map[string]any{ "aws-access-strategy": "external-id", "aws-external-id": externalID, "aws-target-role-arn": plan.AWSRoleARN.ValueString(), "aws-regions": toAnySlice(regions), }) if err != nil { resp.Diagnostics.AddError("Failed to build source config", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "config build failed") return } createResp, err := r.mgmt.CreateSource(ctx, connect.NewRequest(&sdp.CreateSourceRequest{ Properties: &sdp.SourceProperties{ DescriptiveName: plan.Name.ValueString(), Type: "aws", Config: sourceConfig, }, })) if err != nil { resp.Diagnostics.AddError("Failed to create source", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "CreateSource failed") return } source := createResp.Msg.GetSource() sourceUUID, err := uuid.FromBytes(source.GetMetadata().GetUUID()) if err != nil { resp.Diagnostics.AddError("Failed to parse source UUID", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "UUID parse failed") return } plan.ID = types.StringValue(sourceUUID.String()) plan.ExternalID = types.StringValue(externalID) span.SetAttributes(attribute.String("ovm.source.id", sourceUUID.String())) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *awsSourceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { ctx, span := tracing.Tracer().Start(ctx, "AWSSource Read") defer span.End() var state awsSourceResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } span.SetAttributes(attribute.String("ovm.source.id", state.ID.ValueString())) uuidBytes, err := uuidToBytes(state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Invalid source ID", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "invalid UUID") return } getResp, err := r.mgmt.GetSource(ctx, connect.NewRequest(&sdp.GetSourceRequest{ UUID: uuidBytes, })) if err != nil { if connect.CodeOf(err) == connect.CodeNotFound { span.SetAttributes(attribute.Bool("ovm.source.removed", true)) resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Failed to read source", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "GetSource failed") return } source := getResp.Msg.GetSource() props := source.GetProperties() state.Name = types.StringValue(props.GetDescriptiveName()) if cfg := props.GetConfig(); cfg != nil { fields := cfg.GetFields() if v, ok := fields["aws-target-role-arn"]; ok { state.AWSRoleARN = types.StringValue(v.GetStringValue()) } if v, ok := fields["aws-regions"]; ok { regionVals := regionsFromStructValue(v) listVal, diags := types.ListValueFrom(ctx, types.StringType, regionVals) resp.Diagnostics.Append(diags...) state.AWSRegions = listVal } if v, ok := fields["aws-external-id"]; ok { state.ExternalID = types.StringValue(v.GetStringValue()) } } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } func (r *awsSourceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { ctx, span := tracing.Tracer().Start(ctx, "AWSSource Update") defer span.End() var plan awsSourceResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } var state awsSourceResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } span.SetAttributes( attribute.String("ovm.source.id", state.ID.ValueString()), attribute.String("ovm.source.name", plan.Name.ValueString()), ) uuidBytes, err := uuidToBytes(state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Invalid source ID", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "invalid UUID") return } regions, diags := regionsFromList(ctx, plan.AWSRegions) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } externalID := state.ExternalID.ValueString() sourceConfig, err := structpb.NewStruct(map[string]any{ "aws-access-strategy": "external-id", "aws-external-id": externalID, "aws-target-role-arn": plan.AWSRoleARN.ValueString(), "aws-regions": toAnySlice(regions), }) if err != nil { resp.Diagnostics.AddError("Failed to build source config", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "config build failed") return } _, err = r.mgmt.UpdateSource(ctx, connect.NewRequest(&sdp.UpdateSourceRequest{ UUID: uuidBytes, Properties: &sdp.SourceProperties{ DescriptiveName: plan.Name.ValueString(), Type: "aws", Config: sourceConfig, }, })) if err != nil { resp.Diagnostics.AddError("Failed to update source", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "UpdateSource failed") return } plan.ID = state.ID plan.ExternalID = state.ExternalID resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *awsSourceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { ctx, span := tracing.Tracer().Start(ctx, "AWSSource Delete") defer span.End() var state awsSourceResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } span.SetAttributes(attribute.String("ovm.source.id", state.ID.ValueString())) uuidBytes, err := uuidToBytes(state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Invalid source ID", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "invalid UUID") return } _, err = r.mgmt.DeleteSource(ctx, connect.NewRequest(&sdp.DeleteSourceRequest{ UUID: uuidBytes, })) if err != nil { if connect.CodeOf(err) == connect.CodeNotFound { span.SetAttributes(attribute.Bool("ovm.source.alreadyGone", true)) return } resp.Diagnostics.AddError("Failed to delete source", err.Error()) span.RecordError(err) span.SetStatus(codes.Error, "DeleteSource failed") } } func (r *awsSourceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { ctx, span := tracing.Tracer().Start(ctx, "AWSSource Import") defer span.End() span.SetAttributes(attribute.String("ovm.source.id", req.ID)) resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } // --- helpers --- func uuidToBytes(s string) ([]byte, error) { parsed, err := uuid.Parse(s) if err != nil { return nil, fmt.Errorf("parsing UUID %q: %w", s, err) } b := parsed[:] return b, nil } func regionsFromList(ctx context.Context, list types.List) ([]string, diag.Diagnostics) { var regions []string diags := list.ElementsAs(ctx, ®ions, false) return regions, diags } func toAnySlice(ss []string) []any { out := make([]any, len(ss)) for i, s := range ss { out[i] = s } return out } func regionsFromStructValue(v *structpb.Value) []string { lv := v.GetListValue() if lv == nil { return []string{} } vals := lv.GetValues() out := make([]string, 0, len(vals)) for _, item := range vals { if s := item.GetStringValue(); s != "" { out = append(out, s) } } return out } ================================================ FILE: aws-source/module/provider/terraform-registry-manifest.json ================================================ { "version": 1, "metadata": { "protocol_versions": [ "6.0" ] } } ================================================ FILE: aws-source/module/terraform/.github/workflows/finalize-copybara-sync.yml ================================================ name: Finalize Copybara Sync on: push: branches: - 'copybara/v*' concurrency: group: copybara-sync-${{ github.ref }} cancel-in-progress: true jobs: finalize: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Extract version from branch name id: version run: | VERSION=$(echo "$GITHUB_REF" | sed 's|refs/heads/copybara/||') echo "version=$VERSION" >> $GITHUB_OUTPUT - uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Extract original commit author id: author run: | AUTHOR_EMAIL=$(git log -1 --format='%ae' --author='^(?!.*actions@github.com)' --perl-regexp 2>/dev/null || git log -1 --format='%ae') AUTHOR_NAME=$(git log -1 --format='%an' --author='^(?!.*actions@github.com)' --perl-regexp 2>/dev/null || git log -1 --format='%an') echo "email=$AUTHOR_EMAIL" >> $GITHUB_OUTPUT echo "name=$AUTHOR_NAME" >> $GITHUB_OUTPUT if [[ "$AUTHOR_EMAIL" =~ ^([^@]+)@users\.noreply\.github\.com$ ]]; then GITHUB_USER=$(echo "${BASH_REMATCH[1]}" | sed 's/^[0-9]*+//') echo "github_user=$GITHUB_USER" >> $GITHUB_OUTPUT else echo "github_user=" >> $GITHUB_OUTPUT fi - name: Create Pull Request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} AUTHOR_NAME: ${{ steps.author.outputs.name }} AUTHOR_EMAIL: ${{ steps.author.outputs.email }} GITHUB_USER: ${{ steps.author.outputs.github_user }} HEAD_BRANCH: ${{ github.ref_name }} run: | PR_BODY="## Copybara Sync - Release ${VERSION} This PR was automatically created by Copybara, syncing changes from the [overmindtech/workspace](https://github.com/overmindtech/workspace) monorepo. **Original author:** ${AUTHOR_NAME} (${AUTHOR_EMAIL}) ### What happens when this PR is merged? 1. The \`tag-on-merge\` workflow will automatically create the \`${VERSION}\` tag on main 2. Terraform Registry will detect the tag via webhook and publish the module ### Review Checklist - [ ] Changes look correct and match the expected monorepo sync " PR_URL=$(gh pr create \ --base main \ --head "$HEAD_BRANCH" \ --title "Release ${VERSION}" \ --body "$PR_BODY") echo "Created PR: $PR_URL" if [ -n "$GITHUB_USER" ]; then echo "Requesting review from original author: $GITHUB_USER" gh pr edit "$PR_URL" --add-reviewer "$GITHUB_USER" || true fi echo "Requesting review from Engineering team" gh pr edit "$PR_URL" --add-reviewer "overmindtech/Engineering" || true ================================================ FILE: aws-source/module/terraform/.github/workflows/tag-on-merge.yml ================================================ name: Tag Release on Merge on: pull_request: types: - closed branches: - main jobs: tag-release: if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'copybara/v') runs-on: ubuntu-latest permissions: contents: write steps: - name: Extract version from branch name id: version env: BRANCH: ${{ github.event.pull_request.head.ref }} run: | VERSION=$(echo "$BRANCH" | sed 's|copybara/||') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Extracted version: $VERSION" - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} - name: Configure Git run: | git config user.name "GitHub Actions Bot" git config user.email "actions@github.com" - name: Create and push tag env: VERSION: ${{ steps.version.outputs.version }} run: | echo "Creating tag: $VERSION" git tag "$VERSION" git push origin "$VERSION" echo "Successfully pushed tag $VERSION" - name: Delete copybara branch env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: ${{ github.event.pull_request.head.ref }} run: | echo "Deleting branch: $BRANCH" git push origin --delete "$BRANCH" || echo "Branch may have already been deleted" ================================================ FILE: aws-source/module/terraform/LICENSE ================================================ # Functional Source License, Version 1.1, Apache 2.0 Future License ## Abbreviation FSL-1.1-Apache-2.0 ## Notice Copyright 2024 Overmind Technology Inc. ## Terms and Conditions ### Licensor ("We") The party offering the Software under these Terms and Conditions. ### The Software The "Software" is each version of the software that we make available under these Terms and Conditions, as indicated by our inclusion of these Terms and Conditions with the Software. ### License Grant Subject to your compliance with this License Grant and the Patents, Redistribution and Trademark clauses below, we hereby grant you the right to use, copy, modify, create derivative works, publicly perform, publicly display and redistribute the Software for any Permitted Purpose identified below. ### Permitted Purpose A Permitted Purpose is any purpose other than a Competing Use. A Competing Use means making the Software available to others in a commercial product or service that: 1. substitutes for the Software; 2. substitutes for any other product or service we offer using the Software that exists as of the date we make the Software available; or 3. offers the same or substantially similar functionality as the Software. Permitted Purposes specifically include using the Software: 1. for your internal use and access; 2. for non-commercial education; 3. for non-commercial research; and 4. in connection with professional services that you provide to a licensee using the Software in accordance with these Terms and Conditions. ### Patents To the extent your use for a Permitted Purpose would necessarily infringe our patents, the license grant above includes a license under our patents. If you make a claim against any party that the Software infringes or contributes to the infringement of any patent, then your patent license to the Software ends immediately. ### Redistribution The Terms and Conditions apply to all copies, modifications and derivatives of the Software. If you redistribute any copies, modifications or derivatives of the Software, you must include a copy of or a link to these Terms and Conditions and not remove any copyright notices provided in or with the Software. ### Disclaimer THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. ### Trademarks Except for displaying the License Details and identifying us as the origin of the Software, you have no right under these Terms and Conditions to use our trademarks, trade names, service marks or product names. ## Grant of Future License We hereby irrevocably grant you an additional license to use the Software under the Apache License, Version 2.0 that is effective on the second anniversary of the date we make the Software available. On or after that date, you may use the Software under the Apache License, Version 2.0, in which case the following will apply: Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: aws-source/module/terraform/README.md ================================================ # Overmind AWS Source Setup Terraform module that configures an AWS account for [Overmind](https://overmind.tech) infrastructure discovery. A single `terraform apply` creates: 1. An IAM role with a read-only policy in the target AWS account 2. A trust policy allowing Overmind to assume the role via STS external ID 3. An Overmind source registration pointing at the role ## Usage ```hcl provider "overmind" {} provider "aws" { region = "us-east-1" } module "overmind_aws_source" { source = "overmindtech/aws-source/overmind" name = "production" } ``` ## Inputs | Name | Description | Type | Default | Required | | --- | --- | --- | --- | --- | | `name` | Descriptive name for the source in Overmind | `string` | n/a | yes | | `regions` | AWS regions to discover (defaults to all non-opt-in regions) | `list(string)` | All 17 standard regions | no | | `role_name` | Name for the IAM role created in this account | `string` | `"overmind-read-only"` | no | | `tags` | Additional tags to apply to IAM resources | `map(string)` | `{}` | no | ## Outputs | Name | Description | | --- | --- | | `role_arn` | ARN of the created IAM role | | `source_id` | UUID of the Overmind source | | `external_id` | AWS STS external ID used in the trust policy | ## Multi-Account Example Use AWS provider aliases to onboard several accounts at once: ```hcl provider "overmind" {} provider "aws" { alias = "production" region = "us-east-1" assume_role { role_arn = "arn:aws:iam::111111111111:role/terraform" } } provider "aws" { alias = "staging" region = "eu-west-1" assume_role { role_arn = "arn:aws:iam::222222222222:role/terraform" } } module "overmind_production" { source = "overmindtech/aws-source/overmind" name = "production" providers = { aws = aws.production overmind = overmind } } module "overmind_staging" { source = "overmindtech/aws-source/overmind" name = "staging" regions = ["eu-west-1"] providers = { aws = aws.staging overmind = overmind } } ``` ## Importing Existing Sources If you already created an Overmind AWS source through the UI and want to manage it with Terraform, you can import it using the source UUID (visible on the source details page in [Settings > Sources](https://app.overmind.tech/settings/sources)): ```shell terraform import module.overmind_aws_source.overmind_aws_source.this ``` After importing, run `terraform plan` to verify the state matches your configuration. Terraform will show any drift between the imported resource and your HCL. ## Authentication The Overmind provider accepts an API key via the `api_key` attribute or the `OVERMIND_API_KEY` environment variable. The attribute takes precedence. The key must have `sources:write` scope. ```hcl provider "overmind" { api_key = var.overmind_api_key } ``` The AWS provider must have permissions to create IAM roles and policies in the target account. ## Requirements | Name | Version | | --- | --- | | terraform | >= 1.5.0 | | aws | >= 6.0 | | overmind | >= 0.1.0 | ================================================ FILE: aws-source/module/terraform/examples/multi-account/main.tf ================================================ provider "overmind" {} provider "aws" { alias = "production" region = "us-east-1" assume_role { role_arn = "arn:aws:iam::111111111111:role/terraform" } } provider "aws" { alias = "staging" region = "eu-west-1" assume_role { role_arn = "arn:aws:iam::222222222222:role/terraform" } } module "overmind_production" { source = "overmindtech/aws-source/overmind" name = "production" providers = { aws = aws.production overmind = overmind } } module "overmind_staging" { source = "overmindtech/aws-source/overmind" name = "staging" regions = ["eu-west-1"] providers = { aws = aws.staging overmind = overmind } } output "production_role_arn" { value = module.overmind_production.role_arn } output "staging_role_arn" { value = module.overmind_staging.role_arn } ================================================ FILE: aws-source/module/terraform/examples/single-account/main.tf ================================================ provider "overmind" {} provider "aws" { region = "us-east-1" } module "overmind_aws_source" { source = "overmindtech/aws-source/overmind" name = "production" } output "role_arn" { value = module.overmind_aws_source.role_arn } output "source_id" { value = module.overmind_aws_source.source_id } ================================================ FILE: aws-source/module/terraform/main.tf ================================================ data "overmind_aws_external_id" "this" {} resource "aws_iam_role" "overmind" { name = var.role_name assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Principal = { AWS = "arn:aws:iam::${var.overmind_aws_account_id}:root" } Action = "sts:AssumeRole" Condition = { StringEquals = { "sts:ExternalId" = data.overmind_aws_external_id.this.external_id } } }, { Effect = "Allow" Principal = { AWS = "arn:aws:iam::${var.overmind_aws_account_id}:root" } Action = "sts:TagSession" }, ] }) tags = merge(var.tags, { "overmind.version" = "2026-02-17" }) } resource "aws_iam_role_policy" "overmind" { name = "OvmReadOnly" role = aws_iam_role.overmind.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "apigateway:Get*", "autoscaling:Describe*", "cloudfront:Get*", "cloudfront:List*", "cloudwatch:Describe*", "cloudwatch:GetMetricData", "cloudwatch:ListTagsForResource", "directconnect:Describe*", "dynamodb:Describe*", "dynamodb:List*", "ec2:Describe*", "ecs:Describe*", "ecs:List*", "eks:Describe*", "eks:List*", "elasticfilesystem:Describe*", "elasticloadbalancing:Describe*", "iam:Get*", "iam:List*", "kms:Describe*", "kms:Get*", "kms:List*", "lambda:Get*", "lambda:List*", "network-firewall:Describe*", "network-firewall:List*", "networkmanager:Describe*", "networkmanager:Get*", "networkmanager:List*", "rds:Describe*", "rds:ListTagsForResource", "route53:Get*", "route53:List*", "s3:GetBucket*", "s3:ListAllMyBuckets", "sns:Get*", "sns:List*", "sqs:Get*", "sqs:List*", "ssm:Describe*", "ssm:Get*", "ssm:ListTagsForResource", ] Resource = "*" }, ] }) } resource "overmind_aws_source" "this" { name = var.name aws_role_arn = aws_iam_role.overmind.arn aws_regions = var.regions } ================================================ FILE: aws-source/module/terraform/outputs.tf ================================================ output "role_arn" { description = "ARN of the created IAM role." value = aws_iam_role.overmind.arn } output "source_id" { description = "UUID of the Overmind source." value = overmind_aws_source.this.id } output "external_id" { description = "AWS STS external ID used in the trust policy." value = data.overmind_aws_external_id.this.external_id } ================================================ FILE: aws-source/module/terraform/variables.tf ================================================ variable "name" { type = string description = "Descriptive name for the source in Overmind." } variable "regions" { type = list(string) default = [ "us-east-1", "us-east-2", "us-west-1", "us-west-2", "ap-south-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-southeast-1", "ap-southeast-2", "ca-central-1", "eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "eu-north-1", "sa-east-1", ] description = "AWS regions to discover. Defaults to all non-opt-in regions." } variable "role_name" { type = string default = "overmind-read-only" description = "Name for the IAM role created in this account." } variable "tags" { type = map(string) default = {} description = "Additional tags to apply to IAM resources." } variable "overmind_aws_account_id" { type = string default = "942836531449" description = "Internal override for the Overmind AWS account that runs source pods. Do not change this unless you are an Overmind engineer deploying to a non-production environment. All customers should use the default." } ================================================ FILE: aws-source/module/terraform/versions.tf ================================================ terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0" } overmind = { source = "overmindtech/overmind" version = ">= 0.1.0" } } } ================================================ FILE: aws-source/proc/proc.go ================================================ package proc import ( "context" "errors" "fmt" "net/http" "strings" "sync" "sync/atomic" "time" awsapigateway "github.com/aws/aws-sdk-go-v2/service/apigateway" awsautoscaling "github.com/aws/aws-sdk-go-v2/service/autoscaling" awscloudfront "github.com/aws/aws-sdk-go-v2/service/cloudfront" awscloudwatch "github.com/aws/aws-sdk-go-v2/service/cloudwatch" awsdirectconnect "github.com/aws/aws-sdk-go-v2/service/directconnect" awsdynamodb "github.com/aws/aws-sdk-go-v2/service/dynamodb" awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" awsecs "github.com/aws/aws-sdk-go-v2/service/ecs" awsefs "github.com/aws/aws-sdk-go-v2/service/efs" awseks "github.com/aws/aws-sdk-go-v2/service/eks" awselasticloadbalancing "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing" awselasticloadbalancingv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" awsiam "github.com/aws/aws-sdk-go-v2/service/iam" awskms "github.com/aws/aws-sdk-go-v2/service/kms" awslambda "github.com/aws/aws-sdk-go-v2/service/lambda" awsnetworkfirewall "github.com/aws/aws-sdk-go-v2/service/networkfirewall" awsnetworkmanager "github.com/aws/aws-sdk-go-v2/service/networkmanager" awsrds "github.com/aws/aws-sdk-go-v2/service/rds" awsroute53 "github.com/aws/aws-sdk-go-v2/service/route53" awssns "github.com/aws/aws-sdk-go-v2/service/sns" awssqs "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/smithy-go" "github.com/sourcegraph/conc/pool" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" stscredsv2 "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" log "github.com/sirupsen/logrus" "github.com/spf13/viper" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // This package contains a few functions needed by the CLI to load this in-proc. // These can not go into `/sources` because that would cause an import cycle // with everything else. type AwsAuthConfig struct { Strategy string AccessKeyID string SecretAccessKey string ExternalID string TargetRoleARN string Profile string AutoConfig bool Regions []string } // ConfigFromViper reads AWS configuration from viper, parses regions, and creates // AWS configs for each region. Consolidates config loading and validation. func ConfigFromViper() ([]aws.Config, error) { authConfig := AwsAuthConfig{ Strategy: viper.GetString("aws-access-strategy"), AccessKeyID: viper.GetString("aws-access-key-id"), SecretAccessKey: viper.GetString("aws-secret-access-key"), ExternalID: viper.GetString("aws-external-id"), TargetRoleARN: viper.GetString("aws-target-role-arn"), Profile: viper.GetString("aws-profile"), AutoConfig: viper.GetBool("auto-config"), } if err := viper.UnmarshalKey("aws-regions", &authConfig.Regions); err != nil { return nil, fmt.Errorf("could not parse aws-regions: %w", err) } return CreateAWSConfigs(authConfig) } // isTimeoutError checks if an error is a context deadline exceeded. // A single unresponsive region (e.g. me-south-1 being decommissioned) must // not take down the whole source — see ENG-3665. func isTimeoutError(err error) bool { return err != nil && errors.Is(err, context.DeadlineExceeded) } // isOptInRegionError checks if an error indicates an opt-in region that is not // enabled in the AWS account (InvalidIdentityToken + OIDC). func isOptInRegionError(err error) bool { if err == nil { return false } var apiErr smithy.APIError if errors.As(err, &apiErr) { if apiErr.ErrorCode() == "InvalidIdentityToken" { errMsg := err.Error() if strings.Contains(errMsg, "No OpenIDConnect provider found") { return true } } } return false } // isSkippableRegionError checks if an error indicates a region that cannot be // reached and should be skipped rather than failing the entire source. func isSkippableRegionError(err error) bool { return isTimeoutError(err) || isOptInRegionError(err) } // wrapRegionError wraps misleading AWS errors with more helpful context func wrapRegionError(err error, region string) error { if err == nil { return nil } if isTimeoutError(err) { return fmt.Errorf("%w. Region '%s' is unreachable (timeout); it may be decommissioned or experiencing an outage", err, region) } if isOptInRegionError(err) { return fmt.Errorf("%w. This error often occurs when region '%s' is not enabled in the target AWS account", err, region) } return err } func (c AwsAuthConfig) GetAWSConfig(region string) (aws.Config, error) { // Validate inputs if region == "" { return aws.Config{}, errors.New("aws-region cannot be blank") } ctx := context.Background() options := []func(*config.LoadOptions) error{ config.WithRegion(region), config.WithAppID("Overmind"), } if c.AutoConfig { if c.Strategy != "defaults" { log.WithField("aws-access-strategy", c.Strategy).Warn("auto-config is set to true, but aws-access-strategy is not set to 'defaults'. This may cause unexpected behaviour") } return config.LoadDefaultConfig(ctx, options...) } switch c.Strategy { case "defaults": return config.LoadDefaultConfig(ctx, options...) case "access-key": if c.AccessKeyID == "" { return aws.Config{}, errors.New("with access-key strategy, aws-access-key-id cannot be blank") } if c.SecretAccessKey == "" { return aws.Config{}, errors.New("with access-key strategy, aws-secret-access-key cannot be blank") } if c.ExternalID != "" { return aws.Config{}, errors.New("with access-key strategy, aws-external-id must be blank") } if c.TargetRoleARN != "" { return aws.Config{}, errors.New("with access-key strategy, aws-target-role-arn must be blank") } if c.Profile != "" { return aws.Config{}, errors.New("with access-key strategy, aws-profile must be blank") } options = append(options, config.WithCredentialsProvider( credentials.NewStaticCredentialsProvider(c.AccessKeyID, c.SecretAccessKey, ""), )) return config.LoadDefaultConfig(ctx, options...) case "external-id": if c.AccessKeyID != "" { return aws.Config{}, errors.New("with external-id strategy, aws-access-key-id must be blank") } if c.SecretAccessKey != "" { return aws.Config{}, errors.New("with external-id strategy, aws-secret-access-key must be blank") } if c.ExternalID == "" { return aws.Config{}, errors.New("with external-id strategy, aws-external-id cannot be blank") } if c.TargetRoleARN == "" { return aws.Config{}, errors.New("with external-id strategy, aws-target-role-arn cannot be blank") } if c.Profile != "" { return aws.Config{}, errors.New("with external-id strategy, aws-profile must be blank") } assumeConfig, err := config.LoadDefaultConfig(ctx, options...) if err != nil { return aws.Config{}, fmt.Errorf("could not load default config from environment: %w", err) } options = append(options, config.WithCredentialsProvider(aws.NewCredentialsCache( stscredsv2.NewAssumeRoleProvider( sts.NewFromConfig(assumeConfig), c.TargetRoleARN, func(aro *stscredsv2.AssumeRoleOptions) { aro.ExternalID = &c.ExternalID }, )), )) return config.LoadDefaultConfig(ctx, options...) case "sso-profile": if c.AccessKeyID != "" { return aws.Config{}, errors.New("with sso-profile strategy, aws-access-key-id must be blank") } if c.SecretAccessKey != "" { return aws.Config{}, errors.New("with sso-profile strategy, aws-secret-access-key must be blank") } if c.ExternalID != "" { return aws.Config{}, errors.New("with sso-profile strategy, aws-external-id must be blank") } if c.TargetRoleARN != "" { return aws.Config{}, errors.New("with sso-profile strategy, aws-target-role-arn must be blank") } if c.Profile == "" { return aws.Config{}, errors.New("with sso-profile strategy, aws-profile cannot be blank") } options = append(options, config.WithSharedConfigProfile(c.Profile)) return config.LoadDefaultConfig(ctx, options...) default: return aws.Config{}, errors.New("invalid aws-access-strategy") } } // Takes AwsAuthConfig options and converts these into a slice of AWS configs, // one for each region. These can then be passed to // `InitializeAwsSourceEngine()“ to actually start the source func CreateAWSConfigs(awsAuthConfig AwsAuthConfig) ([]aws.Config, error) { if len(awsAuthConfig.Regions) == 0 { return nil, errors.New("no regions specified") } configs := make([]aws.Config, 0, len(awsAuthConfig.Regions)) for _, region := range awsAuthConfig.Regions { region = strings.Trim(region, " ") cfg, err := awsAuthConfig.GetAWSConfig(region) if err != nil { return nil, fmt.Errorf("error getting AWS config for region %v: %w", region, err) } // Add OTel instrumentation cfg.HTTPClient = &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport), } configs = append(configs, cfg) } return configs, nil } // InitializeAwsSourceAdapters adds AWS adapters to an existing engine. This is a single-attempt // function; retry logic is handled by the caller via Engine.InitialiseAdapters. // // The context provided will be used for the rate limit buckets and should not be cancelled until // the source is shut down. AWS configs should be provided for each region that is enabled. func InitializeAwsSourceAdapters(ctx context.Context, e *discovery.Engine, configs ...aws.Config) error { // Create a shared cache for all adapters in this source sharedCache := sdpcache.NewCache(ctx) // ReadinessCheck verifies adapters are healthy by using an EC2VpcAdapter // Timeout is handled by SendHeartbeat, HTTP handlers rely on request context e.SetReadinessCheck(func(ctx context.Context) error { // Find an EC2VpcAdapter to verify adapter health adapters := e.AdaptersByType("ec2-vpc") if len(adapters) == 0 { return fmt.Errorf("readiness check failed: no ec2-vpc adapters available") } // Use first adapter and try to list from first scope adapter := adapters[0] scopes := adapter.Scopes() if len(scopes) == 0 { return fmt.Errorf("readiness check failed: no scopes available for ec2-vpc adapter") } listableAdapter, ok := adapter.(discovery.ListStreamableAdapter) if !ok { return fmt.Errorf("readiness check failed: ec2-vpc adapter is not listable") } stream := discovery.NewRecordingQueryResultStream() listableAdapter.ListStream(ctx, scopes[0], true, stream) for _, err := range stream.GetErrors() { if err != nil { return fmt.Errorf("readiness check (listing VPCs) failed: %w", err) } } return nil }) if len(configs) == 0 { return errors.New("no configs specified") } var globalDone atomic.Bool // Track regions that are skipped due to not being enabled (opt-in regions) type skippedRegion struct { region string err error } var skippedRegions []skippedRegion var skippedRegionsMu sync.Mutex p := pool.New().WithContext(ctx) for _, cfg := range configs { p.Go(func(ctx context.Context) error { configCtx, configCancel := context.WithTimeout(ctx, 10*time.Second) defer configCancel() log.WithFields(log.Fields{ "region": cfg.Region, }).Info("Initializing AWS source") // Work out what account we're using. This will be used in item scopes stsClient := sts.NewFromConfig(cfg) callerID, err := stsClient.GetCallerIdentity(configCtx, &sts.GetCallerIdentityInput{}) if err != nil { lf := log.Fields{ "region": cfg.Region, } // Check if this is a skippable region error (timeout or opt-in) if isSkippableRegionError(err) { wrappedErr := wrapRegionError(err, cfg.Region) skippedRegionsMu.Lock() skippedRegions = append(skippedRegions, skippedRegion{ region: cfg.Region, err: wrappedErr, }) skippedRegionsMu.Unlock() reason := "opt-in region not enabled" if isTimeoutError(err) { reason = "timeout" log.WithError(wrappedErr).WithFields(lf).Warn("Skipping region - unreachable (timeout)") } else { log.WithError(wrappedErr).WithFields(lf).Warn("Skipping region - not enabled in account") } span := trace.SpanFromContext(ctx) span.AddEvent("ovm.adapter.regionSkipped", trace.WithAttributes( attribute.String("ovm.adapter.region", cfg.Region), attribute.String("ovm.adapter.skipReason", reason), attribute.String("ovm.adapter.error", wrappedErr.Error()), )) return nil // Don't fail the pool for skippable regions } // Wrap misleading OIDC errors with helpful region enablement context wrappedErr := wrapRegionError(err, cfg.Region) log.WithError(wrappedErr).WithFields(lf).Error("Error retrieving account information") return fmt.Errorf("error getting caller identity for region %v: %w", cfg.Region, wrappedErr) } // Create shared clients for each API autoscalingClient := awsautoscaling.NewFromConfig(cfg, func(o *awsautoscaling.Options) { o.RetryMode = aws.RetryModeAdaptive }) cloudfrontClient := awscloudfront.NewFromConfig(cfg, func(o *awscloudfront.Options) { o.RetryMode = aws.RetryModeAdaptive }) cloudwatchClient := awscloudwatch.NewFromConfig(cfg, func(o *awscloudwatch.Options) { o.RetryMode = aws.RetryModeAdaptive }) directconnectClient := awsdirectconnect.NewFromConfig(cfg, func(o *awsdirectconnect.Options) { o.RetryMode = aws.RetryModeAdaptive }) dynamodbClient := awsdynamodb.NewFromConfig(cfg, func(o *awsdynamodb.Options) { o.RetryMode = aws.RetryModeAdaptive }) ec2Client := awsec2.NewFromConfig(cfg, func(o *awsec2.Options) { o.RetryMode = aws.RetryModeAdaptive }) ecsClient := awsecs.NewFromConfig(cfg, func(o *awsecs.Options) { o.RetryMode = aws.RetryModeAdaptive }) efsClient := awsefs.NewFromConfig(cfg, func(o *awsefs.Options) { o.RetryMode = aws.RetryModeAdaptive }) eksClient := awseks.NewFromConfig(cfg, func(o *awseks.Options) { o.RetryMode = aws.RetryModeAdaptive }) elbClient := awselasticloadbalancing.NewFromConfig(cfg, func(o *awselasticloadbalancing.Options) { o.RetryMode = aws.RetryModeAdaptive }) elbv2Client := awselasticloadbalancingv2.NewFromConfig(cfg, func(o *awselasticloadbalancingv2.Options) { o.RetryMode = aws.RetryModeAdaptive }) lambdaClient := awslambda.NewFromConfig(cfg, func(o *awslambda.Options) { o.RetryMode = aws.RetryModeAdaptive }) networkfirewallClient := awsnetworkfirewall.NewFromConfig(cfg, func(o *awsnetworkfirewall.Options) { o.RetryMode = aws.RetryModeAdaptive }) rdsClient := awsrds.NewFromConfig(cfg, func(o *awsrds.Options) { o.RetryMode = aws.RetryModeAdaptive }) snsClient := awssns.NewFromConfig(cfg, func(o *awssns.Options) { o.RetryMode = aws.RetryModeAdaptive }) sqsClient := awssqs.NewFromConfig(cfg, func(o *awssqs.Options) { o.RetryMode = aws.RetryModeAdaptive }) route53Client := awsroute53.NewFromConfig(cfg, func(o *awsroute53.Options) { o.RetryMode = aws.RetryModeAdaptive }) networkmanagerClient := awsnetworkmanager.NewFromConfig(cfg, func(o *awsnetworkmanager.Options) { o.RetryMode = aws.RetryModeAdaptive }) iamClient := awsiam.NewFromConfig(cfg, func(o *awsiam.Options) { o.RetryMode = aws.RetryModeAdaptive // Increase this from the default of 3 since IAM as such low rate limits o.RetryMaxAttempts = 5 }) kmsClient := awskms.NewFromConfig(cfg, func(o *awskms.Options) { o.RetryMode = aws.RetryModeAdaptive }) apigatewayClient := awsapigateway.NewFromConfig(cfg, func(o *awsapigateway.Options) { o.RetryMode = aws.RetryModeAdaptive }) ssmClient := ssm.NewFromConfig(cfg, func(o *ssm.Options) { o.RetryMode = aws.RetryModeAdaptive }) configuredAdapters := []discovery.Adapter{ // EC2 adapters.NewEC2AddressAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2CapacityReservationFleetAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2CapacityReservationAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2EgressOnlyInternetGatewayAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2IamInstanceProfileAssociationAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2ImageAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2InstanceEventWindowAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2InstanceAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2InstanceStatusAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2InternetGatewayAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2KeyPairAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2LaunchTemplateAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2LaunchTemplateVersionAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2NatGatewayAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2NetworkAclAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2NetworkInterfacePermissionAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2NetworkInterfaceAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2PlacementGroupAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2ReservedInstanceAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2RouteTableAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2TransitGatewayRouteTableAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2TransitGatewayRouteTableAssociationAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2TransitGatewayRouteTablePropagationAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2TransitGatewayRouteAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2SecurityGroupRuleAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2SecurityGroupAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2SnapshotAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2SubnetAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2VolumeAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2VolumeStatusAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2VpcEndpointAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2VpcPeeringConnectionAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewEC2VpcAdapter(ec2Client, *callerID.Account, cfg.Region, sharedCache), // EFS (I'm assuming it shares its rate limit with EC2)) adapters.NewEFSAccessPointAdapter(efsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewEFSBackupPolicyAdapter(efsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewEFSFileSystemAdapter(efsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewEFSMountTargetAdapter(efsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewEFSReplicationConfigurationAdapter(efsClient, *callerID.Account, cfg.Region, sharedCache), // EKS adapters.NewEKSAddonAdapter(eksClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewEKSClusterAdapter(eksClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewEKSFargateProfileAdapter(eksClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewEKSNodegroupAdapter(eksClient, *callerID.Account, cfg.Region, sharedCache), // Route 53 adapters.NewRoute53HealthCheckAdapter(route53Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewRoute53HostedZoneAdapter(route53Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewRoute53ResourceRecordSetAdapter(route53Client, *callerID.Account, cfg.Region, sharedCache), // Cloudwatch adapters.NewCloudwatchAlarmAdapter(cloudwatchClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewCloudwatchInstanceMetricAdapter(cloudwatchClient, *callerID.Account, cfg.Region, sharedCache), // Lambda adapters.NewLambdaFunctionAdapter(lambdaClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewLambdaLayerAdapter(lambdaClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewLambdaLayerVersionAdapter(lambdaClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewLambdaEventSourceMappingAdapter(lambdaClient, *callerID.Account, cfg.Region, sharedCache), // ECS adapters.NewECSCapacityProviderAdapter(ecsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewECSClusterAdapter(ecsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewECSContainerInstanceAdapter(ecsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewECSServiceAdapter(ecsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewECSTaskDefinitionAdapter(ecsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewECSTaskAdapter(ecsClient, *callerID.Account, cfg.Region, sharedCache), // DynamoDB adapters.NewDynamoDBBackupAdapter(dynamodbClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDynamoDBTableAdapter(dynamodbClient, *callerID.Account, cfg.Region, sharedCache), // RDS adapters.NewRDSDBClusterParameterGroupAdapter(rdsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewRDSDBClusterAdapter(rdsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewRDSDBInstanceAdapter(rdsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewRDSDBParameterGroupAdapter(rdsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewRDSDBSubnetGroupAdapter(rdsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewRDSOptionGroupAdapter(rdsClient, *callerID.Account, cfg.Region, sharedCache), // AutoScaling adapters.NewAutoScalingGroupAdapter(autoscalingClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAutoScalingPolicyAdapter(autoscalingClient, *callerID.Account, cfg.Region, sharedCache), // ELB adapters.NewELBInstanceHealthAdapter(elbClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewELBLoadBalancerAdapter(elbClient, *callerID.Account, cfg.Region, sharedCache), // ELBv2 adapters.NewELBv2ListenerAdapter(elbv2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewELBv2LoadBalancerAdapter(elbv2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewELBv2RuleAdapter(elbv2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewELBv2TargetGroupAdapter(elbv2Client, *callerID.Account, cfg.Region, sharedCache), adapters.NewELBv2TargetHealthAdapter(elbv2Client, *callerID.Account, cfg.Region, sharedCache), // Network Firewall adapters.NewNetworkFirewallFirewallAdapter(networkfirewallClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkFirewallFirewallPolicyAdapter(networkfirewallClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkFirewallRuleGroupAdapter(networkfirewallClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkFirewallTLSInspectionConfigurationAdapter(networkfirewallClient, *callerID.Account, cfg.Region, sharedCache), // Direct Connect adapters.NewDirectConnectGatewayAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectGatewayAssociationAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectGatewayAssociationProposalAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectConnectionAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectGatewayAttachmentAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectVirtualInterfaceAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectVirtualGatewayAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectCustomerMetadataAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectLagAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectLocationAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectHostedConnectionAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectInterconnectAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewDirectConnectRouterConfigurationAdapter(directconnectClient, *callerID.Account, cfg.Region, sharedCache), // Network Manager adapters.NewNetworkManagerConnectAttachmentAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerConnectPeerAssociationAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerConnectPeerAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerCoreNetworkPolicyAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerCoreNetworkAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerNetworkResourceRelationshipsAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerSiteToSiteVpnAttachmentAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerTransitGatewayConnectPeerAssociationAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerTransitGatewayPeeringAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerTransitGatewayRegistrationAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerTransitGatewayRouteTableAttachmentAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewNetworkManagerVPCAttachmentAdapter(networkmanagerClient, *callerID.Account, cfg.Region, sharedCache), // SQS adapters.NewSQSQueueAdapter(sqsClient, *callerID.Account, cfg.Region, sharedCache), // SNS adapters.NewSNSSubscriptionAdapter(snsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewSNSTopicAdapter(snsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewSNSPlatformApplicationAdapter(snsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewSNSEndpointAdapter(snsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewSNSDataProtectionPolicyAdapter(snsClient, *callerID.Account, cfg.Region, sharedCache), // KMS adapters.NewKMSKeyAdapter(kmsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewKMSCustomKeyStoreAdapter(kmsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewKMSAliasAdapter(kmsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewKMSGrantAdapter(kmsClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewKMSKeyPolicyAdapter(kmsClient, *callerID.Account, cfg.Region, sharedCache), // ApiGateway adapters.NewAPIGatewayRestApiAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayResourceAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayDomainNameAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayMethodAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayMethodResponseAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayIntegrationAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayApiKeyAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayAuthorizerAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayDeploymentAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayStageAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), adapters.NewAPIGatewayModelAdapter(apigatewayClient, *callerID.Account, cfg.Region, sharedCache), // SSM adapters.NewSSMParameterAdapter(ssmClient, *callerID.Account, cfg.Region, sharedCache), } err = e.AddAdapters(configuredAdapters...) if err != nil { return err } // Add "global" sources (those that aren't tied to a region, like // cloudfront). but only do this once for the first region. For // these APIs it doesn't matter which region we call them from, we // get global results if globalDone.CompareAndSwap(false, true) { globalAdapters := []discovery.Adapter{ // Cloudfront adapters.NewCloudfrontCachePolicyAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontContinuousDeploymentPolicyAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontDistributionAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontCloudfrontFunctionAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontKeyGroupAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontOriginAccessControlAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontOriginRequestPolicyAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontResponseHeadersPolicyAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontRealtimeLogConfigsAdapter(cloudfrontClient, *callerID.Account, sharedCache), adapters.NewCloudfrontStreamingDistributionAdapter(cloudfrontClient, *callerID.Account, sharedCache), // S3 adapters.NewS3Adapter(cfg, *callerID.Account, sharedCache), // Networkmanager adapters.NewNetworkManagerGlobalNetworkAdapter(networkmanagerClient, *callerID.Account, sharedCache), adapters.NewNetworkManagerSiteAdapter(networkmanagerClient, *callerID.Account, sharedCache), adapters.NewNetworkManagerLinkAdapter(networkmanagerClient, *callerID.Account, sharedCache), adapters.NewNetworkManagerDeviceAdapter(networkmanagerClient, *callerID.Account, sharedCache), adapters.NewNetworkManagerLinkAssociationAdapter(networkmanagerClient, *callerID.Account, sharedCache), adapters.NewNetworkManagerConnectionAdapter(networkmanagerClient, *callerID.Account, sharedCache), // IAM adapters.NewIAMPolicyAdapter(iamClient, *callerID.Account, sharedCache), adapters.NewIAMGroupAdapter(iamClient, *callerID.Account, sharedCache), adapters.NewIAMInstanceProfileAdapter(iamClient, *callerID.Account, sharedCache), adapters.NewIAMRoleAdapter(iamClient, *callerID.Account, sharedCache), adapters.NewIAMUserAdapter(iamClient, *callerID.Account, sharedCache), } err = e.AddAdapters(globalAdapters...) if err != nil { return err } } return nil }) } if err := p.Wait(); err != nil { return err } // Log summary of skipped regions if any if len(skippedRegions) > 0 { skippedRegionNames := make([]string, 0, len(skippedRegions)) for _, sr := range skippedRegions { skippedRegionNames = append(skippedRegionNames, sr.region) } log.WithFields(log.Fields{ "skipped_regions": skippedRegionNames, "count": len(skippedRegions), }).Warn("Some regions were skipped because they are unreachable or not enabled in the AWS account. The source will operate normally with the remaining regions.") } log.Debug("Sources initialized") return nil } ================================================ FILE: aws-source/proc/proc_test.go ================================================ package proc import ( "context" "errors" "fmt" "strings" "testing" "github.com/aws/smithy-go" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testAdapter is a minimal adapter for testing type testAdapter struct { adapterType string scopes []string } func (t *testAdapter) Type() string { return t.adapterType } func (t *testAdapter) Name() string { return "test-adapter" } func (t *testAdapter) Scopes() []string { return t.scopes } func (t *testAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: t.adapterType, DescriptiveName: "Test Adapter", } } func (t *testAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not implemented for test", Scope: scope, } } // TestInitializeAwsSourceEngine_RetryClearsAdapters tests that when a retry // occurs, adapters from the previous attempt are cleared to avoid duplicate // registration errors. This test verifies the fix for the issue where // adapters from a previous retry attempt would remain in the engine, causing // "adapter with type X and overlapping scopes already exists" errors. func TestInitializeAwsSourceEngine_RetryClearsAdapters(t *testing.T) { // Create a minimal engine config without NATS to avoid needing a real connection ec := &discovery.EngineConfig{ MaxParallelExecutions: 10, SourceName: "test-aws-source", EngineType: "aws", Version: "test", } // Create an engine manually to test the clearing behavior engine, err := discovery.NewEngine(ec) require.NoError(t, err) // Create a test adapter to simulate a partial success scenario // where some adapters were added before a failure testAdapter := &testAdapter{ adapterType: "ec2-address", scopes: []string{"123456789012.us-east-1"}, } err = engine.AddAdapters(testAdapter) require.NoError(t, err) // Verify adapter was added by checking available scopes scopes, _ := engine.GetAvailableScopesAndMetadata() assert.Contains(t, scopes, "123456789012.us-east-1", "Scope should be present before clear") // Verify we can't add the same adapter again (this would cause the error we're fixing) err = engine.AddAdapters(testAdapter) require.Error(t, err, "Should get error when adding duplicate adapter") require.Contains(t, err.Error(), "overlapping scopes already exists", "Error should mention overlapping scopes") // Clear adapters (simulating what happens before retry in InitializeAwsSourceEngine) engine.ClearAdapters() // Verify adapter was cleared by checking scopes scopes, _ = engine.GetAvailableScopesAndMetadata() assert.NotContains(t, scopes, "123456789012.us-east-1", "Scope should not be present after clear") // Now we should be able to add the adapter again without error // This simulates what happens on retry - adapters are cleared, so we can add them again err = engine.AddAdapters(testAdapter) require.NoError(t, err, "Should be able to add adapter again after clearing") // Verify adapter was added again scopes, _ = engine.GetAvailableScopesAndMetadata() assert.Contains(t, scopes, "123456789012.us-east-1", "Scope should be present after re-adding") } // mockAPIError implements smithy.APIError for testing type mockAPIError struct { code string message string } func (m *mockAPIError) Error() string { return m.message } func (m *mockAPIError) ErrorCode() string { return m.code } func (m *mockAPIError) ErrorMessage() string { return m.message } func (m *mockAPIError) ErrorFault() smithy.ErrorFault { return smithy.FaultUnknown } func TestIsOptInRegionError(t *testing.T) { tests := []struct { name string err error expectedResult bool }{ { name: "nil error returns false", err: nil, expectedResult: false, }, { name: "InvalidIdentityToken with OIDC message returns true", err: &mockAPIError{ code: "InvalidIdentityToken", message: "InvalidIdentityToken: No OpenIDConnect provider found in your account for https://oidc.eks.eu-west-2.amazonaws.com/id/ABC123", }, expectedResult: true, }, { name: "wrapped InvalidIdentityToken with OIDC message returns true", err: fmt.Errorf("operation error STS: AssumeRoleWithWebIdentity: %w", &mockAPIError{ code: "InvalidIdentityToken", message: "No OpenIDConnect provider found in your account", }), expectedResult: true, }, { name: "InvalidIdentityToken without OIDC message returns false", err: &mockAPIError{ code: "InvalidIdentityToken", message: "Invalid identity token for some other reason", }, expectedResult: false, }, { name: "different error code returns false", err: &mockAPIError{ code: "AccessDenied", message: "Access denied", }, expectedResult: false, }, { name: "non-AWS error returns false", err: errors.New("some random error"), expectedResult: false, }, { name: "error with OIDC text but not API error returns false", err: errors.New("No OpenIDConnect provider found"), expectedResult: false, }, { name: "context.DeadlineExceeded returns false", err: context.DeadlineExceeded, expectedResult: false, }, { name: "context.Canceled returns false", err: context.Canceled, expectedResult: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isOptInRegionError(tt.err) if result != tt.expectedResult { t.Errorf("isOptInRegionError() = %v, want %v for error: %v", result, tt.expectedResult, tt.err) } }) } } func TestIsTimeoutError(t *testing.T) { tests := []struct { name string err error expectedResult bool }{ { name: "nil error returns false", err: nil, expectedResult: false, }, { name: "context.DeadlineExceeded returns true", err: context.DeadlineExceeded, expectedResult: true, }, { name: "wrapped context.DeadlineExceeded returns true", err: fmt.Errorf("operation error STS: GetCallerIdentity: %w", context.DeadlineExceeded), expectedResult: true, }, { name: "context.Canceled returns false", err: context.Canceled, expectedResult: false, }, { name: "wrapped context.Canceled returns false", err: fmt.Errorf("operation error STS: GetCallerIdentity: %w", context.Canceled), expectedResult: false, }, { name: "non-timeout error returns false", err: errors.New("some random error"), expectedResult: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isTimeoutError(tt.err) if result != tt.expectedResult { t.Errorf("isTimeoutError() = %v, want %v for error: %v", result, tt.expectedResult, tt.err) } }) } } func TestIsSkippableRegionError(t *testing.T) { tests := []struct { name string err error expectedResult bool }{ { name: "nil error returns false", err: nil, expectedResult: false, }, { name: "context.DeadlineExceeded returns true (ENG-3665)", err: context.DeadlineExceeded, expectedResult: true, }, { name: "wrapped context.DeadlineExceeded returns true (ENG-3665)", err: fmt.Errorf("operation error STS: GetCallerIdentity: %w", context.DeadlineExceeded), expectedResult: true, }, { name: "context.Canceled returns false (parent cancellation, not region timeout)", err: context.Canceled, expectedResult: false, }, { name: "opt-in region error returns true", err: &mockAPIError{ code: "InvalidIdentityToken", message: "No OpenIDConnect provider found in your account", }, expectedResult: true, }, { name: "non-skippable error returns false", err: errors.New("some random error"), expectedResult: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isSkippableRegionError(tt.err) if result != tt.expectedResult { t.Errorf("isSkippableRegionError() = %v, want %v for error: %v", result, tt.expectedResult, tt.err) } }) } } func TestWrapRegionError(t *testing.T) { tests := []struct { name string err error region string shouldWrap bool expectedText string }{ { name: "nil error returns nil", err: nil, region: "us-east-1", shouldWrap: false, expectedText: "", }, { name: "opt-in region error gets wrapped", err: &mockAPIError{ code: "InvalidIdentityToken", message: "No OpenIDConnect provider found in your account", }, region: "eu-central-2", shouldWrap: true, expectedText: "region 'eu-central-2' is not enabled", }, { name: "wrapped opt-in region error gets additional context", err: fmt.Errorf("operation error STS: AssumeRoleWithWebIdentity: %w", &mockAPIError{ code: "InvalidIdentityToken", message: "No OpenIDConnect provider found in your account", }), region: "ap-south-2", shouldWrap: true, expectedText: "region 'ap-south-2' is not enabled", }, { name: "InvalidIdentityToken without OIDC text not wrapped", err: &mockAPIError{ code: "InvalidIdentityToken", message: "some other message", }, region: "me-central-1", shouldWrap: false, expectedText: "", }, { name: "unrelated error not wrapped", err: errors.New("some other AWS error"), region: "us-west-2", shouldWrap: false, expectedText: "", }, { name: "timeout error gets timeout-specific message", err: context.DeadlineExceeded, region: "me-south-1", shouldWrap: true, expectedText: "unreachable (timeout)", }, { name: "wrapped timeout error gets timeout-specific message", err: fmt.Errorf("operation error STS: GetCallerIdentity: %w", context.DeadlineExceeded), region: "me-south-1", shouldWrap: true, expectedText: "unreachable (timeout)", }, { name: "canceled error is not wrapped (parent cancellation, not region timeout)", err: context.Canceled, region: "me-south-1", shouldWrap: false, expectedText: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := wrapRegionError(tt.err, tt.region) if tt.err == nil { if result != nil { t.Errorf("expected nil, got %v", result) } return } if result == nil { t.Errorf("expected error, got nil") return } resultMsg := result.Error() if tt.shouldWrap { if !strings.Contains(resultMsg, tt.expectedText) { t.Errorf("expected wrapped error to contain '%s', got: %v", tt.expectedText, resultMsg) } // Verify the original error is preserved (wrapped with %w) if !errors.Is(result, tt.err) { t.Errorf("expected wrapped error to contain original error") } } else { if strings.Contains(resultMsg, "region") && strings.Contains(resultMsg, "not enabled") { t.Errorf("expected error not to be wrapped, but it was: %v", resultMsg) } } }) } } ================================================ FILE: cmd/auth_client.go ================================================ package cmd import ( "context" "fmt" "net/http" "time" "github.com/hashicorp/go-retryablehttp" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" ) // newRetryableHTTPClient creates a new HTTP client that uses standard retryablehttp settings func newRetryableHTTPClient() *http.Client { retryableClient := &retryablehttp.Client{ HTTPClient: tracing.HTTPClient(), RetryWaitMin: 1 * time.Second, RetryWaitMax: 10 * time.Second, RetryMax: 5, CheckRetry: retryablehttp.DefaultRetryPolicy, Backoff: retryablehttp.DefaultBackoff, } return retryableClient.StandardClient() } // UnauthenticatedApiKeyClient Returns an apikey client with otel instrumentation // but no authentication. Can only be used for ExchangeKeyForToken func UnauthenticatedApiKeyClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.ApiKeyServiceClient { log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind apikeys API") return sdpconnect.NewApiKeyServiceClient(tracing.HTTPClient(), oi.ApiUrl.String()) } // AuthenticatedBookmarkClient Returns a bookmark client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedBookmarkClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.BookmarksServiceClient { httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind bookmark API") return sdpconnect.NewBookmarksServiceClient(httpClient, oi.ApiUrl.String()) } // AuthenticatedChangesClient Returns a changes client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedChangesClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.ChangesServiceClient { httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind changes API") return sdpconnect.NewChangesServiceClient(httpClient, oi.ApiUrl.String()) } // AuthenticatedConfigurationClient Returns a config client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedConfigurationClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.ConfigurationServiceClient { httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind configuration API") return sdpconnect.NewConfigurationServiceClient(httpClient, oi.ApiUrl.String()) } // AuthenticatedManagementClient Returns a management client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedManagementClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.ManagementServiceClient { httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind management API") return sdpconnect.NewManagementServiceClient(httpClient, oi.ApiUrl.String()) } // AuthenticatedSnapshotsClient Returns a Snapshots client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedSnapshotsClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.SnapshotsServiceClient { httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind snapshot API") return sdpconnect.NewSnapshotsServiceClient(httpClient, oi.ApiUrl.String()) } // AuthenticatedInviteClient Returns a Invite client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedInviteClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.InviteServiceClient { httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind invite API") return sdpconnect.NewInviteServiceClient(httpClient, oi.ApiUrl.String()) } func AuthenticatedSignalsClient(ctx context.Context, oi sdp.OvermindInstance) sdpconnect.SignalServiceClient { httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) log.WithContext(ctx).WithField("apiUrl", oi.ApiUrl).Debug("Connecting to overmind signals API") return sdpconnect.NewSignalServiceClient(httpClient, oi.ApiUrl.String()) } // AuthenticatedClient is a http.Client that will automatically add the required // Authorization header to the request, which is taken from the context that it // is created with. We also always set the X-overmind-interactive header to // false type AuthenticatedTransport struct { from http.RoundTripper ctx context.Context } // NewAuthenticatedClient creates a new AuthenticatedClient from the given // context and http.Client. func NewAuthenticatedClient(ctx context.Context, from *http.Client) *http.Client { return &http.Client{ Transport: &AuthenticatedTransport{ from: from.Transport, ctx: ctx, }, CheckRedirect: from.CheckRedirect, Jar: from.Jar, Timeout: from.Timeout, } } // RoundTrip Adds the Authorization header to the request then call the // underlying roundTripper func (y *AuthenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) { // ask for otel trace linkup req.Header.Set("X-Overmind-Interactive", "false") // Extract auth from the context ctxToken := y.ctx.Value(auth.UserTokenContextKey{}) if ctxToken != nil { token, ok := ctxToken.(string) if ok && token != "" { bearer := fmt.Sprintf("Bearer %v", token) req.Header.Set("Authorization", bearer) } } return y.from.RoundTrip(req) } ================================================ FILE: cmd/auth_client_test.go ================================================ package cmd import ( "context" "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" "os" "sync" "testing" "time" "github.com/hashicorp/go-retryablehttp" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/tracing" ) // testProxyServer is a simple HTTP proxy server for testing type testProxyServer struct { server *httptest.Server requests []*http.Request requestsMu sync.Mutex handler http.HandlerFunc } // startTestProxyServer starts a test HTTP proxy server that logs all requests func startTestProxyServer(t *testing.T) *testProxyServer { proxy := &testProxyServer{ requests: make([]*http.Request, 0), } proxy.handler = func(w http.ResponseWriter, r *http.Request) { proxy.requestsMu.Lock() proxy.requests = append(proxy.requests, r) proxy.requestsMu.Unlock() // Handle CONNECT for WebSocket/TLS if r.Method == http.MethodConnect { hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "Hijacking not supported", http.StatusInternalServerError) return } clientConn, _, err := hijacker.Hijack() if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } defer clientConn.Close() // Connect to target using context dialer := &net.Dialer{} targetConn, err := dialer.DialContext(r.Context(), "tcp", r.Host) if err != nil { _, _ = clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) return } defer targetConn.Close() // Send 200 Connection Established _, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) // Copy data between connections go func() { _, _ = io.Copy(targetConn, clientConn) }() _, _ = io.Copy(clientConn, targetConn) return } // Handle regular HTTP requests - forward to target targetURLStr := r.URL.String() if !r.URL.IsAbs() { // Construct absolute URL from Host header targetURLStr = "http://" + r.Host + r.URL.Path } // Parse and forward request targetURL, err := url.Parse(targetURLStr) if err != nil { http.Error(w, fmt.Sprintf("Invalid URL: %v", err), http.StatusBadRequest) return } // Create new request to forward forwardReq := r.Clone(r.Context()) forwardReq.URL = targetURL forwardReq.RequestURI = "" forwardReq.Header.Del("Proxy-Connection") // Create HTTP client without proxy to avoid proxy loop client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{ DisableKeepAlives: true, }, } resp, err := client.Do(forwardReq) if err != nil { http.Error(w, fmt.Sprintf("Proxy error: %v", err), http.StatusBadGateway) return } defer resp.Body.Close() // Copy response headers for k, v := range resp.Header { for _, val := range v { w.Header().Add(k, val) } } w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) } proxy.server = httptest.NewServer(proxy.handler) t.Cleanup(func() { proxy.server.Close() }) return proxy } // getURL returns the proxy server URL func (p *testProxyServer) getURL() string { return p.server.URL } // setProxyEnv sets HTTP_PROXY and HTTPS_PROXY environment variables // Also clears NO_PROXY to ensure localhost requests go through proxy func setProxyEnv(t *testing.T, proxyURL string) func() { t.Helper() oldHTTPProxy := os.Getenv("HTTP_PROXY") oldHTTPSProxy := os.Getenv("HTTPS_PROXY") oldNoProxy := os.Getenv("NO_PROXY") os.Setenv("HTTP_PROXY", proxyURL) os.Setenv("HTTPS_PROXY", proxyURL) // Clear NO_PROXY to ensure localhost goes through proxy for testing os.Unsetenv("NO_PROXY") return func() { if oldHTTPProxy != "" { os.Setenv("HTTP_PROXY", oldHTTPProxy) } else { os.Unsetenv("HTTP_PROXY") } if oldHTTPSProxy != "" { os.Setenv("HTTPS_PROXY", oldHTTPSProxy) } else { os.Unsetenv("HTTPS_PROXY") } if oldNoProxy != "" { os.Setenv("NO_PROXY", oldNoProxy) } else { os.Unsetenv("NO_PROXY") } } } // TestNewRetryableHTTPClientRespectsProxy tests that newRetryableHTTPClient() // creates an HTTP client that respects HTTP_PROXY environment variables func TestNewRetryableHTTPClientRespectsProxy(t *testing.T) { // Start test proxy server proxy := startTestProxyServer(t) defer setProxyEnv(t, proxy.getURL())() // Create a test HTTP server that will be the target targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) })) defer targetServer.Close() // Create HTTP client using newRetryableHTTPClient() // This uses otelhttp.DefaultClient which should respect proxy settings client := newRetryableHTTPClient() // Verify that the transport's Proxy function is set correctly // Since newRetryableHTTPClient() uses otelhttp.DefaultClient which wraps // http.DefaultTransport, and http.DefaultTransport has Proxy set to // ProxyFromEnvironment, we verify this configuration is preserved. // // We test by verifying that otelhttp.DefaultClient (which is what // newRetryableHTTPClient uses) has the correct proxy configuration. transport := client.Transport if transport == nil { t.Fatal("HTTP client has no transport") } // Get the underlying http.Transport // The transport chain is: retryablehttp.RoundTripper -> otelhttp.Transport -> http.Transport var httpTransport *http.Transport // Unwrap through retryablehttp if rt, ok := transport.(*retryablehttp.RoundTripper); ok && rt.Client != nil && rt.Client.HTTPClient != nil { // otelhttp.Transport wraps http.DefaultTransport, but we can't easily unwrap it // So we'll verify by checking http.DefaultTransport directly, which is what // otelhttp.DefaultClient uses httpTransport = http.DefaultTransport.(*http.Transport) } else { t.Fatalf("Unexpected transport type: %T", transport) } if httpTransport == nil { t.Fatal("Could not get http.Transport") return } // Verify proxy function is set to ProxyFromEnvironment if httpTransport.Proxy == nil { t.Error("Expected Transport.Proxy to be set (ProxyFromEnvironment), but got nil") return } // Test that Proxy function returns a proxy URL // Use localhost.df.overmind-demo.com which resolves to 127.0.0.1 // but won't be bypassed by ProxyFromEnvironment (which only bypasses "localhost") testURL, _ := url.Parse("http://localhost.df.overmind-demo.com/test") testReq, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL.String(), nil) proxyURLReturned, err := httpTransport.Proxy(testReq) if err != nil { t.Errorf("Proxy function returned error: %v", err) return } if proxyURLReturned == nil { t.Error("Expected Proxy function to return proxy URL, but got nil") return } // Verify ProxyFromEnvironment is working by checking it returns a valid proxy URL // We don't check the exact URL match because: // 1. CI environments may already have HTTP_PROXY set // 2. Parallel test execution may cause race conditions // The important thing is that Proxy is configured and returns a valid proxy URL if proxyURLReturned.Host == "" { t.Error("Proxy function returned URL with empty host") } } // TestAuthenticatedChangesClientUsesProxy tests that AuthenticatedChangesClient // uses proxy settings when making HTTP requests by testing the underlying HTTP client func TestAuthenticatedChangesClientUsesProxy(t *testing.T) { // Start test proxy server proxy := startTestProxyServer(t) defer setProxyEnv(t, proxy.getURL())() // Create context with auth token ctx := context.WithValue(context.Background(), auth.UserTokenContextKey{}, "test-token") // Create AuthenticatedChangesClient - this uses newRetryableHTTPClient() // which wraps otelhttp.DefaultClient that should respect proxy settings // We'll test the underlying HTTP client directly httpClient := NewAuthenticatedClient(ctx, newRetryableHTTPClient()) // Verify the transport chain preserves proxy settings // AuthenticatedTransport wraps newRetryableHTTPClient().Transport // which uses otelhttp.DefaultClient -> http.DefaultTransport transport := httpClient.Transport if transport == nil { t.Fatal("HTTP client has no transport") } // Verify it's AuthenticatedTransport wrapping the retryable client if authTransport, ok := transport.(*AuthenticatedTransport); ok { // Get the underlying transport (should be retryablehttp.RoundTripper) underlyingTransport := authTransport.from if underlyingTransport == nil { t.Fatal("AuthenticatedTransport has no underlying transport") } // Verify it wraps retryablehttp which wraps otelhttp.DefaultClient if rt, ok := underlyingTransport.(*retryablehttp.RoundTripper); ok { if rt.Client == nil || rt.Client.HTTPClient == nil { t.Error("retryablehttp.RoundTripper missing HTTPClient") } else { // Verify otelhttp.DefaultClient uses http.DefaultTransport // which has ProxyFromEnvironment set. ProxyFromEnvironment reads // environment variables at request time, so it should use our test proxy. // Note: Since tests run in parallel, we can't reliably check the exact proxy URL // (another parallel test might have set HTTP_PROXY), but we can verify // that ProxyFromEnvironment is configured and returns a proxy URL. httpTransport := http.DefaultTransport.(*http.Transport) if httpTransport.Proxy == nil { t.Error("Expected http.DefaultTransport.Proxy to be set (ProxyFromEnvironment)") } else { // Test proxy function // Use localhost.df.overmind-demo.com which resolves to 127.0.0.1 // but won't be bypassed by ProxyFromEnvironment (which only bypasses "localhost") testURL, _ := url.Parse("http://localhost.df.overmind-demo.com/test") testReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, testURL.String(), nil) proxyURLReturned, err := httpTransport.Proxy(testReq) if err != nil { t.Errorf("Proxy function returned error: %v", err) } else if proxyURLReturned == nil { t.Error("Expected Proxy function to return proxy URL, but got nil") } else { // Verify that ProxyFromEnvironment is working by checking it returns a proxy URL // Since tests run in parallel, we can't check the exact URL, but we can verify // it's reading from environment variables correctly if proxyURLReturned.Host == "" { t.Error("Proxy function returned URL with empty host") } // Verify it's reading from HTTP_PROXY (should match our proxy or another parallel test's proxy) // Both are valid - the important thing is that ProxyFromEnvironment is configured } } } } else { t.Errorf("Expected *retryablehttp.RoundTripper, got %T", underlyingTransport) } } else { t.Errorf("Expected *AuthenticatedTransport, got %T", transport) } } // TestWebSocketDialerUsesProxy tests that WebSocket connections use proxy // settings when HTTP_PROXY is set. WebSocket connections use HTTP CONNECT // method through the proxy. func TestWebSocketDialerUsesProxy(t *testing.T) { // Start test proxy server proxy := startTestProxyServer(t) defer setProxyEnv(t, proxy.getURL())() // Create a WebSocket server using localhost.df.overmind-demo.com // which resolves to 127.0.0.1 but won't be bypassed by ProxyFromEnvironment wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Upgrade") == "websocket" { // Simple WebSocket upgrade response w.Header().Set("Upgrade", "websocket") w.Header().Set("Connection", "Upgrade") w.WriteHeader(http.StatusSwitchingProtocols) } else { w.WriteHeader(http.StatusOK) } })) defer wsServer.Close() // Convert HTTP server URL to use localhost.df.overmind-demo.com // Parse the server URL and replace hostname serverURL, err := url.Parse(wsServer.URL) if err != nil { t.Fatalf("Failed to parse server URL: %v", err) } serverURL.Host = "localhost.df.overmind-demo.com:" + serverURL.Port() wsURL := "ws://" + serverURL.Host + serverURL.Path // Create context with auth token ctx := context.WithValue(context.Background(), auth.UserTokenContextKey{}, "test-token") // Create HTTP client that should use proxy // This is what sdpws.DialBatch uses - NewAuthenticatedClient with otelhttp.DefaultClient httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) // Try to dial WebSocket - this should use HTTP CONNECT through proxy // Note: We'll use the websocket package directly like sdpws does // Since we can't easily test sdpws.DialBatch without a full gateway, // we'll test that the HTTP client would use proxy for CONNECT requests // by verifying the proxy configuration // Actually, let's test by making a CONNECT request manually // to verify the proxy is used proxyURL, err := url.Parse(proxy.getURL()) if err != nil { t.Fatalf("Failed to parse proxy URL: %v", err) } // Parse the WebSocket URL targetURL, err := url.Parse(wsURL) if err != nil { t.Fatalf("Failed to parse WebSocket URL: %v", err) } // The HTTP client should use the proxy for CONNECT requests // We can verify this by checking if ProxyFromEnvironment returns the proxy transport := httpClient.Transport if transport == nil { t.Fatal("HTTP client has no transport") } // Get the underlying transport to check proxy configuration // Since we're using AuthenticatedTransport wrapping otelhttp.Transport wrapping http.DefaultTransport, // we need to unwrap to check the proxy function baseTransport := transport for range 10 { // Limit iterations to prevent infinite loops // Check if we've reached http.Transport if _, ok := baseTransport.(*http.Transport); ok { break } // Try to unwrap further var nextTransport http.RoundTripper switch t := baseTransport.(type) { case *AuthenticatedTransport: nextTransport = t.from case interface{ Unwrap() http.RoundTripper }: nextTransport = t.Unwrap() default: // Can't unwrap further break } // Prevent infinite loops if nextTransport == nil || nextTransport == baseTransport { break } baseTransport = nextTransport } // Check if it's http.Transport and verify proxy function if httpTransport, ok := baseTransport.(*http.Transport); ok { if httpTransport.Proxy == nil { t.Error("Expected Transport.Proxy to be set (ProxyFromEnvironment), but got nil") } else { // Test that Proxy function returns the proxy URL // Use localhost.df.overmind-demo.com which resolves to 127.0.0.1 // but won't be bypassed by ProxyFromEnvironment testReq, err := http.NewRequestWithContext(context.Background(), http.MethodGet, targetURL.String(), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } proxyURLReturned, err := httpTransport.Proxy(testReq) if err != nil { t.Errorf("Proxy function returned error: %v", err) } else if proxyURLReturned == nil { t.Error("Expected Proxy function to return proxy URL for localhost.df.overmind-demo.com, but got nil") } else if proxyURLReturned.String() != proxyURL.String() { t.Errorf("Expected proxy URL %s, got %s", proxyURL.String(), proxyURLReturned.String()) } } } // Verify proxy received at least one CONNECT request (from the Proxy function check) // Actually, the Proxy function check doesn't make a real request, so we need to // make an actual request to verify time.Sleep(100 * time.Millisecond) // We can't easily test WebSocket CONNECT without a real connection attempt, // but we've verified the proxy configuration is correct } ================================================ FILE: cmd/bookmarks.go ================================================ /* Copyright © 2024 NAME HERE */ package cmd import ( "github.com/spf13/cobra" ) // bookmarksCmd represents the bookmarks command var bookmarksCmd = &cobra.Command{ Use: "bookmarks", GroupID: "api", Short: "Interact with the bookmarks that were created in the Explore view", Long: `A bookmark in Overmind is a set of queries that are stored together and can be executed as a single block.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } func init() { rootCmd.AddCommand(bookmarksCmd) addAPIFlags(bookmarksCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // bookmarksCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // bookmarksCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } ================================================ FILE: cmd/bookmarks_create_bookmark.go ================================================ package cmd import ( "encoding/json" "fmt" "io" "os" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // createBookmarkCmd represents the get-bookmark command var createBookmarkCmd = &cobra.Command{ Use: "create-bookmark [--file FILE]", Short: "Creates a bookmark from JSON.", PreRun: PreRunSetup, RunE: CreateBookmark, } func CreateBookmark(cmd *cobra.Command, args []string) error { ctx := cmd.Context() var err error in := os.Stdin if viper.GetString("file") != "" { in, err = os.Open(viper.GetString("file")) if err != nil { return loggedError{ err: err, fields: log.Fields{ "file": viper.GetString("file"), }, message: "failed to open input", } } } ctx, oi, _, err := login(ctx, cmd, []string{"changes:write"}, nil) if err != nil { return err } contents, err := io.ReadAll(in) if err != nil { return loggedError{ err: err, fields: log.Fields{"file": viper.GetString("file")}, message: "failed to read file", } } msg := sdp.BookmarkProperties{} err = json.Unmarshal(contents, &msg) if err != nil { return loggedError{ err: err, message: "failed to parse input", } } client := AuthenticatedBookmarkClient(ctx, oi) response, err := client.CreateBookmark(ctx, &connect.Request[sdp.CreateBookmarkRequest]{ Msg: &sdp.CreateBookmarkRequest{ Properties: &msg, }, }) if err != nil { return loggedError{ err: err, message: "failed to get bookmark", } } log.WithContext(ctx).WithFields(log.Fields{ "bookmark-uuid": uuid.UUID(response.Msg.GetBookmark().GetMetadata().GetUUID()), "bookmark-created": response.Msg.GetBookmark().GetMetadata().GetCreated(), "bookmark-name": response.Msg.GetBookmark().GetProperties().GetName(), "bookmark-description": response.Msg.GetBookmark().GetProperties().GetDescription(), }).Info("created bookmark") for _, q := range response.Msg.GetBookmark().GetProperties().GetQueries() { log.WithContext(ctx).WithFields(log.Fields{ "bookmark-query": q, }).Info("created bookmark query") } b, err := json.MarshalIndent(response.Msg.GetBookmark().GetProperties(), "", " ") if err != nil { log.Infof("Error rendering bookmark: %v", err) } else { fmt.Println(string(b)) } return nil } func init() { bookmarksCmd.AddCommand(createBookmarkCmd) createBookmarkCmd.PersistentFlags().String("file", "", "JSON formatted file to read bookmark. (defaults to stdin)") } ================================================ FILE: cmd/bookmarks_get_affected_bookmarks.go ================================================ package cmd import ( "fmt" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // getAffectedBookmarksCmd represents the get-affected-bookmarks command var getAffectedBookmarksCmd = &cobra.Command{ Use: "get-affected-bookmarks --snapshot-uuid ID --bookmark-uuids ID,ID,ID", Short: "Calculates the bookmarks that would be overlapping with a snapshot.", PreRun: PreRunSetup, RunE: GetAffectedBookmarks, } func GetAffectedBookmarks(cmd *cobra.Command, args []string) error { ctx := cmd.Context() snapshotUuid, err := uuid.Parse(viper.GetString("snapshot-uuid")) if err != nil { return flagError{usage: fmt.Sprintf("invalid --snapshot-uuid value '%v': %v\n\n%v", viper.GetString("snapshot-uuid"), err, cmd.UsageString())} } uuidStrings := viper.GetStringSlice("bookmark-uuids") bookmarkUuids := [][]byte{} for _, s := range uuidStrings { bookmarkUuid, err := uuid.Parse(s) if err != nil { return flagError{usage: fmt.Sprintf("invalid --bookmark-uuids value '%v': %v\n\n%v", bookmarkUuid, err, cmd.UsageString())} } bookmarkUuids = append(bookmarkUuids, bookmarkUuid[:]) } ctx, oi, _, err := login(ctx, cmd, []string{"changes:read"}, nil) if err != nil { return err } client := AuthenticatedBookmarkClient(ctx, oi) response, err := client.GetAffectedBookmarks(ctx, &connect.Request[sdp.GetAffectedBookmarksRequest]{ Msg: &sdp.GetAffectedBookmarksRequest{ SnapshotUUID: snapshotUuid[:], BookmarkUUIDs: bookmarkUuids, }, }) if err != nil { return loggedError{ err: err, message: "Failed to get affected bookmarks.", } } for _, u := range response.Msg.GetBookmarkUUIDs() { bookmarkUuid := uuid.UUID(u) log.WithContext(ctx).WithFields(log.Fields{ "uuid": bookmarkUuid, }).Info("found affected bookmark") } return nil } func init() { bookmarksCmd.AddCommand(getAffectedBookmarksCmd) getAffectedBookmarksCmd.PersistentFlags().String("snapshot-uuid", "", "The UUID of the snapshot that should be checked.") getAffectedBookmarksCmd.PersistentFlags().String("bookmark-uuids", "", "A comma separated list of UUIDs of the potentially affected bookmarks.") } ================================================ FILE: cmd/bookmarks_get_bookmark.go ================================================ package cmd import ( "encoding/json" "fmt" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // getBookmarkCmd represents the get-bookmark command var getBookmarkCmd = &cobra.Command{ Use: "get-bookmark --uuid ID", Short: "Displays the contents of a bookmark.", PreRun: PreRunSetup, RunE: GetBookmark, } func GetBookmark(cmd *cobra.Command, args []string) error { ctx := cmd.Context() bookmarkUuid, err := uuid.Parse(viper.GetString("uuid")) if err != nil { return flagError{ usage: fmt.Sprintf("invalid --uuid value '%v' (%v)\n\n%v", viper.GetString("uuid"), err, cmd.UsageString()), } } ctx, oi, _, err := login(ctx, cmd, []string{"changes:read"}, nil) if err != nil { return err } client := AuthenticatedBookmarkClient(ctx, oi) response, err := client.GetBookmark(ctx, &connect.Request[sdp.GetBookmarkRequest]{ Msg: &sdp.GetBookmarkRequest{ UUID: bookmarkUuid[:], }, }) if err != nil { return loggedError{ err: err, message: "failed to get bookmark", } } log.WithContext(ctx).WithFields(log.Fields{ "bookmark-uuid": uuid.UUID(response.Msg.GetBookmark().GetMetadata().GetUUID()), "bookmark-created": response.Msg.GetBookmark().GetMetadata().GetCreated().AsTime(), "bookmark-name": response.Msg.GetBookmark().GetProperties().GetName(), "bookmark-description": response.Msg.GetBookmark().GetProperties().GetDescription(), }).Info("found bookmark") b, err := json.MarshalIndent(response.Msg.GetBookmark().ToMap(), "", " ") if err != nil { log.Infof("Error rendering bookmark: %v", err) } else { fmt.Println(string(b)) } return nil } func init() { bookmarksCmd.AddCommand(getBookmarkCmd) getBookmarkCmd.PersistentFlags().String("uuid", "", "The UUID of the bookmark that should be displayed.") } ================================================ FILE: cmd/changes.go ================================================ package cmd import ( "github.com/spf13/cobra" ) // changesCmd represents the changes command var changesCmd = &cobra.Command{ Use: "changes", GroupID: "api", Short: "Create, update and delete changes in Overmind", Long: `Manage changes that are being tracked using Overmind. NOTE: It is probably easier to use our IaC wrappers such as 'overmind terraform plan' rather than using these commands directly, but they are provided for flexibility.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } func init() { rootCmd.AddCommand(changesCmd) addAPIFlags(changesCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // changesCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // changesCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } ================================================ FILE: cmd/changes_end_change.go ================================================ package cmd import ( "time" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // endChangeCmd represents the end-change command var endChangeCmd = &cobra.Command{ Use: "end-change --uuid ID", Short: "Finishes the specified change. Call this just after you finished the change. This will store a snapshot of the current system state for later reference.", PreRun: PreRunSetup, RunE: EndChange, } func EndChange(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"changes:write"}, nil) if err != nil { return err } // Resolve the change UUID without checking status. The server-side // EndChangeSimple handles status validation atomically and queues end-change // behind start-change if needed, avoiding the TOCTOU race where status // transitions between client-side checks. changeUuid, err := getChangeUUID(ctx, oi, viper.GetString("ticket-link")) if err != nil { return loggedError{ err: err, message: "failed to identify change", } } lf := log.Fields{"uuid": changeUuid.String()} // Call the simple RPC (enqueues a background job and returns immediately) client := AuthenticatedChangesClient(ctx, oi) resp, err := client.EndChangeSimple(ctx, &connect.Request[sdp.EndChangeRequest]{ Msg: &sdp.EndChangeRequest{ ChangeUUID: changeUuid[:], }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to end change", } } queuedAfterStart := resp.Msg.GetQueuedAfterStart() waitForSnapshot := viper.GetBool("wait-for-snapshot") if waitForSnapshot { // Poll until change status is DONE log.WithContext(ctx).WithFields(lf).Info("waiting for snapshot to complete") for { changeResp, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ Msg: &sdp.GetChangeRequest{ UUID: changeUuid[:], }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to get change status", } } if changeResp.Msg.GetChange().GetMetadata().GetStatus() == sdp.ChangeStatus_CHANGE_STATUS_DONE { break } log.WithContext(ctx).WithFields(lf).WithFields(log.Fields{ "status": changeResp.Msg.GetChange().GetMetadata().GetStatus().String(), }).Info("waiting for snapshot") time.Sleep(3 * time.Second) // check if the context is cancelled if ctx.Err() != nil { return loggedError{ err: ctx.Err(), fields: lf, message: "context cancelled", } } } log.WithContext(ctx).WithFields(lf).Info("finished change") } else { if queuedAfterStart { log.WithContext(ctx).WithFields(lf).Info("change end queued (will run after start-change completes)") } else { log.WithContext(ctx).WithFields(lf).Info("change end initiated (processing in background)") } } return nil } func init() { changesCmd.AddCommand(endChangeCmd) addChangeUuidFlags(endChangeCmd) endChangeCmd.PersistentFlags().Bool("wait-for-snapshot", false, "Wait for the snapshot to complete before returning. Defaults to false.") } ================================================ FILE: cmd/changes_get_change.go ================================================ package cmd import ( _ "embed" "fmt" "slices" "strings" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // getChangeCmd represents the get-change command var getChangeCmd = &cobra.Command{ Use: "get-change {--uuid ID | --change https://app.overmind.tech/changes/c772d072-6b0b-4763-b7c5-ff5069beed4c}", Short: "Displays the contents of a change.", PreRun: PreRunSetup, RunE: GetChange, } func GetChange(cmd *cobra.Command, args []string) error { ctx := cmd.Context() app := viper.GetString("app") // Validate status flag status, err := validateChangeStatus(viper.GetString("status")) if err != nil { return err } riskLevels := []sdp.Risk_Severity{} for _, level := range viper.GetStringSlice("risk-levels") { switch level { case "high": riskLevels = append(riskLevels, sdp.Risk_SEVERITY_HIGH) case "medium": riskLevels = append(riskLevels, sdp.Risk_SEVERITY_MEDIUM) case "low": riskLevels = append(riskLevels, sdp.Risk_SEVERITY_LOW) default: return flagError{fmt.Sprintf("invalid --risk-levels value '%v', allowed values are 'high', 'medium', 'low'", level)} } } slices.Sort(riskLevels) riskLevels = slices.Compact(riskLevels) if len(riskLevels) == 0 { riskLevels = []sdp.Risk_Severity{sdp.Risk_SEVERITY_HIGH, sdp.Risk_SEVERITY_MEDIUM, sdp.Risk_SEVERITY_LOW} } ctx, oi, _, err := login(ctx, cmd, []string{"changes:read"}, nil) if err != nil { return err } changeUuid, err := getChangeUUIDAndCheckStatus(ctx, oi, status, viper.GetString("ticket-link"), true) if err != nil { return loggedError{ err: err, message: "failed to identify change", } } lf := log.Fields{ "uuid": changeUuid.String(), "change-url": viper.GetString("change-url"), } client := AuthenticatedChangesClient(ctx, oi) if viper.GetBool("wait") { if err := waitForChangeAnalysis(ctx, client, changeUuid, lf); err != nil { return err } } app, _ = strings.CutSuffix(app, "/") // get the change var format sdp.ChangeOutputFormat switch viper.GetString("format") { case "json": format = sdp.ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_JSON case "markdown": format = sdp.ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_MARKDOWN default: return fmt.Errorf("Unknown output format. Please select 'json' or 'markdown'") } changeRes, err := client.GetChangeSummary(ctx, &connect.Request[sdp.GetChangeSummaryRequest]{ Msg: &sdp.GetChangeSummaryRequest{ UUID: changeUuid[:], ChangeOutputFormat: format, RiskSeverityFilter: riskLevels, AppURL: app, }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to get change summary", } } log.WithContext(ctx).WithFields(log.Fields{ "ovm.change.uuid": changeUuid.String(), }).Debug("found change") fmt.Println(changeRes.Msg.GetChange()) return nil } // validateChangeStatus validates that the provided status string is a valid ChangeStatus func validateChangeStatus(statusStr string) (sdp.ChangeStatus, error) { // Define valid status values (excluding UNSPECIFIED and PROCESSING as they are not typically used) validStatuses := map[string]sdp.ChangeStatus{ "CHANGE_STATUS_DEFINING": sdp.ChangeStatus_CHANGE_STATUS_DEFINING, "CHANGE_STATUS_HAPPENING": sdp.ChangeStatus_CHANGE_STATUS_HAPPENING, "CHANGE_STATUS_DONE": sdp.ChangeStatus_CHANGE_STATUS_DONE, } if status, exists := validStatuses[statusStr]; exists { return status, nil } // Build list of valid status names for error message validNames := make([]string, 0, len(validStatuses)) for name := range validStatuses { validNames = append(validNames, name) } return sdp.ChangeStatus_CHANGE_STATUS_UNSPECIFIED, flagError{ fmt.Sprintf("invalid --status value '%s', allowed values are: %s", statusStr, strings.Join(validNames, ", ")), } } func init() { changesCmd.AddCommand(getChangeCmd) addAPIFlags(getChangeCmd) addChangeUuidFlags(getChangeCmd) getChangeCmd.PersistentFlags().String("status", "CHANGE_STATUS_DEFINING", "The expected status of the change. Use this with --ticket-link to get the first change with that status for a given ticket link. Allowed values: CHANGE_STATUS_DEFINING (ready for analysis/analysis in progress), CHANGE_STATUS_HAPPENING (deployment in progress), CHANGE_STATUS_DONE (deployment completed)") getChangeCmd.PersistentFlags().String("frontend", "", "The frontend base URL") cobra.CheckErr(getChangeCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.")) getChangeCmd.PersistentFlags().Bool("wait", true, "Wait for analysis to complete before returning. Set to false to return immediately with the current status.") getChangeCmd.PersistentFlags().String("format", "json", "How to render the change. Possible values: json, markdown") getChangeCmd.PersistentFlags().StringSlice("risk-levels", []string{"high", "medium", "low"}, "Only show changes with the specified risk levels. Allowed values: high, medium, low") } ================================================ FILE: cmd/changes_get_change_test.go ================================================ package cmd import ( "errors" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestGetChangeCmdHasWaitFlag(t *testing.T) { t.Parallel() flag := getChangeCmd.PersistentFlags().Lookup("wait") if flag == nil { t.Error("Expected wait flag to be registered on get-change command") return } if flag.DefValue != "true" { t.Errorf("Expected wait flag default value to be 'true', got %q", flag.DefValue) } } func TestValidateChangeStatus(t *testing.T) { tests := []struct { name string statusStr string expected sdp.ChangeStatus expectError bool }{ { name: "valid defining status", statusStr: "CHANGE_STATUS_DEFINING", expected: sdp.ChangeStatus_CHANGE_STATUS_DEFINING, expectError: false, }, { name: "valid happening status", statusStr: "CHANGE_STATUS_HAPPENING", expected: sdp.ChangeStatus_CHANGE_STATUS_HAPPENING, expectError: false, }, { name: "valid done status", statusStr: "CHANGE_STATUS_DONE", expected: sdp.ChangeStatus_CHANGE_STATUS_DONE, expectError: false, }, { name: "invalid status - empty string", statusStr: "", expected: sdp.ChangeStatus_CHANGE_STATUS_UNSPECIFIED, expectError: true, }, { name: "invalid status - random string", statusStr: "INVALID_STATUS", expected: sdp.ChangeStatus_CHANGE_STATUS_UNSPECIFIED, expectError: true, }, { name: "invalid status - unspecified", statusStr: "CHANGE_STATUS_UNSPECIFIED", expected: sdp.ChangeStatus_CHANGE_STATUS_UNSPECIFIED, expectError: true, }, { name: "invalid status - lowercase", statusStr: "change_status_defining", expected: sdp.ChangeStatus_CHANGE_STATUS_UNSPECIFIED, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := validateChangeStatus(tt.statusStr) if tt.expectError { if err == nil { t.Errorf("validateChangeStatus() expected error but got none") } // Check that it returns a flagError var fError flagError if !errors.As(err, &fError) { t.Errorf("validateChangeStatus() expected flagError but got %T", err) } } else { if err != nil { t.Errorf("validateChangeStatus() unexpected error: %v", err) } } if result != tt.expected { t.Errorf("validateChangeStatus() = %v, expected %v", result, tt.expected) } }) } } ================================================ FILE: cmd/changes_get_signals.go ================================================ package cmd import ( _ "embed" "fmt" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // getSignalsCmd represents the get-signals command var getSignalsCmd = &cobra.Command{ Use: "get-signals {--uuid ID | --change https://app.overmind.tech/changes/c772d072-6b0b-4763-b7c5-ff5069beed4c}", Short: "Displays all signals for a change including overview, item, and custom signals.", Long: `Displays all signals for a change including: - Overall signal for the change - Top level signals for each category - Routineness signals per item - Individual custom signals This provides more detailed signal information than get-change.`, PreRun: PreRunSetup, RunE: GetSignals, } func GetSignals(cmd *cobra.Command, args []string) error { ctx := cmd.Context() // Validate status flag status, err := validateChangeStatus(viper.GetString("status")) if err != nil { return err } ctx, oi, _, err := login(ctx, cmd, []string{"changes:read"}, nil) if err != nil { return err } changeUuid, err := getChangeUUIDAndCheckStatus(ctx, oi, status, viper.GetString("ticket-link"), true) if err != nil { return loggedError{ err: err, message: "failed to identify change", } } lf := log.Fields{ "uuid": changeUuid.String(), "change-url": viper.GetString("change"), } client := AuthenticatedChangesClient(ctx, oi) if err := waitForChangeAnalysis(ctx, client, changeUuid, lf); err != nil { return err } // get the change signals var format sdp.ChangeOutputFormat switch viper.GetString("format") { case "json": format = sdp.ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_JSON case "markdown": format = sdp.ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_MARKDOWN default: return fmt.Errorf("Unknown output format. Please select 'json' or 'markdown'") } signalsRes, err := client.GetChangeSignals(ctx, &connect.Request[sdp.GetChangeSignalsRequest]{ Msg: &sdp.GetChangeSignalsRequest{ UUID: changeUuid[:], ChangeOutputFormat: format, }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to get change signals", } } log.WithContext(ctx).WithFields(log.Fields{ "ovm.change.uuid": changeUuid.String(), }).Debug("found change signals") fmt.Println(signalsRes.Msg.GetSignals()) return nil } func init() { changesCmd.AddCommand(getSignalsCmd) addAPIFlags(getSignalsCmd) addChangeUuidFlags(getSignalsCmd) getSignalsCmd.PersistentFlags().String("status", "CHANGE_STATUS_DEFINING", "The expected status of the change. Use this with --ticket-link to get the first change with that status for a given ticket link. Allowed values: CHANGE_STATUS_DEFINING (ready for analysis/analysis in progress), CHANGE_STATUS_HAPPENING (deployment in progress), CHANGE_STATUS_DONE (deployment completed)") getSignalsCmd.PersistentFlags().String("frontend", "", "The frontend base URL") cobra.CheckErr(getSignalsCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.")) getSignalsCmd.PersistentFlags().String("format", "json", "How to render the signals. Possible values: json, markdown") } ================================================ FILE: cmd/changes_get_signals_test.go ================================================ package cmd import ( "testing" "github.com/spf13/viper" ) func TestGetSignalsCmd(t *testing.T) { // Test that the command is properly registered if getSignalsCmd == nil { t.Fatal("getSignalsCmd is nil") } if getSignalsCmd.Use == "" { t.Error("getSignalsCmd.Use should not be empty") } // Test that required flags are set formatFlag := getSignalsCmd.PersistentFlags().Lookup("format") if formatFlag == nil { t.Error("format flag should be defined") } else if formatFlag.DefValue != "json" { t.Errorf("format flag default should be 'json', got '%s'", formatFlag.DefValue) } statusFlag := getSignalsCmd.PersistentFlags().Lookup("status") if statusFlag == nil { t.Error("status flag should be defined") } } func TestGetSignalsFormat(t *testing.T) { tests := []struct { name string format string shouldError bool }{ { name: "json format", format: "json", shouldError: false, }, { name: "markdown format", format: "markdown", shouldError: false, }, { name: "invalid format", format: "xml", shouldError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { viper.Set("format", tt.format) format := viper.GetString("format") isValid := format == "json" || format == "markdown" if tt.shouldError && isValid { t.Error("Expected format validation to fail, but it passed") } if !tt.shouldError && !isValid { t.Error("Expected format validation to pass, but it failed") } }) } } ================================================ FILE: cmd/changes_list_changes.go ================================================ package cmd import ( "context" "encoding/json" "fmt" "os" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // listChangesCmd represents the get-change command var listChangesCmd = &cobra.Command{ Use: "list-changes --dir ./output", Short: "Displays the contents of a change.", PreRun: PreRunSetup, RunE: ListChanges, } func ListChanges(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"changes:read"}, nil) if err != nil { return err } snapshots := AuthenticatedSnapshotsClient(ctx, oi) bookmarks := AuthenticatedBookmarkClient(ctx, oi) changes := AuthenticatedChangesClient(ctx, oi) response, err := changes.ListChanges(ctx, &connect.Request[sdp.ListChangesRequest]{ Msg: &sdp.ListChangesRequest{}, }) if err != nil { return loggedError{ err: err, message: "failed to list changes", } } for _, change := range response.Msg.GetChanges() { changeUuid := uuid.UUID(change.GetMetadata().GetUUID()) log.WithContext(ctx).WithFields(log.Fields{ "ovm.change.uuid": changeUuid, "change-created": change.GetMetadata().GetCreatedAt().AsTime(), "change-status": change.GetMetadata().GetStatus().String(), "change-name": change.GetProperties().GetTitle(), "change-description": change.GetProperties().GetDescription(), }).Debug("found change") b, err := json.MarshalIndent(change.ToMap(), "", " ") if err != nil { return loggedError{ err: err, message: "Error rendering change", } } err = printJson(ctx, b, "change", changeUuid.String(), cmd) if err != nil { return err } if viper.GetBool("fetch-data") { ciUuid := uuid.UUID(change.GetProperties().GetChangingItemsBookmarkUUID()) if ciUuid != uuid.Nil { changingItems, err := bookmarks.GetBookmark(ctx, &connect.Request[sdp.GetBookmarkRequest]{ Msg: &sdp.GetBookmarkRequest{ UUID: ciUuid[:], }, }) // continue processing if item not found if connect.CodeOf(err) != connect.CodeNotFound { if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "changing-items-uuid": ciUuid.String(), }, message: "failed to get ChangingItemsBookmark", } } b, err := json.MarshalIndent(changingItems.Msg.GetBookmark().ToMap(), "", " ") if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "changing-items-uuid": ciUuid.String(), }, message: "Error rendering changing items bookmark", } } err = printJson(ctx, b, "changing-items", ciUuid.String(), cmd) if err != nil { return err } } } brUuid := uuid.UUID(change.GetProperties().GetBlastRadiusSnapshotUUID()) if brUuid != uuid.Nil { brSnap, err := snapshots.GetSnapshot(ctx, &connect.Request[sdp.GetSnapshotRequest]{ Msg: &sdp.GetSnapshotRequest{ UUID: brUuid[:], }, }) // continue processing if item not found if connect.CodeOf(err) != connect.CodeNotFound { if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "blast-radius-uuid": brUuid.String(), }, message: "failed to get BlastRadiusSnapshot", } } b, err := json.MarshalIndent(brSnap.Msg.GetSnapshot().ToMap(), "", " ") if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "blast-radius-uuid": brUuid.String(), }, message: "Error rendering blast radius snapshot", } } err = printJson(ctx, b, "blast-radius", brUuid.String(), cmd) if err != nil { return err } } } sbsUuid := uuid.UUID(change.GetProperties().GetSystemBeforeSnapshotUUID()) if sbsUuid != uuid.Nil { brSnap, err := snapshots.GetSnapshot(ctx, &connect.Request[sdp.GetSnapshotRequest]{ Msg: &sdp.GetSnapshotRequest{ UUID: sbsUuid[:], }, }) // continue processing if item not found if connect.CodeOf(err) != connect.CodeNotFound { if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "system-before-uuid": sbsUuid.String(), }, message: "failed to get SystemBeforeSnapshot", } } b, err := json.MarshalIndent(brSnap.Msg.GetSnapshot().ToMap(), "", " ") if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "system-before-uuid": sbsUuid.String(), }, message: "Error rendering system before snapshot", } } err = printJson(ctx, b, "system-before", sbsUuid.String(), cmd) if err != nil { return err } } } sasUuid := uuid.UUID(change.GetProperties().GetSystemAfterSnapshotUUID()) if sasUuid != uuid.Nil { brSnap, err := snapshots.GetSnapshot(ctx, &connect.Request[sdp.GetSnapshotRequest]{ Msg: &sdp.GetSnapshotRequest{ UUID: sasUuid[:], }, }) // continue processing if item not found if connect.CodeOf(err) != connect.CodeNotFound { if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "system-after-uuid": sasUuid.String(), }, message: "failed to get SystemAfterSnapshot", } } b, err := json.MarshalIndent(brSnap.Msg.GetSnapshot().ToMap(), "", " ") if err != nil { return loggedError{ err: err, fields: log.Fields{ "ovm.change.uuid": changeUuid, "system-after-uuid": sasUuid.String(), }, message: "Error rendering system after snapshot", } } err = printJson(ctx, b, "system-after", sasUuid.String(), cmd) if err != nil { return err } } } } } return nil } func printJson(_ context.Context, b []byte, prefix, id string, cmd *cobra.Command) error { switch viper.GetString("format") { case "json": fmt.Println(string(b)) case "files": dir := viper.GetString("dir") if dir == "" { return flagError{fmt.Sprintf("need --dir value to write to files\n\n%v", cmd.UsageString())} } // attempt to create the directory err := os.MkdirAll(dir, 0755) if err != nil { return loggedError{ err: err, fields: log.Fields{ "output-dir": dir, }, message: "failed to create output directory", } } // write the change to a file fileName := fmt.Sprintf("%v/%v-%v.json", dir, prefix, id) file, err := os.Create(fileName) if err != nil { return loggedError{ err: err, fields: log.Fields{ "prefix": prefix, "id": id, "output-dir": dir, "output-file": fileName, }, message: "failed to create file", } } _, err = file.Write(b) if err != nil { return loggedError{ err: err, fields: log.Fields{ "prefix": prefix, "id": id, "output-dir": dir, "output-file": fileName, }, message: "failed to write file", } } } return nil } func init() { changesCmd.AddCommand(listChangesCmd) listChangesCmd.PersistentFlags().String("format", "files", "How to render the change. Possible values: files, json") listChangesCmd.PersistentFlags().String("dir", "./output", "A directory name to use for rendering changes when using the 'files' format") listChangesCmd.PersistentFlags().Bool("fetch-data", false, "also fetch the blast radius and system state snapshots for each change") } ================================================ FILE: cmd/changes_start_analysis.go ================================================ package cmd import ( "fmt" "strings" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // startAnalysisCmd represents the start-analysis command var startAnalysisCmd = &cobra.Command{ Use: "start-analysis {--ticket-link URL | --uuid ID | --change URL}", Short: "Triggers analysis on a change with previously stored planned changes", Long: `Triggers analysis on a change that has previously stored planned changes. This command is used in multi-plan workflows (e.g., Atlantis parallel planning) where multiple terraform plans are submitted independently using 'submit-plan --no-start', and then analysis is triggered once all plans are submitted. The change must be in DEFINING status and must have at least one planned change stored.`, PreRun: PreRunSetup, RunE: StartAnalysis, } func StartAnalysis(cmd *cobra.Command, args []string) error { ctx := cmd.Context() app := viper.GetString("app") ctx, oi, _, err := login(ctx, cmd, []string{"changes:write", "sources:read"}, nil) if err != nil { return err } lf := log.Fields{} changeUUID, err := getChangeUUIDAndCheckStatus(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, viper.GetString("ticket-link"), true) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to identify change", } } lf["change"] = changeUUID.String() analysisConfig, err := buildAnalysisConfig(ctx, lf) if err != nil { return err } client := AuthenticatedChangesClient(ctx, oi) resp, err := client.StartChangeAnalysis(ctx, &connect.Request[sdp.StartChangeAnalysisRequest]{ Msg: &sdp.StartChangeAnalysisRequest{ ChangeUUID: changeUUID[:], ChangingItems: nil, // uses pre-stored items from AddPlannedChanges BlastRadiusConfigOverride: analysisConfig.BlastRadiusConfig, RoutineChangesConfigOverride: analysisConfig.RoutineChangesConfig, GithubOrganisationProfileOverride: analysisConfig.GithubOrgProfile, Knowledge: analysisConfig.KnowledgeFiles, PostGithubComment: viper.GetBool("comment"), }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to start change analysis", } } app, _ = strings.CutSuffix(app, "/") changeUrl := fmt.Sprintf("%v/changes/%v?utm_source=cli&cli_version=%v", app, changeUUID, tracing.Version()) log.WithContext(ctx).WithFields(lf).WithField("change-url", changeUrl).Info("Change analysis started") if viper.GetBool("comment") { fmt.Printf("CHANGE_URL='%s'\n", changeUrl) fmt.Printf("GITHUB_APP_ACTIVE='%v'\n", resp.Msg.GetGithubAppActive()) } else { fmt.Println(changeUrl) } if viper.GetBool("wait") { log.WithContext(ctx).WithFields(lf).Info("Waiting for analysis to complete") return waitForChangeAnalysis(ctx, client, changeUUID, lf) } return nil } func init() { changesCmd.AddCommand(startAnalysisCmd) addAPIFlags(startAnalysisCmd) addChangeUuidFlags(startAnalysisCmd) addAnalysisFlags(startAnalysisCmd) startAnalysisCmd.PersistentFlags().Bool("wait", false, "Wait for analysis to complete before returning.") } ================================================ FILE: cmd/changes_start_analysis_test.go ================================================ package cmd import ( "context" "os" "path/filepath" "testing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) func TestAddAnalysisFlags(t *testing.T) { t.Parallel() cmd := &cobra.Command{Use: "test"} addAnalysisFlags(cmd) tests := []struct { name string flagName string flagType string }{ {"blast-radius-link-depth", "blast-radius-link-depth", "int32"}, {"blast-radius-max-items", "blast-radius-max-items", "int32"}, {"blast-radius-max-time", "blast-radius-max-time", "duration"}, {"change-analysis-target-duration", "change-analysis-target-duration", "duration"}, {"signal-config", "signal-config", "string"}, {"comment", "comment", "bool"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { flag := cmd.PersistentFlags().Lookup(tt.flagName) if flag == nil { t.Errorf("Expected flag %q to be registered", tt.flagName) return } if flag.Value.Type() != tt.flagType { t.Errorf("Expected flag %q to have type %q, got %q", tt.flagName, tt.flagType, flag.Value.Type()) } }) } // Verify blast-radius-max-time is deprecated flag := cmd.PersistentFlags().Lookup("blast-radius-max-time") if flag == nil { t.Error("Expected blast-radius-max-time flag to be registered") return } if flag.Deprecated == "" { t.Error("Expected blast-radius-max-time flag to be deprecated") } } func TestBuildAnalysisConfigWithNoFlags(t *testing.T) { // Reset viper to ensure clean state viper.Reset() ctx := context.Background() lf := log.Fields{} // When no flags are set, buildAnalysisConfig should succeed with nil/empty configs config, err := buildAnalysisConfig(ctx, lf) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if config == nil { t.Fatal("Expected config to be non-nil") } // BlastRadiusConfig should be nil when no flags are set if config.BlastRadiusConfig != nil { t.Errorf("Expected BlastRadiusConfig to be nil when no flags are set") } // RoutineChangesConfig should be nil when no signal config file exists if config.RoutineChangesConfig != nil { t.Errorf("Expected RoutineChangesConfig to be nil when no signal config exists") } // GithubOrgProfile should be nil when no signal config file exists if config.GithubOrgProfile != nil { t.Errorf("Expected GithubOrgProfile to be nil when no signal config exists") } } func TestBuildAnalysisConfigWithBlastRadiusFlags(t *testing.T) { // Reset viper to ensure clean state viper.Reset() viper.Set("blast-radius-link-depth", int32(5)) viper.Set("blast-radius-max-items", int32(1000)) ctx := context.Background() lf := log.Fields{} config, err := buildAnalysisConfig(ctx, lf) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if config == nil { t.Fatal("Expected config to be non-nil") } if config.BlastRadiusConfig == nil { t.Fatal("Expected BlastRadiusConfig to be non-nil") } if config.BlastRadiusConfig.GetLinkDepth() != 5 { t.Errorf("Expected LinkDepth to be 5, got %d", config.BlastRadiusConfig.GetLinkDepth()) } if config.BlastRadiusConfig.GetMaxItems() != 1000 { t.Errorf("Expected MaxItems to be 1000, got %d", config.BlastRadiusConfig.GetMaxItems()) } } func TestBuildAnalysisConfigWithInvalidSignalConfigPath(t *testing.T) { // Reset viper to ensure clean state viper.Reset() // Set a non-existent signal config path viper.Set("signal-config", "/nonexistent/path/signal-config.yaml") ctx := context.Background() lf := log.Fields{} _, err := buildAnalysisConfig(ctx, lf) if err == nil { t.Fatal("Expected error for invalid signal config path") } } func TestBuildAnalysisConfigWithValidSignalConfig(t *testing.T) { // Reset viper to ensure clean state viper.Reset() // Create a temporary signal config file with valid content tempDir := t.TempDir() signalConfigPath := filepath.Join(tempDir, "signal-config.yaml") signalConfigContent := `routine_changes_config: sensitivity: 0 duration_in_days: 1 events_per_day: 1 ` err := os.WriteFile(signalConfigPath, []byte(signalConfigContent), 0644) if err != nil { t.Fatalf("Failed to create temp signal config: %v", err) } viper.Set("signal-config", signalConfigPath) ctx := context.Background() lf := log.Fields{} config, err := buildAnalysisConfig(ctx, lf) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if config == nil { t.Fatal("Expected config to be non-nil") } // The signal config should be loaded if config.RoutineChangesConfig == nil { t.Error("Expected RoutineChangesConfig to be non-nil when signal config is loaded") } } func TestStartAnalysisCmdFlags(t *testing.T) { t.Parallel() // Verify the command has the expected flags registered tests := []struct { name string flagName string }{ {"wait flag", "wait"}, {"ticket-link flag", "ticket-link"}, {"uuid flag", "uuid"}, {"change flag", "change"}, {"app flag", "app"}, {"timeout flag", "timeout"}, // Analysis flags {"blast-radius-link-depth", "blast-radius-link-depth"}, {"blast-radius-max-items", "blast-radius-max-items"}, {"signal-config", "signal-config"}, {"comment flag", "comment"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { flag := startAnalysisCmd.PersistentFlags().Lookup(tt.flagName) if flag == nil { // Check the parent command's flags flag = startAnalysisCmd.Flags().Lookup(tt.flagName) } if flag == nil { t.Errorf("Expected flag %q to be registered on start-analysis command", tt.flagName) } }) } } func TestSubmitPlanCmdHasCommentFlag(t *testing.T) { t.Parallel() flag := submitPlanCmd.PersistentFlags().Lookup("comment") if flag == nil { t.Error("Expected comment flag to be registered on submit-plan command") return } if flag.DefValue != "false" { t.Errorf("Expected comment flag default value to be 'false', got %q", flag.DefValue) } } func TestSubmitPlanCmdHasNoStartFlag(t *testing.T) { t.Parallel() flag := submitPlanCmd.PersistentFlags().Lookup("no-start") if flag == nil { t.Error("Expected no-start flag to be registered on submit-plan command") return } if flag.DefValue != "false" { t.Errorf("Expected no-start flag default value to be 'false', got %q", flag.DefValue) } } ================================================ FILE: cmd/changes_start_change.go ================================================ package cmd import ( "time" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // startChangeCmd represents the start-change command var startChangeCmd = &cobra.Command{ Use: "start-change --uuid ID", Short: "Starts the specified change. Call this just before you're about to start the change. This will store a snapshot of the current system state for later reference.", PreRun: PreRunSetup, RunE: StartChange, } func StartChange(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"changes:write"}, nil) if err != nil { return err } changeUuid, err := getChangeUUIDAndCheckStatus(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, viper.GetString("ticket-link"), true) if err != nil { return loggedError{ err: err, fields: log.Fields{ "ticket-link": viper.GetString("ticket-link"), }, message: "failed to identify change", } } lf := log.Fields{ "uuid": changeUuid.String(), "ticket-link": viper.GetString("ticket-link"), } // wait for change analysis to complete (poll GetChange by change_analysis_status) client := AuthenticatedChangesClient(ctx, oi) if err := waitForChangeAnalysis(ctx, client, changeUuid, lf); err != nil { return err } // Call the simple RPC (enqueues a background job and returns immediately) _, err = client.StartChangeSimple(ctx, &connect.Request[sdp.StartChangeRequest]{ Msg: &sdp.StartChangeRequest{ ChangeUUID: changeUuid[:], }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to start change", } } waitForSnapshot := viper.GetBool("wait-for-snapshot") if waitForSnapshot { // Poll until change status has moved on log.WithContext(ctx).WithFields(lf).Info("waiting for snapshot to complete") for { changeResp, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ Msg: &sdp.GetChangeRequest{ UUID: changeUuid[:], }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to get change status", } } status := changeResp.Msg.GetChange().GetMetadata().GetStatus() // Accept HAPPENING, or DONE: if an end-change was queued during // start-change, the worker kicks it off atomically and it may complete before // the next poll, advancing status to DONE. We must not poll indefinitely. if status == sdp.ChangeStatus_CHANGE_STATUS_HAPPENING || status == sdp.ChangeStatus_CHANGE_STATUS_DONE { break } log.WithContext(ctx).WithFields(lf).WithFields(log.Fields{ "status": status.String(), }).Info("waiting for snapshot") time.Sleep(3 * time.Second) // check if the context is cancelled if ctx.Err() != nil { return loggedError{ err: ctx.Err(), fields: lf, message: "context cancelled", } } } log.WithContext(ctx).WithFields(lf).Info("started change") } else { log.WithContext(ctx).WithFields(lf).Info("change start initiated (processing in background)") } return nil } func init() { changesCmd.AddCommand(startChangeCmd) addChangeUuidFlags(startChangeCmd) startChangeCmd.PersistentFlags().Bool("wait-for-snapshot", false, "Wait for the snapshot to complete before returning. Defaults to false.") } ================================================ FILE: cmd/changes_submit_plan.go ================================================ package cmd import ( "context" "fmt" "os" "os/exec" "os/user" "strings" "time" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/tfutils" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" "google.golang.org/protobuf/types/known/durationpb" ) // submitPlanCmd represents the submit-plan command var submitPlanCmd = &cobra.Command{ Use: "submit-plan [--no-start] [--title TITLE] [--description DESCRIPTION] [--ticket-link URL] FILE [FILE ...]", Short: "Creates a new Change from a given terraform plan file", Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return flagError{fmt.Sprintf("no plan files specified\n\n%v", cmd.UsageString())} } for _, f := range args { _, err := os.Stat(f) if err != nil { return err } } return nil }, PreRun: PreRunSetup, RunE: SubmitPlan, } type TfData struct { Address string Type string Values map[string]any } func changeTitle(ctx context.Context, arg string) string { if arg != "" { // easy, return the user's choice return arg } describeBytes, err := exec.CommandContext(ctx, "git", "describe", "--long").Output() describe := strings.TrimSpace(string(describeBytes)) if err != nil { log.WithError(err).Trace("failed to run 'git describe' for default title") describe, err = os.Getwd() if err != nil { log.WithError(err).Trace("failed to get current directory for default title") describe = "unknown" } } u, err := user.Current() var username string if err != nil { log.WithError(err).Trace("failed to get current user for default title") username = "unknown" } else { username = u.Username } result := fmt.Sprintf("Deployment from %v by %v", describe, username) log.WithField("generated-title", result).Debug("Using default title") return result } func tryLoadText(ctx context.Context, fileName string) string { if fileName == "" { return "" } bytes, err := os.ReadFile(fileName) if err != nil { log.WithContext(ctx).WithError(err).WithField("file", fileName).Warn("Failed to read file") return "" } return strings.TrimSpace(string(bytes)) } func createBlastRadiusConfig(maxDepth, maxItems int32, maxTime, changeAnalysisTargetDuration time.Duration) (*sdp.BlastRadiusConfig, error) { var blastRadiusConfigOverride *sdp.BlastRadiusConfig if maxDepth > 0 || maxItems > 0 || maxTime > 0 || changeAnalysisTargetDuration > 0 { blastRadiusConfigOverride = &sdp.BlastRadiusConfig{ MaxItems: maxItems, LinkDepth: maxDepth, } // this is for backward compatibility, remove in a future release if maxTime > 0 { // we convert the maxTime to changeAnalysisTargetDuration, this means multiplying the (blast radius calculation timeout) maxTime by 1.5 // eg 10 minute max (blast radius calculation) -> 15 minute target duration blastRadiusConfigOverride.ChangeAnalysisTargetDuration = durationpb.New(time.Duration(float64(maxTime) * 1.5)) } // Add changeAnalysisTargetDuration if specified if changeAnalysisTargetDuration > 0 { blastRadiusConfigOverride.ChangeAnalysisTargetDuration = durationpb.New(changeAnalysisTargetDuration) } } // validate the ChangeAnalysisTargetDuration if blastRadiusConfigOverride != nil && blastRadiusConfigOverride.GetChangeAnalysisTargetDuration() != nil { changeAnalysisTargetDuration = blastRadiusConfigOverride.GetChangeAnalysisTargetDuration().AsDuration() if changeAnalysisTargetDuration < 1*time.Minute || changeAnalysisTargetDuration > 30*time.Minute { return nil, flagError{"--change-analysis-target-duration must be between 1 minute and 30 minutes"} } } return blastRadiusConfigOverride, nil } func SubmitPlan(cmd *cobra.Command, args []string) error { ctx := cmd.Context() app := viper.GetString("app") ctx, oi, _, err := login(ctx, cmd, []string{"changes:write", "sources:read"}, nil) if err != nil { return err } lf := log.Fields{} // Detect the repository URL if it wasn't provided repoUrl := viper.GetString("repo") if repoUrl == "" { repoUrl, err = DetectRepoURL(AllDetectors) if err != nil { log.WithContext(ctx).WithError(err).WithFields(lf).Debug("Failed to detect repository URL. Use the --repo flag to specify it manually if you require it") } } scope := tfutils.RepoToScope(repoUrl) fileWord := "file" if len(args) > 1 { fileWord = "files" } log.WithContext(ctx).Infof("Reading %v plan %v", len(args), fileWord) plannedChanges := make([]*sdp.MappedItemDiff, 0) for _, f := range args { lf["file"] = f result, err := tfutils.MappedItemDiffsFromPlanFile(ctx, f, scope, lf) if err != nil { return loggedError{ err: err, fields: lf, message: "Error parsing terraform plan", } } plannedChanges = append(plannedChanges, result.GetItemDiffs()...) } delete(lf, "file") client := AuthenticatedChangesClient(ctx, oi) changeUUID, err := getChangeUUIDAndCheckStatus(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, viper.GetString("ticket-link"), false) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed searching for existing changes", } } title := changeTitle(ctx, viper.GetString("title")) tfPlanOutput := tryLoadText(ctx, viper.GetString("terraform-plan-output")) codeChangesOutput := tryLoadText(ctx, viper.GetString("code-changes-diff")) enrichedTags, err := parseTagsArgument() if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to parse tags", } } labels, err := parseLabelsArgument() if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to parse labels", } } properties := &sdp.ChangeProperties{ Title: title, Description: viper.GetString("description"), TicketLink: viper.GetString("ticket-link"), Owner: viper.GetString("owner"), RawPlan: tfPlanOutput, CodeChanges: codeChangesOutput, Repo: repoUrl, EnrichedTags: enrichedTags, Labels: labels, } if changeUUID == uuid.Nil { log.WithContext(ctx).WithFields(lf).Debug("Creating a new change") createResponse, err := client.CreateChange(ctx, &connect.Request[sdp.CreateChangeRequest]{ Msg: &sdp.CreateChangeRequest{ Properties: properties, }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to create change", } } maybeChangeUuid := createResponse.Msg.GetChange().GetMetadata().GetUUIDParsed() if maybeChangeUuid == nil { return loggedError{ err: err, fields: lf, message: "Failed to read change id", } } changeUUID = *maybeChangeUuid lf["change"] = changeUUID log.WithContext(ctx).WithFields(lf).Info("Created a new change") } else { lf["change"] = changeUUID log.WithContext(ctx).WithFields(lf).Debug("Updating an existing change") _, err := client.UpdateChange(ctx, &connect.Request[sdp.UpdateChangeRequest]{ Msg: &sdp.UpdateChangeRequest{ UUID: changeUUID[:], Properties: properties, }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to update change", } } log.WithContext(ctx).WithFields(lf).Info("Re-using change") } var githubAppActive bool if viper.GetBool("no-start") { if viper.GetBool("comment") { log.WithContext(ctx).WithFields(lf).Info("--comment has no effect with --no-start; pass --comment to start-analysis instead") } // Store planned changes without starting analysis (multi-plan workflow) _, err = client.AddPlannedChanges(ctx, &connect.Request[sdp.AddPlannedChangesRequest]{ Msg: &sdp.AddPlannedChangesRequest{ ChangeUUID: changeUUID[:], ChangingItems: plannedChanges, }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to store planned changes", } } log.WithContext(ctx).WithFields(lf).Info("Stored planned changes without starting analysis") } else { // Build analysis config and start analysis (default behavior) analysisConfig, err := buildAnalysisConfig(ctx, lf) if err != nil { return err } resp, err := client.StartChangeAnalysis(ctx, &connect.Request[sdp.StartChangeAnalysisRequest]{ Msg: &sdp.StartChangeAnalysisRequest{ ChangeUUID: changeUUID[:], ChangingItems: plannedChanges, BlastRadiusConfigOverride: analysisConfig.BlastRadiusConfig, RoutineChangesConfigOverride: analysisConfig.RoutineChangesConfig, GithubOrganisationProfileOverride: analysisConfig.GithubOrgProfile, Knowledge: analysisConfig.KnowledgeFiles, PostGithubComment: viper.GetBool("comment"), }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to start change analysis", } } githubAppActive = resp.Msg.GetGithubAppActive() } app, _ = strings.CutSuffix(app, "/") changeUrl := fmt.Sprintf("%v/changes/%v?utm_source=cli&cli_version=%v", app, changeUUID, tracing.Version()) log.WithContext(ctx).WithFields(lf).WithField("change-url", changeUrl).Info("Change ready") if viper.GetBool("comment") { fmt.Printf("CHANGE_URL='%s'\n", changeUrl) fmt.Printf("GITHUB_APP_ACTIVE='%v'\n", githubAppActive) } else { fmt.Println(changeUrl) } return nil } func loadSignalConfigFile(signalConfigPath string) (*sdp.SignalConfigFile, error) { // check if the file exists _, err := os.Stat(signalConfigPath) if err != nil { return nil, fmt.Errorf("signal config file %q does not exist: %w", signalConfigPath, err) } // read the file signalConfig, err := os.ReadFile(signalConfigPath) if err != nil { return nil, fmt.Errorf("failed to read signal config file %q: %w", signalConfigPath, err) } signalConfigOverride, err := sdp.YamlStringToSignalConfig(string(signalConfig)) if err != nil { return nil, fmt.Errorf("failed to parse signal config file %q: %w", signalConfigPath, err) } return signalConfigOverride, nil } // order of precedence: flag > default config file func checkForAndLoadSignalConfigFile(ctx context.Context, lf log.Fields, manualPath string) (*sdp.SignalConfigFile, error) { foundPath := "" if manualPath != "" { _, err := os.Stat(manualPath) if err == nil { // we found the file foundPath = manualPath } else { // the specified file does not exist // hard fail lf["signalConfig"] = manualPath err = fmt.Errorf("signal config file does not exist: %w", err) return nil, err } } // let's look for the default files // yaml if foundPath == "" { _, err := os.Stat(".overmind/signal-config.yaml") if err == nil { // we found the file foundPath = ".overmind/signal-config.yaml" } } // yml if foundPath == "" { _, err := os.Stat(".overmind/signal-config.yml") if err == nil { // we found the file foundPath = ".overmind/signal-config.yml" } } if foundPath != "" { // we found a file, load it lf["signalConfig"] = foundPath log.WithContext(ctx).WithFields(lf).Info("Loading signal config") signalConfigOverride, err := loadSignalConfigFile(foundPath) if err != nil { return nil, err } return signalConfigOverride, nil } // we didn't find any files, thats ok return nil, nil } func init() { changesCmd.AddCommand(submitPlanCmd) addAPIFlags(submitPlanCmd) addChangeCreationFlags(submitPlanCmd) addAnalysisFlags(submitPlanCmd) submitPlanCmd.PersistentFlags().String("frontend", "", "The frontend base URL") cobra.CheckErr(submitPlanCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.")) submitPlanCmd.PersistentFlags().String("auto-tag-rules", "", "The path to the auto-tag rules file. If not provided, it will check the default location which is '.overmind/auto-tag-rules.yaml'. If no rules are found locally, the rules configured through the UI are used.") submitPlanCmd.PersistentFlags().Bool("no-start", false, "Store the planned changes without starting analysis. Use with 'start-analysis' to trigger analysis later.") } ================================================ FILE: cmd/changes_submit_plan_test.go ================================================ package cmd import ( "testing" "time" ) func TestBlastRadiusConfigCreation(t *testing.T) { t.Parallel() tests := []struct { name string blastRadiusMaxDepth int32 blastRadiusMaxItems int32 blastRadiusMaxTime time.Duration changeAnalysisTargetDuration time.Duration expectBlastRadiusConfig bool expectedBlastRadiusMaxItems int32 expectedBlastRadiusLinkDepth int32 expectChangeAnalysisTargetDuration bool expectedChangeAnalysisTargetDuration time.Duration expectError bool expectedErrorMsg string }{ { name: "No flags specified", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 0, blastRadiusMaxTime: 0, expectBlastRadiusConfig: false, }, { name: "Only maxDepth specified", blastRadiusMaxDepth: 5, blastRadiusMaxItems: 0, blastRadiusMaxTime: 0, expectBlastRadiusConfig: true, expectedBlastRadiusMaxItems: 0, expectedBlastRadiusLinkDepth: 5, }, { name: "Only maxItems specified", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 1000, blastRadiusMaxTime: 0, expectBlastRadiusConfig: true, expectedBlastRadiusMaxItems: 1000, expectedBlastRadiusLinkDepth: 0, }, { name: "Only maxTime specified - BUG: creates config with zero values", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 0, blastRadiusMaxTime: 10 * time.Minute, expectBlastRadiusConfig: true, // BUG DEMONSTRATED: When only maxTime is specified, a BlastRadiusConfig is created // with MaxItems=0 and LinkDepth=0. These explicit zeros will override the server's // defaults (100,000 and 1,000), effectively breaking the blast radius calculation. // The server should treat 0 values as "use defaults" rather than literal zeros. expectedBlastRadiusMaxItems: 0, expectedBlastRadiusLinkDepth: 0, expectChangeAnalysisTargetDuration: true, expectedChangeAnalysisTargetDuration: 15 * time.Minute, // maxTime * 1.5 }, { name: "All flags specified", blastRadiusMaxDepth: 5, blastRadiusMaxItems: 1000, blastRadiusMaxTime: 15 * time.Minute, changeAnalysisTargetDuration: 20 * time.Minute, expectBlastRadiusConfig: true, expectedBlastRadiusMaxItems: 1000, expectedBlastRadiusLinkDepth: 5, expectChangeAnalysisTargetDuration: true, expectedChangeAnalysisTargetDuration: 20 * time.Minute, // changeAnalysisTargetDuration overrides maxTime }, { name: "maxTime and maxDepth specified", blastRadiusMaxDepth: 3, blastRadiusMaxItems: 0, blastRadiusMaxTime: 5 * time.Minute, expectBlastRadiusConfig: true, expectedBlastRadiusMaxItems: 0, expectedBlastRadiusLinkDepth: 3, expectChangeAnalysisTargetDuration: true, expectedChangeAnalysisTargetDuration: 7*time.Minute + 30*time.Second, // maxTime * 1.5 }, { name: "maxTime and maxItems specified", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 500, blastRadiusMaxTime: 20 * time.Minute, expectBlastRadiusConfig: true, expectedBlastRadiusMaxItems: 500, expectedBlastRadiusLinkDepth: 0, expectChangeAnalysisTargetDuration: true, expectedChangeAnalysisTargetDuration: 30 * time.Minute, // maxTime * 1.5 }, { name: "Only changeAnalysisTargetDuration specified", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 0, blastRadiusMaxTime: 0, changeAnalysisTargetDuration: 10 * time.Minute, expectBlastRadiusConfig: true, expectedBlastRadiusMaxItems: 0, expectedBlastRadiusLinkDepth: 0, expectChangeAnalysisTargetDuration: true, expectedChangeAnalysisTargetDuration: 10 * time.Minute, }, { name: "changeAnalysisTargetDuration too low", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 0, blastRadiusMaxTime: 0, changeAnalysisTargetDuration: 30 * time.Second, expectBlastRadiusConfig: true, expectError: true, expectedErrorMsg: "--change-analysis-target-duration must be between 1 minute and 30 minutes", }, { name: "changeAnalysisTargetDuration too high", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 0, blastRadiusMaxTime: 0, changeAnalysisTargetDuration: 31 * time.Minute, expectBlastRadiusConfig: true, expectError: true, expectedErrorMsg: "--change-analysis-target-duration must be between 1 minute and 30 minutes", }, { name: "maxTime results in timeout too low", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 0, blastRadiusMaxTime: 30 * time.Second, // * 1.5 = 45 seconds, which is < 1 minute expectBlastRadiusConfig: true, expectError: true, expectedErrorMsg: "--change-analysis-target-duration must be between 1 minute and 30 minutes", }, { name: "maxTime results in timeout too high", blastRadiusMaxDepth: 0, blastRadiusMaxItems: 0, blastRadiusMaxTime: 21 * time.Minute, // * 1.5 = 31.5 minutes, which is > 30 minutes expectBlastRadiusConfig: true, expectError: true, expectedErrorMsg: "--change-analysis-target-duration must be between 1 minute and 30 minutes", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() blastRadiusConfigOverride, err := createBlastRadiusConfig(tt.blastRadiusMaxDepth, tt.blastRadiusMaxItems, tt.blastRadiusMaxTime, tt.changeAnalysisTargetDuration) // Check error expectations if tt.expectError { if err == nil { t.Errorf("Expected error, but got nil") return } if err.Error() != tt.expectedErrorMsg { t.Errorf("Expected error message %q, but got %q", tt.expectedErrorMsg, err.Error()) } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } // Verify expectations if tt.expectBlastRadiusConfig && blastRadiusConfigOverride == nil { t.Errorf("Expected BlastRadiusConfig to be created, but got nil") return } if !tt.expectBlastRadiusConfig && blastRadiusConfigOverride != nil { t.Errorf("Expected BlastRadiusConfig to be nil, but got %+v", blastRadiusConfigOverride) return } if tt.expectBlastRadiusConfig { if blastRadiusConfigOverride.GetMaxItems() != tt.expectedBlastRadiusMaxItems { t.Errorf("Expected MaxItems to be %d, but got %d", tt.expectedBlastRadiusMaxItems, blastRadiusConfigOverride.GetMaxItems()) } if blastRadiusConfigOverride.GetLinkDepth() != tt.expectedBlastRadiusLinkDepth { t.Errorf("Expected LinkDepth to be %d, but got %d", tt.expectedBlastRadiusLinkDepth, blastRadiusConfigOverride.GetLinkDepth()) } if tt.expectChangeAnalysisTargetDuration { if blastRadiusConfigOverride.GetChangeAnalysisTargetDuration() == nil { t.Errorf("Expected ChangeAnalysisTargetDuration to be set, but got nil") } else { actualTimeout := blastRadiusConfigOverride.GetChangeAnalysisTargetDuration().AsDuration() if actualTimeout != tt.expectedChangeAnalysisTargetDuration { t.Errorf("Expected ChangeAnalysisTargetDuration to be %v, but got %v", tt.expectedChangeAnalysisTargetDuration, actualTimeout) } } } else { if blastRadiusConfigOverride.GetChangeAnalysisTargetDuration() != nil { t.Errorf("Expected ChangeAnalysisTargetDuration to be nil, but got %v", blastRadiusConfigOverride.GetChangeAnalysisTargetDuration()) } } } }) } } ================================================ FILE: cmd/changes_submit_signal.go ================================================ package cmd import ( "encoding/json" "fmt" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // submitSignalCmd represents the submit-signal command var submitSignalCmd = &cobra.Command{ Use: "submit-signal --title TITLE --description DESCRIPTION [--value VALUE] [--category CATEGORY]", Short: "Creates a custom signal for a change", Example: `overmind changes submit-signal --title "Automated testing results" --description "All automated tests passed" --value 5.0 --category Testing`, PreRun: PreRunSetup, RunE: SubmitSignal, } func SubmitSignal(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"changes:write"}, nil) if err != nil { return err } // Validate required flags if viper.GetString("title") == "" { return flagError{"--title is required"} } value, err := validateValue(viper.GetFloat64("value")) if err != nil { return flagError{"--value is invalid: " + err.Error()} } if viper.GetString("description") == "" { return flagError{"--description is required"} } changeUUID, err := getChangeUUIDAndCheckStatus(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, viper.GetString("ticket-link"), true) if err != nil { return loggedError{ err: err, message: "failed to identify change", } } lf := log.Fields{ "uuid": changeUUID.String(), "change-url": viper.GetString("change-url"), } client := AuthenticatedSignalsClient(ctx, oi) returnedSignal, err := client.AddSignal(ctx, connect.NewRequest(&sdp.AddSignalRequest{ Properties: &sdp.SignalProperties{ Name: viper.GetString("title"), Description: viper.GetString("description"), Value: value, Category: viper.GetString("category"), }, ChangeUUID: changeUUID[:], })) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to create signal", } } if returnedSignal.Msg == nil { return loggedError{ err: err, fields: lf, message: "signal creation returned no data", } } b, err := json.MarshalIndent(returnedSignal.Msg, "", " ") if err != nil { fmt.Printf("Successfully created signal for change %s\n", changeUUID.String()) log.Infof("Error rendering Signal: %v", err) } else { fmt.Printf("Successfully created signal for change %s\n", changeUUID.String()) fmt.Println(string(b)) } return nil } func validateValue(value float64) (float64, error) { if value < -5.0 || value > 5.0 { return 0, fmt.Errorf("must be between -5.0 and 5.0, got %f", value) } return value, nil } func init() { changesCmd.AddCommand(submitSignalCmd) addAPIFlags(submitSignalCmd) addChangeUuidFlags(submitSignalCmd) submitSignalCmd.PersistentFlags().String("title", "", "Title of the signal") submitSignalCmd.PersistentFlags().String("description", "", "Description of the signal") submitSignalCmd.PersistentFlags().Float64("value", 0, "Value of the signal (eg from -5.0 to 5.0, where -5.0 is very bad and 5.0 is very good)") submitSignalCmd.PersistentFlags().String("category", string(sdp.SignalCategoryNameCustom), "Category of the signal (eg Custom, etc.)") } ================================================ FILE: cmd/changes_submit_signal_test.go ================================================ package cmd import ( "testing" ) func TestValidateValue(t *testing.T) { tests := []struct { input float64 expectedOutput float64 expectError bool }{ {input: 5.0, expectedOutput: 5.0, expectError: false}, {input: 0.0, expectedOutput: 0.0, expectError: false}, {input: -1.0, expectedOutput: -1.0, expectError: false}, {input: 11.0, expectedOutput: 0.0, expectError: true}, {input: -6.0, expectedOutput: 0.0, expectError: true}, } for _, test := range tests { output, err := validateValue(test.input) if (err != nil) != test.expectError { t.Errorf("validateValue(%v) unexpected error status: got %v, want error: %v", test.input, err != nil, test.expectError) } if output != test.expectedOutput { t.Errorf("validateValue(%v) = %v; want %v", test.input, output, test.expectedOutput) } } } ================================================ FILE: cmd/explore.go ================================================ package cmd import ( "context" "errors" "fmt" "net/http" "os" "slices" "strings" "atomicgo.dev/keyboard" "atomicgo.dev/keyboard/keys" "github.com/aws/aws-sdk-go-v2/aws" awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" "github.com/aws/aws-sdk-go-v2/config" "github.com/google/uuid" "github.com/overmindtech/pterm" "github.com/overmindtech/cli/aws-source/proc" "github.com/overmindtech/cli/tfutils" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" azureproc "github.com/overmindtech/cli/sources/azure/proc" gcpproc "github.com/overmindtech/cli/sources/gcp/proc" snapshotadapters "github.com/overmindtech/cli/sources/snapshot/adapters" stdlibSource "github.com/overmindtech/cli/stdlib-source/adapters" "github.com/pkg/browser" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/pool" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/oauth2" ) // exploreCmd represents the explore command var exploreCmd = &cobra.Command{ Use: "explore", Short: "Run local sources for using in the Explore page", Long: `Run sources locally using terraform's configured authorization to provide data when using https://app.overmind.tech/explore. The CLI automatically discovers and uses: - AWS providers from your Terraform configuration - GCP providers from your Terraform configuration (google and google-beta) - Falls back to default cloud provider credentials if no Terraform providers are found Set SNAPSHOT_SOURCE to a snapshot file path or URL to run only the snapshot source (no cloud sources will be started). Useful for local testing with fixed data. For GCP, ensure you have appropriate permissions (roles/browser or equivalent) to access project metadata.`, PreRun: PreRunSetup, RunE: Explore, // SilenceErrors: false, } // StartLocalSources runs the local sources using local auth tokens for use by // any query or request during the runtime of the CLI. for proper cleanup, // execute the returned function. The method returns once the sources are // started. Progress is reported into the provided multi printer. // If enableAzurePreview is true, Azure source support is enabled (preview feature). func StartLocalSources(ctx context.Context, oi sdp.OvermindInstance, token *oauth2.Token, tfArgs []string, failOverToDefaultLoginCfg bool, enableAzurePreview bool) (func(), error) { var err error // Default to recursive search unless --no-recursion is set tfRecursive := !viper.GetBool("no-recursion") multi := pterm.DefaultMultiPrinter _, _ = multi.Start() defer func() { _, _ = multi.Stop() }() natsOpts := natsOptions(ctx, oi, token) hostname, err := os.Hostname() if err != nil { return func() {}, fmt.Errorf("failed to get hostname: %w", err) } // If SNAPSHOT_SOURCE is set, run ONLY the snapshot source -- skip all live sources. // Snapshot mode replays pre-recorded data, so cloud providers are unnecessary. if snapshotSourcePath := os.Getenv("SNAPSHOT_SOURCE"); snapshotSourcePath != "" { snapshotSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Starting snapshot source engine (snapshot-only mode)") ec := discovery.EngineConfig{ EngineType: "cli-snapshot", Version: fmt.Sprintf("cli-%v", tracing.Version()), SourceName: fmt.Sprintf("snapshot-source-%v", hostname), SourceUUID: uuid.New(), App: oi.ApiUrl.Host, ApiKey: token.AccessToken, NATSOptions: &natsOpts, MaxParallelExecutions: 2_000, HeartbeatOptions: heartbeatOptions(oi, token), } snapshotEngine, err := discovery.NewEngine(&ec) if err != nil { snapshotSpinner.Fail(fmt.Sprintf("Failed to create snapshot source engine: %v", err)) return func() {}, fmt.Errorf("failed to create snapshot source engine: %w", err) } err = snapshotadapters.InitializeAdapters(ctx, snapshotEngine, snapshotSourcePath) if err != nil { snapshotSpinner.Fail(fmt.Sprintf("Failed to initialize snapshot source adapters: %v", err)) return func() {}, fmt.Errorf("failed to initialize snapshot source adapters: %w", err) } snapshotEngine.MarkAdaptersInitialized() err = snapshotEngine.Start(ctx) if err != nil { snapshotSpinner.Fail(fmt.Sprintf("Failed to start snapshot source engine: %v", err)) return func() {}, fmt.Errorf("failed to start snapshot source engine: %w", err) } snapshotEngine.StartSendingHeartbeats(ctx) snapshotSpinner.Success("Snapshot source engine started (snapshot-only mode)") return func() { if err := snapshotEngine.Stop(); err != nil { log.WithError(err).Error("failed to stop snapshot engine") } }, nil } p := pool.NewWithResults[[]*discovery.Engine]().WithErrors() // find all the terraform files tfFiles, err := tfutils.FindTerraformFiles(".", tfRecursive) if err != nil { // we only error if there is a filesystem error, 0 files is handled below return nil, err } // if no terraform files are found, return an error if len(tfFiles) == 0 && !failOverToDefaultLoginCfg { currentDir, _ := os.Getwd() msgLines := []string{ fmt.Sprintf("No Terraform configuration files found in %s", currentDir), "", "The Overmind CLI requires access to Terraform configuration files (.tf files) to discover and authenticate with cloud providers. Without Terraform configuration, the CLI cannot determine which cloud resources to interrogate.", "", "To resolve this issue:", "- Ensure you're running the command from a directory containing Terraform files (.tf files)", "- Or create Terraform configuration files that define your cloud providers", "", } if !tfRecursive { msgLines = append(msgLines, "- Or remove --no-recursion to scan subdirectories for Terraform stacks") } msgLines = append(msgLines, "For more information about Terraform configuration, visit: https://developer.hashicorp.com/terraform/language") return nil, errors.New(strings.Join(msgLines, "\n")) } stdlibSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Starting stdlib source engine") awsSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Starting AWS source engine") gcpSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Starting GCP source engine") var azureSpinner *pterm.SpinnerPrinter if enableAzurePreview { azureSpinner, _ = pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Starting Azure source engine") } statusArea := pterm.DefaultParagraph.WithWriter(multi.NewWriter()) foundCloudProvider := false p.Go(func() ([]*discovery.Engine, error) { ec := discovery.EngineConfig{ Version: fmt.Sprintf("cli-%v", tracing.Version()), EngineType: "cli-stdlib", SourceName: fmt.Sprintf("stdlib-source-%v", hostname), SourceUUID: uuid.New(), App: oi.ApiUrl.Host, ApiKey: token.AccessToken, NATSOptions: &natsOpts, MaxParallelExecutions: 2_000, HeartbeatOptions: heartbeatOptions(oi, token), } stdlibEngine, err := discovery.NewEngine(&ec) if err != nil { stdlibSpinner.Fail("Failed to create stdlib source engine") return nil, fmt.Errorf("failed to create stdlib source engine: %w", err) } err = stdlibSource.InitializeAdapters(ctx, stdlibEngine, true) if err != nil { stdlibSpinner.Fail("Failed to initialize stdlib source adapters") return nil, fmt.Errorf("failed to initialize stdlib source adapters: %w", err) } // todo: pass in context with timeout to abort timely and allow Ctrl-C to work stdlibEngine.MarkAdaptersInitialized() err = stdlibEngine.Start(ctx) if err != nil { stdlibSpinner.Fail("Failed to start stdlib source engine") return nil, fmt.Errorf("failed to start stdlib source engine: %w", err) } stdlibEngine.StartSendingHeartbeats(ctx) stdlibSpinner.Success("Stdlib source engine started") return []*discovery.Engine{stdlibEngine}, nil }) p.Go(func() ([]*discovery.Engine, error) { tfEval, err := tfutils.LoadEvalContext(tfArgs, os.Environ()) if err != nil { awsSpinner.Fail("Failed to load variables from the environment") return nil, fmt.Errorf("failed to load variables from the environment: %w", err) } awsProviders, err := tfutils.ParseAWSProviders(".", tfEval, tfRecursive) if err != nil { awsSpinner.Fail("Failed to parse AWS providers") return nil, fmt.Errorf("failed to parse AWS providers: %w", err) } if len(awsProviders) == 0 && !failOverToDefaultLoginCfg { awsSpinner.Warning("No AWS terraform providers found, skipping AWS source initialization.") return nil, nil // skip AWS if there are no awsProviders } configs := []aws.Config{} for _, p := range awsProviders { if p.Error != nil { // skip providers that had errors. This allows us to use // providers we _could_ detect, while still failing if there is // a true syntax error and no providers are available at all. statusArea.Println(fmt.Sprintf("Skipping AWS provider in %s with %s.", p.FilePath, p.Error.Error())) continue } c, err := tfutils.ConfigFromProvider(ctx, *p.Provider) if err != nil { awsSpinner.Fail("Error when converting AWS Terraform provider to config: ", err) return nil, fmt.Errorf("error when converting AWS Terraform provider to config: %w", err) } credentials, _ := c.Credentials.Retrieve(ctx) aliasInfo := "" if p.Provider.Alias != "" { aliasInfo = fmt.Sprintf(" (alias: %s)", p.Provider.Alias) } statusArea.Println(fmt.Sprintf("Using AWS provider %s%s in %s with %s.", p.Provider.Name, aliasInfo, p.FilePath, credentials.Source)) configs = append(configs, c) } if len(configs) == 0 && failOverToDefaultLoginCfg { statusArea.Println("No AWS terraform providers found. Attempting to use AWS default credentials for configuration.") // Configure HTTP client to respect proxy environment variables httpClient := awshttp.NewBuildableClient() httpClient.WithTransportOptions(func(t *http.Transport) { t.Proxy = http.ProxyFromEnvironment }) userConfig, err := config.LoadDefaultConfig(ctx, config.WithHTTPClient(httpClient), ) if err != nil { awsSpinner.Fail("Failed to load default AWS config: ", err) return nil, fmt.Errorf("failed to load default AWS config: %w", err) } configs = append(configs, userConfig) } ec := discovery.EngineConfig{ EngineType: "cli-aws", Version: fmt.Sprintf("cli-%v", tracing.Version()), SourceName: fmt.Sprintf("aws-source-%v", hostname), SourceUUID: uuid.New(), App: oi.ApiUrl.Host, ApiKey: token.AccessToken, MaxParallelExecutions: 2_000, NATSOptions: &natsOpts, HeartbeatOptions: heartbeatOptions(oi, token), } awsEngine, err := discovery.NewEngine(&ec) if err != nil { awsSpinner.Fail("Failed to create AWS source engine") return nil, fmt.Errorf("failed to create AWS source engine: %w", err) } err = proc.InitializeAwsSourceAdapters( ctx, awsEngine, configs..., ) if err != nil { if os.Getenv("AWS_PROFILE") == "" { // look for the AWS_PROFILE env var and suggest setting it awsSpinner.Fail("Failed to initialize AWS source adapters. Consider setting AWS_PROFILE to use the default AWS CLI profile.") } else { awsSpinner.Fail("Failed to initialize AWS source adapters") } return nil, fmt.Errorf("failed to initialize AWS source adapters: %w", err) } awsEngine.MarkAdaptersInitialized() err = awsEngine.Start(ctx) if err != nil { awsSpinner.Fail("Failed to start AWS source engine") return nil, fmt.Errorf("failed to start AWS source engine: %w", err) } awsEngine.StartSendingHeartbeats(ctx) awsSpinner.Success("AWS source engine started") foundCloudProvider = true return []*discovery.Engine{awsEngine}, nil }) p.Go(func() ([]*discovery.Engine, error) { // Parse GCP providers from Terraform configuration tfEval, err := tfutils.LoadEvalContext(tfArgs, os.Environ()) if err != nil { gcpSpinner.Fail("Failed to load variables from the environment for GCP") return nil, fmt.Errorf("failed to load variables from the environment for GCP: %w", err) } gcpProviders, err := tfutils.ParseGCPProviders(".", tfEval, tfRecursive) if err != nil { gcpSpinner.Fail("Failed to parse GCP providers") return nil, fmt.Errorf("failed to parse GCP providers: %w", err) } if len(gcpProviders) == 0 && !failOverToDefaultLoginCfg { gcpSpinner.Warning("No GCP terraform providers found, skipping GCP source initialization.") return nil, nil // skip GCP if there are no providers } // Process GCP providers and extract configurations gcpConfigs := []*gcpproc.GCPConfig{} for _, p := range gcpProviders { if p.Error != nil { statusArea.Println(fmt.Sprintf("Skipping GCP provider in %s: %s", p.FilePath, p.Error.Error())) continue } config, err := tfutils.ConfigFromGCPProvider(*p.Provider) if err != nil { statusArea.Println(fmt.Sprintf("Error configuring GCP provider %s in %s: %s", p.Provider.Name, p.FilePath, err.Error())) continue } gcpConfigs = append(gcpConfigs, &gcpproc.GCPConfig{ ProjectID: config.ProjectID, Regions: config.Regions, Zones: config.Zones, }) aliasInfo := "" if config.Alias != "" { aliasInfo = fmt.Sprintf(" (alias: %s)", config.Alias) } statusArea.Println(fmt.Sprintf("Using GCP provider in %s with project %s%s.", p.FilePath, config.ProjectID, aliasInfo)) } gcpConfigs = unifiedGCPConfigs(gcpConfigs) // Fallback to default GCP config if no terraform providers found if len(gcpConfigs) == 0 && failOverToDefaultLoginCfg { statusArea.Println("No GCP terraform providers found. Attempting to use GCP Application Default Credentials for configuration.") // Try to use Application Default Credentials by passing nil config gcpConfigs = append(gcpConfigs, nil) } // Start multiple GCP engines for each configuration gcpEngines := []*discovery.Engine{} for i, gcpConfig := range gcpConfigs { engineSuffix := "" if len(gcpConfigs) > 1 { engineSuffix = fmt.Sprintf("-%d", i) } ec := discovery.EngineConfig{ EngineType: "cli-gcp", Version: fmt.Sprintf("cli-%v", tracing.Version()), SourceName: fmt.Sprintf("gcp-source-%v%s", hostname, engineSuffix), SourceUUID: uuid.New(), App: oi.ApiUrl.Host, ApiKey: token.AccessToken, MaxParallelExecutions: 2_000, NATSOptions: &natsOpts, HeartbeatOptions: heartbeatOptions(oi, token), } gcpEngine, err := discovery.NewEngine(&ec) if err != nil { if gcpConfig == nil { statusArea.Println(fmt.Sprintf("Failed to create GCP source engine with default credentials: %s", err.Error())) } else { statusArea.Println(fmt.Sprintf("Failed to create GCP source engine for project %s: %s", gcpConfig.ProjectID, err.Error())) } continue // Skip this engine but continue with others } err = gcpproc.InitializeAdapters(ctx, gcpEngine, gcpConfig) if err != nil { if gcpConfig == nil { // Default config failed statusArea.Println(fmt.Sprintf("Failed to initialize GCP source adapters with default credentials: %s", err.Error())) } else { statusArea.Println(fmt.Sprintf("Failed to initialize GCP source adapters for project %s: %s", gcpConfig.ProjectID, err.Error())) } continue // Skip this engine but continue with others } gcpEngine.MarkAdaptersInitialized() err = gcpEngine.Start(ctx) if err != nil { if gcpConfig == nil { statusArea.Println(fmt.Sprintf("Failed to start GCP source with default credentials: %s", err.Error())) } else { statusArea.Println(fmt.Sprintf("Failed to start GCP source for project %s: %s", gcpConfig.ProjectID, err.Error())) } continue // Skip this engine but continue with others } gcpEngine.StartSendingHeartbeats(ctx) gcpEngines = append(gcpEngines, gcpEngine) } if len(gcpEngines) == 0 { gcpSpinner.Fail("Failed to initialize any GCP source engines") return nil, nil // skip GCP if there are no valid configurations } if len(gcpEngines) == 1 { gcpSpinner.Success("GCP source engine started") } else { gcpSpinner.Success(fmt.Sprintf("%d GCP source engines started", len(gcpEngines))) } foundCloudProvider = true return gcpEngines, nil }) if enableAzurePreview { p.Go(func() ([]*discovery.Engine, error) { // Parse Azure providers from Terraform configuration tfEval, err := tfutils.LoadEvalContext(tfArgs, os.Environ()) if err != nil { azureSpinner.Fail("Failed to load variables from the environment for Azure") return nil, fmt.Errorf("failed to load variables from the environment for Azure: %w", err) } azureProviders, err := tfutils.ParseAzureProviders(".", tfEval, tfRecursive) if err != nil { azureSpinner.Fail("Failed to parse Azure providers") return nil, fmt.Errorf("failed to parse Azure providers: %w", err) } if len(azureProviders) == 0 && !failOverToDefaultLoginCfg { azureSpinner.Warning("No Azure terraform providers found, skipping Azure source initialization.") return nil, nil // skip Azure if there are no providers } // Process Azure providers and extract configurations azureConfigs := []*azureproc.AzureConfig{} for _, p := range azureProviders { if p.Error != nil { statusArea.Println(fmt.Sprintf("Skipping Azure provider in %s: %s", p.FilePath, p.Error.Error())) continue } config, err := tfutils.ConfigFromAzureProvider(*p.Provider) if err != nil { statusArea.Println(fmt.Sprintf("Error configuring Azure provider in %s: %s", p.FilePath, err.Error())) continue } azureConfigs = append(azureConfigs, &azureproc.AzureConfig{ SubscriptionID: config.SubscriptionID, TenantID: config.TenantID, ClientID: config.ClientID, }) aliasInfo := "" if config.Alias != "" { aliasInfo = fmt.Sprintf(" (alias: %s)", config.Alias) } statusArea.Println(fmt.Sprintf("Using Azure provider in %s with subscription %s%s.", p.FilePath, config.SubscriptionID, aliasInfo)) } azureConfigs = unifiedAzureConfigs(azureConfigs) // Fallback to environment variables if no terraform providers found // Azure requires subscription_id at minimum, unlike GCP which can discover project from ADC // Check ARM_* first (Terraform Azure provider convention), then AZURE_* (Azure SDK convention) if len(azureConfigs) == 0 && failOverToDefaultLoginCfg { azureSubscriptionID := os.Getenv("ARM_SUBSCRIPTION_ID") if azureSubscriptionID == "" { azureSubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID") } if azureSubscriptionID == "" { azureSpinner.Warning("No Azure terraform providers found and ARM_SUBSCRIPTION_ID/AZURE_SUBSCRIPTION_ID not set, skipping Azure source initialization.") return nil, nil } azureTenantID := os.Getenv("ARM_TENANT_ID") if azureTenantID == "" { azureTenantID = os.Getenv("AZURE_TENANT_ID") } azureClientID := os.Getenv("ARM_CLIENT_ID") if azureClientID == "" { azureClientID = os.Getenv("AZURE_CLIENT_ID") } statusArea.Println("No Azure terraform providers found. Using Azure credentials from environment (az login or environment variables).") azureConfigs = append(azureConfigs, &azureproc.AzureConfig{ SubscriptionID: azureSubscriptionID, TenantID: azureTenantID, ClientID: azureClientID, }) } if len(azureConfigs) == 0 { azureSpinner.Warning("No valid Azure terraform providers found, skipping Azure source initialization.") return nil, nil // skip Azure if there are no valid configurations } // Start multiple Azure engines for each configuration azureEngines := []*discovery.Engine{} for i, azureConfig := range azureConfigs { engineSuffix := "" if len(azureConfigs) > 1 { engineSuffix = fmt.Sprintf("-%d", i) } ec := discovery.EngineConfig{ EngineType: "cli-azure", Version: fmt.Sprintf("cli-%v", tracing.Version()), SourceName: fmt.Sprintf("azure-source-%v%s", hostname, engineSuffix), SourceUUID: uuid.New(), App: oi.ApiUrl.Host, ApiKey: token.AccessToken, MaxParallelExecutions: 2_000, NATSOptions: &natsOpts, HeartbeatOptions: heartbeatOptions(oi, token), } azureEngine, err := discovery.NewEngine(&ec) if err != nil { statusArea.Println(fmt.Sprintf("Failed to create Azure source engine for subscription %s: %s", azureConfig.SubscriptionID, err.Error())) continue // Skip this engine but continue with others } err = azureproc.InitializeAdapters(ctx, azureEngine, azureConfig) if err != nil { statusArea.Println(fmt.Sprintf("Failed to initialize Azure source adapters for subscription %s: %s", azureConfig.SubscriptionID, err.Error())) continue // Skip this engine but continue with others } azureEngine.MarkAdaptersInitialized() err = azureEngine.Start(ctx) if err != nil { statusArea.Println(fmt.Sprintf("Failed to start Azure source for subscription %s: %s", azureConfig.SubscriptionID, err.Error())) continue // Skip this engine but continue with others } azureEngine.StartSendingHeartbeats(ctx) azureEngines = append(azureEngines, azureEngine) } if len(azureEngines) == 0 { azureSpinner.Fail("Failed to initialize any Azure source engines") return nil, nil // skip Azure if there are no valid configurations } if len(azureEngines) == 1 { azureSpinner.Success("Azure source engine started") } else { azureSpinner.Success(fmt.Sprintf("%d Azure source engines started", len(azureEngines))) } foundCloudProvider = true return azureEngines, nil }) } engines, err := p.Wait() if err != nil { return func() {}, fmt.Errorf("error starting sources: %w", err) } if !foundCloudProvider { noCloudProviderMsg := `No cloud providers found in Terraform configuration. The Overmind CLI requires access to cloud provider configurations to interrogate resources. Without configured providers, the CLI cannot determine which cloud resources to query and as a result calculate a successful blast radius. To resolve this issue ensure your Terraform configuration files define at least one supported cloud provider (e.g., AWS, GCP) For more information about configuring cloud providers in Terraform, visit: - AWS: https://registry.terraform.io/providers/hashicorp/aws/latest/docs - GCP: https://registry.terraform.io/providers/hashicorp/google/latest/docs` if enableAzurePreview { noCloudProviderMsg = `No cloud providers found in Terraform configuration. The Overmind CLI requires access to cloud provider configurations to interrogate resources. Without configured providers, the CLI cannot determine which cloud resources to query and as a result calculate a successful blast radius. To resolve this issue ensure your Terraform configuration files define at least one supported cloud provider (e.g., AWS, GCP, Azure) For more information about configuring cloud providers in Terraform, visit: - AWS: https://registry.terraform.io/providers/hashicorp/aws/latest/docs - Azure: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs - GCP: https://registry.terraform.io/providers/hashicorp/google/latest/docs` } statusArea.Println(noCloudProviderMsg) } // Return a cleanup function to stop all engines return func() { for _, e := range slices.Concat(engines...) { err := e.Stop() if err != nil { log.WithError(err).Error("failed to stop engine") } } }, nil } func Explore(cmd *cobra.Command, args []string) error { PTermSetup() ctx := cmd.Context() multi := pterm.DefaultMultiPrinter _, _ = multi.Start() // multi-printer controls the lifecycle of screen output, it needs to be stopped before printing anything else defer func() { _, _ = multi.Stop() }() ctx, oi, token, err := login(ctx, cmd, []string{"request:receive", "api:read"}, multi.NewWriter()) _, _ = multi.Stop() if err != nil { return err } enableAzurePreview := viper.GetBool("enable-azure-preview") cleanup, err := StartLocalSources(ctx, oi, token, args, true, enableAzurePreview) if err != nil { return err } defer cleanup() exploreURL := fmt.Sprintf("%v/explore", oi.FrontendUrl) _ = browser.OpenURL(exploreURL) // ignore error, we can't do anything about it pterm.Println() pterm.Println(fmt.Sprintf("Explore your infrastructure graph at %s", exploreURL)) pterm.Println() pterm.Success.Println("Press Ctrl+C to stop the locally running sources") err = keyboard.Listen(func(keyInfo keys.Key) (stop bool, err error) { if keyInfo.Code == keys.CtrlC { return true, nil } return false, nil }) if err != nil { return fmt.Errorf("error reading keyboard input: %w", err) } // This spinner will spin forever as the command shuts down as this could // take a couple of seconds and we want the user to know it's doing // something _, _ = pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Shutting down") return nil } func init() { rootCmd.AddCommand(exploreCmd) addAPIFlags(exploreCmd) // flag to opt-out of recursion and only scan the current folder for *.tf files exploreCmd.PersistentFlags().Bool("no-recursion", false, "Only scan the current directory for Terraform files (non-recursive).") // hidden flag to enable Azure preview support exploreCmd.PersistentFlags().Bool("enable-azure-preview", false, "Enable Azure source support (preview feature).") cobra.CheckErr(exploreCmd.PersistentFlags().MarkHidden("enable-azure-preview")) } // unifiedGCPConfigs collates the given GCP configs by project ID. // If there are multiple configs for the same project ID, the configs are merged. func unifiedGCPConfigs(gcpConfigs []*gcpproc.GCPConfig) []*gcpproc.GCPConfig { unified := make(map[string]*gcpproc.GCPConfig) for _, config := range gcpConfigs { if _, ok := unified[config.ProjectID]; !ok { unified[config.ProjectID] = config } else { unified[config.ProjectID].Regions = append(unified[config.ProjectID].Regions, config.Regions...) unified[config.ProjectID].Zones = append(unified[config.ProjectID].Zones, config.Zones...) } } unifiedConfigs := make([]*gcpproc.GCPConfig, 0, len(unified)) for _, config := range unified { var deDuplicatedRegions []string var deDuplicatedZones []string for _, region := range config.Regions { if !slices.Contains(deDuplicatedRegions, region) { deDuplicatedRegions = append(deDuplicatedRegions, region) } } for _, zone := range config.Zones { if !slices.Contains(deDuplicatedZones, zone) { deDuplicatedZones = append(deDuplicatedZones, zone) } } config.Regions = deDuplicatedRegions config.Zones = deDuplicatedZones unifiedConfigs = append(unifiedConfigs, config) } return unifiedConfigs } // unifiedAzureConfigs collates the given Azure configs by subscription ID. // If there are multiple configs for the same subscription ID, only the first is used // since Azure configs don't have regions/zones to merge. func unifiedAzureConfigs(azureConfigs []*azureproc.AzureConfig) []*azureproc.AzureConfig { unified := make(map[string]*azureproc.AzureConfig) for _, config := range azureConfigs { if _, ok := unified[config.SubscriptionID]; !ok { unified[config.SubscriptionID] = config } // For Azure, we don't merge configs - just use the first one for each subscription // since there are no regions/zones to merge } unifiedConfigs := make([]*azureproc.AzureConfig, 0, len(unified)) for _, config := range unified { unifiedConfigs = append(unifiedConfigs, config) } return unifiedConfigs } ================================================ FILE: cmd/explore_test.go ================================================ package cmd import ( "reflect" "testing" azureproc "github.com/overmindtech/cli/sources/azure/proc" gcpproc "github.com/overmindtech/cli/sources/gcp/proc" ) func TestUnifiedGCPConfigs(t *testing.T) { t.Run("Multiple configs with different project IDs - no unification", func(t *testing.T) { configs := []*gcpproc.GCPConfig{ { ProjectID: "project-1", Regions: []string{"us-central1", "us-east1"}, Zones: []string{"us-central1-a", "us-east1-a"}, }, { ProjectID: "project-2", Regions: []string{"us-central1", "us-east1"}, Zones: []string{"us-central1-a", "us-east1-a"}, }, { ProjectID: "project-3", Regions: []string{"europe-west1"}, Zones: []string{"europe-west1-b"}, }, } result := unifiedGCPConfigs(configs) // Should have 3 configs (no unification since all project IDs are different) if len(result) != 3 { t.Fatalf("Expected 3 configs, got %d", len(result)) } // Verify each project ID appears exactly once projectIDs := make(map[string]int) for _, config := range result { projectIDs[config.ProjectID]++ } expectedProjects := []string{"project-1", "project-2", "project-3"} for _, projectID := range expectedProjects { if count, exists := projectIDs[projectID]; !exists || count != 1 { t.Fatalf("Expected project %s to appear exactly once, got %d", projectID, count) } } // Find and verify each config maintains its original regions and zones for _, originalConfig := range configs { var foundConfig *gcpproc.GCPConfig for _, resultConfig := range result { if resultConfig.ProjectID == originalConfig.ProjectID { foundConfig = resultConfig break } } if foundConfig == nil { t.Fatalf("Could not find config for project %s in result", originalConfig.ProjectID) return } if !reflect.DeepEqual(foundConfig.Regions, originalConfig.Regions) { t.Fatalf("Regions for project %s don't match. Expected %v, got %v", originalConfig.ProjectID, originalConfig.Regions, foundConfig.Regions) } if !reflect.DeepEqual(foundConfig.Zones, originalConfig.Zones) { t.Fatalf("Zones for project %s don't match. Expected %v, got %v", originalConfig.ProjectID, originalConfig.Zones, foundConfig.Zones) } } }) t.Run("Same project ID with different regions - unification", func(t *testing.T) { configs := []*gcpproc.GCPConfig{ { ProjectID: "unified-project", Regions: []string{"us-central1", "us-east1"}, Zones: []string{"us-central1-a"}, }, { ProjectID: "unified-project", Regions: []string{"europe-west1", "asia-east1"}, Zones: []string{"europe-west1-b"}, }, { ProjectID: "different-project", Regions: []string{"us-west1"}, Zones: []string{"us-west1-a"}, }, } result := unifiedGCPConfigs(configs) // Should have 2 configs (unified-project configs merged) if len(result) != 2 { t.Fatalf("Expected 2 configs, got %d", len(result)) } // Find the unified config var unifiedConfig *gcpproc.GCPConfig var differentConfig *gcpproc.GCPConfig for _, config := range result { switch config.ProjectID { case "unified-project": unifiedConfig = config case "different-project": differentConfig = config } } if unifiedConfig == nil { t.Fatal("Could not find unified-project config in result") return } if differentConfig == nil { t.Fatal("Could not find different-project config in result") return } // Verify unified config has all regions expectedRegions := []string{"us-central1", "us-east1", "europe-west1", "asia-east1"} if !reflect.DeepEqual(unifiedConfig.Regions, expectedRegions) { t.Fatalf("Unified regions don't match. Expected %v, got %v", expectedRegions, unifiedConfig.Regions) } // Verify unified config has all zones expectedZones := []string{"us-central1-a", "europe-west1-b"} if !reflect.DeepEqual(unifiedConfig.Zones, expectedZones) { t.Fatalf("Unified zones don't match. Expected %v, got %v", expectedZones, unifiedConfig.Zones) } // Verify different-project config is unchanged if !reflect.DeepEqual(differentConfig.Regions, []string{"us-west1"}) { t.Fatalf("Different project regions changed. Expected [us-west1], got %v", differentConfig.Regions) } if !reflect.DeepEqual(differentConfig.Zones, []string{"us-west1-a"}) { t.Fatalf("Different project zones changed. Expected [us-west1-a], got %v", differentConfig.Zones) } }) t.Run("Same project ID with different zones and regions - unification", func(t *testing.T) { configs := []*gcpproc.GCPConfig{ { ProjectID: "zone-project", Regions: []string{"us-central1"}, Zones: []string{"us-central1-a", "us-central1-b"}, }, { ProjectID: "zone-project", Regions: []string{"us-east1"}, Zones: []string{"us-east1-a", "us-east1-c"}, }, } result := unifiedGCPConfigs(configs) // Should have 1 config (both configs merged) if len(result) != 1 { t.Fatalf("Expected 1 config, got %d", len(result)) } unifiedConfig := result[0] if unifiedConfig.ProjectID != "zone-project" { t.Fatalf("Expected project ID 'zone-project', got %s", unifiedConfig.ProjectID) } // Verify unified config has all regions expectedRegions := []string{"us-central1", "us-east1"} if !reflect.DeepEqual(unifiedConfig.Regions, expectedRegions) { t.Fatalf("Unified regions don't match. Expected %v, got %v", expectedRegions, unifiedConfig.Regions) } // Verify unified config has all zones expectedZones := []string{"us-central1-a", "us-central1-b", "us-east1-a", "us-east1-c"} if !reflect.DeepEqual(unifiedConfig.Zones, expectedZones) { t.Fatalf("Unified zones don't match. Expected %v, got %v", expectedZones, unifiedConfig.Zones) } }) t.Run("Same project ID with overlapping regions and zones - proper unification", func(t *testing.T) { configs := []*gcpproc.GCPConfig{ { ProjectID: "overlap-project", Regions: []string{"us-central1", "us-east1", "europe-west1"}, Zones: []string{"us-central1-a", "us-central1-b", "europe-west1-a"}, }, { ProjectID: "overlap-project", Regions: []string{"us-central1", "asia-east1"}, // us-central1 overlaps Zones: []string{"us-central1-a", "asia-east1-a"}, // us-central1-a overlaps }, { ProjectID: "overlap-project", Regions: []string{"europe-west1", "us-west1"}, // europe-west1 overlaps Zones: []string{"europe-west1-a", "us-west1-b"}, // europe-west1-a overlaps }, } result := unifiedGCPConfigs(configs) // Should have 1 config (all configs merged) if len(result) != 1 { t.Fatalf("Expected 1 config, got %d", len(result)) } unifiedConfig := result[0] if unifiedConfig.ProjectID != "overlap-project" { t.Fatalf("Expected project ID 'overlap-project', got %s", unifiedConfig.ProjectID) } expectedRegions := []string{"us-central1", "us-east1", "europe-west1", "asia-east1", "us-west1"} if !reflect.DeepEqual(unifiedConfig.Regions, expectedRegions) { t.Fatalf("Unified regions don't match. Expected %v, got %v", expectedRegions, unifiedConfig.Regions) } expectedZones := []string{"us-central1-a", "us-central1-b", "europe-west1-a", "asia-east1-a", "us-west1-b"} if !reflect.DeepEqual(unifiedConfig.Zones, expectedZones) { t.Fatalf("Unified zones don't match. Expected %v, got %v", expectedZones, unifiedConfig.Zones) } }) } func TestUnifiedAzureConfigs(t *testing.T) { t.Run("Multiple configs with different subscription IDs - no unification", func(t *testing.T) { configs := []*azureproc.AzureConfig{ { SubscriptionID: "00000000-0000-0000-0000-000000000001", TenantID: "tenant-1", ClientID: "client-1", }, { SubscriptionID: "00000000-0000-0000-0000-000000000002", TenantID: "tenant-2", ClientID: "client-2", }, { SubscriptionID: "00000000-0000-0000-0000-000000000003", TenantID: "tenant-3", ClientID: "client-3", }, } result := unifiedAzureConfigs(configs) // Should have 3 configs (no unification since all subscription IDs are different) if len(result) != 3 { t.Fatalf("Expected 3 configs, got %d", len(result)) } // Verify each subscription ID appears exactly once subscriptionIDs := make(map[string]int) for _, config := range result { subscriptionIDs[config.SubscriptionID]++ } expectedSubscriptions := []string{ "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000003", } for _, subID := range expectedSubscriptions { if count, exists := subscriptionIDs[subID]; !exists || count != 1 { t.Fatalf("Expected subscription %s to appear exactly once, got %d", subID, count) } } }) t.Run("Same subscription ID multiple times - uses first config", func(t *testing.T) { configs := []*azureproc.AzureConfig{ { SubscriptionID: "00000000-0000-0000-0000-000000000001", TenantID: "tenant-first", ClientID: "client-first", }, { SubscriptionID: "00000000-0000-0000-0000-000000000001", TenantID: "tenant-second", ClientID: "client-second", }, { SubscriptionID: "00000000-0000-0000-0000-000000000002", TenantID: "tenant-different", ClientID: "client-different", }, } result := unifiedAzureConfigs(configs) // Should have 2 configs (duplicate subscription ID removed) if len(result) != 2 { t.Fatalf("Expected 2 configs, got %d", len(result)) } // Find the config for the duplicated subscription var unifiedConfig *azureproc.AzureConfig var differentConfig *azureproc.AzureConfig for _, config := range result { switch config.SubscriptionID { case "00000000-0000-0000-0000-000000000001": unifiedConfig = config case "00000000-0000-0000-0000-000000000002": differentConfig = config } } if unifiedConfig == nil { t.Fatal("Could not find config for subscription 00000000-0000-0000-0000-000000000001 in result") return } if differentConfig == nil { t.Fatal("Could not find config for subscription 00000000-0000-0000-0000-000000000002 in result") return } // Verify the first config was kept (tenant-first, client-first) if unifiedConfig.TenantID != "tenant-first" { t.Fatalf("Expected tenant_id 'tenant-first', got %s", unifiedConfig.TenantID) } if unifiedConfig.ClientID != "client-first" { t.Fatalf("Expected client_id 'client-first', got %s", unifiedConfig.ClientID) } // Verify the different subscription config is unchanged if differentConfig.TenantID != "tenant-different" { t.Fatalf("Expected tenant_id 'tenant-different', got %s", differentConfig.TenantID) } if differentConfig.ClientID != "client-different" { t.Fatalf("Expected client_id 'client-different', got %s", differentConfig.ClientID) } }) t.Run("Empty configs", func(t *testing.T) { configs := []*azureproc.AzureConfig{} result := unifiedAzureConfigs(configs) if len(result) != 0 { t.Fatalf("Expected 0 configs, got %d", len(result)) } }) t.Run("Single config", func(t *testing.T) { configs := []*azureproc.AzureConfig{ { SubscriptionID: "00000000-0000-0000-0000-000000000001", TenantID: "tenant-1", ClientID: "client-1", }, } result := unifiedAzureConfigs(configs) if len(result) != 1 { t.Fatalf("Expected 1 config, got %d", len(result)) } if result[0].SubscriptionID != "00000000-0000-0000-0000-000000000001" { t.Fatalf("Expected subscription_id '00000000-0000-0000-0000-000000000001', got %s", result[0].SubscriptionID) } if result[0].TenantID != "tenant-1" { t.Fatalf("Expected tenant_id 'tenant-1', got %s", result[0].TenantID) } if result[0].ClientID != "client-1" { t.Fatalf("Expected client_id 'client-1', got %s", result[0].ClientID) } }) } ================================================ FILE: cmd/flags.go ================================================ package cmd import ( "context" "fmt" "strconv" "strings" "time" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/knowledge" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // This file contains re-usable sets of flags that should be used when creating // commands // Adds flags for selecting a change by UUID, frontend URL or ticket link func addChangeUuidFlags(cmd *cobra.Command) { cmd.PersistentFlags().String("change", "", "The frontend URL of the change to get") cmd.PersistentFlags().String("ticket-link", "", "Link to the ticket for this change.") cmd.PersistentFlags().String("uuid", "", "The UUID of the change that should be displayed.") cmd.MarkFlagsMutuallyExclusive("change", "ticket-link", "uuid") } // Adds flags that should be present when creating a change func addChangeCreationFlags(cmd *cobra.Command) { cmd.PersistentFlags().String("title", "", "Short title for this change. If this is not specified, overmind will try to come up with one for you.") cmd.PersistentFlags().String("description", "", "Quick description of the change.") cmd.PersistentFlags().String("ticket-link", "*", "Link to the ticket for this change. Usually this would be the link to something like the pull request, since the CLI uses this as a unique identifier for the change, meaning that multiple runs with the same ticket link will update the same change.") cmd.PersistentFlags().String("owner", "", "The owner of this change.") cmd.PersistentFlags().String("repo", "", "The repository URL that this change should be linked to. This will be automatically detected is possible from the Git config or CI environment.") cmd.PersistentFlags().String("terraform-plan-output", "", "Filename of cached terraform plan output for this change.") cmd.PersistentFlags().String("code-changes-diff", "", "Filename of the code diff of this change.") cmd.PersistentFlags().StringSlice("tags", []string{}, "Tags to apply to this change, these should be specified in key=value format. Multiple tags can be specified by repeating the flag or using a comma separated list.") // ENG-1985, disabled until we decide how manual labels and manual tags should be handled. // cmd.PersistentFlags().StringSlice("labels", []string{}, "Labels to apply to this change, these should be specified in name=color format where color is a hex code (e.g., FF0000 or #FF0000). Multiple labels can be specified by repeating the flag or using a comma separated list.") } func parseTagsArgument() (*sdp.EnrichedTags, error) { tags := map[string]string{} // get into key pair for _, tag := range viper.GetStringSlice("tags") { parts := strings.SplitN(tag, "=", 2) if len(parts) != 2 { return nil, fmt.Errorf("invalid tag format: %s", tag) } tags[parts[0]] = parts[1] } // put into enriched tags enrichedTags := &sdp.EnrichedTags{ TagValue: make(map[string]*sdp.TagValue), } for key, value := range tags { enrichedTags.TagValue[key] = &sdp.TagValue{ Value: &sdp.TagValue_UserTagValue{ UserTagValue: &sdp.UserTagValue{ Value: value, }, }, } } return enrichedTags, nil } func parseLabelsArgument() ([]*sdp.Label, error) { labels := make([]*sdp.Label, 0) for _, label := range viper.GetStringSlice("labels") { parts := strings.SplitN(label, "=", 2) if len(parts) != 2 { return nil, fmt.Errorf("invalid label format: %s (expected name=color)", label) } if parts[0] == "" { return nil, fmt.Errorf("invalid label format: %s (label name cannot be empty)", label) } // Normalise colour: strip leading # if present, validate, then add # back colour := strings.TrimPrefix(parts[1], "#") if colour == "" { return nil, fmt.Errorf("invalid colour format: %s (colour cannot be empty)", parts[1]) } // Validate it's exactly 6 hex digits if len(colour) != 6 { return nil, fmt.Errorf("invalid colour format: %s (must be 6 hex digits, got %d)", parts[1], len(colour)) } // Validate all characters are valid hex digits if _, err := strconv.ParseUint(colour, 16, 64); err != nil { return nil, fmt.Errorf("invalid colour format: %s (must be valid hex digits)", parts[1]) } // Normalise to canonical form: always #rrggbb normalisedColour := "#" + strings.ToUpper(colour) labels = append(labels, &sdp.Label{ Name: parts[0], Colour: normalisedColour, Type: sdp.LabelType_LABEL_TYPE_USER, }) } return labels, nil } // Adds common flags to API commands e.g. timeout func addAPIFlags(cmd *cobra.Command) { cmd.PersistentFlags().String("timeout", "31m", "How long to wait for responses") cmd.PersistentFlags().String("app", "https://app.overmind.tech", "The overmind instance to connect to.") } // Adds terraform-related flags to a command func addTerraformBaseFlags(cmd *cobra.Command) { cmd.PersistentFlags().Bool("reset-stored-config", false, "[deprecated: this is now autoconfigured from local terraform files] Set this to reset the sources config stored in Overmind and input fresh values.") cmd.PersistentFlags().String("aws-config", "", "[deprecated: this is now autoconfigured from local terraform files] The chosen AWS config method, best set through the initial wizard when running the CLI. Options: 'profile_input', 'aws_profile', 'defaults', 'managed'.") cmd.PersistentFlags().String("aws-profile", "", "[deprecated: this is now autoconfigured from local terraform files] Set this to the name of the AWS profile to use.") cobra.CheckErr(cmd.PersistentFlags().MarkHidden("reset-stored-config")) cobra.CheckErr(cmd.PersistentFlags().MarkHidden("aws-config")) cobra.CheckErr(cmd.PersistentFlags().MarkHidden("aws-profile")) cmd.PersistentFlags().Bool("only-use-managed-sources", false, "Set this to skip local autoconfiguration and only use the managed sources as configured in Overmind.") } // Adds analysis-related flags (blast radius config, signal config) to a command. // These flags are shared between submit-plan and start-analysis commands. func addAnalysisFlags(cmd *cobra.Command) { cmd.PersistentFlags().Int32("blast-radius-link-depth", 0, "Used in combination with '--blast-radius-max-items' to customise how many levels are traversed when calculating the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") cmd.PersistentFlags().Int32("blast-radius-max-items", 0, "Used in combination with '--blast-radius-link-depth' to customise how many items are included in the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") cmd.PersistentFlags().Duration("blast-radius-max-time", 0, "Maximum time duration for blast radius calculation (e.g., '5m', '15m', '30m'). When the time limit is reached, the analysis continues with risks identified up to that point. Defaults to the account level settings (QUICK: 10m, DETAILED: 15m, FULL: 30m). Valid range: 1m to 30m.") cobra.CheckErr(cmd.PersistentFlags().MarkDeprecated("blast-radius-max-time", "This flag is no longer used and will be removed in a future release. Use the '--change-analysis-target-duration' flag instead.")) cmd.PersistentFlags().Duration("change-analysis-target-duration", 0, "Target duration for change analysis planning (e.g., '5m', '15m', '30m'). This is NOT a hard deadline - the blast radius phase uses 67% of this target to stop gracefully. The job can run slightly past this target and is only hard-stopped at 30 minutes. Defaults to the account level settings (QUICK: 10m, DETAILED: 15m, FULL: 30m). Valid range: 1m to 30m.") cmd.PersistentFlags().String("signal-config", "", "The path to the signal config file. If not provided, it will check the default location which is '.overmind/signal-config.yaml'. If no config is found locally, the config configured through the UI is used.") cmd.PersistentFlags().StringSlice("knowledge-dir", []string{}, "Knowledge directory paths to load. Can be specified multiple times (--knowledge-dir global --knowledge-dir local) or comma-separated (--knowledge-dir global,local). Later directories override earlier ones when the same knowledge file name appears. If not specified, auto-discovers .overmind/knowledge/ by walking up from the current directory. Example: --knowledge-dir .overmind/knowledge --knowledge-dir ./stacks/prod/.overmind/knowledge") cmd.PersistentFlags().Bool("comment", false, "Request the GitHub App to post analysis results as a PR comment. Requires the account to have the Overmind GitHub App installed with pull_requests:write.") } // AnalysisConfig holds all the configuration needed to start change analysis. type AnalysisConfig struct { BlastRadiusConfig *sdp.BlastRadiusConfig RoutineChangesConfig *sdp.RoutineChangesConfig GithubOrgProfile *sdp.GithubOrganisationProfile KnowledgeFiles []*sdp.Knowledge } // buildAnalysisConfig reads viper flags and builds the analysis configuration // used by StartChangeAnalysis. This includes blast radius config, routine changes // config, github org profile, and knowledge files. func buildAnalysisConfig(ctx context.Context, lf log.Fields) (*AnalysisConfig, error) { maxDepth := viper.GetInt32("blast-radius-link-depth") maxItems := viper.GetInt32("blast-radius-max-items") maxTime := viper.GetDuration("blast-radius-max-time") changeAnalysisTargetDuration := viper.GetDuration("change-analysis-target-duration") blastRadiusConfig, err := createBlastRadiusConfig(maxDepth, maxItems, maxTime, changeAnalysisTargetDuration) if err != nil { return nil, err } signalConfigPath := viper.GetString("signal-config") signalConfigOverride, err := checkForAndLoadSignalConfigFile(ctx, lf, signalConfigPath) if err != nil { return nil, loggedError{ err: err, fields: lf, message: "Failed to load signal config", } } var githubOrgProfile *sdp.GithubOrganisationProfile var routineChangesConfig *sdp.RoutineChangesConfig if signalConfigOverride != nil { githubOrgProfile = signalConfigOverride.GithubOrganisationProfile routineChangesConfig = signalConfigOverride.RoutineChangesConfig } explicitDirs := viper.GetStringSlice("knowledge-dir") knowledgeDirs := knowledge.ResolveKnowledgeDirs(".", explicitDirs) knowledgeFiles := knowledge.DiscoverAndConvert(ctx, knowledgeDirs...) return &AnalysisConfig{ BlastRadiusConfig: blastRadiusConfig, RoutineChangesConfig: routineChangesConfig, GithubOrgProfile: githubOrgProfile, KnowledgeFiles: knowledgeFiles, }, nil } // waitForChangeAnalysis polls the change until analysis reaches a terminal status // (STATUS_DONE, STATUS_SKIPPED, or STATUS_ERROR). It returns nil on successful // completion, or an error if analysis failed or was cancelled. func waitForChangeAnalysis(ctx context.Context, client sdpconnect.ChangesServiceClient, changeUUID uuid.UUID, lf log.Fields) error { for { changeRes, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ Msg: &sdp.GetChangeRequest{ UUID: changeUUID[:], }, }) if err != nil { return loggedError{ err: err, fields: lf, message: "failed to get change", } } if changeRes.Msg == nil || changeRes.Msg.GetChange() == nil { return loggedError{ err: fmt.Errorf("unexpected nil response from GetChange"), fields: lf, message: "failed to get change", } } ch := changeRes.Msg.GetChange() md := ch.GetMetadata() if md == nil || md.GetChangeAnalysisStatus() == nil { return loggedError{ err: fmt.Errorf("change metadata or change analysis status is nil"), fields: lf, message: "failed to get change analysis status", } } status := md.GetChangeAnalysisStatus().GetStatus() switch status { case sdp.ChangeAnalysisStatus_STATUS_DONE, sdp.ChangeAnalysisStatus_STATUS_SKIPPED: log.WithContext(ctx).WithFields(lf).WithField("status", status.String()).Info("Change analysis complete") return nil case sdp.ChangeAnalysisStatus_STATUS_ERROR: return loggedError{ err: fmt.Errorf("change analysis completed with error status"), fields: lf, message: "change analysis failed", } case sdp.ChangeAnalysisStatus_STATUS_UNSPECIFIED, sdp.ChangeAnalysisStatus_STATUS_INPROGRESS: log.WithContext(ctx).WithFields(lf).WithField("status", status.String()).Info("Waiting for change analysis to complete") } time.Sleep(3 * time.Second) if ctx.Err() != nil { return loggedError{ err: ctx.Err(), fields: lf, message: "context cancelled", } } } } ================================================ FILE: cmd/flags_test.go ================================================ package cmd import ( "strings" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/spf13/viper" ) func TestParseLabelsArgument(t *testing.T) { tests := []struct { name string labels []string want []*sdp.Label errorContains string }{ { name: "empty labels", labels: []string{}, want: []*sdp.Label{}, }, { name: "single label with hash", labels: []string{"label1=#FF0000"}, want: []*sdp.Label{ { Name: "label1", Colour: "#FF0000", Type: sdp.LabelType_LABEL_TYPE_USER, }, }, }, { name: "single label without hash", labels: []string{"label1=ff0000"}, want: []*sdp.Label{ { Name: "label1", Colour: "#FF0000", Type: sdp.LabelType_LABEL_TYPE_USER, }, }, }, { name: "single label with lowercase hex", labels: []string{"label1=abc123"}, want: []*sdp.Label{ { Name: "label1", Colour: "#ABC123", Type: sdp.LabelType_LABEL_TYPE_USER, }, }, }, { name: "multiple labels with hash", labels: []string{"label1=#FF0000", "label2=#00FF00", "label3=#0000FF"}, want: []*sdp.Label{ { Name: "label1", Colour: "#FF0000", Type: sdp.LabelType_LABEL_TYPE_USER, }, { Name: "label2", Colour: "#00FF00", Type: sdp.LabelType_LABEL_TYPE_USER, }, { Name: "label3", Colour: "#0000FF", Type: sdp.LabelType_LABEL_TYPE_USER, }, }, }, { name: "multiple labels mixed hash and no hash", labels: []string{"label1=#FF0000", "label2=00FF00", "label3=#0000FF"}, want: []*sdp.Label{ { Name: "label1", Colour: "#FF0000", Type: sdp.LabelType_LABEL_TYPE_USER, }, { Name: "label2", Colour: "#00FF00", Type: sdp.LabelType_LABEL_TYPE_USER, }, { Name: "label3", Colour: "#0000FF", Type: sdp.LabelType_LABEL_TYPE_USER, }, }, }, { name: "missing equals sign", labels: []string{"label1FF0000"}, errorContains: "invalid label format", }, { name: "empty label name", labels: []string{"=#FF0000"}, errorContains: "label name cannot be empty", }, { name: "empty colour", labels: []string{"label1="}, errorContains: "colour cannot be empty", }, { name: "colour too short", labels: []string{"label1=#FF00"}, errorContains: "must be 6 hex digits", }, { name: "colour too long", labels: []string{"label1=#FF00000"}, errorContains: "must be 6 hex digits", }, { name: "invalid hex characters", labels: []string{"label1=#GGGGGG"}, errorContains: "must be valid hex digits", }, { name: "colour without hash too short", labels: []string{"label1=FF00"}, errorContains: "must be 6 hex digits", }, { name: "colour without hash invalid characters", labels: []string{"label1=ZZZZZZ"}, errorContains: "must be valid hex digits", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set up viper with test labels viper.Reset() viper.Set("labels", tt.labels) got, err := parseLabelsArgument() if tt.errorContains != "" { if err == nil { t.Errorf("parseLabelsArgument() expected error containing %q, got nil", tt.errorContains) return } if !strings.Contains(err.Error(), tt.errorContains) { t.Errorf("parseLabelsArgument() error = %v, want error containing %q", err, tt.errorContains) } return } if err != nil { t.Errorf("parseLabelsArgument() unexpected error: %v", err) return } if len(got) != len(tt.want) { t.Errorf("parseLabelsArgument() returned %d labels, want %d", len(got), len(tt.want)) return } for i, wantLabel := range tt.want { if got[i].GetName() != wantLabel.GetName() { t.Errorf("parseLabelsArgument() label[%d].Name = %q, want %q", i, got[i].GetName(), wantLabel.GetName()) } if got[i].GetColour() != wantLabel.GetColour() { t.Errorf("parseLabelsArgument() label[%d].Colour = %q, want %q", i, got[i].GetColour(), wantLabel.GetColour()) } if got[i].GetType() != wantLabel.GetType() { t.Errorf("parseLabelsArgument() label[%d].Type = %v, want %v", i, got[i].GetType(), wantLabel.GetType()) } } }) } } ================================================ FILE: cmd/integrations.go ================================================ package cmd import ( "github.com/spf13/cobra" ) // integrationsCmd represents the integrations command var integrationsCmd = &cobra.Command{ Use: "integrations", GroupID: "api", Short: "Manage integrations with Overmind", Long: `Manage integrations with Overmind. These integrations allow you to integrate Overmind with other tools and services.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } func init() { rootCmd.AddCommand(integrationsCmd) addAPIFlags(integrationsCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // integrationsCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // integrationsCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } ================================================ FILE: cmd/integrations_tfc.go ================================================ package cmd import ( "errors" "fmt" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) // createTfcCmd represents the tfc command var createTfcCmd = &cobra.Command{ Use: "create-tfc", Short: "Initialize the HCP Terraform Cloud integration", Long: "Create the initial set of parameters to configure HCP Terraform to talk to Overmind.", PreRun: PreRunSetup, RunE: CreateTfc, } // getTfcCmd represents the tfc command var getTfcCmd = &cobra.Command{ Use: "get-tfc", Short: "Retrieve the existing parameters for the HCP Terraform Cloud integration", Long: "Retrieve the existing parameters for the HCP Terraform Cloud integration.", PreRun: PreRunSetup, RunE: GetTfc, } // deleteTfcCmd represents the tfc command var deleteTfcCmd = &cobra.Command{ Use: "delete-tfc", Short: "Delete the HCP Terraform Cloud integration", Long: "This will delete the HCP Terraform Cloud integration and disable all access from HCP Terraform Cloud to Overmind.", PreRun: PreRunSetup, RunE: DeleteTfc, } func init() { integrationsCmd.AddCommand(createTfcCmd) integrationsCmd.AddCommand(getTfcCmd) integrationsCmd.AddCommand(deleteTfcCmd) addAPIFlags(createTfcCmd) addAPIFlags(getTfcCmd) addAPIFlags(deleteTfcCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // tfcCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // tfcCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } func CreateTfc(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"config:write", "api_keys:write", "changes:write", "explore:read", "request:send", "reverselink:request"}, nil) if err != nil { return err } client := AuthenticatedConfigurationClient(ctx, oi) fmt.Println("Creating HCP Terraform Cloud integration") params, err := client.CreateHcpConfig(ctx, &connect.Request[sdp.CreateHcpConfigRequest]{ Msg: &sdp.CreateHcpConfigRequest{ FinalFrontendRedirect: oi.FrontendUrl.String(), }, }) if err != nil { return fmt.Errorf("failed to create tfc integration: %w", err) } fmt.Printf("Please visit %v to authorize the integration\nPress return to continue.\n", params.Msg.GetApiKey().GetAuthorizeURL()) _, err = fmt.Scanln() if err != nil { return fmt.Errorf("failed waiting for confirmation: %w", err) } fmt.Println("You can now create a new Run Task in HCP Terraform with the following parameters:") fmt.Println("") fmt.Println("Name: Overmind") fmt.Println("Endpoint URL: ", params.Msg.GetConfig().GetEndpoint()) fmt.Println("Description: Overmind provides a risk analysis and change tracking for your Terraform changes with no extra effort.") fmt.Println("HMAC Key (secret):", params.Msg.GetConfig().GetSecret()) fmt.Println("") log.WithContext(ctx).Info("created tfc integration") return nil } func GetTfc(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"config:read"}, nil) if err != nil { return err } client := AuthenticatedConfigurationClient(ctx, oi) params, err := client.GetHcpConfig(ctx, &connect.Request[sdp.GetHcpConfigRequest]{}) var cErr *connect.Error if errors.As(err, &cErr) { if cErr.Code() == connect.CodeNotFound { fmt.Println("HCP Terraform Cloud integration is not enabled. Use `create-tfc` to enable it.") return nil } } if err != nil { return fmt.Errorf("failed to get tfc integration params: %w", err) } fmt.Println("HCP Terraform Cloud integration found") fmt.Println("") fmt.Println("Name: Overmind") fmt.Println("Endpoint URL: ", params.Msg.GetConfig().GetEndpoint()) fmt.Println("Description: Overmind provides a risk analysis and change tracking for your Terraform changes with no extra effort.") fmt.Println("HMAC Key (secret):", params.Msg.GetConfig().GetSecret()) fmt.Println("") return nil } func DeleteTfc(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"config:write", "api_keys:write"}, nil) if err != nil { return err } client := AuthenticatedConfigurationClient(ctx, oi) fmt.Println("Deleting HCP Terraform Cloud integration") _, err = client.DeleteHcpConfig(ctx, &connect.Request[sdp.DeleteHcpConfigRequest]{}) if err != nil { fmt.Println(err) return fmt.Errorf("failed to delete tfc integration: %w", err) } log.WithContext(ctx).Info("deleted tfc integration") return nil } ================================================ FILE: cmd/invites.go ================================================ package cmd import ( "github.com/spf13/cobra" ) // invitesCmd represents the invites command var invitesCmd = &cobra.Command{ Use: "invites", GroupID: "api", Short: "Manage invites for your team to Overmind", Long: `Create and revoke Overmind invitations`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } func init() { rootCmd.AddCommand(invitesCmd) addAPIFlags(invitesCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // invitesCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // invitesCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } ================================================ FILE: cmd/invites_crud.go ================================================ package cmd import ( "fmt" "os" "connectrpc.com/connect" "github.com/jedib0t/go-pretty/v6/table" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // listCmd represents the list command var listCmd = &cobra.Command{ Use: "list-invites", Short: "List all invites", PreRun: PreRunSetup, RunE: InvitesList, } // createCmd represents the create command var createCmd = &cobra.Command{ Use: "create-invite", Short: "Create a new invite", PreRun: PreRunSetup, RunE: InvitesCreate, } // revokeCmd represents the revoke command var revokeCmd = &cobra.Command{ Use: "revoke-invites", Short: "Revoke an existing invite", PreRun: PreRunSetup, RunE: InvitesRevoke, } func InvitesRevoke(cmd *cobra.Command, args []string) error { ctx := cmd.Context() var err error email := viper.GetString("email") if email == "" { log.WithContext(ctx).Error("You must specify an email address to revoke using --email") return flagError{usage: fmt.Sprintf("You must specify an email address to revoke using --email\n\n%v", cmd.UsageString())} } ctx, oi, _, err := login(ctx, cmd, []string{"account:write"}, nil) if err != nil { return err } client := AuthenticatedInviteClient(ctx, oi) // Create the invite _, err = client.RevokeInvite(ctx, &connect.Request[sdp.RevokeInviteRequest]{ Msg: &sdp.RevokeInviteRequest{ Email: email, }, }) if err != nil { return loggedError{ err: err, fields: log.Fields{"email": email}, message: "failed to revoke invite", } } log.WithContext(ctx).WithFields(log.Fields{"email": email}).Info("Invite revoked successfully") return nil } func InvitesCreate(cmd *cobra.Command, args []string) error { ctx := cmd.Context() emails := viper.GetStringSlice("emails") if len(emails) == 0 { return flagError{usage: fmt.Sprintf("You must specify at least one email address to invite using --emails\n\n%v", cmd.UsageString())} } ctx, oi, _, err := login(ctx, cmd, []string{"account:write"}, nil) if err != nil { return err } client := AuthenticatedInviteClient(ctx, oi) // Create the invite _, err = client.CreateInvite(ctx, &connect.Request[sdp.CreateInviteRequest]{ Msg: &sdp.CreateInviteRequest{ Emails: emails, }, }) if err != nil { return loggedError{ err: err, fields: log.Fields{"emails": emails}, message: "failed to create invite", } } log.WithContext(ctx).WithFields(log.Fields{"emails": emails}).Info("Invites created successfully") return nil } func InvitesList(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"account:read"}, nil) if err != nil { return err } client := AuthenticatedInviteClient(ctx, oi) // List all invites resp, err := client.ListInvites(ctx, &connect.Request[sdp.ListInvitesRequest]{}) if err != nil { return loggedError{ err: err, message: "failed to list invites", } } t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Email", "Status"}) for _, invite := range resp.Msg.GetInvites() { t.AppendRow(table.Row{invite.GetEmail(), invite.GetStatus().String()}) } t.Render() return nil } func init() { // list sub-command invitesCmd.AddCommand(listCmd) // create sub-command invitesCmd.AddCommand(createCmd) createCmd.PersistentFlags().StringSlice("emails", []string{}, "A list of emails to invite") // revoke sub-command invitesCmd.AddCommand(revokeCmd) revokeCmd.PersistentFlags().String("email", "", "The email address to revoke") } ================================================ FILE: cmd/knowledge.go ================================================ package cmd import ( "github.com/spf13/cobra" ) // knowledgeCmd represents the knowledge command var knowledgeCmd = &cobra.Command{ Use: "knowledge", GroupID: "iac", Short: "Manage tribal knowledge files used for change analysis", Long: `Knowledge files in .overmind/knowledge/ help Overmind understand your infrastructure context, giving better change analysis and risk assessment. The 'list' subcommand shows which knowledge files Overmind would discover from your current location, using the same logic as 'overmind terraform plan'.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } func init() { rootCmd.AddCommand(knowledgeCmd) } ================================================ FILE: cmd/knowledge_dir_flag_test.go ================================================ package cmd import ( "testing" "github.com/spf13/cobra" "github.com/spf13/viper" ) // TestKnowledgeDirFlagViperRoundTrip verifies that StringSlice + Viper correctly // round-trips the --knowledge-dir flag value through both repeated and comma-separated formats. // This is a defensive test against framework gotchas with StringSlice flag handling. func TestKnowledgeDirFlagViperRoundTrip(t *testing.T) { tests := []struct { name string args []string expected []string }{ { name: "empty flag", args: []string{}, expected: []string{}, }, { name: "single directory", args: []string{"--knowledge-dir", "/path/to/dir1"}, expected: []string{"/path/to/dir1"}, }, { name: "repeated flags", args: []string{"--knowledge-dir", "/path/to/dir1", "--knowledge-dir", "/path/to/dir2"}, expected: []string{"/path/to/dir1", "/path/to/dir2"}, }, { name: "comma-separated", args: []string{"--knowledge-dir", "/path/to/dir1,/path/to/dir2"}, expected: []string{"/path/to/dir1", "/path/to/dir2"}, }, { name: "mixed repeated and comma-separated", args: []string{"--knowledge-dir", "/path/to/dir1", "--knowledge-dir", "/path/to/dir2,/path/to/dir3"}, expected: []string{"/path/to/dir1", "/path/to/dir2", "/path/to/dir3"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a fresh viper instance for each test v := viper.New() // Create a test command with the knowledge-dir flag cmd := &cobra.Command{ Use: "test", Run: func(cmd *cobra.Command, args []string) {}, } cmd.Flags().StringSlice("knowledge-dir", []string{}, "Test flag") // Bind the flag to viper err := v.BindPFlag("knowledge-dir", cmd.Flags().Lookup("knowledge-dir")) if err != nil { t.Fatalf("failed to bind flag: %v", err) } // Parse the test args cmd.SetArgs(tt.args) err = cmd.Execute() if err != nil { t.Fatalf("failed to execute command: %v", err) } // Get the value from viper result := v.GetStringSlice("knowledge-dir") // Compare results if len(result) != len(tt.expected) { t.Errorf("expected %d directories, got %d: expected=%v, got=%v", len(tt.expected), len(result), tt.expected, result) return } for i := range result { if result[i] != tt.expected[i] { t.Errorf("directory at index %d: expected %q, got %q", i, tt.expected[i], result[i]) } } }) } } ================================================ FILE: cmd/knowledge_list.go ================================================ package cmd import ( "errors" "fmt" "strings" "github.com/overmindtech/pterm" "github.com/overmindtech/cli/knowledge" "github.com/spf13/cobra" "github.com/spf13/viper" ) // ErrInvalidKnowledgeFiles is returned when one or more knowledge files are invalid/skipped. // Used so "knowledge list" can exit non-zero in CI when invalid files are found. var ErrInvalidKnowledgeFiles = errors.New("invalid knowledge files found") // knowledgeListCmd represents the knowledge list command var knowledgeListCmd = &cobra.Command{ Use: "list", Short: "Lists knowledge files that would be used from the current location", PreRun: PreRunSetup, RunE: KnowledgeList, } func KnowledgeList(cmd *cobra.Command, args []string) error { startDir := viper.GetString("dir") explicitDirs := viper.GetStringSlice("knowledge-dir") output, err := renderKnowledgeList(startDir, explicitDirs) fmt.Print(output) if err != nil { return err } return nil } // renderKnowledgeList handles the knowledge list logic and returns formatted output. // This is separated from the command for testability. // If explicitDirs is provided, uses those directories; otherwise falls back to auto-discovery. func renderKnowledgeList(startDir string, explicitDirs []string) (string, error) { var output strings.Builder knowledgeDirs := knowledge.ResolveKnowledgeDirs(startDir, explicitDirs) if len(knowledgeDirs) == 0 { output.WriteString(pterm.Info.Sprint("No .overmind/knowledge/ directory found from current location\n\n")) output.WriteString("Knowledge files help Overmind understand your infrastructure context.\n") output.WriteString("Create a .overmind/knowledge/ directory to add knowledge files.\n") output.WriteString("Without knowledge files, 'terraform plan' will proceed with standard analysis.\n") return output.String(), nil } files, warnings := knowledge.Discover(knowledgeDirs...) // Show resolved directories if len(knowledgeDirs) == 1 { output.WriteString(pterm.Info.Sprintf("Knowledge directory: %s\n\n", knowledgeDirs[0])) } else { output.WriteString(pterm.Info.Sprint("Knowledge directories (later overrides earlier):\n")) for i, dir := range knowledgeDirs { output.WriteString(pterm.Info.Sprintf(" %d. %s\n", i+1, dir)) } output.WriteString("\n") } // Show valid files if len(files) > 0 { output.WriteString(pterm.DefaultHeader.Sprint("Valid Knowledge Files") + "\n\n") // Create table data with Source Dir column when multiple directories var tableData pterm.TableData if len(knowledgeDirs) > 1 { tableData = pterm.TableData{ {"Name", "Description", "File Path", "Source Dir"}, } } else { tableData = pterm.TableData{ {"Name", "Description", "File Path"}, } } for _, f := range files { if len(knowledgeDirs) > 1 { tableData = append(tableData, []string{ f.Name, truncateDescription(f.Description, 60), f.FileName, f.SourceDir, }) } else { tableData = append(tableData, []string{ f.Name, truncateDescription(f.Description, 60), f.FileName, }) } } table, err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Srender() if err != nil { return "", fmt.Errorf("failed to render table: %w", err) } output.WriteString(table) output.WriteString("\n") } else if len(warnings) == 0 { output.WriteString(pterm.Info.Sprint("No knowledge files found\n\n")) } // Show warnings if len(warnings) > 0 { output.WriteString(pterm.DefaultHeader.Sprint("Invalid/Skipped Files") + "\n\n") for _, w := range warnings { output.WriteString(pterm.Warning.Sprintf(" %s\n", w.Path)) fmt.Fprintf(&output, " Reason: %s\n", w.Reason) } output.WriteString("\n") return output.String(), fmt.Errorf("%w (%d file(s))", ErrInvalidKnowledgeFiles, len(warnings)) } return output.String(), nil } // truncateDescription truncates a description to maxLen characters, adding "..." if truncated func truncateDescription(desc string, maxLen int) string { if len(desc) <= maxLen { return desc } return desc[:maxLen-3] + "..." } func init() { knowledgeCmd.AddCommand(knowledgeListCmd) knowledgeListCmd.Flags().String("dir", ".", "Directory to start searching from") cobra.CheckErr(knowledgeListCmd.Flags().MarkHidden("dir")) knowledgeListCmd.Flags().StringSlice("knowledge-dir", []string{}, "Knowledge directory paths to load. Can be specified multiple times or comma-separated. If not specified, auto-discovers .overmind/knowledge/ by walking up from the current directory.") } ================================================ FILE: cmd/knowledge_list_test.go ================================================ package cmd import ( "errors" "os" "path/filepath" "strings" "testing" ) func TestRenderKnowledgeList_NoKnowledgeDir(t *testing.T) { dir := t.TempDir() output, err := renderKnowledgeList(dir, []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(output, "No .overmind/knowledge/ directory found") { t.Errorf("expected message about no directory found, got: %s", output) } if !strings.Contains(output, "Create a .overmind/knowledge/ directory") { t.Errorf("expected helpful message about creating directory, got: %s", output) } if !strings.Contains(output, "terraform plan") { t.Errorf("expected reference to terraform plan, got: %s", output) } } func TestRenderKnowledgeList_EmptyKnowledgeDir(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } output, err := renderKnowledgeList(dir, []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(output, "Knowledge directory:") { t.Errorf("expected resolved directory message, got: %s", output) } if !strings.Contains(output, knowledgeDir) { t.Errorf("expected directory path %s in output, got: %s", knowledgeDir, output) } if !strings.Contains(output, "No knowledge files found") { t.Errorf("expected 'No knowledge files found' message, got: %s", output) } } func TestRenderKnowledgeList_ValidFiles(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create valid knowledge files writeTestFile(t, filepath.Join(knowledgeDir, "aws-s3.md"), `--- name: aws-s3-security description: Security best practices for S3 buckets --- # AWS S3 Security Content here. `) subdir := filepath.Join(knowledgeDir, "cloud") err = os.Mkdir(subdir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(subdir, "gcp.md"), `--- name: gcp-compute description: GCP Compute Engine guidelines --- # GCP Compute Content here. `) output, err := renderKnowledgeList(dir, []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } // Check for resolved directory if !strings.Contains(output, "Knowledge directory:") { t.Errorf("expected resolved directory message, got: %s", output) } if !strings.Contains(output, knowledgeDir) { t.Errorf("expected directory path in output, got: %s", output) } // Check for header if !strings.Contains(output, "Valid Knowledge Files") { t.Errorf("expected 'Valid Knowledge Files' header, got: %s", output) } // Check for first file details if !strings.Contains(output, "aws-s3-security") { t.Errorf("expected file name 'aws-s3-security', got: %s", output) } if !strings.Contains(output, "Security best practices for S3 buckets") { t.Errorf("expected file description, got: %s", output) } if !strings.Contains(output, "aws-s3.md") { t.Errorf("expected file path 'aws-s3.md', got: %s", output) } // Check for second file details if !strings.Contains(output, "gcp-compute") { t.Errorf("expected file name 'gcp-compute', got: %s", output) } if !strings.Contains(output, "GCP Compute Engine guidelines") { t.Errorf("expected file description, got: %s", output) } if !strings.Contains(output, filepath.Join("cloud", "gcp.md")) { t.Errorf("expected file path 'cloud/gcp.md', got: %s", output) } } func TestRenderKnowledgeList_InvalidFiles(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create valid file writeTestFile(t, filepath.Join(knowledgeDir, "valid.md"), `--- name: valid-file description: A valid knowledge file --- Content here. `) // Create invalid file (missing frontmatter) writeTestFile(t, filepath.Join(knowledgeDir, "invalid.md"), `# No frontmatter This file is missing frontmatter. `) output, err := renderKnowledgeList(dir, []string{}) if err == nil { t.Fatal("expected error when invalid files present, got nil") } if !errors.Is(err, ErrInvalidKnowledgeFiles) { t.Errorf("expected ErrInvalidKnowledgeFiles, got: %v", err) } // Check for valid file if !strings.Contains(output, "Valid Knowledge Files") { t.Errorf("expected 'Valid Knowledge Files' header, got: %s", output) } if !strings.Contains(output, "valid-file") { t.Errorf("expected valid file name, got: %s", output) } // Check for warnings section if !strings.Contains(output, "Invalid/Skipped Files") { t.Errorf("expected 'Invalid/Skipped Files' header, got: %s", output) } if !strings.Contains(output, "invalid.md") { t.Errorf("expected invalid file path in warnings, got: %s", output) } if !strings.Contains(output, "Reason:") { t.Errorf("expected reason in warnings, got: %s", output) } } func TestRenderKnowledgeList_OnlyInvalidFiles(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create only invalid files writeTestFile(t, filepath.Join(knowledgeDir, "bad1.md"), `# No frontmatter`) writeTestFile(t, filepath.Join(knowledgeDir, "bad2.md"), `--- name: invalid name with spaces description: This has an invalid name --- Content. `) output, err := renderKnowledgeList(dir, []string{}) if err == nil { t.Fatal("expected error when only invalid files present, got nil") } if !errors.Is(err, ErrInvalidKnowledgeFiles) { t.Errorf("expected ErrInvalidKnowledgeFiles, got: %v", err) } // Should NOT have valid files section if strings.Contains(output, "Valid Knowledge Files") { t.Errorf("should not have 'Valid Knowledge Files' header when all files are invalid, got: %s", output) } // Should have warnings if !strings.Contains(output, "Invalid/Skipped Files") { t.Errorf("expected 'Invalid/Skipped Files' header, got: %s", output) } if !strings.Contains(output, "bad1.md") { t.Errorf("expected bad1.md in warnings, got: %s", output) } if !strings.Contains(output, "bad2.md") { t.Errorf("expected bad2.md in warnings, got: %s", output) } } func TestRenderKnowledgeList_SubdirectoryUsesLocal(t *testing.T) { dir := t.TempDir() // Create parent knowledge dir parentKnowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(parentKnowledgeDir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(parentKnowledgeDir, "parent.md"), `--- name: parent-file description: Parent knowledge file --- Content. `) // Create subdirectory with its own knowledge dir childDir := filepath.Join(dir, "child") childKnowledgeDir := filepath.Join(childDir, ".overmind", "knowledge") err = os.MkdirAll(childKnowledgeDir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(childKnowledgeDir, "child.md"), `--- name: child-file description: Child knowledge file --- Content. `) output, err := renderKnowledgeList(childDir, []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should use child knowledge dir if !strings.Contains(output, childKnowledgeDir) { t.Errorf("expected child knowledge dir %s, got: %s", childKnowledgeDir, output) } if strings.Contains(output, parentKnowledgeDir) { t.Errorf("should not mention parent knowledge dir, got: %s", output) } // Should show child file, not parent file if !strings.Contains(output, "child-file") { t.Errorf("expected child file, got: %s", output) } if strings.Contains(output, "parent-file") { t.Errorf("should not show parent file, got: %s", output) } } func TestRenderKnowledgeList_SubdirectoryUsesParent(t *testing.T) { dir := t.TempDir() // Create parent knowledge dir parentKnowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(parentKnowledgeDir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(parentKnowledgeDir, "parent.md"), `--- name: parent-file description: Parent knowledge file --- Content. `) // Create subdirectory WITHOUT its own knowledge dir childDir := filepath.Join(dir, "child") err = os.Mkdir(childDir, 0o755) if err != nil { t.Fatal(err) } output, err := renderKnowledgeList(childDir, []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should use parent knowledge dir if !strings.Contains(output, parentKnowledgeDir) { t.Errorf("expected parent knowledge dir %s, got: %s", parentKnowledgeDir, output) } // Should show parent file if !strings.Contains(output, "parent-file") { t.Errorf("expected parent file, got: %s", output) } } func TestRenderKnowledgeList_StopsAtGitBoundary(t *testing.T) { dir := t.TempDir() // Create outer directory with knowledge (outside git repo) outerKnowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(outerKnowledgeDir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(outerKnowledgeDir, "outer.md"), `--- name: outer-file description: Knowledge file outside git repo --- Content. `) // Create a git repo subdirectory repoDir := filepath.Join(dir, "my-repo") repoGitDir := filepath.Join(repoDir, ".git") err = os.MkdirAll(repoGitDir, 0o755) if err != nil { t.Fatal(err) } // Create a workspace dir inside the repo (without its own knowledge) workspaceDir := filepath.Join(repoDir, "workspace") err = os.Mkdir(workspaceDir, 0o755) if err != nil { t.Fatal(err) } output, err := renderKnowledgeList(workspaceDir, []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should NOT find outer knowledge dir (stops at .git boundary) if !strings.Contains(output, "No .overmind/knowledge/ directory found") { t.Errorf("expected no knowledge dir found (should stop at .git), got: %s", output) } if strings.Contains(output, "outer-file") { t.Errorf("should not find knowledge from outside git repo, got: %s", output) } } func TestTruncateDescription(t *testing.T) { tests := []struct { name string desc string maxLen int expected string }{ { name: "short description", desc: "Short", maxLen: 20, expected: "Short", }, { name: "exact length", desc: "Exactly twenty char", maxLen: 20, expected: "Exactly twenty char", }, { name: "needs truncation", desc: "This is a very long description that needs to be truncated", maxLen: 20, expected: "This is a very lo...", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := truncateDescription(tt.desc, tt.maxLen) if result != tt.expected { t.Errorf("expected %q, got %q", tt.expected, result) } if len(result) > tt.maxLen { t.Errorf("result length %d exceeds maxLen %d", len(result), tt.maxLen) } }) } } // Multi-directory tests func TestRenderKnowledgeList_ExplicitSingleDir(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(knowledgeDir, "test.md"), `--- name: test-file description: Test file --- Content. `) output, err := renderKnowledgeList(dir, []string{knowledgeDir}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(output, "Knowledge directory:") { t.Errorf("expected single directory message, got: %s", output) } if !strings.Contains(output, "test-file") { t.Errorf("expected test file, got: %s", output) } } func TestRenderKnowledgeList_ExplicitMultipleDirs(t *testing.T) { dir := t.TempDir() // Create global directory globalDir := filepath.Join(dir, "global") err := os.Mkdir(globalDir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(globalDir, "global.md"), `--- name: global-file description: Global file --- Global. `) // Create local directory localDir := filepath.Join(dir, "local") err = os.Mkdir(localDir, 0o755) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(localDir, "local.md"), `--- name: local-file description: Local file --- Local. `) output, err := renderKnowledgeList(dir, []string{globalDir, localDir}) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should show multiple directories header if !strings.Contains(output, "Knowledge directories (later overrides earlier)") { t.Errorf("expected multiple directories header, got: %s", output) } if !strings.Contains(output, globalDir) { t.Errorf("expected global directory in list, got: %s", output) } if !strings.Contains(output, localDir) { t.Errorf("expected local directory in list, got: %s", output) } // Should show both files if !strings.Contains(output, "global-file") { t.Errorf("expected global file, got: %s", output) } if !strings.Contains(output, "local-file") { t.Errorf("expected local file, got: %s", output) } // Should show Source Dir column when multiple directories if !strings.Contains(output, "Source Dir") { t.Errorf("expected Source Dir column for multiple directories, got: %s", output) } } func TestRenderKnowledgeList_ExplicitMissingDir(t *testing.T) { dir := t.TempDir() missingDir := filepath.Join(dir, "missing") // Should handle missing directory gracefully output, err := renderKnowledgeList(dir, []string{missingDir}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(output, "No .overmind/knowledge/ directory found") { t.Errorf("expected no directory message, got: %s", output) } } // Helper function for tests func writeTestFile(t *testing.T, path, content string) { t.Helper() err := os.WriteFile(path, []byte(content), 0o644) if err != nil { t.Fatalf("failed to write file %s: %v", path, err) } } ================================================ FILE: cmd/logging.go ================================================ package cmd import ( "github.com/ttacon/chalk" ) var ( // Styles Underline = TextStyle{chalk.Underline} Bold = TextStyle{chalk.Bold} // Colors Black = Color{chalk.Black} Red = Color{chalk.Red} Green = Color{chalk.Green} Yellow = Color{chalk.Yellow} Blue = Color{chalk.Blue} Magenta = Color{chalk.Magenta} Cyan = Color{chalk.Cyan} White = Color{chalk.White} ) // A type that wraps chalk.TextStyle but adds detections for if we're in a TTY type TextStyle struct { underlying chalk.TextStyle } // A type that wraps chalk.Color but adds detections for if we're in a TTY type Color struct { underlying chalk.Color } ================================================ FILE: cmd/pterm.go ================================================ package cmd import ( "context" "errors" "fmt" "net/http" "os" "os/exec" "strings" "sync/atomic" "time" "connectrpc.com/connect" "github.com/overmindtech/pterm" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/pool" "github.com/spf13/cobra" "github.com/spf13/viper" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "golang.org/x/oauth2" ) func PTermSetup() { pterm.Success.Prefix.Text = OkSymbol() pterm.Warning.Prefix.Text = UnknownSymbol() pterm.Error.Prefix.Text = ErrSymbol() pterm.DefaultMultiPrinter.UpdateDelay = 80 * time.Millisecond pterm.DefaultSpinner.Sequence = []string{" ⠋ ", " ⠙ ", " ⠹ ", " ⠸ ", " ⠼ ", " ⠴ ", " ⠦ ", " ⠧ ", " ⠇ ", " ⠏ "} pterm.DefaultSpinner.Delay = 80 * time.Millisecond // ensure that only error messages are printed to the console, // disrupting bubbletea rendering (and potentially getting overwritten). // Otherwise, when TEABUG is set, log to a file. if len(os.Getenv("TEABUG")) > 0 { f, err := os.OpenFile("teabug.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) if err != nil { fmt.Println("fatal:", err) os.Exit(1) } // leave the log file open until the very last moment, so we capture everything // defer f.Close() log.SetOutput(f) formatter := new(log.TextFormatter) formatter.DisableTimestamp = false log.SetFormatter(formatter) viper.Set("log", "trace") log.SetLevel(log.TraceLevel) } else { // avoid log messages from sources and others to interrupt bubbletea rendering viper.Set("log", "fatal") log.SetLevel(log.FatalLevel) } } func StartSources(ctx context.Context, cmd *cobra.Command, args []string) (context.Context, sdp.OvermindInstance, *oauth2.Token, func(), error) { multi := pterm.DefaultMultiPrinter _, _ = multi.Start() defer func() { _, _ = multi.Stop() }() ctx, oi, token, err := login(ctx, cmd, []string{"explore:read", "changes:write", "config:write", "request:receive", "api:read", "sources:read"}, multi.NewWriter()) if err != nil { return ctx, sdp.OvermindInstance{}, nil, nil, err } // use only-use-managed-sources flag to determine if we should start local sources if viper.GetBool("only-use-managed-sources") { return ctx, oi, token, nil, nil } enableAzurePreview := viper.GetBool("enable-azure-preview") cleanup, err := StartLocalSources(ctx, oi, token, args, false, enableAzurePreview) if err != nil { return ctx, sdp.OvermindInstance{}, nil, nil, err } return ctx, oi, token, cleanup, nil } // start revlink warmup in the background func RunRevlinkWarmup(ctx context.Context, oi sdp.OvermindInstance, postPlanPrinter *atomic.Pointer[pterm.MultiPrinter], args []string) *pool.ErrorPool { p := pool.New().WithErrors() p.Go(func() error { ctx, span := tracing.Tracer().Start(ctx, "revlink warmup") defer span.End() client := AuthenticatedManagementClient(ctx, oi) stream, err := client.RevlinkWarmup(ctx, &connect.Request[sdp.RevlinkWarmupRequest]{ Msg: &sdp.RevlinkWarmupRequest{}, }) if err != nil { return fmt.Errorf("error warming up revlink: %w", err) } // this will get set once the terminal is available var spinner *pterm.SpinnerPrinter for stream.Receive() { msg := stream.Msg() if spinner == nil { multi := postPlanPrinter.Load() if multi != nil { // start the spinner in the background, now that a multi // printer is available spinner, _ = pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Discovering and linking all resources") } } // only update the spinner if we have access to the terminal if spinner != nil { items := msg.GetItems() edges := msg.GetEdges() if items+edges > 0 { spinner.UpdateText(fmt.Sprintf("Discovering and linking all resources: %v (%v items, %v edges)", msg.GetStatus(), items, edges)) } else { spinner.UpdateText(fmt.Sprintf("Discovering and linking all resources: %v", msg.GetStatus())) } } } err = stream.Err() if err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { if spinner != nil { spinner.Fail(fmt.Sprintf("Error warming up revlink: %v", err)) } return fmt.Errorf("error warming up revlink: %w", err) } if spinner != nil { spinner.Success("Discovered and linked all resources") } else { // if we didn't have a spinner, print a success message // this can happen if the terminal is not available, or if the revlink warmup is very fast pterm.Success.Println("Discovered and linked all resources") } return nil }) return p } func RunPlan(ctx context.Context, args []string) error { c := exec.CommandContext(ctx, "terraform", args...) // remove go's default process cancel behaviour, so that terraform has a // chance to gracefully shutdown when ^C is pressed. Otherwise the // process would get killed immediately and leave locks lingering behind c.Cancel = func() error { return nil } c.Stdout = os.Stdout c.Stderr = os.Stderr _, span := tracing.Tracer().Start(ctx, "terraform plan") defer span.End() log.WithField("args", c.Args).Debug("running terraform plan") pterm.Println("Running terraform plan: " + strings.Join(c.Args, " ")) err := c.Run() if err != nil { span.RecordError(err) return fmt.Errorf("failed to run terraform plan: %w", err) } return nil } func RunApply(ctx context.Context, args []string) error { c := exec.CommandContext(ctx, "terraform", args...) // remove go's default process cancel behaviour, so that terraform has a // chance to gracefully shutdown when ^C is pressed. Otherwise the // process would get killed immediately and leave locks lingering behind c.Cancel = func() error { return nil } c.Stdout = os.Stdout c.Stderr = os.Stderr _, span := tracing.Tracer().Start(ctx, "terraform apply") defer span.End() log.WithField("args", c.Args).Debug("running terraform apply") pterm.Println("Running terraform apply: " + strings.Join(c.Args, " ")) err := c.Run() if err != nil { span.RecordError(err) return fmt.Errorf("failed to run terraform apply: %w", err) } return nil } func snapshotDetail(state string, items, edges uint32) string { itemStr := "" switch items { case 0: itemStr = "0 items" case 1: itemStr = "1 item" default: itemStr = fmt.Sprintf("%d items", items) } edgeStr := "" switch edges { case 0: edgeStr = "0 edges" case 1: edgeStr = "1 edge" default: edgeStr = fmt.Sprintf("%d edges", edges) } detailStr := state if itemStr != "" || edgeStr != "" { detailStr = fmt.Sprintf("%s (%s, %s)", state, itemStr, edgeStr) } return detailStr } func natsOptions(ctx context.Context, oi sdp.OvermindInstance, token *oauth2.Token) auth.NATSOptions { hostname, err := os.Hostname() if err != nil { hostname = "localhost" } natsNamePrefix := "overmind-cli" openapiUrl := *oi.ApiUrl openapiUrl.Path = "/api" tokenClient := auth.NewOAuthTokenClientWithContext( ctx, openapiUrl.String(), "", oauth2.StaticTokenSource(token), ) return auth.NATSOptions{ NumRetries: 3, RetryDelay: 1 * time.Second, Servers: []string{oi.NatsUrl.String()}, ConnectionName: fmt.Sprintf("%v.%v", natsNamePrefix, hostname), ConnectionTimeout: (10 * time.Second), // TODO: Make configurable MaxReconnects: -1, ReconnectWait: 1 * time.Second, ReconnectJitter: 1 * time.Second, TokenClient: tokenClient, } } func heartbeatOptions(oi sdp.OvermindInstance, token *oauth2.Token) *discovery.HeartbeatOptions { tokenSource := oauth2.StaticTokenSource(token) transport := oauth2.Transport{ Source: tokenSource, Base: http.DefaultTransport, } authenticatedClient := http.Client{ Transport: otelhttp.NewTransport(&transport), } return &discovery.HeartbeatOptions{ ManagementClient: sdpconnect.NewManagementServiceClient( &authenticatedClient, oi.ApiUrl.String(), ), Frequency: time.Second * 10, } } ================================================ FILE: cmd/repo.go ================================================ package cmd import ( "errors" "fmt" "os" "strings" "gopkg.in/ini.v1" ) var AllDetectors = []RepoDetector{ &RepoDetectorGithubActions{}, &RepoDetectorJenkins{}, &RepoDetectorGitlab{}, &RepoDetectorCircleCI{}, &RepoDetectorAzureDevOps{}, &RepoDetectorSpacelift{}, &RepoDetectorScalr{}, &RepoDetectorGitConfig{}, } // Detects the URL of the repository that the user is working in based on the // environment variables that are set in the user's shell. You should usually // pass in `AllDetectors` to this function, though you can pass in a subset of // detectors if you want to. // // Returns the URL of the repository that the user is working in, or an error if // the URL could not be detected. func DetectRepoURL(detectors []RepoDetector) (string, error) { var errs []error for _, detector := range detectors { if detector == nil { continue } envVars := make(map[string]string) for _, requiredVar := range detector.RequiredEnvVars() { if val, ok := os.LookupEnv(requiredVar); !ok { // If any of the required environment variables are not set, move on to the next detector break } else { envVars[requiredVar] = val } } repoURL, err := detector.DetectRepoURL(envVars) if err != nil { errs = append(errs, err) continue } if repoURL == "" { continue } return repoURL, nil } if len(errs) > 0 { return "", errors.Join(errs...) } return "", errors.New("no repository URL detected") } // RepoDetector is an interface for detecting the URL of the repository that the // user is working in. Implementations should be able to detect the URL of the // repository based on the environment variables that are set in the user's // shell. type RepoDetector interface { // Returns a list of environment variables that are required for the // implementation to detect the repository URL. // // This detector will only be run if all variables are present. If this is // an empty slice the detector will always run. RequiredEnvVars() []string // DetectRepoURL detects the URL of the repository that the user is working // in based on the environment variables that are set. The set of // environment variables that were returned by RequiredEnvVars() will be // passed in as a map, along with their values. // // This means that if RequiredEnvVars() returns ["GIT_DIR"], then // DetectRepoURL will be called with a map containing the value of the // GIT_DIR environment variable. i.e. envVars["GIT_DIR"] will contain the // value of the GIT_DIR environment variable. DetectRepoURL(envVars map[string]string) (string, error) } // Detects the repository URL based on the environment variables that are set in // Github Actions by default. // // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables type RepoDetectorGithubActions struct{} func (d *RepoDetectorGithubActions) RequiredEnvVars() []string { return []string{"GITHUB_SERVER_URL", "GITHUB_REPOSITORY"} } func (d *RepoDetectorGithubActions) DetectRepoURL(envVars map[string]string) (string, error) { serverURL, ok := envVars["GITHUB_SERVER_URL"] if !ok { return "", errors.New("GITHUB_SERVER_URL not set") } repo, ok := envVars["GITHUB_REPOSITORY"] if !ok { return "", errors.New("GITHUB_REPOSITORY not set") } return serverURL + "/" + repo, nil } // Detects the repository URL based on the environment variables that are set in // Jenkins Git plugin by default. // // https://wiki.jenkins.io/JENKINS/Git-Plugin.html type RepoDetectorJenkins struct{} func (d *RepoDetectorJenkins) RequiredEnvVars() []string { return []string{"GIT_URL"} } func (d *RepoDetectorJenkins) DetectRepoURL(envVars map[string]string) (string, error) { gitURL, ok := envVars["GIT_URL"] if !ok { return "", errors.New("GIT_URL not set") } return gitURL, nil } // Detects the repository URL based on teh default env vars from Gitlab // // https://docs.gitlab.com/ee/ci/variables/predefined_variables.html type RepoDetectorGitlab struct{} func (d *RepoDetectorGitlab) RequiredEnvVars() []string { return []string{"CI_SERVER_URL", "CI_PROJECT_PATH"} } func (d *RepoDetectorGitlab) DetectRepoURL(envVars map[string]string) (string, error) { serverURL, ok := envVars["CI_SERVER_URL"] if !ok { return "", errors.New("CI_SERVER_URL not set") } projectPath, ok := envVars["CI_PROJECT_PATH"] if !ok { return "", errors.New("CI_PROJECT_PATH not set") } return serverURL + "/" + projectPath, nil } // Detects the repository URL based on the environment variables that are set in // CircleCI by default. // // https://circleci.com/docs/variables/ type RepoDetectorCircleCI struct{} func (d *RepoDetectorCircleCI) RequiredEnvVars() []string { return []string{"CIRCLE_REPOSITORY_URL"} } func (d *RepoDetectorCircleCI) DetectRepoURL(envVars map[string]string) (string, error) { repoURL, ok := envVars["CIRCLE_REPOSITORY_URL"] if !ok { return "", errors.New("CIRCLE_REPOSITORY_URL not set") } return repoURL, nil } // Detects the repository URL based on the environment variables that are set in // Azure DevOps by default. // // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops type RepoDetectorAzureDevOps struct{} func (d *RepoDetectorAzureDevOps) RequiredEnvVars() []string { return []string{"BUILD_REPOSITORY_URI"} } func (d *RepoDetectorAzureDevOps) DetectRepoURL(envVars map[string]string) (string, error) { repoURL, ok := envVars["BUILD_REPOSITORY_URI"] if !ok { return "", errors.New("BUILD_REPOSITORY_URI not set") } return repoURL, nil } // Detects the repository URL based on the environment variables that are set in // Spacelift by default. // // https://docs.spacelift.io/concepts/configuration/environment.html#environment-variables // // Note that since Spacelift doesn't expose the full URL, you just get the last // bit i.e. username/repo type RepoDetectorSpacelift struct{} func (d *RepoDetectorSpacelift) RequiredEnvVars() []string { return []string{"TF_VAR_spacelift_repository"} } func (d *RepoDetectorSpacelift) DetectRepoURL(envVars map[string]string) (string, error) { repoURL, ok := envVars["TF_VAR_spacelift_repository"] if !ok { return "", errors.New("TF_VAR_spacelift_repository not set") } return repoURL, nil } type RepoDetectorGitConfig struct { // Optional override path to the gitconfig file, only used for testing gitconfigPath string } func (d *RepoDetectorGitConfig) RequiredEnvVars() []string { return []string{""} } // Load the .git/config file and extract the remote URL from it func (d *RepoDetectorGitConfig) DetectRepoURL(envVars map[string]string) (string, error) { var gitConfigPath string if d.gitconfigPath != "" { gitConfigPath = d.gitconfigPath } else { gitConfigPath = ".git/config" } // Try to read the .git/config file gitConfig, err := ini.Load(gitConfigPath) if err != nil { return "", fmt.Errorf("could not open .git/config to determine repo: %w", err) } for _, section := range gitConfig.Sections() { if strings.HasPrefix(section.Name(), "remote") { urlKey, err := section.GetKey("url") if err != nil { continue } return urlKey.String(), nil } } return "", fmt.Errorf("could not find remote URL in %v", gitConfigPath) } type RepoDetectorScalr struct{} func (d *RepoDetectorScalr) RequiredEnvVars() []string { return []string{"SCALR_WORKSPACE_NAME", "SCALR_ENVIRONMENT_NAME"} } func (d *RepoDetectorScalr) DetectRepoURL(envVars map[string]string) (string, error) { workspaceName, ok := envVars["SCALR_WORKSPACE_NAME"] if !ok { return "", errors.New("SCALR_WORKSPACE_NAME not set") } environmentName, ok := envVars["SCALR_ENVIRONMENT_NAME"] if !ok { return "", errors.New("SCALR_ENVIRONMENT_NAME not set") } // A full Scalr URL can be constructed using // "https://$SCALR_HOSTNAME/v2/e/$SCALR_ENVIRONMENT_ID/workspaces/$SCALR_WORKSPACE_ID". // The problem with this is that the environment and workspace IDs are // computer generated and people aren't likely to understand what they mean. // Therefore we are going to go with custom URL scheme to make sure that the // URL is readable return fmt.Sprintf("scalr://%s/%s", environmentName, workspaceName), nil } ================================================ FILE: cmd/repo_test.go ================================================ package cmd import ( "errors" "os" "testing" ) type testDetector struct { requiredEnvVarsCallback func() []string repoURLCallback func(map[string]string) (string, error) } func (d *testDetector) RequiredEnvVars() []string { return d.requiredEnvVarsCallback() } func (d *testDetector) DetectRepoURL(envVars map[string]string) (string, error) { return d.repoURLCallback(envVars) } func TestDetectRepoURL(t *testing.T) { t.Parallel() t.Run("no detectors", func(t *testing.T) { t.Parallel() detectors := []RepoDetector{} repoURL, err := DetectRepoURL(detectors) if err == nil { t.Fatal("expected error") } if repoURL != "" { t.Fatalf("expected empty repoURL, got %q", repoURL) } }) t.Run("with a failing detector", func(t *testing.T) { t.Parallel() detectors := []RepoDetector{ &testDetector{ requiredEnvVarsCallback: func() []string { return []string{"FOO"} }, repoURLCallback: func(map[string]string) (string, error) { return "", errors.New("failed to detect repo URL") }, }, } repoURL, err := DetectRepoURL(detectors) if err == nil { t.Fatal("expected error") } if repoURL != "" { t.Fatalf("expected empty repoURL, got %q", repoURL) } }) t.Run("with multiple failing detectors", func(t *testing.T) { t.Parallel() detectors := []RepoDetector{ &testDetector{ requiredEnvVarsCallback: func() []string { return []string{"FOO"} }, repoURLCallback: func(map[string]string) (string, error) { return "", errors.New("mint") }, }, &testDetector{ requiredEnvVarsCallback: func() []string { return []string{"BAR"} }, repoURLCallback: func(map[string]string) (string, error) { return "", errors.New("choc") }, }, } repoURL, err := DetectRepoURL(detectors) if err == nil { t.Fatal("expected error") } if repoURL != "" { t.Fatalf("expected empty repoURL, got %q", repoURL) } if err.Error() != "mint\nchoc" { t.Fatalf("expected error to contain both messages, got %q", err.Error()) } }) t.Run("with a successful detector", func(t *testing.T) { t.Parallel() detectors := []RepoDetector{ &testDetector{ requiredEnvVarsCallback: func() []string { return []string{"FOO"} }, repoURLCallback: func(map[string]string) (string, error) { return "https://example.com/foo", nil }, }, } repoURL, err := DetectRepoURL(detectors) if err != nil { t.Fatalf("unexpected error: %v", err) } if repoURL != "https://example.com/foo" { t.Fatalf("expected repoURL to be %q, got %q", "https://example.com/foo", repoURL) } }) t.Run("with multiple detectors, one successful", func(t *testing.T) { t.Parallel() detectors := []RepoDetector{ &testDetector{ requiredEnvVarsCallback: func() []string { return []string{"FOO"} }, repoURLCallback: func(map[string]string) (string, error) { return "", nil }, }, &testDetector{ requiredEnvVarsCallback: func() []string { return []string{"BAR"} }, repoURLCallback: func(map[string]string) (string, error) { return "https://example.com/bar", nil }, }, } repoURL, err := DetectRepoURL(detectors) if err != nil { t.Fatalf("unexpected error: %v", err) } if repoURL != "https://example.com/bar" { t.Fatalf("expected repoURL to be %q, got %q", "https://example.com/bar", repoURL) } }) } func TestRepoDetectorGithubActions(t *testing.T) { t.Parallel() t.Run("with valid values", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "GITHUB_REPOSITORY": "owner/repo", "GITHUB_SERVER_URL": "https://github.com", } detector := &RepoDetectorGithubActions{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoUrl := "https://github.com/owner/repo" if repoURL != expectedRepoUrl { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) } }) t.Run("with missing GITHUB_REPOSITORY", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "GITHUB_SERVER_URL": "https://github.com", } detector := &RepoDetectorGithubActions{} repoURL, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } if repoURL != "" { t.Fatalf("expected empty repoURL, got %q", repoURL) } }) } func TestRepoDetectorJenkins(t *testing.T) { t.Parallel() t.Run("with valid GIT_URL", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "GIT_URL": "https://example.com/repo.git", } detector := &RepoDetectorJenkins{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoUrl := "https://example.com/repo.git" if repoURL != expectedRepoUrl { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) } }) t.Run("missing GIT_URL", func(t *testing.T) { t.Parallel() envVars := map[string]string{} detector := &RepoDetectorJenkins{} _, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } expectedError := "GIT_URL not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) } func TestRepoDetectorGitlab(t *testing.T) { t.Parallel() t.Run("with valid CI_SERVER_URL and CI_PROJECT_PATH", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "CI_SERVER_URL": "https://gitlab.com", "CI_PROJECT_PATH": "owner/repo", } detector := &RepoDetectorGitlab{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoUrl := "https://gitlab.com/owner/repo" if repoURL != expectedRepoUrl { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) } }) t.Run("missing CI_SERVER_URL", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "CI_PROJECT_PATH": "owner/repo", } detector := &RepoDetectorGitlab{} _, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } expectedError := "CI_SERVER_URL not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) t.Run("missing CI_PROJECT_PATH", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "CI_SERVER_URL": "https://gitlab.com", } detector := &RepoDetectorGitlab{} _, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } expectedError := "CI_PROJECT_PATH not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) } func TestRepoDetectorCircleCI(t *testing.T) { t.Parallel() t.Run("with valid CIRCLE_REPOSITORY_URL", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "CIRCLE_REPOSITORY_URL": "https://example.com/repo.git", } detector := &RepoDetectorCircleCI{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoUrl := "https://example.com/repo.git" if repoURL != expectedRepoUrl { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) } }) t.Run("missing CIRCLE_REPOSITORY_URL", func(t *testing.T) { t.Parallel() envVars := map[string]string{} detector := &RepoDetectorCircleCI{} _, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } expectedError := "CIRCLE_REPOSITORY_URL not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) } func TestRepoDetectorAzureDevOps(t *testing.T) { t.Parallel() t.Run("with valid BUILD_REPOSITORY_URI", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "BUILD_REPOSITORY_URI": "https://dev.azure.com/organization/project/_git/repo", } detector := &RepoDetectorAzureDevOps{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoUrl := "https://dev.azure.com/organization/project/_git/repo" if repoURL != expectedRepoUrl { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) } }) t.Run("missing BUILD_REPOSITORY_URI", func(t *testing.T) { t.Parallel() envVars := map[string]string{} detector := &RepoDetectorAzureDevOps{} _, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } expectedError := "BUILD_REPOSITORY_URI not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) } func TestRepoDetectorGitConfig(t *testing.T) { t.Parallel() t.Run("With a simple gitconfig", func(t *testing.T) { t.Parallel() gitconfig := `[core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true ignorecase = true precomposeunicode = true [remote "origin"] url = git@github.com:overmindtech/cli.git` // Write gitconfig to a temporary file gitConfigFile, err := os.CreateTemp("", "gitconfig") if err != nil { t.Fatalf("unexpected error: %v", err) } t.Cleanup(func() { os.Remove(gitConfigFile.Name()) }) _, err = gitConfigFile.WriteString(gitconfig) if err != nil { t.Fatalf("unexpected error: %v", err) } detector := RepoDetectorGitConfig{ gitconfigPath: gitConfigFile.Name(), } url, err := detector.DetectRepoURL(map[string]string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedUrl := "git@github.com:overmindtech/cli.git" if url != expectedUrl { t.Fatalf("expected url to be %q, got %q", expectedUrl, url) } }) t.Run("with no gitconfig", func(t *testing.T) { t.Parallel() detector := RepoDetectorGitConfig{ gitconfigPath: "nonexistent-path", } _, err := detector.DetectRepoURL(map[string]string{}) if err == nil { t.Fatal("expected error") } }) t.Run("with a gitconfig with no remote", func(t *testing.T) { t.Parallel() gitconfig := `[core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true ignorecase = true precomposeunicode = true` // Write gitconfig to a temporary file gitConfigFile, err := os.CreateTemp("", "gitconfig") if err != nil { t.Fatalf("unexpected error: %v", err) } t.Cleanup(func() { os.Remove(gitConfigFile.Name()) }) _, err = gitConfigFile.WriteString(gitconfig) if err != nil { t.Fatalf("unexpected error: %v", err) } detector := RepoDetectorGitConfig{ gitconfigPath: gitConfigFile.Name(), } _, err = detector.DetectRepoURL(map[string]string{}) if err == nil { t.Fatal("expected error") } }) t.Run("with an empty gitconfig", func(t *testing.T) { t.Parallel() gitconfig := `` // Write gitconfig to a temporary file gitConfigFile, err := os.CreateTemp("", "gitconfig") if err != nil { t.Fatalf("unexpected error: %v", err) } t.Cleanup(func() { os.Remove(gitConfigFile.Name()) }) _, err = gitConfigFile.WriteString(gitconfig) if err != nil { t.Fatalf("unexpected error: %v", err) } detector := RepoDetectorGitConfig{ gitconfigPath: gitConfigFile.Name(), } _, err = detector.DetectRepoURL(map[string]string{}) if err == nil { t.Fatal("expected error") } }) t.Run("with a gitconfig that isn't a valid ini file", func(t *testing.T) { t.Parallel() gitconfig := `not a valid ini file! =======` // Write gitconfig to a temporary file gitConfigFile, err := os.CreateTemp("", "gitconfig") if err != nil { t.Fatalf("unexpected error: %v", err) } t.Cleanup(func() { os.Remove(gitConfigFile.Name()) }) _, err = gitConfigFile.WriteString(gitconfig) if err != nil { t.Fatalf("unexpected error: %v", err) } detector := RepoDetectorGitConfig{ gitconfigPath: gitConfigFile.Name(), } _, err = detector.DetectRepoURL(map[string]string{}) if err == nil { t.Fatal("expected error") } }) } func TestRepoDetectorScalr(t *testing.T) { t.Parallel() t.Run("with valid SCALR_WORKSPACE_NAME and SCALR_ENVIRONMENT_NAME", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "SCALR_WORKSPACE_NAME": "my-workspace", "SCALR_ENVIRONMENT_NAME": "production", } detector := &RepoDetectorScalr{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoURL := "scalr://production/my-workspace" if repoURL != expectedRepoURL { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoURL, repoURL) } }) t.Run("with missing SCALR_WORKSPACE_NAME", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "SCALR_ENVIRONMENT_NAME": "production", } detector := &RepoDetectorScalr{} repoURL, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } if repoURL != "" { t.Fatalf("expected empty repoURL, got %q", repoURL) } expectedError := "SCALR_WORKSPACE_NAME not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) t.Run("with missing SCALR_ENVIRONMENT_NAME", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "SCALR_WORKSPACE_NAME": "my-workspace", } detector := &RepoDetectorScalr{} repoURL, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } if repoURL != "" { t.Fatalf("expected empty repoURL, got %q", repoURL) } expectedError := "SCALR_ENVIRONMENT_NAME not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) t.Run("with both variables missing", func(t *testing.T) { t.Parallel() envVars := map[string]string{} detector := &RepoDetectorScalr{} repoURL, err := detector.DetectRepoURL(envVars) if err == nil { t.Fatal("expected error") } if repoURL != "" { t.Fatalf("expected empty repoURL, got %q", repoURL) } expectedError := "SCALR_WORKSPACE_NAME not set" if err.Error() != expectedError { t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) } }) t.Run("with empty values", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "SCALR_WORKSPACE_NAME": "", "SCALR_ENVIRONMENT_NAME": "", } detector := &RepoDetectorScalr{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoURL := "scalr:///" if repoURL != expectedRepoURL { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoURL, repoURL) } }) t.Run("with special characters", func(t *testing.T) { t.Parallel() envVars := map[string]string{ "SCALR_WORKSPACE_NAME": "my-workspace-with-dashes_and_underscores", "SCALR_ENVIRONMENT_NAME": "prod-env_123", } detector := &RepoDetectorScalr{} repoURL, err := detector.DetectRepoURL(envVars) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedRepoURL := "scalr://prod-env_123/my-workspace-with-dashes_and_underscores" if repoURL != expectedRepoURL { t.Fatalf("expected repoURL to be %q, got %q", expectedRepoURL, repoURL) } }) } ================================================ FILE: cmd/request.go ================================================ package cmd import ( "context" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpws" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // requestCmd represents the start command var requestCmd = &cobra.Command{ Use: "request", GroupID: "api", Short: "Runs a request against the overmind API", PreRun: func(cmd *cobra.Command, args []string) { // Bind these to viper err := viper.BindPFlags(cmd.Flags()) if err != nil { log.WithError(err).Fatal("could not bind `request` flags") } }, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } // requestHandler is a simple implementation of GatewayMessageHandler that // implements the required logging for the `request` command. type requestHandler struct { lf log.Fields queriesStarted int snapshotLoadResult chan *sdp.SnapshotLoadResult bookmarkLoadResult chan *sdp.BookmarkLoadResult items []*sdp.Item edges []*sdp.Edge msgLog []*sdp.GatewayResponse sdpws.LoggingGatewayMessageHandler } // assert that requestHandler implements GatewayMessageHandler var _ sdpws.GatewayMessageHandler = (*requestHandler)(nil) func (l *requestHandler) NewItem(ctx context.Context, item *sdp.Item) { l.LoggingGatewayMessageHandler.NewItem(ctx, item) l.items = append(l.items, item) l.msgLog = append(l.msgLog, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{NewItem: item}, }) log.WithContext(ctx).WithFields(l.lf).WithField("item", item.GloballyUniqueName()).Infof("new item") } func (l *requestHandler) NewEdge(ctx context.Context, edge *sdp.Edge) { l.LoggingGatewayMessageHandler.NewEdge(ctx, edge) l.edges = append(l.edges, edge) l.msgLog = append(l.msgLog, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewEdge{NewEdge: edge}, }) log.WithContext(ctx).WithFields(l.lf).WithFields(log.Fields{ "from": edge.GetFrom().GloballyUniqueName(), "to": edge.GetTo().GloballyUniqueName(), }).Info("new edge") } func (l *requestHandler) Error(ctx context.Context, errorMessage string) { log.WithContext(ctx).WithFields(l.lf).Errorf("generic error: %v", errorMessage) } func (l *requestHandler) QueryError(ctx context.Context, err *sdp.QueryError) { log.WithContext(ctx).WithFields(l.lf).Errorf("Error for %v from %v(%v): %v", uuid.Must(uuid.FromBytes(err.GetUUID())), err.GetResponderName(), err.GetSourceName(), err) } func (l *requestHandler) QueryStatus(ctx context.Context, status *sdp.QueryStatus) { l.LoggingGatewayMessageHandler.QueryStatus(ctx, status) statusFields := log.Fields{ "status": status.GetStatus().String(), } queryUuid := status.GetUUIDParsed() if queryUuid == nil { log.WithContext(ctx).WithFields(l.lf).WithFields(statusFields).Debug("Received QueryStatus with nil UUID") return } statusFields["query"] = queryUuid if status.GetStatus() == sdp.QueryStatus_STARTED { l.queriesStarted += 1 } //nolint:exhaustive // we _want_ to log all other status fields as unexpected switch status.GetStatus() { case sdp.QueryStatus_STARTED, sdp.QueryStatus_FINISHED, sdp.QueryStatus_ERRORED, sdp.QueryStatus_CANCELLED: // do nothing default: statusFields["unexpected_status"] = true } log.WithContext(ctx).WithFields(l.lf).WithFields(statusFields).Debug("query status update") } // Waits for the next snapshot load result to be received. func (l *requestHandler) WaitSnapshotResult(ctx context.Context) (*sdp.SnapshotLoadResult, error) { select { case result := <-l.snapshotLoadResult: return result, nil case <-ctx.Done(): return nil, ctx.Err() } } // Waits for the next bookmark load result to be received. func (l *requestHandler) WaitBookmarkResult(ctx context.Context) (*sdp.BookmarkLoadResult, error) { select { case result := <-l.bookmarkLoadResult: return result, nil case <-ctx.Done(): return nil, ctx.Err() } } func (l *requestHandler) SnapshotLoadResult(ctx context.Context, result *sdp.SnapshotLoadResult) { log.WithContext(ctx).WithField("result", result).Log(l.Level, "received snapshot load result") l.snapshotLoadResult <- result } func (l *requestHandler) BookmarkLoadResult(ctx context.Context, result *sdp.BookmarkLoadResult) { log.WithContext(ctx).WithField("result", result).Log(l.Level, "received bookmark load result") l.bookmarkLoadResult <- result } func init() { rootCmd.AddCommand(requestCmd) addAPIFlags(requestCmd) } ================================================ FILE: cmd/request_load.go ================================================ package cmd import ( "encoding/json" "fmt" "os" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpws" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // requestLoadCmd represents the start command var requestLoadCmd = &cobra.Command{ Use: "load", Short: "Loads a snapshot or bookmark from the overmind API", PreRun: PreRunSetup, RunE: RequestLoad, } func RequestLoad(cmd *cobra.Command, args []string) error { ctx := cmd.Context() var uuidString string var u uuid.UUID isBookmark := false if viper.GetString("bookmark-uuid") != "" { uuidString = viper.GetString("bookmark-uuid") isBookmark = true } else if viper.GetString("snapshot-uuid") != "" { uuidString = viper.GetString("snapshot-uuid") } else { return flagError{fmt.Sprintf("No bookmark or snapshot UUID provided\n\n%v", cmd.UsageString())} } u, err := uuid.Parse(uuidString) if err != nil { return flagError{fmt.Sprintf("Failed to parse UUID '%v': %v\n\n%v", uuidString, err, cmd.UsageString())} } ctx, oi, _, err := login(ctx, cmd, []string{"explore:read", "changes:read"}, nil) if err != nil { return err } lf := log.Fields{ "uuid": u, } handler := &requestHandler{ lf: lf, LoggingGatewayMessageHandler: sdpws.LoggingGatewayMessageHandler{Level: log.TraceLevel}, items: []*sdp.Item{}, edges: []*sdp.Edge{}, msgLog: []*sdp.GatewayResponse{}, bookmarkLoadResult: make(chan *sdp.BookmarkLoadResult, 128), snapshotLoadResult: make(chan *sdp.SnapshotLoadResult, 128), } gatewayUrl := oi.GatewayUrl() lf["gateway-url"] = gatewayUrl c, err := sdpws.DialBatch(ctx, gatewayUrl, NewAuthenticatedClient(ctx, tracing.HTTPClient()), handler, ) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to connect to overmind API", } } defer c.Close(ctx) // Send the load request if isBookmark { err = c.SendLoadBookmark(ctx, &sdp.LoadBookmark{ UUID: u[:], }) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to send load bookmark request", } } result, err := handler.WaitBookmarkResult(ctx) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to receive for bookmark result", } } log.WithContext(ctx).WithFields(lf).WithField("result", result).Info("bookmark loaded") } else if viper.GetString("snapshot-uuid") != "" { err = c.SendLoadSnapshot(ctx, &sdp.LoadSnapshot{ UUID: u[:], }) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to send load snapshot request", } } result, err := handler.WaitSnapshotResult(ctx) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to receive for snapshot result", } } log.WithContext(ctx).WithFields(lf).WithField("result", result).Info("snapshot loaded") } dumpFileName := viper.GetString("dump-json") if dumpFileName != "" { f, err := os.Create(dumpFileName) if err != nil { lf["file"] = dumpFileName return loggedError{ err: err, fields: lf, message: "Failed to open file for dumping", } } defer f.Close() type dump struct { Msgs []*sdp.GatewayResponse `json:"msgs"` } err = json.NewEncoder(f).Encode(dump{ Msgs: handler.msgLog, }) if err != nil { lf["file"] = dumpFileName return loggedError{ err: err, fields: lf, message: "Failed to dump to file", } } log.WithContext(ctx).WithFields(lf).WithField("file", dumpFileName).Info("dumped to file") } if viper.GetBool("snapshot-after") { log.WithContext(ctx).Info("Starting snapshot") snId, err := c.StoreSnapshot(ctx, viper.GetString("snapshot-name"), viper.GetString("snapshot-description")) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to send snapshot request", } } log.WithContext(ctx).WithFields(lf).Infof("Snapshot stored successfully: %v", snId) } return nil } func init() { requestCmd.AddCommand(requestLoadCmd) addAPIFlags(requestLoadCmd) requestLoadCmd.PersistentFlags().String("dump-json", "", "Dump the request to the given file as JSON") requestLoadCmd.PersistentFlags().String("bookmark-uuid", "", "The UUID of the bookmark or snapshot to load") requestLoadCmd.PersistentFlags().String("snapshot-uuid", "", "The UUID of the snapshot to load") } ================================================ FILE: cmd/request_query.go ================================================ package cmd import ( "encoding/json" "fmt" "os" "time" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpws" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" "google.golang.org/protobuf/types/known/timestamppb" ) // requestQueryCmd represents the start command var requestQueryCmd = &cobra.Command{ Use: "query", Short: "Runs an SDP query against the overmind API", PreRun: PreRunSetup, RunE: RequestQuery, } func RequestQuery(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"explore:read", "changes:read"}, nil) if err != nil { return err } lf := log.Fields{} handler := &requestHandler{ lf: lf, LoggingGatewayMessageHandler: sdpws.LoggingGatewayMessageHandler{Level: log.TraceLevel}, items: []*sdp.Item{}, edges: []*sdp.Edge{}, msgLog: []*sdp.GatewayResponse{}, bookmarkLoadResult: make(chan *sdp.BookmarkLoadResult, 128), snapshotLoadResult: make(chan *sdp.SnapshotLoadResult, 128), } gatewayUrl := oi.GatewayUrl() lf["gateway-url"] = gatewayUrl c, err := sdpws.DialBatch(ctx, gatewayUrl, NewAuthenticatedClient(ctx, tracing.HTTPClient()), handler, ) if err != nil { log.WithContext(ctx).WithFields(lf).WithError(err).Error("Failed to connect to overmind API") return loggedError{ err: err, fields: lf, message: "Failed to connect to overmind API", } } defer c.Close(ctx) q, err := CreateQuery() if err != nil { return flagError{usage: fmt.Sprintf("invalid query: %v\n\n%v", err, cmd.UsageString())} } err = c.SendQuery(ctx, q) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to execute query", } } log.WithContext(ctx).WithFields(lf).WithError(err).Info("received items") // Log the request in JSON b, err := json.MarshalIndent(q, "", " ") if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to marshal query for logging", } } log.WithContext(ctx).WithFields(lf).WithField("uuid", uuid.UUID(q.GetUUID())).Infof("Query:\n%v", string(b)) err = c.Wait(ctx, uuid.UUIDs{uuid.UUID(q.GetUUID())}) if err != nil { log.WithContext(ctx).WithFields(lf).WithError(err).Error("queries failed") } log.WithContext(ctx).WithFields(lf).WithFields(log.Fields{ "queriesStarted": handler.queriesStarted, "itemsReceived": len(handler.items), "edgesReceived": len(handler.edges), }).Info("all queries done") dumpFileName := viper.GetString("dump-json") if dumpFileName != "" { f, err := os.Create(dumpFileName) if err != nil { lf["file"] = dumpFileName return loggedError{ err: err, fields: lf, message: "Failed to open file for dumping", } } defer f.Close() type dump struct { Msgs []*sdp.GatewayResponse `json:"msgs"` } err = json.NewEncoder(f).Encode(dump{ Msgs: handler.msgLog, }) if err != nil { lf["file"] = dumpFileName return loggedError{ err: err, fields: lf, message: "Failed to dump to file", } } log.WithContext(ctx).WithFields(lf).WithField("file", dumpFileName).Info("dumped to file") } if viper.GetBool("snapshot-after") { log.WithContext(ctx).Info("Starting snapshot") snId, err := c.StoreSnapshot(ctx, viper.GetString("snapshot-name"), viper.GetString("snapshot-description")) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to send snapshot request", } } log.WithContext(ctx).WithFields(lf).Infof("Snapshot stored successfully: %v", snId) } return nil } func MethodFromString(method string) (sdp.QueryMethod, error) { var result sdp.QueryMethod switch method { case "get": result = sdp.QueryMethod_GET case "list": result = sdp.QueryMethod_LIST case "search": result = sdp.QueryMethod_SEARCH default: return 0, fmt.Errorf("query method '%v' not supported", method) } return result, nil } func CreateQuery() (*sdp.Query, error) { u := uuid.New() method, err := MethodFromString(viper.GetString("query-method")) if err != nil { return nil, err } return &sdp.Query{ Method: method, Type: viper.GetString("query-type"), Query: viper.GetString("query"), Scope: viper.GetString("query-scope"), Deadline: timestamppb.New(time.Now().Add(10 * time.Hour)), UUID: u[:], RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: viper.GetUint32("link-depth"), }, IgnoreCache: viper.GetBool("ignore-cache"), }, nil } func init() { requestCmd.AddCommand(requestQueryCmd) addAPIFlags(requestQueryCmd) requestQueryCmd.PersistentFlags().String("dump-json", "", "Dump the request to the given file as JSON") requestQueryCmd.PersistentFlags().String("query-method", "get", "The method to use (get, list, search)") requestQueryCmd.PersistentFlags().String("query-type", "*", "The type to query") requestQueryCmd.PersistentFlags().String("query", "", "The actual query to send") requestQueryCmd.PersistentFlags().String("query-scope", "*", "The scope to query") requestQueryCmd.PersistentFlags().Bool("ignore-cache", false, "Set to true to ignore all caches in overmind.") requestQueryCmd.PersistentFlags().Bool("snapshot-after", false, "Set this to create a snapshot of the query results") requestQueryCmd.PersistentFlags().String("snapshot-name", "CLI", "The snapshot name of the query results") requestQueryCmd.PersistentFlags().String("snapshot-description", "none", "The snapshot description of the query results") requestQueryCmd.PersistentFlags().Uint32("link-depth", 0, "How deeply to link") } ================================================ FILE: cmd/root.go ================================================ package cmd import ( "context" _ "embed" "errors" "fmt" "io" "net/url" "os" "os/signal" "path" "strings" "syscall" "time" "connectrpc.com/connect" "github.com/getsentry/sentry-go" "github.com/go-jose/go-jose/v4" josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" "github.com/overmindtech/pterm" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/cliauth" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/uptrace/opentelemetry-go-extra/otellogrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" ) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "overmind", Short: "The Overmind CLI", Long: `Calculate the blast radius of your changes, track risks, and make changes with confidence. This CLI will prompt you for authentication using Overmind's OAuth service, however it can also be configured to use an API key by setting the OVM_API_KEY environment variable.`, Version: tracing.Version(), SilenceUsage: true, PreRun: PreRunSetup, } var cmdSpan trace.Span func PreRunSetup(cmd *cobra.Command, args []string) { ctx := cmd.Context() // Bind these to viper err := viper.BindPFlags(cmd.Flags()) if err != nil { log.WithError(err).Fatalf("could not bind `%v` flags", cmd.CommandPath()) } // set up logging logLevel := viper.GetString("log") var lvl log.Level if logLevel != "" { lvl, err = log.ParseLevel(logLevel) if err != nil { log.WithFields(log.Fields{"level": logLevel, "err": err}).Errorf("couldn't parse `log` config, defaulting to `info`") lvl = log.InfoLevel } } else { lvl = log.ErrorLevel } log.SetLevel(lvl) // set up tracing if honeycombApiKey := viper.GetString("honeycomb-api-key"); honeycombApiKey != "" { if err := tracing.InitTracerWithUpstreams("overmind-cli", honeycombApiKey, ""); err != nil { log.Fatal(err) } log.AddHook(otellogrus.NewHook(otellogrus.WithLevels( log.AllLevels[:log.GetLevel()+1]..., ))) } // set up app, it may be ambiguous if frontend is set app := getAppUrl(viper.GetString("frontend"), viper.GetString("app")) if app == "" { log.Fatal("no app specified, please use --app or set the 'APP' environment variable") } viper.Set("app", app) // capture span in global variable to allow Execute() below to end it ctx, cmdSpan = tracing.Tracer().Start(ctx, fmt.Sprintf("CLI %v", cmd.CommandPath()), trace.WithAttributes( attribute.String("ovm.config", fmt.Sprintf("%v", tracedSettings())), )) cmd.SetContext(ctx) // Check for CLI version updates (non-blocking with timeout) // Run in goroutine to avoid blocking command execution // Use command context so the check is cancelled when command completes currentVersion := tracing.Version() go displayVersionWarning(ctx, currentVersion) } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { formatter := new(log.TextFormatter) formatter.DisableTimestamp = true log.SetFormatter(formatter) log.SetOutput(os.Stderr) // Configure pterm to output to stderr instead of stdout // This ensures status messages don't interfere with piped output pterm.SetDefaultOutput(os.Stderr) pterm.Info.Writer = os.Stderr pterm.Success.Writer = os.Stderr pterm.Warning.Writer = os.Stderr pterm.Error.Writer = os.Stderr // create a sub-scope to run deferred cleanups before shutting down the tracer err := func() error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) // Create a goroutine to watch for cancellation signals and aborting the // running command. Note that bubbletea converts ^C to a Quit message, // so we also need to handle that, but we still need to deal with the // regular signals. go func() { select { case signal := <-sigs: log.Info("Received signal, shutting down") if cmdSpan != nil { cmdSpan.SetAttributes(attribute.Bool("ovm.cli.aborted", true)) cmdSpan.AddEvent("CLI Aborted", trace.WithAttributes( attribute.String("ovm.cli.signal", signal.String()), )) cmdSpan.SetStatus(codes.Error, "CLI aborted by user") } cancel() case <-ctx.Done(): } }() err := rootCmd.ExecuteContext(ctx) if err != nil { switch err := err.(type) { //nolint:errorlint // the selected error types are all top-level wrappers used by the CLI implementation case flagError: // print errors from viper with usage to stderr fmt.Fprintln(os.Stderr, err) case loggedError: log.WithContext(ctx).WithError(err.err).WithFields(err.fields).Error(err.message) } if cmdSpan != nil { // if printing the error was not requested by the appropriate // wrapper, only record the data to honeycomb and sentry, the // command already has handled logging cmdSpan.SetAttributes( attribute.Bool("ovm.cli.fatalError", true), attribute.String("ovm.cli.fatalError.msg", err.Error()), ) cmdSpan.RecordError(err) } sentry.CaptureException(err) } return err }() // shutdown and submit any remaining otel data before exiting if cmdSpan != nil { cmdSpan.End() } tracing.ShutdownTracer(context.Background()) if err != nil { // If we have an error, exit with a non-zero status. Logging is handled by each command. os.Exit(1) } } // ptermLogger adapts pterm output to the cliauth.Logger interface type ptermLogger struct{} func (p *ptermLogger) Info(msg string, keysAndValues ...any) { if len(keysAndValues) > 0 { kvs := make([]string, 0, len(keysAndValues)/2) for i := 0; i+1 < len(keysAndValues); i += 2 { kvs = append(kvs, fmt.Sprintf("%v: %v", keysAndValues[i], keysAndValues[i+1])) } pterm.Info.Println(fmt.Sprintf("%s (%s)", msg, strings.Join(kvs, ", "))) } else { pterm.Info.Println(msg) } } func (p *ptermLogger) Error(msg string, keysAndValues ...any) { if len(keysAndValues) > 0 { kvs := make([]string, 0, len(keysAndValues)/2) for i := 0; i+1 < len(keysAndValues); i += 2 { kvs = append(kvs, fmt.Sprintf("%v: %v", keysAndValues[i], keysAndValues[i+1])) } pterm.Error.Println(fmt.Sprintf("%s (%s)", msg, strings.Join(kvs, ", "))) } else { pterm.Error.Println(msg) } } // getChangeUUIDAndCheckStatus returns the UUID of a change, as selected by --uuid or --change, or a change with the specified status and having --ticket-link func getChangeUUIDAndCheckStatus(ctx context.Context, oi sdp.OvermindInstance, expectedStatus sdp.ChangeStatus, ticketLink string, errorOnNotFound bool) (uuid.UUID, error) { var changeUUID uuid.UUID var err error uuidString := viper.GetString("uuid") changeUrlString := viper.GetString("change") // If no arguments are specified then return an error if uuidString == "" && changeUrlString == "" && ticketLink == "" { return uuid.Nil, errors.New("no change specified; use one of --change, --ticket-link or --uuid") } // Check UUID first if more than one is set if uuidString != "" { changeUUID, err = uuid.Parse(uuidString) if err != nil { return uuid.Nil, fmt.Errorf("invalid --uuid value '%v', error: %w", uuidString, err) } trace.SpanFromContext(ctx).SetAttributes( attribute.String("ovm.change.uuid", changeUUID.String()), ) return changeUUID, nil } // Then check for a change URL if changeUrlString != "" { uuidFromChangeURL, err := parseChangeUrl(changeUrlString) if err != nil { return uuidFromChangeURL, err } trace.SpanFromContext(ctx).SetAttributes( attribute.String("ovm.change.uuid", uuidFromChangeURL.String()), ) return uuidFromChangeURL, nil } // Finally look up by ticket link with retry changeUUID, err = getChangeByTicketLinkWithRetry(ctx, oi, ticketLink, expectedStatus, errorOnNotFound) if errorOnNotFound && err != nil { return uuid.Nil, err } // this could be uuid.Nil if the change is not found and errorOnNotFound is false trace.SpanFromContext(ctx).SetAttributes( attribute.String("ovm.change.uuid", changeUUID.String()), ) return changeUUID, nil } // getChangeUUID resolves a change UUID from --uuid, --change, or --ticket-link without // checking the change status. Use this when the server-side RPC handles status validation // (e.g. EndChangeSimple already validates status atomically and has queuing logic). func getChangeUUID(ctx context.Context, oi sdp.OvermindInstance, ticketLink string) (uuid.UUID, error) { uuidString := viper.GetString("uuid") changeUrlString := viper.GetString("change") // If no arguments are specified then return an error if uuidString == "" && changeUrlString == "" && ticketLink == "" { return uuid.Nil, errors.New("no change specified; use one of --change, --ticket-link or --uuid") } // Check UUID first if more than one is set if uuidString != "" { changeUUID, err := uuid.Parse(uuidString) if err != nil { return uuid.Nil, fmt.Errorf("invalid --uuid value '%v', error: %w", uuidString, err) } trace.SpanFromContext(ctx).SetAttributes( attribute.String("ovm.change.uuid", changeUUID.String()), ) return changeUUID, nil } // Then check for a change URL if changeUrlString != "" { uuidFromChangeURL, err := parseChangeUrl(changeUrlString) if err != nil { return uuidFromChangeURL, err } trace.SpanFromContext(ctx).SetAttributes( attribute.String("ovm.change.uuid", uuidFromChangeURL.String()), ) return uuidFromChangeURL, nil } // Finally look up by ticket link (single attempt, no status check) client := AuthenticatedChangesClient(ctx, oi) change, err := client.GetChangeByTicketLink(ctx, &connect.Request[sdp.GetChangeByTicketLinkRequest]{ Msg: &sdp.GetChangeByTicketLinkRequest{ TicketLink: ticketLink, }, }) if err != nil { return uuid.Nil, fmt.Errorf("error looking up change with ticket link %v: %w", ticketLink, err) } uuidPtr := change.Msg.GetChange().GetMetadata().GetUUIDParsed() if uuidPtr == nil { return uuid.Nil, fmt.Errorf("change found with ticket link %v but has no UUID", ticketLink) } trace.SpanFromContext(ctx).SetAttributes( attribute.String("ovm.change.uuid", uuidPtr.String()), ) return *uuidPtr, nil } // getChangeByTicketLinkWithRetry performs the GetChangeByTicketLink API call with retry logic, // retrying both on error and when the status does not match the expected status. // NB api-server will only return the latest change with this ticket link. func getChangeByTicketLinkWithRetry(ctx context.Context, oi sdp.OvermindInstance, ticketLink string, expectedStatus sdp.ChangeStatus, errorOnNotFound bool) (uuid.UUID, error) { client := AuthenticatedChangesClient(ctx, oi) var change *connect.Response[sdp.GetChangeResponse] var currentStatus sdp.ChangeStatus var err error maxRetries := 3 if !errorOnNotFound { // If not erroring on not found, only attempt once. maxRetries = 1 } retryDelay := 3 * time.Second for attempt := 1; attempt <= maxRetries; attempt++ { change, err = client.GetChangeByTicketLink(ctx, &connect.Request[sdp.GetChangeByTicketLinkRequest]{ Msg: &sdp.GetChangeByTicketLinkRequest{ TicketLink: ticketLink, }, }) if err == nil { // change found var uuidPtr *uuid.UUID if change != nil && change.Msg != nil && change.Msg.GetChange() != nil && change.Msg.GetChange().GetMetadata() != nil { uuidPtr = change.Msg.GetChange().GetMetadata().GetUUIDParsed() currentStatus = change.Msg.GetChange().GetMetadata().GetStatus() if uuidPtr != nil && (currentStatus == expectedStatus) { // Success: we have a UUID and status matches the expected status return *uuidPtr, nil } } } // Log the error and retry if not the last attempt if attempt < maxRetries { logFields := log.Fields{ "ovm.change.ticketLink": ticketLink, "expectedStatus": expectedStatus.String(), "attempt": attempt, "maxRetries": maxRetries, "currentStatus": currentStatus.String(), } if err != nil { logFields["error"] = err.Error() log.WithContext(ctx).WithFields(logFields).Debug("failed to get change by ticket link, retrying") } else { log.WithContext(ctx).WithFields(logFields).Debug("change found but status does not match, retrying") } time.Sleep(retryDelay) } } if err != nil { // Final attempt failed with an error return uuid.Nil, fmt.Errorf("error looking up change with ticket link %v after %d attempts: %w", ticketLink, maxRetries, err) } // Final attempt found a change but status did not match return uuid.Nil, fmt.Errorf("change %s found with ticket link %v. Change status %v does not match expected status %v after %d attempts", change.Msg.GetChange().GetMetadata().GetUUIDParsed(), ticketLink, currentStatus.String(), expectedStatus.String(), maxRetries) } func parseChangeUrl(changeUrlString string) (uuid.UUID, error) { changeUrl, err := url.ParseRequestURI(changeUrlString) if err != nil { return uuid.Nil, fmt.Errorf("invalid --change value '%v', error: %w", changeUrlString, err) } pathParts := strings.Split(path.Clean(changeUrl.Path), "/") if len(pathParts) < 2 { return uuid.Nil, fmt.Errorf("invalid --change value '%v', not long enough: %w", changeUrlString, err) } changeUuid, err := uuid.Parse(pathParts[2]) if err != nil { return uuid.Nil, fmt.Errorf("invalid --change value '%v', couldn't parse UUID: %w", changeUrlString, err) } return changeUuid, nil } type flagError struct { usage string } func (f flagError) Error() string { return f.usage } type loggedError struct { err error fields log.Fields message string } func (l loggedError) Error() string { return fmt.Sprintf("%v (%v): %v", l.message, l.fields, l.err) } func init() { cobra.OnInitialize(initConfig) // Initialize the pallette for lip gloss, it detects the colour of the terminal. InitPalette() rootCmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error { return flagError{fmt.Sprintf("%v\n\n%s", err, c.UsageString())} }) // General Config rootCmd.PersistentFlags().String("log", "info", "Set the log level. Valid values: panic, fatal, error, warn, info, debug, trace") cobra.CheckErr(viper.BindEnv("log", "OVERMIND_LOG", "LOG")) // fallback to global config // Support API Keys in the environment err := viper.BindEnv("api-key", "OVM_API_KEY", "API_KEY") if err != nil { log.WithError(err).Fatal("could not bind api key to env") } // internal configs rootCmd.PersistentFlags().String("honeycomb-api-key", "hcaik_01j03qe0exnn2jxpj2vxkqb7yrqtr083kyk9rxxt2wzjamz8be94znqmwa", "If specified, configures opentelemetry libraries to submit traces to honeycomb.") rootCmd.PersistentFlags().String("sentry-dsn", "https://276b6d99c77358d9bf85aafbff81b515@o4504565700886528.ingest.us.sentry.io/4507413529690112", "If specified, configures the sentry libraries to send error reports to the service.") rootCmd.PersistentFlags().String("ovm-test-fake", "", "If non-empty, instructs some commands to only use fake data for fast development iteration.") rootCmd.PersistentFlags().String("run-mode", "release", "Set the run mode for this command, 'release', 'debug' or 'test'. Defaults to 'release'.") // Mark these as hidden. This means that it will still be parsed of supplied, // and we will still look for it in the environment, but it won't be shown // in the help cobra.CheckErr(rootCmd.PersistentFlags().MarkHidden("honeycomb-api-key")) cobra.CheckErr(rootCmd.PersistentFlags().MarkHidden("sentry-dsn")) cobra.CheckErr(rootCmd.PersistentFlags().MarkHidden("ovm-test-fake")) cobra.CheckErr(rootCmd.PersistentFlags().MarkHidden("run-mode")) // Create groups rootCmd.AddGroup(&cobra.Group{ ID: "iac", Title: "Infrastructure as Code:", }) rootCmd.AddGroup(&cobra.Group{ ID: "api", Title: "Overmind API:", }) } // initConfig reads in config file and ENV variables if set. func initConfig() { replacer := strings.NewReplacer("-", "_") viper.SetEnvKeyReplacer(replacer) viper.AutomaticEnv() // read in environment variables that match } func tracedSettings() map[string]any { result := make(map[string]any) result["log"] = viper.GetString("log") if viper.GetString("api-key") != "" { result["api-key"] = "[REDACTED]" } if viper.GetString("honeycomb-api-key") != "hcaik_01j03qe0exnn2jxpj2vxkqb7yrqtr083kyk9rxxt2wzjamz8be94znqmwa" { result["honeycomb-api-key"] = "[NON-DEFAULT]" } if viper.GetString("sentry-dsn") != "https://276b6d99c77358d9bf85aafbff81b515@o4504565700886528.ingest.us.sentry.io/4507413529690112" { result["sentry-dsn"] = "[NON-DEFAULT]" } result["ovm-test-fake"] = viper.GetString("ovm-test-fake") result["run-mode"] = viper.GetString("run-mode") result["timeout"] = viper.GetString("timeout") result["app"] = viper.GetString("app") result["change"] = viper.GetString("change") if viper.GetString("ticket-link") != "" { result["ticket-link"] = "[REDACTED]" } result["uuid"] = viper.GetString("uuid") return result } func login(ctx context.Context, cmd *cobra.Command, scopes []string, writer io.Writer) (context.Context, sdp.OvermindInstance, *oauth2.Token, error) { timeout, err := time.ParseDuration(viper.GetString("timeout")) if err != nil { return ctx, sdp.OvermindInstance{}, nil, flagError{usage: fmt.Sprintf("invalid --timeout value '%v'\n\n%v", viper.GetString("timeout"), cmd.UsageString())} } lf := log.Fields{ "app": viper.GetString("app"), } var multi *pterm.MultiPrinter if writer == nil { multi = pterm.DefaultMultiPrinter.WithWriter(os.Stderr) _, _ = multi.Start() } else { multi = pterm.DefaultMultiPrinter.WithWriter(writer) } app := viper.GetString("app") if err := cliauth.ConfirmUntrustedHost(app, viper.GetString("api-key") != "", os.Stdin, os.Stderr); err != nil { _, _ = multi.Stop() return ctx, sdp.OvermindInstance{}, nil, err } connectSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Connecting to Overmind") oi, err := sdp.NewOvermindInstance(ctx, app) if err != nil { connectSpinner.Fail("Failed to get instance data from app") _, _ = multi.Stop() return ctx, sdp.OvermindInstance{}, nil, loggedError{ err: err, fields: lf, message: "failed to get instance data from app", } } connectSpinner.Success("Connected to Overmind") _, _ = multi.Stop() ctx, token, err := ensureToken(ctx, oi, scopes) if err != nil { connectSpinner.Fail("Failed to authenticate") return ctx, sdp.OvermindInstance{}, nil, loggedError{ err: err, fields: lf, message: "failed to authenticate", } } // apply a timeout to the main body of processing ctx, _ = context.WithTimeout(ctx, timeout) //nolint:govet,gosec // the context will not leak as the command will exit when it is done return ctx, oi, token, nil } func ensureToken(ctx context.Context, oi sdp.OvermindInstance, requiredScopes []string) (context.Context, *oauth2.Token, error) { apiKey := viper.GetString("api-key") app := viper.GetString("app") token, err := cliauth.GetToken(ctx, oi, app, apiKey, requiredScopes, &ptermLogger{}) if err != nil { return ctx, nil, fmt.Errorf("error getting token: %w", err) } if token == nil { return ctx, nil, fmt.Errorf("error token: nil") } // Add account/auth info to the span for traceability tok, err := josejwt.ParseSigned(token.AccessToken, []jose.SignatureAlgorithm{jose.RS256}) if err != nil { return ctx, nil, fmt.Errorf("Error running program: received invalid token: %w", err) } out := josejwt.Claims{} customClaims := auth.CustomClaims{} err = tok.UnsafeClaimsWithoutVerification(&out, &customClaims) if err != nil { return ctx, nil, fmt.Errorf("Error running program: received unparsable token: %w", err) } trace.SpanFromContext(ctx).SetAttributes( attribute.Bool("ovm.auth.authenticated", true), attribute.String("ovm.auth.accountName", customClaims.AccountName), attribute.String("ovm.auth.scopes", customClaims.Scope), attribute.String("ovm.auth.subject", out.Subject), attribute.String("ovm.auth.expiry", out.Expiry.Time().String()), ) ok, missing, err := cliauth.HasScopesFlexible(token, requiredScopes) if err != nil { return ctx, nil, fmt.Errorf("error checking token scopes: %w", err) } if !ok { return ctx, nil, fmt.Errorf("authenticated successfully, but you don't have the required permission: '%v'", missing) } // Store the token for later use by sdp-go's auth client. Note that this // loses access to the RefreshToken and could be done better by using an // oauth2.TokenSource, but this would require more work on updating sdp-go // that is currently not scheduled. ctx = context.WithValue(ctx, auth.UserTokenContextKey{}, token.AccessToken) return ctx, token, nil } func getAppUrl(frontend, app string) string { if frontend == "" && app == "" { return "https://app.overmind.tech" } if frontend != "" && app == "" { return frontend } if frontend != "" && app != "" { log.Warnf("Both --frontend and --app are set, but they are different. Using --app: %v", app) } return app } ================================================ FILE: cmd/root_test.go ================================================ package cmd import ( _ "embed" "encoding/base64" "encoding/json" "fmt" "testing" "time" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/cliauth" "golang.org/x/oauth2" ) type mockLogger struct{} func (m *mockLogger) Info(msg string, keysAndValues ...any) {} func (m *mockLogger) Error(msg string, keysAndValues ...any) {} func TestParseChangeUrl(t *testing.T) { tests := []struct { input string want string }{ {input: "https://app.overmind.tech/changes/3e717be8-2478-4938-aa9e-70496d496904", want: "3e717be8-2478-4938-aa9e-70496d496904"}, {input: "https://app.overmind.tech/changes/b4454604-b92a-41a7-9f0d-fa66063a7c74/", want: "b4454604-b92a-41a7-9f0d-fa66063a7c74"}, {input: "https://app.overmind.tech/changes/c36f1af4-d55c-4f63-937b-ac5ede7a0cc9/blast-radius", want: "c36f1af4-d55c-4f63-937b-ac5ede7a0cc9"}, } for _, tc := range tests { u, err := parseChangeUrl(tc.input) if err != nil { t.Fatalf("unexpected fail: %v", err) } if u.String() != tc.want { t.Fatalf("expected: %v, got: %v", tc.want, u) } } } func TestHasScopesFlexible(t *testing.T) { claims := &auth.CustomClaims{ Scope: "changes:read users:write", AccountName: "test", } claimBytes, err := json.Marshal(claims) if err != nil { t.Fatalf("unexpected fail marshalling claims: %v", err) } fakeAccessToken := fmt.Sprintf(".%v.", base64.RawURLEncoding.EncodeToString(claimBytes)) token := &oauth2.Token{ AccessToken: fakeAccessToken, TokenType: "", RefreshToken: "", } tests := []struct { Name string RequiredScopes []string ShouldPass bool }{ { Name: "Same scope", RequiredScopes: []string{"changes:read"}, ShouldPass: true, }, { Name: "Multiple scopes", RequiredScopes: []string{"changes:read", "users:write"}, ShouldPass: true, }, { Name: "Missing scope", RequiredScopes: []string{"changes:read", "users:write", "colours:create"}, ShouldPass: false, }, { Name: "Write instead of read", RequiredScopes: []string{"users:read"}, ShouldPass: true, }, } for _, tc := range tests { t.Run(tc.Name, func(t *testing.T) { if pass, _, _ := cliauth.HasScopesFlexible(token, tc.RequiredScopes); pass != tc.ShouldPass { t.Fatalf("expected: %v, got: %v", tc.ShouldPass, !tc.ShouldPass) } }) } } func Test_getAppUrl(t *testing.T) { type args struct { frontend string app string } tests := []struct { name string args args want string }{ {name: "empty", args: args{frontend: "", app: ""}, want: "https://app.overmind.tech"}, {name: "empty app", args: args{frontend: "https://app.overmind.tech", app: ""}, want: "https://app.overmind.tech"}, {name: "empty frontend", args: args{frontend: "", app: "https://app.overmind.tech"}, want: "https://app.overmind.tech"}, {name: "same", args: args{frontend: "https://app.overmind.tech", app: "https://app.overmind.tech"}, want: "https://app.overmind.tech"}, {name: "different", args: args{frontend: "https://app.overmind.tech", app: "https://app.overmind.tech/changes/123"}, want: "https://app.overmind.tech/changes/123"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := getAppUrl(tt.args.frontend, tt.args.app) if got != tt.want { t.Errorf("getAppUrl() = %v, want %v", got, tt.want) } }) } } func TestSaveTokenFile(t *testing.T) { tempDir := t.TempDir() app := "https://localhost.df.overmind-demo.com:3000" log := &mockLogger{} claims := auth.CustomClaims{ Scope: "scope1 scope2", AccountName: "test", } jsonClaims, err := json.Marshal(claims) if err != nil { t.Fatalf("unexpected fail marshalling claims: %v", err) } claimsSection := base64.RawURLEncoding.EncodeToString([]byte(jsonClaims)) accessToken := fmt.Sprintf("%s.%s.%s", "header", claimsSection, "signature") token := &oauth2.Token{ AccessToken: accessToken, Expiry: time.Now().Add(1 * time.Hour), } err = cliauth.SaveLocalToken(tempDir, app, token, log) if err != nil { t.Fatalf("unexpected fail saving token file: %v", err) } readAppToken, readClaims, err := cliauth.ReadLocalToken(tempDir, app, nil, log) if err != nil { t.Fatalf("unexpected fail reading token file: %v", err) } if readAppToken.AccessToken != token.AccessToken { t.Fatalf("expected: %v, got: %v", token.AccessToken, readAppToken.AccessToken) } if readClaims[0] != "scope1" { t.Fatalf("expected: %v, got: %v", "scope1", readClaims[0]) } if readClaims[1] != "scope2" { t.Fatalf("expected: %v, got: %v", "scope2", readClaims[1]) } nonExistentToken, _, err := cliauth.ReadLocalToken(tempDir, "otherApp", nil, log) if err == nil { t.Fatalf("expected error, got nil") } if nonExistentToken == readAppToken { t.Fatalf("expected different tokens, got the same") } otherApp := "otherApp" err = cliauth.SaveLocalToken(tempDir, otherApp, token, log) if err != nil { t.Fatalf("unexpected fail saving token file: %v", err) } readAppToken, _, err = cliauth.ReadLocalToken(tempDir, otherApp, nil, log) if err != nil { t.Fatalf("unexpected fail reading token file: %v", err) } if readAppToken.AccessToken != token.AccessToken { t.Fatalf("expected: %v, got: %v", token.AccessToken, readAppToken.AccessToken) } claims = auth.CustomClaims{ Scope: "scope3 scope4", AccountName: "test", } jsonClaims, err = json.Marshal(claims) if err != nil { t.Fatalf("unexpected fail marshalling claims: %v", err) } claimsSection = base64.RawURLEncoding.EncodeToString([]byte(jsonClaims)) accessToken = fmt.Sprintf("%s.%s.%s", "header", claimsSection, "signature") newToken := &oauth2.Token{ AccessToken: accessToken, Expiry: time.Now().Add(1 * time.Hour), } err = cliauth.SaveLocalToken(tempDir, app, newToken, log) if err != nil { t.Fatalf("unexpected fail saving token file: %v", err) } _, lastClaims, err := cliauth.ReadLocalToken(tempDir, app, nil, log) if err != nil { t.Fatalf("unexpected fail reading token file: %v", err) } if lastClaims[0] != "scope3" { t.Fatalf("expected: %v, got: %v", "scope3", lastClaims[0]) } if lastClaims[1] != "scope4" { t.Fatalf("expected: %v, got: %v", "scope4", lastClaims[1]) } } ================================================ FILE: cmd/snapshots.go ================================================ package cmd import ( "github.com/spf13/cobra" ) // snapshotsCmd represents the snapshots command var snapshotsCmd = &cobra.Command{ Use: "snapshots", GroupID: "api", Short: "Create, view and delete snapshots if your infrastructure", Long: `Overmind automatically creates snapshots are part of the change lifecycle, however you can use these commands to interact directly with the API if required.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } func init() { rootCmd.AddCommand(snapshotsCmd) addAPIFlags(snapshotsCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // snapshotsCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // snapshotsCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } ================================================ FILE: cmd/snapshots_create.go ================================================ package cmd import ( "context" "encoding/json" "fmt" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpws" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // createSnapshotCmd represents the create snapshot command var createSnapshotCmd = &cobra.Command{ Use: "create", Short: "Creates a snapshot by running a query and storing the results", Long: `Creates a snapshot by executing a query with the specified parameters and then storing all discovered items and edges as a named snapshot. This is useful for capturing the state of your infrastructure at a specific point in time. The command accepts the same query parameters as the 'query' command, plus snapshot-specific parameters for naming and describing the snapshot.`, PreRun: PreRunSetup, RunE: CreateSnapshot, } func CreateSnapshot(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, oi, _, err := login(ctx, cmd, []string{"explore:read", "changes:write", "reverselink:request"}, nil) if err != nil { return err } // Validate required snapshot parameters name := viper.GetString("name") if name == "" { return flagError{usage: fmt.Sprintf("snapshot name is required\n\n%v", cmd.UsageString())} } lf := log.Fields{ "snapshot-name": name, } description := viper.GetString("description") if description != "" { lf["snapshot-description"] = description } handler := &createSnapshotHandler{ lf: lf, LoggingGatewayMessageHandler: sdpws.LoggingGatewayMessageHandler{Level: log.InfoLevel}, items: []*sdp.Item{}, edges: []*sdp.Edge{}, } gatewayUrl := oi.GatewayUrl() lf["gateway-url"] = gatewayUrl c, err := sdpws.DialBatch(ctx, gatewayUrl, NewAuthenticatedClient(ctx, tracing.HTTPClient()), handler, ) if err != nil { log.WithContext(ctx).WithFields(lf).WithError(err).Error("Failed to connect to overmind API") return loggedError{ err: err, fields: lf, message: "Failed to connect to overmind API", } } defer c.Close(ctx) // Create and validate the query q, err := CreateQuery() if err != nil { return flagError{usage: fmt.Sprintf("invalid query: %v\n\n%v", err, cmd.UsageString())} } log.WithContext(ctx).WithFields(lf).WithField("uuid", uuid.UUID(q.GetUUID())).Info("Starting query for snapshot creation") // Execute the query err = c.SendQuery(ctx, q) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to execute query", } } // Log the query details b, err := json.MarshalIndent(q, "", " ") if err != nil { log.WithContext(ctx).WithFields(lf).WithError(err).Warn("Failed to marshal query for logging") } else { log.WithContext(ctx).WithFields(lf).WithField( "uuid", uuid.UUID(q.GetUUID()), ).WithField( "query", string(b), ).Debug("Query executed") } // Wait for the query to complete err = c.Wait(ctx, uuid.UUIDs{uuid.UUID(q.GetUUID())}) if err != nil { log.WithContext(ctx).WithFields(lf).WithError(err).Error("Query failed") return loggedError{ err: err, fields: lf, message: "Query execution failed", } } log.WithContext(ctx).WithFields(lf).WithFields(log.Fields{ "itemsCollected": len(handler.items), "edgesCollected": len(handler.edges), }).Info("Query completed, creating snapshot") // Create the snapshot snapshotID, err := c.StoreSnapshot(ctx, name, description) if err != nil { return loggedError{ err: err, fields: lf, message: "Failed to create snapshot", } } log.WithContext(ctx).WithFields(lf).WithFields(log.Fields{ "snapshot-id": snapshotID.String(), "itemsStored": len(handler.items), "edgesStored": len(handler.edges), }).Info("Snapshot created successfully") fmt.Printf("✅ Snapshot created successfully\n") fmt.Printf(" ID: %s\n", snapshotID.String()) fmt.Printf(" Name: %s\n", name) if description != "" { fmt.Printf(" Description: %s\n", description) } fmt.Printf(" Items: %d\n", len(handler.items)) fmt.Printf(" Edges: %d\n", len(handler.edges)) return nil } // createSnapshotHandler is a simple implementation of GatewayMessageHandler for snapshot creation type createSnapshotHandler struct { lf log.Fields items []*sdp.Item edges []*sdp.Edge sdpws.LoggingGatewayMessageHandler } // assert that createSnapshotHandler implements GatewayMessageHandler var _ sdpws.GatewayMessageHandler = (*createSnapshotHandler)(nil) func (h *createSnapshotHandler) NewItem(ctx context.Context, item *sdp.Item) { h.LoggingGatewayMessageHandler.NewItem(ctx, item) h.items = append(h.items, item) } func (h *createSnapshotHandler) NewEdge(ctx context.Context, edge *sdp.Edge) { h.LoggingGatewayMessageHandler.NewEdge(ctx, edge) h.edges = append(h.edges, edge) } func init() { snapshotsCmd.AddCommand(createSnapshotCmd) addAPIFlags(createSnapshotCmd) // Query parameters (reused from query command) createSnapshotCmd.PersistentFlags().String("query-method", "get", "The method to use (get, list, search)") createSnapshotCmd.PersistentFlags().String("query-type", "*", "The type to query") createSnapshotCmd.PersistentFlags().String("query", "", "The actual query to send") createSnapshotCmd.PersistentFlags().String("query-scope", "*", "The scope to query") createSnapshotCmd.PersistentFlags().Bool("ignore-cache", false, "Set to true to ignore all caches in overmind") createSnapshotCmd.PersistentFlags().Uint32("link-depth", 0, "How deeply to link") createSnapshotCmd.PersistentFlags().Bool("blast-radius", false, "Whether to query using blast radius, note that if using this option, link-depth should be set to > 0") // Snapshot-specific parameters createSnapshotCmd.PersistentFlags().String("name", "", "The name for the snapshot (required)") createSnapshotCmd.PersistentFlags().String("description", "", "The description for the snapshot") // Mark name as required _ = createSnapshotCmd.MarkPersistentFlagRequired("name") } ================================================ FILE: cmd/snapshots_get_snapshot.go ================================================ package cmd import ( "encoding/json" "fmt" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // getSnapshotCmd represents the get-snapshot command var getSnapshotCmd = &cobra.Command{ Use: "get-snapshot --uuid ID", Short: "Displays the contents of a snapshot.", PreRun: PreRunSetup, RunE: GetSnapshot, } func GetSnapshot(cmd *cobra.Command, args []string) error { ctx := cmd.Context() snapshotUuid, err := uuid.Parse(viper.GetString("uuid")) if err != nil { return flagError{usage: fmt.Sprintf("invalid --uuid value '%v', error: %v\n\n%v", viper.GetString("uuid"), err, cmd.UsageString())} } ctx, oi, _, err := login(ctx, cmd, []string{"explore:read", "changes:read"}, nil) if err != nil { return err } client := AuthenticatedSnapshotsClient(ctx, oi) response, err := client.GetSnapshot(ctx, &connect.Request[sdp.GetSnapshotRequest]{ Msg: &sdp.GetSnapshotRequest{ UUID: snapshotUuid[:], }, }) if err != nil { return loggedError{ err: err, message: "failed to get snapshot", } } log.WithContext(ctx).WithFields(log.Fields{ "snapshot-uuid": uuid.UUID(response.Msg.GetSnapshot().GetMetadata().GetUUID()), "snapshot-created": response.Msg.GetSnapshot().GetMetadata().GetCreated().AsTime(), "snapshot-name": response.Msg.GetSnapshot().GetProperties().GetName(), "snapshot-description": response.Msg.GetSnapshot().GetProperties().GetDescription(), }).Info("found snapshot") for _, q := range response.Msg.GetSnapshot().GetProperties().GetQueries() { log.WithContext(ctx).WithFields(log.Fields{ "snapshot-query": q, }).Info("found snapshot query") } for _, i := range response.Msg.GetSnapshot().GetProperties().GetItems() { log.WithContext(ctx).WithFields(log.Fields{ "snapshot-item": i, }).Info("found snapshot item") } b, err := json.MarshalIndent(response.Msg.GetSnapshot().ToMap(), "", " ") if err != nil { log.Infof("Error rendering snapshot: %v", err) } else { fmt.Println(string(b)) } return nil } func init() { snapshotsCmd.AddCommand(getSnapshotCmd) getSnapshotCmd.PersistentFlags().String("uuid", "", "The UUID of the snapshot that should be displayed.") } ================================================ FILE: cmd/terraform.go ================================================ package cmd import ( "strings" "github.com/spf13/cobra" ) // terraformCmd represents the terraform command var terraformCmd = &cobra.Command{ Use: "terraform", GroupID: "iac", Short: "Run Terraform with Overmind's risk analysis and change tracking", Long: `By using 'overmind terraform plan/apply' in place of your normal 'terraform plan/apply' commands, you can get a risk analysis and change tracking for your Terraform changes with no extra effort. Plan: Overmind will run a normal plan, then determine the potential blast radius using real-time data from AWS and Kubernetes. It will then analyse the risks that the changes pose to your infrastructure and return them at the command line. Apply: Overmind will do all the same steps as a plan, plus it will take a snapshot before and after the actual apply, meaning that you get a diff of everything that happened, including any unexpected repercussions.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } func init() { rootCmd.AddCommand(terraformCmd) addChangeCreationFlags(terraformCmd) // hidden flag to enable Azure preview support terraformCmd.PersistentFlags().Bool("enable-azure-preview", false, "Enable Azure source support (preview feature).") cobra.CheckErr(terraformCmd.PersistentFlags().MarkHidden("enable-azure-preview")) } var applyOnlyArgs = []string{ "auto-approve", } var planOnlyArgs = []string{ "var", "var-file", } // planArgsFromApplyArgs filters out all apply-specific arguments from arguments // to `terraform apply`, so that we can run the corresponding `terraform plan` // command func planArgsFromApplyArgs(args []string) []string { planArgs := []string{} appendLoop: for _, arg := range args { for _, applyOnlyArg := range applyOnlyArgs { if strings.HasPrefix(arg, "-"+applyOnlyArg) { continue appendLoop } if strings.HasPrefix(arg, "--"+applyOnlyArg) { continue appendLoop } } planArgs = append(planArgs, arg) } return planArgs } // applyArgsFromApplyArgs filters out all plan-specific arguments from arguments to `terraform apply`, so that we can run the corresponding `terraform apply` command func applyArgsFromApplyArgs(args []string) []string { applyArgs := []string{} appendLoop: for _, arg := range args { for _, planOnlyArg := range planOnlyArgs { if strings.HasPrefix(arg, "-"+planOnlyArg) { continue appendLoop } if strings.HasPrefix(arg, "--"+planOnlyArg) { continue appendLoop } } applyArgs = append(applyArgs, arg) } return applyArgs } ================================================ FILE: cmd/terraform_apply.go ================================================ package cmd import ( "context" "errors" "fmt" "os" "strings" "time" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/pterm" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // terraformApplyCmd represents the `terraform apply` command var terraformApplyCmd = &cobra.Command{ Use: "apply [overmind options...] -- [terraform options...]", Short: "Runs `terraform apply` between two full system configuration snapshots for tracking. This will be automatically connected with the Change created by the `plan` command.", PreRun: PreRunSetup, RunE: TerraformApply, // CmdWrapper("apply", []string{"explore:read", "changes:write", "config:write", "request:receive"}, NewTfApplyModel), } func TerraformApply(cmd *cobra.Command, args []string) error { ctx := cmd.Context() // span := trace.SpanFromContext(ctx) PTermSetup() hasPlanSet := false autoApprove := false planFile := "overmind.plan" if len(args) >= 1 { f, err := os.Stat(args[len(args)-1]) if err == nil && !f.IsDir() { // the last argument is a file, check that the previous arg is not // one that would eat this as argument hasPlanSet = true if len(args) >= 2 { prev := args[len(args)-2] for _, a := range []string{"-backup", "--backup", "-state", "--state", "-state-out", "--state-out"} { if prev == a || strings.HasPrefix(prev, a+"=") { hasPlanSet = false break } } } } if hasPlanSet { planFile = args[len(args)-1] autoApprove = true } } planArgs := append([]string{"plan"}, planArgsFromApplyArgs(args)...) if !hasPlanSet { // if the user has not set a plan, we need to set a temporary file to // capture the output for all calculations and to run apply afterwards f, err := os.CreateTemp("", "overmind-plan") if err != nil { log.WithError(err).Fatal("failed to create temporary plan file") } planFile = f.Name() planArgs = append(planArgs, "-out", planFile) args = append(args, planFile) // check for auto-approval setting on the command line. note that // terraform will ignore -auto-approve if a plan file is supplied, // therefore we only check for the flag when no plan file is supplied for _, a := range args { if a == "-auto-approve" || a == "-auto-approve=true" || a == "-auto-approve=TRUE" || a == "--auto-approve" || a == "--auto-approve=true" || a == "--auto-approve=TRUE" { autoApprove = true } if a == "-auto-approve=false" || a == "-auto-approve=FALSE" || a == "--auto-approve=false" || a == "--auto-approve=FALSE" { autoApprove = false } } } args = append([]string{"apply"}, args...) needPlan := !hasPlanSet needApproval := !autoApprove ctx, oi, _, cleanup, err := StartSources(ctx, cmd, args) if err != nil { return err } defer cleanup() if needPlan { err := TerraformPlanImpl(ctx, cmd, oi, planArgs, planFile) if err != nil { return err } } if needApproval { pterm.Println("") pterm.Println("Do you want to perform these actions?") pterm.Println("") pterm.Println("Terraform will perform the actions described above.") result, _ := pterm.DefaultInteractiveTextInput.WithDefaultText("Only 'yes' will be accepted to approve").Show() if result != "yes" { return errors.New("aborted by user") } } return TerraformApplyImpl(ctx, cmd, oi, args, planFile) } func TerraformApplyImpl(ctx context.Context, cmd *cobra.Command, oi sdp.OvermindInstance, args []string, planFile string) error { client := AuthenticatedChangesClient(ctx, oi) changeUuid, err := func() (uuid.UUID, error) { multi := pterm.DefaultMultiPrinter _, _ = multi.Start() defer func() { _, _ = multi.Stop() }() var err error ticketLink := viper.GetString("ticket-link") if ticketLink == "" { ticketLink, err = getTicketLinkFromPlan(planFile) if err != nil { return uuid.Nil, err } } changeUuid, err := getChangeUUIDAndCheckStatus(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, ticketLink, true) if err != nil { return uuid.Nil, fmt.Errorf("failed to identify change: %w", err) } startingChangeSnapshotSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Starting Change") startStream, err := client.StartChange(ctx, &connect.Request[sdp.StartChangeRequest]{ Msg: &sdp.StartChangeRequest{ ChangeUUID: changeUuid[:], }, }) if err != nil { startingChangeSnapshotSpinner.Fail(fmt.Sprintf("Starting Change: %v", err)) return uuid.Nil, fmt.Errorf("failed to start change: %w", err) } var startMsg *sdp.StartChangeResponse lastLog := time.Now().Add(-1 * time.Minute) for startStream.Receive() { startMsg = startStream.Msg() // print progress every 2 seconds if time.Now().After(lastLog.Add(2 * time.Second)) { log.WithFields(log.Fields{ "state": startMsg.GetState(), "items": startMsg.GetNumItems(), "edges": startMsg.GetNumEdges(), }).Trace("progress") lastLog = time.Now() } stateLabel := "unknown" switch startMsg.GetState() { case sdp.StartChangeResponse_STATE_UNSPECIFIED: stateLabel = "unknown" case sdp.StartChangeResponse_STATE_TAKING_SNAPSHOT: stateLabel = "capturing current state" case sdp.StartChangeResponse_STATE_SAVING_SNAPSHOT: stateLabel = "saving state" case sdp.StartChangeResponse_STATE_DONE: stateLabel = "done" } startingChangeSnapshotSpinner.UpdateText(fmt.Sprintf("Starting Change: %v", snapshotDetail(stateLabel, startMsg.GetNumItems(), startMsg.GetNumEdges()))) } if startStream.Err() != nil { startingChangeSnapshotSpinner.Fail(fmt.Sprintf("Starting Change: %v", startStream.Err())) return uuid.Nil, startStream.Err() } startingChangeSnapshotSpinner.Success() return changeUuid, nil }() if err != nil { return err } // apply the args filtering here, after providers have been configured above // (which might still need --var and --var-file information) err = RunApply(ctx, applyArgsFromApplyArgs(args)) if err != nil { return err } multi := pterm.DefaultMultiPrinter _, _ = multi.Start() defer func() { _, _ = multi.Stop() }() endingChangeSnapshotSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Ending Change") endStream, err := client.EndChange(ctx, &connect.Request[sdp.EndChangeRequest]{ Msg: &sdp.EndChangeRequest{ ChangeUUID: changeUuid[:], }, }) if err != nil { endingChangeSnapshotSpinner.Fail(fmt.Sprintf("Ending Change: %v", err)) return fmt.Errorf("failed to end change: %w", err) } var endMsg *sdp.EndChangeResponse lastLog := time.Now().Add(-1 * time.Minute) for endStream.Receive() { endMsg = endStream.Msg() // print progress every 2 seconds if time.Now().After(lastLog.Add(2 * time.Second)) { log.WithFields(log.Fields{ "state": endMsg.GetState(), "items": endMsg.GetNumItems(), "edges": endMsg.GetNumEdges(), }).Trace("progress") lastLog = time.Now() } stateLabel := "unknown" switch endMsg.GetState() { case sdp.EndChangeResponse_STATE_UNSPECIFIED: stateLabel = "unknown" case sdp.EndChangeResponse_STATE_TAKING_SNAPSHOT: stateLabel = "capturing current state" case sdp.EndChangeResponse_STATE_SAVING_SNAPSHOT: stateLabel = "saving state" case sdp.EndChangeResponse_STATE_DONE: stateLabel = "done" } endingChangeSnapshotSpinner.UpdateText(fmt.Sprintf("Ending Change: %v", snapshotDetail(stateLabel, endMsg.GetNumItems(), endMsg.GetNumEdges()))) } if endStream.Err() != nil { endingChangeSnapshotSpinner.Fail(fmt.Sprintf("Ending Change: %v", endStream.Err())) return endStream.Err() } endingChangeSnapshotSpinner.Success() return nil } func init() { terraformCmd.AddCommand(terraformApplyCmd) addAPIFlags(terraformApplyCmd) addChangeUuidFlags(terraformApplyCmd) addTerraformBaseFlags(terraformApplyCmd) } ================================================ FILE: cmd/terraform_plan.go ================================================ package cmd import ( "context" "crypto/sha256" "fmt" "os" "os/exec" "slices" "strings" "sync/atomic" "time" lipgloss "charm.land/lipgloss/v2" "connectrpc.com/connect" "github.com/google/uuid" "github.com/muesli/reflow/wordwrap" "github.com/overmindtech/pterm" "github.com/overmindtech/cli/tfutils" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // terraformPlanCmd represents the `terraform plan` command var terraformPlanCmd = &cobra.Command{ Use: "plan [overmind options...] -- [terraform options...]", Short: "Runs `terraform plan` and sends the results to Overmind to calculate a blast radius and risks.", PreRun: PreRunSetup, RunE: TerraformPlan, } func TerraformPlan(cmd *cobra.Command, args []string) error { ctx := cmd.Context() PTermSetup() hasPlanOutSet := false planFile := "overmind.plan" for i, a := range args { if a == "-out" || a == "--out=true" { hasPlanOutSet = true planFile = args[i+1] } if strings.HasPrefix(a, "-out=") { hasPlanOutSet = true planFile, _ = strings.CutPrefix(a, "-out=") } if strings.HasPrefix(a, "--out=") { hasPlanOutSet = true planFile, _ = strings.CutPrefix(a, "--out=") } } args = append([]string{"plan"}, args...) if !hasPlanOutSet { // if the user has not set a plan, we need to set a temporary file to // capture the output for the blast radius and risks calculation f, err := os.CreateTemp("", "overmind-plan") if err != nil { log.WithError(err).Fatal("failed to create temporary plan file") } planFile = f.Name() args = append(args, "-out", planFile) // TODO: remember whether we used a temporary plan file and remove it when done } ctx, oi, _, cleanup, err := StartSources(ctx, cmd, args) if err != nil { return err } if cleanup != nil { defer cleanup() } return TerraformPlanImpl(ctx, cmd, oi, args, planFile) } func TerraformPlanImpl(ctx context.Context, cmd *cobra.Command, oi sdp.OvermindInstance, args []string, planFile string) error { span := trace.SpanFromContext(ctx) // this printer will be configured once the terraform plan command has // completed and the terminal is available again postPlanPrinter := atomic.Pointer[pterm.MultiPrinter]{} revlinkPool := RunRevlinkWarmup(ctx, oi, &postPlanPrinter, args) err := RunPlan(ctx, args) if err != nil { return err } log.Debug("done running terraform plan") // start showing revlink warmup status now that the terminal is free multi := pterm.DefaultMultiPrinter _, _ = multi.Start() defer func() { _, _ = multi.Stop() }() // create a spinner for removing secrets before publishing `multi` to the // postPlanPrinter, so that "removing secrets" is shown before the revlink // status updates removingSecretsSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Removing secrets") postPlanPrinter.Store(&multi) /////////////////////////////////////////////////////////////////// // Convert provided plan into JSON for easier parsing /////////////////////////////////////////////////////////////////// tfPlanJsonCmd := exec.CommandContext(ctx, "terraform", "show", "-json", planFile) tfPlanJsonCmd.Stderr = multi.NewWriter() // send output through PTerm; is usually empty log.WithField("args", tfPlanJsonCmd.Args).Debug("converting plan to JSON") planJson, err := tfPlanJsonCmd.Output() if err != nil { removingSecretsSpinner.Fail(fmt.Sprintf("Removing secrets: %v", err)) return fmt.Errorf("failed to convert terraform plan to JSON: %w", err) } removingSecretsSpinner.Success() // Detect the repository URL if it wasn't provided repoUrl := viper.GetString("repo") if repoUrl == "" { repoUrl, _ = DetectRepoURL(AllDetectors) } /////////////////////////////////////////////////////////////////// // Extract changes from the plan and created mapped item diffs /////////////////////////////////////////////////////////////////// resourceExtractionSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Extracting resources") resourceExtractionResults := multi.NewWriter() time.Sleep(200 * time.Millisecond) // give the UI a little time to update scope := tfutils.RepoToScope(repoUrl) // Map the terraform changes to Overmind queries mappingResponse, err := tfutils.MappedItemDiffsFromPlan(ctx, planJson, planFile, scope, log.Fields{}) if err != nil { resourceExtractionSpinner.Fail(fmt.Sprintf("Removing secrets: %v", err)) return nil } removingSecretsSpinner.Success(fmt.Sprintf("Removed %v secrets", mappingResponse.RemovedSecrets)) resourceExtractionSpinner.UpdateText(fmt.Sprintf("Extracted %v changing resources: %v supported %v skipped %v unsupported %v pending creation\n", mappingResponse.NumTotal(), mappingResponse.NumSuccess(), mappingResponse.NumNotEnoughInfo(), mappingResponse.NumUnsupported(), mappingResponse.NumPendingCreation(), )) // Sort the supported and unsupported changes so that they display nicely slices.SortFunc(mappingResponse.Results, func(a, b tfutils.PlannedChangeMapResult) int { return int(a.Status) - int(b.Status) }) // render the list of supported and unsupported changes for the UI for _, mapping := range mappingResponse.Results { var printer pterm.PrefixPrinter switch mapping.Status { case tfutils.MapStatusSuccess: printer = pterm.Success case tfutils.MapStatusNotEnoughInfo: printer = pterm.Warning case tfutils.MapStatusUnsupported: printer = pterm.Error case tfutils.MapStatusPendingCreation: printer = pterm.Info } line := printer.Sprintf("%v (%v)", mapping.TerraformName, mapping.Message) _, err = fmt.Fprintf(resourceExtractionResults, " %v\n", line) if err != nil { return fmt.Errorf("error writing to resource extraction results: %w", err) } } time.Sleep(200 * time.Millisecond) // give the UI a little time to update resourceExtractionSpinner.Success() // wait for the revlink warmup for 15 seconds. if it takes longer, we'll just continue waitCh := make(chan error, 1) go func() { waitCh <- revlinkPool.Wait() }() select { case err = <-waitCh: if err != nil { return fmt.Errorf("error waiting for revlink warmup: %w", err) } case <-time.After(15 * time.Second): pterm.Info.Print("Done waiting for revlink warmup") } /////////////////////////////////////////////////////////////////// // try to link up the plan with a Change and start submitting to the API /////////////////////////////////////////////////////////////////// uploadChangesSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Uploading planned changes") ticketLink := viper.GetString("ticket-link") if ticketLink == "" { ticketLink, err = getTicketLinkFromPlan(planFile) if err != nil { uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed to get ticket link from plan: %v", err)) return nil } } client := AuthenticatedChangesClient(ctx, oi) changeUuid, err := getChangeUUIDAndCheckStatus(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, ticketLink, false) if err != nil { uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed searching for existing changes: %v", err)) return nil } title := changeTitle(ctx, viper.GetString("title")) tfPlanTextCmd := exec.CommandContext(ctx, "terraform", "show", planFile) tfPlanTextCmd.Stderr = multi.NewWriter() // send output through PTerm; is usually empty log.WithField("args", tfPlanTextCmd.Args).Debug("pretty-printing plan") tfPlanOutput, err := tfPlanTextCmd.Output() if err != nil { uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed to pretty-print plan: %v", err)) return nil } codeChangesOutput := tryLoadText(ctx, viper.GetString("code-changes-diff")) enrichedTags, err := parseTagsArgument() if err != nil { uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed to parse tags: %v", err)) return nil } labels, err := parseLabelsArgument() if err != nil { uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed to parse labels: %v", err)) return nil } properties := &sdp.ChangeProperties{ Title: title, Description: viper.GetString("description"), TicketLink: ticketLink, Owner: viper.GetString("owner"), RawPlan: string(tfPlanOutput), CodeChanges: codeChangesOutput, Repo: repoUrl, EnrichedTags: enrichedTags, Labels: labels, } if changeUuid == uuid.Nil { uploadChangesSpinner.UpdateText("Uploading planned changes (new)") log.Debug("Creating a new change") createResponse, err := client.CreateChange(ctx, &connect.Request[sdp.CreateChangeRequest]{ Msg: &sdp.CreateChangeRequest{ Properties: properties, }, }) if err != nil { uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed to create a new change: %v", err)) return nil } maybeChangeUuid := createResponse.Msg.GetChange().GetMetadata().GetUUIDParsed() if maybeChangeUuid == nil { uploadChangesSpinner.Fail("Uploading planned changes: failed to read change id") return nil } changeUuid = *maybeChangeUuid span.SetAttributes( attribute.String("ovm.change.uuid", changeUuid.String()), attribute.Bool("ovm.change.new", true), ) } else { uploadChangesSpinner.UpdateText("Uploading planned changes (update)") log.WithField("change", changeUuid).Debug("Updating an existing change") _, err := client.UpdateChange(ctx, &connect.Request[sdp.UpdateChangeRequest]{ Msg: &sdp.UpdateChangeRequest{ UUID: changeUuid[:], Properties: properties, }, }) if err != nil { uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed to update change: %v", err)) return nil } } time.Sleep(200 * time.Millisecond) // give the UI a little time to update uploadChangesSpinner.Success() /////////////////////////////////////////////////////////////////// // Upload the planned changes to the API /////////////////////////////////////////////////////////////////// uploadPlannedChange, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Uploading planned changes") log.WithField("change", changeUuid).Debug("Uploading planned changes") // Build analysis configuration (includes knowledge files) analysisConfig, err := buildAnalysisConfig(ctx, log.Fields{"change": changeUuid}) if err != nil { uploadPlannedChange.Fail(fmt.Sprintf("Uploading planned changes: failed to build analysis config: %v", err)) return nil } _, err = client.StartChangeAnalysis(ctx, &connect.Request[sdp.StartChangeAnalysisRequest]{ Msg: &sdp.StartChangeAnalysisRequest{ ChangeUUID: changeUuid[:], ChangingItems: mappingResponse.GetItemDiffs(), Knowledge: analysisConfig.KnowledgeFiles, }, }) if err != nil { uploadPlannedChange.Fail(fmt.Sprintf("Uploading planned changes: failed to update: %v", err)) return nil } uploadPlannedChange.Success("Uploaded planned changes: Done") changeUrl := *oi.FrontendUrl changeUrl.Path = fmt.Sprintf("%v/changes/%v", changeUrl.Path, changeUuid) log.WithField("change-url", changeUrl.String()).Info("Change ready") /////////////////////////////////////////////////////////////////// // wait for change analysis to complete (poll GetChange by change_analysis_status) /////////////////////////////////////////////////////////////////// changeAnalysisSpinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Change Analysis") retryLoop: for { changeRes, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ Msg: &sdp.GetChangeRequest{ UUID: changeUuid[:], }, }) if err != nil { changeAnalysisSpinner.Fail(fmt.Sprintf("Change Analysis failed to get change: %v", err)) return fmt.Errorf("failed to get change during change analysis: %w", err) } if changeRes.Msg == nil || changeRes.Msg.GetChange() == nil { changeAnalysisSpinner.Fail("Change Analysis failed: received empty change response") return fmt.Errorf("change analysis failed: received empty change response") } ch := changeRes.Msg.GetChange() md := ch.GetMetadata() if md == nil || md.GetChangeAnalysisStatus() == nil { changeAnalysisSpinner.Fail("Change Analysis failed: change metadata or analysis status missing") return fmt.Errorf("change analysis failed: change metadata or change analysis status is nil") } status := md.GetChangeAnalysisStatus().GetStatus() switch status { case sdp.ChangeAnalysisStatus_STATUS_DONE, sdp.ChangeAnalysisStatus_STATUS_SKIPPED: changeAnalysisSpinner.Success() break retryLoop case sdp.ChangeAnalysisStatus_STATUS_ERROR: changeAnalysisSpinner.Fail("Change analysis failed") return fmt.Errorf("change analysis completed with error status") case sdp.ChangeAnalysisStatus_STATUS_UNSPECIFIED, sdp.ChangeAnalysisStatus_STATUS_INPROGRESS: // keep polling } time.Sleep(3 * time.Second) if ctx.Err() != nil { changeAnalysisSpinner.Fail("Cancelled") return ctx.Err() } } risksRes, err := client.GetChangeRisks(ctx, &connect.Request[sdp.GetChangeRisksRequest]{ Msg: &sdp.GetChangeRisksRequest{ UUID: changeUuid[:], }, }) if err != nil { return fmt.Errorf("failed to get calculated risks: %w", err) } if risksRes.Msg == nil { return fmt.Errorf("failed to get calculated risks: response message was nil") } if risksRes.Msg.GetChangeRiskMetadata() == nil { return fmt.Errorf("failed to get calculated risks: change risk metadata was nil") } calculatedRisks := risksRes.Msg.GetChangeRiskMetadata().GetRisks() // Submit milestone for tracing if cmdSpan != nil { cmdSpan.AddEvent("Change Analysis finished", trace.WithAttributes( attribute.Int("ovm.risks.count", len(calculatedRisks)), attribute.String("ovm.change.uuid", changeUuid.String()), )) } bits := []string{} bits = append(bits, "") bits = append(bits, "") if len(calculatedRisks) == 0 { bits = append(bits, styleH1().Render("Potential Risks")) bits = append(bits, "") bits = append(bits, "Overmind has not identified any risks associated with this change.") bits = append(bits, "") bits = append(bits, "This could be due to the change being low risk with no impact on other parts of the system, or involving resources that Overmind currently does not support.") } else if changeUrl.String() != "" { bits = append(bits, styleH1().Render("Potential Risks")) bits = append(bits, "") for _, r := range calculatedRisks { severity := "" switch r.GetSeverity() { case sdp.Risk_SEVERITY_HIGH: severity = lipgloss.NewStyle(). Background(ColorPalette.BgDanger). Foreground(ColorPalette.LabelTitle). Padding(0, 1). Bold(true). Render("High ‼") case sdp.Risk_SEVERITY_MEDIUM: severity = lipgloss.NewStyle(). Background(ColorPalette.BgWarning). Foreground(ColorPalette.LabelTitle). Padding(0, 1). Render("Medium !") case sdp.Risk_SEVERITY_LOW: severity = lipgloss.NewStyle(). Background(ColorPalette.LabelBase). Foreground(ColorPalette.LabelTitle). Padding(0, 1). Render("Low ⓘ ") case sdp.Risk_SEVERITY_UNSPECIFIED: // do nothing } title := lipgloss.NewStyle(). Foreground(ColorPalette.BgMain). PaddingRight(1). Bold(true). Render(r.GetTitle()) bits = append(bits, fmt.Sprintf("%v%v\n\n%v", title, severity, wordwrap.String(r.GetDescription(), min(160, pterm.GetTerminalWidth()-4)))) riskUUID, _ := uuid.FromBytes(r.GetUUID()) riskURL := fmt.Sprintf("%v/blast-radius?selectedRisk=%v&utm_source=cli&cli_version=%v", changeUrl.String(), riskUUID.String(), tracing.Version()) bits = append(bits, fmt.Sprintf("%v\n\n", osc8Hyperlink(riskURL, "View risk ↗"))) } changeURLWithUTM := fmt.Sprintf("%v?utm_source=cli&cli_version=%v", changeUrl.String(), tracing.Version()) bits = append(bits, fmt.Sprintf("\nView the blast radius graph and risks:\n%v\n\n", osc8Hyperlink(changeURLWithUTM, "Open in Overmind ↗"))) } pterm.Fprintln(multi.NewWriter(), strings.Join(bits, "\n")) return nil } // supportsOSCHyperlinks checks if the terminal likely supports OSC 8 hyperlinks. // Combines a TTY check with environment-based heuristics. func supportsOSCHyperlinks() bool { if fi, err := os.Stdout.Stat(); err != nil || fi.Mode()&os.ModeCharDevice == 0 { return false } return envSupportsOSCHyperlinks() } // envSupportsOSCHyperlinks checks environment variables to determine if the terminal // likely supports OSC 8 hyperlinks. Split out from supportsOSCHyperlinks so that tests // can exercise the env heuristics in isolation — go test pipes stdout, so the // TTY check in supportsOSCHyperlinks always fails under test. func envSupportsOSCHyperlinks() bool { if os.Getenv("CI") != "" { return false } if term := os.Getenv("TERM"); term == "dumb" { return false } if strings.HasPrefix(os.Getenv("TERM"), "screen") && os.Getenv("TMUX") == "" { return false } if os.Getenv("TERM_PROGRAM") != "" { return true } if os.Getenv("VTE_VERSION") != "" { return true } if os.Getenv("TERM") == "xterm-kitty" { return true } if strings.Contains(os.Getenv("TERM"), "256color") { return true } return false } // osc8Hyperlink returns an OSC 8 hyperlink if the terminal supports it, otherwise // the raw URL. Supported by iTerm2, GNOME Terminal, Windows Terminal, WezTerm, // kitty, Alacritty; degrades gracefully in unsupported terminals. func osc8Hyperlink(url, text string) string { if supportsOSCHyperlinks() { return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) } return url } // getTicketLinkFromPlan reads the plan file to create a unique hash to identify this change func getTicketLinkFromPlan(planFile string) (string, error) { plan, err := os.ReadFile(planFile) if err != nil { return "", fmt.Errorf("failed to read plan file (%v): %w", planFile, err) } h := sha256.New() h.Write(plan) return fmt.Sprintf("tfplan://{SHA256}%x", h.Sum(nil)), nil } func init() { terraformCmd.AddCommand(terraformPlanCmd) addAPIFlags(terraformPlanCmd) addChangeUuidFlags(terraformPlanCmd) addTerraformBaseFlags(terraformPlanCmd) addAnalysisFlags(terraformPlanCmd) } ================================================ FILE: cmd/terraform_plan_test.go ================================================ package cmd import ( "fmt" "os" "strings" "testing" lipgloss "charm.land/lipgloss/v2" "github.com/muesli/reflow/wordwrap" ) func TestOSC8Hyperlink(t *testing.T) { t.Parallel() url := "https://app.overmind.tech/changes/abc/blast-radius?selectedRisk=xyz&utm_source=cli&cli_version=0.42.0" text := "View risk ↗" // In tests, stdout is not a TTY, so supportsOSCHyperlinks() returns false // and osc8Hyperlink falls back to the raw URL. result := osc8Hyperlink(url, text) if result != url { t.Errorf("osc8Hyperlink() = %q, want raw URL %q when stdout is not a TTY", result, url) } } func TestEnvSupportsOSC8(t *testing.T) { tests := []struct { name string env map[string]string want bool }{ {"CI disables", map[string]string{"CI": "true"}, false}, {"dumb terminal", map[string]string{"TERM": "dumb"}, false}, {"screen without tmux", map[string]string{"TERM": "screen"}, false}, {"screen with tmux and 256color", map[string]string{"TERM": "screen-256color", "TMUX": "/tmp/tmux-1000/default,12345,0"}, true}, {"TERM_PROGRAM set", map[string]string{"TERM_PROGRAM": "iTerm.app"}, true}, {"VTE_VERSION set", map[string]string{"VTE_VERSION": "6800"}, true}, {"xterm-kitty", map[string]string{"TERM": "xterm-kitty"}, true}, {"256color", map[string]string{"TERM": "xterm-256color"}, true}, {"no signals", map[string]string{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("CI", "") t.Setenv("TERM", "") t.Setenv("TMUX", "") t.Setenv("TERM_PROGRAM", "") t.Setenv("VTE_VERSION", "") for k, v := range tt.env { t.Setenv(k, v) } if got := envSupportsOSCHyperlinks(); got != tt.want { t.Errorf("envSupportsOSCHyperlinks() = %v, want %v", got, tt.want) } }) } } // TestRenderRiskPreview prints an exact replica of the CLI risk output using // the real lipgloss styles and theme. Run from an interactive terminal with: // // go test ./cli/cmd/ -run TestRenderRiskPreview -v // // This is a visual inspection test, not an assertion-based test. It formats the // OSC 8 escape directly because go test pipes stdout through the test runner, // which fails the TTY check in supportsOSCHyperlinks. The real CLI runs in the user's // terminal where the TTY check passes naturally. func TestRenderRiskPreview(t *testing.T) { if os.Getenv("CI") == "true" { t.Skip("visual inspection test — skipped in CI") } InitPalette() changeURL := "https://app.overmind.tech/changes/d7f79e24-d123-40f2-9f5d-7296cff5fc7b" cliVersion := "0.42.0" type fakeRisk struct { title string description string severity string riskUUID string } risks := []fakeRisk{ { title: "Security group opens port 22 to 0.0.0.0/0", description: "Opening SSH to all IPs exposes the instance to brute-force attacks and unauthorized access. The security group sg-0abc123 allows inbound TCP/22 from 0.0.0.0/0, making it reachable from any IP on the internet.", severity: "high", riskUUID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", }, { title: "Load balancer target group has no health check", description: "Without health checks, traffic may be routed to unhealthy instances causing user-facing errors. Target group arn:aws:elasticloadbalancing:us-east-1:123456:tg/my-tg has no health check configured.", severity: "medium", riskUUID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", }, { title: "Route table change may affect private subnet connectivity", description: "Modifying route table rtb-0def456 could disrupt connectivity for instances in subnet-789ghi that rely on the NAT gateway for outbound traffic.", severity: "low", riskUUID: "c3d4e5f6-a7b8-9012-cdef-123456789012", }, } osc8 := func(url, text string) string { return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) } bits := []string{"", ""} bits = append(bits, styleH1().Render("Potential Risks")) bits = append(bits, "") for _, r := range risks { var severity string switch r.severity { case "high": severity = lipgloss.NewStyle(). Background(ColorPalette.BgDanger). Foreground(ColorPalette.LabelTitle). Padding(0, 1). Bold(true). Render("High ‼") case "medium": severity = lipgloss.NewStyle(). Background(ColorPalette.BgWarning). Foreground(ColorPalette.LabelTitle). Padding(0, 1). Render("Medium !") case "low": severity = lipgloss.NewStyle(). Background(ColorPalette.LabelBase). Foreground(ColorPalette.LabelTitle). Padding(0, 1). Render("Low ⓘ ") } title := lipgloss.NewStyle(). Foreground(ColorPalette.BgMain). PaddingRight(1). Bold(true). Render(r.title) bits = append(bits, fmt.Sprintf("%v%v\n\n%v", title, severity, wordwrap.String(r.description, 160))) riskURL := fmt.Sprintf("%v/blast-radius?selectedRisk=%v&utm_source=cli&cli_version=%v", changeURL, r.riskUUID, cliVersion) bits = append(bits, fmt.Sprintf("%v\n\n", osc8(riskURL, "View risk ↗"))) } changeURLWithUTM := fmt.Sprintf("%v?utm_source=cli&cli_version=%v", changeURL, cliVersion) bits = append(bits, fmt.Sprintf("\nView the blast radius graph and risks:\n%v\n\n", osc8(changeURLWithUTM, "Open in Overmind ↗"))) fmt.Println(strings.Join(bits, "\n")) } ================================================ FILE: cmd/theme.go ================================================ package cmd import ( _ "embed" "fmt" "image/color" "os" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/ansi" lipgloss "charm.land/lipgloss/v2" ) // constrain the maximum terminal width to avoid readability issues with too // long lines const MAX_TERMINAL_WIDTH = 120 type LogoPalette struct { a string b string c string d string e string f string } type Palette struct { BgBase color.Color BgBaseHover color.Color BgShade color.Color BgSub color.Color BgBorder color.Color BgBorderHover color.Color BgDivider color.Color BgMain color.Color BgMainHover color.Color BgDanger color.Color BgDangerHover color.Color BgSuccess color.Color BgSuccessHover color.Color BgContrast color.Color BgContrastHover color.Color BgWarning color.Color BgWarningHover color.Color LabelControl color.Color LabelFaint color.Color LabelMuted color.Color LabelBase color.Color LabelTitle color.Color LabelLink color.Color LabelContrast color.Color } // This is the gradient that is used in the Overmind logo var LogoGradient = LogoPalette{ a: "#1badf2", b: "#4b6ddf", c: "#5f51d5", d: "#c640ad", e: "#ef4971", f: "#fd6e43", } var ColorPalette Palette func InitPalette() { hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stderr) lightDark := lipgloss.LightDark(hasDarkBG) ColorPalette = Palette{ BgBase: lightDark(lipgloss.Color("#ffffff"), lipgloss.Color("#242428")), BgBaseHover: lightDark(lipgloss.Color("#ebebeb"), lipgloss.Color("#2d2d34")), BgShade: lightDark(lipgloss.Color("#fafafa"), lipgloss.Color("#27272b")), BgSub: lightDark(lipgloss.Color("#ffffff"), lipgloss.Color("#1a1a1f")), BgBorder: lightDark(lipgloss.Color("#e3e3e3"), lipgloss.Color("#37373f")), BgBorderHover: lightDark(lipgloss.Color("#d4d4d4"), lipgloss.Color("#434351")), BgDivider: lightDark(lipgloss.Color("#f0f0f0"), lipgloss.Color("#29292e")), BgMain: lightDark(lipgloss.Color("#655add"), lipgloss.Color("#7a70eb")), BgMainHover: lightDark(lipgloss.Color("#4840a0"), lipgloss.Color("#938af5")), BgDanger: lightDark(lipgloss.Color("#d74249"), lipgloss.Color("#be5056")), BgDangerHover: lightDark(lipgloss.Color("#c8373e"), lipgloss.Color("#d0494f")), BgSuccess: lightDark(lipgloss.Color("#5bb856"), lipgloss.Color("#61ac5d")), BgSuccessHover: lightDark(lipgloss.Color("#4da848"), lipgloss.Color("#6ac865")), BgContrast: lightDark(lipgloss.Color("#141414"), lipgloss.Color("#fafafa")), BgContrastHover: lightDark(lipgloss.Color("#2b2b2b"), lipgloss.Color("#ffffff")), BgWarning: lightDark(lipgloss.Color("#e59c57"), lipgloss.Color("#ca8d53")), BgWarningHover: lightDark(lipgloss.Color("#d9873a"), lipgloss.Color("#f0a660")), LabelControl: lightDark(lipgloss.Color("#ffffff"), lipgloss.Color("#ffffff")), LabelFaint: lightDark(lipgloss.Color("#adadad"), lipgloss.Color("#616161")), LabelMuted: lightDark(lipgloss.Color("#616161"), lipgloss.Color("#8c8c8c")), LabelBase: lightDark(lipgloss.Color("#383838"), lipgloss.Color("#bababa")), LabelTitle: lightDark(lipgloss.Color("#141414"), lipgloss.Color("#ededed")), LabelLink: lightDark(lipgloss.Color("#4f81ee"), lipgloss.Color("#688ede")), LabelContrast: lightDark(lipgloss.Color("#ffffff"), lipgloss.Color("#1e1e24")), } } func MarkdownStyle() ansi.StyleConfig { return ansi.StyleConfig{ Document: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BlockPrefix: "\n", BlockSuffix: "\n", Color: getHex(ColorPalette.LabelBase), }, Indent: new(uint(2)), }, BlockQuote: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Italic: new(true), }, Indent: new(uint(1)), IndentToken: new("│ "), }, List: ansi.StyleList{ LevelIndent: 2, }, Heading: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Bold: new(true), Color: getHex(ColorPalette.LabelTitle), BlockSuffix: "\n", }, }, H1: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BackgroundColor: getHex(ColorPalette.BgMain), Color: getHex(ColorPalette.BgBase), }, }, H3: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Color: getHex(ColorPalette.LabelMuted), }, }, H4: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "#### ", }, }, H5: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "##### ", }, }, H6: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "###### ", Bold: new(false), }, }, Strikethrough: ansi.StylePrimitive{ CrossedOut: new(true), }, Emph: ansi.StylePrimitive{ Italic: new(true), }, Strong: ansi.StylePrimitive{ Bold: new(true), }, HorizontalRule: ansi.StylePrimitive{ Color: getHex(ColorPalette.LabelBase), Format: "\n--------\n", }, Item: ansi.StylePrimitive{ BlockPrefix: "• ", }, Enumeration: ansi.StylePrimitive{ BlockPrefix: ". ", }, Task: ansi.StyleTask{ Ticked: "[✓] ", Unticked: "[ ] ", }, Link: ansi.StylePrimitive{ Color: getHex(ColorPalette.LabelLink), Underline: new(true), BlockPrefix: "(", BlockSuffix: ")", }, LinkText: ansi.StylePrimitive{ Bold: new(true), }, Image: ansi.StylePrimitive{ Color: getHex(ColorPalette.LabelLink), Underline: new(true), BlockPrefix: "(", BlockSuffix: ")", }, ImageText: ansi.StylePrimitive{ Color: getHex(ColorPalette.LabelLink), }, CodeBlock: ansi.StyleCodeBlock{ StyleBlock: ansi.StyleBlock{ Margin: new(uint(4)), }, Theme: "solarized-light", }, Table: ansi.StyleTable{ CenterSeparator: new("┼"), ColumnSeparator: new("│"), RowSeparator: new("─"), }, DefinitionDescription: ansi.StylePrimitive{ BlockPrefix: "\n🠶 ", }, } } func styleH1() lipgloss.Style { return lipgloss.NewStyle(). Foreground(lipgloss.Color("#ffffff")). Background(ColorPalette.BgMain). Bold(true). PaddingLeft(2). PaddingRight(2) } // markdownToString converts the markdown string to a string containing ANSI // formatting sequences with at most maxWidth visible characters per line. Set // maxWidth to zero to use the underlying library's default. func markdownToString(maxWidth int, markdown string) string { opts := []glamour.TermRendererOption{ glamour.WithStyles(MarkdownStyle()), } if maxWidth > 0 { // reduce maxWidth by 4 to account for padding in the various styles if maxWidth > 4 { maxWidth -= 4 } opts = append(opts, glamour.WithWordWrap(maxWidth)) } r, err := glamour.NewTermRenderer(opts...) if err != nil { panic(fmt.Errorf("failed to initialize terminal renderer: %w", err)) } out, err := r.Render(markdown) if err != nil { panic(fmt.Errorf("failed to render markdown: %w", err)) } return out } func OkSymbol() string { if IsConhost() { return "OK" } return "✔︎" } func UnknownSymbol() string { if IsConhost() { return "??" } return "?" } func ErrSymbol() string { if IsConhost() { return "ERR" } return "✗" } func IndentSymbol() string { if IsConhost() { // because conhost symbols are wider, we also indent a space more return " " } return " " } func getHex(c color.Color) *string { r, g, b, _ := c.RGBA() // RGBA returns values in 0-65535, convert to 0-255 retVal := fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint: gosec // overflows for displaying a color is not a security issue return &retVal } ================================================ FILE: cmd/theme_darwin.go ================================================ package cmd // IsConhost returns true if the current terminal is conhost. This indicates // that it can't deal with multi-byte characters and requires special treatment. // See https://github.com/overmindtech/cli/issues/388 for detailed analysis. func IsConhost() bool { return false } ================================================ FILE: cmd/theme_linux.go ================================================ package cmd import ( "bytes" "os" "sync" ) var isWslCache int // 0 = unset; 1 = WSL; 2 = not WSL var isWslCacheMu sync.RWMutex // IsConhost returns true if the current terminal is conhost. This indicates // that it can't deal with multi-byte characters and requires special treatment. // See https://github.com/overmindtech/cli/issues/388 for detailed analysis. func IsConhost() bool { // shortcut this if we (probably) run in Windows Terminal (through WSL) or // on something that smells like a regular Linux terminal if os.Getenv("WT_SESSION") != "" { return false } isWslCacheMu.RLock() w := isWslCache isWslCacheMu.RUnlock() switch w { case 1: return true case 2: return false } // isWslCache has not yet been initialised, so we need to check if we are in WSL // since we don't know if we are in WSL, we need to check now isWslCacheMu.Lock() defer isWslCacheMu.Unlock() if w != 0 { // someone else raced the lock and has already decided return isWslCache == 1 } // check if we run in WSL ver, err := os.ReadFile("/proc/version") if err == nil && bytes.Contains(ver, []byte("Microsoft")) { isWslCache = 1 return true } // we can't access /proc/version or it does not contain Microsoft, we are _probably_ not in WSL isWslCache = 2 return false } ================================================ FILE: cmd/theme_test.go ================================================ package cmd import ( "testing" ) func TestMarkdownToString(t *testing.T) { markdown := `# some random markdown` expectedOutput := "\n\x1b[38;2;36;36;40;48;2;121;112;235;1m\x1b[0m\x1b[38;2;36;36;40;48;2;121;112;235;1m\x1b[0m \x1b[38;2;36;36;40;48;2;121;112;235;1msome random\x1b[0m\x1b[38;2;36;36;40;48;2;121;112;235;1m markdown\x1b[0m\x1b[38;2;186;186;186m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[38;2;186;186;186m \x1b[0m\x1b[0m\n\x1b[0m\n" got := markdownToString(0, markdown) if got != expectedOutput { t.Errorf("Expected %q, but got %q", expectedOutput, got) t.Log("Expected output:") t.Log(expectedOutput) t.Log("Got output:") t.Log(got) } } ================================================ FILE: cmd/theme_windows.go ================================================ package cmd import "os" // IsConhost returns true if the current terminal is conhost. This indicates // that it can't deal with multi-byte characters and requires special treatment. // See https://github.com/overmindtech/cli/issues/388 for detailed analysis. func IsConhost() bool { return os.Getenv("WT_SESSION") == "" } ================================================ FILE: cmd/version_check.go ================================================ package cmd import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "github.com/Masterminds/semver/v3" log "github.com/sirupsen/logrus" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) var ( githubReleasesURL = "https://api.github.com/repos/overmindtech/cli/releases/latest" versionCheckTimeout = 3 * time.Second ) // githubReleaseResponse represents the response from GitHub API for a release type githubReleaseResponse struct { TagName string `json:"tag_name"` Name string `json:"name"` } // checkVersion checks if the current CLI version is out of date by comparing // it with the latest release from GitHub. Returns the latest version and whether // an update is available. Errors are logged but not returned to avoid blocking // command execution. func checkVersion(ctx context.Context, currentVersion string) (latestVersion string, updateAvailable bool) { // Skip check for dev builds if currentVersion == "dev" || currentVersion == "" { return "", false } // Create a context with timeout to avoid blocking too long checkCtx, cancel := context.WithTimeout(ctx, versionCheckTimeout) defer cancel() // Timeout is handled by the context timeout above client := &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport), } req, err := http.NewRequestWithContext(checkCtx, http.MethodGet, githubReleasesURL, nil) if err != nil { log.WithError(err).Debug("Failed to create version check request") return "", false } // Set User-Agent to identify the CLI req.Header.Set("User-Agent", fmt.Sprintf("overmind-cli/%s", currentVersion)) req.Header.Set("Accept", "application/vnd.github.v3+json") resp, err := client.Do(req) if err != nil { log.WithError(err).Debug("Failed to check for CLI updates") return "", false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.WithFields(log.Fields{ "status_code": resp.StatusCode, }).Debug("Failed to check for CLI updates: non-200 response") return "", false } body, err := io.ReadAll(resp.Body) if err != nil { log.WithError(err).Debug("Failed to read version check response") return "", false } var release githubReleaseResponse if err := json.Unmarshal(body, &release); err != nil { log.WithError(err).Debug("Failed to parse version check response") return "", false } latestVersion = strings.TrimPrefix(release.TagName, "v") currentVersionTrimmed := strings.TrimPrefix(currentVersion, "v") // Use proper semantic version comparison currentSemver, err := semver.NewVersion(currentVersionTrimmed) if err != nil { log.WithError(err).WithField("version", currentVersionTrimmed).Debug("Failed to parse current version as semver, skipping comparison") return latestVersion, false } latestSemver, err := semver.NewVersion(latestVersion) if err != nil { log.WithError(err).WithField("version", latestVersion).Debug("Failed to parse latest version as semver, skipping comparison") return latestVersion, false } // Check if latest version is greater than current version if latestSemver.GreaterThan(currentSemver) { updateAvailable = true } return latestVersion, updateAvailable } // displayVersionWarning displays a warning message if the CLI version is out of date func displayVersionWarning(ctx context.Context, currentVersion string) { latestVersion, updateAvailable := checkVersion(ctx, currentVersion) if !updateAvailable { return } // Ensure both versions are displayed with "v" prefix for consistency currentDisplay := currentVersion if !strings.HasPrefix(currentVersion, "v") && currentVersion != "" { currentDisplay = "v" + currentVersion } latestDisplay := latestVersion if !strings.HasPrefix(latestVersion, "v") && latestVersion != "" { latestDisplay = "v" + latestVersion } // Display warning on stderr so it doesn't interfere with command output fmt.Fprintf(os.Stderr, "⚠️ Warning: You are using CLI version %s, but version %s is available. Please update to the latest version.\n", currentDisplay, latestDisplay) } ================================================ FILE: cmd/version_check_test.go ================================================ package cmd import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" ) func TestCheckVersion(t *testing.T) { tests := []struct { name string currentVersion string latestTag string wantUpdate bool skipCheck bool }{ { name: "outdated version", currentVersion: "1.0.0", latestTag: "v1.0.1", wantUpdate: true, }, { name: "current version", currentVersion: "1.0.1", latestTag: "v1.0.1", wantUpdate: false, }, { name: "dev version skipped", currentVersion: "dev", latestTag: "v1.0.1", wantUpdate: false, skipCheck: true, }, { name: "empty version skipped", currentVersion: "", latestTag: "v1.0.1", wantUpdate: false, skipCheck: true, }, { name: "version with v prefix", currentVersion: "v1.0.0", latestTag: "v1.0.1", wantUpdate: true, }, { name: "multi-digit minor version - user newer", currentVersion: "1.10.0", latestTag: "v1.9.0", wantUpdate: false, }, { name: "multi-digit minor version - update available", currentVersion: "1.9.0", latestTag: "v1.10.0", wantUpdate: true, }, { name: "multi-digit patch version - user newer", currentVersion: "1.0.10", latestTag: "v1.0.9", wantUpdate: false, }, { name: "multi-digit patch version - update available", currentVersion: "1.0.9", latestTag: "v1.0.10", wantUpdate: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/repos/overmindtech/cli/releases/latest" { t.Errorf("Expected path /repos/overmindtech/cli/releases/latest, got %s", r.URL.Path) } release := githubReleaseResponse{ TagName: tt.latestTag, Name: tt.latestTag, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(release); err != nil { t.Errorf("Failed to encode release: %v", err) } })) defer server.Close() // Temporarily override the GitHub URL for testing originalURL := githubReleasesURL githubReleasesURL = server.URL + "/repos/overmindtech/cli/releases/latest" defer func() { githubReleasesURL = originalURL }() ctx := context.Background() latestVersion, updateAvailable := checkVersion(ctx, tt.currentVersion) if tt.skipCheck { if latestVersion != "" || updateAvailable { t.Errorf("Expected check to be skipped, but got latestVersion=%s, updateAvailable=%v", latestVersion, updateAvailable) } return } if updateAvailable != tt.wantUpdate { t.Errorf("checkVersion() updateAvailable = %v, want %v", updateAvailable, tt.wantUpdate) } if tt.wantUpdate && latestVersion == "" { t.Errorf("checkVersion() expected latestVersion to be set when update is available") } }) } } func TestCheckVersionErrorScenarios(t *testing.T) { tests := []struct { name string currentVersion string setupServer func() *httptest.Server wantUpdate bool wantVersion string }{ { name: "network error - server unreachable", currentVersion: "1.0.0", setupServer: func() *httptest.Server { // Return nil to simulate unreachable server return nil }, wantUpdate: false, wantVersion: "", }, { name: "HTTP 404 not found", currentVersion: "1.0.0", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("Not Found")) })) }, wantUpdate: false, wantVersion: "", }, { name: "HTTP 500 internal server error", currentVersion: "1.0.0", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Internal Server Error")) })) }, wantUpdate: false, wantVersion: "", }, { name: "malformed JSON response", currentVersion: "1.0.0", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"invalid": json}`)) })) }, wantUpdate: false, wantVersion: "", }, { name: "empty JSON response", currentVersion: "1.0.0", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{}`)) })) }, wantUpdate: false, wantVersion: "", }, { name: "invalid semver in response", currentVersion: "1.0.0", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { release := githubReleaseResponse{ TagName: "not-a-version", Name: "not-a-version", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(release) })) }, wantUpdate: false, wantVersion: "not-a-version", }, { name: "timeout - server delays response", currentVersion: "1.0.0", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Delay longer than the timeout (3 seconds) time.Sleep(4 * time.Second) release := githubReleaseResponse{ TagName: "v1.0.1", Name: "v1.0.1", } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(release) })) }, wantUpdate: false, wantVersion: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Temporarily override the GitHub URL for testing originalURL := githubReleasesURL defer func() { githubReleasesURL = originalURL }() var server *httptest.Server if tt.setupServer != nil { server = tt.setupServer() if server != nil { defer server.Close() githubReleasesURL = server.URL + "/repos/overmindtech/cli/releases/latest" } else { // For network error test, use an invalid URL githubReleasesURL = "http://localhost:0/repos/overmindtech/cli/releases/latest" } } ctx := context.Background() latestVersion, updateAvailable := checkVersion(ctx, tt.currentVersion) if updateAvailable != tt.wantUpdate { t.Errorf("checkVersion() updateAvailable = %v, want %v", updateAvailable, tt.wantUpdate) } if latestVersion != tt.wantVersion { t.Errorf("checkVersion() latestVersion = %q, want %q", latestVersion, tt.wantVersion) } }) } } ================================================ FILE: demos/plan.tape ================================================ Output demos/plan.gif # Output demos/plan.mp4 Set Margin 20 Set MarginFill "#7a70eb" # use Dark.BgMain Set BorderRadius 10 Set Width 1200 Set Height 900 Set FontSize 15 Hide Type "cd tmp" Enter Type "export PATH=$PWD:$PATH" Enter Type "clear" Enter Show Type@10ms "overmind terraform plan" Enter Sleep 2 Down Sleep 500ms Down Sleep 1 Enter Type "sso-dogfood" Sleep 1 Enter Sleep 60 Sleep 20 # Ctrl+c # Sleep 10 ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-api-key.json ================================================ { "type": "apigateway-api-key", "category": 4, "descriptiveName": "API Key", "supportedQueryMethods": { "get": true, "getDescription": "Get an API Key by ID", "list": true, "listDescription": "List all API Keys", "search": true, "searchDescription": "Search for API Keys by their name" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_api_key.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-authorizer.json ================================================ { "type": "apigateway-authorizer", "category": 4, "descriptiveName": "API Gateway Authorizer", "supportedQueryMethods": { "get": true, "getDescription": "Get an API Gateway Authorizer by its rest API ID and ID: rest-api-id/authorizer-id", "search": true, "searchDescription": "Search for API Gateway Authorizers by their rest API ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_authorizer.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-deployment.json ================================================ { "type": "apigateway-deployment", "category": 7, "descriptiveName": "API Gateway Deployment", "supportedQueryMethods": { "get": true, "getDescription": "Get an API Gateway Deployment by its rest API ID and ID: rest-api-id/deployment-id", "search": true, "searchDescription": "Search for API Gateway Deployments by their rest API ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_deployment.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-domain-name.json ================================================ { "type": "apigateway-domain-name", "category": 1, "potentialLinks": ["acm-certificate"], "descriptiveName": "API Gateway Domain Name", "supportedQueryMethods": { "get": true, "getDescription": "Get a Domain Name by domain-name", "list": true, "listDescription": "List Domain Names", "search": true, "searchDescription": "Search Domain Names by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_domain_name.domain_name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-integration.json ================================================ { "type": "apigateway-integration", "category": 3, "descriptiveName": "API Gateway Integration", "supportedQueryMethods": { "get": true, "getDescription": "Get an Integration by rest-api id, resource id, and http-method", "search": true, "searchDescription": "Search Integrations by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-method-response.json ================================================ { "type": "apigateway-method-response", "category": 3, "descriptiveName": "API Gateway Method Response", "supportedQueryMethods": { "get": true, "getDescription": "Get a Method Response by it's ID: {rest-api-id}/{resource-id}/{http-method}/{status-code}", "search": true, "searchDescription": "Search Method Responses by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-method.json ================================================ { "type": "apigateway-method", "category": 3, "potentialLinks": [ "apigateway-integration", "apigateway-authorizer", "apigateway-request-validator", "apigateway-method-response" ], "descriptiveName": "API Gateway Method", "supportedQueryMethods": { "get": true, "getDescription": "Get a Method by it's ID: {rest-api-id}/{resource-id}/{http-method}", "search": true, "searchDescription": "Search Methods by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-model.json ================================================ { "type": "apigateway-model", "category": 7, "descriptiveName": "API Gateway Model", "supportedQueryMethods": { "get": true, "getDescription": "Get an API Gateway Model by its rest API ID and model name: rest-api-id/model-name", "search": true, "searchDescription": "Search for API Gateway Models by their rest API ID: rest-api-id" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_model.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-resource.json ================================================ { "type": "apigateway-resource", "category": 1, "potentialLinks": ["apigateway-method"], "descriptiveName": "API Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get a Resource by rest-api-id/resource-id", "search": true, "searchDescription": "Search Resources by REST API ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_resource.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-rest-api.json ================================================ { "type": "apigateway-rest-api", "category": 1, "potentialLinks": ["ec2-vpc-endpoint", "apigateway-resource"], "descriptiveName": "REST API", "supportedQueryMethods": { "get": true, "getDescription": "Get a REST API by ID", "list": true, "listDescription": "List all REST APIs", "search": true, "searchDescription": "Search for REST APIs by their name" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_rest_api.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/apigateway-stage.json ================================================ { "type": "apigateway-stage", "category": 7, "potentialLinks": ["wafv2-web-acl"], "descriptiveName": "API Gateway Stage", "supportedQueryMethods": { "get": true, "getDescription": "Get an API Gateway Stage by its rest API ID and stage name: rest-api-id/stage-name", "search": true, "searchDescription": "Search for API Gateway Stages by their rest API ID or with rest API ID and deployment-id: rest-api-id/deployment-id" }, "terraformMappings": [ { "terraformQueryMap": "aws_api_gateway_stage.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/autoscaling-auto-scaling-group.json ================================================ { "type": "autoscaling-auto-scaling-group", "category": 7, "potentialLinks": [ "ec2-launch-template", "elbv2-target-group", "ec2-instance", "iam-role", "autoscaling-launch-configuration", "ec2-placement-group" ], "descriptiveName": "Autoscaling Group", "supportedQueryMethods": { "get": true, "getDescription": "Get an Autoscaling Group by name", "list": true, "listDescription": "List Autoscaling Groups", "search": true, "searchDescription": "Search for Autoscaling Groups by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_autoscaling_group.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-cache-policy.json ================================================ { "type": "cloudfront-cache-policy", "category": 7, "descriptiveName": "CloudFront Cache Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a CloudFront Cache Policy", "list": true, "listDescription": "List CloudFront Cache Policies", "search": true, "searchDescription": "Search CloudFront Cache Policies by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_cloudfront_cache_policy.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-continuous-deployment-policy.json ================================================ { "type": "cloudfront-continuous-deployment-policy", "category": 7, "potentialLinks": ["dns"], "descriptiveName": "CloudFront Continuous Deployment Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a CloudFront Continuous Deployment Policy by ID", "list": true, "listDescription": "List CloudFront Continuous Deployment Policies", "search": true, "searchDescription": "Search CloudFront Continuous Deployment Policies by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-distribution.json ================================================ { "type": "cloudfront-distribution", "category": 3, "potentialLinks": [ "cloudfront-key-group", "cloudfront-cloud-front-origin-access-identity", "cloudfront-continuous-deployment-policy", "cloudfront-cache-policy", "cloudfront-field-level-encryption", "cloudfront-function", "cloudfront-origin-request-policy", "cloudfront-realtime-log-config", "cloudfront-response-headers-policy", "dns", "lambda-function", "s3-bucket" ], "descriptiveName": "CloudFront Distribution", "supportedQueryMethods": { "get": true, "getDescription": "Get a distribution by ID", "list": true, "listDescription": "List all distributions", "search": true, "searchDescription": "Search distributions by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_cloudfront_distribution.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-function.json ================================================ { "type": "cloudfront-function", "category": 1, "descriptiveName": "CloudFront Function", "supportedQueryMethods": { "get": true, "getDescription": "Get a CloudFront Function by name", "list": true, "listDescription": "List CloudFront Functions", "search": true, "searchDescription": "Search CloudFront Functions by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_cloudfront_function.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-key-group.json ================================================ { "type": "cloudfront-key-group", "category": 7, "descriptiveName": "CloudFront Key Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a CloudFront Key Group by ID", "list": true, "listDescription": "List CloudFront Key Groups", "search": true, "searchDescription": "Search CloudFront Key Groups by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_cloudfront_key_group.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-origin-access-control.json ================================================ { "type": "cloudfront-origin-access-control", "category": 4, "descriptiveName": "Cloudfront Origin Access Control", "supportedQueryMethods": { "get": true, "getDescription": "Get Origin Access Control by ID", "list": true, "listDescription": "List Origin Access Controls", "search": true, "searchDescription": "Origin Access Control by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_cloudfront_origin_access_control.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-origin-request-policy.json ================================================ { "type": "cloudfront-origin-request-policy", "category": 3, "descriptiveName": "CloudFront Origin Request Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get Origin Request Policy by ID", "list": true, "listDescription": "List Origin Request Policies", "search": true, "searchDescription": "Origin Request Policy by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_cloudfront_origin_request_policy.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-realtime-log-config.json ================================================ { "type": "cloudfront-realtime-log-config", "category": 7, "descriptiveName": "CloudFront Realtime Log Config", "supportedQueryMethods": { "get": true, "getDescription": "Get Realtime Log Config by Name", "list": true, "listDescription": "List Realtime Log Configs", "search": true, "searchDescription": "Search Realtime Log Configs by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_cloudfront_realtime_log_config.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-response-headers-policy.json ================================================ { "type": "cloudfront-response-headers-policy", "category": 3, "descriptiveName": "CloudFront Response Headers Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get Response Headers Policy by ID", "list": true, "listDescription": "List Response Headers Policies", "search": true, "searchDescription": "Search Response Headers Policy by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_cloudfront_response_headers_policy.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudfront-streaming-distribution.json ================================================ { "type": "cloudfront-streaming-distribution", "category": 3, "potentialLinks": ["dns"], "descriptiveName": "CloudFront Streaming Distribution", "supportedQueryMethods": { "get": true, "getDescription": "Get a Streaming Distribution by ID", "list": true, "listDescription": "List Streaming Distributions", "search": true, "searchDescription": "Search Streaming Distributions by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_cloudfront_distribution.arn" }, { "terraformQueryMap": "aws_cloudfront_distribution.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/cloudwatch-alarm.json ================================================ { "type": "cloudwatch-alarm", "category": 5, "potentialLinks": ["cloudwatch-metric"], "descriptiveName": "CloudWatch Alarm", "supportedQueryMethods": { "get": true, "getDescription": "Get an alarm by name", "list": true, "listDescription": "List all alarms", "search": true, "searchDescription": "Search for alarms. This accepts JSON in the format of `cloudwatch.DescribeAlarmsForMetricInput`" }, "terraformMappings": [ { "terraformQueryMap": "aws_cloudwatch_metric_alarm.alarm_name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-connection.json ================================================ { "type": "directconnect-connection", "category": 3, "potentialLinks": [ "directconnect-lag", "directconnect-location", "directconnect-loa", "directconnect-virtual-interface" ], "descriptiveName": "Connection", "supportedQueryMethods": { "get": true, "getDescription": "Get a connection by ID", "list": true, "listDescription": "List all connections", "search": true, "searchDescription": "Search connection by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_connection.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-customer-metadata.json ================================================ { "type": "directconnect-customer-metadata", "category": 7, "descriptiveName": "Customer Metadata", "supportedQueryMethods": { "get": true, "getDescription": "Get a customer agreement by name", "list": true, "listDescription": "List all customer agreements", "search": true, "searchDescription": "Search customer agreements by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-direct-connect-gateway-association-proposal.json ================================================ { "type": "directconnect-direct-connect-gateway-association-proposal", "category": 7, "potentialLinks": ["directconnect-direct-connect-gateway-association"], "descriptiveName": "Direct Connect Gateway Association Proposal", "supportedQueryMethods": { "get": true, "getDescription": "Get a Direct Connect Gateway Association Proposal by ID", "list": true, "listDescription": "List all Direct Connect Gateway Association Proposals", "search": true, "searchDescription": "Search Direct Connect Gateway Association Proposals by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_gateway_association_proposal.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-direct-connect-gateway-association.json ================================================ { "type": "directconnect-direct-connect-gateway-association", "category": 3, "potentialLinks": ["directconnect-direct-connect-gateway"], "descriptiveName": "Direct Connect Gateway Association", "supportedQueryMethods": { "get": true, "getDescription": "Get a direct connect gateway association by direct connect gateway ID and virtual gateway ID", "search": true, "searchDescription": "Search direct connect gateway associations by direct connect gateway ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_gateway_association.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-direct-connect-gateway-attachment.json ================================================ { "type": "directconnect-direct-connect-gateway-attachment", "category": 3, "potentialLinks": [ "directconnect-direct-connect-gateway", "directconnect-virtual-interface" ], "descriptiveName": "Direct Connect Gateway Attachment", "supportedQueryMethods": { "get": true, "getDescription": "Get a direct connect gateway attachment by DirectConnectGatewayId/VirtualInterfaceId", "search": true, "searchDescription": "Search direct connect gateway attachments for given VirtualInterfaceId" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-direct-connect-gateway.json ================================================ { "type": "directconnect-direct-connect-gateway", "category": 3, "descriptiveName": "Direct Connect Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get a direct connect gateway by ID", "list": true, "listDescription": "List all direct connect gateways", "search": true, "searchDescription": "Search direct connect gateway by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_gateway.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-hosted-connection.json ================================================ { "type": "directconnect-hosted-connection", "category": 3, "potentialLinks": [ "directconnect-lag", "directconnect-location", "directconnect-loa", "directconnect-virtual-interface" ], "descriptiveName": "Hosted Connection", "supportedQueryMethods": { "get": true, "getDescription": "Get a Hosted Connection by connection ID", "search": true, "searchDescription": "Search Hosted Connections by Interconnect or LAG ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_hosted_connection.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-interconnect.json ================================================ { "type": "directconnect-interconnect", "category": 3, "potentialLinks": [ "directconnect-hosted-connection", "directconnect-lag", "directconnect-loa", "directconnect-location" ], "descriptiveName": "Interconnect", "supportedQueryMethods": { "get": true, "getDescription": "Get a Interconnect by InterconnectId", "list": true, "listDescription": "List all Interconnects", "search": true, "searchDescription": "Search Interconnects by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-lag.json ================================================ { "type": "directconnect-lag", "category": 3, "potentialLinks": [ "directconnect-connection", "directconnect-hosted-connection", "directconnect-location" ], "descriptiveName": "Link Aggregation Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a Link Aggregation Group by ID", "list": true, "listDescription": "List all Link Aggregation Groups", "search": true, "searchDescription": "Search Link Aggregation Group by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_lag.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-location.json ================================================ { "type": "directconnect-location", "category": 3, "descriptiveName": "Direct Connect Location", "supportedQueryMethods": { "get": true, "getDescription": "Get a Location by its code", "list": true, "listDescription": "List all Direct Connect Locations", "search": true, "searchDescription": "Search Direct Connect Locations by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_location.location_code" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-router-configuration.json ================================================ { "type": "directconnect-router-configuration", "category": 7, "potentialLinks": ["directconnect-virtual-interface"], "descriptiveName": "Router Configuration", "supportedQueryMethods": { "get": true, "getDescription": "Get a Router Configuration by Virtual Interface ID", "search": true, "searchDescription": "Search Router Configuration by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_router_configuration.virtual_interface_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-virtual-gateway.json ================================================ { "type": "directconnect-virtual-gateway", "category": 3, "descriptiveName": "Direct Connect Virtual Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get a virtual gateway by ID", "list": true, "listDescription": "List all virtual gateways", "search": true, "searchDescription": "Search virtual gateways by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/directconnect-virtual-interface.json ================================================ { "type": "directconnect-virtual-interface", "category": 3, "potentialLinks": [ "directconnect-connection", "directconnect-direct-connect-gateway", "rdap-ip-network", "directconnect-direct-connect-gateway-attachment", "directconnect-virtual-interface" ], "descriptiveName": "Virtual Interface", "supportedQueryMethods": { "get": true, "getDescription": "Get a virtual interface by ID", "list": true, "listDescription": "List all virtual interfaces", "search": true, "searchDescription": "Search virtual interfaces by connection ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_dx_private_virtual_interface.id" }, { "terraformQueryMap": "aws_dx_public_virtual_interface.id" }, { "terraformQueryMap": "aws_dx_transit_virtual_interface.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/dynamodb-backup.json ================================================ { "type": "dynamodb-backup", "category": 6, "potentialLinks": ["dynamodb-table"], "descriptiveName": "DynamoDB Backup", "supportedQueryMethods": { "list": true, "listDescription": "List all DynamoDB backups", "search": true, "searchDescription": "Search for a DynamoDB backup by table name" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/dynamodb-table.json ================================================ { "type": "dynamodb-table", "category": 6, "potentialLinks": [ "kinesis-stream", "backup-recovery-point", "dynamodb-table", "kms-key" ], "descriptiveName": "DynamoDB Table", "supportedQueryMethods": { "get": true, "getDescription": "Get a DynamoDB table by name", "list": true, "listDescription": "List all DynamoDB tables", "search": true, "searchDescription": "Search for DynamoDB tables by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_dynamodb_table.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-address.json ================================================ { "type": "ec2-address", "category": 3, "potentialLinks": ["ec2-instance", "ip", "ec2-network-interface"], "descriptiveName": "EC2 Address", "supportedQueryMethods": { "get": true, "getDescription": "Get an EC2 address by Public IP", "list": true, "listDescription": "List EC2 addresses", "search": true, "searchDescription": "Search for EC2 addresses by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_eip.public_ip" }, { "terraformQueryMap": "aws_eip_association.public_ip" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-capacity-reservation-fleet.json ================================================ { "type": "ec2-capacity-reservation-fleet", "category": 7, "potentialLinks": ["ec2-capacity-reservation"], "descriptiveName": "Capacity Reservation Fleet", "supportedQueryMethods": { "get": true, "getDescription": "Get a capacity reservation fleet by ID", "list": true, "listDescription": "List capacity reservation fleets", "search": true, "searchDescription": "Search capacity reservation fleets by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-capacity-reservation.json ================================================ { "type": "ec2-capacity-reservation", "category": 7, "potentialLinks": [ "outposts-outpost", "ec2-placement-group", "ec2-capacity-reservation-fleet" ], "descriptiveName": "Capacity Reservation", "supportedQueryMethods": { "get": true, "getDescription": "Get a capacity reservation fleet by ID", "list": true, "listDescription": "List capacity reservation fleets", "search": true, "searchDescription": "Search capacity reservation fleets by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_ec2_capacity_reservation_fleet.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-egress-only-internet-gateway.json ================================================ { "type": "ec2-egress-only-internet-gateway", "category": 3, "potentialLinks": ["ec2-vpc"], "descriptiveName": "Egress Only Internet Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get an egress only internet gateway by ID", "list": true, "listDescription": "List all egress only internet gateways", "search": true, "searchDescription": "Search egress only internet gateways by ARN" }, "terraformMappings": [ { "terraformQueryMap": "egress_only_internet_gateway.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-iam-instance-profile-association.json ================================================ { "type": "ec2-iam-instance-profile-association", "category": 4, "potentialLinks": ["iam-instance-profile", "ec2-instance"], "descriptiveName": "IAM Instance Profile Association", "supportedQueryMethods": { "get": true, "getDescription": "Get an IAM Instance Profile Association by ID", "list": true, "listDescription": "List all IAM Instance Profile Associations", "search": true, "searchDescription": "Search IAM Instance Profile Associations by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-image.json ================================================ { "type": "ec2-image", "category": 1, "descriptiveName": "Amazon Machine Image (AMI)", "supportedQueryMethods": { "get": true, "getDescription": "Get an AMI by ID", "list": true, "listDescription": "List all AMIs", "search": true, "searchDescription": "Search AMIs by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_ami.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-instance-event-window.json ================================================ { "type": "ec2-instance-event-window", "category": 7, "potentialLinks": ["ec2-host", "ec2-instance"], "descriptiveName": "EC2 Instance Event Window", "supportedQueryMethods": { "get": true, "getDescription": "Get an event window by ID", "list": true, "listDescription": "List all event windows", "search": true, "searchDescription": "Search for event windows by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-instance-status.json ================================================ { "type": "ec2-instance-status", "category": 5, "descriptiveName": "EC2 Instance Status", "supportedQueryMethods": { "get": true, "getDescription": "Get an EC2 instance status by Instance ID", "list": true, "listDescription": "List all EC2 instance statuses", "search": true, "searchDescription": "Search EC2 instance statuses by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-instance.json ================================================ { "type": "ec2-instance", "category": 1, "potentialLinks": [ "ec2-instance-status", "iam-instance-profile", "ec2-capacity-reservation", "ec2-elastic-gpu", "elastic-inference-accelerator", "license-manager-license-configuration", "outposts-outpost", "ec2-spot-instance-request", "ec2-image", "ec2-key-pair", "ec2-placement-group", "ip", "ec2-subnet", "ec2-vpc", "dns", "ec2-security-group", "ec2-volume" ], "descriptiveName": "EC2 Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get an EC2 instance by ID", "list": true, "listDescription": "List all EC2 instances", "search": true, "searchDescription": "Search EC2 instances by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_instance.id" }, { "terraformMethod": 2, "terraformQueryMap": "aws_instance.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-internet-gateway.json ================================================ { "type": "ec2-internet-gateway", "category": 3, "potentialLinks": ["ec2-vpc"], "descriptiveName": "Internet Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get an internet gateway by ID", "list": true, "listDescription": "List all internet gateways", "search": true, "searchDescription": "Search internet gateways by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_internet_gateway.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-key-pair.json ================================================ { "type": "ec2-key-pair", "category": 4, "descriptiveName": "Key Pair", "supportedQueryMethods": { "get": true, "getDescription": "Get a key pair by name", "list": true, "listDescription": "List all key pairs", "search": true, "searchDescription": "Search for key pairs by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_key_pair.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-launch-template-version.json ================================================ { "type": "ec2-launch-template-version", "category": 1, "potentialLinks": [ "ec2-network-interface", "ec2-subnet", "ec2-security-group", "ec2-image", "ec2-key-pair", "ec2-snapshot", "ec2-capacity-reservation", "ec2-placement-group", "ec2-host", "ip" ], "descriptiveName": "Launch Template Version", "supportedQueryMethods": { "get": true, "getDescription": "Get a launch template version by {templateId}.{version}", "list": true, "listDescription": "List all launch template versions", "search": true, "searchDescription": "Search launch template versions by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-launch-template.json ================================================ { "type": "ec2-launch-template", "category": 1, "descriptiveName": "Launch Template", "supportedQueryMethods": { "get": true, "getDescription": "Get a launch template by ID", "list": true, "listDescription": "List all launch templates", "search": true, "searchDescription": "Search for launch templates by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_launch_template.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-nat-gateway.json ================================================ { "type": "ec2-nat-gateway", "category": 3, "potentialLinks": ["ec2-vpc", "ec2-subnet", "ec2-network-interface", "ip"], "descriptiveName": "NAT Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get a NAT Gateway by ID", "list": true, "listDescription": "List all NAT gateways", "search": true, "searchDescription": "Search for NAT gateways by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_nat_gateway.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-network-acl.json ================================================ { "type": "ec2-network-acl", "category": 4, "potentialLinks": ["ec2-subnet", "ec2-vpc"], "descriptiveName": "Network ACL", "supportedQueryMethods": { "get": true, "getDescription": "Get a network ACL", "list": true, "listDescription": "List all network ACLs", "search": true, "searchDescription": "Search for network ACLs by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_network_acl.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-network-interface-permission.json ================================================ { "type": "ec2-network-interface-permission", "category": 4, "potentialLinks": ["ec2-network-interface"], "descriptiveName": "Network Interface Permission", "supportedQueryMethods": { "get": true, "getDescription": "Get a network interface permission by ID", "list": true, "listDescription": "List all network interface permissions", "search": true, "searchDescription": "Search network interface permissions by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-network-interface.json ================================================ { "type": "ec2-network-interface", "category": 3, "potentialLinks": [ "ec2-instance", "ec2-security-group", "ip", "dns", "ec2-subnet", "ec2-vpc" ], "descriptiveName": "EC2 Network Interface", "supportedQueryMethods": { "get": true, "getDescription": "Get a network interface by ID", "list": true, "listDescription": "List all network interfaces", "search": true, "searchDescription": "Search network interfaces by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_network_interface.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-placement-group.json ================================================ { "type": "ec2-placement-group", "category": 1, "descriptiveName": "Placement Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a placement group by ID", "list": true, "listDescription": "List all placement groups", "search": true, "searchDescription": "Search for placement groups by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_placement_group.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-reserved-instance.json ================================================ { "type": "ec2-reserved-instance", "category": 1, "descriptiveName": "Reserved EC2 Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get a reserved EC2 instance by ID", "list": true, "listDescription": "List all reserved EC2 instances", "search": true, "searchDescription": "Search reserved EC2 instances by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-route-table.json ================================================ { "type": "ec2-route-table", "category": 3, "potentialLinks": [ "ec2-vpc", "ec2-subnet", "ec2-internet-gateway", "ec2-vpc-endpoint", "ec2-carrier-gateway", "ec2-egress-only-internet-gateway", "ec2-instance", "ec2-local-gateway", "ec2-nat-gateway", "ec2-network-interface", "ec2-transit-gateway", "ec2-vpc-peering-connection" ], "descriptiveName": "Route Table", "supportedQueryMethods": { "get": true, "getDescription": "Get a route table by ID", "list": true, "listDescription": "List all route tables", "search": true, "searchDescription": "Search route tables by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_route_table.id" }, { "terraformQueryMap": "aws_route_table_association.route_table_id" }, { "terraformQueryMap": "aws_default_route_table.default_route_table_id" }, { "terraformQueryMap": "aws_route.route_table_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-security-group-rule.json ================================================ { "type": "ec2-security-group-rule", "category": 4, "potentialLinks": ["ec2-security-group"], "descriptiveName": "Security Group Rule", "supportedQueryMethods": { "get": true, "getDescription": "Get a security group rule by ID", "list": true, "listDescription": "List all security group rules", "search": true, "searchDescription": "Search security group rules by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_security_group_rule.security_group_rule_id" }, { "terraformQueryMap": "aws_vpc_security_group_ingress_rule.security_group_rule_id" }, { "terraformQueryMap": "aws_vpc_security_group_egress_rule.security_group_rule_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-security-group.json ================================================ { "type": "ec2-security-group", "category": 4, "potentialLinks": ["ec2-vpc"], "descriptiveName": "Security Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a security group by ID", "list": true, "listDescription": "List all security groups", "search": true, "searchDescription": "Search for security groups by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_security_group.id" }, { "terraformQueryMap": "aws_security_group_rule.security_group_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-snapshot.json ================================================ { "type": "ec2-snapshot", "category": 2, "potentialLinks": ["ec2-volume"], "descriptiveName": "EC2 Snapshot", "supportedQueryMethods": { "get": true, "getDescription": "Get a snapshot by ID", "list": true, "listDescription": "List all snapshots", "search": true, "searchDescription": "Search snapshots by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-subnet.json ================================================ { "type": "ec2-subnet", "category": 3, "potentialLinks": ["ec2-vpc"], "descriptiveName": "EC2 Subnet", "supportedQueryMethods": { "get": true, "getDescription": "Get a subnet by ID", "list": true, "listDescription": "List all subnets", "search": true, "searchDescription": "Search for subnets by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_route_table_association.subnet_id" }, { "terraformQueryMap": "aws_subnet.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-transit-gateway-route-table-association.json ================================================ { "type": "ec2-transit-gateway-route-table-association", "category": 3, "potentialLinks": [ "ec2-transit-gateway-route-table", "ec2-transit-gateway-attachment", "ec2-vpc", "ec2-vpn-connection", "directconnect-direct-connect-gateway" ], "descriptiveName": "Transit Gateway Route Table Association", "supportedQueryMethods": { "get": true, "getDescription": "Get by TransitGatewayRouteTableId|TransitGatewayAttachmentId", "list": true, "listDescription": "List all route table associations", "search": true, "searchDescription": "Search by TransitGatewayRouteTableId to list associations for that route table" }, "terraformMappings": [ { "terraformQueryMap": "aws_ec2_transit_gateway_route_table_association.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-transit-gateway-route-table-propagation.json ================================================ { "type": "ec2-transit-gateway-route-table-propagation", "category": 3, "potentialLinks": [ "ec2-transit-gateway-route-table", "ec2-transit-gateway-route-table-association", "ec2-transit-gateway-attachment", "ec2-vpc", "ec2-vpn-connection", "directconnect-direct-connect-gateway" ], "descriptiveName": "Transit Gateway Route Table Propagation", "supportedQueryMethods": { "get": true, "getDescription": "Get by TransitGatewayRouteTableId|TransitGatewayAttachmentId", "list": true, "listDescription": "List all route table propagations", "search": true, "searchDescription": "Search by TransitGatewayRouteTableId to list propagations for that route table" }, "terraformMappings": [ { "terraformQueryMap": "aws_ec2_transit_gateway_route_table_propagation.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-transit-gateway-route-table.json ================================================ { "type": "ec2-transit-gateway-route-table", "category": 3, "potentialLinks": [ "ec2-transit-gateway", "ec2-transit-gateway-route-table-association", "ec2-transit-gateway-route-table-propagation", "ec2-transit-gateway-route" ], "descriptiveName": "Transit Gateway Route Table", "supportedQueryMethods": { "get": true, "getDescription": "Get a transit gateway route table by ID", "list": true, "listDescription": "List all transit gateway route tables", "search": true, "searchDescription": "Search transit gateway route tables by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_ec2_transit_gateway_route_table.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-transit-gateway-route.json ================================================ { "type": "ec2-transit-gateway-route", "category": 3, "potentialLinks": [ "ec2-transit-gateway-route-table", "ec2-transit-gateway-route-table-association", "ec2-transit-gateway-attachment", "ec2-transit-gateway-route-table-announcement", "ec2-vpc", "ec2-vpn-connection", "ec2-managed-prefix-list", "directconnect-direct-connect-gateway" ], "descriptiveName": "Transit Gateway Route", "supportedQueryMethods": { "get": true, "getDescription": "Get by TransitGatewayRouteTableId|Destination (CIDR or pl:PrefixListId)", "list": true, "listDescription": "List all transit gateway routes", "search": true, "searchDescription": "Search by TransitGatewayRouteTableId to list routes for that route table" }, "terraformMappings": [ { "terraformQueryMap": "aws_ec2_transit_gateway_route.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-volume-status.json ================================================ { "type": "ec2-volume-status", "category": 5, "potentialLinks": ["ec2-instance"], "descriptiveName": "EC2 Volume Status", "supportedQueryMethods": { "get": true, "getDescription": "Get a volume status by volume ID", "list": true, "listDescription": "List all volume statuses", "search": true, "searchDescription": "Search for volume statuses by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-volume.json ================================================ { "type": "ec2-volume", "category": 2, "potentialLinks": ["ec2-instance"], "descriptiveName": "EC2 Volume", "supportedQueryMethods": { "get": true, "getDescription": "Get a volume by ID", "list": true, "listDescription": "List all volumes", "search": true, "searchDescription": "Search volumes by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_ebs_volume.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-vpc-endpoint.json ================================================ { "type": "ec2-vpc-endpoint", "category": 3, "descriptiveName": "VPC Endpoint", "supportedQueryMethods": { "get": true, "getDescription": "Get a VPC Endpoint by ID", "list": true, "listDescription": "List all VPC Endpoints", "search": true, "searchDescription": "Search VPC Endpoints by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_vpc_endpoint.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-vpc-peering-connection.json ================================================ { "type": "ec2-vpc-peering-connection", "category": 3, "potentialLinks": ["ec2-vpc"], "descriptiveName": "VPC Peering Connection", "supportedQueryMethods": { "get": true, "getDescription": "Get a VPC Peering Connection by ID", "list": true, "listDescription": "List all VPC Peering Connections", "search": true, "searchDescription": "Search for VPC Peering Connections by their ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_vpc_peering_connection.id" }, { "terraformQueryMap": "aws_vpc_peering_connection_accepter.id" }, { "terraformQueryMap": "aws_vpc_peering_connection_options.vpc_peering_connection_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ec2-vpc.json ================================================ { "type": "ec2-vpc", "category": 3, "descriptiveName": "VPC", "supportedQueryMethods": { "get": true, "getDescription": "Get a VPC by ID", "list": true, "listDescription": "List all VPCs" }, "terraformMappings": [ { "terraformQueryMap": "aws_vpc.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ecs-capacity-provider.json ================================================ { "type": "ecs-capacity-provider", "category": 7, "potentialLinks": ["autoscaling-auto-scaling-group"], "descriptiveName": "Capacity Provider", "supportedQueryMethods": { "get": true, "getDescription": "Get a capacity provider by its short name or full Amazon Resource Name (ARN).", "list": true, "listDescription": "List capacity providers.", "search": true, "searchDescription": "Search capacity providers by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_ecs_capacity_provider.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ecs-cluster.json ================================================ { "type": "ecs-cluster", "category": 1, "potentialLinks": [ "ecs-container-instance", "ecs-service", "ecs-task", "ecs-capacity-provider" ], "descriptiveName": "ECS Cluster", "supportedQueryMethods": { "get": true, "getDescription": "Get a cluster by name", "list": true, "listDescription": "List all clusters", "search": true, "searchDescription": "Search for a cluster by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_ecs_cluster.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ecs-container-instance.json ================================================ { "type": "ecs-container-instance", "category": 1, "potentialLinks": ["ec2-instance"], "descriptiveName": "Container Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get a container instance by ID which consists of {clusterName}/{id}", "search": true, "searchDescription": "Search for container instances by cluster" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ecs-service.json ================================================ { "type": "ecs-service", "category": 1, "potentialLinks": [ "ecs-cluster", "elbv2-target-group", "servicediscovery-service", "ecs-task-definition", "ecs-capacity-provider", "ec2-subnet", "ecs-security-group", "dns" ], "descriptiveName": "ECS Service", "supportedQueryMethods": { "get": true, "getDescription": "Get an ECS service by full name ({clusterName}/{id})", "search": true, "searchDescription": "Search for ECS services by cluster" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_ecs_service.cluster_name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ecs-task-definition.json ================================================ { "type": "ecs-task-definition", "category": 1, "potentialLinks": ["iam-role", "secretsmanager-secret", "ssm-parameter"], "descriptiveName": "Task Definition", "supportedQueryMethods": { "get": true, "getDescription": "Get a task definition by revision name ({family}:{revision})", "list": true, "listDescription": "List all task definitions", "search": true, "searchDescription": "Search for task definitions by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_ecs_task_definition.family" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ecs-task.json ================================================ { "type": "ecs-task", "category": 1, "potentialLinks": [ "ecs-cluster", "ecs-container-instance", "ecs-task-definition", "ec2-network-interface", "ip" ], "descriptiveName": "ECS Task", "supportedQueryMethods": { "get": true, "getDescription": "Get an ECS task by ID", "search": true, "searchDescription": "Search for ECS tasks by cluster" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/efs-access-point.json ================================================ { "type": "efs-access-point", "category": 3, "descriptiveName": "EFS Access Point", "supportedQueryMethods": { "get": true, "getDescription": "Get an access point by ID", "list": true, "listDescription": "List all access points", "search": true, "searchDescription": "Search for an access point by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_efs_access_point.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/efs-backup-policy.json ================================================ { "type": "efs-backup-policy", "category": 2, "descriptiveName": "EFS Backup Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get an Backup Policy by file system ID", "search": true, "searchDescription": "Search for an Backup Policy by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_efs_backup_policy.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/efs-file-system.json ================================================ { "type": "efs-file-system", "category": 2, "descriptiveName": "EFS File System", "supportedQueryMethods": { "get": true, "getDescription": "Get a file system by ID", "list": true, "listDescription": "List file systems", "search": true, "searchDescription": "Search file systems by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_efs_file_system.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/efs-mount-target.json ================================================ { "type": "efs-mount-target", "category": 2, "descriptiveName": "EFS Mount Target", "supportedQueryMethods": { "get": true, "getDescription": "Get an mount target by ID", "search": true, "searchDescription": "Search for mount targets by file system ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_efs_mount_target.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/efs-replication-configuration.json ================================================ { "type": "efs-replication-configuration", "category": 2, "descriptiveName": "EFS Replication Configuration", "supportedQueryMethods": { "get": true, "getDescription": "Get a replication configuration by file system ID", "list": true, "listDescription": "List all replication configurations", "search": true, "searchDescription": "Search for a replication configuration by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_efs_replication_configuration.source_file_system_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/eks-addon.json ================================================ { "type": "eks-addon", "category": 1, "descriptiveName": "EKS Addon", "supportedQueryMethods": { "get": true, "getDescription": "Get an addon by unique name ({clusterName}:{addonName})", "search": true, "searchDescription": "Search addons by cluster name" }, "terraformMappings": [ { "terraformQueryMap": "aws_eks_addon.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/eks-cluster.json ================================================ { "type": "eks-cluster", "category": 1, "descriptiveName": "EKS Cluster", "supportedQueryMethods": { "get": true, "getDescription": "Get a cluster by name", "list": true, "listDescription": "List all clusters", "search": true, "searchDescription": "Search for clusters by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_eks_cluster.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/eks-fargate-profile.json ================================================ { "type": "eks-fargate-profile", "category": 7, "potentialLinks": ["iam-role", "ec2-subnet"], "descriptiveName": "Fargate Profile", "supportedQueryMethods": { "get": true, "getDescription": "Get a fargate profile by unique name ({clusterName}:{FargateProfileName})", "search": true, "searchDescription": "Search for fargate profiles by cluster name" }, "terraformMappings": [ { "terraformQueryMap": "aws_eks_fargate_profile.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/eks-nodegroup.json ================================================ { "type": "eks-nodegroup", "category": 1, "potentialLinks": [ "ec2-key-pair", "ec2-security-group", "ec2-subnet", "autoscaling-auto-scaling-group", "ec2-launch-template" ], "descriptiveName": "EKS Nodegroup", "supportedQueryMethods": { "get": true, "getDescription": "Get a node group by unique name ({clusterName}:{NodegroupName})", "search": true, "searchDescription": "Search for node groups by cluster name" }, "terraformMappings": [ { "terraformQueryMap": "aws_eks_node_group.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/elb-instance-health.json ================================================ { "type": "elb-instance-health", "category": 5, "potentialLinks": ["ec2-instance"], "descriptiveName": "ELB Instance Health", "supportedQueryMethods": { "get": true, "getDescription": "Get instance health by ID ({LoadBalancerName}/{InstanceId})", "list": true, "listDescription": "List all instance healths" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/elb-load-balancer.json ================================================ { "type": "elb-load-balancer", "category": 3, "potentialLinks": [ "dns", "route53-hosted-zone", "ec2-subnet", "ec2-vpc", "ec2-instance", "elb-instance-health", "ec2-security-group" ], "descriptiveName": "Classic Load Balancer", "supportedQueryMethods": { "get": true, "getDescription": "Get a classic load balancer by name", "list": true, "listDescription": "List all classic load balancers", "search": true, "searchDescription": "Search for classic load balancers by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_elb.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/elbv2-listener.json ================================================ { "type": "elbv2-listener", "category": 3, "potentialLinks": [ "elbv2-load-balancer", "acm-certificate", "elbv2-rule", "cognito-idp-user-pool", "http", "elbv2-target-group" ], "descriptiveName": "ELB Listener", "supportedQueryMethods": { "get": true, "getDescription": "Get an ELB listener by ARN", "search": true, "searchDescription": "Search for ELB listeners by load balancer ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_alb_listener.arn" }, { "terraformQueryMap": "aws_lb_listener.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/elbv2-load-balancer.json ================================================ { "type": "elbv2-load-balancer", "category": 3, "potentialLinks": [ "elbv2-target-group", "elbv2-listener", "dns", "route53-hosted-zone", "ec2-vpc", "ec2-subnet", "ec2-address", "ip", "ec2-security-group", "ec2-coip-pool" ], "descriptiveName": "Elastic Load Balancer", "supportedQueryMethods": { "get": true, "getDescription": "Get an ELB by name", "list": true, "listDescription": "List all ELBs", "search": true, "searchDescription": "Search for ELBs by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_lb.arn" }, { "terraformQueryMap": "aws_lb.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/elbv2-rule.json ================================================ { "type": "elbv2-rule", "category": 7, "descriptiveName": "ELB Rule", "supportedQueryMethods": { "get": true, "getDescription": "Get a rule by ARN", "search": true, "searchDescription": "Search for rules by listener ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_alb_listener_rule.arn" }, { "terraformQueryMap": "aws_lb_listener_rule.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/elbv2-target-group.json ================================================ { "type": "elbv2-target-group", "category": 3, "potentialLinks": ["ec2-vpc", "elbv2-load-balancer", "elbv2-target-health"], "descriptiveName": "Target Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a target group by name", "list": true, "listDescription": "List all target groups", "search": true, "searchDescription": "Search for target groups by load balancer ARN or target group ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_alb_target_group.arn" }, { "terraformMethod": 2, "terraformQueryMap": "aws_lb_target_group.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/elbv2-target-health.json ================================================ { "type": "elbv2-target-health", "category": 5, "potentialLinks": [ "ec2-instance", "lambda-function", "ip", "elbv2-load-balancer" ], "descriptiveName": "ELB Target Health", "supportedQueryMethods": { "get": true, "getDescription": "Get target health by unique ID ({TargetGroupArn}|{Id}|{AvailabilityZone}|{Port})", "search": true, "searchDescription": "Search for target health by target group ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/iam-group.json ================================================ { "type": "iam-group", "category": 4, "descriptiveName": "IAM Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a group by name", "list": true, "listDescription": "List all IAM groups", "search": true, "searchDescription": "Search for a group by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_iam_group.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/iam-instance-profile.json ================================================ { "type": "iam-instance-profile", "category": 4, "potentialLinks": ["iam-role", "iam-policy"], "descriptiveName": "IAM Instance Profile", "supportedQueryMethods": { "get": true, "getDescription": "Get an IAM instance profile by name", "list": true, "listDescription": "List all IAM instance profiles", "search": true, "searchDescription": "Search IAM instance profiles by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_iam_instance_profile.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/iam-policy.json ================================================ { "type": "iam-policy", "category": 4, "potentialLinks": ["iam-group", "iam-user", "iam-role"], "descriptiveName": "IAM Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get an IAM policy by policyFullName ({path} + {policyName})", "list": true, "listDescription": "List all IAM policies", "search": true, "searchDescription": "Search for IAM policies by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_iam_policy.arn" }, { "terraformMethod": 2, "terraformQueryMap": "aws_iam_user_policy_attachment.policy_arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/iam-role.json ================================================ { "type": "iam-role", "category": 4, "potentialLinks": ["iam-policy"], "descriptiveName": "IAM Role", "supportedQueryMethods": { "get": true, "getDescription": "Get an IAM role by name", "list": true, "listDescription": "List all IAM roles", "search": true, "searchDescription": "Search for IAM roles by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_iam_role.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/iam-user.json ================================================ { "type": "iam-user", "category": 4, "potentialLinks": ["iam-group"], "descriptiveName": "IAM User", "supportedQueryMethods": { "get": true, "getDescription": "Get an IAM user by name", "list": true, "listDescription": "List all IAM users", "search": true, "searchDescription": "Search for IAM users by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_iam_user.arn" }, { "terraformQueryMap": "aws_iam_user_group_membership.user" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/kms-alias.json ================================================ { "type": "kms-alias", "category": 4, "potentialLinks": ["kms-key"], "descriptiveName": "KMS Alias", "supportedQueryMethods": { "get": true, "getDescription": "Get an alias by keyID/aliasName", "list": true, "listDescription": "List all aliases", "search": true, "searchDescription": "Search aliases by keyID" }, "terraformMappings": [ { "terraformQueryMap": "aws_kms_alias.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/kms-custom-key-store.json ================================================ { "type": "kms-custom-key-store", "category": 2, "potentialLinks": ["cloudhsmv2-cluster", "ec2-vpc-endpoint-service"], "descriptiveName": "Custom Key Store", "supportedQueryMethods": { "get": true, "getDescription": "Get a custom key store by its ID", "list": true, "listDescription": "List all custom key stores", "search": true, "searchDescription": "Search custom key store by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_kms_custom_key_store.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/kms-grant.json ================================================ { "type": "kms-grant", "category": 4, "potentialLinks": ["kms-key", "iam-user", "iam-role"], "descriptiveName": "KMS Grant", "supportedQueryMethods": { "get": true, "getDescription": "Get a grant by keyID/grantId", "search": true, "searchDescription": "Search grants by keyID" }, "terraformMappings": [ { "terraformQueryMap": "aws_kms_grant.grant_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/kms-key-policy.json ================================================ { "type": "kms-key-policy", "category": 4, "potentialLinks": ["kms-key"], "descriptiveName": "KMS Key Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a KMS key policy by its Key ID", "search": true, "searchDescription": "Search KMS key policies by Key ID" }, "terraformMappings": [ { "terraformQueryMap": "aws_kms_key_policy.key_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/kms-key.json ================================================ { "type": "kms-key", "category": 4, "potentialLinks": ["kms-custom-key-store", "kms-key-policy", "kms-grant"], "descriptiveName": "KMS Key", "supportedQueryMethods": { "get": true, "getDescription": "Get a KMS Key by its ID", "list": true, "listDescription": "List all KMS Keys", "search": true, "searchDescription": "Search for KMS Keys by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_kms_key.key_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/lambda-event-source-mapping.json ================================================ { "type": "lambda-event-source-mapping", "category": 1, "potentialLinks": [ "lambda-function", "dynamodb-table", "kinesis-stream", "sqs-queue", "kafka-cluster", "mq-broker", "rds-db-cluster" ], "descriptiveName": "Lambda Event Source Mapping", "supportedQueryMethods": { "get": true, "getDescription": "Get a Lambda event source mapping by UUID", "list": true, "listDescription": "List all Lambda event source mappings", "search": true, "searchDescription": "Search for Lambda event source mappings by Event Source ARN (SQS, DynamoDB, Kinesis, etc.)" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_lambda_event_source_mapping.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/lambda-function.json ================================================ { "type": "lambda-function", "category": 1, "potentialLinks": [ "iam-role", "s3-bucket", "sns-topic", "sqs-queue", "lambda-function", "events-event-bus", "elbv2-target-group", "vpc-lattice-target-group", "logs-log-group" ], "descriptiveName": "Lambda Function", "supportedQueryMethods": { "get": true, "getDescription": "Get a lambda function by name", "list": true, "listDescription": "List all lambda functions", "search": true, "searchDescription": "Search for lambda functions by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_lambda_function.arn" }, { "terraformQueryMap": "aws_lambda_function_event_invoke_config.id" }, { "terraformQueryMap": "aws_lambda_function_url.function_arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/lambda-layer-version.json ================================================ { "type": "lambda-layer-version", "category": 1, "potentialLinks": ["signer-signing-job", "signer-signing-profile"], "descriptiveName": "Lambda Layer Version", "supportedQueryMethods": { "get": true, "getDescription": "Get a layer version by full name ({layerName}:{versionNumber})", "search": true, "searchDescription": "Search for layer versions by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_lambda_layer_version.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/lambda-layer.json ================================================ { "type": "lambda-layer", "category": 1, "potentialLinks": ["lambda-layer-version"], "descriptiveName": "Lambda Layer", "supportedQueryMethods": { "list": true, "listDescription": "List all lambda layers" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/network-firewall-firewall-policy.json ================================================ { "type": "network-firewall-firewall-policy", "category": 3, "potentialLinks": [ "network-firewall-rule-group", "network-firewall-tls-inspection-configuration", "kms-key" ], "descriptiveName": "Network Firewall Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a Network Firewall Policy by name", "list": true, "listDescription": "List Network Firewall Policies", "search": true, "searchDescription": "Search for Network Firewall Policies by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkfirewall_firewall_policy.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/network-firewall-firewall.json ================================================ { "type": "network-firewall-firewall", "category": 3, "potentialLinks": [ "network-firewall-firewall-policy", "ec2-subnet", "ec2-vpc", "logs-log-group", "s3-bucket", "firehose-delivery-stream", "iam-policy", "kms-key" ], "descriptiveName": "Network Firewall", "supportedQueryMethods": { "get": true, "getDescription": "Get a Network Firewall by name", "list": true, "listDescription": "List Network Firewalls", "search": true, "searchDescription": "Search for Network Firewalls by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkfirewall_firewall.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/network-firewall-rule-group.json ================================================ { "type": "network-firewall-rule-group", "category": 4, "potentialLinks": ["kms-key", "sns-topic", "network-firewall-rule-group"], "descriptiveName": "Network Firewall Rule Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a Network Firewall Rule Group by name", "list": true, "listDescription": "List Network Firewall Rule Groups", "search": true, "searchDescription": "Search for Network Firewall Rule Groups by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkfirewall_rule_group.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/network-firewall-tls-inspection-configuration.json ================================================ { "type": "network-firewall-tls-inspection-configuration", "category": 7, "potentialLinks": [ "acm-certificate", "acm-pca-certificate-authority", "acm-pca-certificate-authority-certificate", "network-firewall-encryption-configuration" ], "descriptiveName": "Network Firewall TLS Inspection Configuration", "supportedQueryMethods": { "get": true, "getDescription": "Get a Network Firewall TLS Inspection Configuration by name", "list": true, "listDescription": "List Network Firewall TLS Inspection Configurations", "search": true, "searchDescription": "Search for Network Firewall TLS Inspection Configurations by ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-connect-attachment.json ================================================ { "type": "networkmanager-connect-attachment", "category": 3, "potentialLinks": ["networkmanager-core-network"], "descriptiveName": "Networkmanager Connect Attachment", "supportedQueryMethods": { "get": true }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_core_network.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-connect-peer-association.json ================================================ { "type": "networkmanager-connect-peer-association", "category": 3, "potentialLinks": [ "networkmanager-global-network", "networkmanager-connect-peer", "networkmanager-device", "networkmanager-link" ], "descriptiveName": "Networkmanager Connect Peer Association", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Connect Peer Association", "list": true, "listDescription": "List all Networkmanager Connect Peer Associations", "search": true, "searchDescription": "Search for Networkmanager ConnectPeerAssociations by GlobalNetworkId" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-connect-peer.json ================================================ { "type": "networkmanager-connect-peer", "category": 3, "potentialLinks": [ "networkmanager-core-network", "networkmanager-connect-attachment", "ip", "rdap-asn", "ec2-subnet" ], "descriptiveName": "Networkmanager Connect Peer", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Connect Peer by id" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_connect_peer.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-connection.json ================================================ { "type": "networkmanager-connection", "category": 3, "potentialLinks": [ "networkmanager-global-network", "networkmanager-link", "networkmanager-device" ], "descriptiveName": "Networkmanager Connection", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Connection", "search": true, "searchDescription": "Search for Networkmanager Connections by GlobalNetworkId, Device ARN, or Connection ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_networkmanager_connection.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-core-network-policy.json ================================================ { "type": "networkmanager-core-network-policy", "category": 3, "potentialLinks": ["networkmanager-core-network"], "descriptiveName": "Networkmanager Core Network Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Core Network Policy by Core Network id" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_core_network_policy.core_network_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-core-network.json ================================================ { "type": "networkmanager-core-network", "category": 3, "potentialLinks": [ "networkmanager-core-network-policy", "networkmanager-connect-peer" ], "descriptiveName": "Networkmanager Core Network", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Core Network by id" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_core_network.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-device.json ================================================ { "type": "networkmanager-device", "category": 3, "potentialLinks": [ "networkmanager-global-network", "networkmanager-site", "networkmanager-link-association", "networkmanager-connection", "networkmanager-network-resource-relationship" ], "descriptiveName": "Networkmanager Device", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Device", "search": true, "searchDescription": "Search for Networkmanager Devices by GlobalNetworkId, {GlobalNetworkId|SiteId} or ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_networkmanager_device.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-global-network.json ================================================ { "type": "networkmanager-global-network", "category": 3, "potentialLinks": [ "networkmanager-site", "networkmanager-transit-gateway-registration", "networkmanager-connect-peer-association", "networkmanager-transit-gateway-connect-peer-association", "networkmanager-network-resource-relationship", "networkmanager-link", "networkmanager-device", "networkmanager-connection" ], "descriptiveName": "Network Manager Global Network", "supportedQueryMethods": { "get": true, "getDescription": "Get a global network by id", "list": true, "listDescription": "List all global networks", "search": true, "searchDescription": "Search for a global network by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_networkmanager_global_network.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-link-association.json ================================================ { "type": "networkmanager-link-association", "category": 3, "potentialLinks": [ "networkmanager-global-network", "networkmanager-link", "networkmanager-device" ], "descriptiveName": "Networkmanager LinkAssociation", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Link Association", "search": true, "searchDescription": "Search for Networkmanager Link Associations by GlobalNetworkId and DeviceId or LinkId" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-link.json ================================================ { "type": "networkmanager-link", "category": 3, "potentialLinks": [ "networkmanager-global-network", "networkmanager-link-association", "networkmanager-site", "networkmanager-network-resource-relationship" ], "descriptiveName": "Networkmanager Link", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Link", "search": true, "searchDescription": "Search for Networkmanager Links by GlobalNetworkId, GlobalNetworkId with SiteId, or ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_networkmanager_link.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-network-resource-relationship.json ================================================ { "type": "networkmanager-network-resource-relationship", "category": 3, "potentialLinks": [ "networkmanager-connection", "networkmanager-device", "networkmanager-link", "networkmanager-site", "directconnect-connection", "directconnect-direct-connect-gateway", "directconnect-virtual-interface", "ec2-customer" ], "descriptiveName": "Networkmanager Network Resource Relationships", "supportedQueryMethods": { "search": true, "searchDescription": "Search for Networkmanager NetworkResourceRelationships by GlobalNetworkId" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-site-to-site-vpn-attachment.json ================================================ { "type": "networkmanager-site-to-site-vpn-attachment", "category": 3, "potentialLinks": ["networkmanager-core-network", "ec2-vpn-connection"], "descriptiveName": "Networkmanager Site To Site Vpn Attachment", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Site To Site Vpn Attachment by id" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_site_to_site_vpn_attachment.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-site.json ================================================ { "type": "networkmanager-site", "category": 3, "potentialLinks": [ "networkmanager-global-network", "networkmanager-link", "networkmanager-device" ], "descriptiveName": "Networkmanager Site", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Site", "search": true, "searchDescription": "Search for Networkmanager Sites by GlobalNetworkId or Site ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_networkmanager_site.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-transit-gateway-connect-peer-association.json ================================================ { "type": "networkmanager-transit-gateway-connect-peer-association", "category": 3, "potentialLinks": [ "networkmanager-global-network", "networkmanager-device", "networkmanager-link" ], "descriptiveName": "Networkmanager Transit Gateway Connect Peer Association", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Transit Gateway Connect Peer Association by id", "list": true, "listDescription": "List all Networkmanager Transit Gateway Connect Peer Associations", "search": true, "searchDescription": "Search for Networkmanager Transit Gateway Connect Peer Associations by GlobalNetworkId" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-transit-gateway-peering.json ================================================ { "type": "networkmanager-transit-gateway-peering", "category": 3, "potentialLinks": [ "networkmanager-core-network", "ec2-transit-gateway-peering-attachment", "ec2-transit-gateway" ], "descriptiveName": "Networkmanager Transit Gateway Peering", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Transit Gateway Peering by id" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_transit_gateway_peering.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-transit-gateway-registration.json ================================================ { "type": "networkmanager-transit-gateway-registration", "category": 3, "potentialLinks": ["networkmanager-global-network", "ec2-transit-gateway"], "descriptiveName": "Networkmanager Transit Gateway Registrations", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Transit Gateway Registrations", "list": true, "listDescription": "List all Networkmanager Transit Gateway Registrations", "search": true, "searchDescription": "Search for Networkmanager Transit Gateway Registrations by GlobalNetworkId" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-transit-gateway-route-table-attachment.json ================================================ { "type": "networkmanager-transit-gateway-route-table-attachment", "category": 3, "potentialLinks": [ "networkmanager-core-network", "networkmanager-transit-gateway-peering", "ec2-transit-gateway-route-table" ], "descriptiveName": "Networkmanager Transit Gateway Route Table Attachment", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager Transit Gateway Route Table Attachment by id" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_transit_gateway_route_table_attachment.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/networkmanager-vpc-attachment.json ================================================ { "type": "networkmanager-vpc-attachment", "category": 3, "potentialLinks": ["networkmanager-core-network"], "descriptiveName": "Networkmanager VPC Attachment", "supportedQueryMethods": { "get": true, "getDescription": "Get a Networkmanager VPC Attachment by id" }, "terraformMappings": [ { "terraformQueryMap": "aws_networkmanager_vpc_attachment.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/rds-db-cluster-parameter-group.json ================================================ { "type": "rds-db-cluster-parameter-group", "category": 6, "descriptiveName": "RDS Cluster Parameter Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a parameter group by name", "list": true, "listDescription": "List all RDS parameter groups", "search": true, "searchDescription": "Search for a parameter group by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_rds_cluster_parameter_group.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/rds-db-cluster.json ================================================ { "type": "rds-db-cluster", "category": 6, "potentialLinks": [ "rds-db-subnet-group", "dns", "rds-db-cluster", "ec2-security-group", "route53-hosted-zone", "kms-key", "kinesis-stream", "rds-option-group", "secretsmanager-secret", "iam-role" ], "descriptiveName": "RDS Cluster", "supportedQueryMethods": { "get": true, "getDescription": "Get a parameter group by name", "list": true, "listDescription": "List all RDS parameter groups", "search": true, "searchDescription": "Search for a parameter group by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_rds_cluster.cluster_identifier" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/rds-db-instance.json ================================================ { "type": "rds-db-instance", "category": 6, "potentialLinks": [ "dns", "route53-hosted-zone", "ec2-security-group", "rds-db-parameter-group", "rds-db-subnet-group", "rds-db-cluster", "kms-key", "logs-log-stream", "iam-role", "kinesis-stream", "backup-recovery-point", "iam-instance-profile", "rds-db-instance-automated-backup", "secretsmanager-secret" ], "descriptiveName": "RDS Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get an instance by ID", "list": true, "listDescription": "List all instances", "search": true, "searchDescription": "Search for instances by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_db_instance.identifier" }, { "terraformQueryMap": "aws_db_instance_role_association.db_instance_identifier" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/rds-db-parameter-group.json ================================================ { "type": "rds-db-parameter-group", "category": 6, "descriptiveName": "RDS Parameter Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a parameter group by name", "list": true, "listDescription": "List all parameter groups", "search": true, "searchDescription": "Search for a parameter group by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_db_parameter_group.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/rds-db-subnet-group.json ================================================ { "type": "rds-db-subnet-group", "category": 3, "potentialLinks": ["ec2-vpc", "ec2-subnet", "outposts-outpost"], "descriptiveName": "RDS Subnet Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a subnet group by name", "list": true, "listDescription": "List all subnet groups", "search": true, "searchDescription": "Search for subnet groups by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_db_subnet_group.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/rds-option-group.json ================================================ { "type": "rds-option-group", "category": 6, "descriptiveName": "RDS Option Group", "supportedQueryMethods": { "get": true, "getDescription": "Get an option group by name", "list": true, "listDescription": "List all RDS option groups", "search": true, "searchDescription": "Search for an option group by ARN" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_db_option_group.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/route53-health-check.json ================================================ { "type": "route53-health-check", "category": 5, "potentialLinks": ["cloudwatch-alarm"], "descriptiveName": "Route53 Health Check", "supportedQueryMethods": { "get": true, "getDescription": "Get health check by ID", "list": true, "listDescription": "List all health checks", "search": true, "searchDescription": "Search for health checks by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_route53_health_check.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/route53-hosted-zone.json ================================================ { "type": "route53-hosted-zone", "category": 3, "potentialLinks": ["route53-resource-record-set"], "descriptiveName": "Hosted Zone", "supportedQueryMethods": { "get": true, "getDescription": "Get a hosted zone by ID", "list": true, "listDescription": "List all hosted zones", "search": true, "searchDescription": "Search for a hosted zone by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_route53_hosted_zone_dnssec.id" }, { "terraformQueryMap": "aws_route53_zone.zone_id" }, { "terraformQueryMap": "aws_route53_zone_association.zone_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/route53-resource-record-set.json ================================================ { "type": "route53-resource-record-set", "category": 3, "potentialLinks": ["dns", "route53-health-check"], "descriptiveName": "Route53 Record Set", "supportedQueryMethods": { "get": true, "getDescription": "Get a Route53 record Set by name", "search": true, "searchDescription": "Search for a record set by hosted zone ID in the format \"/hostedzone/JJN928734JH7HV\" or \"JJN928734JH7HV\" or by terraform ID in the format \"{hostedZone}_{recordName}_{type}\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "aws_route53_record.arn" }, { "terraformMethod": 2, "terraformQueryMap": "aws_route53_record.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/s3-bucket.json ================================================ { "type": "s3-bucket", "category": 2, "potentialLinks": ["lambda-function", "sqs-queue", "sns-topic", "s3-bucket"], "descriptiveName": "S3 Bucket", "supportedQueryMethods": { "get": true, "getDescription": "Get an S3 bucket by name", "list": true, "listDescription": "List all S3 buckets", "search": true, "searchDescription": "Search for S3 buckets by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_s3_bucket_acl.bucket" }, { "terraformQueryMap": "aws_s3_bucket_analytics_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_cors_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_intelligent_tiering_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_inventory.bucket" }, { "terraformQueryMap": "aws_s3_bucket_lifecycle_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_logging.bucket" }, { "terraformQueryMap": "aws_s3_bucket_metric.bucket" }, { "terraformQueryMap": "aws_s3_bucket_notification.bucket" }, { "terraformQueryMap": "aws_s3_bucket_object_lock_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_object.bucket" }, { "terraformQueryMap": "aws_s3_bucket_ownership_controls.bucket" }, { "terraformQueryMap": "aws_s3_bucket_policy.bucket" }, { "terraformQueryMap": "aws_s3_bucket_public_access_block.bucket" }, { "terraformQueryMap": "aws_s3_bucket_replication_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_request_payment_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_server_side_encryption_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket_versioning.bucket" }, { "terraformQueryMap": "aws_s3_bucket_website_configuration.bucket" }, { "terraformQueryMap": "aws_s3_bucket.id" }, { "terraformQueryMap": "aws_s3_object_copy.bucket" }, { "terraformQueryMap": "aws_s3_object.bucket" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/sns-data-protection-policy.json ================================================ { "type": "sns-data-protection-policy", "category": 7, "potentialLinks": ["sns-topic"], "descriptiveName": "SNS Data Protection Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get an SNS data protection policy by associated topic ARN", "search": true, "searchDescription": "Search SNS data protection policies by its ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_sns_topic_data_protection_policy.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/sns-endpoint.json ================================================ { "type": "sns-endpoint", "category": 7, "descriptiveName": "SNS Endpoint", "supportedQueryMethods": { "get": true, "getDescription": "Get an SNS endpoint by its ARN", "search": true, "searchDescription": "Search SNS endpoints by associated Platform Application ARN" } } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/sns-platform-application.json ================================================ { "type": "sns-platform-application", "category": 7, "potentialLinks": ["sns-endpoint"], "descriptiveName": "SNS Platform Application", "supportedQueryMethods": { "get": true, "getDescription": "Get an SNS platform application by its ARN", "list": true, "listDescription": "List all SNS platform applications", "search": true, "searchDescription": "Search SNS platform applications by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_sns_platform_application.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/sns-subscription.json ================================================ { "type": "sns-subscription", "category": 7, "potentialLinks": ["sns-topic", "iam-role"], "descriptiveName": "SNS Subscription", "supportedQueryMethods": { "get": true, "getDescription": "Get an SNS subscription by its ARN", "list": true, "listDescription": "List all SNS subscriptions", "search": true, "searchDescription": "Search SNS subscription by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_sns_topic_subscription.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/sns-topic.json ================================================ { "type": "sns-topic", "category": 7, "potentialLinks": ["kms-key"], "descriptiveName": "SNS Topic", "supportedQueryMethods": { "get": true, "getDescription": "Get an SNS topic by its ARN", "list": true, "listDescription": "List all SNS topics", "search": true, "searchDescription": "Search SNS topic by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_sns_topic.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/sqs-queue.json ================================================ { "type": "sqs-queue", "category": 1, "potentialLinks": ["http", "lambda-event-source-mapping"], "descriptiveName": "SQS Queue", "supportedQueryMethods": { "get": true, "getDescription": "Get an SQS queue attributes by its URL", "list": true, "listDescription": "List all SQS queue URLs", "search": true, "searchDescription": "Search SQS queue by ARN" }, "terraformMappings": [ { "terraformQueryMap": "aws_sqs_queue.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/aws/data/ssm-parameter.json ================================================ { "type": "ssm-parameter", "category": 7, "potentialLinks": ["ip", "http", "dns"], "descriptiveName": "SSM Parameter", "supportedQueryMethods": { "get": true, "getDescription": "Get an SSM parameter by name", "list": true, "listDescription": "List all SSM parameters", "search": true, "searchDescription": "Search for SSM parameters by ARN. This supports ARNs from IAM policies that contain wildcards" }, "terraformMappings": [ { "terraformQueryMap": "aws_ssm_parameter.name" }, { "terraformMethod": 2, "terraformQueryMap": "aws_ssm_parameter.arn" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/embed.go ================================================ // Package adapterdata embeds the per-type adapter metadata JSON files so // other packages can look up category, descriptive name, supported query // methods, and potential links without duplicating the data. package adapterdata import "embed" // Files contains every adapter JSON file under {provider}/data/*.json. // //go:embed */data/*.json var Files embed.FS ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-ai-platform-batch-prediction-job.json ================================================ { "type": "gcp-ai-platform-batch-prediction-job", "category": 8, "potentialLinks": [ "gcp-ai-platform-endpoint", "gcp-ai-platform-model", "gcp-big-query-table", "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-iam-service-account", "gcp-storage-bucket" ], "descriptiveName": "GCP Ai Platform Batch Prediction Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-ai-platform-batch-prediction-job by its \"locations|batchPredictionJobs\"", "search": true, "searchDescription": "Search Batch Prediction Jobs within a location. Use the location name e.g., 'us-central1'" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-ai-platform-custom-job.json ================================================ { "type": "gcp-ai-platform-custom-job", "category": 8, "potentialLinks": [ "gcp-ai-platform-model", "gcp-artifact-registry-docker-image", "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-iam-service-account", "gcp-storage-bucket" ], "descriptiveName": "GCP Ai Platform Custom Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-ai-platform-custom-job by its \"name\"", "list": true, "listDescription": "List all gcp-ai-platform-custom-job" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-ai-platform-endpoint.json ================================================ { "type": "gcp-ai-platform-endpoint", "category": 8, "potentialLinks": [ "gcp-ai-platform-model", "gcp-ai-platform-model-deployment-monitoring-job", "gcp-big-query-table", "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-iam-service-account" ], "descriptiveName": "GCP Ai Platform Endpoint", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-ai-platform-endpoint by its \"name\"", "list": true, "listDescription": "List all gcp-ai-platform-endpoint" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-ai-platform-model-deployment-monitoring-job.json ================================================ { "type": "gcp-ai-platform-model-deployment-monitoring-job", "category": 8, "potentialLinks": [ "gcp-ai-platform-endpoint", "gcp-ai-platform-model", "gcp-big-query-table", "gcp-cloud-kms-crypto-key", "gcp-monitoring-notification-channel", "gcp-storage-bucket" ], "descriptiveName": "GCP Ai Platform Model Deployment Monitoring Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-ai-platform-model-deployment-monitoring-job by its \"locations|modelDeploymentMonitoringJobs\"", "search": true, "searchDescription": "Search Model Deployment Monitoring Jobs within a location. Use the location name e.g., 'us-central1'" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-ai-platform-model.json ================================================ { "type": "gcp-ai-platform-model", "category": 8, "potentialLinks": [ "gcp-ai-platform-endpoint", "gcp-ai-platform-pipeline-job", "gcp-artifact-registry-docker-image", "gcp-cloud-kms-crypto-key", "gcp-storage-bucket" ], "descriptiveName": "GCP Ai Platform Model", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-ai-platform-model by its \"name\"", "list": true, "listDescription": "List all gcp-ai-platform-model" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-ai-platform-pipeline-job.json ================================================ { "type": "gcp-ai-platform-pipeline-job", "category": 8, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-iam-service-account", "gcp-storage-bucket" ], "descriptiveName": "GCP Ai Platform Pipeline Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-ai-platform-pipeline-job by its \"name\"", "list": true, "listDescription": "List all gcp-ai-platform-pipeline-job" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-artifact-registry-docker-image.json ================================================ { "type": "gcp-artifact-registry-docker-image", "category": 2, "descriptiveName": "GCP Artifact Registry Docker Image", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-artifact-registry-docker-image by its \"locations|repositories|dockerImages\"", "search": true, "searchDescription": "Search for Docker images in Artifact Registry. Use the format \"location|repository_id\" or \"projects/[project]/locations/[location]/repository/[repository_id]/dockerImages/[docker_image]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_artifact_registry_docker_image.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-query-data-transfer-transfer-config.json ================================================ { "type": "gcp-big-query-data-transfer-transfer-config", "category": 6, "potentialLinks": [ "gcp-big-query-dataset", "gcp-cloud-kms-crypto-key", "gcp-iam-service-account", "gcp-pub-sub-topic" ], "descriptiveName": "GCP Big Query Data Transfer Transfer Config", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-big-query-data-transfer-transfer-config by its \"locations|transferConfigs\"", "search": true, "searchDescription": "Search for BigQuery Data Transfer transfer configs in a location. Use the format \"location\" or \"projects/project_id/locations/location/transferConfigs/transfer_config_id\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_bigquery_data_transfer_config.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-query-dataset.json ================================================ { "type": "gcp-big-query-dataset", "category": 6, "potentialLinks": [ "gcp-big-query-dataset", "gcp-big-query-routine", "gcp-big-query-table", "gcp-cloud-kms-crypto-key", "gcp-iam-service-account" ], "descriptiveName": "GCP Big Query Dataset", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Big Query Dataset by \"gcp-big-query-dataset-id\"", "list": true, "listDescription": "List all GCP Big Query Dataset items" }, "terraformMappings": [ { "terraformQueryMap": "google_bigquery_dataset.dataset_id" }, { "terraformQueryMap": "google_bigquery_dataset_iam_binding.dataset_id" }, { "terraformQueryMap": "google_bigquery_dataset_iam_member.dataset_id" }, { "terraformQueryMap": "google_bigquery_dataset_iam_policy.dataset_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-query-routine.json ================================================ { "type": "gcp-big-query-routine", "category": 6, "potentialLinks": ["gcp-big-query-dataset", "gcp-storage-bucket"], "descriptiveName": "GCP Big Query Routine", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Big Query Routine by \"gcp-big-query-dataset-id|gcp-big-query-routine-id\"", "search": true, "searchDescription": "Search for GCP Big Query Routine by \"gcp-big-query-routine-id\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_bigquery_routine.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-query-table.json ================================================ { "type": "gcp-big-query-table", "category": 6, "potentialLinks": [ "gcp-big-query-dataset", "gcp-big-query-table", "gcp-cloud-kms-crypto-key", "gcp-storage-bucket" ], "descriptiveName": "GCP Big Query Table", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Big Query Table by \"gcp-big-query-dataset-id|gcp-big-query-table-id\"", "search": true, "searchDescription": "Search for GCP Big Query Table by \"gcp-big-query-dataset-id\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_bigquery_table.id" }, { "terraformMethod": 2, "terraformQueryMap": "google_bigquery_table_iam_binding.dataset_id" }, { "terraformMethod": 2, "terraformQueryMap": "google_bigquery_table_iam_member.dataset_id" }, { "terraformMethod": 2, "terraformQueryMap": "google_bigquery_table_iam_policy.dataset_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-table-admin-app-profile.json ================================================ { "type": "gcp-big-table-admin-app-profile", "category": 7, "potentialLinks": [ "gcp-big-table-admin-cluster", "gcp-big-table-admin-instance" ], "descriptiveName": "GCP Big Table Admin App Profile", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-big-table-admin-app-profile by its \"instances|appProfiles\"", "search": true, "searchDescription": "Search for BigTable App Profiles in an instance. Use the format \"instance\" or \"projects/[project_id]/instances/[instance_name]/appProfiles/[app_profile_id]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_bigtable_app_profile.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-table-admin-backup.json ================================================ { "type": "gcp-big-table-admin-backup", "potentialLinks": [ "gcp-big-table-admin-backup", "gcp-big-table-admin-cluster", "gcp-big-table-admin-table", "gcp-cloud-kms-crypto-key-version" ], "descriptiveName": "GCP Big Table Admin Backup", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-big-table-admin-backup by its \"instances|clusters|backups\"", "search": true, "searchDescription": "Search for gcp-big-table-admin-backup by its \"instances|clusters\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-table-admin-cluster.json ================================================ { "type": "gcp-big-table-admin-cluster", "category": 7, "potentialLinks": [ "gcp-big-table-admin-instance", "gcp-cloud-kms-crypto-key" ], "descriptiveName": "GCP Big Table Admin Cluster", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-big-table-admin-cluster by its \"instances|clusters\"", "search": true, "searchDescription": "Search for gcp-big-table-admin-cluster by its \"instances\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-table-admin-instance.json ================================================ { "type": "gcp-big-table-admin-instance", "category": 7, "potentialLinks": ["gcp-big-table-admin-cluster"], "descriptiveName": "GCP Big Table Admin Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-big-table-admin-instance by its \"name\"", "list": true, "listDescription": "List all gcp-big-table-admin-instance" }, "terraformMappings": [ { "terraformQueryMap": "google_bigtable_instance.name" }, { "terraformQueryMap": "google_bigtable_instance_iam_binding.instance" }, { "terraformQueryMap": "google_bigtable_instance_iam_member.instance" }, { "terraformQueryMap": "google_bigtable_instance_iam_policy.instance" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-big-table-admin-table.json ================================================ { "type": "gcp-big-table-admin-table", "category": 6, "potentialLinks": [ "gcp-big-table-admin-backup", "gcp-big-table-admin-instance", "gcp-big-table-admin-table" ], "descriptiveName": "GCP Big Table Admin Table", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-big-table-admin-table by its \"instances|tables\"", "search": true, "searchDescription": "Search for BigTable tables in an instance. Use the format \"instance_name\" or \"projects/[project_id]/instances/[instance_name]/tables/[table_name]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_bigtable_table.id" }, { "terraformMethod": 2, "terraformQueryMap": "google_bigtable_table_iam_binding.instance_name" }, { "terraformMethod": 2, "terraformQueryMap": "google_bigtable_table_iam_member.instance_name" }, { "terraformMethod": 2, "terraformQueryMap": "google_bigtable_table_iam_policy.instance_name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-certificate-manager-certificate.json ================================================ { "type": "gcp-certificate-manager-certificate", "category": 4, "descriptiveName": "GCP Certificate Manager Certificate", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Certificate Manager Certificate by \"gcp-certificate-manager-certificate-location|gcp-certificate-manager-certificate-name\"", "search": true, "searchDescription": "Search for GCP Certificate Manager Certificate by \"gcp-certificate-manager-certificate-location\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_certificate_manager_certificate.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-billing-billing-info.json ================================================ { "type": "gcp-cloud-billing-billing-info", "category": 7, "potentialLinks": ["gcp-cloud-resource-manager-project"], "descriptiveName": "GCP Cloud Billing Billing Info", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-cloud-billing-billing-info by its \"name\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-build-build.json ================================================ { "type": "gcp-cloud-build-build", "category": 7, "potentialLinks": [ "gcp-artifact-registry-docker-image", "gcp-cloud-kms-crypto-key", "gcp-iam-service-account", "gcp-logging-bucket", "gcp-secret-manager-secret", "gcp-storage-bucket" ], "descriptiveName": "GCP Cloud Build Build", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-cloud-build-build by its \"name\"", "list": true, "listDescription": "List all gcp-cloud-build-build" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-functions-function.json ================================================ { "type": "gcp-cloud-functions-function", "category": 1, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-iam-service-account", "gcp-pub-sub-topic", "gcp-run-service", "gcp-storage-bucket" ], "descriptiveName": "GCP Cloud Functions Function", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-cloud-functions-function by its \"locations|functions\"", "search": true, "searchDescription": "Search for gcp-cloud-functions-function by its \"locations\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-kms-crypto-key-version.json ================================================ { "type": "gcp-cloud-kms-crypto-key-version", "category": 4, "potentialLinks": ["gcp-cloud-kms-crypto-key"], "descriptiveName": "GCP Cloud Kms Crypto Key Version", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Cloud Kms Crypto Key Version by \"gcp-cloud-kms-key-ring-location|gcp-cloud-kms-key-ring-name|gcp-cloud-kms-crypto-key-name|gcp-cloud-kms-crypto-key-version-version\"", "search": true, "searchDescription": "Search for GCP Cloud Kms Crypto Key Version by \"gcp-cloud-kms-key-ring-location|gcp-cloud-kms-key-ring-name|gcp-cloud-kms-crypto-key-name\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_kms_crypto_key_version.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-kms-crypto-key.json ================================================ { "type": "gcp-cloud-kms-crypto-key", "category": 4, "potentialLinks": [ "gcp-cloud-kms-crypto-key-version", "gcp-cloud-kms-key-ring" ], "descriptiveName": "GCP Cloud Kms Crypto Key", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Cloud Kms Crypto Key by \"gcp-cloud-kms-key-ring-location|gcp-cloud-kms-key-ring-name|gcp-cloud-kms-crypto-key-name\"", "search": true, "searchDescription": "Search for GCP Cloud Kms Crypto Key by \"gcp-cloud-kms-key-ring-location|gcp-cloud-kms-key-ring-name\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_kms_crypto_key.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-kms-key-ring.json ================================================ { "type": "gcp-cloud-kms-key-ring", "category": 4, "potentialLinks": ["gcp-cloud-kms-crypto-key"], "descriptiveName": "GCP Cloud Kms Key Ring", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Cloud Kms Key Ring by \"gcp-cloud-kms-key-ring-location|gcp-cloud-kms-key-ring-name\"", "list": true, "listDescription": "List all GCP Cloud Kms Key Ring items", "search": true, "searchDescription": "Search for GCP Cloud Kms Key Ring by \"gcp-cloud-kms-key-ring-location\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_kms_key_ring.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-resource-manager-project.json ================================================ { "type": "gcp-cloud-resource-manager-project", "category": 7, "descriptiveName": "GCP Cloud Resource Manager Project", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-cloud-resource-manager-project by its \"name\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-cloud-resource-manager-tag-value.json ================================================ { "type": "gcp-cloud-resource-manager-tag-value", "category": 7, "descriptiveName": "GCP Cloud Resource Manager Tag Value", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-cloud-resource-manager-tag-value by its \"name\"", "search": true, "searchDescription": "Search for TagValues by TagKey." }, "terraformMappings": [ { "terraformQueryMap": "google_tags_tag_value.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-address.json ================================================ { "type": "gcp-compute-address", "category": 3, "potentialLinks": [ "gcp-compute-address", "gcp-compute-forwarding-rule", "gcp-compute-global-forwarding-rule", "gcp-compute-instance", "gcp-compute-network", "gcp-compute-public-delegated-prefix", "gcp-compute-router", "gcp-compute-subnetwork" ], "descriptiveName": "GCP Compute Address", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Address by \"gcp-compute-address-name\"", "list": true, "listDescription": "List all GCP Compute Address items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_address.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-autoscaler.json ================================================ { "type": "gcp-compute-autoscaler", "category": 7, "potentialLinks": ["gcp-compute-instance-group-manager"], "descriptiveName": "GCP Compute Autoscaler", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Autoscaler by \"gcp-compute-autoscaler-name\"", "list": true, "listDescription": "List all GCP Compute Autoscaler items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_autoscaler.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-backend-service.json ================================================ { "type": "gcp-compute-backend-service", "category": 1, "potentialLinks": [ "gcp-compute-health-check", "gcp-compute-instance", "gcp-compute-instance-group", "gcp-compute-network", "gcp-compute-network-endpoint-group", "gcp-compute-security-policy" ], "descriptiveName": "GCP Compute Backend Service", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Backend Service by \"gcp-compute-backend-service-name\"", "list": true, "listDescription": "List all GCP Compute Backend Service items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_backend_service.name" }, { "terraformQueryMap": "google_compute_region_backend_service.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-disk.json ================================================ { "type": "gcp-compute-disk", "category": 2, "potentialLinks": [ "gcp-cloud-kms-crypto-key-version", "gcp-compute-disk", "gcp-compute-image", "gcp-compute-instance", "gcp-compute-instant-snapshot", "gcp-compute-snapshot", "gcp-storage-bucket" ], "descriptiveName": "GCP Compute Disk", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Disk by \"gcp-compute-disk-name\"", "list": true, "listDescription": "List all GCP Compute Disk items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_disk.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-external-vpn-gateway.json ================================================ { "type": "gcp-compute-external-vpn-gateway", "category": 3, "descriptiveName": "GCP Compute External Vpn Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-external-vpn-gateway by its \"name\"", "list": true, "listDescription": "List all gcp-compute-external-vpn-gateway" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_external_vpn_gateway.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-firewall.json ================================================ { "type": "gcp-compute-firewall", "category": 3, "potentialLinks": [ "gcp-compute-instance", "gcp-compute-network", "gcp-iam-service-account" ], "descriptiveName": "GCP Compute Firewall", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-firewall by its \"name\"", "list": true, "listDescription": "List all gcp-compute-firewall", "search": true, "searchDescription": "Search for firewalls by network tag. The query is a plain network tag name." }, "terraformMappings": [ { "terraformQueryMap": "google_compute_firewall.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-forwarding-rule.json ================================================ { "type": "gcp-compute-forwarding-rule", "category": 3, "potentialLinks": [ "gcp-compute-backend-service", "gcp-compute-forwarding-rule", "gcp-compute-network", "gcp-compute-public-delegated-prefix", "gcp-compute-subnetwork", "gcp-compute-target-http-proxy", "gcp-compute-target-https-proxy", "gcp-compute-target-pool" ], "descriptiveName": "GCP Compute Forwarding Rule", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Forwarding Rule by \"gcp-compute-forwarding-rule-name\"", "list": true, "listDescription": "List all GCP Compute Forwarding Rule items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_forwarding_rule.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-global-address.json ================================================ { "type": "gcp-compute-global-address", "category": 3, "potentialLinks": [ "gcp-compute-network", "gcp-compute-public-delegated-prefix", "gcp-compute-subnetwork" ], "descriptiveName": "GCP Compute Global Address", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-global-address by its \"name\"", "list": true, "listDescription": "List all gcp-compute-global-address" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_global_address.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-global-forwarding-rule.json ================================================ { "type": "gcp-compute-global-forwarding-rule", "category": 3, "potentialLinks": [ "gcp-compute-backend-service", "gcp-compute-network", "gcp-compute-subnetwork", "gcp-compute-target-http-proxy" ], "descriptiveName": "GCP Compute Global Forwarding Rule", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-global-forwarding-rule by its \"name\"", "list": true, "listDescription": "List all gcp-compute-global-forwarding-rule" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_global_forwarding_rule.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-health-check.json ================================================ { "type": "gcp-compute-health-check", "category": 7, "descriptiveName": "GCP Compute Health Check", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Health Check by \"gcp-compute-health-check-name\"", "list": true, "listDescription": "List all GCP Compute Health Check items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_health_check.name" }, { "terraformQueryMap": "google_compute_region_health_check.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-http-health-check.json ================================================ { "type": "gcp-compute-http-health-check", "category": 3, "descriptiveName": "GCP Compute Http Health Check", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-http-health-check by its \"name\"", "list": true, "listDescription": "List all gcp-compute-http-health-check" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_http_health_check.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-image.json ================================================ { "type": "gcp-compute-image", "category": 1, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-compute-disk", "gcp-compute-image", "gcp-compute-snapshot", "gcp-iam-service-account", "gcp-storage-bucket" ], "descriptiveName": "GCP Compute Image", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Image by \"gcp-compute-image-name\"", "list": true, "listDescription": "List all GCP Compute Image items", "search": true, "searchDescription": "Search for GCP Compute Image by \"gcp-compute-image-family\"" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_image.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-instance-group-manager.json ================================================ { "type": "gcp-compute-instance-group-manager", "category": 1, "potentialLinks": [ "gcp-compute-autoscaler", "gcp-compute-health-check", "gcp-compute-instance-group", "gcp-compute-instance-template", "gcp-compute-target-pool" ], "descriptiveName": "GCP Compute Instance Group Manager", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Instance Group Manager by \"gcp-compute-instance-group-manager-name\"", "list": true, "listDescription": "List all GCP Compute Instance Group Manager items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_instance_group_manager.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-instance-group.json ================================================ { "type": "gcp-compute-instance-group", "category": 1, "potentialLinks": ["gcp-compute-network", "gcp-compute-subnetwork"], "descriptiveName": "GCP Compute Instance Group", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Instance Group by \"gcp-compute-instance-group-name\"", "list": true, "listDescription": "List all GCP Compute Instance Group items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_instance_group.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-instance-template.json ================================================ { "type": "gcp-compute-instance-template", "category": 1, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-compute-disk", "gcp-compute-firewall", "gcp-compute-image", "gcp-compute-instance", "gcp-compute-network", "gcp-compute-node-group", "gcp-compute-reservation", "gcp-compute-route", "gcp-compute-security-policy", "gcp-compute-snapshot", "gcp-compute-subnetwork", "gcp-iam-service-account" ], "descriptiveName": "GCP Compute Instance Template", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-instance-template by its \"name\"", "list": true, "listDescription": "List all gcp-compute-instance-template", "search": true, "searchDescription": "Search for instance templates by network tag. The query is a plain network tag name." }, "terraformMappings": [ { "terraformQueryMap": "google_compute_instance_template.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-instance.json ================================================ { "type": "gcp-compute-instance", "category": 1, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-compute-disk", "gcp-compute-firewall", "gcp-compute-image", "gcp-compute-instance-group-manager", "gcp-compute-instance-template", "gcp-compute-network", "gcp-compute-route", "gcp-compute-snapshot", "gcp-compute-subnetwork", "gcp-iam-service-account" ], "descriptiveName": "GCP Compute Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Instance by \"gcp-compute-instance-name\"", "list": true, "listDescription": "List all GCP Compute Instance items", "search": true, "searchDescription": "Search for GCP Compute Instance by \"gcp-compute-instance-networkTag\"" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_instance.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-instant-snapshot.json ================================================ { "type": "gcp-compute-instant-snapshot", "category": 2, "potentialLinks": ["gcp-compute-disk"], "descriptiveName": "GCP Compute Instant Snapshot", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Instant Snapshot by \"gcp-compute-instant-snapshot-name\"", "list": true, "listDescription": "List all GCP Compute Instant Snapshot items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_instant_snapshot.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-machine-image.json ================================================ { "type": "gcp-compute-machine-image", "category": 1, "potentialLinks": [ "gcp-cloud-kms-crypto-key-version", "gcp-compute-disk", "gcp-compute-image", "gcp-compute-instance", "gcp-compute-network", "gcp-compute-snapshot", "gcp-compute-subnetwork", "gcp-iam-service-account" ], "descriptiveName": "GCP Compute Machine Image", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Machine Image by \"gcp-compute-machine-image-name\"", "list": true, "listDescription": "List all GCP Compute Machine Image items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_machine_image.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-network-endpoint-group.json ================================================ { "type": "gcp-compute-network-endpoint-group", "category": 3, "potentialLinks": [ "gcp-cloud-functions-function", "gcp-compute-network", "gcp-compute-subnetwork", "gcp-run-service" ], "descriptiveName": "GCP Compute Network Endpoint Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-network-endpoint-group by its \"name\"", "list": true, "listDescription": "List all gcp-compute-network-endpoint-group" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_network_endpoint_group.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-network.json ================================================ { "type": "gcp-compute-network", "category": 3, "potentialLinks": ["gcp-compute-network", "gcp-compute-subnetwork"], "descriptiveName": "GCP Compute Network", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-network by its \"name\"", "list": true, "listDescription": "List all gcp-compute-network" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_network.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-node-group.json ================================================ { "type": "gcp-compute-node-group", "category": 1, "potentialLinks": ["gcp-compute-node-template"], "descriptiveName": "GCP Compute Node Group", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Node Group by \"gcp-compute-node-group-name\"", "list": true, "listDescription": "List all GCP Compute Node Group items", "search": true, "searchDescription": "Search for GCP Compute Node Group by \"gcp-compute-node-group-nodeTemplateName\"" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_node_group.name" }, { "terraformMethod": 2, "terraformQueryMap": "google_compute_node_template.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-node-template.json ================================================ { "type": "gcp-compute-node-template", "category": 7, "potentialLinks": ["gcp-compute-node-group"], "descriptiveName": "GCP Compute Node Template", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Node Template by \"gcp-compute-node-template-name\"", "list": true, "listDescription": "List all GCP Compute Node Template items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_node_template.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-project.json ================================================ { "type": "gcp-compute-project", "category": 7, "potentialLinks": ["gcp-iam-service-account", "gcp-storage-bucket"], "descriptiveName": "GCP Compute Project", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-project by its \"name\"" }, "terraformMappings": [ { "terraformQueryMap": "google_project.project_id" }, { "terraformQueryMap": "google_compute_shared_vpc_host_project.project" }, { "terraformQueryMap": "google_compute_shared_vpc_service_project.service_project" }, { "terraformQueryMap": "google_compute_shared_vpc_service_project.host_project" }, { "terraformQueryMap": "google_project_iam_binding.project" }, { "terraformQueryMap": "google_project_iam_member.project" }, { "terraformQueryMap": "google_project_iam_policy.project" }, { "terraformQueryMap": "google_project_iam_audit_config.project" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-public-delegated-prefix.json ================================================ { "type": "gcp-compute-public-delegated-prefix", "category": 3, "potentialLinks": [ "gcp-cloud-resource-manager-project", "gcp-compute-public-delegated-prefix" ], "descriptiveName": "GCP Compute Public Delegated Prefix", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-public-delegated-prefix by its \"name\"", "list": true, "listDescription": "List all gcp-compute-public-delegated-prefix", "search": true, "searchDescription": "Search with full ID: projects/[project]/regions/[region]/publicDelegatedPrefixes/[name] (used for terraform mapping)." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_compute_public_delegated_prefix.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-region-commitment.json ================================================ { "type": "gcp-compute-region-commitment", "potentialLinks": ["gcp-compute-reservation"], "descriptiveName": "GCP Compute Region Commitment", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-region-commitment by its \"name\"", "list": true, "listDescription": "List all gcp-compute-region-commitment" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_region_commitment.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-regional-instance-group-manager.json ================================================ { "type": "gcp-compute-regional-instance-group-manager", "category": 1, "potentialLinks": [ "gcp-compute-autoscaler", "gcp-compute-health-check", "gcp-compute-instance-group", "gcp-compute-instance-template", "gcp-compute-target-pool" ], "descriptiveName": "GCP Compute Regional Instance Group Manager", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Regional Instance Group Manager by \"gcp-compute-regional-instance-group-manager-name\"", "list": true, "listDescription": "List all GCP Compute Regional Instance Group Manager items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_region_instance_group_manager.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-reservation.json ================================================ { "type": "gcp-compute-reservation", "category": 1, "potentialLinks": ["gcp-compute-region-commitment"], "descriptiveName": "GCP Compute Reservation", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Reservation by \"gcp-compute-reservation-name\"", "list": true, "listDescription": "List all GCP Compute Reservation items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_reservation.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-route.json ================================================ { "type": "gcp-compute-route", "category": 3, "potentialLinks": [ "gcp-compute-forwarding-rule", "gcp-compute-instance", "gcp-compute-network", "gcp-compute-vpn-tunnel" ], "descriptiveName": "GCP Compute Route", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-route by its \"name\"", "list": true, "listDescription": "List all gcp-compute-route", "search": true, "searchDescription": "Search for routes by network tag. The query is a plain network tag name." }, "terraformMappings": [ { "terraformQueryMap": "google_compute_route.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-router.json ================================================ { "type": "gcp-compute-router", "category": 3, "potentialLinks": [ "gcp-compute-network", "gcp-compute-subnetwork", "gcp-compute-vpn-tunnel" ], "descriptiveName": "GCP Compute Router", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-router by its \"name\"", "list": true, "listDescription": "List all gcp-compute-router", "search": true, "searchDescription": "Search with full ID: projects/[project]/regions/[region]/routers/[router] (used for terraform mapping)." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_compute_router.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-security-policy.json ================================================ { "type": "gcp-compute-security-policy", "category": 4, "descriptiveName": "GCP Compute Security Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Security Policy by \"gcp-compute-security-policy-name\"", "list": true, "listDescription": "List all GCP Compute Security Policy items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_security_policy.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-snapshot.json ================================================ { "type": "gcp-compute-snapshot", "category": 2, "potentialLinks": [ "gcp-cloud-kms-crypto-key-version", "gcp-compute-disk", "gcp-compute-instant-snapshot" ], "descriptiveName": "GCP Compute Snapshot", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Compute Snapshot by \"gcp-compute-snapshot-name\"", "list": true, "listDescription": "List all GCP Compute Snapshot items" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_snapshot.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-ssl-certificate.json ================================================ { "type": "gcp-compute-ssl-certificate", "category": 7, "descriptiveName": "GCP Compute Ssl Certificate", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-ssl-certificate by its \"name\"", "list": true, "listDescription": "List all gcp-compute-ssl-certificate" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_ssl_certificate.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-ssl-policy.json ================================================ { "type": "gcp-compute-ssl-policy", "category": 4, "descriptiveName": "GCP Compute Ssl Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-ssl-policy by its \"name\"", "list": true, "listDescription": "List all gcp-compute-ssl-policy" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_ssl_policy.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-subnetwork.json ================================================ { "type": "gcp-compute-subnetwork", "category": 3, "potentialLinks": [ "gcp-compute-network", "gcp-compute-public-delegated-prefix" ], "descriptiveName": "GCP Compute Subnetwork", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-subnetwork by its \"name\"", "list": true, "listDescription": "List all gcp-compute-subnetwork" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_subnetwork.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-target-http-proxy.json ================================================ { "type": "gcp-compute-target-http-proxy", "category": 3, "potentialLinks": ["gcp-compute-url-map"], "descriptiveName": "GCP Compute Target Http Proxy", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-target-http-proxy by its \"name\"", "list": true, "listDescription": "List all gcp-compute-target-http-proxy" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_target_http_proxy.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-target-https-proxy.json ================================================ { "type": "gcp-compute-target-https-proxy", "category": 3, "potentialLinks": [ "gcp-compute-ssl-certificate", "gcp-compute-ssl-policy", "gcp-compute-url-map" ], "descriptiveName": "GCP Compute Target Https Proxy", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-target-https-proxy by its \"name\"", "list": true, "listDescription": "List all gcp-compute-target-https-proxy" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_target_https_proxy.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-target-pool.json ================================================ { "type": "gcp-compute-target-pool", "category": 3, "potentialLinks": [ "gcp-compute-health-check", "gcp-compute-instance", "gcp-compute-target-pool" ], "descriptiveName": "GCP Compute Target Pool", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-target-pool by its \"name\"", "list": true, "listDescription": "List all gcp-compute-target-pool", "search": true, "searchDescription": "Search with full ID: projects/[project]/regions/[region]/targetPools/[name] (used for terraform mapping)." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_compute_target_pool.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-url-map.json ================================================ { "type": "gcp-compute-url-map", "category": 3, "potentialLinks": ["gcp-compute-backend-service"], "descriptiveName": "GCP Compute Url Map", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-url-map by its \"name\"", "list": true, "listDescription": "List all gcp-compute-url-map" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_url_map.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-vpn-gateway.json ================================================ { "type": "gcp-compute-vpn-gateway", "category": 3, "potentialLinks": ["gcp-compute-network"], "descriptiveName": "GCP Compute Vpn Gateway", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-vpn-gateway by its \"name\"", "list": true, "listDescription": "List all gcp-compute-vpn-gateway" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_ha_vpn_gateway.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-compute-vpn-tunnel.json ================================================ { "type": "gcp-compute-vpn-tunnel", "category": 3, "potentialLinks": [ "gcp-compute-external-vpn-gateway", "gcp-compute-router", "gcp-compute-vpn-gateway" ], "descriptiveName": "GCP Compute Vpn Tunnel", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-compute-vpn-tunnel by its \"name\"", "list": true, "listDescription": "List all gcp-compute-vpn-tunnel" }, "terraformMappings": [ { "terraformQueryMap": "google_compute_vpn_tunnel.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-container-cluster.json ================================================ { "type": "gcp-container-cluster", "category": 1, "potentialLinks": [ "gcp-big-query-dataset", "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-compute-network", "gcp-compute-node-group", "gcp-compute-subnetwork", "gcp-container-node-pool", "gcp-iam-service-account", "gcp-pub-sub-topic" ], "descriptiveName": "GCP Container Cluster", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-container-cluster by its \"locations|clusters\"", "search": true, "searchDescription": "Search for GKE clusters in a location. Use the format \"location\" or the full resource name supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_container_cluster.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-container-node-pool.json ================================================ { "type": "gcp-container-node-pool", "category": 1, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-compute-instance-group-manager", "gcp-compute-network", "gcp-compute-node-group", "gcp-compute-subnetwork", "gcp-container-cluster", "gcp-iam-service-account" ], "descriptiveName": "GCP Container Node Pool", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-container-node-pool by its \"locations|clusters|nodePools\"", "search": true, "searchDescription": "Search GKE Node Pools within a cluster. Use \"[location]|[cluster]\" or the full resource name supported by Terraform mappings: \"[project]/[location]/[cluster]/[node_pool_name]\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_container_node_pool.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dataflow-job.json ================================================ { "type": "gcp-dataflow-job", "category": 7, "potentialLinks": [ "gcp-big-query-dataset", "gcp-big-query-table", "gcp-big-table-admin-instance", "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-compute-subnetwork", "gcp-iam-service-account", "gcp-pub-sub-subscription", "gcp-pub-sub-topic", "gcp-spanner-instance" ], "descriptiveName": "GCP Dataflow Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dataflow-job by its \"locations|jobs\"", "search": true, "searchDescription": "Search for gcp-dataflow-job by location" }, "terraformMappings": [ { "terraformQueryMap": "google_dataflow_job.job_id" }, { "terraformQueryMap": "google_dataflow_flex_template_job.job_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dataform-repository.json ================================================ { "type": "gcp-dataform-repository", "category": 6, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-iam-service-account", "gcp-secret-manager-secret" ], "descriptiveName": "GCP Dataform Repository", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dataform-repository by its \"locations|repositories\"", "search": true, "searchDescription": "Search for Dataform repositories in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/repositories/[repository_name]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_dataform_repository.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dataplex-aspect-type.json ================================================ { "type": "gcp-dataplex-aspect-type", "category": 7, "descriptiveName": "GCP Dataplex Aspect Type", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dataplex-aspect-type by its \"locations|aspectTypes\"", "search": true, "searchDescription": "Search for Dataplex aspect types in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/aspectTypes/[aspect_type_id]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_dataplex_aspect_type.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dataplex-data-scan.json ================================================ { "type": "gcp-dataplex-data-scan", "category": 5, "potentialLinks": ["gcp-big-query-table", "gcp-storage-bucket"], "descriptiveName": "GCP Dataplex Data Scan", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dataplex-data-scan by its \"locations|dataScans\"", "search": true, "searchDescription": "Search for Dataplex data scans in a location. Use the location name e.g., 'us-central1' or the format \"projects/[project_id]/locations/[location]/dataScans/[data_scan_id]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_dataplex_datascan.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dataplex-entry-group.json ================================================ { "type": "gcp-dataplex-entry-group", "category": 2, "descriptiveName": "GCP Dataplex Entry Group", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dataplex-entry-group by its \"locations|entryGroups\"", "search": true, "searchDescription": "Search for Dataplex entry groups in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/entryGroups/[entry_group_id]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_dataplex_entry_group.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dataproc-autoscaling-policy.json ================================================ { "type": "gcp-dataproc-autoscaling-policy", "category": 7, "descriptiveName": "GCP Dataproc Autoscaling Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dataproc-autoscaling-policy by its \"name\"", "list": true, "listDescription": "List all gcp-dataproc-autoscaling-policy" }, "terraformMappings": [ { "terraformQueryMap": "google_dataproc_autoscaling_policy.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dataproc-cluster.json ================================================ { "type": "gcp-dataproc-cluster", "category": 1, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-compute-image", "gcp-compute-instance-group-manager", "gcp-compute-network", "gcp-compute-node-group", "gcp-compute-subnetwork", "gcp-container-cluster", "gcp-container-node-pool", "gcp-dataproc-autoscaling-policy", "gcp-dataproc-cluster", "gcp-iam-service-account", "gcp-storage-bucket" ], "descriptiveName": "GCP Dataproc Cluster", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dataproc-cluster by its \"name\"", "list": true, "listDescription": "List all gcp-dataproc-cluster" }, "terraformMappings": [ { "terraformQueryMap": "google_dataproc_cluster.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-dns-managed-zone.json ================================================ { "type": "gcp-dns-managed-zone", "category": 3, "potentialLinks": ["gcp-compute-network", "gcp-container-cluster"], "descriptiveName": "GCP Dns Managed Zone", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-dns-managed-zone by its \"name\"", "list": true, "listDescription": "List all gcp-dns-managed-zone" }, "terraformMappings": [ { "terraformQueryMap": "google_dns_managed_zone.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-essential-contacts-contact.json ================================================ { "type": "gcp-essential-contacts-contact", "descriptiveName": "GCP Essential Contacts Contact", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-essential-contacts-contact by its \"name\"", "list": true, "listDescription": "List all gcp-essential-contacts-contact", "search": true, "searchDescription": "Search for contacts by their ID in the form of \"projects/[project_id]/contacts/[contact_id]\"." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_essential_contacts_contact.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-file-instance.json ================================================ { "type": "gcp-file-instance", "category": 2, "potentialLinks": ["gcp-cloud-kms-crypto-key", "gcp-compute-network"], "descriptiveName": "GCP File Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-file-instance by its \"locations|instances\"", "search": true, "searchDescription": "Search for Filestore instances in a location. Use the location string or the full resource name supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_filestore_instance.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-iam-role.json ================================================ { "type": "gcp-iam-role", "category": 4, "descriptiveName": "GCP Iam Role", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-iam-role by its \"name\"", "list": true, "listDescription": "List all gcp-iam-role" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-iam-service-account-key.json ================================================ { "type": "gcp-iam-service-account-key", "category": 4, "potentialLinks": ["gcp-iam-service-account"], "descriptiveName": "GCP Iam Service Account Key", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Iam Service Account Key by \"gcp-iam-service-account-email or unique_id|gcp-iam-service-account-key-name\"", "search": true, "searchDescription": "Search for GCP Iam Service Account Key by \"gcp-iam-service-account-email or unique_id\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_service_account_key.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-iam-service-account.json ================================================ { "type": "gcp-iam-service-account", "category": 4, "potentialLinks": [ "gcp-cloud-resource-manager-project", "gcp-iam-service-account-key" ], "descriptiveName": "GCP Iam Service Account", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Iam Service Account by \"gcp-iam-service-account-email or unique_id\"", "list": true, "listDescription": "List all GCP Iam Service Account items" }, "terraformMappings": [ { "terraformQueryMap": "google_service_account.email" }, { "terraformQueryMap": "google_service_account.unique_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-logging-bucket.json ================================================ { "type": "gcp-logging-bucket", "category": 5, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-iam-service-account" ], "descriptiveName": "GCP Logging Bucket", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-logging-bucket by its \"locations|buckets\"", "search": true, "searchDescription": "Search for gcp-logging-bucket by its \"locations\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-logging-link.json ================================================ { "type": "gcp-logging-link", "category": 5, "potentialLinks": ["gcp-big-query-dataset", "gcp-logging-bucket"], "descriptiveName": "GCP Logging Link", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-logging-link by its \"locations|buckets|links\"", "search": true, "searchDescription": "Search for gcp-logging-link by its \"locations|buckets\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-logging-saved-query.json ================================================ { "type": "gcp-logging-saved-query", "category": 5, "descriptiveName": "GCP Logging Saved Query", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-logging-saved-query by its \"locations|savedQueries\"", "search": true, "searchDescription": "Search for gcp-logging-saved-query by its \"locations\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-logging-sink.json ================================================ { "type": "gcp-logging-sink", "category": 7, "potentialLinks": [ "gcp-big-query-dataset", "gcp-iam-service-account", "gcp-logging-bucket", "gcp-pub-sub-topic", "gcp-storage-bucket" ], "descriptiveName": "GCP Logging Sink", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Logging Sink by \"gcp-logging-sink-name\"", "list": true, "listDescription": "List all GCP Logging Sink items" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-monitoring-alert-policy.json ================================================ { "type": "gcp-monitoring-alert-policy", "category": 5, "potentialLinks": ["gcp-monitoring-notification-channel"], "descriptiveName": "GCP Monitoring Alert Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-monitoring-alert-policy by its \"name\"", "list": true, "listDescription": "List all gcp-monitoring-alert-policy", "search": true, "searchDescription": "Search by full resource name: projects/[project]/alertPolicies/[alert_policy_id] (used for terraform mapping)." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_monitoring_alert_policy.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-monitoring-custom-dashboard.json ================================================ { "type": "gcp-monitoring-custom-dashboard", "category": 5, "descriptiveName": "GCP Monitoring Custom Dashboard", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-monitoring-custom-dashboard by its \"name\"", "list": true, "listDescription": "List all gcp-monitoring-custom-dashboard", "search": true, "searchDescription": "Search for custom dashboards by their ID in the form of \"projects/[project_id]/dashboards/[dashboard_id]\". This is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_monitoring_dashboard.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-monitoring-notification-channel.json ================================================ { "type": "gcp-monitoring-notification-channel", "category": 5, "potentialLinks": ["gcp-pub-sub-topic"], "descriptiveName": "GCP Monitoring Notification Channel", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-monitoring-notification-channel by its \"name\"", "list": true, "listDescription": "List all gcp-monitoring-notification-channel", "search": true, "searchDescription": "Search by full resource name: projects/[project]/notificationChannels/[notificationChannel] (used for terraform mapping)." }, "terraformMappings": [ { "terraformQueryMap": "google_monitoring_notification_channel.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-orgpolicy-policy.json ================================================ { "type": "gcp-orgpolicy-policy", "category": 7, "potentialLinks": ["gcp-cloud-resource-manager-project"], "descriptiveName": "GCP Orgpolicy Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-orgpolicy-policy by its \"name\"", "list": true, "listDescription": "List all gcp-orgpolicy-policy", "search": true, "searchDescription": "Search with the full policy name: projects/[project]/policies/[constraint] (used for terraform mapping)." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_org_policy_policy.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-pub-sub-subscription.json ================================================ { "type": "gcp-pub-sub-subscription", "category": 7, "potentialLinks": [ "gcp-big-query-table", "gcp-iam-service-account", "gcp-pub-sub-subscription", "gcp-pub-sub-topic", "gcp-storage-bucket" ], "descriptiveName": "GCP Pub Sub Subscription", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-pub-sub-subscription by its \"name\"", "list": true, "listDescription": "List all gcp-pub-sub-subscription" }, "terraformMappings": [ { "terraformQueryMap": "google_pubsub_subscription.name" }, { "terraformQueryMap": "google_pubsub_subscription_iam_binding.subscription" }, { "terraformQueryMap": "google_pubsub_subscription_iam_member.subscription" }, { "terraformQueryMap": "google_pubsub_subscription_iam_policy.subscription" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-pub-sub-topic.json ================================================ { "type": "gcp-pub-sub-topic", "category": 7, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-iam-service-account", "gcp-storage-bucket" ], "descriptiveName": "GCP Pub Sub Topic", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-pub-sub-topic by its \"name\"", "list": true, "listDescription": "List all gcp-pub-sub-topic" }, "terraformMappings": [ { "terraformQueryMap": "google_pubsub_topic.name" }, { "terraformQueryMap": "google_pubsub_topic_iam_binding.topic" }, { "terraformQueryMap": "google_pubsub_topic_iam_member.topic" }, { "terraformQueryMap": "google_pubsub_topic_iam_policy.topic" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-redis-instance.json ================================================ { "type": "gcp-redis-instance", "category": 6, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-compute-ssl-certificate" ], "descriptiveName": "GCP Redis Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-redis-instance by its \"locations|instances\"", "search": true, "searchDescription": "Search Redis instances in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/instances/[instance_name]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_redis_instance.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-run-revision.json ================================================ { "type": "gcp-run-revision", "category": 7, "potentialLinks": [ "gcp-artifact-registry-docker-image", "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-compute-subnetwork", "gcp-iam-service-account", "gcp-run-service", "gcp-secret-manager-secret", "gcp-sql-admin-instance", "gcp-storage-bucket" ], "descriptiveName": "GCP Run Revision", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-run-revision by its \"locations|services|revisions\"", "search": true, "searchDescription": "Search for gcp-run-revision by its \"locations|services\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-run-service.json ================================================ { "type": "gcp-run-service", "category": 1, "potentialLinks": [ "gcp-artifact-registry-docker-image", "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-compute-subnetwork", "gcp-iam-service-account", "gcp-run-revision", "gcp-secret-manager-secret", "gcp-sql-admin-instance", "gcp-storage-bucket" ], "descriptiveName": "GCP Run Service", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-run-service by its \"locations|services\"", "search": true, "searchDescription": "Search for gcp-run-service by its \"locations\"" }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_cloud_run_v2_service.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-secret-manager-secret.json ================================================ { "type": "gcp-secret-manager-secret", "category": 4, "potentialLinks": ["gcp-cloud-kms-crypto-key", "gcp-pub-sub-topic"], "descriptiveName": "GCP Secret Manager Secret", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-secret-manager-secret by its \"name\"", "list": true, "listDescription": "List all gcp-secret-manager-secret" }, "terraformMappings": [ { "terraformQueryMap": "google_secret_manager_secret.secret_id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-security-center-management-security-center-service.json ================================================ { "type": "gcp-security-center-management-security-center-service", "category": 4, "potentialLinks": ["gcp-cloud-resource-manager-project"], "descriptiveName": "GCP Security Center Management Security Center Service", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-security-center-management-security-center-service by its \"locations|securityCenterServices\"", "search": true, "searchDescription": "Search Security Center services in a location. Use the format \"location\"." } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-service-directory-endpoint.json ================================================ { "type": "gcp-service-directory-endpoint", "category": 7, "potentialLinks": ["gcp-compute-network"], "descriptiveName": "GCP Service Directory Endpoint", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-service-directory-endpoint by its \"locations|namespaces|services|endpoints\"", "search": true, "searchDescription": "Search for endpoints by \"location|namespace_id|service_id\" or \"projects/[project_id]/locations/[location]/namespaces/[namespace_id]/services/[service_id]/endpoints/[endpoint_id]\" which is supported for terraform mappings." }, "terraformMappings": [ { "terraformMethod": 2, "terraformQueryMap": "google_service_directory_endpoint.id" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-service-usage-service.json ================================================ { "type": "gcp-service-usage-service", "category": 7, "potentialLinks": ["gcp-cloud-resource-manager-project", "gcp-pub-sub-topic"], "descriptiveName": "GCP Service Usage Service", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-service-usage-service by its \"name\"", "list": true, "listDescription": "List all gcp-service-usage-service" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-spanner-database.json ================================================ { "type": "gcp-spanner-database", "category": 6, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-spanner-database", "gcp-spanner-instance" ], "descriptiveName": "GCP Spanner Database", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-spanner-database by its \"instances|databases\"", "search": true, "searchDescription": "Search for gcp-spanner-database by its \"instances\"" }, "terraformMappings": [ { "terraformQueryMap": "google_spanner_database.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-spanner-instance.json ================================================ { "type": "gcp-spanner-instance", "category": 6, "potentialLinks": ["gcp-spanner-database"], "descriptiveName": "GCP Spanner Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-spanner-instance by its \"name\"", "list": true, "listDescription": "List all gcp-spanner-instance" }, "terraformMappings": [ { "terraformQueryMap": "google_spanner_instance.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-sql-admin-backup-run.json ================================================ { "type": "gcp-sql-admin-backup-run", "category": 6, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-sql-admin-instance" ], "descriptiveName": "GCP Sql Admin Backup Run", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-sql-admin-backup-run by its \"instances|backupRuns\"", "search": true, "searchDescription": "Search for gcp-sql-admin-backup-run by its \"instances\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-sql-admin-backup.json ================================================ { "type": "gcp-sql-admin-backup", "category": 6, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-cloud-kms-crypto-key-version", "gcp-compute-network", "gcp-sql-admin-instance" ], "descriptiveName": "GCP Sql Admin Backup", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-sql-admin-backup by its \"name\"", "list": true, "listDescription": "List all gcp-sql-admin-backup" } } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-sql-admin-instance.json ================================================ { "type": "gcp-sql-admin-instance", "category": 6, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-compute-subnetwork", "gcp-iam-service-account", "gcp-sql-admin-backup-run", "gcp-sql-admin-instance", "gcp-storage-bucket" ], "descriptiveName": "GCP Sql Admin Instance", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-sql-admin-instance by its \"name\"", "list": true, "listDescription": "List all gcp-sql-admin-instance" }, "terraformMappings": [ { "terraformQueryMap": "google_sql_database_instance.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-storage-bucket-iam-policy.json ================================================ { "type": "gcp-storage-bucket-iam-policy", "category": 4, "potentialLinks": [ "gcp-compute-project", "gcp-iam-role", "gcp-iam-service-account", "gcp-storage-bucket" ], "descriptiveName": "GCP Storage Bucket Iam Policy", "supportedQueryMethods": { "get": true, "getDescription": "Get GCP Storage Bucket Iam Policy by \"gcp-storage-bucket-iam-policy-bucket\"", "search": true, "searchDescription": "Search for GCP Storage Bucket Iam Policy by \"gcp-storage-bucket-iam-policy-bucket\"" }, "terraformMappings": [ { "terraformQueryMap": "google_storage_bucket_iam_binding.bucket" }, { "terraformQueryMap": "google_storage_bucket_iam_member.bucket" }, { "terraformQueryMap": "google_storage_bucket_iam_policy.bucket" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-storage-bucket.json ================================================ { "type": "gcp-storage-bucket", "category": 2, "potentialLinks": [ "gcp-cloud-kms-crypto-key", "gcp-compute-network", "gcp-logging-bucket", "gcp-storage-bucket-iam-policy" ], "descriptiveName": "GCP Storage Bucket", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-storage-bucket by its \"name\"", "list": true, "listDescription": "List all gcp-storage-bucket" }, "terraformMappings": [ { "terraformQueryMap": "google_storage_bucket.name" }, { "terraformQueryMap": "google_storage_bucket_iam_binding.bucket" }, { "terraformQueryMap": "google_storage_bucket_iam_member.bucket" }, { "terraformQueryMap": "google_storage_bucket_iam_policy.bucket" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/gcp/data/gcp-storage-transfer-transfer-job.json ================================================ { "type": "gcp-storage-transfer-transfer-job", "category": 2, "potentialLinks": [ "gcp-iam-service-account", "gcp-pub-sub-subscription", "gcp-pub-sub-topic", "gcp-secret-manager-secret", "gcp-storage-bucket" ], "descriptiveName": "GCP Storage Transfer Transfer Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a gcp-storage-transfer-transfer-job by its \"name\"", "list": true, "listDescription": "List all gcp-storage-transfer-transfer-job" }, "terraformMappings": [ { "terraformQueryMap": "google_storage_transfer_job.name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/ClusterRole.json ================================================ { "type": "ClusterRole", "category": 4, "descriptiveName": "Cluster Role", "supportedQueryMethods": { "get": true, "getDescription": "Get a Cluster Role by name", "list": true, "listDescription": "List all Cluster Roles", "search": true, "searchDescription": "Search for a Cluster Role using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_cluster_role_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/ClusterRoleBinding.json ================================================ { "type": "ClusterRoleBinding", "category": 4, "potentialLinks": ["ClusterRole", "ServiceAccount", "User", "Group"], "descriptiveName": "Cluster Role Binding", "supportedQueryMethods": { "get": true, "getDescription": "Get a Cluster Role Binding by name", "list": true, "listDescription": "List all Cluster Role Bindings", "search": true, "searchDescription": "Search for a Cluster Role Binding using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_cluster_role_binding_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_cluster_role_binding.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/ConfigMap.json ================================================ { "type": "ConfigMap", "category": 7, "descriptiveName": "Config Map", "supportedQueryMethods": { "get": true, "getDescription": "Get a Config Map by name", "list": true, "listDescription": "List all Config Maps", "search": true, "searchDescription": "Search for a Config Map using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_config_map_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_config_map.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/CronJob.json ================================================ { "type": "CronJob", "category": 1, "descriptiveName": "Cron Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a Cron Job by name", "list": true, "listDescription": "List all Cron Jobs", "search": true, "searchDescription": "Search for a Cron Job using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_cron_job_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_cron_job.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/DaemonSet.json ================================================ { "type": "DaemonSet", "category": 1, "descriptiveName": "Daemon Set", "supportedQueryMethods": { "get": true, "getDescription": "Get a Daemon Set by name", "list": true, "listDescription": "List all Daemon Sets", "search": true, "searchDescription": "Search for a Daemon Set using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_daemon_set_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_daemonset.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Deployment.json ================================================ { "type": "Deployment", "category": 1, "potentialLinks": ["ReplicaSet"], "descriptiveName": "Deployment", "supportedQueryMethods": { "get": true, "getDescription": "Get a Deployment by name", "list": true, "listDescription": "List all Deployments", "search": true, "searchDescription": "Search for a Deployment using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_deployment_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_deployment.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/EndpointSlice.json ================================================ { "type": "EndpointSlice", "category": 3, "potentialLinks": ["Node", "Pod", "dns", "ip", "Service"], "descriptiveName": "Endpoint Slice", "supportedQueryMethods": { "get": true, "getDescription": "Get a EndpointSlice by name", "list": true, "listDescription": "List all EndpointSlices", "search": true, "searchDescription": "Search for a EndpointSlice using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_endpoints_slice_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_endpoints_slice.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Endpoints.json ================================================ { "type": "Endpoints", "category": 3, "potentialLinks": ["Node", "ip", "Pod", "ExternalName", "DNS"], "descriptiveName": "Endpoints", "supportedQueryMethods": { "get": true, "getDescription": "Get a Endpoints by name", "list": true, "listDescription": "List all Endpointss", "search": true, "searchDescription": "Search for a Endpoints using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_endpoints.metadata[0].name" }, { "terraformQueryMap": "kubernetes_endpoints_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/HorizontalPodAutoscaler.json ================================================ { "type": "HorizontalPodAutoscaler", "category": 7, "descriptiveName": "Horizontal Pod Autoscaler", "supportedQueryMethods": { "get": true, "getDescription": "Get a Horizontal Pod Autoscaler by name", "list": true, "listDescription": "List all Horizontal Pod Autoscalers", "search": true, "searchDescription": "Search for a Horizontal Pod Autoscaler using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_horizontal_pod_autoscaler_v2.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Ingress.json ================================================ { "type": "Ingress", "category": 3, "potentialLinks": ["Service", "IngressClass", "dns"], "descriptiveName": "Ingress", "supportedQueryMethods": { "get": true, "getDescription": "Get a Ingress by name", "list": true, "listDescription": "List all Ingresss", "search": true, "searchDescription": "Search for a Ingress using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_ingress_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Job.json ================================================ { "type": "Job", "category": 1, "potentialLinks": ["Pod"], "descriptiveName": "Job", "supportedQueryMethods": { "get": true, "getDescription": "Get a Job by name", "list": true, "listDescription": "List all Jobs", "search": true, "searchDescription": "Search for a Job using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_job.metadata[0].name" }, { "terraformQueryMap": "kubernetes_job_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/LimitRange.json ================================================ { "type": "LimitRange", "category": 7, "descriptiveName": "Limit Range", "supportedQueryMethods": { "get": true, "getDescription": "Get a Limit Range by name", "list": true, "listDescription": "List all Limit Ranges", "search": true, "searchDescription": "Search for a Limit Range using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_limit_range_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_limit_range.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/NetworkPolicy.json ================================================ { "type": "NetworkPolicy", "category": 4, "potentialLinks": ["Pod"], "descriptiveName": "Network Policy", "terraformMappings": [ { "terraformQueryMap": "kubernetes_network_policy.metadata[0].name" }, { "terraformQueryMap": "kubernetes_network_policy_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Node.json ================================================ { "type": "Node", "category": 1, "potentialLinks": ["dns", "ip", "ec2-volume"], "descriptiveName": "Node", "supportedQueryMethods": { "get": true, "getDescription": "Get a Node by name", "list": true, "listDescription": "List all Nodes", "search": true, "searchDescription": "Search for a Node using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_node_taint.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/PersistentVolume.json ================================================ { "type": "PersistentVolume", "category": 2, "potentialLinks": ["ec2-volume", "efs-access-point", "StorageClass"], "descriptiveName": "Persistent Volume", "supportedQueryMethods": { "get": true, "getDescription": "Get a PersistentVolume by name", "list": true, "listDescription": "List all PersistentVolumes", "search": true, "searchDescription": "Search for a PersistentVolume using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_persistent_volume.metadata[0].name" }, { "terraformQueryMap": "kubernetes_persistent_volume_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/PersistentVolumeClaim.json ================================================ { "type": "PersistentVolumeClaim", "category": 2, "potentialLinks": ["PersistentVolume"], "descriptiveName": "Persistent Volume Claim", "supportedQueryMethods": { "get": true, "getDescription": "Get a PersistentVolumeClaim by name", "list": true, "listDescription": "List all PersistentVolumeClaims", "search": true, "searchDescription": "Search for a PersistentVolumeClaim using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_persistent_volume_claim.metadata[0].name" }, { "terraformQueryMap": "kubernetes_persistent_volume_claim_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Pod.json ================================================ { "type": "Pod", "category": 1, "potentialLinks": [ "ConfigMap", "ec2-volume", "dns", "ip", "PersistentVolumeClaim", "PriorityClass", "Secret", "ServiceAccount" ], "descriptiveName": "Pod", "supportedQueryMethods": { "get": true, "getDescription": "Get a Pod by name", "list": true, "listDescription": "List all Pods", "search": true, "searchDescription": "Search for a Pod using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_pod.metadata[0].name" }, { "terraformQueryMap": "kubernetes_pod_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/PodDisruptionBudget.json ================================================ { "type": "PodDisruptionBudget", "category": 7, "potentialLinks": ["Pod"], "descriptiveName": "Pod Disruption Budget", "supportedQueryMethods": { "get": true, "getDescription": "Get a PodDisruptionBudget by name", "list": true, "listDescription": "List all PodDisruptionBudgets", "search": true, "searchDescription": "Search for a PodDisruptionBudget using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_pod_disruption_budget_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/PriorityClass.json ================================================ { "type": "PriorityClass", "category": 7, "descriptiveName": "Priority Class", "supportedQueryMethods": { "get": true, "getDescription": "Get a Priority Class by name", "list": true, "listDescription": "List all Priority Classs", "search": true, "searchDescription": "Search for a Priority Class using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_priority_class_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_priority_class.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/ReplicaSet.json ================================================ { "type": "ReplicaSet", "category": 1, "potentialLinks": ["Pod"], "descriptiveName": "Replica Set", "supportedQueryMethods": { "get": true, "getDescription": "Get a ReplicaSet by name", "list": true, "listDescription": "List all ReplicaSets", "search": true, "searchDescription": "Search for a ReplicaSet using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" } } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/ReplicationController.json ================================================ { "type": "ReplicationController", "category": 1, "potentialLinks": ["Pod"], "descriptiveName": "Replication Controller", "supportedQueryMethods": { "get": true, "getDescription": "Get a ReplicationController by name", "list": true, "listDescription": "List all ReplicationControllers", "search": true, "searchDescription": "Search for a ReplicationController using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_replication_controller.metadata[0].name" }, { "terraformQueryMap": "kubernetes_replication_controller_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/ResourceQuota.json ================================================ { "type": "ResourceQuota", "category": 7, "descriptiveName": "Resource Quota", "supportedQueryMethods": { "get": true, "getDescription": "Get a Resource Quota by name", "list": true, "listDescription": "List all Resource Quotas", "search": true, "searchDescription": "Search for a Resource Quota using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_resource_quota_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_resource_quota.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Role.json ================================================ { "type": "Role", "category": 4, "descriptiveName": "Role", "supportedQueryMethods": { "get": true, "getDescription": "Get a Role by name", "list": true, "listDescription": "List all Roles", "search": true, "searchDescription": "Search for a Role using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_role_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_role.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/RoleBinding.json ================================================ { "type": "RoleBinding", "category": 4, "potentialLinks": ["Role", "ClusterRole", "ServiceAccount", "User", "Group"], "descriptiveName": "Role Binding", "supportedQueryMethods": { "get": true, "getDescription": "Get a RoleBinding by name", "list": true, "listDescription": "List all RoleBindings", "search": true, "searchDescription": "Search for a RoleBinding using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_role_binding.metadata[0].name" }, { "terraformQueryMap": "kubernetes_role_binding_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Secret.json ================================================ { "type": "Secret", "category": 7, "descriptiveName": "Secret", "supportedQueryMethods": { "get": true, "getDescription": "Get a Secret by name", "list": true, "listDescription": "List all Secrets", "search": true, "searchDescription": "Search for a Secret using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_secret_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_secret.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/Service.json ================================================ { "type": "Service", "category": 3, "potentialLinks": ["Pod", "ip", "dns", "Endpoints", "EndpointSlice"], "descriptiveName": "Service", "supportedQueryMethods": { "get": true, "getDescription": "Get a Service by name", "list": true, "listDescription": "List all Services", "search": true, "searchDescription": "Search for a Service using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_service.metadata[0].name" }, { "terraformQueryMap": "kubernetes_service_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/ServiceAccount.json ================================================ { "type": "ServiceAccount", "category": 4, "potentialLinks": ["Secret"], "descriptiveName": "Service Account", "supportedQueryMethods": { "get": true, "getDescription": "Get a ServiceAccount by name", "list": true, "listDescription": "List all ServiceAccounts", "search": true, "searchDescription": "Search for a ServiceAccount using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_service_account.metadata[0].name" }, { "terraformQueryMap": "kubernetes_service_account_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/StatefulSet.json ================================================ { "type": "StatefulSet", "category": 1, "descriptiveName": "Stateful Set", "supportedQueryMethods": { "get": true, "getDescription": "Get a Stateful Set by name", "list": true, "listDescription": "List all Stateful Sets", "search": true, "searchDescription": "Search for a Stateful Set using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_stateful_set_v1.metadata[0].name" }, { "terraformQueryMap": "kubernetes_stateful_set.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/StorageClass.json ================================================ { "type": "StorageClass", "category": 2, "descriptiveName": "Storage Class", "supportedQueryMethods": { "get": true, "getDescription": "Get a Storage Class by name", "list": true, "listDescription": "List all Storage Classs", "search": true, "searchDescription": "Search for a Storage Class using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" }, "terraformMappings": [ { "terraformQueryMap": "kubernetes_storage_class.metadata[0].name" }, { "terraformQueryMap": "kubernetes_storage_class_v1.metadata[0].name" } ] } ================================================ FILE: docs.overmind.tech/docs/sources/k8s/data/VolumeAttachment.json ================================================ { "type": "VolumeAttachment", "category": 2, "potentialLinks": ["PersistentVolume", "Node"], "descriptiveName": "Volume Attachment", "supportedQueryMethods": { "get": true, "getDescription": "Get a VolumeAttachment by name", "list": true, "listDescription": "List all VolumeAttachments", "search": true, "searchDescription": "Search for a VolumeAttachment using the ListOptions JSON format e.g. {\"labelSelector\": \"app=wordpress\"}" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/certificate.json ================================================ { "type": "certificate", "category": 3, "descriptiveName": "Certificate", "supportedQueryMethods": { "search": true, "searchDescription": "Takes a full certificate, or certificate bundle as input in PEM encoded format" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/dns.json ================================================ { "type": "dns", "category": 3, "potentialLinks": ["dns", "ip", "rdap-domain"], "descriptiveName": "DNS Entry", "supportedQueryMethods": { "get": true, "getDescription": "A DNS A or AAAA entry to look up", "search": true, "searchDescription": "A DNS name (or IP for reverse DNS), this will perform a recursive search and return all results. It is recommended that you always use the SEARCH method" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/http.json ================================================ { "type": "http", "category": 3, "potentialLinks": ["ip", "dns", "certificate", "http"], "descriptiveName": "HTTP Endpoint", "supportedQueryMethods": { "get": true, "getDescription": "A HTTP endpoint to run a `HEAD` request against", "search": true, "searchDescription": "A HTTP URL to search for. Query parameters and fragments will be stripped from the URL before processing." } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/ip.json ================================================ { "type": "ip", "category": 3, "potentialLinks": ["dns", "rdap-ip-network"], "descriptiveName": "IP Address", "supportedQueryMethods": { "get": true, "getDescription": "An ipv4 or ipv6 address" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/rdap-asn.json ================================================ { "type": "rdap-asn", "category": 3, "potentialLinks": ["rdap-entity"], "descriptiveName": "Autonomous System Number (ASN)", "supportedQueryMethods": { "get": true, "getDescription": "Get an ASN by handle i.e. \"AS15169\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/rdap-domain.json ================================================ { "type": "rdap-domain", "category": 3, "potentialLinks": [ "dns", "rdap-nameserver", "rdap-entity", "rdap-ip-network" ], "descriptiveName": "RDAP Domain", "supportedQueryMethods": { "search": true, "searchDescription": "Search for a domain record by the domain name e.g. \"www.google.com\"" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/rdap-entity.json ================================================ { "type": "rdap-entity", "category": 4, "potentialLinks": ["rdap-asn"], "descriptiveName": "RDAP Entity", "supportedQueryMethods": { "get": true, "getDescription": "Get an entity by its handle. This method is discouraged as it's not reliable since entity bootstrapping isn't comprehensive", "search": true, "searchDescription": "Search for an entity by its URL e.g. https://rdap.apnic.net/entity/AIC3-AP" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/rdap-ip-network.json ================================================ { "type": "rdap-ip-network", "category": 3, "potentialLinks": ["rdap-entity"], "descriptiveName": "RDAP IP Network", "supportedQueryMethods": { "search": true, "searchDescription": "Search for the most specific network that contains the specified IP or CIDR" } } ================================================ FILE: docs.overmind.tech/docs/sources/stdlib/data/rdap-nameserver.json ================================================ { "type": "rdap-nameserver", "category": 3, "potentialLinks": ["dns", "ip", "rdap-entity"], "descriptiveName": "RDAP Nameserver", "supportedQueryMethods": { "search": true, "searchDescription": "Search for the RDAP entry for a nameserver by its full URL e.g. \"https://rdap.verisign.com/com/v1/nameserver/NS4.GOOGLE.COM\"" } } ================================================ FILE: examples/create-bookmark.json ================================================ { "name": "Changing items for 'CN=GTS Root R1,O=Google Trust Services'", "description": "This bookmark contains the items that are changing as part of the 'CN=GTS Root R1,O=Google Trust Services' change. Generated using UpdateChangingItems", "queries": [ { "type": "certificate", "query": "CN=GTS Root R1,O=Google Trust Services", "scope": "global" } ] } ================================================ FILE: go/audit/main.go ================================================ package audit import ( "bufio" "context" "errors" "net" "net/http" log "github.com/sirupsen/logrus" ) type contextKey struct{} // AuditData holds identity fields populated by auth middleware for // post-request audit logging. The audit middleware places a mutable // *AuditData in the request context before calling inner handlers; // auth fills it after token validation so the log emitted after // the response contains the correct identity. type AuditData struct { Subject string AccountName string Scopes string } // AuditDataFromContext returns the AuditData pointer placed in context // by the audit middleware. Returns nil when called outside the chain. func AuditDataFromContext(ctx context.Context) *AuditData { ad, _ := ctx.Value(contextKey{}).(*AuditData) return ad } // Option configures the audit middleware. type Option func(*auditConfig) type auditConfig struct { excludePaths map[string]bool } // WithExcludePaths skips audit logging for the given exact request // paths (e.g. "/healthz"). func WithExcludePaths(paths ...string) Option { return func(c *auditConfig) { for _, p := range paths { c.excludePaths[p] = true } } } // statusRecorder wraps http.ResponseWriter to capture the status code. type statusRecorder struct { http.ResponseWriter status int wroteHeader bool } func (sr *statusRecorder) WriteHeader(code int) { if !sr.wroteHeader { sr.status = code sr.wroteHeader = true } sr.ResponseWriter.WriteHeader(code) } func (sr *statusRecorder) Write(b []byte) (int, error) { if !sr.wroteHeader { sr.WriteHeader(http.StatusOK) } return sr.ResponseWriter.Write(b) } // Unwrap returns the underlying ResponseWriter, preserving optional // interfaces (Flusher, Hijacker, etc.) for http.ResponseController. func (sr *statusRecorder) Unwrap() http.ResponseWriter { return sr.ResponseWriter } // Hijack implements http.Hijacker by delegating to the underlying // ResponseWriter. This is required for WebSocket upgrade handshakes // which do direct type assertions on the writer. func (sr *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { if h, ok := sr.ResponseWriter.(http.Hijacker); ok { return h.Hijack() } return nil, nil, errors.New("underlying ResponseWriter does not support hijacking") } // Flush implements http.Flusher by delegating to the underlying // ResponseWriter. This is needed for streaming responses (SSE, etc.). func (sr *statusRecorder) Flush() { if f, ok := sr.ResponseWriter.(http.Flusher); ok { f.Flush() } } // NewAuditMiddleware returns middleware that emits a structured audit // log entry after each request completes. Identity fields (sub, account, // scopes) are populated by auth middleware via [AuditDataFromContext]. // // The middleware must wrap the handler chain from outside otelhttp so // that audit logs are not exported to the tracing backend. func NewAuditMiddleware(logger *log.Logger, opts ...Option) func(next http.Handler) http.Handler { cfg := &auditConfig{excludePaths: make(map[string]bool)} for _, o := range opts { o(cfg) } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if cfg.excludePaths[r.URL.Path] { next.ServeHTTP(w, r) return } ad := &AuditData{} ctx := context.WithValue(r.Context(), contextKey{}, ad) rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r.WithContext(ctx)) logger.WithContext(ctx). WithField("method", r.Method). WithField("url", r.URL.String()). WithField("status", rec.status). WithField("sub", ad.Subject). WithField("account", ad.AccountName). WithField("ovm.audit", true). WithField("scopes", ad.Scopes). Info("audit") }) } } ================================================ FILE: go/audit/main_test.go ================================================ package audit import ( "bufio" "bytes" "encoding/json" "net" "net/http" "net/http/httptest" "testing" log "github.com/sirupsen/logrus" ) func TestAuditMiddleware_AuthenticatedRequest(t *testing.T) { var buf bytes.Buffer testLogger := log.New() testLogger.SetOutput(&buf) testLogger.SetFormatter(&log.JSONFormatter{}) inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if ad := AuditDataFromContext(r.Context()); ad != nil { ad.Subject = "auth0|user123" ad.AccountName = "acme-corp" ad.Scopes = "read:items write:items" } w.WriteHeader(http.StatusOK) }) mw := NewAuditMiddleware(testLogger) rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/items", nil) mw(inner).ServeHTTP(rec, req) var entry map[string]any if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { t.Fatalf("failed to unmarshal log entry: %v", err) } if entry["method"] != "GET" { t.Errorf("expected method GET, got %q", entry["method"]) } if entry["url"] != "/api/items" { t.Errorf("expected url /api/items, got %q", entry["url"]) } if entry["sub"] != "auth0|user123" { t.Errorf("expected sub auth0|user123, got %q", entry["sub"]) } if entry["account"] != "acme-corp" { t.Errorf("expected account acme-corp, got %q", entry["account"]) } if entry["scopes"] != "read:items write:items" { t.Errorf("expected scopes 'read:items write:items', got %q", entry["scopes"]) } if entry["ovm.audit"] != true { t.Errorf("expected ovm.audit true, got %v", entry["ovm.audit"]) } if entry["status"] != float64(http.StatusOK) { t.Errorf("expected status 200, got %v", entry["status"]) } } func TestAuditMiddleware_UnauthenticatedRequest(t *testing.T) { var buf bytes.Buffer testLogger := log.New() testLogger.SetOutput(&buf) testLogger.SetFormatter(&log.JSONFormatter{}) inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }) mw := NewAuditMiddleware(testLogger) rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/secret", nil) mw(inner).ServeHTTP(rec, req) var entry map[string]any if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { t.Fatalf("failed to unmarshal log entry: %v", err) } if entry["sub"] != "" { t.Errorf("expected empty sub for unauthenticated request, got %q", entry["sub"]) } if entry["account"] != "" { t.Errorf("expected empty account for unauthenticated request, got %q", entry["account"]) } if entry["status"] != float64(http.StatusUnauthorized) { t.Errorf("expected status 401, got %v", entry["status"]) } } func TestAuditMiddleware_ExcludedPath(t *testing.T) { var buf bytes.Buffer testLogger := log.New() testLogger.SetOutput(&buf) testLogger.SetFormatter(&log.JSONFormatter{}) called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) mw := NewAuditMiddleware(testLogger, WithExcludePaths("/healthz")) rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/healthz", nil) mw(inner).ServeHTTP(rec, req) if !called { t.Error("inner handler was not called for excluded path") } if buf.Len() > 0 { t.Errorf("expected no audit log for excluded path, got: %s", buf.String()) } } func TestAuditMiddleware_NonExcludedPathStillLogged(t *testing.T) { var buf bytes.Buffer testLogger := log.New() testLogger.SetOutput(&buf) testLogger.SetFormatter(&log.JSONFormatter{}) inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) mw := NewAuditMiddleware(testLogger, WithExcludePaths("/healthz")) rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/changes", nil) mw(inner).ServeHTTP(rec, req) if buf.Len() == 0 { t.Error("expected audit log for non-excluded path") } } func TestAuditMiddleware_CapturesStatusCode(t *testing.T) { var buf bytes.Buffer testLogger := log.New() testLogger.SetOutput(&buf) testLogger.SetFormatter(&log.JSONFormatter{}) inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }) mw := NewAuditMiddleware(testLogger) rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/api/admin/user", nil) mw(inner).ServeHTTP(rec, req) var entry map[string]any if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { t.Fatalf("failed to unmarshal log entry: %v", err) } if entry["status"] != float64(http.StatusForbidden) { t.Errorf("expected status 403, got %v", entry["status"]) } if entry["method"] != "DELETE" { t.Errorf("expected method DELETE, got %q", entry["method"]) } } func TestAuditMiddleware_DefaultStatusIs200(t *testing.T) { var buf bytes.Buffer testLogger := log.New() testLogger.SetOutput(&buf) testLogger.SetFormatter(&log.JSONFormatter{}) inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) mw := NewAuditMiddleware(testLogger) rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/items", nil) mw(inner).ServeHTTP(rec, req) var entry map[string]any if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { t.Fatalf("failed to unmarshal log entry: %v", err) } if entry["status"] != float64(http.StatusOK) { t.Errorf("expected status 200 when handler writes body without explicit WriteHeader, got %v", entry["status"]) } } func TestAuditDataFromContext_NilOutsideMiddleware(t *testing.T) { if ad := AuditDataFromContext(t.Context()); ad != nil { t.Error("expected nil AuditData outside audit middleware chain") } } func TestStatusRecorder_Hijack(t *testing.T) { hijacked := false mock := &mockHijackWriter{ ResponseWriter: httptest.NewRecorder(), hijackFunc: func() (net.Conn, *bufio.ReadWriter, error) { hijacked = true return nil, nil, nil }, } var w http.ResponseWriter = &statusRecorder{ResponseWriter: mock, status: http.StatusOK} h, ok := w.(http.Hijacker) if !ok { t.Fatal("statusRecorder should implement http.Hijacker") } _, _, err := h.Hijack() if err != nil { t.Fatalf("unexpected error: %v", err) } if !hijacked { t.Error("expected Hijack to be delegated to underlying writer") } } func TestStatusRecorder_HijackNotSupported(t *testing.T) { var w http.ResponseWriter = &statusRecorder{ResponseWriter: httptest.NewRecorder(), status: http.StatusOK} _, _, err := w.(http.Hijacker).Hijack() if err == nil { t.Error("expected error when underlying writer doesn't support Hijack") } } func TestStatusRecorder_Flush(t *testing.T) { flushed := false mock := &mockFlushWriter{ ResponseWriter: httptest.NewRecorder(), flushFunc: func() { flushed = true }, } var w http.ResponseWriter = &statusRecorder{ResponseWriter: mock, status: http.StatusOK} f, ok := w.(http.Flusher) if !ok { t.Fatal("statusRecorder should implement http.Flusher") } f.Flush() if !flushed { t.Error("expected Flush to be delegated to underlying writer") } } type mockHijackWriter struct { http.ResponseWriter hijackFunc func() (net.Conn, *bufio.ReadWriter, error) } func (m *mockHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return m.hijackFunc() } type mockFlushWriter struct { http.ResponseWriter flushFunc func() } func (m *mockFlushWriter) Flush() { m.flushFunc() } ================================================ FILE: go/auth/auth.go ================================================ package auth import ( "context" "errors" "fmt" "net/http" "net/url" "os" "time" "connectrpc.com/connect" jose "github.com/go-jose/go-jose/v4" josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/nats-io/jwt/v2" "github.com/nats-io/nkeys" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/codes" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" ) const UserAgentVersion = "0.1" // TokenClient Represents something that is capable of getting NATS JWT tokens // for a given set of NKeys type TokenClient interface { // Returns a NATS token that can be used to connect GetJWT() (string, error) // Uses the NKeys associated with the token to sign some binary data Sign([]byte) ([]byte, error) } // BasicTokenClient stores a static token and returns it when called, ignoring // any provided NKeys or context since it already has the token and doesn't need // to make any requests type BasicTokenClient struct { staticToken string staticKeys nkeys.KeyPair } // assert interface implementation var _ TokenClient = (*BasicTokenClient)(nil) // NewBasicTokenClient Creates a new basic token client that simply returns a static token func NewBasicTokenClient(token string, keys nkeys.KeyPair) *BasicTokenClient { return &BasicTokenClient{ staticToken: token, staticKeys: keys, } } func (b *BasicTokenClient) GetJWT() (string, error) { return b.staticToken, nil } func (b *BasicTokenClient) Sign(in []byte) ([]byte, error) { return b.staticKeys.Sign(in) } // ClientCredentialsConfig Authenticates to Overmind using the Client // Credentials flow // https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow type ClientCredentialsConfig struct { // The ClientID of the application that we'll be authenticating as ClientID string // ClientSecret that corresponds to the ClientID ClientSecret string } type TokenSourceOptionsFunc func(*clientcredentials.Config) // This option means that the token that is retrieved will have the following // account embedded in it through impersonation. In order for this to work, the // Auth0 ClientID must be added to workspace/deploy/auth0.tf. This will use // deploy/auth0_embed_account_m2m.tftpl to update the Auth0 action that we use // to allow impersonation. If this isn't done first you will get an error from // Auth0. func WithImpersonateAccount(account string) TokenSourceOptionsFunc { return func(c *clientcredentials.Config) { c.EndpointParams.Set("account_name", account) } } // TokenSource Returns a token source that can be used to get OAuth tokens. // Cache this between invocations to avoid additional charges by Auth0 for M2M // tokens. The oAuthTokenURL looks like this: // https://somedomain.auth0.com/oauth/token // // The context that is passed to this function is used when getting new tokens, // which will happen initially, and then subsequently when the token expires. // This means that if this token source is going to be stored and used for many // requests, it should not use the context of the request that created it, as // this will be cancelled. Instead it should probably use `context.Background()` // or similar. func (flowConfig ClientCredentialsConfig) TokenSource(ctx context.Context, oAuthTokenURL, oAuthAudience string, opts ...TokenSourceOptionsFunc) oauth2.TokenSource { // inject otel into oauth2 ctx = context.WithValue(ctx, oauth2.HTTPClient, tracing.HTTPClient()) conf := &clientcredentials.Config{ ClientID: flowConfig.ClientID, ClientSecret: flowConfig.ClientSecret, TokenURL: oAuthTokenURL, EndpointParams: url.Values{ "audience": []string{oAuthAudience}, }, } for _, opt := range opts { opt(conf) } // this will be a `oauth2.ReuseTokenSource`, thus caching the M2M token. // note that this token source is safe for concurrent use and will // automatically refresh the token when it expires. Also note that this // token source will use the passed in http client from otelhttp for all // requests, but will not get the actual caller's context, so spans will not // link up. return conf.TokenSource(ctx) } // Auth0Config contains credentials for creating impersonation HTTP clients // using Auth0's client credentials flow with account impersonation. type Auth0Config struct { Domain string ClientID string ClientSecret string Audience string // ManagementAudience is the Auth0 tenant hostname for the Management API. // Token endpoint: https://{ManagementAudience}/oauth/token // API audience: https://{ManagementAudience}/api/v2/ ManagementAudience string } // ImpersonationHTTPClient creates an HTTP client that can impersonate the specified account. // If the config is nil or ClientID is empty, returns a basic tracing HTTP client. func (c *Auth0Config) ImpersonationHTTPClient(ctx context.Context, accountName string) *http.Client { if c == nil || c.ClientID == "" { return tracing.HTTPClient() } creds := ClientCredentialsConfig{ ClientID: c.ClientID, ClientSecret: c.ClientSecret, } ts := creds.TokenSource( ctx, fmt.Sprintf("https://%s/oauth/token", c.Domain), c.Audience, WithImpersonateAccount(accountName), ) // inject otel into oauth2 ctx = context.WithValue(ctx, oauth2.HTTPClient, tracing.HTTPClient()) return oauth2.NewClient(ctx, ts) } // natsTokenClient A client that is capable of getting NATS JWTs and signing the // required nonce to prove ownership of the NKeys. Satisfies the `TokenClient` // interface type natsTokenClient struct { // The name of the account to impersonate. If this is omitted then the // account will be determined based on the account included in the resulting // token. Account string // authenticated clients for the Overmind API adminClient sdpconnect.AdminServiceClient mgmtClient sdpconnect.ManagementServiceClient jwt string keys nkeys.KeyPair } // assert interface implementation var _ TokenClient = (*natsTokenClient)(nil) // generateKeys Generates a new set of keys for the client func (n *natsTokenClient) generateKeys() error { var err error n.keys, err = nkeys.CreateUser() return err } // generateJWT Gets a new JWT from the auth API func (n *natsTokenClient) generateJWT(ctx context.Context) error { if n.adminClient == nil || n.mgmtClient == nil { return errors.New("no Overmind API client configured") } // If we don't yet have keys generate them if n.keys == nil { err := n.generateKeys() if err != nil { return err } } pubKey, err := n.keys.PublicKey() if err != nil { return err } hostname, err := os.Hostname() if err != nil { return err } req := &sdp.CreateTokenRequest{ UserPublicNkey: pubKey, UserName: hostname, } // Create the request for a NATS token var response *connect.Response[sdp.CreateTokenResponse] if n.Account == "" { // Use the regular API and let the client authentication determine what our org should be log.WithFields(log.Fields{ "account": n.Account, "publicNKey": req.GetUserPublicNkey(), "UserName": req.GetUserName(), }).Trace("Using regular API to get NATS token") response, err = n.mgmtClient.CreateToken(ctx, connect.NewRequest(req)) } else { log.WithFields(log.Fields{ "account": n.Account, "publicNKey": req.GetUserPublicNkey(), "UserName": req.GetUserName(), }).Trace("Using admin API to get NATS token") // Explicitly request an org response, err = n.adminClient.CreateToken(ctx, connect.NewRequest(&sdp.AdminCreateTokenRequest{ Account: n.Account, Request: req, })) } if err != nil { return fmt.Errorf("getting NATS token failed: %w", err) } n.jwt = response.Msg.GetToken() return nil } func (n *natsTokenClient) GetJWT() (string, error) { ctx, span := tracer.Start(context.Background(), "connect.GetJWT") defer span.End() // If we don't yet have a JWT, generate one if n.jwt == "" { err := n.generateJWT(ctx) if err != nil { err = fmt.Errorf("error generating JWT: %w", err) span.SetStatus(codes.Error, err.Error()) return "", err } } claims, err := jwt.DecodeUserClaims(n.jwt) if err != nil { err = fmt.Errorf("error decoding JWT: %w", err) span.SetStatus(codes.Error, err.Error()) return n.jwt, err } // Validate to make sure the JWT is valid. If it isn't we'll generate a new // one var vr jwt.ValidationResults claims.Validate(&vr) if vr.IsBlocking(true) { // Regenerate the token err := n.generateJWT(ctx) if err != nil { err = fmt.Errorf("error validating JWT: %w", err) span.SetStatus(codes.Error, err.Error()) return "", err } } span.SetStatus(codes.Ok, "Completed") return n.jwt, nil } func (n *natsTokenClient) Sign(in []byte) ([]byte, error) { if n.keys == nil { err := n.generateKeys() if err != nil { return []byte{}, err } } return n.keys.Sign(in) } // An OAuth2 token source which uses an Overmind API token as a source for OAuth // tokens type APIKeyTokenSource struct { // The API Key to use to authenticate to the Overmind API ApiKey string token *oauth2.Token apiKeyClient sdpconnect.ApiKeyServiceClient } func NewAPIKeyTokenSource(apiKey string, overmindAPIURL string) *APIKeyTokenSource { httpClient := http.Client{ Timeout: 10 * time.Second, Transport: otelhttp.NewTransport(http.DefaultTransport), } // Create a client that exchanges the API key for a JWT apiKeyClient := sdpconnect.NewApiKeyServiceClient(&httpClient, overmindAPIURL) return &APIKeyTokenSource{ ApiKey: apiKey, apiKeyClient: apiKeyClient, } } // Exchange an API key for an OAuth token func (ats *APIKeyTokenSource) Token() (*oauth2.Token, error) { if ats.token != nil { // If we already have a token, and it is valid, return it if ats.token.Valid() { return ats.token, nil } } // Get a new token res, err := ats.apiKeyClient.ExchangeKeyForToken(context.Background(), connect.NewRequest(&sdp.ExchangeKeyForTokenRequest{ ApiKey: ats.ApiKey, })) if err != nil { return nil, fmt.Errorf("error exchanging API key: %w", err) } if res.Msg.GetAccessToken() == "" { return nil, errors.New("no access token returned") } // Parse the expiry out of the token token, err := josejwt.ParseSigned(res.Msg.GetAccessToken(), []jose.SignatureAlgorithm{jose.RS256}) if err != nil { return nil, fmt.Errorf("error parsing JWT: %w", err) } claims := josejwt.Claims{} err = token.UnsafeClaimsWithoutVerification(&claims) if err != nil { return nil, fmt.Errorf("error parsing JWT claims: %w", err) } ats.token = &oauth2.Token{ AccessToken: res.Msg.GetAccessToken(), TokenType: "Bearer", Expiry: claims.Expiry.Time(), } return ats.token, nil } // NewAPIKeyClient Creates a new token client that authenticates to Overmind // using an API key. This is exchanged for an OAuth token, which is then used to // get a NATS token. // // The provided `overmindAPIURL` parameter should be the root URL of the // Overmind API, without the /api suffix e.g. https://api.app.overmind.tech func NewAPIKeyClient(overmindAPIURL string, apiKey string) (*natsTokenClient, error) { // Create a token source that exchanges the API key for an OAuth token tokenSource := NewAPIKeyTokenSource(apiKey, overmindAPIURL) transport := oauth2.Transport{ Source: tokenSource, Base: http.DefaultTransport, } httpClient := http.Client{ Transport: otelhttp.NewTransport(&transport), } return &natsTokenClient{ adminClient: sdpconnect.NewAdminServiceClient(&httpClient, overmindAPIURL), mgmtClient: sdpconnect.NewManagementServiceClient(&httpClient, overmindAPIURL), }, nil } // NewStaticTokenClient Creates a new token client that uses a static token // The user must pass the Overmind API URL to configure the client to connect // to, the raw JWT OAuth access token, and the type of token. This is almost // always "Bearer" func NewStaticTokenClient(overmindAPIURL, token, tokenType string) (*natsTokenClient, error) { transport := oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: token, TokenType: tokenType, }), } httpClient := http.Client{ Transport: otelhttp.NewTransport(&transport), } return &natsTokenClient{ adminClient: sdpconnect.NewAdminServiceClient(&httpClient, overmindAPIURL), mgmtClient: sdpconnect.NewManagementServiceClient(&httpClient, overmindAPIURL), }, nil } // NewOAuthTokenClient creates a token client that uses the provided TokenSource // to get a NATS token. `overmindAPIURL` is the root URL of the NATS token // exchange API that will be used e.g. https://api.server.test/v1 // // Tokens will be minted under the specified account as long as the client has // admin permissions, if not, the account that is attached to the client via // Auth0 metadata will be used func NewOAuthTokenClient(overmindAPIURL string, account string, ts oauth2.TokenSource) *natsTokenClient { return NewOAuthTokenClientWithContext(context.Background(), overmindAPIURL, account, ts) } // NewOAuthTokenClientWithContext creates a token client that uses the provided // TokenSource to get a NATS token. `overmindAPIURL` is the root URL of the NATS // token exchange API that will be used e.g. https://api.server.test/v1 // // Tokens will be minted under the specified account as long as the client has // admin permissions, if not, the account that is attached to the client via // Auth0 metadata will be used // // The provided context is used for cancellation and to lookup the HTTP client // used by oauth2. See the oauth2.HTTPClient variable. // // Provide an account name and an admin token to create a token client for a // foreign account. func NewOAuthTokenClientWithContext(ctx context.Context, overmindAPIURL string, account string, ts oauth2.TokenSource) *natsTokenClient { authenticatedClient := oauth2.NewClient(ctx, ts) // backwards compatibility: remove previously existing "/api" suffix from URL for connect apiUrl, err := url.Parse(overmindAPIURL) if err == nil { apiUrl.Path = "" overmindAPIURL = apiUrl.String() } return &natsTokenClient{ Account: account, adminClient: sdpconnect.NewAdminServiceClient(authenticatedClient, overmindAPIURL), mgmtClient: sdpconnect.NewManagementServiceClient(authenticatedClient, overmindAPIURL), } } ================================================ FILE: go/auth/auth_client.go ================================================ package auth import ( "context" "fmt" "net/http" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" ) // AuthenticatedClient is a http.Client that will automatically add the required // Authorization header to the request, which is taken from the context that it // is created with. We also always set the X-overmind-interactive header to // false to connect opentelemetry traces. type AuthenticatedTransport struct { from http.RoundTripper token string } // RoundTrip Adds the Authorization header to the request then call the // underlying roundTripper func (y *AuthenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) { // ask for otel trace linkup req.Header.Set("X-Overmind-Interactive", "false") if y.token != "" { bearer := fmt.Sprintf("Bearer %v", y.token) req.Header.Set("Authorization", bearer) } return y.from.RoundTrip(req) } // NewAuthenticatedClient creates a new AuthenticatedClient from the given // context and http.Client. func NewAuthenticatedClient(ctx context.Context, from *http.Client) *http.Client { token, ok := ctx.Value(UserTokenContextKey{}).(string) if !ok { token = "" } return &http.Client{ Transport: &AuthenticatedTransport{ from: from.Transport, token: token, }, CheckRedirect: from.CheckRedirect, Jar: from.Jar, Timeout: from.Timeout, } } // ContextAwareAuthTransport is an http.RoundTripper that extracts the user JWT // from each request's context at call time (not at client-creation time). This // enables a single persistent http.Client to pass through per-request JWTs, // which is needed when the client is created once at startup but serves // requests from different users. type ContextAwareAuthTransport struct { from http.RoundTripper } // RoundTrip extracts the JWT from the request's context and adds it as a // Bearer token in the Authorization header. func (t *ContextAwareAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("X-Overmind-Interactive", "false") if token, ok := req.Context().Value(UserTokenContextKey{}).(string); ok && token != "" { req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) } return t.from.RoundTrip(req) } // NewContextAwareAuthClient creates an http.Client whose transport extracts the // JWT from each outgoing request's context. Unlike NewAuthenticatedClient (which // captures the token once), this client re-reads the token on every call — // making it safe to reuse across requests from different users. func NewContextAwareAuthClient(from *http.Client) *http.Client { return &http.Client{ Transport: &ContextAwareAuthTransport{ from: from.Transport, }, CheckRedirect: from.CheckRedirect, Jar: from.Jar, Timeout: from.Timeout, } } // AuthenticatedAdminClient Returns a bookmark client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedAdminClient(ctx context.Context, apiUrl string) sdpconnect.AdminServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind admin API (pre-authenticated)") return sdpconnect.NewAdminServiceClient(httpClient, apiUrl) } // AuthenticatedApiKeyClient Returns an apikey client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedApiKeyClient(ctx context.Context, apiUrl string) sdpconnect.ApiKeyServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind apikeys API (pre-authenticated)") return sdpconnect.NewApiKeyServiceClient(httpClient, apiUrl) } // UnauthenticatedApiKeyClient Returns an apikey client with otel instrumentation // but no authentication. Can only be used for ExchangeKeyForToken func UnauthenticatedApiKeyClient(ctx context.Context, apiUrl string) sdpconnect.ApiKeyServiceClient { log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind apikeys API") return sdpconnect.NewApiKeyServiceClient(tracing.HTTPClient(), apiUrl) } // AuthenticatedBookmarkClient Returns a bookmark client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedBookmarkClient(ctx context.Context, apiUrl string) sdpconnect.BookmarksServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind bookmark API (pre-authenticated)") return sdpconnect.NewBookmarksServiceClient(httpClient, apiUrl) } // AuthenticatedChangesClient Returns a bookmark client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedChangesClient(ctx context.Context, apiUrl string) sdpconnect.ChangesServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind changes API (pre-authenticated)") return sdpconnect.NewChangesServiceClient(httpClient, apiUrl) } // AuthenticatedConfigurationClient Returns a bookmark client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedConfigurationClient(ctx context.Context, apiUrl string) sdpconnect.ConfigurationServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind configuration API (pre-authenticated)") return sdpconnect.NewConfigurationServiceClient(httpClient, apiUrl) } // AuthenticatedManagementClient Returns a bookmark client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedManagementClient(ctx context.Context, apiUrl string) sdpconnect.ManagementServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind management API (pre-authenticated)") return sdpconnect.NewManagementServiceClient(httpClient, apiUrl) } // AuthenticatedSnapshotsClient Returns a Snapshots client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedSnapshotsClient(ctx context.Context, apiUrl string) sdpconnect.SnapshotsServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind snapshot API (pre-authenticated)") return sdpconnect.NewSnapshotsServiceClient(httpClient, apiUrl) } // AuthenticatedInviteClient Returns a Invite client that uses the auth // embedded in the context and otel instrumentation func AuthenticatedInviteClient(ctx context.Context, apiUrl string) sdpconnect.InviteServiceClient { httpClient := NewAuthenticatedClient(ctx, tracing.HTTPClient()) log.WithContext(ctx).WithField("apiUrl", apiUrl).Debug("Connecting to overmind invite API (pre-authenticated)") return sdpconnect.NewInviteServiceClient(httpClient, apiUrl) } ================================================ FILE: go/auth/auth_test.go ================================================ package auth import ( "context" "fmt" "net" "net/http/httptest" "net/url" "os" "testing" "time" "connectrpc.com/connect" "github.com/nats-io/nkeys" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" ) var tokenExchangeURLs = []string{ "http://api-server:8080", "http://localhost:8080", } func TestBasicTokenClient(t *testing.T) { var c TokenClient keys, err := nkeys.CreateUser() if err != nil { t.Fatal(err) } c = NewBasicTokenClient("tokeny_mc_tokenface", keys) var token string token, err = c.GetJWT() if err != nil { t.Error(err) } if token != "tokeny_mc_tokenface" { t.Error("token mismatch") } data := []byte{1, 156, 230, 4, 23, 175, 11} signed, err := c.Sign(data) if err != nil { t.Fatal(err) } err = keys.Verify(data, signed) if err != nil { t.Error(err) } } func GetTestOAuthTokenClient(t *testing.T) *natsTokenClient { var domain string var clientID string var clientSecret string var exists bool errorFormat := "environment variable %v not found. Set up your test environment first. See: https://github.com/overmindtech/cli/go/auth0-test-data" // Read secrets form the environment if domain, exists = os.LookupEnv("OVERMIND_NTE_ALLPERMS_DOMAIN"); !exists || domain == "" { t.Errorf(errorFormat, "OVERMIND_NTE_ALLPERMS_DOMAIN") t.Skip("Skipping due to missing environment setup") } if clientID, exists = os.LookupEnv("OVERMIND_NTE_ALLPERMS_CLIENT_ID"); !exists || clientID == "" { t.Errorf(errorFormat, "OVERMIND_NTE_ALLPERMS_CLIENT_ID") t.Skip("Skipping due to missing environment setup") } if clientSecret, exists = os.LookupEnv("OVERMIND_NTE_ALLPERMS_CLIENT_SECRET"); !exists || clientSecret == "" { t.Errorf(errorFormat, "OVERMIND_NTE_ALLPERMS_CLIENT_SECRET") t.Skip("Skipping due to missing environment setup") } exchangeURL, err := GetWorkingTokenExchange() if err != nil { t.Fatal(err) } flowConfig := ClientCredentialsConfig{ ClientID: clientID, ClientSecret: clientSecret, } return NewOAuthTokenClient( exchangeURL, "overmind-development", flowConfig.TokenSource(t.Context(), fmt.Sprintf("https://%v/oauth/token", domain), os.Getenv("API_SERVER_AUDIENCE")), ) } func TestOAuthTokenClient(t *testing.T) { if os.Getenv("CI") == "true" { t.Skip("Skipping test in CI environment, missing nats token exchange server") } c := GetTestOAuthTokenClient(t) var err error _, err = c.GetJWT() if err != nil { t.Error(err) } // Make sure it can sign data := []byte{1, 156, 230, 4, 23, 175, 11} _, err = c.Sign(data) if err != nil { t.Fatal(err) } } type testAPIKeyHandler struct { sdpconnect.UnimplementedApiKeyServiceHandler } // Always return a valid token func (h *testAPIKeyHandler) ExchangeKeyForToken(ctx context.Context, req *connect.Request[sdp.ExchangeKeyForTokenRequest]) (*connect.Response[sdp.ExchangeKeyForTokenResponse], error) { return &connect.Response[sdp.ExchangeKeyForTokenResponse]{ Msg: &sdp.ExchangeKeyForTokenResponse{ AccessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjM5MDIyfQ.Tt0D8zOO3uzfbR1VLc3v7S1_jNrP9_crU1Gi_LpVinEXn4hndTWnI9rMd9r9D0iiv6U-CZAb9JKlun58MO3Pbf_S7apiLGHGE11coIMdk5OKuQFepwXPEk4ixs8_51wmWtJAKg7L5JJG6NuLGnGK8a53hzSHjoK80ROBqlsE9dJ4lpgigj8ZcL-xWpjS4TnUiGLHOvNDnHdqP5D_3DA1teWk9PNh9uU6Wn3U3ShH9rRCI9mKz9amdZ7QzH44J5Gsh2-uo0m2BtZILBE5_p-BeJ7op2RicEXbm69Vae8SPjkJLorBQxbO2lMG4y00q1n-wRDfg_eLFH8ZVC-5lpVXIw", }, }, nil } func TestNewAPIKeyTokenSource(t *testing.T) { _, handler := sdpconnect.NewApiKeyServiceHandler(&testAPIKeyHandler{}) testServer := httptest.NewServer(handler) defer testServer.Close() ts := NewAPIKeyTokenSource("test", testServer.URL) token, err := ts.Token() if err != nil { t.Fatal(err) } // Make sure the expiry is correct if token.Expiry.Unix() != 1516239022 { t.Errorf("token expiry incorrect. Expected 1516239022, got %v", token.Expiry.Unix()) } } func GetWorkingTokenExchange() (string, error) { errMap := make(map[string]error) for _, url := range tokenExchangeURLs { var err error if err = testURL(url); err == nil { return url, nil } errMap[url] = err } var errString string for url, err := range errMap { errString = errString + fmt.Sprintf(" %v: %v\n", url, err.Error()) } return "", fmt.Errorf("no working token exchanges found:\n%v", errString) } func testURL(testURL string) error { url, err := url.Parse(testURL) if err != nil { return fmt.Errorf("could not parse NATS URL: %v. Error: %w", testURL, err) } dialer := &net.Dialer{ Timeout: time.Second, } conn, err := dialer.DialContext(context.Background(), "tcp", net.JoinHostPort(url.Hostname(), url.Port())) if err == nil { conn.Close() return nil } return err } ================================================ FILE: go/auth/context_aware_auth_test.go ================================================ package auth import ( "context" "net/http" "net/http/httptest" "testing" ) func TestContextAwareAuthTransport_InjectsToken(t *testing.T) { var capturedAuth string ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { capturedAuth = r.Header.Get("Authorization") })) defer ts.Close() client := NewContextAwareAuthClient(ts.Client()) ctx := context.WithValue(context.Background(), UserTokenContextKey{}, "test-jwt-token") req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) resp, err := client.Do(req) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if capturedAuth != "Bearer test-jwt-token" { t.Errorf("expected 'Bearer test-jwt-token', got %q", capturedAuth) } } func TestContextAwareAuthTransport_NoToken(t *testing.T) { var capturedAuth string ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { capturedAuth = r.Header.Get("Authorization") })) defer ts.Close() client := NewContextAwareAuthClient(ts.Client()) req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL, nil) resp, err := client.Do(req) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if capturedAuth != "" { t.Errorf("expected empty auth header, got %q", capturedAuth) } } func TestContextAwareAuthTransport_DifferentTokensPerRequest(t *testing.T) { var capturedTokens []string ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { capturedTokens = append(capturedTokens, r.Header.Get("Authorization")) })) defer ts.Close() client := NewContextAwareAuthClient(ts.Client()) for _, token := range []string{"token-a", "token-b"} { ctx := context.WithValue(context.Background(), UserTokenContextKey{}, token) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) resp, err := client.Do(req) if err != nil { t.Fatalf("unexpected error: %v", err) } resp.Body.Close() } if len(capturedTokens) != 2 { t.Fatalf("expected 2 requests, got %d", len(capturedTokens)) } if capturedTokens[0] != "Bearer token-a" { t.Errorf("first request: expected 'Bearer token-a', got %q", capturedTokens[0]) } if capturedTokens[1] != "Bearer token-b" { t.Errorf("second request: expected 'Bearer token-b', got %q", capturedTokens[1]) } } ================================================ FILE: go/auth/gcpauth.go ================================================ // This file is adapted from https://gist.github.com/ahmetb/548059cdbf12fb571e4e2f1e29c48997 package auth import ( "context" "fmt" "log" "net/http" "strings" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "k8s.io/client-go/rest" ) var ( googleScopes = []string{ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email"} ) const ( GoogleAuthPlugin = "custom_gcp" // so that this is different than "gcp" that's already in client-go tree. ) func init() { if err := rest.RegisterAuthProviderPlugin(GoogleAuthPlugin, newGoogleAuthProvider); err != nil { log.Fatalf("Failed to register %s auth plugin: %v", GoogleAuthPlugin, err) } } var _ rest.AuthProvider = &googleAuthProvider{} type googleAuthProvider struct { tokenSource oauth2.TokenSource } func (g *googleAuthProvider) WrapTransport(rt http.RoundTripper) http.RoundTripper { return &oauth2.Transport{ Base: rt, Source: g.tokenSource, } } func (g *googleAuthProvider) Login() error { return nil } func newGoogleAuthProvider(addr string, config map[string]string, persister rest.AuthProviderConfigPersister) (rest.AuthProvider, error) { scopes := googleScopes scopesCfg, found := config["scopes"] if found { scopes = strings.Split(scopesCfg, " ") } ts, err := google.DefaultTokenSource(context.Background(), scopes...) if err != nil { return nil, fmt.Errorf("failed to create google token source: %w", err) } return &googleAuthProvider{tokenSource: ts}, nil } ================================================ FILE: go/auth/mcpoauth.go ================================================ package auth import ( "encoding/json" "fmt" "net/http" "net/url" ) // NewMCPOAuthMetadataHandler returns an HTTP handler that serves OAuth 2.0 // Authorization Server Metadata (RFC 8414) for an MCP endpoint. // // Instead of proxying Auth0's metadata at runtime, it constructs a static // document that points authorization_endpoint and token_endpoint to Auth0 // while advertising our own registration_endpoint for Dynamic Client // Registration (RFC 7591). This lets MCP clients like Cursor discover // the client_id automatically without any user configuration. // // scopes should include both the standard OIDC scopes and any // application-specific scopes (e.g. "admin:read", "changes:read"). func NewMCPOAuthMetadataHandler(auth0Domain, issuerURL, registrationEndpointURL string, scopes []string) http.Handler { metadata := map[string]any{ "issuer": issuerURL, "authorization_endpoint": fmt.Sprintf("https://%s/authorize", auth0Domain), "token_endpoint": fmt.Sprintf("https://%s/oauth/token", auth0Domain), "registration_endpoint": registrationEndpointURL, "jwks_uri": fmt.Sprintf("https://%s/.well-known/jwks.json", auth0Domain), "userinfo_endpoint": fmt.Sprintf("https://%s/userinfo", auth0Domain), "revocation_endpoint": fmt.Sprintf("https://%s/oauth/revoke", auth0Domain), "response_types_supported": []string{"code"}, "grant_types_supported": []string{"authorization_code", "refresh_token"}, "code_challenge_methods_supported": []string{"S256"}, "token_endpoint_auth_methods_supported": []string{"none"}, "scopes_supported": scopes, } body, _ := json.Marshal(metadata) //nolint:errchkjson // static map of strings/slices, cannot fail return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) }) } // NewMCPDCRHandler returns an HTTP handler that implements a minimal OAuth 2.0 // Dynamic Client Registration (RFC 7591) endpoint. It always returns the // same pre-configured Auth0 client_id since all MCP clients share a single // public OAuth application. // // Per RFC 7591 Section 3.2, the response echoes back the registered client // metadata including redirect_uris from the request. func NewMCPDCRHandler(clientID string) http.Handler { type dcrRequest struct { RedirectURIs []string `json:"redirect_uris"` ClientName string `json:"client_name"` } type dcrResponse struct { ClientID string `json:"client_id"` RedirectURIs []string `json:"redirect_uris"` ClientName string `json:"client_name,omitempty"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } limited := http.MaxBytesReader(w, r.Body, 64<<10) var req dcrRequest if err := json.NewDecoder(limited).Decode(&req); err != nil { req = dcrRequest{} } _ = limited.Close() // Don't echo back arbitrary redirect_uris; Auth0 enforces the // registered set during token exchange, but echoing unchecked URIs // could mislead clients that trust the DCR response blindly. // Instead, return only localhost URIs which are the standard // callback for native/public OAuth clients per RFC 8252. safeURIs := make([]string, 0, len(req.RedirectURIs)) for _, uri := range req.RedirectURIs { if IsLocalhostRedirect(uri) { safeURIs = append(safeURIs, uri) } } resp := dcrResponse{ ClientID: clientID, RedirectURIs: safeURIs, ClientName: req.ClientName, TokenEndpointAuthMethod: "none", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(resp) }) } // NewMCPPRMHandler returns an http.Handler that serves the OAuth 2.0 Protected // Resource Metadata (RFC 9728) JSON document for an MCP endpoint. No // authentication is required. // // authorizationServerURL is the issuer URL of the OAuth metadata endpoint (not // the raw Auth0 domain). MCP clients use this to discover the authorization // and token endpoints, as well as the Dynamic Client Registration endpoint. func NewMCPPRMHandler(authorizationServerURL, resourceURL string, scopes []string) http.Handler { type prmResponse struct { Resource string `json:"resource"` AuthorizationServers []string `json:"authorization_servers"` ScopesSupported []string `json:"scopes_supported"` BearerMethodsSupported []string `json:"bearer_methods_supported"` } resp := prmResponse{ Resource: resourceURL, AuthorizationServers: []string{authorizationServerURL}, ScopesSupported: scopes, BearerMethodsSupported: []string{"header"}, } body, _ := json.Marshal(resp) //nolint:errchkjson // static struct of strings, cannot fail return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write(body) }) } // IsLocalhostRedirect returns true if the URI is a loopback redirect, which is // the standard callback for native/public OAuth clients (RFC 8252 Section 7.3). func IsLocalhostRedirect(raw string) bool { u, err := url.Parse(raw) if err != nil { return false } host := u.Hostname() return host == "127.0.0.1" || host == "::1" || host == "localhost" } ================================================ FILE: go/auth/mcpoauth_test.go ================================================ package auth import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) func TestNewMCPOAuthMetadataHandler(t *testing.T) { scopes := []string{"openid", "profile", "email", "offline_access", "admin:read"} handler := NewMCPOAuthMetadataHandler( "auth.example.com", "https://api.example.com/area51/oauth", "https://api.example.com/area51/oauth/register", scopes, ) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/.well-known/oauth-authorization-server/area51/oauth", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var body map[string]any if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("failed to decode metadata: %v", err) } if body["issuer"] != "https://api.example.com/area51/oauth" { t.Errorf("unexpected issuer: %v", body["issuer"]) } if body["authorization_endpoint"] != "https://auth.example.com/authorize" { t.Errorf("unexpected authorization_endpoint: %v", body["authorization_endpoint"]) } if body["token_endpoint"] != "https://auth.example.com/oauth/token" { t.Errorf("unexpected token_endpoint: %v", body["token_endpoint"]) } if body["registration_endpoint"] != "https://api.example.com/area51/oauth/register" { t.Errorf("unexpected registration_endpoint: %v", body["registration_endpoint"]) } if body["jwks_uri"] != "https://auth.example.com/.well-known/jwks.json" { t.Errorf("unexpected jwks_uri: %v", body["jwks_uri"]) } scopesAny, ok := body["scopes_supported"].([]any) if !ok { t.Fatalf("scopes_supported is not an array: %T", body["scopes_supported"]) } if len(scopesAny) != len(scopes) { t.Errorf("expected %d scopes, got %d", len(scopes), len(scopesAny)) } } func TestNewMCPDCRHandler(t *testing.T) { handler := NewMCPDCRHandler("test-client-id") reqBody := `{"redirect_uris":["http://127.0.0.1/callback"],"client_name":"Test Client"}` req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/area51/oauth/register", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d", rec.Code) } var body struct { ClientID string `json:"client_id"` RedirectURIs []string `json:"redirect_uris"` ClientName string `json:"client_name"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("failed to decode DCR response: %v", err) } if body.ClientID != "test-client-id" { t.Errorf("unexpected client_id: %q", body.ClientID) } if body.TokenEndpointAuthMethod != "none" { t.Errorf("unexpected token_endpoint_auth_method: %q", body.TokenEndpointAuthMethod) } if len(body.RedirectURIs) != 1 || body.RedirectURIs[0] != "http://127.0.0.1/callback" { t.Errorf("unexpected redirect_uris: %v", body.RedirectURIs) } if body.ClientName != "Test Client" { t.Errorf("unexpected client_name: %q", body.ClientName) } } func TestNewMCPDCRHandler_MethodNotAllowed(t *testing.T) { handler := NewMCPDCRHandler("test-client-id") req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/area51/oauth/register", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405, got %d", rec.Code) } } func TestNewMCPDCRHandler_FiltersNonLocalhostRedirects(t *testing.T) { handler := NewMCPDCRHandler("test-client-id") reqBody := `{"redirect_uris":["http://127.0.0.1/callback","https://evil.com/callback","http://localhost:3000/callback"]}` req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/register", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d", rec.Code) } var body struct { RedirectURIs []string `json:"redirect_uris"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("failed to decode: %v", err) } if len(body.RedirectURIs) != 2 { t.Fatalf("expected 2 safe redirect URIs, got %d: %v", len(body.RedirectURIs), body.RedirectURIs) } } func TestNewMCPPRMHandler(t *testing.T) { handler := NewMCPPRMHandler( "https://api.example.com/area51/oauth", "https://api.example.com/area51/mcp", []string{"admin:read"}, ) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/.well-known/oauth-protected-resource/area51/mcp", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("expected Content-Type application/json, got %q", ct) } var body struct { Resource string `json:"resource"` AuthorizationServers []string `json:"authorization_servers"` ScopesSupported []string `json:"scopes_supported"` BearerMethodsSupported []string `json:"bearer_methods_supported"` ClientID string `json:"client_id"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("failed to decode PRM response: %v", err) } if body.Resource != "https://api.example.com/area51/mcp" { t.Errorf("unexpected resource: %q", body.Resource) } if len(body.AuthorizationServers) != 1 || body.AuthorizationServers[0] != "https://api.example.com/area51/oauth" { t.Errorf("unexpected authorization_servers: %v", body.AuthorizationServers) } if len(body.ScopesSupported) != 1 || body.ScopesSupported[0] != "admin:read" { t.Errorf("unexpected scopes_supported: %v", body.ScopesSupported) } if len(body.BearerMethodsSupported) != 1 || body.BearerMethodsSupported[0] != "header" { t.Errorf("unexpected bearer_methods_supported: %v", body.BearerMethodsSupported) } if body.ClientID != "" { t.Errorf("expected no client_id in PRM, got %q", body.ClientID) } } func TestIsLocalhostRedirect(t *testing.T) { tests := []struct { uri string want bool }{ {"http://127.0.0.1/callback", true}, {"http://localhost:3000/callback", true}, {"http://[::1]:8080/callback", true}, {"https://evil.com/callback", false}, {"https://example.com", false}, {"not-a-url", false}, } for _, tt := range tests { got := IsLocalhostRedirect(tt.uri) if got != tt.want { t.Errorf("IsLocalhostRedirect(%q) = %v, want %v", tt.uri, got, tt.want) } } } ================================================ FILE: go/auth/middleware.go ================================================ package auth import ( "context" "errors" "fmt" "net/http" "net/url" "regexp" "slices" "strings" "time" jwtmiddleware "github.com/auth0/go-jwt-middleware/v3" "github.com/auth0/go-jwt-middleware/v3/jwks" "github.com/auth0/go-jwt-middleware/v3/validator" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/audit" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) // ScopeCheckBypassedContextKey is a key that is stored in the request context // when scope checking is actively being bypassed, e.g. in development. When // this is set the `HasScopes()` function will always return true, and can be // set using the `WithBypassScopeCheck()` middleware. type ScopeCheckBypassedContextKey struct{} // CustomClaimsContextKey is the key that is used to store the custom claims // from the JWT type CustomClaimsContextKey struct{} // AccountNameContextKey is the key that is used to store the currently acting // account name type AccountNameContextKey struct{} // UserTokenContextKey is the key that is used to store the full JWT token of the user type UserTokenContextKey struct{} // CurrentSubjectContextKey is the key that is used to store the current subject attribute. // This will be the auth0 `user_id` from the tokens `sub` claim. type CurrentSubjectContextKey struct{} // ValidatedClaimsContextKey stores the full *validator.ValidatedClaims in // context. In v3 the middleware's context key is unexported, so we use our own // for code that needs the full validated claims (e.g. token expiry lookup). type ValidatedClaimsContextKey struct{} // MiddlewareConfig Configuration for the auth middleware type MiddlewareConfig struct { Auth0Domain string Auth0Audience string // The names of the cookies that will be used to authenticate, these will be // checked in order with the first one that is found being used AuthCookieNames []string // Use this to specify the full issuer URL for validating the JWTs. This // should only be used if we aren't using Auth0 as a source for tokens (such // as in testing). Auth0Domain will take precedence if both are set. IssuerURL string // Bypasses all auth checks, meaning that HasScopes() will always return // true. This should be used in conjunction with the `AccountOverride` field // since there won't be a token to parse the account from BypassAuth bool // Bypasses auth for the given paths. This is a regular expression that is // matched against the path of the request. If the regex matches then the // request will be allowed through without auth. This should be used with // `AccountOverride` in order to avoid the required context values not being // set and therefore causing issues (probably nil pointer panics) BypassAuthForPaths *regexp.Regexp // Overrides the account name stored in the CustomClaimsContextKey AccountOverride *string // Overrides the scope stored in the CustomClaimsContextKey ScopeOverride *string } // HasScopes compatibility alias for HasAllScopes func HasScopes(ctx context.Context, requiredScopes ...string) bool { return HasAllScopes(ctx, requiredScopes...) } // HasAllScopes checks that the authenticated user in the request context has all the // required scopes. If auth has been bypassed, this will always return true func HasAllScopes(ctx context.Context, requiredScopes ...string) bool { span := trace.SpanFromContext(ctx) span.SetAttributes( attribute.StringSlice("ovm.auth.requiredScopes.all", requiredScopes), ) if ctx.Value(ScopeCheckBypassedContextKey{}) == true { // this is always set when auth is bypassed // set it here again to capture non-standard auth configs span.SetAttributes(attribute.Bool("ovm.auth.bypass", true)) // Bypass all auth return true } claims, ok := ctx.Value(CustomClaimsContextKey{}).(*CustomClaims) if !ok { span.SetAttributes(attribute.String("ovm.auth.missingClaims", "all")) return false } for _, scope := range requiredScopes { if !claims.HasScope(scope) { span.SetAttributes(attribute.String("ovm.auth.missingClaims", scope)) return false } } return true } // HasAnyScopes checks that the authenticated user in the request context has any of the // required scopes. If auth has been bypassed, this will always return true func HasAnyScopes(ctx context.Context, requiredScopes ...string) bool { span := trace.SpanFromContext(ctx) span.SetAttributes( attribute.StringSlice("ovm.auth.requiredScopes.any", requiredScopes), ) if ctx.Value(ScopeCheckBypassedContextKey{}) == true { // this is always set when auth is bypassed // set it here again to capture non-standard auth configs span.SetAttributes(attribute.Bool("ovm.auth.bypass", true)) // Bypass all auth return true } claims, ok := ctx.Value(CustomClaimsContextKey{}).(*CustomClaims) if !ok { span.SetAttributes(attribute.String("ovm.auth.missingClaims", "all")) return false } span.SetAttributes( attribute.String("ovm.auth.tokenScopes", claims.Scope), ) for _, scope := range requiredScopes { if claims.HasScope(scope) { span.SetAttributes(attribute.String("ovm.auth.usedClaim", scope)) return true } } return false } var ErrNoClaims = errors.New("error extracting claims from token") // ExtractAccount Extracts the account name from a context func ExtractAccount(ctx context.Context) (string, error) { claims := ctx.Value(CustomClaimsContextKey{}) if claims == nil { return "", ErrNoClaims } return claims.(*CustomClaims).AccountName, nil } // NewAuthMiddleware Creates new auth middleware. The options allow you to // bypass the authentication process or not, but either way this middleware will // set the `CustomClaimsContextKey` in the request context which allows you to // use the `HasScopes()` function to check the scopes without having to worry // about whether the server is using auth or not. // // If auth is not bypassed, then tokens will be validated using Auth0 and // therefore the following environment variables must be set: AUTH0_DOMAIN, // AUTH0_AUDIENCE. If cookie auth is intended to be used, then AUTH_COOKIE_NAME // must also be set. func NewAuthMiddleware(config MiddlewareConfig, next http.Handler) http.Handler { processOverrides := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { options := []OverrideAuthOptionFunc{} if config.ScopeOverride != nil { options = append(options, WithScope(*config.ScopeOverride)) } if config.AccountOverride != nil { options = append(options, WithAccount(*config.AccountOverride)) } ctx := r.Context() if len(options) > 0 { ctx = OverrideAuth(r.Context(), options...) } if ad := audit.AuditDataFromContext(ctx); ad != nil { if sub, ok := ctx.Value(CurrentSubjectContextKey{}).(string); ok { ad.Subject = sub } if account, ok := ctx.Value(AccountNameContextKey{}).(string); ok { ad.AccountName = account } if claims, ok := ctx.Value(CustomClaimsContextKey{}).(*CustomClaims); ok { ad.Scopes = claims.Scope } } r = r.Clone(ctx) next.ServeHTTP(w, r) }) return ensureValidTokenHandler(config, processOverrides) } type OverrideAuthOptionFunc func(ctx context.Context) context.Context // Sets the scope in the context to the given value. This should be the value // that would be embedded directly in the token, with each scope being separated // by a space. func WithScope(scope string) OverrideAuthOptionFunc { return withCustomClaims(func(claims *CustomClaims) { claims.Scope = scope }) } // Sets the account in the context to the given value. func WithAccount(account string) OverrideAuthOptionFunc { return withCustomClaims(func(claims *CustomClaims) { claims.AccountName = account }) } // Sets the subject (typically the Auth0 user_id from the token's sub claim) // in the context. func WithSubject(subject string) OverrideAuthOptionFunc { return func(ctx context.Context) context.Context { return context.WithValue(ctx, CurrentSubjectContextKey{}, subject) } } // Sets the auth info in the context directly from the validated claims produced // by the `github.com/auth0/go-jwt-middleware/v3/validator` package. This is // essentially what the middleware already does when receiving a request, and // therefore should only be used in exceptional circumstances, like testing, when the // middleware is not being used. // // If this is being used, there is no need to use the `WithScope` or `WithAccount` // options as the claims will be extracted directly from the validated claims. func WithValidatedClaims(claims *validator.ValidatedClaims) OverrideAuthOptionFunc { return func(ctx context.Context) context.Context { customClaims := claims.CustomClaims.(*CustomClaims) ctx = context.WithValue(ctx, ValidatedClaimsContextKey{}, claims) ctx = context.WithValue(ctx, CustomClaimsContextKey{}, customClaims) ctx = context.WithValue(ctx, CurrentSubjectContextKey{}, claims.RegisteredClaims.Subject) ctx = context.WithValue(ctx, AccountNameContextKey{}, customClaims.AccountName) return ctx } } // Bypasses the scope check, meaning that `HasScopes()` and `HasAllScopes` will // always return true. This is useful for testing. func WithBypassScopeCheck() OverrideAuthOptionFunc { return func(ctx context.Context) context.Context { return context.WithValue(ctx, ScopeCheckBypassedContextKey{}, true) } } // Overrides the authentication that is currently stored in the context. This // can only be used within a single process, and doesn't mean that the overrides // set here will be passed on if you are using `NewAuthenticatedClient` to pass // through auth. It is however useful for testing, or for calling other handlers // within the same process. func OverrideAuth(ctx context.Context, opts ...OverrideAuthOptionFunc) context.Context { for _, opt := range opts { ctx = opt(ctx) } return ctx } func withCustomClaims(modify func(*CustomClaims)) OverrideAuthOptionFunc { return func(ctx context.Context) context.Context { i := ctx.Value(CustomClaimsContextKey{}) var claims *CustomClaims var newClaims CustomClaims var ok bool if claims, ok = i.(*CustomClaims); ok { // clone out the values to avoid sharing newClaims = *claims } modify(&newClaims) // Store the new claims in the context ctx = context.WithValue(ctx, CustomClaimsContextKey{}, &newClaims) ctx = context.WithValue(ctx, AccountNameContextKey{}, newClaims.AccountName) return ctx } } // ensureValidTokenHandler is a middleware that will check the validity of our // JWT. // // This will fail if all of Auth0Domain, Auth0Audience and AuthCookieName are // empty. // // This middleware also extract custom claims form the token and stores them in // CustomClaimsContextKey // // NOTE: This function uses log.Fatalf for startup-time configuration errors // because its signature returns http.Handler, not (http.Handler, error). // Propagating errors would require changing every caller of NewAuthMiddleware. func ensureValidTokenHandler(config MiddlewareConfig, next http.Handler) http.Handler { if config.BypassAuth { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { span := trace.SpanFromContext(r.Context()) span.SetAttributes(attribute.Bool("ovm.auth.bypass", true)) ctx := OverrideAuth(r.Context(), WithBypassScopeCheck(), WithSubject("auth-bypass")) next.ServeHTTP(w, r.Clone(ctx)) }) } if config.Auth0Audience == "" || (config.Auth0Domain == "" && config.IssuerURL == "") { log.Fatalf("Auth0 configuration is incomplete: audience=%q, domain=%q, issuerURL=%q", config.Auth0Audience, config.Auth0Domain, config.IssuerURL) } var issuerURL *url.URL var err error if config.Auth0Domain != "" { issuerURL, err = url.Parse("https://" + config.Auth0Domain + "/") } else { issuerURL, err = url.Parse(config.IssuerURL) } if err != nil { log.Fatalf("Failed to parse the issuer url: %v", err) } provider, err := jwks.NewCachingProvider( jwks.WithIssuerURL(issuerURL), jwks.WithCacheTTL(5*time.Minute), ) if err != nil { log.Fatalf("Failed to set up the jwks provider: %v", err) } jwtValidator, err := validator.New( validator.WithKeyFunc(provider.KeyFunc), validator.WithAlgorithm(validator.RS256), validator.WithIssuer(issuerURL.String()), validator.WithAudience(config.Auth0Audience), validator.WithCustomClaims(func() *CustomClaims { return &CustomClaims{} }), validator.WithAllowedClockSkew(time.Minute), ) if err != nil { log.Fatalf("Failed to set up the jwt validator: %v", err) } errorHandler := func(w http.ResponseWriter, r *http.Request, err error) { // copied from auth0's DefaultErrorHandler, but with some extra logging and reporting span := trace.SpanFromContext(r.Context()) span.SetAttributes( attribute.String("ovm.auth.error", err.Error()), attribute.String("ovm.auth.audience", config.Auth0Audience), attribute.String("ovm.auth.domain", config.Auth0Domain), attribute.String("ovm.auth.expectedIssuer", issuerURL.String()), ) // Check if this is a Connect/gRPC request by looking at the Content-Type header // Connect requests use content types like: // - application/connect+proto // - application/connect+json // - application/grpc (base type without suffix) // - application/grpc+proto // - application/grpc+json // For these requests, we should not set Content-Type: application/json // as it will cause content-type mismatch errors on the client side contentType := r.Header.Get("Content-Type") isConnectRequest := strings.HasPrefix(contentType, "application/connect+") || strings.HasPrefix(contentType, "application/grpc") // Only set JSON content-type for non-Connect requests if !isConnectRequest { w.Header().Set("Content-Type", "application/json") } switch { case errors.Is(err, jwtmiddleware.ErrJWTMissing): // since connectrpc would translate the original `BadRequest` to a // `CodeInternal` instead of something sensible, we also need to // return StatusUnauthorized here, to provide the correct status // code to the client. w.WriteHeader(http.StatusUnauthorized) if !isConnectRequest { _, _ = w.Write([]byte(`{"message":"JWT is missing."}`)) } case errors.Is(err, jwtmiddleware.ErrJWTInvalid): w.WriteHeader(http.StatusUnauthorized) if !isConnectRequest { _, _ = w.Write([]byte(`{"message":"JWT is invalid."}`)) } default: span.SetStatus(codes.Error, "Something went wrong while checking the JWT") sentry.CaptureException(err) w.WriteHeader(http.StatusInternalServerError) if !isConnectRequest { _, _ = w.Write([]byte(`{"message":"Something went wrong while checking the JWT."}`)) } } } // Set up token extractors based on what env vars are available extractors := []jwtmiddleware.TokenExtractor{ jwtmiddleware.AuthHeaderTokenExtractor, } for _, cookieName := range config.AuthCookieNames { extractors = append(extractors, jwtmiddleware.CookieTokenExtractor(cookieName)) } tokenExtractor := jwtmiddleware.MultiTokenExtractor(extractors...) middleware, err := jwtmiddleware.New( jwtmiddleware.WithValidator(jwtValidator), jwtmiddleware.WithErrorHandler(errorHandler), jwtmiddleware.WithTokenExtractor(tokenExtractor), ) if err != nil { log.Fatalf("Failed to set up the jwt middleware: %v", err) } jwtValidationMiddleware := middleware.CheckJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // extract account name and setup otel attributes after the JWT was validated, but before the actual handler runs claims, err := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context()) if err != nil { errorHandler(w, r, fmt.Errorf("error getting validated claims: %w", err)) return } extractedToken, err := tokenExtractor(r) // we should never hit this as the middleware wouldn't call the handler if err != nil { // This is not ErrJWTMissing because an error here means that the // tokenExtractor had an error and _not_ that the token was missing. errorHandler(w, r, fmt.Errorf("error extracting token: %w", err)) return } customClaims := claims.CustomClaims.(*CustomClaims) if customClaims == nil { errorHandler(w, r, fmt.Errorf("couldn't get claims from: %v", claims)) return } ctx := r.Context() // note that the values are looked up in last-in-first-out order, so // there is an absolutely minor perf optimisation to have the context // values set in ascending order of access frequency. ctx = context.WithValue(ctx, UserTokenContextKey{}, extractedToken.Token) ctx = context.WithValue(ctx, ValidatedClaimsContextKey{}, claims) ctx = context.WithValue(ctx, CustomClaimsContextKey{}, customClaims) ctx = context.WithValue(ctx, CurrentSubjectContextKey{}, claims.RegisteredClaims.Subject) ctx = context.WithValue(ctx, AccountNameContextKey{}, customClaims.AccountName) trace.SpanFromContext(ctx).SetAttributes( attribute.String("ovm.auth.accountName", customClaims.AccountName), attribute.Int64("ovm.auth.expiry", claims.RegisteredClaims.Expiry), attribute.String("ovm.auth.scopes", customClaims.Scope), // subject is the auth0 client id or user id attribute.String("ovm.auth.subject", claims.RegisteredClaims.Subject), ) // if its a service impersonating an account, we should mark it as impersonation if strings.HasSuffix(claims.RegisteredClaims.Subject, "@clients") { trace.SpanFromContext(ctx).SetAttributes( attribute.Bool("ovm.auth.impersonation", true), ) } r = r.Clone(ctx) next.ServeHTTP(w, r) })) // Basically what I need to do here is I need to have a middleware that // checks for bypassing, then passes on to middleware.checkJWT. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() span := trace.SpanFromContext(ctx) var shouldBypass bool // Check if the request path matches the bypass regex if config.BypassAuthForPaths != nil { shouldBypass = config.BypassAuthForPaths.MatchString(r.URL.Path) if shouldBypass { span.SetAttributes(attribute.String("ovm.auth.bypassedPath", r.URL.Path)) } } span.SetAttributes(attribute.Bool("ovm.auth.bypass", shouldBypass)) if shouldBypass { ctx = OverrideAuth(ctx, WithBypassScopeCheck(), WithSubject("auth-bypass")) r = r.Clone(ctx) // Call the next handler without adding any JWT validation next.ServeHTTP(w, r) } else { // Otherwise we need to inject the JWT validation middleware jwtValidationMiddleware.ServeHTTP(w, r) } }) } // WithResourceMetadata wraps a handler to include RFC 9728 resource_metadata // in the WWW-Authenticate header on 401 responses, enabling MCP clients to // discover the authorization server via Protected Resource Metadata. func WithResourceMetadata(resourceMetadataURL string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(&resourceMetadataWriter{ ResponseWriter: w, resourceMetadataURL: resourceMetadataURL, }, r) }) } type resourceMetadataWriter struct { http.ResponseWriter resourceMetadataURL string wroteHeader bool } func (w *resourceMetadataWriter) WriteHeader(statusCode int) { if !w.wroteHeader { w.wroteHeader = true if statusCode == http.StatusUnauthorized { w.ResponseWriter.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, w.resourceMetadataURL)) } } w.ResponseWriter.WriteHeader(statusCode) } func (w *resourceMetadataWriter) Write(b []byte) (int, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) } return w.ResponseWriter.Write(b) } // Unwrap returns the underlying ResponseWriter, enabling http.ResponseController // and middleware that check for optional interfaces (Flusher, Hijacker, etc.). func (w *resourceMetadataWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } // CustomClaims contains custom data we want from the token. type CustomClaims struct { Scope string `json:"scope"` AccountName string `json:"https://api.overmind.tech/account-name"` } // HasScope checks whether our claims have a specific scope. func (c CustomClaims) HasScope(expectedScope string) bool { result := strings.Split(c.Scope, " ") return slices.Contains(result, expectedScope) } // Validate does nothing for this example, but we need // it to satisfy validator.CustomClaims interface. func (c CustomClaims) Validate(ctx context.Context) error { return nil } ================================================ FILE: go/auth/middleware_test.go ================================================ package auth import ( "context" "crypto/rand" "crypto/rsa" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "regexp" "testing" "time" "github.com/auth0/go-jwt-middleware/v3/validator" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" "github.com/overmindtech/cli/go/audit" log "github.com/sirupsen/logrus" ) func TestHasScopes(t *testing.T) { t.Run("with auth bypassed", func(t *testing.T) { t.Parallel() ctx := OverrideAuth(context.Background(), WithBypassScopeCheck()) pass := HasAllScopes(ctx, "test") if !pass { t.Error("expected to allow since auth is bypassed") } }) t.Run("with good scopes", func(t *testing.T) { t.Parallel() account := "foo" scope := "test foo bar" ctx := OverrideAuth(context.Background(), WithScope(scope), WithAccount(account)) pass := HasAllScopes(ctx, "test") if !pass { t.Error("expected to allow since `test` scope is present") } }) t.Run("with multiple good scopes", func(t *testing.T) { t.Parallel() account := "foo" scope := "test foo bar" ctx := OverrideAuth(context.Background(), WithScope(scope), WithAccount(account)) pass := HasAllScopes(ctx, "test", "foo") if !pass { t.Error("expected to allow since `test` scope is present") } }) t.Run("with bad scopes", func(t *testing.T) { t.Parallel() account := "foo" scope := "test foo bar" ctx := OverrideAuth(context.Background(), WithScope(scope), WithAccount(account)) pass := HasAllScopes(ctx, "baz") if pass { t.Error("expected to deny since `baz` scope is not present") } }) t.Run("with one scope missing", func(t *testing.T) { t.Parallel() account := "foo" scope := "test foo bar" ctx := OverrideAuth(context.Background(), WithScope(scope), WithAccount(account)) pass := HasAllScopes(ctx, "test", "baz") if pass { t.Error("expected to deny since `baz` scope is not present") } }) t.Run("with any scopes", func(t *testing.T) { t.Parallel() account := "foo" scope := "test foo bar" ctx := OverrideAuth(context.Background(), WithScope(scope), WithAccount(account)) pass := HasAnyScopes(ctx, "fail", "foo") if !pass { t.Error("expected to allow since `foo` scope is present") } }) t.Run("without any scopes", func(t *testing.T) { t.Parallel() account := "foo" scope := "test foo bar" ctx := OverrideAuth(context.Background(), WithScope(scope), WithAccount(account)) pass := HasAnyScopes(ctx, "fail", "fail harder") if pass { t.Error("expected to deny since no matching scope is present") } }) } func TestNewAuthMiddleware(t *testing.T) { server, err := NewTestJWTServer() if err != nil { t.Fatal(err) } ctx := t.Context() jwksURL := server.Start(ctx) defaultConfig := MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", } bypassHealthConfig := MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", BypassAuthForPaths: regexp.MustCompile("/health"), } correctAccount := "test" correctScope := "test:pass" tests := []struct { Name string TokenOptions *TestTokenOptions ExpectedCode int AuthConfig MiddlewareConfig Path string }{ { Name: "with expired token", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(-time.Hour), }, AuthConfig: defaultConfig, ExpectedCode: http.StatusUnauthorized, }, { Name: "with wrong audience", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://something.not.expected"}, Expiry: time.Now().Add(time.Hour), }, AuthConfig: defaultConfig, ExpectedCode: http.StatusUnauthorized, }, { Name: "with insufficient scopes", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:fail", }, }, AuthConfig: defaultConfig, ExpectedCode: http.StatusUnauthorized, }, { Name: "with correct scopes but wrong account", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "fail", Scope: "test:pass", }, }, AuthConfig: defaultConfig, ExpectedCode: http.StatusUnauthorized, }, { Name: "with correct scopes and account", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:pass", }, }, AuthConfig: defaultConfig, ExpectedCode: http.StatusOK, }, { Name: "with the correct scope and many others", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:pass test:fail foo:bar something", }, }, AuthConfig: defaultConfig, ExpectedCode: http.StatusOK, }, { Name: "with many audiences and many scopes", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech", "https://api.overmind.tech/other"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:pass test:other", }, }, AuthConfig: defaultConfig, ExpectedCode: http.StatusOK, }, { Name: "with many audiences and one scope", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech", "https://api.overmind.tech/other"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:pass", }, }, AuthConfig: defaultConfig, ExpectedCode: http.StatusOK, }, { Name: "with good token and some bypassed paths", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:pass", }, }, AuthConfig: MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", BypassAuthForPaths: regexp.MustCompile("/health"), }, ExpectedCode: http.StatusOK, }, { Name: "with no token on a non-bypassed path", Path: "/", AuthConfig: bypassHealthConfig, ExpectedCode: http.StatusUnauthorized, }, { Name: "with no token on a bypassed path", Path: "/health", AuthConfig: bypassHealthConfig, ExpectedCode: http.StatusOK, }, { Name: "with bad token on a non-bypassed path", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:fail", }, }, ExpectedCode: http.StatusUnauthorized, AuthConfig: bypassHealthConfig, }, { Name: "with bad token on a bypassed path", Path: "/health", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:fail", }, }, ExpectedCode: http.StatusOK, AuthConfig: bypassHealthConfig, }, { Name: "with a good token and bypassed auth", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:pass", }, }, ExpectedCode: http.StatusOK, AuthConfig: MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", BypassAuth: true, }, }, { Name: "with a bad token and bypassed auth", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(-time.Hour), // expired CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:pass", }, }, ExpectedCode: http.StatusOK, AuthConfig: MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", BypassAuth: true, }, }, { Name: "with account override", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "bad", Scope: "test:pass", }, }, ExpectedCode: http.StatusOK, AuthConfig: MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", AccountOverride: &correctAccount, }, }, { Name: "with scope override", Path: "/", TokenOptions: &TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "test", Scope: "test:fail", }, }, ExpectedCode: http.StatusOK, AuthConfig: MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", ScopeOverride: &correctScope, }, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { handler := NewAuthMiddleware(test.AuthConfig, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // This is a test handler that always does the same thing, it checks // that the account is set to the correct value and that the user has // the test:pass scope if !HasAnyScopes(ctx, "test:pass") { w.WriteHeader(http.StatusUnauthorized) _, err := w.Write([]byte("missing required scope")) if err != nil { t.Error(err) } return } if ctx.Value(ScopeCheckBypassedContextKey{}) == true { // If we are bypassing auth then we don't want to check the account } else { claims, ok := ctx.Value(CustomClaimsContextKey{}).(*CustomClaims) if !ok { w.WriteHeader(http.StatusUnauthorized) _, err := fmt.Fprintf(w, "expected *CustomClaims in context, got %T", ctx.Value(CustomClaimsContextKey{})) if err != nil { t.Error(err) } return } if claims.AccountName != "test" { w.WriteHeader(http.StatusUnauthorized) _, err := fmt.Fprintf(w, "expected account to be 'test', but was '%s'", claims.AccountName) if err != nil { t.Error(err) } return } } })) rr := httptest.NewRecorder() req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, test.Path, nil) if err != nil { t.Fatal(err) } if test.TokenOptions != nil { // Create a test Token token, err := server.GenerateJWT(test.TokenOptions) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } handler.ServeHTTP(rr, req) if rr.Code != test.ExpectedCode { t.Errorf("expected status code %d, but got %d", test.ExpectedCode, rr.Code) t.Error(rr.Body.String()) } }) } } // TestBypassAuthInjectsSubject verifies the BypassAuth code path (local/dev // environments only — never runs in production where real JWTs provide the // subject). It ensures a synthetic "auth-bypass" subject is injected into // CurrentSubjectContextKey so handlers like Area51 job scheduling and feature // flags work without a JWT. func TestBypassAuthInjectsSubject(t *testing.T) { t.Parallel() bypassConfig := MiddlewareConfig{ BypassAuth: true, } var capturedSubject string handler := NewAuthMiddleware(bypassConfig, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if subj, ok := r.Context().Value(CurrentSubjectContextKey{}).(string); ok { capturedSubject = subj } w.WriteHeader(http.StatusOK) })) t.Run("injects default subject", func(t *testing.T) { capturedSubject = "" rr := httptest.NewRecorder() req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) if err != nil { t.Fatal(err) } handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("expected 200, got %d", rr.Code) } if capturedSubject != "auth-bypass" { t.Errorf("expected subject %q, got %q", "auth-bypass", capturedSubject) } }) t.Run("scope check is bypassed", func(t *testing.T) { rr := httptest.NewRecorder() req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) if err != nil { t.Fatal(err) } scopeHandler := NewAuthMiddleware(bypassConfig, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !HasAllScopes(r.Context(), "any:scope") { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) scopeHandler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("expected 200 (scope check bypassed), got %d", rr.Code) } }) } func TestWithSubject(t *testing.T) { t.Parallel() t.Run("sets subject in context", func(t *testing.T) { ctx := OverrideAuth(context.Background(), WithSubject("auth0|user-123")) subject, ok := ctx.Value(CurrentSubjectContextKey{}).(string) if !ok { t.Fatal("expected CurrentSubjectContextKey to be set") } if subject != "auth0|user-123" { t.Errorf("expected subject %q, got %q", "auth0|user-123", subject) } }) t.Run("last WithSubject wins", func(t *testing.T) { ctx := OverrideAuth(context.Background(), WithSubject("first"), WithSubject("second"), ) subject, ok := ctx.Value(CurrentSubjectContextKey{}).(string) if !ok { t.Fatal("expected CurrentSubjectContextKey to be set") } if subject != "second" { t.Errorf("expected subject %q, got %q", "second", subject) } }) t.Run("composes with other options", func(t *testing.T) { ctx := OverrideAuth(context.Background(), WithScope("api:read"), WithAccount("test-account"), WithSubject("auth0|user-456"), ) subject, ok := ctx.Value(CurrentSubjectContextKey{}).(string) if !ok { t.Fatal("expected CurrentSubjectContextKey to be set") } if subject != "auth0|user-456" { t.Errorf("expected subject %q, got %q", "auth0|user-456", subject) } accountName, err := ExtractAccount(ctx) if err != nil { t.Fatal(err) } if accountName != "test-account" { t.Errorf("expected account %q, got %q", "test-account", accountName) } if !HasAllScopes(ctx, "api:read") { t.Error("expected api:read scope to be present") } }) } func TestOverrideAuth(t *testing.T) { tests := []struct { Name string Options []OverrideAuthOptionFunc HasAllScopes []string HasAccountName string }{ { Name: "with account override", Options: []OverrideAuthOptionFunc{ WithAccount("test"), }, HasAccountName: "test", }, { Name: "with scope override", Options: []OverrideAuthOptionFunc{ WithScope("test:pass"), }, HasAllScopes: []string{"test:pass"}, }, { Name: "with account and scope override", Options: []OverrideAuthOptionFunc{ WithAccount("test"), WithScope("test:pass"), }, HasAccountName: "test", HasAllScopes: []string{"test:pass"}, }, { Name: "with account and scope override in reverse order", Options: []OverrideAuthOptionFunc{ WithScope("test:pass"), WithAccount("test"), }, HasAccountName: "test", HasAllScopes: []string{"test:pass"}, }, { Name: "with validated custom claims", Options: []OverrideAuthOptionFunc{ WithValidatedClaims(&validator.ValidatedClaims{ CustomClaims: &CustomClaims{ Scope: "test:pass", AccountName: "test", }, RegisteredClaims: validator.RegisteredClaims{ Issuer: "https://api.overmind.tech", Subject: "test", Audience: []string{"https://api.overmind.tech"}, ID: "test", }, }), }, HasAccountName: "test", HasAllScopes: []string{"test:pass"}, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { ctx := context.Background() ctx = OverrideAuth(ctx, test.Options...) if test.HasAccountName != "" { accountName, err := ExtractAccount(ctx) if err != nil { t.Error(err) } if accountName != test.HasAccountName { t.Errorf("expected account name to be %s, but got %s", test.HasAccountName, accountName) } } for _, scope := range test.HasAllScopes { if !HasAllScopes(ctx, scope) { t.Errorf("expected to have scope %s, but did not", scope) } } }) } } func BenchmarkAuthMiddleware(b *testing.B) { config := MiddlewareConfig{ Auth0Domain: "auth.overmind-demo.com", Auth0Audience: "https://api.overmind.tech", } okHandler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } handler := NewAuthMiddleware(config, http.HandlerFunc(okHandler)) // Reduce logging log.SetLevel(log.FatalLevel) for range b.N { // Create a request to pass to our handler. We don't have any query parameters for now, so we'll // pass 'nil' as the third parameter. req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) if err != nil { b.Fatal(err) } // Set to a known bad JWT (this JWT is garbage don't freak out) req.Header.Set("Authorization", "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InBpQWx1Q1FkQTB4MTNweG1JQzM4dyJ9.eyJodHRwczovL2FwaS5vdmVybWluZC50ZWNoL2FjY291bnQtbmFtZSI6IlRFU1QiLCJpc3MiOiJodHRwczovL29tLWRvZ2Zvb2QuZXUuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfFRFU1QiLCJhdWQiOlsiaHR0cHM6Ly9hcGkuZGYub3Zlcm1pbmQtZGVtby5jb20iLCJodHRwczovL29tLWRvZ2Zvb2QuZXUuYXV0aDAuY29tL3VzZXJpbmZvIl0sImlhdCI6MTcxNDA0MjA5MiwiZXhwIjoxNzE0MTI4NDkyLCJzY29wZSI6Im1hbnkgc2NvcGVzIiwiYXpwIjoiVEVTVCJ9.cEEh8jVnEItZel4SoyPybLUg7sArwduCrmSJHMz3YNRfzpRl9lxry39psuDUHFKdgOoNVxUv3Lgm-JWG-9uddCKYOW_zQxEvQvj6o8tcpQkmBZBlc8huG21dLPz7yrPhogVAcApLjdHf1fqii9EHxQegxch9FHlyfF7Xii5t9Hus62l4vdZ5dVWaIuiOLtcbG_hLxl9yqBf5tzN8eEC-Pa1SoAciRPesqH4AARfKyBFBhN774Fu3NzfNtW3wD_ASvnv7aFwzblS8ff5clqdTr2GuuJKdIPcmjQV2LaGSExHg2riCryf5guAhitAuwhugssW__STQmwp8dJmhifs7DA") // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { b.Errorf("expected status code %d, but got %d", http.StatusUnauthorized, rr.Code) } } } // Creates a new server that mints real, signed JWTs for testing. It even // provides its own JWKS endpoint so they can be externally validated. To start // the JWKS server you should call .Start() func NewTestJWTServer() (*TestJWTServer, error) { // Generate an RSA private key privateKey, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return nil, err } // Wrap this in a JWK object jwk := jose.JSONWebKey{ Key: privateKey, KeyID: "test-signing-key", Algorithm: string(jose.RS256), } // Create a signer that will sign all of our tokens signingKey := jose.SigningKey{ Algorithm: jose.RS256, Key: jwk, } signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{}) if err != nil { return nil, err } // Export the public key to be used for validation pubJwk := jwk.Public() keySet := jose.JSONWebKeySet{ Keys: []jose.JSONWebKey{pubJwk}, } return &TestJWTServer{ signer: signer, privateKey: jwk, publicKey: pubJwk, publicKeySet: keySet, }, nil } // This server is used to mint JWTs for testing purposes. It is basically the // same as Auth0 when it comes to creating tokens in that it returns a JWKS // endpoint that can be used to validate the tokens it creates, and the tokens // use the same algorithm as Auth0 type TestJWTServer struct { signer jose.Signer privateKey jose.JSONWebKey publicKey jose.JSONWebKey publicKeySet jose.JSONWebKeySet server *httptest.Server } type TestTokenOptions struct { Audience []string Expiry time.Time CustomClaims } func (s *TestJWTServer) GenerateJWT(options *TestTokenOptions) (string, error) { builder := jwt.Signed(s.signer) builder = builder.Claims(jwt.Claims{ Issuer: s.server.URL, Subject: "test", Audience: jwt.Audience(options.Audience), Expiry: jwt.NewNumericDate(options.Expiry), IssuedAt: jwt.NewNumericDate(time.Now()), }) builder = builder.Claims(options.CustomClaims) return builder.Serialize() } // Starts the server in the background, the server will exit when the context is // cancelled. Returns the URL of the server func (s *TestJWTServer) Start(ctx context.Context) string { s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/.well-known/openid-configuration": // The endpoint tells the validating party where to find the JWKS, // this contains our public keys that can be used to validate tokens // issued by our server w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") _, err := fmt.Fprintf(w, `{"issuer": %q, "jwks_uri": "%s/.well-known/jwks.json"}`, s.server.URL, s.server.URL) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } case "/.well-known/jwks.json": // Write the public key set as JSON w.Header().Set("Content-Type", "application/json") b, err := json.MarshalIndent(s.publicKeySet, "", " ") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) _, err = w.Write(b) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } })) go func() { <-ctx.Done() s.server.Close() }() return s.server.URL } func TestWithResourceMetadata(t *testing.T) { t.Parallel() prmURL := "https://api.example.com/.well-known/oauth-protected-resource/area51/mcp" t.Run("adds WWW-Authenticate on 401", func(t *testing.T) { t.Parallel() inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"message":"JWT is missing."}`)) }) handler := WithResourceMetadata(prmURL, inner) rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/area51/mcp", nil) handler.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rr.Code) } wwwAuth := rr.Header().Get("WWW-Authenticate") expected := `Bearer resource_metadata="` + prmURL + `"` if wwwAuth != expected { t.Errorf("expected WWW-Authenticate %q, got %q", expected, wwwAuth) } }) t.Run("no WWW-Authenticate on 200", func(t *testing.T) { t.Parallel() inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) handler := WithResourceMetadata(prmURL, inner) rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/area51/mcp", nil) handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } if wwwAuth := rr.Header().Get("WWW-Authenticate"); wwwAuth != "" { t.Errorf("expected no WWW-Authenticate header, got %q", wwwAuth) } }) t.Run("no WWW-Authenticate on 403", func(t *testing.T) { t.Parallel() inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }) handler := WithResourceMetadata(prmURL, inner) rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/area51/mcp", nil) handler.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", rr.Code) } if wwwAuth := rr.Header().Get("WWW-Authenticate"); wwwAuth != "" { t.Errorf("expected no WWW-Authenticate header on 403, got %q", wwwAuth) } }) t.Run("implicit 200 from Write without WriteHeader", func(t *testing.T) { t.Parallel() inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) handler := WithResourceMetadata(prmURL, inner) rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/area51/mcp", nil) handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } if wwwAuth := rr.Header().Get("WWW-Authenticate"); wwwAuth != "" { t.Errorf("expected no WWW-Authenticate header, got %q", wwwAuth) } }) } func TestConnectErrorHandling(t *testing.T) { // Create a test JWT server server, err := NewTestJWTServer() if err != nil { t.Fatal(err) } ctx := t.Context() jwksURL := server.Start(ctx) // Create the middleware handler := NewAuthMiddleware(MiddlewareConfig{ Auth0Domain: "", Auth0Audience: "test", IssuerURL: jwksURL, }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) tests := []struct { Name string ContentType string ExpectJSONResponse bool ExpectContentType string }{ { Name: "Regular JSON request without auth", ContentType: "application/json", ExpectJSONResponse: true, ExpectContentType: "application/json", }, { Name: "Connect proto request without auth", ContentType: "application/connect+proto", ExpectJSONResponse: false, ExpectContentType: "", }, { Name: "Connect json request without auth", ContentType: "application/connect+json", ExpectJSONResponse: false, ExpectContentType: "", }, { Name: "gRPC base request without auth", ContentType: "application/grpc", ExpectJSONResponse: false, ExpectContentType: "", }, { Name: "gRPC proto request without auth", ContentType: "application/grpc+proto", ExpectJSONResponse: false, ExpectContentType: "", }, { Name: "gRPC json request without auth", ContentType: "application/grpc+json", ExpectJSONResponse: false, ExpectContentType: "", }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { rr := httptest.NewRecorder() req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/test", nil) if err != nil { t.Fatal(err) } // Set the Content-Type header req.Header.Set("Content-Type", test.ContentType) // Don't set any auth token, so it will fail auth handler.ServeHTTP(rr, req) // Should return 401 Unauthorized if rr.Code != http.StatusUnauthorized { t.Errorf("expected status code %d, but got %d", http.StatusUnauthorized, rr.Code) } // Check Content-Type header contentType := rr.Header().Get("Content-Type") if test.ExpectContentType != contentType { t.Errorf("expected Content-Type header to be '%s', but got '%s'", test.ExpectContentType, contentType) } // Check if response has JSON body hasJSONBody := len(rr.Body.Bytes()) > 0 && contentType == "application/json" if test.ExpectJSONResponse != hasJSONBody { t.Errorf("expected JSON response: %v, but got: %v (body length: %d)", test.ExpectJSONResponse, hasJSONBody, len(rr.Body.Bytes())) } }) } } func TestAuthMiddleware_PopulatesAuditData(t *testing.T) { server, err := NewTestJWTServer() if err != nil { t.Fatal(err) } jwksURL := server.Start(t.Context()) discardLogger := log.New() discardLogger.SetOutput(io.Discard) t.Run("populates audit data from JWT", func(t *testing.T) { var capturedAD *audit.AuditData inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedAD = audit.AuditDataFromContext(r.Context()) w.WriteHeader(http.StatusOK) }) handler := audit.NewAuditMiddleware(discardLogger)( NewAuthMiddleware(MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", }, inner), ) token, err := server.GenerateJWT(&TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "acme-corp", Scope: "read:items write:items", }, }) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test", nil) req.Header.Set("Authorization", "Bearer "+token) handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } if capturedAD == nil { t.Fatal("expected audit data to be present in context") } if capturedAD.Subject != "test" { t.Errorf("expected subject 'test', got %q", capturedAD.Subject) } if capturedAD.AccountName != "acme-corp" { t.Errorf("expected account 'acme-corp', got %q", capturedAD.AccountName) } if capturedAD.Scopes != "read:items write:items" { t.Errorf("expected scopes 'read:items write:items', got %q", capturedAD.Scopes) } }) t.Run("populates audit data with account override", func(t *testing.T) { var capturedAD *audit.AuditData override := "override-acme" inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedAD = audit.AuditDataFromContext(r.Context()) w.WriteHeader(http.StatusOK) }) handler := audit.NewAuditMiddleware(discardLogger)( NewAuthMiddleware(MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", AccountOverride: &override, }, inner), ) token, err := server.GenerateJWT(&TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "original-acme", Scope: "read:items", }, }) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test", nil) req.Header.Set("Authorization", "Bearer "+token) handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } if capturedAD == nil { t.Fatal("expected audit data to be present in context") } if capturedAD.AccountName != "override-acme" { t.Errorf("expected overridden account 'override-acme', got %q", capturedAD.AccountName) } }) t.Run("works without audit context", func(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) handler := NewAuthMiddleware(MiddlewareConfig{ IssuerURL: jwksURL, Auth0Audience: "https://api.overmind.tech", }, inner) token, err := server.GenerateJWT(&TestTokenOptions{ Audience: []string{"https://api.overmind.tech"}, Expiry: time.Now().Add(time.Hour), CustomClaims: CustomClaims{ AccountName: "acme-corp", Scope: "read:items", }, }) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test", nil) req.Header.Set("Authorization", "Bearer "+token) handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200 (no panic without audit context), got %d", rr.Code) } }) } ================================================ FILE: go/auth/nats.go ================================================ package auth import ( "errors" "strings" "time" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/nats-io/nats.go" ) // Defaults const MaxReconnectsDefault = -1 const ReconnectWaitDefault = 1 * time.Second const ReconnectJitterDefault = 5 * time.Second const ConnectionTimeoutDefault = 10 * time.Second type MaxRetriesError struct{} func (m MaxRetriesError) Error() string { return "maximum retries reached" } func fieldsFromConn(c *nats.Conn) log.Fields { fields := log.Fields{} if c != nil { fields["ovm.nats.address"] = c.ConnectedAddr() fields["ovm.nats.reconnects"] = c.Reconnects fields["ovm.nats.serverId"] = c.ConnectedServerId() fields["ovm.nats.url"] = c.ConnectedUrl() if c.LastError() != nil { fields["ovm.nats.lastError"] = c.LastError() } } return fields } var DisconnectErrHandlerDefault = func(c *nats.Conn, err error) { fields := fieldsFromConn(c) if err != nil { log.WithError(err).WithFields(fields).Error("NATS disconnected") } else { log.WithFields(fields).Debug("NATS disconnected") } } var ConnectHandlerDefault = func(c *nats.Conn) { fields := fieldsFromConn(c) log.WithFields(fields).Debug("NATS connected") } var ReconnectHandlerDefault = func(c *nats.Conn) { fields := fieldsFromConn(c) log.WithFields(fields).Debug("NATS reconnected") } var ClosedHandlerDefault = func(c *nats.Conn) { fields := fieldsFromConn(c) log.WithFields(fields).Debug("NATS connection closed") } var LameDuckModeHandlerDefault = func(c *nats.Conn) { fields := fieldsFromConn(c) log.WithFields(fields).Debug("NATS server has entered lame duck mode") } var ErrorHandlerDefault = func(c *nats.Conn, s *nats.Subscription, err error) { fields := fieldsFromConn(c) if s != nil { fields["ovm.nats.subject"] = s.Subject fields["ovm.nats.queue"] = s.Queue } log.WithFields(fields).WithError(err).Error("NATS error") } type NATSOptions struct { Servers []string // List of server to connect to ConnectionName string // The client name MaxReconnects int // The maximum number of reconnect attempts ConnectionTimeout time.Duration // The timeout for Dial on a connection ReconnectWait time.Duration // Wait time between reconnect attempts ReconnectJitter time.Duration // The upper bound of a random delay added ReconnectWait TokenClient TokenClient // The client to use to get NATS tokens ConnectHandler nats.ConnHandler // Runs when NATS is connected DisconnectErrHandler nats.ConnErrHandler // Runs when NATS is disconnected ReconnectHandler nats.ConnHandler // Runs when NATS has successfully reconnected ClosedHandler nats.ConnHandler // Runs when NATS will no longer be connected ErrorHandler nats.ErrHandler // Runs when there is a NATS error LameDuckModeHandler nats.ConnHandler // Runs when the connection enters "lame duck mode" AdditionalOptions []nats.Option // Addition options to pass to the connection NumRetries int // How many times to retry connecting initially, use -1 to retry indefinitely RetryDelay time.Duration // Delay between connection attempts } // Creates a copy of the NATS options, **excluding** the token client as these // should not be re-used func (o NATSOptions) Copy() NATSOptions { return NATSOptions{ Servers: o.Servers, ConnectionName: o.ConnectionName, MaxReconnects: o.MaxReconnects, ConnectionTimeout: o.ConnectionTimeout, ReconnectWait: o.ReconnectWait, ReconnectJitter: o.ReconnectJitter, ConnectHandler: o.ConnectHandler, DisconnectErrHandler: o.DisconnectErrHandler, ReconnectHandler: o.ReconnectHandler, ClosedHandler: o.ClosedHandler, LameDuckModeHandler: o.LameDuckModeHandler, ErrorHandler: o.ErrorHandler, AdditionalOptions: o.AdditionalOptions, NumRetries: o.NumRetries, RetryDelay: o.RetryDelay, } } // ToNatsOptions Converts the struct to connection string and a set of NATS // options func (o NATSOptions) ToNatsOptions() (string, []nats.Option) { serverString := strings.Join(o.Servers, ",") options := []nats.Option{} if o.ConnectionName != "" { options = append(options, nats.Name(o.ConnectionName)) } if o.MaxReconnects != 0 { options = append(options, nats.MaxReconnects(o.MaxReconnects)) } else { options = append(options, nats.MaxReconnects(MaxReconnectsDefault)) } if o.ConnectionTimeout != 0 { options = append(options, nats.Timeout(o.ConnectionTimeout)) } else { options = append(options, nats.Timeout(ConnectionTimeoutDefault)) } if o.ReconnectWait != 0 { options = append(options, nats.ReconnectWait(o.ReconnectWait)) } else { options = append(options, nats.ReconnectWait(ReconnectWaitDefault)) } if o.ReconnectJitter != 0 { options = append(options, nats.ReconnectJitter(o.ReconnectJitter, o.ReconnectJitter)) } else { options = append(options, nats.ReconnectJitter(ReconnectJitterDefault, ReconnectJitterDefault)) } if o.TokenClient != nil { options = append(options, nats.UserJWT(func() (string, error) { return o.TokenClient.GetJWT() }, o.TokenClient.Sign)) } if o.ConnectHandler != nil { options = append(options, nats.ConnectHandler(o.ConnectHandler)) } else { options = append(options, nats.ConnectHandler(ConnectHandlerDefault)) } if o.DisconnectErrHandler != nil { options = append(options, nats.DisconnectErrHandler(o.DisconnectErrHandler)) } else { options = append(options, nats.DisconnectErrHandler(DisconnectErrHandlerDefault)) } if o.ReconnectHandler != nil { options = append(options, nats.ReconnectHandler(o.ReconnectHandler)) } else { options = append(options, nats.ReconnectHandler(ReconnectHandlerDefault)) } if o.ClosedHandler != nil { options = append(options, nats.ClosedHandler(o.ClosedHandler)) } else { options = append(options, nats.ClosedHandler(ClosedHandlerDefault)) } if o.LameDuckModeHandler != nil { options = append(options, nats.LameDuckModeHandler(o.LameDuckModeHandler)) } else { options = append(options, nats.LameDuckModeHandler(LameDuckModeHandlerDefault)) } if o.ErrorHandler != nil { options = append(options, nats.ErrorHandler(o.ErrorHandler)) } else { options = append(options, nats.ErrorHandler(ErrorHandlerDefault)) } options = append(options, o.AdditionalOptions...) return serverString, options } // ConnectAs Connects to NATS using the supplied options, including retrying if // unavailable func (o NATSOptions) Connect() (sdp.EncodedConnection, error) { servers, opts := o.ToNatsOptions() var triesLeft int if o.NumRetries >= 0 { triesLeft = o.NumRetries + 1 } else { triesLeft = -1 } var nc *nats.Conn var err error for triesLeft != 0 { if triesLeft > 0 { triesLeft-- } // Log a non-negative value: 0 means unlimited retries (NumRetries < 0) logTriesLeft := max(triesLeft, 0) lf := log.Fields{ "servers": servers, "triesLeft": logTriesLeft, } log.WithFields(lf).Info("NATS connecting") nc, err = nats.Connect( servers, opts..., ) if err != nil && triesLeft != 0 { log.WithError(err).WithFields(lf).Error("Error connecting to NATS") time.Sleep(o.RetryDelay) continue } log.WithFields(lf).Info("NATS connected") break } if err != nil { err = errors.Join(err, MaxRetriesError{}) return &sdp.EncodedConnectionImpl{}, err } return &sdp.EncodedConnectionImpl{Conn: nc}, nil } ================================================ FILE: go/auth/nats_test.go ================================================ package auth import ( "context" "errors" "os" "testing" "time" "github.com/google/uuid" "github.com/nats-io/jwt/v2" "github.com/nats-io/nats.go" "github.com/nats-io/nkeys" "github.com/overmindtech/cli/go/sdp-go" ) func TestToNatsOptions(t *testing.T) { t.Run("with defaults", func(t *testing.T) { o := NATSOptions{} expectedOptions, err := optionsToStruct([]nats.Option{ nats.Timeout(ConnectionTimeoutDefault), nats.MaxReconnects(MaxReconnectsDefault), nats.ReconnectWait(ReconnectWaitDefault), nats.ReconnectJitter(ReconnectJitterDefault, ReconnectJitterDefault), nats.ConnectHandler(ConnectHandlerDefault), nats.DisconnectErrHandler(DisconnectErrHandlerDefault), nats.ReconnectHandler(ReconnectHandlerDefault), nats.ClosedHandler(ClosedHandlerDefault), nats.LameDuckModeHandler(LameDuckModeHandlerDefault), nats.ErrorHandler(ErrorHandlerDefault), }) if err != nil { t.Fatal(err) } server, options := o.ToNatsOptions() if server != "" { t.Error("Expected server to be empty") } actualOptions, err := optionsToStruct(options) if err != nil { t.Fatal(err) } if expectedOptions.MaxReconnect != actualOptions.MaxReconnect { t.Errorf("Expected MaxReconnect to be %v, got %v", expectedOptions.MaxReconnect, actualOptions.MaxReconnect) } if expectedOptions.Timeout != actualOptions.Timeout { t.Errorf("Expected ConnectionTimeout to be %v, got %v", expectedOptions.Timeout, actualOptions.Timeout) } if expectedOptions.ReconnectWait != actualOptions.ReconnectWait { t.Errorf("Expected ReconnectWait to be %v, got %v", expectedOptions.ReconnectWait, actualOptions.ReconnectWait) } if expectedOptions.ReconnectJitter != actualOptions.ReconnectJitter { t.Errorf("Expected ReconnectJitter to be %v, got %v", expectedOptions.ReconnectJitter, actualOptions.ReconnectJitter) } // TokenClient if expectedOptions.UserJWT != nil || expectedOptions.SignatureCB != nil { t.Error("Expected TokenClient to be nil") } if actualOptions.DisconnectedErrCB == nil { t.Error("Expected DisconnectedErrCB to be non-nil") } if actualOptions.ReconnectedCB == nil { t.Error("Expected ReconnectedCB to be non-nil") } if actualOptions.ClosedCB == nil { t.Error("Expected ClosedCB to be non-nil") } if actualOptions.LameDuckModeHandler == nil { t.Error("Expected LameDuckModeHandler to be non-nil") } if actualOptions.AsyncErrorCB == nil { t.Error("Expected AsyncErrorCB to be non-nil") } }) t.Run("with non-defaults", func(t *testing.T) { var connectHandlerUsed bool var disconnectErrHandlerUsed bool var reconnectHandlerUsed bool var closedHandlerUsed bool var lameDuckModeHandlerUsed bool var errorHandlerUsed bool o := NATSOptions{ Servers: []string{"one", "two"}, ConnectionName: "foo", MaxReconnects: 999, ReconnectWait: 999, ReconnectJitter: 999, ConnectHandler: func(c *nats.Conn) { connectHandlerUsed = true }, DisconnectErrHandler: func(c *nats.Conn, err error) { disconnectErrHandlerUsed = true }, ReconnectHandler: func(c *nats.Conn) { reconnectHandlerUsed = true }, ClosedHandler: func(c *nats.Conn) { closedHandlerUsed = true }, LameDuckModeHandler: func(c *nats.Conn) { lameDuckModeHandlerUsed = true }, ErrorHandler: func(c *nats.Conn, s *nats.Subscription, err error) { errorHandlerUsed = true }, } expectedOptions, err := optionsToStruct([]nats.Option{ nats.Name("foo"), nats.MaxReconnects(999), nats.ReconnectWait(999), nats.ReconnectJitter(999, 999), nats.DisconnectErrHandler(nil), nats.ReconnectHandler(nil), nats.ClosedHandler(nil), nats.LameDuckModeHandler(nil), nats.ErrorHandler(nil), }) if err != nil { t.Fatal(err) } server, options := o.ToNatsOptions() if server != "one,two" { t.Errorf("Expected server to be one,two got %v", server) } actualOptions, err := optionsToStruct(options) if err != nil { t.Fatal(err) } if expectedOptions.MaxReconnect != actualOptions.MaxReconnect { t.Errorf("Expected MaxReconnect to be %v, got %v", expectedOptions.MaxReconnect, actualOptions.MaxReconnect) } if expectedOptions.ReconnectWait != actualOptions.ReconnectWait { t.Errorf("Expected ReconnectWait to be %v, got %v", expectedOptions.ReconnectWait, actualOptions.ReconnectWait) } if expectedOptions.ReconnectJitter != actualOptions.ReconnectJitter { t.Errorf("Expected ReconnectJitter to be %v, got %v", expectedOptions.ReconnectJitter, actualOptions.ReconnectJitter) } if actualOptions.DisconnectedErrCB != nil { actualOptions.DisconnectedErrCB(nil, nil) if !disconnectErrHandlerUsed { t.Error("DisconnectErrHandler not used") } } else { t.Error("Expected DisconnectedErrCB to non-nil") } if actualOptions.ConnectedCB != nil { actualOptions.ConnectedCB(nil) if !connectHandlerUsed { t.Error("ConnectHandler not used") } } else { t.Error("Expected ConnectedCB to non-nil") } if actualOptions.ReconnectedCB != nil { actualOptions.ReconnectedCB(nil) if !reconnectHandlerUsed { t.Error("ReconnectHandler not used") } } else { t.Error("Expected ReconnectedCB to non-nil") } if actualOptions.ClosedCB != nil { actualOptions.ClosedCB(nil) if !closedHandlerUsed { t.Error("ClosedHandler not used") } } else { t.Error("Expected ClosedCB to non-nil") } if actualOptions.LameDuckModeHandler != nil { actualOptions.LameDuckModeHandler(nil) if !lameDuckModeHandlerUsed { t.Error("LameDuckModeHandler not used") } } else { t.Error("Expected LameDuckModeHandler to non-nil") } if actualOptions.AsyncErrorCB != nil { actualOptions.AsyncErrorCB(nil, nil, nil) if !errorHandlerUsed { t.Error("ErrorHandler not used") } } else { t.Error("Expected AsyncErrorCB to non-nil") } }) } func TestNATSConnect(t *testing.T) { if os.Getenv("CI") == "true" { t.Skip("Skipping test in CI environment, missing nats token exchange server") } t.Run("with a bad URL", func(t *testing.T) { o := NATSOptions{ Servers: []string{"nats://badname.dontresolve.com"}, NumRetries: 5, RetryDelay: 100 * time.Millisecond, } start := time.Now() _, err := o.Connect() // Just sanity check the duration here, it should not be less than // NumRetries * RetryDelay and it should be more than... Some larger // number of seconds. This is very much dependant on how long it takes // to not resolve the name if time.Since(start) < 5*100*time.Millisecond { t.Errorf("Reconnecting didn't take long enough, expected >0.5s got: %v", time.Since(start).String()) } if time.Since(start) > 3*time.Second { t.Errorf("Reconnecting took too long, expected <3s got: %v", time.Since(start).String()) } var maxRetriesError MaxRetriesError if !errors.As(err, &maxRetriesError) { t.Errorf("Unknown error type %T: %v", err, err) } }) t.Run("with a bad URL, but a good token", func(t *testing.T) { tk := GetTestOAuthTokenClient(t) startToken, err := tk.GetJWT() if err != nil { t.Fatal(err) } o := NATSOptions{ Servers: []string{"nats://badname.dontresolve.com"}, TokenClient: tk, NumRetries: 3, RetryDelay: 100 * time.Millisecond, } _, err = o.Connect() var maxRetriesError MaxRetriesError if errors.As(err, &maxRetriesError) { // Make sure we have only got one token, not three currentToken, err := o.TokenClient.GetJWT() if err != nil { t.Fatal(err) } if currentToken != startToken { t.Error("Tokens have changed") } } else { t.Errorf("Unknown error type %T", err) } }) t.Run("with a good URL", func(t *testing.T) { o := NATSOptions{ Servers: []string{ "nats://nats:4222", "nats://localhost:4222", }, NumRetries: 3, RetryDelay: 100 * time.Millisecond, } conn, err := o.Connect() if err != nil { t.Fatal(err) } ValidateNATSConnection(t, conn) }) t.Run("with a good URL but no retries", func(t *testing.T) { o := NATSOptions{ Servers: []string{ "nats://nats:4222", "nats://localhost:4222", }, } conn, err := o.Connect() if err != nil { t.Fatal(err) } ValidateNATSConnection(t, conn) }) t.Run("with a good URL and infinite retries", func(t *testing.T) { o := NATSOptions{ Servers: []string{ "nats://nats:4222", "nats://localhost:4222", }, NumRetries: -1, RetryDelay: 100 * time.Millisecond, } conn, err := o.Connect() if err != nil { t.Error(err) } ValidateNATSConnection(t, conn) }) } func TestTokenRefresh(t *testing.T) { if os.Getenv("CI") == "true" { t.Skip("Skipping test in CI environment, missing nats token exchange server") } tk := GetTestOAuthTokenClient(t) // Get a token token, err := tk.GetJWT() if err != nil { t.Fatal(err) } // Artificially set the expiry and replace the token claims, err := jwt.DecodeUserClaims(token) if err != nil { t.Fatal(err) } pair, err := nkeys.CreateAccount() if err != nil { t.Fatal(err) } claims.Expires = time.Now().Add(-10 * time.Second).Unix() tk.jwt, err = claims.Encode(pair) expiredToken := tk.jwt if err != nil { t.Error(err) } // Get the token again newToken, err := tk.GetJWT() if err != nil { t.Error(err) } if expiredToken == newToken { t.Error("token is unchanged") } } func ValidateNATSConnection(t *testing.T, ec sdp.EncodedConnection) { t.Helper() done := make(chan struct{}) sub, err := ec.Subscribe("test", sdp.NewQueryResponseHandler("test", func(ctx context.Context, qr *sdp.QueryResponse) { rt, ok := qr.GetResponseType().(*sdp.QueryResponse_Response) if !ok { t.Errorf("Received unexpected message: %v", qr) } if rt.Response.GetResponder() == "test" { done <- struct{}{} } })) if err != nil { t.Error(err) } ru := uuid.New() err = ec.Publish(context.Background(), "test", sdp.NewQueryResponseFromResponse(&sdp.Response{ Responder: "test", ResponderUUID: ru[:], State: sdp.ResponderState_COMPLETE, })) if err != nil { t.Error(err) } // Wait for the message to come back select { case <-done: // Good case <-time.After(500 * time.Millisecond): t.Error("Didn't get message after 500ms") } err = sub.Unsubscribe() if err != nil { t.Error(err) } } func optionsToStruct(options []nats.Option) (nats.Options, error) { var o nats.Options var err error for _, option := range options { err = option(&o) if err != nil { return o, err } } return o, nil } ================================================ FILE: go/auth/tracing.go ================================================ package auth import ( "go.opentelemetry.io/otel" semconv "go.opentelemetry.io/otel/semconv/v1.24.0" "go.opentelemetry.io/otel/trace" ) const ( instrumentationName = "github.com/overmindtech/cli/go/auth" instrumentationVersion = "0.0.1" ) var tracer = otel.GetTracerProvider().Tracer( instrumentationName, trace.WithInstrumentationVersion(instrumentationVersion), trace.WithSchemaURL(semconv.SchemaURL), ) ================================================ FILE: go/cliauth/cliauth.go ================================================ // Package cliauth provides shared CLI authentication logic for OAuth device flow, // API key exchange, and token caching. // // This package is used by both the public overmind CLI and the area51-cli to avoid // code duplication and ensure consistent authentication behavior. package cliauth import ( "bufio" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/url" "os" "path/filepath" "strings" "time" "connectrpc.com/connect" "github.com/overmindtech/cli/go/auth" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" "github.com/overmindtech/cli/go/tracing" "github.com/pkg/browser" "golang.org/x/oauth2" ) // Logger is an interface for outputting authentication messages. // Implementations can use pterm, slog, or any other logging framework. type Logger interface { Info(msg string, keysAndValues ...any) Error(msg string, keysAndValues ...any) } // ConfirmUntrustedHost checks whether appURL points to a trusted Overmind host // (see [sdp.IsTrustedHost]). If not, it writes a warning to w and reads a // [y/N] confirmation from stdin. Returns nil when the host is trusted or the // user confirms; returns an error otherwise. // // Set hasAPIKey to true when an API key is configured so the warning can // mention that the key will be sent to the untrusted host. func ConfirmUntrustedHost(appURL string, hasAPIKey bool, stdin io.Reader, w io.Writer) error { parsed, err := url.Parse(appURL) if err != nil { return fmt.Errorf("invalid app URL %q: %w", appURL, err) } if sdp.IsTrustedHost(parsed.Hostname()) { return nil } credentialDetail := "OAuth tokens" //nolint:gosec // G101 false positive: this is a user-facing label, not a credential if hasAPIKey { credentialDetail = "your API key and OAuth tokens" } fmt.Fprintf(w, "\n WARNING: The target host %q is not a known Overmind domain.\n", parsed.Hostname()) fmt.Fprintf(w, " Credentials (%s) will be sent to this host.\n", credentialDetail) fmt.Fprintf(w, "\n Only continue if you trust this host.\n\n") fmt.Fprintf(w, " Continue? [y/N]: ") reader := bufio.NewReader(stdin) line, err := reader.ReadString('\n') if err != nil && (!errors.Is(err, io.EOF) || len(line) == 0) { return fmt.Errorf("failed to read confirmation: %w", err) } answer := strings.TrimSpace(strings.ToLower(line)) if answer != "y" && answer != "yes" { return errors.New("aborted: untrusted host not confirmed") } return nil } // TokenFile represents the ~/.overmind/token.json file structure. // This format is shared between all Overmind CLI tools. type TokenFile struct { AuthEntries map[string]*TokenEntry `json:"auth_entries"` } // TokenEntry represents a single auth entry in the token file type TokenEntry struct { Token *oauth2.Token `json:"token"` AddedDate time.Time `json:"added_date"` } // ReadLocalToken reads a cached token from ~/.overmind/token.json for the given // app URL. Returns the token and its current scopes if valid and sufficient. func ReadLocalToken(homeDir, app string, requiredScopes []string, log Logger) (*oauth2.Token, []string, error) { path := filepath.Join(homeDir, ".overmind", "token.json") tokenFile := new(TokenFile) if _, err := os.Stat(path); err != nil { return nil, nil, err } file, err := os.Open(path) if err != nil { return nil, nil, fmt.Errorf("error opening token file at %q: %w", path, err) } defer file.Close() err = json.NewDecoder(file).Decode(tokenFile) if err != nil { return nil, nil, fmt.Errorf("error decoding token file at %q: %w", path, err) } authEntry, ok := tokenFile.AuthEntries[app] if !ok { return nil, nil, fmt.Errorf("no token found for app %s in %q", app, path) } if authEntry == nil { return nil, nil, fmt.Errorf("token entry for app %s is null in %q", app, path) } if authEntry.Token == nil { return nil, nil, fmt.Errorf("token for app %s is null in %q", app, path) } if !authEntry.Token.Valid() { return nil, nil, errors.New("token is no longer valid") } claims, err := ExtractClaims(authEntry.Token.AccessToken) if err != nil { return nil, nil, fmt.Errorf("error extracting claims from token: %s in %q: %w", app, path, err) } if claims.Scope == "" { return nil, nil, errors.New("token does not have any scopes") } currentScopes := strings.Split(claims.Scope, " ") ok, missing, err := HasScopesFlexible(authEntry.Token, requiredScopes) if err != nil { return nil, currentScopes, fmt.Errorf("error checking token scopes: %s in %q: %w", app, path, err) } if !ok { return nil, currentScopes, fmt.Errorf("local token is missing this permission: '%v'. %s in %q", missing, app, path) } log.Info("Using local token", "app", app, "path", path) return authEntry.Token, currentScopes, nil } // SaveLocalToken saves a token to ~/.overmind/token.json with secure permissions // (directory 0700, file 0600). The token is keyed by app URL so multiple // environments can coexist. func SaveLocalToken(homeDir, app string, token *oauth2.Token, log Logger) error { path := filepath.Join(homeDir, ".overmind", "token.json") dir := filepath.Dir(path) tokenFile := &TokenFile{ AuthEntries: make(map[string]*TokenEntry), } if _, err := os.Stat(path); err == nil { file, err := os.Open(path) if err == nil { defer file.Close() err = json.NewDecoder(file).Decode(tokenFile) if err != nil { return fmt.Errorf("error decoding token file at %q: %w", path, err) } if tokenFile.AuthEntries == nil { tokenFile.AuthEntries = make(map[string]*TokenEntry) } } } else { err = os.MkdirAll(dir, 0700) if err != nil { return fmt.Errorf("unexpected fail creating directories: %w", err) } } if err := os.Chmod(dir, 0700); err != nil { return fmt.Errorf("failed to set directory permissions: %w", err) } tokenFile.AuthEntries[app] = &TokenEntry{ Token: token, AddedDate: time.Now(), } file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("error creating token file at %q: %w", path, err) } defer file.Close() err = json.NewEncoder(file).Encode(tokenFile) if err != nil { return fmt.Errorf("error encoding token file at %q: %w", path, err) } if err := os.Chmod(path, 0600); err != nil { return fmt.Errorf("failed to set file permissions: %w", err) } log.Info("Saved token locally", "app", app, "path", path) return nil } // HasScopesFlexible checks if a token has the required scopes. A service:write // scope is treated as satisfying service:read. func HasScopesFlexible(token *oauth2.Token, requiredScopes []string) (bool, string, error) { if token == nil { return false, "", errors.New("HasScopesFlexible: token is nil") } claims, err := ExtractClaims(token.AccessToken) if err != nil { return false, "", fmt.Errorf("error extracting claims from token: %w", err) } for _, scope := range requiredScopes { if !claims.HasScope(scope) { sections := strings.Split(scope, ":") var hasWriteInstead bool if len(sections) == 2 { service, action := sections[0], sections[1] if action == "read" { hasWriteInstead = claims.HasScope(fmt.Sprintf("%v:write", service)) } } if !hasWriteInstead { return false, scope, nil } } } return true, "", nil } // ExtractClaims extracts custom claims from a JWT token without verifying the // signature. Signature verification is the server's responsibility; we only // need the claims for scope checking. func ExtractClaims(token string) (*auth.CustomClaims, error) { sections := strings.Split(token, ".") if len(sections) != 3 { return nil, errors.New("token is not a JWT") } decodedPayload, err := base64.RawURLEncoding.DecodeString(sections[1]) if err != nil { return nil, fmt.Errorf("error decoding token payload: %w", err) } claims := new(auth.CustomClaims) err = json.Unmarshal(decodedPayload, claims) if err != nil { return nil, fmt.Errorf("error parsing token payload: %w", err) } return claims, nil } // GetOauthToken authenticates using the OAuth2 device authorization flow. // It first checks for a cached token in ~/.overmind/token.json and falls back // to the interactive device flow if needed. New tokens are cached for reuse. func GetOauthToken(ctx context.Context, oi sdp.OvermindInstance, app string, requiredScopes []string, log Logger) (*oauth2.Token, error) { var localScopes []string var localToken *oauth2.Token home, err := os.UserHomeDir() if err == nil { localToken, localScopes, err = ReadLocalToken(home, app, requiredScopes, log) if err != nil { if !os.IsNotExist(err) { log.Info("Skipping local token, re-authenticating", "error", err.Error()) } } else { return localToken, nil } } // Request the required scopes on top of whatever the current local token // has so that we don't keep replacing it with one that has fewer scopes. // Use a new slice to avoid mutating the caller's requiredScopes. requestScopes := make([]string, 0, len(requiredScopes)+len(localScopes)) requestScopes = append(requestScopes, requiredScopes...) requestScopes = append(requestScopes, localScopes...) config := oauth2.Config{ ClientID: oi.CLIClientID, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("https://%v/authorize", oi.Auth0Domain), TokenURL: fmt.Sprintf("https://%v/oauth/token", oi.Auth0Domain), DeviceAuthURL: fmt.Sprintf("https://%v/oauth/device/code", oi.Auth0Domain), }, Scopes: requestScopes, } deviceCode, err := config.DeviceAuth(ctx, oauth2.SetAuthURLParam("audience", oi.Audience), oauth2.AccessTypeOffline, ) if err != nil { return nil, fmt.Errorf("error getting device code: %w", err) } var urlToOpen string if deviceCode.VerificationURIComplete != "" { urlToOpen = deviceCode.VerificationURIComplete } else { urlToOpen = deviceCode.VerificationURI } _ = browser.OpenURL(urlToOpen) log.Info("Open this URL in your browser to authenticate", "url", deviceCode.VerificationURI, "code", deviceCode.UserCode) token, err := config.DeviceAccessToken(ctx, deviceCode) if err != nil { log.Error("Unable to authenticate. Please try again.", "error", err.Error()) return nil, fmt.Errorf("error getting device access token: %w", err) } if token == nil { log.Error("No token received") return nil, errors.New("no token received") } log.Info("Authenticated successfully") if home != "" { err = SaveLocalToken(home, app, token, log) if err != nil { log.Error("Error saving token", "error", err.Error()) } } return token, nil } // GetAPIKeyToken exchanges an Overmind API key (ovm_api_*) for a JWT token // via the ApiKeyService, then verifies the token has the required scopes. func GetAPIKeyToken(ctx context.Context, oi sdp.OvermindInstance, app, apiKey string, requiredScopes []string, log Logger) (*oauth2.Token, error) { if !strings.HasPrefix(apiKey, "ovm_api_") { return nil, errors.New("API key does not match pattern 'ovm_api_*'") } httpClient := tracing.HTTPClient() client := sdpconnect.NewApiKeyServiceClient(httpClient, oi.ApiUrl.String()) resp, err := client.ExchangeKeyForToken(ctx, &connect.Request[sdp.ExchangeKeyForTokenRequest]{ Msg: &sdp.ExchangeKeyForTokenRequest{ ApiKey: apiKey, }, }) if err != nil { return nil, fmt.Errorf("error authenticating the API token for %s: %w", app, err) } token := &oauth2.Token{ AccessToken: resp.Msg.GetAccessToken(), TokenType: "Bearer", } ok, missing, err := HasScopesFlexible(token, requiredScopes) if err != nil { return nil, fmt.Errorf("error checking token scopes for %s: %w", app, err) } if !ok { return nil, fmt.Errorf("authenticated successfully against %s, but your API key is missing this permission: '%v'", app, missing) } log.Info("Using Overmind API key", "app", app) return token, nil } // GetToken gets a token using either API key exchange (if apiKey is non-empty) // or the OAuth device flow. func GetToken(ctx context.Context, oi sdp.OvermindInstance, app, apiKey string, requiredScopes []string, log Logger) (*oauth2.Token, error) { if apiKey != "" { return GetAPIKeyToken(ctx, oi, app, apiKey, requiredScopes, log) } return GetOauthToken(ctx, oi, app, requiredScopes, log) } ================================================ FILE: go/cliauth/cliauth_test.go ================================================ package cliauth import ( "encoding/base64" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/overmindtech/cli/go/auth" "golang.org/x/oauth2" ) type mockLogger struct { infoMsgs []string errorMsgs []string } func (m *mockLogger) Info(msg string, keysAndValues ...any) { m.infoMsgs = append(m.infoMsgs, msg) } func (m *mockLogger) Error(msg string, keysAndValues ...any) { m.errorMsgs = append(m.errorMsgs, msg) } func TestExtractClaims(t *testing.T) { testToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6ImFkbWluOnJlYWQgYWRtaW46d3JpdGUiLCJzdWIiOiJ0ZXN0LXVzZXIiLCJpYXQiOjEyMzQ1Njc4OTAsImV4cCI6OTk5OTk5OTk5OX0.placeholder" claims, err := ExtractClaims(testToken) if err != nil { t.Fatalf("ExtractClaims failed: %v", err) } if claims.Scope != "admin:read admin:write" { t.Errorf("Expected scope 'admin:read admin:write', got '%s'", claims.Scope) } } func TestExtractClaimsInvalidJWT(t *testing.T) { _, err := ExtractClaims("not-a-jwt") if err == nil { t.Fatal("Expected error for non-JWT token, got nil") } } func TestExtractClaimsInvalidBase64(t *testing.T) { _, err := ExtractClaims("header.!!!invalid-base64!!!.sig") if err == nil { t.Fatal("Expected error for invalid base64, got nil") } } func TestHasScopesFlexible(t *testing.T) { tests := []struct { name string tokenScopes string requiredScopes []string expectOK bool expectMissing string }{ { name: "exact match", tokenScopes: "admin:read", requiredScopes: []string{"admin:read"}, expectOK: true, }, { name: "write satisfies read", tokenScopes: "admin:write", requiredScopes: []string{"admin:read"}, expectOK: true, }, { name: "missing scope", tokenScopes: "changes:read", requiredScopes: []string{"admin:read"}, expectOK: false, expectMissing: "admin:read", }, { name: "multiple scopes all present", tokenScopes: "admin:read changes:write", requiredScopes: []string{"admin:read", "changes:read"}, expectOK: true, }, { name: "read does not satisfy write", tokenScopes: "admin:read", requiredScopes: []string{"admin:write"}, expectOK: false, expectMissing: "admin:write", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testToken := &oauth2.Token{ AccessToken: createTestJWT(tt.tokenScopes), TokenType: "Bearer", } ok, missing, err := HasScopesFlexible(testToken, tt.requiredScopes) if err != nil { t.Fatalf("HasScopesFlexible failed: %v", err) } if ok != tt.expectOK { t.Errorf("Expected ok=%v, got %v", tt.expectOK, ok) } if !tt.expectOK && missing != tt.expectMissing { t.Errorf("Expected missing='%s', got '%s'", tt.expectMissing, missing) } }) } } func TestHasScopesFlexibleNilToken(t *testing.T) { _, _, err := HasScopesFlexible(nil, []string{"admin:read"}) if err == nil { t.Fatal("Expected error for nil token, got nil") } } func TestReadWriteLocalToken(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) log := &mockLogger{} app := "https://test.overmind.tech" token := &oauth2.Token{ AccessToken: createTestJWT("admin:read admin:write"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } err = SaveLocalToken(tmpDir, app, token, log) if err != nil { t.Fatalf("SaveLocalToken failed: %v", err) } tokenPath := filepath.Join(tmpDir, ".overmind", "token.json") if _, err := os.Stat(tokenPath); os.IsNotExist(err) { t.Fatalf("Token file was not created") } readToken, scopes, err := ReadLocalToken(tmpDir, app, []string{"admin:read"}, log) if err != nil { t.Fatalf("ReadLocalToken failed: %v", err) } if readToken.AccessToken != token.AccessToken { t.Errorf("Token mismatch") } if len(scopes) != 2 { t.Errorf("Expected 2 scopes, got %d", len(scopes)) } } func TestReadLocalTokenWrongApp(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) log := &mockLogger{} app := "https://test.overmind.tech" token := &oauth2.Token{ AccessToken: createTestJWT("admin:read"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } if err := SaveLocalToken(tmpDir, app, token, log); err != nil { t.Fatalf("SaveLocalToken failed: %v", err) } _, _, err = ReadLocalToken(tmpDir, "https://wrong.overmind.tech", []string{"admin:read"}, log) if err == nil { t.Errorf("Expected error for wrong app, got nil") } } func TestReadLocalTokenInsufficientScopes(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) log := &mockLogger{} app := "https://test.overmind.tech" token := &oauth2.Token{ AccessToken: createTestJWT("changes:read"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } if err := SaveLocalToken(tmpDir, app, token, log); err != nil { t.Fatalf("SaveLocalToken failed: %v", err) } _, _, err = ReadLocalToken(tmpDir, app, []string{"admin:read"}, log) if err == nil { t.Errorf("Expected error for insufficient scopes, got nil") } } func TestReadLocalTokenFileNotFound(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) log := &mockLogger{} _, _, err = ReadLocalToken(tmpDir, "https://test.overmind.tech", []string{"admin:read"}, log) if err == nil { t.Fatal("Expected error for missing file, got nil") } } func TestSaveLocalTokenSecurePermissions(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) log := &mockLogger{} token := &oauth2.Token{ AccessToken: createTestJWT("admin:read"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } if err := SaveLocalToken(tmpDir, "https://test.overmind.tech", token, log); err != nil { t.Fatalf("SaveLocalToken failed: %v", err) } dirInfo, err := os.Stat(filepath.Join(tmpDir, ".overmind")) if err != nil { t.Fatalf("Failed to stat directory: %v", err) } if dirInfo.Mode().Perm() != 0700 { t.Errorf("Expected directory permissions 0700, got %o", dirInfo.Mode().Perm()) } fileInfo, err := os.Stat(filepath.Join(tmpDir, ".overmind", "token.json")) if err != nil { t.Fatalf("Failed to stat token file: %v", err) } if fileInfo.Mode().Perm() != 0600 { t.Errorf("Expected file permissions 0600, got %o", fileInfo.Mode().Perm()) } } func TestSaveLocalTokenNilMap(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) tokenPath := filepath.Join(tmpDir, ".overmind", "token.json") if err := os.MkdirAll(filepath.Dir(tokenPath), 0700); err != nil { t.Fatalf("Failed to create directory: %v", err) } // Simulate a corrupt token file with null auth_entries if err := os.WriteFile(tokenPath, []byte(`{"auth_entries": null}`), 0600); err != nil { t.Fatalf("Failed to write token file: %v", err) } log := &mockLogger{} token := &oauth2.Token{ AccessToken: createTestJWT("admin:read"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } err = SaveLocalToken(tmpDir, "https://test.overmind.tech", token, log) if err != nil { t.Fatalf("SaveLocalToken failed with nil map: %v", err) } readToken, _, err := ReadLocalToken(tmpDir, "https://test.overmind.tech", []string{"admin:read"}, log) if err != nil { t.Fatalf("ReadLocalToken failed: %v", err) } if readToken.AccessToken != token.AccessToken { t.Errorf("Token mismatch after nil map save") } } func TestReadLocalTokenNilEntry(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) tokenPath := filepath.Join(tmpDir, ".overmind", "token.json") if err := os.MkdirAll(filepath.Dir(tokenPath), 0700); err != nil { t.Fatalf("Failed to create directory: %v", err) } if err := os.WriteFile(tokenPath, []byte(`{"auth_entries": {"https://test.overmind.tech": null}}`), 0600); err != nil { t.Fatalf("Failed to write token file: %v", err) } log := &mockLogger{} _, _, err = ReadLocalToken(tmpDir, "https://test.overmind.tech", []string{"admin:read"}, log) if err == nil { t.Fatal("Expected error for null token entry, got nil") } if !strings.Contains(err.Error(), "null") { t.Errorf("Expected error to mention 'null', got: %v", err) } } func TestReadLocalTokenNilToken(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) tokenPath := filepath.Join(tmpDir, ".overmind", "token.json") if err := os.MkdirAll(filepath.Dir(tokenPath), 0700); err != nil { t.Fatalf("Failed to create directory: %v", err) } if err := os.WriteFile(tokenPath, []byte(`{"auth_entries": {"https://test.overmind.tech": {"token": null, "added_date": "2024-01-01T00:00:00Z"}}}`), 0600); err != nil { t.Fatalf("Failed to write token file: %v", err) } log := &mockLogger{} _, _, err = ReadLocalToken(tmpDir, "https://test.overmind.tech", []string{"admin:read"}, log) if err == nil { t.Fatal("Expected error for null token, got nil") } if !strings.Contains(err.Error(), "null") { t.Errorf("Expected error to mention 'null', got: %v", err) } } func TestSaveLocalTokenOverwriteExisting(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) log := &mockLogger{} app := "https://test.overmind.tech" token1 := &oauth2.Token{ AccessToken: createTestJWT("admin:read"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } token2 := &oauth2.Token{ AccessToken: createTestJWT("admin:write"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } if err := SaveLocalToken(tmpDir, app, token1, log); err != nil { t.Fatalf("SaveLocalToken (first) failed: %v", err) } if err := SaveLocalToken(tmpDir, app, token2, log); err != nil { t.Fatalf("SaveLocalToken (second) failed: %v", err) } readToken, _, err := ReadLocalToken(tmpDir, app, []string{"admin:write"}, log) if err != nil { t.Fatalf("ReadLocalToken failed: %v", err) } if readToken.AccessToken != token2.AccessToken { t.Errorf("Expected second token, got first") } } func TestSaveLocalTokenMultipleApps(t *testing.T) { tmpDir, err := os.MkdirTemp("", "cliauth-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) log := &mockLogger{} app1 := "https://app.overmind.tech" app2 := "https://app.staging.overmind.tech" token1 := &oauth2.Token{ AccessToken: createTestJWT("admin:read"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } token2 := &oauth2.Token{ AccessToken: createTestJWT("admin:write"), TokenType: "Bearer", Expiry: time.Now().Add(1 * time.Hour), } if err := SaveLocalToken(tmpDir, app1, token1, log); err != nil { t.Fatalf("SaveLocalToken (app1) failed: %v", err) } if err := SaveLocalToken(tmpDir, app2, token2, log); err != nil { t.Fatalf("SaveLocalToken (app2) failed: %v", err) } read1, _, err := ReadLocalToken(tmpDir, app1, []string{"admin:read"}, log) if err != nil { t.Fatalf("ReadLocalToken (app1) failed: %v", err) } if read1.AccessToken != token1.AccessToken { t.Errorf("App1 token mismatch") } read2, _, err := ReadLocalToken(tmpDir, app2, []string{"admin:write"}, log) if err != nil { t.Fatalf("ReadLocalToken (app2) failed: %v", err) } if read2.AccessToken != token2.AccessToken { t.Errorf("App2 token mismatch") } } func TestNoSliceMutationInScopeMerge(t *testing.T) { // Verify the pattern used in GetOauthToken doesn't mutate caller slices requiredScopes := make([]string, 1, 10) // extra capacity — the mutation scenario requiredScopes[0] = "admin:read" originalLen := len(requiredScopes) localScopes := []string{"changes:read", "config:read"} // This is the safe pattern used in GetOauthToken requestScopes := make([]string, 0, len(requiredScopes)+len(localScopes)) requestScopes = append(requestScopes, requiredScopes...) requestScopes = append(requestScopes, localScopes...) if len(requiredScopes) != originalLen { t.Errorf("Original slice length changed from %d to %d", originalLen, len(requiredScopes)) } if len(requestScopes) != 3 { t.Errorf("Expected 3 scopes in combined slice, got %d", len(requestScopes)) } } func TestConfirmUntrustedHost_TrustedSkipsPrompt(t *testing.T) { trustedURLs := []string{ "https://app.overmind.tech", "https://df.overmind-demo.com", "http://localhost:3000", "http://127.0.0.1:8080", } for _, u := range trustedURLs { t.Run(u, func(t *testing.T) { err := ConfirmUntrustedHost(u, false, strings.NewReader(""), io.Discard) if err != nil { t.Errorf("Expected no prompt for trusted URL %q, got error: %v", u, err) } }) } } func TestConfirmUntrustedHost_UntrustedPrompts(t *testing.T) { tests := []struct { name string url string input string wantError bool errMsg string }{ { name: "user confirms with y", url: "https://custom.example.com", input: "y\n", }, { name: "user confirms with yes", url: "https://custom.example.com", input: "yes\n", }, { name: "user confirms with YES (case insensitive)", url: "https://custom.example.com", input: "YES\n", }, { name: "user declines with n", url: "https://custom.example.com", input: "n\n", wantError: true, errMsg: "aborted", }, { name: "user declines with empty (default N)", url: "https://custom.example.com", input: "\n", wantError: true, errMsg: "aborted", }, { name: "user types something else", url: "https://custom.example.com", input: "maybe\n", wantError: true, errMsg: "aborted", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ConfirmUntrustedHost(tt.url, false, strings.NewReader(tt.input), io.Discard) if tt.wantError { if err == nil { t.Fatal("Expected error, got nil") } if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("Expected error containing %q, got: %v", tt.errMsg, err) } } else { if err != nil { t.Fatalf("Unexpected error: %v", err) } } }) } } func TestConfirmUntrustedHost_PipedInputWithoutNewline(t *testing.T) { // Simulates: echo -n y | area51 export-archive --change https://custom.example.com/changes/UUID err := ConfirmUntrustedHost("https://custom.example.com", false, strings.NewReader("y"), io.Discard) if err != nil { t.Fatalf("Expected piped 'y' without newline to be accepted, got error: %v", err) } err = ConfirmUntrustedHost("https://custom.example.com", false, strings.NewReader("n"), io.Discard) if err == nil { t.Fatal("Expected piped 'n' without newline to be rejected") } err = ConfirmUntrustedHost("https://custom.example.com", false, strings.NewReader(""), io.Discard) if err == nil { t.Fatal("Expected empty piped input to be rejected") } } func TestConfirmUntrustedHost_WarningMentionsAPIKey(t *testing.T) { var buf strings.Builder _ = ConfirmUntrustedHost("https://custom.example.com", true, strings.NewReader("n\n"), &buf) output := buf.String() if !strings.Contains(output, "API key") { t.Errorf("Expected warning to mention API key when hasAPIKey=true, got: %s", output) } } // createTestJWT creates a minimal JWT token for testing (no signature verification) func createTestJWT(scopes string) string { header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" payload := auth.CustomClaims{ Scope: scopes, } payloadJSON, err := json.Marshal(payload) if err != nil { panic(fmt.Sprintf("failed to marshal test payload: %v", err)) } payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) return header + "." + payloadB64 + ".test-signature" } ================================================ FILE: go/discovery/adapter.go ================================================ package discovery import ( "context" "slices" "sync" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) // Adapter is capable of finding information about items // // Adapters must implement all of the methods to satisfy this interface in order // to be able to used as an SDP adapter. Note that the `context.Context` value // that is passed to the Get(), List() and Search() (optional) methods needs to // handled by each adapter individually. Adapter authors should make an effort // ensure that expensive operations that the adapter undertakes can be cancelled // if the context `ctx` is cancelled type Adapter interface { // Type The type of items that this adapter is capable of finding Type() string // Descriptive name for the adapter, used in logging and metadata Name() string // List of scopes that this adapter is capable of find items for. If the // adapter supports all scopes the special value "*" // should be used Scopes() []string // Get Get a single item with a given scope and query. The item returned // should have a UniqueAttributeValue that matches the `query` parameter. Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) // A struct that contains information about the adapter, it is used by the api-server to determine the capabilities of the adapter // It is mandatory for all adapters to implement this method Metadata() *sdp.AdapterMetadata } // An adapter that support the List method. This was previously part of the // Adapter interface however it was split out to allow for the transition to // streaming responses type ListableAdapter interface { Adapter // List Lists all items in a given scope List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) } // ListStreamableAdapter supports streaming for the List queries. type ListStreamableAdapter interface { Adapter ListStream(ctx context.Context, scope string, ignoreCache bool, stream QueryResultStream) } // SearchStreamableAdapter supports streaming for the Search queries. type SearchStreamableAdapter interface { Adapter SearchStream(ctx context.Context, scope string, query string, ignoreCache bool, stream QueryResultStream) } // CachingAdapter Is an adapter of items that supports caching type CachingAdapter interface { Adapter Cache() sdpcache.Cache } // SearchableAdapter Is an adapter of items that supports searching type SearchableAdapter interface { Adapter // Search executes a specific search and returns zero or many items as a // result (and optionally an error). The specific format of the query that // needs to be provided to Search is dependant on the adapter itself as each // adapter will respond to searches differently Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) } // HiddenAdapter adapters that define a `Hidden()` method are able to tell whether // or not the items they produce should be marked as hidden within the metadata. // Hidden items will not be shown in GUIs or stored in databases and are used // for gathering data as part of other processes such as remotely executed // secondary adapters type HiddenAdapter interface { Hidden() bool } // WildcardScopeAdapter is an optional interface that adapters can implement // to declare they can handle "*" wildcard scopes efficiently for LIST queries // (e.g., using GCP's aggregatedList API). When an adapter implements this // interface and returns true from SupportsWildcardScope(), the engine will // pass wildcard scopes directly to the adapter instead of expanding them to // all configured scopes—but only for LIST queries. // // For GET and SEARCH, the engine always expands wildcard scope so that // multiple results can be returned when a resource exists in multiple scopes. // Future work may extend this optimization to SEARCH once adapters support it. type WildcardScopeAdapter interface { Adapter SupportsWildcardScope() bool } // QueryResultStream is a stream of items and errors that are returned from a // query. Adapters should send items to the stream as soon as they are // discovered using the `SendItem` method and should send any errors that occur // using the `SendError` method. These errors will be considered non-fatal. If // the process encounters a fatal error it should return an error to the caller // rather then sending one on the stream. // // Note that this interface does not have a `Close()` method. Clients of this // interface are specific functions that get passed in an instance implementing // this interface. The expectation is that those clients do not return until all // calls into the stream have finished. type QueryResultStream interface { // SendItem sends an item to the stream. This method is thread-safe, but the // ordering vs SendError is only guaranteed for non-overlapping calls. SendItem(item *sdp.Item) // SendError sends an Error to the stream. This method is thread-safe, but // the ordering vs SendItem is only guaranteed for non-overlapping calls. SendError(err error) } // QueryResultStream is a stream of items and errors that are returned from a // query. Adapters should send items to the stream as soon as they are // discovered using the `SendItem` method and should send any errors that occur // using the `SendError` method. These errors will be considered non-fatal. If // the process encounters a fatal error it should return an error to the caller // rather then sending one on the stream type QueryResultStreamWithHandlers struct { itemHandler ItemHandler errHandler ErrHandler } // assert interface implementation var _ QueryResultStream = (*QueryResultStreamWithHandlers)(nil) // ItemHandler is a function that can be used to handle items as they are // received from a QueryResultStream type ItemHandler func(item *sdp.Item) // ErrHandler is a function that can be used to handle errors as they are // received from a QueryResultStream type ErrHandler func(err error) // NewQueryResultStream creates a new QueryResultStream that calls the provided // handlers when items and errors are received. Note that the handlers are // called asynchronously and need to provide for their own thread safety. func NewQueryResultStream(itemHandler ItemHandler, errHandler ErrHandler) *QueryResultStreamWithHandlers { stream := &QueryResultStreamWithHandlers{ itemHandler: itemHandler, errHandler: errHandler, } return stream } // SendItem sends an item to the stream func (qrs *QueryResultStreamWithHandlers) SendItem(item *sdp.Item) { qrs.itemHandler(item) } // SendError sends an error to the stream func (qrs *QueryResultStreamWithHandlers) SendError(err error) { qrs.errHandler(err) } type RecordingQueryResultStream struct { streamMu sync.Mutex items []*sdp.Item errs []error } // assert interface implementation var _ QueryResultStream = (*RecordingQueryResultStream)(nil) func NewRecordingQueryResultStream() *RecordingQueryResultStream { return &RecordingQueryResultStream{ items: []*sdp.Item{}, errs: []error{}, } } func (r *RecordingQueryResultStream) SendItem(item *sdp.Item) { r.streamMu.Lock() defer r.streamMu.Unlock() r.items = append(r.items, item) } func (r *RecordingQueryResultStream) GetItems() []*sdp.Item { r.streamMu.Lock() defer r.streamMu.Unlock() return slices.Clone(r.items) } func (r *RecordingQueryResultStream) SendError(err error) { r.streamMu.Lock() defer r.streamMu.Unlock() r.errs = append(r.errs, err) } func (r *RecordingQueryResultStream) GetErrors() []error { r.streamMu.Lock() defer r.streamMu.Unlock() return slices.Clone(r.errs) } ================================================ FILE: go/discovery/adapter_test.go ================================================ package discovery import ( "context" "errors" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestEngineAddAdapters(t *testing.T) { ec := EngineConfig{} e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } adapter := TestAdapter{} if err := e.AddAdapters(&adapter); err != nil { t.Fatalf("Error adding adapter: %v", err) } if x := len(e.sh.Adapters()); x != 1 { t.Fatalf("Expected 1 adapters, got %v", x) } } func TestGet(t *testing.T) { adapter := TestAdapter{ ReturnName: "orange", ReturnType: "person", ReturnScopes: []string{ "test", "empty", }, cache: sdpcache.NewMemoryCache(), } e := newStartedEngine(t, "TestGet", nil, nil, &adapter) t.Run("Basic test", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) _, _, _, err := e.executeQuerySync(context.Background(), &sdp.Query{ Type: "person", Scope: "test", Query: "three", Method: sdp.QueryMethod_GET, }) if err != nil { t.Fatal(err) } if x := len(adapter.GetCalls); x != 1 { t.Fatalf("Expected 1 get call, got %v", x) } firstCall := adapter.GetCalls[0] if firstCall[0] != "test" || firstCall[1] != "three" { t.Fatalf("First get call parameters unexpected: %v", firstCall) } }) t.Run("not found error", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) items, edges, errs, err := e.executeQuerySync(context.Background(), &sdp.Query{ Type: "person", Scope: "empty", Query: "three", Method: sdp.QueryMethod_GET, }) if err != nil { t.Fatal(err) } if len(errs) == 1 { if errs[0].GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected ErrorType to be %v, got %v", sdp.QueryError_NOTFOUND, errs[0].GetErrorType()) } if errs[0].GetErrorString() != "no items found" { t.Errorf("expected ErrorString to be '%v', got '%v'", "no items found", errs[0].GetErrorString()) } if errs[0].GetScope() != "empty" { t.Errorf("expected Scope to be '%v', got '%v'", "empty", errs[0].GetScope()) } if errs[0].GetSourceName() != "testAdapter-orange" { t.Errorf("expected Adapter name to be '%v', got '%v'", "testAdapter-orange", errs[0].GetSourceName()) } if errs[0].GetItemType() != "person" { t.Errorf("expected ItemType to be '%v', got '%v'", "person", errs[0].GetItemType()) } if errs[0].GetResponderName() != "TestGet" { t.Errorf("expected ResponderName to be '%v', got '%v'", "TestGet", errs[0].GetResponderName()) } } else { t.Errorf("expected 1 error, got %v", len(errs)) } if len(items) != 0 { t.Errorf("expected 0 items, got %v: %v", len(items), items) } if len(edges) != 0 { t.Errorf("expected 0 edges, got %v: %v", len(edges), edges) } }) t.Run("Test caching", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) var list1 []*sdp.Item var item2 []*sdp.Item var item3 []*sdp.Item var err error req := sdp.Query{ Type: "person", Scope: "test", Query: "Dylan", Method: sdp.QueryMethod_GET, } list1, _, _, err = e.executeQuerySync(context.Background(), &req) if err != nil { t.Error(err) } time.Sleep(10 * time.Millisecond) item2, _, _, err = e.executeQuerySync(context.Background(), &req) if err != nil { t.Error(err) } if list1[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() != item2[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() { t.Errorf("Get queries 10ms apart had different timestamps, caching not working. %v != %v", list1[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue(), item2[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue()) } time.Sleep(10 * time.Millisecond) item3, _, _, err = e.executeQuerySync(context.Background(), &req) if err != nil { t.Error(err) } if item2[0].GetMetadata().GetTimestamp().String() == item3[0].GetMetadata().GetTimestamp().String() { t.Error("Get queries after purging had the same timestamps, cache not expiring") } }) t.Run("Test Get() caching errors", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) req := sdp.Query{ Type: "person", Scope: "empty", Query: "query", Method: sdp.QueryMethod_GET, } _, _, errs, err := e.executeQuerySync(context.Background(), &req) if err != nil { t.Fatal(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } _, _, errs, err = e.executeQuerySync(context.Background(), &req) if err != nil { t.Fatal(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } if l := len(adapter.GetCalls); l != 1 { t.Errorf("Expected 1 Get call due to caching og NOTFOUND errors, got %v", l) } }) t.Run("Hidden items", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) adapter.IsHidden = true t.Run("Get", func(t *testing.T) { item, _, _, err := e.executeQuerySync(context.Background(), &sdp.Query{ Type: "person", Scope: "test", Query: "three", Method: sdp.QueryMethod_GET, }) if err != nil { t.Fatal(err) } if !item[0].GetMetadata().GetHidden() { t.Fatal("Item was not marked as hidden in metadata") } }) t.Run("List", func(t *testing.T) { items, _, _, err := e.executeQuerySync(context.Background(), &sdp.Query{ Type: "person", Scope: "test", Method: sdp.QueryMethod_LIST, }) if err != nil { t.Fatal(err) } if !items[0].GetMetadata().GetHidden() { t.Fatal("Item was not marked as hidden in metadata") } }) t.Run("Search", func(t *testing.T) { items, _, _, err := e.executeQuerySync(context.Background(), &sdp.Query{ Type: "person", Scope: "test", Query: "three", Method: sdp.QueryMethod_SEARCH, }) if err != nil { t.Fatal(err) } if !items[0].GetMetadata().GetHidden() { t.Fatal("Item was not marked as hidden in metadata") } }) }) } func TestList(t *testing.T) { adapter := TestAdapter{} adapter.cache = sdpcache.NewMemoryCache() e := newStartedEngine(t, "TestList", nil, nil, &adapter) _, _, _, err := e.executeQuerySync(context.Background(), &sdp.Query{ Type: "person", Scope: "test", Method: sdp.QueryMethod_LIST, }) if err != nil { t.Fatal(err) } if x := len(adapter.ListCalls); x != 1 { t.Fatalf("Expected 1 find call, got %v", x) } firstCall := adapter.ListCalls[0] if firstCall[0] != "test" { t.Fatalf("First find call parameters unexpected: %v", firstCall) } } func TestSearch(t *testing.T) { adapter := TestAdapter{} adapter.cache = sdpcache.NewMemoryCache() e := newStartedEngine(t, "TestSearch", nil, nil, &adapter) _, _, _, err := e.executeQuerySync(context.Background(), &sdp.Query{ Type: "person", Scope: "test", Query: "query", Method: sdp.QueryMethod_SEARCH, }) if err != nil { t.Fatal(err) } if x := len(adapter.SearchCalls); x != 1 { t.Fatalf("Expected 1 Search call, got %v", x) } firstCall := adapter.SearchCalls[0] if firstCall[0] != "test" || firstCall[1] != "query" { t.Fatalf("First Search call parameters unexpected: %v", firstCall) } } func TestListSearchCaching(t *testing.T) { adapter := TestAdapter{ ReturnScopes: []string{ "test", "empty", "error", }, cache: sdpcache.NewMemoryCache(), } e := newStartedEngine(t, "TestListSearchCaching", nil, nil, &adapter) t.Run("caching with successful list", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) var list1 []*sdp.Item var list2 []*sdp.Item var list3 []*sdp.Item var err error q := sdp.Query{ Type: "person", Scope: "test", Method: sdp.QueryMethod_LIST, } list1, _, _, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } time.Sleep(10 * time.Millisecond) list2, _, _, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Fatal(err) } if list1[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() != list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() { t.Errorf("List queries had different generations, caching not working. %v != %v", list1[0].GetAttributes().GetAttrStruct().GetFields()["generation"], list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"]) } time.Sleep(10 * time.Millisecond) list3, _, _, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Fatal(err) } if list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"] == list3[0].GetAttributes().GetAttrStruct().GetFields()["generation"] { t.Errorf("List queries after purging had the same generation, caching not working. %v == %v", list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"], list3[0].GetAttributes().GetAttrStruct().GetFields()["generation"]) } }) t.Run("empty list", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) var err error q := sdp.Query{ Type: "person", Scope: "empty", Method: sdp.QueryMethod_LIST, } _, _, errs, err := e.executeQuerySync(context.Background(), &q) if err != nil { t.Fatal(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } time.Sleep(10 * time.Millisecond) _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Fatal(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } if l := len(adapter.ListCalls); l != 1 { t.Errorf("Expected only 1 list call, got %v, cache not working: %v", l, adapter.ListCalls) } time.Sleep(200 * time.Millisecond) _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Fatal(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } if l := len(adapter.ListCalls); l != 2 { t.Errorf("Expected 2 list calls, got %v, cache not clearing: %v", l, adapter.ListCalls) } }) t.Run("caching with successful search", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) var list1 []*sdp.Item var list2 []*sdp.Item var list3 []*sdp.Item var err error q := sdp.Query{ Type: "person", Scope: "test", Query: "query", Method: sdp.QueryMethod_SEARCH, } list1, _, _, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } time.Sleep(10 * time.Millisecond) list2, _, _, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if list1[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() != list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() { t.Errorf("List queries had different generations, caching not working. %v != %v", list1[0].GetAttributes().GetAttrStruct().GetFields()["generation"], list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"]) } time.Sleep(200 * time.Millisecond) list3, _, _, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() == list3[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() { t.Errorf("List queries 200ms apart had the same generations, caching not working. %v == %v", list2[0].GetAttributes().GetAttrStruct().GetFields()["generation"], list3[0].GetAttributes().GetAttrStruct().GetFields()["generation"]) } }) t.Run("empty search", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) var err error q := sdp.Query{ Type: "person", Scope: "empty", Query: "query", Method: sdp.QueryMethod_SEARCH, } _, _, errs, err := e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } time.Sleep(10 * time.Millisecond) _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } time.Sleep(200 * time.Millisecond) _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } if l := len(adapter.SearchCalls); l != 2 { t.Errorf("Expected 2 find calls, got %v, cache not clearing", l) } }) t.Run("non-caching of OTHER errors", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) q := sdp.Query{ Type: "person", Scope: "error", Query: "query", Method: sdp.QueryMethod_GET, } _, _, errs, err := e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } if l := len(adapter.GetCalls); l != 2 { t.Errorf("Expected 2 get calls, got %v, OTHER errors should not be cached", l) } }) t.Run("non-caching when ignoreCache is specified", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) q := sdp.Query{ Type: "person", Scope: "error", Query: "query", Method: sdp.QueryMethod_GET, } _, _, errs, err := e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } q.Method = sdp.QueryMethod_LIST _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } q.Method = sdp.QueryMethod_SEARCH _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } _, _, errs, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(errs) != 1 { t.Fatalf("Expected 1 error, got %v", len(errs)) } if l := len(adapter.GetCalls); l != 2 { t.Errorf("Expected 2 get calls, got %v", l) } if l := len(adapter.ListCalls); l != 2 { t.Errorf("Expected 2 List calls, got %v", l) } if l := len(adapter.SearchCalls); l != 2 { t.Errorf("Expected 2 Search calls, got %v", l) } }) } func TestSearchGetCaching(t *testing.T) { // We want to be sure that if an item has been found via a search and // cached, the cache will be hit if a Get is run for that particular item adapter := TestAdapter{ ReturnScopes: []string{ "test", }, cache: sdpcache.NewMemoryCache(), } e := newStartedEngine(t, "TestSearchGetCaching", nil, nil, &adapter) t.Run("caching with successful search", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) var searchResult []*sdp.Item var searchErrors []*sdp.QueryError var getResult []*sdp.Item var getErrors []*sdp.QueryError var err error q := sdp.Query{ Type: "person", Scope: "test", Query: "Dylan", Method: sdp.QueryMethod_SEARCH, } t.Logf("Searching for %v", q.GetQuery()) searchResult, _, searchErrors, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(searchErrors) != 0 { for _, err := range searchErrors { t.Error(err) } } if len(searchResult) == 0 { t.Fatal("Got no results") } if len(searchResult) > 1 { t.Fatalf("Got too many results: %v", searchResult) } time.Sleep(10 * time.Millisecond) // Do a get query for that same item q.Method = sdp.QueryMethod_GET q.Query = searchResult[0].UniqueAttributeValue() t.Logf("Getting %v from cache", q.GetQuery()) getResult, _, getErrors, err = e.executeQuerySync(context.Background(), &q) if err != nil { t.Error(err) } if len(getErrors) != 0 { for _, err := range getErrors { t.Error(err) } } if len(getResult) == 0 { t.Error("No result from GET") } if searchResult[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() != getResult[0].GetAttributes().GetAttrStruct().GetFields()["generation"].GetNumberValue() { t.Errorf("Search and Get queries had different generations, caching not working. %v != %v", searchResult[0].GetAttributes().GetAttrStruct().GetFields()["generation"], getResult[0].GetAttributes().GetAttrStruct().GetFields()["generation"]) } }) } func TestNewQueryResultStream(t *testing.T) { items := make(chan *sdp.Item, 10) errs := make(chan error, 10) itemHandler := func(item *sdp.Item) { time.Sleep(10 * time.Millisecond) items <- item } errHandler := func(err error) { time.Sleep(10 * time.Millisecond) errs <- err } stream := NewQueryResultStream(itemHandler, errHandler) // Test Initialization if stream == nil { t.Fatal("Expected stream to be initialized, got nil") return } if stream.itemHandler == nil || stream.errHandler == nil { t.Fatal("Expected handlers to be set") } // Test SendItem testItem := &sdp.Item{} stream.SendItem(testItem) // Due to the fact that the handlers are executed in a goroutine it // essentially gives us a buffered channel with a buffer depth of 1 since // the item can be pulled off the internal items channel immediately then // wait on the handler in parallel. That's what allows this test to work // without extra synchronization if x := <-items; x != testItem { t.Fatalf("Expected item to be %v, got %v", testItem, x) } // Test SendError testErr := errors.New("test error") stream.SendError(testErr) if x := <-errs; x.Error() != testErr.Error() { t.Fatalf("Expected error to be %v, got %v", testErr, x) } } ================================================ FILE: go/discovery/adapterhost.go ================================================ package discovery import ( "errors" "fmt" "strings" "sync" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/proto" ) // AdapterHost This struct holds references to all Adapters in a process // and provides utility functions to work with them. Methods of this // struct are safe to call concurrently. type AdapterHost struct { // Map of types to all adapters for that type adapters []Adapter // Index for O(1) duplicate detection: map[type]map[scope]exists adapterIndex map[string]map[string]bool mutex sync.RWMutex } func NewAdapterHost() *AdapterHost { sh := &AdapterHost{ adapters: make([]Adapter, 0), adapterIndex: make(map[string]map[string]bool), } return sh } var ErrAdapterAlreadyExists = errors.New("adapter already exists") // AddAdapters Adds an adapter to this engine func (sh *AdapterHost) AddAdapters(adapters ...Adapter) error { sh.mutex.Lock() defer sh.mutex.Unlock() for _, newAdapter := range adapters { newType := newAdapter.Type() newScopes := newAdapter.Scopes() // Check for overlapping scopes using O(1) index lookup instead of O(n) scan if scopeMap, exists := sh.adapterIndex[newType]; exists { for _, newScope := range newScopes { if scopeMap[newScope] { log.Errorf("Error: Adapter with type %s and overlapping scope %s already exists", newType, newScope) return fmt.Errorf("adapter with type %s and overlapping scopes already exists", newType) } } } // Add to index if sh.adapterIndex[newType] == nil { sh.adapterIndex[newType] = make(map[string]bool) } for _, scope := range newScopes { sh.adapterIndex[newType][scope] = true } // Add to adapters list sh.adapters = append(sh.adapters, newAdapter) } return nil } // Adapters Returns a slice of all known adapters func (sh *AdapterHost) Adapters() []Adapter { sh.mutex.RLock() defer sh.mutex.RUnlock() adapters := make([]Adapter, 0) adapters = append(adapters, sh.adapters...) return adapters } // VisibleAdapters Returns a slice of all known adapters excluding hidden ones func (sh *AdapterHost) VisibleAdapters() []Adapter { allAdapters := sh.Adapters() result := make([]Adapter, 0) // Add all adapters unless they are hidden for _, adapter := range allAdapters { if hs, ok := adapter.(HiddenAdapter); ok { if hs.Hidden() { // If the adapter is hidden, continue without adding it continue } } result = append(result, adapter) } return result } // AdapterByType Returns the adapters for a given type func (sh *AdapterHost) AdaptersByType(typ string) []Adapter { sh.mutex.RLock() defer sh.mutex.RUnlock() adapters := make([]Adapter, 0) for _, adapter := range sh.adapters { if adapter.Type() == typ { adapters = append(adapters, adapter) } } return adapters } // ExpandQuery Expands queries with wildcards to no longer contain wildcards. // Meaning that if we support 5 types, and a query comes in with a wildcard // type, this function will expand that query into 5 queries, one for each // type. // // The same goes for scopes, if we have a query with a wildcard scope, and // a single adapter that supports 5 scopes, we will end up with 5 queries. The // exception to this is if we have an adapter that supports all scopes // (implements WildcardScopeAdapter) and the query method is LIST. In that // case we pass the wildcard scope directly to the adapter. For GET and // SEARCH, we always expand so multiple results can be returned. // // This functions returns a map of queries with the adapters that they should be // run against func (sh *AdapterHost) ExpandQuery(q *sdp.Query) map[*sdp.Query]Adapter { var checkAdapters []Adapter if IsWildcard(q.GetType()) { // If the query has a wildcard type, all non-hidden adapters might try // to respond checkAdapters = sh.VisibleAdapters() } else { // If the type is specific, pull just adapters for that type checkAdapters = append(checkAdapters, sh.AdaptersByType(q.GetType())...) } expandedQueries := make(map[*sdp.Query]Adapter) for _, adapter := range checkAdapters { // is the adapter is hidden isHidden := false if hs, ok := adapter.(HiddenAdapter); ok { isHidden = hs.Hidden() } // Check if adapter supports wildcard scopes supportsWildcard := false if ws, ok := adapter.(WildcardScopeAdapter); ok { supportsWildcard = ws.SupportsWildcardScope() } // If query has wildcard scope and adapter supports wildcards, // create ONE query with wildcard scope (no expansion). // Only for LIST: GET and SEARCH must expand so we can return // multiple results when a resource exists in multiple scopes. if supportsWildcard && IsWildcard(q.GetScope()) && !isHidden && q.GetMethod() == sdp.QueryMethod_LIST { dest := proto.Clone(q).(*sdp.Query) dest.Type = adapter.Type() // specialise the query to the adapter type expandedQueries[dest] = adapter continue // Skip normal scope expansion loop } for _, adapterScope := range adapter.Scopes() { // Create a new query if: // // * The adapter supports all scopes, or // * The query scope is a wildcard (and the adapter is not hidden), or // * The query scope substring matches adapter scope if IsWildcard(adapterScope) || (IsWildcard(q.GetScope()) && !isHidden) || strings.Contains(adapterScope, q.GetScope()) { dest := proto.Clone(q).(*sdp.Query) dest.Type = adapter.Type() // Choose the more specific scope if IsWildcard(adapterScope) { dest.Scope = q.GetScope() } else { dest.Scope = adapterScope } expandedQueries[dest] = adapter } } } return expandedQueries } // ClearAllAdapters Removes all adapters from the engine func (sh *AdapterHost) ClearAllAdapters() { sh.mutex.Lock() sh.adapters = make([]Adapter, 0) sh.adapterIndex = make(map[string]map[string]bool) sh.mutex.Unlock() } ================================================ FILE: go/discovery/adapterhost_bench_test.go ================================================ package discovery import ( "context" "fmt" "os" "runtime" "runtime/pprof" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/sourcegraph/conc/pool" ) // BenchmarkAddAdapters_GCPScenario simulates the real-world GCP organization scenario // where we have many projects, regions, and zones creating thousands of adapters func BenchmarkAddAdapters_GCPScenario(b *testing.B) { scenarios := []struct { name string projects int regions int zones int adapterTypes int // Simplified: different adapter types per scope level }{ {"Small_5proj", 5, 5, 10, 20}, {"Medium_23proj", 23, 35, 135, 88}, // Current failing scenario {"Large_100proj", 100, 35, 135, 88}, // Enterprise scenario {"VeryLarge_500proj", 500, 35, 135, 88}, // Large enterprise } for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { b.ResetTimer() for range b.N { b.StopTimer() adapters := generateGCPLikeAdapters(sc.projects, sc.regions, sc.zones, sc.adapterTypes) sh := NewAdapterHost() b.StartTimer() start := time.Now() err := sh.AddAdapters(adapters...) elapsed := time.Since(start) b.StopTimer() if err != nil { b.Fatalf("Failed to add adapters: %v", err) } totalAdapters := len(adapters) b.ReportMetric(float64(totalAdapters), "adapters") b.ReportMetric(elapsed.Seconds(), "seconds") b.ReportMetric(float64(totalAdapters)/elapsed.Seconds(), "adapters/sec") } }) } } // BenchmarkAddAdapters_Scaling tests at different scales to demonstrate O(n²) behavior func BenchmarkAddAdapters_Scaling(b *testing.B) { sizes := []int{100, 500, 1000, 5000, 10000, 25000} for _, size := range sizes { b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) { b.ResetTimer() for range b.N { b.StopTimer() adapters := generateSimpleAdapters(size) sh := NewAdapterHost() b.StartTimer() start := time.Now() err := sh.AddAdapters(adapters...) elapsed := time.Since(start) b.StopTimer() if err != nil { b.Fatalf("Failed to add adapters: %v", err) } b.ReportMetric(elapsed.Seconds(), "seconds") b.ReportMetric(float64(size)/elapsed.Seconds(), "adapters/sec") } }) } } // BenchmarkAddAdapters_IncrementalAdd simulates adding adapters one project at a time // This is closer to how it might be used in practice func BenchmarkAddAdapters_IncrementalAdd(b *testing.B) { projects := 100 regionsPerProject := 35 zonesPerProject := 135 typesPerScope := 30 b.ResetTimer() for range b.N { b.StopTimer() sh := NewAdapterHost() b.StartTimer() start := time.Now() // Add adapters project by project (like we do in the real code) for p := range projects { projectAdapters := generateProjectAdapters(p, regionsPerProject, zonesPerProject, typesPerScope) err := sh.AddAdapters(projectAdapters...) if err != nil { b.Fatalf("Failed to add adapters for project %d: %v", p, err) } } elapsed := time.Since(start) b.StopTimer() totalAdapters := len(sh.Adapters()) b.ReportMetric(float64(totalAdapters), "total_adapters") b.ReportMetric(elapsed.Seconds(), "seconds") b.ReportMetric(float64(totalAdapters)/elapsed.Seconds(), "adapters/sec") } } // generateGCPLikeAdapters creates adapters that mimic the GCP source structure: // - Project-level adapters (one per project per type) // - Regional adapters (one per project per region per type) // - Zonal adapters (one per project per zone per type) func generateGCPLikeAdapters(projects, regions, zones, typesPerScope int) []Adapter { projectTypes := typesPerScope / 3 regionalTypes := typesPerScope / 3 zonalTypes := typesPerScope / 3 totalAdapters := (projects * projectTypes) + (projects * regions * regionalTypes) + (projects * zones * zonalTypes) adapters := make([]Adapter, 0, totalAdapters) for p := range projects { projectID := fmt.Sprintf("project-%d", p) // Project-level adapters for t := range projectTypes { adapters = append(adapters, &TestAdapter{ ReturnScopes: []string{projectID}, ReturnType: fmt.Sprintf("gcp-project-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", projectID, t), }) } // Regional adapters for r := range regions { scope := fmt.Sprintf("%s.region-%d", projectID, r) for t := range regionalTypes { adapters = append(adapters, &TestAdapter{ ReturnScopes: []string{scope}, ReturnType: fmt.Sprintf("gcp-regional-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", scope, t), }) } } // Zonal adapters for z := range zones { scope := fmt.Sprintf("%s.zone-%d", projectID, z) for t := range zonalTypes { adapters = append(adapters, &TestAdapter{ ReturnScopes: []string{scope}, ReturnType: fmt.Sprintf("gcp-zonal-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", scope, t), }) } } } return adapters } // generateProjectAdapters creates all adapters for a single project func generateProjectAdapters(projectNum, regions, zones, typesPerScope int) []Adapter { projectTypes := typesPerScope / 3 regionalTypes := typesPerScope / 3 zonalTypes := typesPerScope / 3 totalAdapters := projectTypes + (regions * regionalTypes) + (zones * zonalTypes) adapters := make([]Adapter, 0, totalAdapters) projectID := fmt.Sprintf("project-%d", projectNum) // Project-level adapters for t := range projectTypes { adapters = append(adapters, &TestAdapter{ ReturnScopes: []string{projectID}, ReturnType: fmt.Sprintf("gcp-project-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", projectID, t), }) } // Regional adapters for r := range regions { scope := fmt.Sprintf("%s.region-%d", projectID, r) for t := range regionalTypes { adapters = append(adapters, &TestAdapter{ ReturnScopes: []string{scope}, ReturnType: fmt.Sprintf("gcp-regional-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", scope, t), }) } } // Zonal adapters for z := range zones { scope := fmt.Sprintf("%s.zone-%d", projectID, z) for t := range zonalTypes { adapters = append(adapters, &TestAdapter{ ReturnScopes: []string{scope}, ReturnType: fmt.Sprintf("gcp-zonal-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", scope, t), }) } } return adapters } // generateSimpleAdapters creates n unique adapters for simple scaling tests func generateSimpleAdapters(n int) []Adapter { adapters := make([]Adapter, 0, n) for i := range n { adapters = append(adapters, &TestAdapter{ ReturnScopes: []string{fmt.Sprintf("scope-%d", i)}, ReturnType: fmt.Sprintf("type-%d", i%100), // Reuse 100 types ReturnName: fmt.Sprintf("adapter-%d", i), }) } return adapters } // BenchmarkListAdapter is a test adapter that returns 10 items per LIST query // instead of the default 1 item. This is used for memory benchmarks to simulate // realistic query execution patterns. type BenchmarkListAdapter struct { TestAdapter itemsPerList int // Number of items to return per LIST query } // List returns exactly 10 items (or itemsPerList if set) for each LIST query func (b *BenchmarkListAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if b.cache == nil { b.cache = sdpcache.NewNoOpCache() } // Use the embedded TestAdapter's List method logic but return multiple items // We'll call the parent's cache lookup, but then generate multiple items itemsPerList := b.itemsPerList if itemsPerList == 0 { itemsPerList = 10 // Default to 10 items } cacheHit, ck, cachedItems, qErr, done := b.cache.Lookup(ctx, b.Name(), sdp.QueryMethod_LIST, scope, b.Type(), "", ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { // If we have cached items, return them (they should already be 10 items from previous call) return cachedItems, nil } // Track the call b.ListCalls = append(b.ListCalls, []string{scope}) switch scope { case "empty": err := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no items found", Scope: scope, } b.cache.StoreUnavailableItem(ctx, err, b.DefaultCacheDuration(), ck) return nil, err case "error": return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Error for testing", Scope: scope, } default: // Generate exactly itemsPerList items items := make([]*sdp.Item, 0, itemsPerList) for i := range itemsPerList { item := b.NewTestItem(scope, fmt.Sprintf("item-%d", i)) items = append(items, item) b.cache.StoreItem(ctx, item, b.DefaultCacheDuration(), ck) } return items, nil } } // generateBenchmarkGCPLikeAdapters creates adapters that mimic the GCP source structure // but use BenchmarkListAdapter which returns 10 items per LIST query func generateBenchmarkGCPLikeAdapters(projects, regions, zones, typesPerScope int) []Adapter { projectTypes := typesPerScope / 3 regionalTypes := typesPerScope / 3 zonalTypes := typesPerScope / 3 totalAdapters := (projects * projectTypes) + (projects * regions * regionalTypes) + (projects * zones * zonalTypes) adapters := make([]Adapter, 0, totalAdapters) for p := range projects { projectID := fmt.Sprintf("project-%d", p) // Project-level adapters for t := range projectTypes { adapters = append(adapters, &BenchmarkListAdapter{ TestAdapter: TestAdapter{ ReturnScopes: []string{projectID}, ReturnType: fmt.Sprintf("gcp-project-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", projectID, t), }, itemsPerList: 10, }) } // Regional adapters for r := range regions { scope := fmt.Sprintf("%s.region-%d", projectID, r) for t := range regionalTypes { adapters = append(adapters, &BenchmarkListAdapter{ TestAdapter: TestAdapter{ ReturnScopes: []string{scope}, ReturnType: fmt.Sprintf("gcp-regional-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", scope, t), }, itemsPerList: 10, }) } } // Zonal adapters for z := range zones { scope := fmt.Sprintf("%s.zone-%d", projectID, z) for t := range zonalTypes { adapters = append(adapters, &BenchmarkListAdapter{ TestAdapter: TestAdapter{ ReturnScopes: []string{scope}, ReturnType: fmt.Sprintf("gcp-zonal-type-%d", t), ReturnName: fmt.Sprintf("adapter-%s-type-%d", scope, t), }, itemsPerList: 10, }) } } } return adapters } // newBenchmarkEngine creates an Engine for benchmarks without requiring NATS connection // The execution pools are manually initialized so queries can be executed without Start() func newBenchmarkEngine(adapters ...Adapter) (*Engine, error) { ec := &EngineConfig{ MaxParallelExecutions: 2000, SourceName: "benchmark-engine", NATSQueueName: "", Unauthenticated: true, // No NATSOptions - we don't need NATS for benchmarks } e, err := NewEngine(ec) if err != nil { return nil, fmt.Errorf("error creating engine: %w", err) } // Manually initialize execution pools (normally done in Start()) // This allows us to use ExecuteQuery without connecting to NATS e.listExecutionPool = pool.New().WithMaxGoroutines(ec.MaxParallelExecutions) e.getExecutionPool = pool.New().WithMaxGoroutines(ec.MaxParallelExecutions) if err := e.AddAdapters(adapters...); err != nil { return nil, fmt.Errorf("error adding adapters: %w", err) } return e, nil } // TestAddAdapters_LargeScale is a regular test (not benchmark) that validates // the system can handle a realistic large-scale scenario func TestAddAdapters_LargeScale(t *testing.T) { if testing.Short() { t.Skip("Skipping large-scale test in short mode") } scenarios := []struct { name string projects int regions int zones int types int timeout time.Duration }{ {"23_projects", 23, 35, 135, 88, 30 * time.Second}, {"100_projects", 100, 35, 135, 88, 5 * time.Minute}, } for _, sc := range scenarios { t.Run(sc.name, func(t *testing.T) { adapters := generateGCPLikeAdapters(sc.projects, sc.regions, sc.zones, sc.types) sh := NewAdapterHost() t.Logf("Testing with %d adapters", len(adapters)) done := make(chan error, 1) go func() { done <- sh.AddAdapters(adapters...) }() select { case err := <-done: if err != nil { t.Fatalf("Failed to add adapters: %v", err) } t.Logf("Successfully added %d adapters", len(sh.Adapters())) case <-time.After(sc.timeout): t.Fatalf("AddAdapters timed out after %v (likely O(n²) issue)", sc.timeout) } }) } } // TestMemoryFootprint_EnterpriseScale measures actual memory usage at enterprise scale // This provides accurate memory consumption data for capacity planning func TestMemoryFootprint_EnterpriseScale(t *testing.T) { if testing.Short() { t.Skip("Skipping memory footprint test in short mode") } scenarios := []struct { name string projects int regions int zones int types int }{ {"23_projects", 23, 35, 135, 88}, {"100_projects", 100, 35, 135, 88}, {"500_projects", 500, 35, 135, 88}, } for _, sc := range scenarios { t.Run(sc.name, func(t *testing.T) { // Force GC and get baseline runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // Create adapters adapters := generateGCPLikeAdapters(sc.projects, sc.regions, sc.zones, sc.types) sh := NewAdapterHost() err := sh.AddAdapters(adapters...) if err != nil { t.Fatal(err) } // Get memory stats immediately (don't GC, we want to see actual usage) var m2 runtime.MemStats runtime.ReadMemStats(&m2) // Calculate memory used - use TotalAlloc which is monotonically increasing totalAllocated := m2.TotalAlloc - m1.TotalAlloc currentHeap := m2.HeapAlloc memUsedMB := float64(totalAllocated) / (1024 * 1024) heapUsedMB := float64(currentHeap) / (1024 * 1024) bytesPerAdapter := float64(totalAllocated) / float64(len(adapters)) sysMemMB := float64(m2.Sys) / (1024 * 1024) // Log detailed stats t.Logf("=== Memory Footprint Analysis ===") t.Logf("Adapters created: %d", len(adapters)) t.Logf("Total allocated: %d bytes (%.2f MB)", totalAllocated, memUsedMB) t.Logf("Current heap usage: %d bytes (%.2f MB)", currentHeap, heapUsedMB) t.Logf("Bytes per adapter: %.2f", bytesPerAdapter) t.Logf("Heap objects: %d", m2.HeapObjects) t.Logf("System memory (from OS): %.2f MB", sysMemMB) t.Logf("Number of GC cycles: %d", m2.NumGC-m1.NumGC) // Project memory usage for larger scales based on heap usage if sc.projects == 500 { mem1000 := (heapUsedMB / 500) * 1000 mem5000 := (heapUsedMB / 500) * 5000 t.Logf("\n=== Projected Heap Memory Usage ===") t.Logf("1,000 projects: ~%.0f MB (~%.1f GB)", mem1000, mem1000/1024) t.Logf("5,000 projects: ~%.0f MB (~%.1f GB)", mem5000, mem5000/1024) } }) } } // TestMemoryFootprint_WithListQueries measures memory usage when actually executing // LIST queries against adapters, not just adding them. This simulates real-world // usage where queries are executed and items are returned and cached. // // Memory Profiling: // // To generate memory profiles for analysis: // // 1. Generate memory profile: // go test -run TestMemoryFootprint_WithListQueries/35_projects \ // -memprofile=mem_35_projects.pprof ./discovery/... // // 2. Analyze the profile: // go tool pprof mem_35_projects.pprof // # Then use: top, list , web, etc. // // 3. Or use web UI: // go tool pprof -http=:8080 mem_35_projects.pprof // # Then open http://localhost:8080 in browser // // For heap profiles at specific points (after adapters, after queries): // HEAP_PROFILE=heap go test -run TestMemoryFootprint_WithListQueries/35_projects -v ./discovery/... func TestMemoryFootprint_WithListQueries(t *testing.T) { if testing.Short() { t.Skip("Skipping memory footprint test with list queries in short mode") } scenarios := []struct { name string projects int regions int zones int types int timeout time.Duration }{ {"35_projects", 35, 35, 135, 88, 5 * time.Minute}, } for _, sc := range scenarios { t.Run(sc.name, func(t *testing.T) { // Force GC and get baseline runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // Create adapters using BenchmarkListAdapter (returns 10 items per query) adapters := generateBenchmarkGCPLikeAdapters(sc.projects, sc.regions, sc.zones, sc.types) engine, err := newBenchmarkEngine(adapters...) if err != nil { t.Fatalf("Failed to create engine: %v", err) } // Get memory stats after adding adapters (before queries) var m2 runtime.MemStats runtime.ReadMemStats(&m2) // Write heap profile after adapters if requested if heapProfile := os.Getenv("HEAP_PROFILE"); heapProfile != "" { f, err := os.Create(fmt.Sprintf("%s_%s_%d_projects_after_adapters.pprof", heapProfile, sc.name, sc.projects)) if err == nil { defer f.Close() runtime.GC() if err := pprof.WriteHeapProfile(f); err != nil { t.Logf("Failed to write heap profile: %v", err) } else { t.Logf("Heap profile (after adapters) written to: %s", f.Name()) } } } // Execute LIST queries for each unique adapter type // This will expand to all matching scopes via ExpandQuery ctx, cancel := context.WithTimeout(context.Background(), sc.timeout) defer cancel() // Collect unique adapter types typeSet := make(map[string]bool) for _, adapter := range adapters { typeSet[adapter.Type()] = true } // Execute LIST queries for each unique adapter type across all scopes // This will expand to all matching scopes via ExpandQuery totalItems := 0 totalErrors := 0 // Execute one LIST query per adapter type (will expand to all scopes) for adapterType := range typeSet { query := &sdp.Query{ Type: adapterType, Scope: "*", // Wildcard to match all scopes Method: sdp.QueryMethod_LIST, } items, _, errs, err := engine.executeQuerySync(ctx, query) if err != nil { t.Logf("Query execution error for type %s: %v", adapterType, err) } totalItems += len(items) totalErrors += len(errs) } // Get final memory stats after queries var m3 runtime.MemStats runtime.ReadMemStats(&m3) // Write heap profile if requested via environment variable if heapProfile := os.Getenv("HEAP_PROFILE"); heapProfile != "" { f, err := os.Create(fmt.Sprintf("%s_%s_%d_projects.pprof", heapProfile, sc.name, sc.projects)) if err != nil { t.Logf("Failed to create heap profile: %v", err) } else { defer f.Close() runtime.GC() // Get accurate picture if err := pprof.WriteHeapProfile(f); err != nil { t.Logf("Failed to write heap profile: %v", err) } else { t.Logf("Heap profile written to: %s", f.Name()) } } } // Calculate memory deltas allocAfterAdapters := m2.TotalAlloc - m1.TotalAlloc allocAfterQueries := m3.TotalAlloc - m2.TotalAlloc totalAllocated := m3.TotalAlloc - m1.TotalAlloc heapAfterAdapters := m2.HeapAlloc heapAfterQueries := m3.HeapAlloc // Convert to MB allocAfterAdaptersMB := float64(allocAfterAdapters) / (1024 * 1024) allocAfterQueriesMB := float64(allocAfterQueries) / (1024 * 1024) totalAllocatedMB := float64(totalAllocated) / (1024 * 1024) heapAfterAdaptersMB := float64(heapAfterAdapters) / (1024 * 1024) heapAfterQueriesMB := float64(heapAfterQueries) / (1024 * 1024) // Calculate per-item and per-adapter metrics bytesPerAdapter := float64(totalAllocated) / float64(len(adapters)) bytesPerItem := float64(allocAfterQueries) / float64(totalItems) bytesPerProject := float64(totalAllocated) / float64(sc.projects) // Log detailed stats t.Logf("=== Memory Footprint Analysis with List Queries ===") t.Logf("Adapters created: %d", len(adapters)) t.Logf("Adapter types queried: %d", len(typeSet)) t.Logf("Total items returned: %d", totalItems) t.Logf("Total errors: %d", totalErrors) t.Logf("\n=== Memory After Adding Adapters ===") t.Logf("Total allocated: %d bytes (%.2f MB)", allocAfterAdapters, allocAfterAdaptersMB) t.Logf("Heap usage: %d bytes (%.2f MB)", heapAfterAdapters, heapAfterAdaptersMB) t.Logf("\n=== Memory After Executing Queries ===") t.Logf("Additional allocated: %d bytes (%.2f MB)", allocAfterQueries, allocAfterQueriesMB) t.Logf("Heap usage: %d bytes (%.2f MB)", heapAfterQueries, heapAfterQueriesMB) t.Logf("\n=== Total Memory Usage ===") t.Logf("Total allocated: %d bytes (%.2f MB)", totalAllocated, totalAllocatedMB) t.Logf("Bytes per adapter: %.2f", bytesPerAdapter) t.Logf("Bytes per item returned: %.2f", bytesPerItem) t.Logf("Bytes per project: %.2f", bytesPerProject) t.Logf("Heap objects: %d", m3.HeapObjects) t.Logf("System memory (from OS): %.2f MB", float64(m3.Sys)/(1024*1024)) t.Logf("Number of GC cycles: %d", m3.NumGC-m1.NumGC) // Project memory usage for larger scales if sc.projects >= 100 { mem1000 := (heapAfterQueriesMB / float64(sc.projects)) * 1000 mem5000 := (heapAfterQueriesMB / float64(sc.projects)) * 5000 t.Logf("\n=== Projected Heap Memory Usage (with queries) ===") t.Logf("1,000 projects: ~%.0f MB (~%.1f GB)", mem1000, mem1000/1024) t.Logf("5,000 projects: ~%.0f MB (~%.1f GB)", mem5000, mem5000/1024) } }) } } // BenchmarkMemoryFootprint_WithStats measures memory with runtime.MemStats func BenchmarkMemoryFootprint_WithStats(b *testing.B) { scenarios := []struct { name string projects int regions int zones int types int }{ {"Small_23proj", 23, 35, 135, 88}, {"Medium_100proj", 100, 35, 135, 88}, {"Large_500proj", 500, 35, 135, 88}, } for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { for range b.N { b.StopTimer() // Get baseline memory runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) b.StartTimer() // Create and add adapters adapters := generateGCPLikeAdapters(sc.projects, sc.regions, sc.zones, sc.types) sh := NewAdapterHost() err := sh.AddAdapters(adapters...) b.StopTimer() if err != nil { b.Fatal(err) } // Measure final memory (no GC to see actual usage) var m2 runtime.MemStats runtime.ReadMemStats(&m2) totalAllocated := m2.TotalAlloc - m1.TotalAlloc heapUsed := m2.HeapAlloc memUsedMB := float64(totalAllocated) / (1024 * 1024) heapUsedMB := float64(heapUsed) / (1024 * 1024) b.ReportMetric(float64(len(adapters)), "adapters") b.ReportMetric(memUsedMB, "total_alloc_MB") b.ReportMetric(heapUsedMB, "heap_MB") b.ReportMetric(float64(totalAllocated)/float64(len(adapters)), "bytes/adapter") b.ReportMetric(float64(m2.HeapObjects), "heap_objects") b.ReportMetric(float64(m2.Sys)/(1024*1024), "sys_memory_MB") } }) } } // BenchmarkMemoryFootprint_WithListQueries measures memory usage when executing // LIST queries against adapters that return 10 items each. This provides realistic // memory consumption data for capacity planning when queries are actually executed. func BenchmarkMemoryFootprint_WithListQueries(b *testing.B) { scenarios := []struct { name string projects int regions int zones int types int }{ {"Medium_35proj", 35, 35, 135, 88}, } for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { for range b.N { b.StopTimer() // Get baseline memory runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // Create adapters using BenchmarkListAdapter (returns 10 items per query) adapters := generateBenchmarkGCPLikeAdapters(sc.projects, sc.regions, sc.zones, sc.types) engine, err := newBenchmarkEngine(adapters...) if err != nil { b.Fatalf("Failed to create engine: %v", err) } // Get memory after adding adapters var m2 runtime.MemStats runtime.ReadMemStats(&m2) b.StartTimer() // Execute LIST queries for each unique adapter type ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() // Collect unique adapter types typeSet := make(map[string]bool) for _, adapter := range adapters { typeSet[adapter.Type()] = true } totalItems := 0 for adapterType := range typeSet { query := &sdp.Query{ Type: adapterType, Scope: "*", // Wildcard to match all scopes Method: sdp.QueryMethod_LIST, } items, _, _, err := engine.executeQuerySync(ctx, query) if err != nil { // Log but don't fail - some queries might timeout in benchmarks b.Logf("Query execution error for type %s: %v", adapterType, err) } totalItems += len(items) } b.StopTimer() // Measure final memory after queries var m3 runtime.MemStats runtime.ReadMemStats(&m3) allocAfterAdapters := m2.TotalAlloc - m1.TotalAlloc allocAfterQueries := m3.TotalAlloc - m2.TotalAlloc totalAllocated := m3.TotalAlloc - m1.TotalAlloc heapAfterQueries := m3.HeapAlloc allocAfterAdaptersMB := float64(allocAfterAdapters) / (1024 * 1024) allocAfterQueriesMB := float64(allocAfterQueries) / (1024 * 1024) totalAllocatedMB := float64(totalAllocated) / (1024 * 1024) heapAfterQueriesMB := float64(heapAfterQueries) / (1024 * 1024) b.ReportMetric(float64(len(adapters)), "adapters") b.ReportMetric(float64(totalItems), "items_returned") b.ReportMetric(allocAfterAdaptersMB, "alloc_after_adapters_MB") b.ReportMetric(allocAfterQueriesMB, "alloc_after_queries_MB") b.ReportMetric(totalAllocatedMB, "total_alloc_MB") b.ReportMetric(heapAfterQueriesMB, "heap_MB") b.ReportMetric(float64(totalAllocated)/float64(len(adapters)), "bytes/adapter") b.ReportMetric(float64(allocAfterQueries)/float64(totalItems), "bytes/item") b.ReportMetric(float64(m3.HeapObjects), "heap_objects") b.ReportMetric(float64(m3.Sys)/(1024*1024), "sys_memory_MB") } }) } } ================================================ FILE: go/discovery/adapterhost_test.go ================================================ package discovery import ( "testing" "github.com/overmindtech/cli/go/sdp-go" ) func TestAdapterHostExpandQuery(t *testing.T) { sh := NewAdapterHost() err := sh.AddAdapters( &TestAdapter{ ReturnScopes: []string{"test"}, ReturnType: "person", ReturnName: "person", }, &TestAdapter{ ReturnScopes: []string{"test"}, ReturnType: "fish", ReturnName: "fish", }, &TestAdapter{ ReturnScopes: []string{ "multiA", "multiB", }, ReturnType: "chair", ReturnName: "chair", }, &TestAdapter{ ReturnScopes: []string{"test"}, ReturnType: "hidden_person", IsHidden: true, ReturnName: "hidden_person", }, ) if err != nil { t.Fatal(err) } t.Run("Right type wrong scope", func(t *testing.T) { req := sdp.Query{ Type: "person", Scope: "wrong", } m := sh.ExpandQuery(&req) if len(m) != 0 { t.Fatalf("Expected 0 queries, got %v", len(m)) } }) t.Run("Right scope wrong type", func(t *testing.T) { req := sdp.Query{ Type: "wrong", Scope: "test", } m := sh.ExpandQuery(&req) if len(m) != 0 { t.Fatalf("Expected 0 queries, got %v", len(m)) } }) t.Run("Right both", func(t *testing.T) { req := sdp.Query{ Type: "person", Scope: "test", } m := sh.ExpandQuery(&req) if len(m) != 1 { t.Fatalf("Expected 1 query, got %v", len(m)) } }) t.Run("Multi-scope", func(t *testing.T) { req := sdp.Query{ Type: "chair", Scope: "multiB", } m := sh.ExpandQuery(&req) if len(m) != 1 { t.Fatalf("Expected 1 query, got %v", len(m)) } }) t.Run("Wildcard scope", func(t *testing.T) { req := sdp.Query{ Type: "person", Scope: sdp.WILDCARD, } m := sh.ExpandQuery(&req) if len(m) != 1 { t.Fatalf("Expected 1 query, got %v", len(m)) } req = sdp.Query{ Type: "chair", Scope: sdp.WILDCARD, } m = sh.ExpandQuery(&req) if len(m) != 2 { t.Fatalf("Expected 2 queries, got %v", len(m)) } }) t.Run("Wildcard type", func(t *testing.T) { req := sdp.Query{ Type: sdp.WILDCARD, Scope: "test", } m := sh.ExpandQuery(&req) if len(m) != 2 { t.Fatalf("Expected 2 adapters, got %v", len(m)) } }) t.Run("Wildcard both", func(t *testing.T) { req := sdp.Query{ Type: sdp.WILDCARD, Scope: sdp.WILDCARD, } m := sh.ExpandQuery(&req) if len(m) != 4 { t.Fatalf("Expected 4 adapters, got %v", len(m)) } }) t.Run("substring match", func(t *testing.T) { req := sdp.Query{ Type: sdp.WILDCARD, Scope: "multi", } m := sh.ExpandQuery(&req) if len(m) != 2 { t.Fatalf("Expected 2 queries, got %v", len(m)) } }) t.Run("Listing hidden adapter with wildcard scope", func(t *testing.T) { req := sdp.Query{ Type: "hidden_person", Scope: sdp.WILDCARD, } if x := len(sh.ExpandQuery(&req)); x != 0 { t.Errorf("expected to find 0 adapters, found %v", x) } req = sdp.Query{ Type: "hidden_person", Scope: "test", } if x := len(sh.ExpandQuery(&req)); x != 1 { t.Errorf("expected to find 1 adapter, found %v", x) } }) } func TestAdapterHostAddAdapters(t *testing.T) { sh := NewAdapterHost() adapter := TestAdapter{} err := sh.AddAdapters(&adapter) if err != nil { t.Fatal(err) } if x := len(sh.Adapters()); x != 1 { t.Fatalf("Expected 1 adapters, got %v", x) } } func TestAdapterHostExpandQuery_WildcardScope(t *testing.T) { sh := NewAdapterHost() // Add regular adapter without wildcard support regularAdapter := &TestAdapter{ ReturnScopes: []string{"project.zone-a", "project.zone-b"}, ReturnType: "regular-type", ReturnName: "regular", } // Add wildcard-supporting adapter wildcardAdapter := &TestWildcardAdapter{ TestAdapter: TestAdapter{ ReturnScopes: []string{"project.zone-a", "project.zone-b"}, ReturnType: "wildcard-type", ReturnName: "wildcard", }, supportsWildcard: true, } err := sh.AddAdapters(regularAdapter, wildcardAdapter) if err != nil { t.Fatal(err) } t.Run("Regular adapter with wildcard scope expands to all scopes", func(t *testing.T) { req := sdp.Query{ Type: "regular-type", Scope: sdp.WILDCARD, } expanded := sh.ExpandQuery(&req) // Should expand to 2 queries (one per zone) if len(expanded) != 2 { t.Fatalf("Expected 2 expanded queries for regular adapter, got %v", len(expanded)) } // Check that scopes are specific, not wildcard for q := range expanded { if q.GetScope() == sdp.WILDCARD { t.Errorf("Expected specific scope, got wildcard") } } }) t.Run("Wildcard-supporting adapter with wildcard scope does not expand for LIST", func(t *testing.T) { req := sdp.Query{ Type: "wildcard-type", Method: sdp.QueryMethod_LIST, Scope: sdp.WILDCARD, } expanded := sh.ExpandQuery(&req) // Should NOT expand - just 1 query with wildcard scope if len(expanded) != 1 { t.Fatalf("Expected 1 query for wildcard adapter, got %v", len(expanded)) } // Check that scope is still wildcard for q := range expanded { if q.GetScope() != sdp.WILDCARD { t.Errorf("Expected wildcard scope to be preserved, got %v", q.GetScope()) } } }) t.Run("Wildcard-supporting adapter with wildcard scope expands for GET", func(t *testing.T) { req := sdp.Query{ Type: "wildcard-type", Method: sdp.QueryMethod_GET, Scope: sdp.WILDCARD, } expanded := sh.ExpandQuery(&req) // Should expand to 2 queries (one per scope) for GET if len(expanded) != 2 { t.Fatalf("Expected 2 expanded queries for wildcard adapter with GET, got %v", len(expanded)) } // Check that scopes are specific, not wildcard for q := range expanded { if q.GetScope() == sdp.WILDCARD { t.Errorf("Expected specific scope for GET, got wildcard") } } }) t.Run("Wildcard-supporting adapter with wildcard scope expands for SEARCH", func(t *testing.T) { req := sdp.Query{ Type: "wildcard-type", Method: sdp.QueryMethod_SEARCH, Scope: sdp.WILDCARD, } expanded := sh.ExpandQuery(&req) // Should expand to 2 queries (one per scope) for SEARCH if len(expanded) != 2 { t.Fatalf("Expected 2 expanded queries for wildcard adapter with SEARCH, got %v", len(expanded)) } // Check that scopes are specific, not wildcard for q := range expanded { if q.GetScope() == sdp.WILDCARD { t.Errorf("Expected specific scope for SEARCH, got wildcard") } } }) t.Run("Wildcard-supporting adapter with specific scope works normally", func(t *testing.T) { req := sdp.Query{ Type: "wildcard-type", Scope: "project.zone-a", } expanded := sh.ExpandQuery(&req) // Should return 1 query with specific scope if len(expanded) != 1 { t.Fatalf("Expected 1 query, got %v", len(expanded)) } for q := range expanded { if q.GetScope() != "project.zone-a" { t.Errorf("Expected scope 'project.zone-a', got %v", q.GetScope()) } } }) t.Run("Hidden wildcard adapter with wildcard scope is not included", func(t *testing.T) { hiddenWildcardAdapter := &TestWildcardAdapter{ TestAdapter: TestAdapter{ ReturnScopes: []string{"project.zone-a"}, ReturnType: "hidden-wildcard-type", ReturnName: "hidden-wildcard", IsHidden: true, }, supportsWildcard: true, } err := sh.AddAdapters(hiddenWildcardAdapter) if err != nil { t.Fatal(err) } req := sdp.Query{ Type: "hidden-wildcard-type", Scope: sdp.WILDCARD, } expanded := sh.ExpandQuery(&req) // Hidden adapters should not be expanded for wildcard scopes if len(expanded) != 0 { t.Fatalf("Expected 0 queries for hidden wildcard adapter, got %v", len(expanded)) } }) } // TestWildcardAdapter extends TestAdapter to implement WildcardScopeAdapter type TestWildcardAdapter struct { TestAdapter supportsWildcard bool } // SupportsWildcardScope implements the WildcardScopeAdapter interface func (t *TestWildcardAdapter) SupportsWildcardScope() bool { return t.supportsWildcard } ================================================ FILE: go/discovery/cmd.go ================================================ package discovery import ( "context" "errors" "fmt" "net" "net/http" "os" "runtime" "time" "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdp-go/sdpconnect" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "golang.org/x/oauth2" ) const defaultApp = "https://app.overmind.tech" func AddEngineFlags(command *cobra.Command) { command.PersistentFlags().String("source-name", "", "The name of the source") cobra.CheckErr(viper.BindEnv("source-name", "SOURCE_NAME")) command.PersistentFlags().String("source-uuid", "", "The UUID of the source, is this is blank it will be auto-generated. This is used in heartbeats and shouldn't be supplied usually") cobra.CheckErr(viper.BindEnv("source-uuid", "SOURCE_UUID")) command.PersistentFlags().String("source-access-token", "", "The access token to use to authenticate the source for managed sources") cobra.CheckErr(viper.BindEnv("source-access-token", "SOURCE_ACCESS_TOKEN")) command.PersistentFlags().String("source-access-token-type", "", "The type of token to use to authenticate the source for managed sources") cobra.CheckErr(viper.BindEnv("source-access-token-type", "SOURCE_ACCESS_TOKEN_TYPE")) command.PersistentFlags().String("api-server-service-host", "", "The host of the API server service, only if the source is managed by Overmind") cobra.CheckErr(viper.BindEnv("api-server-service-host", "API_SERVER_SERVICE_HOST")) command.PersistentFlags().String("api-server-service-port", "", "The port of the API server service, only if the source is managed by Overmind") cobra.CheckErr(viper.BindEnv("api-server-service-port", "API_SERVER_SERVICE_PORT")) command.PersistentFlags().String("nats-service-host", "", "The host of the NATS service, only if the source is managed by Overmind") cobra.CheckErr(viper.BindEnv("nats-service-host", "NATS_SERVICE_HOST")) command.PersistentFlags().String("nats-service-port", "", "The port of the NATS service, only if the source is managed by Overmind") cobra.CheckErr(viper.BindEnv("nats-service-port", "NATS_SERVICE_PORT")) command.PersistentFlags().Bool("overmind-managed-source", false, "If you are running the source yourself or if it is managed by Overmind") cobra.CheckErr(command.PersistentFlags().MarkHidden("overmind-managed-source")) cobra.CheckErr(viper.BindEnv("overmind-managed-source", "OVERMIND_MANAGED_SOURCE")) command.PersistentFlags().String("app", defaultApp, "The URL of the Overmind app to use") cobra.CheckErr(viper.BindEnv("app", "APP")) command.PersistentFlags().String("api-key", "", "The API key to use to authenticate to the Overmind API") cobra.CheckErr(viper.BindEnv("api-key", "OVM_API_KEY", "API_KEY")) command.PersistentFlags().String("nats-connection-name", "", "The name that the source should use to connect to NATS") cobra.CheckErr(viper.BindEnv("nats-connection-name", "NATS_CONNECTION_NAME")) command.PersistentFlags().Int("nats-connection-timeout", 10, "The timeout for connecting to NATS") cobra.CheckErr(viper.BindEnv("nats-connection-timeout", "NATS_CONNECTION_TIMEOUT")) command.PersistentFlags().Int("max-parallel", 0, "The maximum number of parallel executions") cobra.CheckErr(viper.BindEnv("max-parallel", "MAX_PARALLEL")) } func EngineConfigFromViper(engineType, version string) (*EngineConfig, error) { var sourceName string hostname, err := os.Hostname() if err != nil { return nil, fmt.Errorf("error getting hostname: %w", err) } if viper.GetString("source-name") == "" { sourceName = fmt.Sprintf("%s-%s", engineType, hostname) } else { sourceName = viper.GetString("source-name") } sourceUUIDString := viper.GetString("source-uuid") var sourceUUID uuid.UUID if sourceUUIDString == "" { sourceUUID = uuid.New() } else { var err error sourceUUID, err = uuid.Parse(sourceUUIDString) if err != nil { return nil, fmt.Errorf("error parsing source-uuid: %w", err) } } var managedSource sdp.SourceManaged if viper.GetBool("overmind-managed-source") { managedSource = sdp.SourceManaged_MANAGED } else { managedSource = sdp.SourceManaged_LOCAL } var apiServerURL string var natsServerURL string appURL := viper.GetString("app") if managedSource == sdp.SourceManaged_MANAGED { apiServerHost := viper.GetString("api-server-service-host") apiServerPort := viper.GetString("api-server-service-port") if apiServerHost == "" || apiServerPort == "" { return nil, errors.New("API_SERVER_SERVICE_HOST and API_SERVER_SERVICE_PORT (provided by k8s) must be set for managed sources") } apiServerURL = net.JoinHostPort(apiServerHost, apiServerPort) if apiServerPort == "443" { apiServerURL = "https://" + apiServerURL } else { apiServerURL = "http://" + apiServerURL } natsServerHost := viper.GetString("nats-service-host") natsServerPort := viper.GetString("nats-service-port") if natsServerHost == "" || natsServerPort == "" { return nil, errors.New("NATS_SERVICE_HOST and NATS_SERVICE_PORT (provided by k8s) must be set for managed sources") } natsServerURL = net.JoinHostPort(natsServerHost, natsServerPort) // default to websocket if the port is 443; this is to allow GCP sources // to connect to NATS from outside the EKS cluster if natsServerPort == "443" { natsServerURL = "wss://" + natsServerURL } else { natsServerURL = "nats://" + natsServerURL } } else { // look up the api server url from the app url ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() oi, err := sdp.NewOvermindInstance(ctx, appURL) if err != nil { err = fmt.Errorf("Could not determine Overmind instance URLs from app URL %s: %w", appURL, err) return nil, err } apiServerURL = oi.ApiUrl.String() natsServerURL = oi.NatsUrl.String() } // setup natsOptions var natsConnectionName string if viper.GetString("nats-connection-name") == "" { natsConnectionName = hostname } natsOptions := auth.NATSOptions{ NumRetries: -1, RetryDelay: 5 * time.Second, Servers: []string{natsServerURL}, ConnectionName: natsConnectionName, ConnectionTimeout: time.Duration(viper.GetInt("nats-connection-timeout")) * time.Second, MaxReconnects: -1, ReconnectWait: 1 * time.Second, ReconnectJitter: 1 * time.Second, } allow := os.Getenv("ALLOW_UNAUTHENTICATED") allowUnauthenticated := allow == "true" // order of precedence is: // unauthenticated overrides everything # used for local development // if managed source, we expect a token // if local source, we expect an api key if allowUnauthenticated { log.Warn("Using unauthenticated mode as ALLOW_UNAUTHENTICATED is set") } else { if viper.GetBool("overmind-managed-source") { log.Info("Running source in managed mode") // If managed source, we expect a token if viper.GetString("source-access-token") == "" { return nil, errors.New("source-access-token must be set for managed sources") } } else if viper.GetString("api-key") == "" { return nil, errors.New("api-key must be set for local sources") } } maxParallelExecutions := viper.GetInt("max-parallel") if maxParallelExecutions == 0 { maxParallelExecutions = runtime.NumCPU() * 100 // we expect most source interactions to be waiting on external services, so adding more parallelism can help } return &EngineConfig{ EngineType: engineType, Version: version, SourceName: sourceName, SourceUUID: sourceUUID, OvermindManagedSource: managedSource, SourceAccessToken: viper.GetString("source-access-token"), SourceAccessTokenType: viper.GetString("source-access-token-type"), App: appURL, APIServerURL: apiServerURL, ApiKey: viper.GetString("api-key"), NATSOptions: &natsOptions, Unauthenticated: allowUnauthenticated, MaxParallelExecutions: maxParallelExecutions, }, nil } // MapFromEngineConfig Returns the config as a map func MapFromEngineConfig(ec *EngineConfig) map[string]any { var apiKeyClientSecret string if ec.ApiKey != "" { apiKeyClientSecret = "[REDACTED]" } var sourceAccessToken string if ec.SourceAccessToken != "" { sourceAccessToken = "[REDACTED]" } return map[string]any{ "engine-type": ec.EngineType, "version": ec.Version, "source-name": ec.SourceName, "source-uuid": ec.SourceUUID, "source-access-token": sourceAccessToken, "source-access-token-type": ec.SourceAccessTokenType, "managed-source": ec.OvermindManagedSource, "app": ec.App, "api-key": apiKeyClientSecret, "api-server-url": ec.APIServerURL, "max-parallel-executions": ec.MaxParallelExecutions, "nats-servers": ec.NATSOptions.Servers, "nats-connection-name": ec.NATSOptions.ConnectionName, "nats-connection-timeout": ec.NATSConnectionTimeout, "nats-queue-name": ec.NATSQueueName, "unauthenticated": ec.Unauthenticated, } } // CreateClients sets up NATS TokenClient and HeartbeatOptions.ManagementClient from config. // Each client is only created if not already set (idempotent), so callers like the CLI // can pre-configure clients without them being overwritten. func (ec *EngineConfig) CreateClients() error { // If we are running in unauthenticated mode then do nothing here if ec.Unauthenticated { log.Warn("Using unauthenticated NATS as ALLOW_UNAUTHENTICATED is set") if ec.NATSOptions != nil { log.WithField("config", fmt.Sprintf("%v", MapFromEngineConfig(ec))).Info("Engine config") } return nil } // If both clients are already configured (e.g. CLI), skip entirely if ec.NATSOptions != nil && ec.NATSOptions.TokenClient != nil && ec.HeartbeatOptions != nil && ec.HeartbeatOptions.ManagementClient != nil { return nil } switch ec.OvermindManagedSource { case sdp.SourceManaged_LOCAL: log.Info("Using API Key for authentication, heartbeats will be sent") if ec.NATSOptions != nil && ec.NATSOptions.TokenClient == nil { tokenClient, err := auth.NewAPIKeyClient(ec.APIServerURL, ec.ApiKey) if err != nil { return fmt.Errorf("error creating API key client: %w", err) } ec.NATSOptions.TokenClient = tokenClient } if ec.HeartbeatOptions == nil { ec.HeartbeatOptions = &HeartbeatOptions{} } if ec.HeartbeatOptions.ManagementClient == nil { tokenSource := auth.NewAPIKeyTokenSource(ec.ApiKey, ec.APIServerURL) transport := oauth2.Transport{ Source: tokenSource, Base: http.DefaultTransport, } authenticatedClient := http.Client{ Transport: otelhttp.NewTransport(&transport), } ec.HeartbeatOptions.ManagementClient = sdpconnect.NewManagementServiceClient( &authenticatedClient, ec.APIServerURL, ) ec.HeartbeatOptions.Frequency = time.Second * 30 } if ec.NATSOptions != nil { log.WithField("config", fmt.Sprintf("%v", MapFromEngineConfig(ec))).Info("Engine config") } return nil case sdp.SourceManaged_MANAGED: log.Info("Using static token for authentication, heartbeats will be sent") if ec.NATSOptions != nil && ec.NATSOptions.TokenClient == nil { tokenClient, err := auth.NewStaticTokenClient(ec.APIServerURL, ec.SourceAccessToken, ec.SourceAccessTokenType) if err != nil { err = fmt.Errorf("error creating static token client: %w", err) sentry.CaptureException(err) return err } ec.NATSOptions.TokenClient = tokenClient } if ec.HeartbeatOptions == nil { ec.HeartbeatOptions = &HeartbeatOptions{} } if ec.HeartbeatOptions.ManagementClient == nil { tokenSource := oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: ec.SourceAccessToken, TokenType: ec.SourceAccessTokenType, }) transport := oauth2.Transport{ Source: tokenSource, Base: http.DefaultTransport, } authenticatedClient := http.Client{ Transport: otelhttp.NewTransport(&transport), } ec.HeartbeatOptions.ManagementClient = sdpconnect.NewManagementServiceClient( &authenticatedClient, ec.APIServerURL, ) ec.HeartbeatOptions.Frequency = time.Second * 30 } if ec.NATSOptions != nil { log.WithField("config", fmt.Sprintf("%v", MapFromEngineConfig(ec))).Info("Engine config") } return nil } err := fmt.Errorf("unable to setup authentication. Please check your configuration %v", ec) return err } ================================================ FILE: go/discovery/cmd_test.go ================================================ package discovery import ( "os" "runtime" "testing" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // NB we do not call AddEngineFlags so we use command line flags, not environment variables func TestEngineConfigFromViper(t *testing.T) { tests := []struct { name string setupViper func() engineType string version string expectedSourceName string expectedSourceUUID uuid.UUID expectedSourceAccessToken string expectedSourceAccessTokenType string expectedManagedSource sdp.SourceManaged expectedApp string expectedApiServerURL string expectedApiKey string expectedNATSUrl string expectedMaxParallel int expectUnauthenticated bool expectError bool }{ { name: "default values", setupViper: func() { viper.Set("app", "https://app.overmind.tech") viper.Set("api-key", "api-key") }, engineType: "test-engine", version: "1.0", expectedSourceName: "test-engine-" + getHostname(t), expectedSourceUUID: uuid.Nil, expectedSourceAccessToken: "", expectedSourceAccessTokenType: "", expectedManagedSource: sdp.SourceManaged_LOCAL, expectedApp: "https://app.overmind.tech", expectedApiServerURL: "https://api.app.overmind.tech", expectedNATSUrl: "wss://messages.app.overmind.tech", expectedApiKey: "api-key", expectedMaxParallel: runtime.NumCPU() * 100, expectError: false, }, { name: "custom values", setupViper: func() { viper.Set("source-name", "custom-source") viper.Set("source-uuid", "123e4567-e89b-12d3-a456-426614174000") viper.Set("app", "https://df.overmind-demo.com/") viper.Set("api-key", "custom-api-key") viper.Set("max-parallel", 10) }, engineType: "test-engine", version: "1.0", expectedSourceName: "custom-source", expectedSourceUUID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), expectedSourceAccessToken: "", expectedSourceAccessTokenType: "", expectedManagedSource: sdp.SourceManaged_LOCAL, expectedApp: "https://df.overmind-demo.com/", expectedApiServerURL: "https://api.df.overmind-demo.com", expectedNATSUrl: "wss://messages.df.overmind-demo.com", expectedApiKey: "custom-api-key", expectedMaxParallel: 10, expectError: false, }, { name: "invalid UUID", setupViper: func() { viper.Set("source-uuid", "invalid-uuid") }, engineType: "test-engine", version: "1.0", expectError: true, }, { name: "managed source - nats", setupViper: func() { viper.Set("source-name", "custom-source") viper.Set("source-uuid", "123e4567-e89b-12d3-a456-426614174000") viper.Set("source-access-token", "custom-access-token") viper.Set("source-access-token-type", "custom-token-type") viper.Set("overmind-managed-source", true) viper.Set("max-parallel", 10) viper.Set("api-server-service-host", "api.app.overmind.tech") viper.Set("api-server-service-port", "443") viper.Set("nats-service-host", "messages.app.overmind.tech") viper.Set("nats-service-port", "4222") }, engineType: "test-engine", version: "1.0", expectedSourceName: "custom-source", expectedSourceUUID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), expectedSourceAccessToken: "custom-access-token", expectedSourceAccessTokenType: "custom-token-type", expectedManagedSource: sdp.SourceManaged_MANAGED, expectedApiServerURL: "https://api.app.overmind.tech:443", expectedNATSUrl: "nats://messages.app.overmind.tech:4222", expectedMaxParallel: 10, expectError: false, }, { name: "managed source - wss", setupViper: func() { viper.Set("source-name", "custom-source") viper.Set("source-uuid", "123e4567-e89b-12d3-a456-426614174000") viper.Set("source-access-token", "custom-access-token") viper.Set("source-access-token-type", "custom-token-type") viper.Set("overmind-managed-source", true) viper.Set("max-parallel", 10) viper.Set("api-server-service-host", "api.app.overmind.tech") viper.Set("api-server-service-port", "443") viper.Set("nats-service-host", "messages.app.overmind.tech") viper.Set("nats-service-port", "443") }, engineType: "test-engine", version: "1.0", expectedSourceName: "custom-source", expectedSourceUUID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), expectedSourceAccessToken: "custom-access-token", expectedSourceAccessTokenType: "custom-token-type", expectedManagedSource: sdp.SourceManaged_MANAGED, expectedApiServerURL: "https://api.app.overmind.tech:443", expectedNATSUrl: "wss://messages.app.overmind.tech:443", expectedMaxParallel: 10, expectError: false, }, { name: "managed source local insecure", setupViper: func() { viper.Set("source-name", "custom-source") viper.Set("source-uuid", "123e4567-e89b-12d3-a456-426614174000") viper.Set("source-access-token", "custom-access-token") viper.Set("source-access-token-type", "custom-token-type") viper.Set("overmind-managed-source", true) viper.Set("max-parallel", 10) viper.Set("api-server-service-host", "localhost") viper.Set("api-server-service-port", "8080") viper.Set("nats-service-host", "localhost") viper.Set("nats-service-port", "4222") }, engineType: "test-engine", version: "1.0", expectedSourceName: "custom-source", expectedSourceUUID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), expectedSourceAccessToken: "custom-access-token", expectedSourceAccessTokenType: "custom-token-type", expectedManagedSource: sdp.SourceManaged_MANAGED, expectedApiServerURL: "http://localhost:8080", expectedNATSUrl: "nats://localhost:4222", expectedMaxParallel: 10, expectError: false, }, { name: "source access token and api key not set", setupViper: func() {}, engineType: "test-engine", version: "1.0", expectError: true, }, { name: "fully unauthenticated", setupViper: func() { viper.Set("app", "https://app.overmind.tech") viper.Set("source-name", "custom-source") t.Setenv("ALLOW_UNAUTHENTICATED", "true") }, engineType: "test-engine", version: "1.0", expectError: false, expectedMaxParallel: runtime.NumCPU() * 100, expectedSourceName: "custom-source", expectedApp: "https://app.overmind.tech", expectedApiServerURL: "https://api.app.overmind.tech", expectedNATSUrl: "wss://messages.app.overmind.tech", expectUnauthenticated: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("ALLOW_UNAUTHENTICATED", "") viper.Reset() tt.setupViper() engineConfig, err := EngineConfigFromViper(tt.engineType, tt.version) if tt.expectError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tt.engineType, engineConfig.EngineType) assert.Equal(t, tt.version, engineConfig.Version) assert.Equal(t, tt.expectedSourceName, engineConfig.SourceName) if tt.expectedSourceUUID == uuid.Nil { assert.NotEqual(t, uuid.Nil, engineConfig.SourceUUID) } else { assert.Equal(t, tt.expectedSourceUUID, engineConfig.SourceUUID) } assert.Equal(t, tt.expectedSourceAccessToken, engineConfig.SourceAccessToken) assert.Equal(t, tt.expectedSourceAccessTokenType, engineConfig.SourceAccessTokenType) assert.Equal(t, tt.expectedManagedSource, engineConfig.OvermindManagedSource) assert.Equal(t, tt.expectedApp, engineConfig.App) assert.Equal(t, tt.expectedApiServerURL, engineConfig.APIServerURL) assert.Equal(t, tt.expectedNATSUrl, engineConfig.NATSOptions.Servers[0]) assert.Equal(t, tt.expectedApiKey, engineConfig.ApiKey) assert.Equal(t, tt.expectedMaxParallel, engineConfig.MaxParallelExecutions) assert.Equal(t, tt.expectUnauthenticated, engineConfig.Unauthenticated) } }) } } func getHostname(t *testing.T) string { hostname, err := os.Hostname() if err != nil { t.Fatalf("error getting hostname: %v", err) } return hostname } ================================================ FILE: go/discovery/doc.go ================================================ // Package discovery provides the engine and protocol types for Overmind sources. // Sources discover infrastructure (AWS, K8s, GCP, etc.) and respond to queries via NATS. // // # Startup sequence for source authors // // Sources should follow this canonical flow so that health probes and heartbeats // work even when adapter initialization fails (avoiding CrashLoopBackOff): // // 1. EngineConfigFromViper(engineType, version) — fail: return/exit // 2. NewEngine(engineConfig) — fail: return/exit (includes CreateClients internally) // 3. ServeHealthProbes(port) // 4. Start(ctx) — fail: return/exit (NATS connection required) // 5. Validate source config — permanent config errors: SetInitError(err), then idle // 6. Adapter init — use InitialiseAdapters (blocks until success or ctx cancelled) for retryable init, or SetInitError for single-attempt // 7. Wait for SIGTERM, then Stop() // // # Readiness gating // // The engine defaults to "not ready" until adapters are initialized. Both // ReadinessHealthCheck (the /healthz/ready HTTP probe) and SendHeartbeat report // an error while adaptersInitialized is false. This prevents Kubernetes from // routing traffic to a pod that has no adapters registered. // // InitialiseAdapters calls MarkAdaptersInitialized automatically on success. // Sources that do their own initialization (without InitialiseAdapters) must // call MarkAdaptersInitialized explicitly after adding adapters. // // # Error handling // // Fatal errors (caller must return or exit): EngineConfigFromViper, NewEngine, Start. // The engine cannot function without a valid config, auth clients, or NATS connection. // // Recoverable errors (call SetInitError and keep running): source config validation // failures (e.g. missing credentials, invalid regions) and adapter initialization // failures that may be transient. The pod stays Running, readiness fails, and the // error is reported via heartbeats and the API/UI. // // Permanent config errors (e.g. invalid API key, missing required flags) should // be detected before calling InitialiseAdapters and reported via SetInitError — // do not retry. Transient adapter init errors (e.g. upstream API temporarily // unavailable) should use InitialiseAdapters, which retries with backoff. // // See SetInitError, MarkAdaptersInitialized, and InitialiseAdapters for details and examples. package discovery ================================================ FILE: go/discovery/engine.go ================================================ package discovery import ( "context" "errors" "fmt" "net/http" "slices" "strings" "sync" "sync/atomic" "time" "connectrpc.com/connect" "github.com/cenkalti/backoff/v5" "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/nats-io/nats.go" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/pool" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) const ( DefaultMaxRequestTimeout = 5 * time.Minute DefaultConnectionWatchInterval = 3 * time.Second ) // The client that will be used to send heartbeats. This will usually be an // `sdpconnect.ManagementServiceClient` type HeartbeatClient interface { SubmitSourceHeartbeat(context.Context, *connect.Request[sdp.SubmitSourceHeartbeatRequest]) (*connect.Response[sdp.SubmitSourceHeartbeatResponse], error) } type HeartbeatOptions struct { // The client that will be used to send heartbeats ManagementClient HeartbeatClient // ReadinessCheck is called during readiness probes to verify adapters are healthy and ready. // This should be a lightweight, adapter-only check (do NOT include engine/liveness checks). // Timeouts are controlled by the caller (e.g., Kubernetes probe timeout / SendHeartbeat). // See: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-readiness-probes ReadinessCheck func(context.Context) error // How frequently to send a heartbeat Frequency time.Duration } // EngineConfig is the configuration for the engine // it is used to configure the engine before starting it type EngineConfig struct { EngineType string // The type of the engine, e.g. "aws" or "kubernetes" Version string // The version of the adapter that should be reported in the heartbeat SourceName string // normally follows the format of "type-hostname", e.g. "stdlib-source" SourceUUID uuid.UUID // The UUID of the source, is this is blank it will be auto-generated. This is used in heartbeats and shouldn't be supplied usually" App string // "https://app.overmind.tech", "The URL of the Overmind app to use" APIServerURL string // The URL of the Overmind API server to uses for the heartbeat, this is calculated // The 'ovm_*' API key to use to authenticate to the Overmind API. // This and 'SourceAccessToken' are mutually exclusive ApiKey string // Static token passed to the source to authenticate. SourceAccessToken string // The access token to use to authenticate to the source SourceAccessTokenType string // The type of token to use to authenticate the source for managed sources // NATS options NATSOptions *auth.NATSOptions // Options for connecting to NATS NATSConnectionTimeout int // The timeout for connecting to NATS NATSQueueName string // The name of the queue to use when subscribing Unauthenticated bool // Whether the source is unauthenticated // The options for the heartbeat. If this is nil the engine won't send // it is not used if we are nats only or unauthenticated. this will only happen if we are running in a test environment HeartbeatOptions *HeartbeatOptions // Whether this adapter is managed by Overmind. This is initially used for // reporting so that you can tell the difference between managed adapters and // ones you're running locally OvermindManagedSource sdp.SourceManaged MaxParallelExecutions int // 2_000, Max number of requests to run in parallel } // Engine is the main discovery engine. This is where all of the Adapters and // adapters are stored and is responsible for calling out to the right adapters to // discover everything // // Note that an engine that does not have a connected NATS connection will // simply not communicate over NATS type Engine struct { EngineConfig *EngineConfig // The maximum request timeout. Defaults to `DefaultMaxRequestTimeout` if // set to zero. If a client does not send a timeout, it will default to this // value. Requests with timeouts larger than this value will have their // timeouts overridden MaxRequestTimeout time.Duration // How often to check for closed connections and try to recover ConnectionWatchInterval time.Duration connectionWatcher NATSWatcher // The configuration for the heartbeat for this engine. If this is nil the // engine won't send heartbeats when started // Internal throttle used to limit MaxParallelExecutions. This reads // MaxParallelExecutions and is populated when the engine is started. This // pool is only used for LIST requests. Since GET requests can be blocked by // LIST requests, they need to be handled in a different pool to avoid // deadlocking. listExecutionPool *pool.Pool // Internal throttle used to limit MaxParallelExecutions. This reads // MaxParallelExecutions and is populated when the engine is started. This // pool is only used for GET and SEARCH requests. Since GET requests can be // blocked by LIST requests, they need to be handled in a different pool to // avoid deadlocking. getExecutionPool *pool.Pool // The NATS connection natsConnection sdp.EncodedConnection natsConnectionMutex sync.Mutex // All Adapters managed by this Engine sh *AdapterHost // handle log requests with this adapter logAdapter LogAdapter logAdapterMu sync.RWMutex // GetListMutex used for locking out Get queries when there's a List happening gfm GetListMutex // trackedQueries is used for storing queries that have a UUID so they can // be cancelled if required trackedQueries map[uuid.UUID]*QueryTracker trackedQueriesMutex sync.RWMutex // Prevents the engine being restarted many times in parallel restartMutex sync.Mutex // Context to background jobs like cache purging and heartbeats. These will // stop when the context is cancelled backgroundJobContext context.Context backgroundJobCancel context.CancelFunc heartbeatCancel context.CancelFunc // Heartbeat status tracking for healthz checks lastSuccessfulHeartbeat time.Time lastHeartbeatError error heartbeatStatusMutex sync.RWMutex // initError stores configuration/credential/initialization failures that prevent // adapters from being added to the engine. This includes: // - AWS: AssumeRole failures, GetCallerIdentity errors, invalid credentials // - K8s: Namespace listing failures, kubeconfig errors // - Harness: API authentication failures, hierarchy discovery errors // The error is surfaced via readiness checks (pod becomes 0/1 Ready) and // heartbeats (visible in UI/API), allowing the pod to stay Running instead of // CrashLoopBackOff so customers can diagnose and fix configuration issues. initError error initErrorMutex sync.RWMutex // adaptersInitialized tracks whether adapters have been successfully registered. // Defaults to false; set to true by InitialiseAdapters on success or manually // via MarkAdaptersInitialized for sources that don't use InitialiseAdapters. // ReadinessHealthCheck and SendHeartbeat both check this flag so that a source // cannot report healthy before it can actually serve queries. adaptersInitialized atomic.Bool } func NewEngine(engineConfig *EngineConfig) (*Engine, error) { if err := engineConfig.CreateClients(); err != nil { return nil, fmt.Errorf("could not create auth clients: %w", err) } sh := NewAdapterHost() return &Engine{ EngineConfig: engineConfig, MaxRequestTimeout: DefaultMaxRequestTimeout, ConnectionWatchInterval: DefaultConnectionWatchInterval, sh: sh, trackedQueries: make(map[uuid.UUID]*QueryTracker), }, nil } // TrackQuery Stores a QueryTracker in the engine so that it can be looked // up later and cancelled if required. The UUID should be supplied as part of // the query itself func (e *Engine) TrackQuery(uuid uuid.UUID, qt *QueryTracker) { e.trackedQueriesMutex.Lock() defer e.trackedQueriesMutex.Unlock() e.trackedQueries[uuid] = qt } // GetTrackedQuery Returns the QueryTracker object for a given UUID. This // tracker can then be used to cancel the query func (e *Engine) GetTrackedQuery(uuid uuid.UUID) (*QueryTracker, error) { e.trackedQueriesMutex.RLock() defer e.trackedQueriesMutex.RUnlock() if qt, ok := e.trackedQueries[uuid]; ok { return qt, nil } else { return nil, fmt.Errorf("tracker with UUID %x not found", uuid) } } // DeleteTrackedQuery Deletes a query from tracking func (e *Engine) DeleteTrackedQuery(uuid [16]byte) { e.trackedQueriesMutex.Lock() defer e.trackedQueriesMutex.Unlock() delete(e.trackedQueries, uuid) } // AddAdapters Adds an adapter to this engine func (e *Engine) AddAdapters(adapters ...Adapter) error { return e.sh.AddAdapters(adapters...) } // Connect Connects to NATS func (e *Engine) connect() error { if e.EngineConfig.NATSOptions != nil { encodedConnection, err := e.EngineConfig.NATSOptions.Connect() if err != nil { return fmt.Errorf("error connecting to NATS '%+v' : %w", e.EngineConfig.NATSOptions.Servers, err) } e.natsConnectionMutex.Lock() e.natsConnection = encodedConnection e.natsConnectionMutex.Unlock() // TODO: this could be replaced by setting the various callbacks on the // natsConnection and waiting for notification from the underlying // connection. e.connectionWatcher = NATSWatcher{ Connection: e.natsConnection, // If the connection stays disconnected for more than 5 minutes, // force a reconnection attempt. This prevents the source from being // stuck in RECONNECTING state indefinitely. ReconnectionTimeout: 5 * time.Minute, FailureHandler: func() { go func() { log.Warn("NATSWatcher triggered failure handler, attempting to reconnect") e.disconnect() if err := e.connect(); err != nil { log.WithError(err).Error("Error reconnecting during failure handler") } }() }, } e.connectionWatcher.Start(e.ConnectionWatchInterval) // Wait for the connection to be completed err = e.natsConnection.Underlying().FlushTimeout(10 * time.Minute) if err != nil { return fmt.Errorf("error flushing NATS connection: %w", err) } log.WithFields(log.Fields{ "ServerID": e.natsConnection.Underlying().ConnectedServerId(), "URL:": e.natsConnection.Underlying().ConnectedUrl(), }).Info("NATS connected") } if e.natsConnection == nil { return errors.New("no NATSOptions struct and no natsConnection provided") } // Since the underlying query processing logic creates its own spans // when it has some real work to do, we are not passing a name to these // query handlers so that we don't get spans that are completely empty err := e.subscribe("request.all", sdp.NewAsyncRawQueryHandler("", func(ctx context.Context, _ *nats.Msg, i *sdp.Query) { e.HandleQuery(ctx, i) })) if err != nil { return fmt.Errorf("error subscribing to request.all: %w", err) } err = e.subscribe("request.scope.>", sdp.NewAsyncRawQueryHandler("", func(ctx context.Context, m *nats.Msg, i *sdp.Query) { e.HandleQuery(ctx, i) })) if err != nil { return fmt.Errorf("error subscribing to request.scope.>: %w", err) } err = e.subscribe("cancel.all", sdp.NewAsyncRawCancelQueryHandler("CancelQueryHandler", func(ctx context.Context, m *nats.Msg, i *sdp.CancelQuery) { e.HandleCancelQuery(ctx, i) })) if err != nil { return fmt.Errorf("error subscribing to cancel.all: %w", err) } err = e.subscribe("cancel.scope.>", sdp.NewAsyncRawCancelQueryHandler("WildcardCancelQueryHandler", func(ctx context.Context, m *nats.Msg, i *sdp.CancelQuery) { e.HandleCancelQuery(ctx, i) })) if err != nil { return fmt.Errorf("error subscribing to cancel.scope.>: %w", err) } if e.logAdapter != nil { for _, scope := range e.logAdapter.Scopes() { subj := fmt.Sprintf("logs.scope.%v", scope) err = e.subscribe(subj, sdp.NewAsyncRawNATSGetLogRecordsRequestHandler("WildcardCancelQueryHandler", func(ctx context.Context, m *nats.Msg, i *sdp.NATSGetLogRecordsRequest) { replyTo := m.Header.Get("reply-to") e.HandleLogRecordsRequest(ctx, replyTo, i) })) if err != nil { return fmt.Errorf("error subscribing to %v: %w", subj, err) } } } return nil } // disconnect Disconnects the engine from the NATS network func (e *Engine) disconnect() { e.connectionWatcher.Stop() e.natsConnectionMutex.Lock() defer e.natsConnectionMutex.Unlock() if e.natsConnection == nil { return } e.natsConnection.Close() e.natsConnection.Drop() } // Start performs all of the initialisation steps required for the engine to // work. Note that this creates NATS subscriptions for all available adapters so // modifying the Adapters value after an engine has been started will not have // any effect until the engine is restarted func (e *Engine) Start(ctx context.Context) error { e.listExecutionPool = pool.New().WithMaxGoroutines(e.EngineConfig.MaxParallelExecutions) e.getExecutionPool = pool.New().WithMaxGoroutines(e.EngineConfig.MaxParallelExecutions) e.backgroundJobContext, e.backgroundJobCancel = context.WithCancel(ctx) // Decide your own UUID if not provided if e.EngineConfig.SourceUUID == uuid.Nil { e.EngineConfig.SourceUUID = uuid.New() } err := e.connect() //nolint:contextcheck // context is passed in through backgroundJobContext if err != nil { _ = e.SendHeartbeat(e.backgroundJobContext, err) //nolint:contextcheck return fmt.Errorf("could not connect to NATS: %w", err) } // Start background jobs e.StartSendingHeartbeats(e.backgroundJobContext) //nolint:contextcheck return nil } // subscribe Subscribes to a subject using the current NATS connection. // Remember to use sdp's genhandler to get a nats.MsgHandler with otel propagation and protobuf marshaling func (e *Engine) subscribe(subject string, handler nats.MsgHandler) error { var err error e.natsConnectionMutex.Lock() defer e.natsConnectionMutex.Unlock() if e.natsConnection.Underlying() == nil { return errors.New("cannot subscribe. NATS connection is nil") } log.WithFields(log.Fields{ "queueName": e.EngineConfig.NATSQueueName, "subject": subject, "engineName": e.EngineConfig.SourceName, }).Debug("creating NATS subscription") if e.EngineConfig.NATSQueueName == "" { _, err = e.natsConnection.Subscribe(subject, handler) } else { _, err = e.natsConnection.QueueSubscribe(subject, e.EngineConfig.NATSQueueName, handler) } if err != nil { return fmt.Errorf("error subscribing to NATS: %w", err) } return nil } // Stop Stops the engine running and disconnects from NATS func (e *Engine) Stop() error { e.disconnect() // Stop purging and clear the cache if e.backgroundJobCancel != nil { e.backgroundJobCancel() } if e.heartbeatCancel != nil { e.heartbeatCancel() } return nil } // Restart Restarts the engine. If called in parallel, subsequent calls are // ignored until the restart is completed func (e *Engine) Restart(ctx context.Context) error { e.restartMutex.Lock() defer e.restartMutex.Unlock() err := e.Stop() if err != nil { return fmt.Errorf("Restart.Stop: %w", err) } err = e.Start(ctx) return fmt.Errorf("Restart.Start: %w", err) } // IsNATSConnected returns whether the engine is connected to NATS func (e *Engine) IsNATSConnected() bool { e.natsConnectionMutex.Lock() defer e.natsConnectionMutex.Unlock() if e.natsConnection == nil { return false } if conn := e.natsConnection.Underlying(); conn != nil { return conn.IsConnected() } return false } // LivenessHealthCheck reports only engine initialization/health (NATS + heartbeat status). // Kubernetes runs liveness/startup independently from readiness; adapter checks do NOT belong here. // See: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#container-probes func (e *Engine) LivenessHealthCheck(ctx context.Context) error { span := trace.SpanFromContext(ctx) e.natsConnectionMutex.Lock() var ( encodedConn = e.natsConnection underlying *nats.Conn ) if encodedConn != nil { underlying = encodedConn.Underlying() } e.natsConnectionMutex.Unlock() natsConnected := underlying != nil && underlying.IsConnected() // Read memory stats and add them to the span memStats := tracing.ReadMemoryStats() tracing.SetMemoryAttributes(span, "ovm.healthcheck", memStats) span.SetAttributes( attribute.String("ovm.engine.name", e.EngineConfig.SourceName), attribute.String("ovm.sdp.source_name", e.EngineConfig.SourceName), attribute.Bool("ovm.nats.connected", natsConnected), attribute.Int("ovm.discovery.listExecutionPoolCount", int(listExecutionPoolCount.Load())), attribute.Int("ovm.discovery.getExecutionPoolCount", int(getExecutionPoolCount.Load())), ) if underlying != nil { span.SetAttributes( attribute.String("ovm.nats.serverId", underlying.ConnectedServerId()), attribute.String("ovm.nats.url", underlying.ConnectedUrl()), attribute.Int64("ovm.nats.reconnects", int64(underlying.Reconnects)), //nolint:gosec // Reconnects is always a small positive number ) } if !natsConnected { return errors.New("NATS connection is not connected") } // Check if heartbeats are failing to submit to api-server // This fails healthz faster than api-server marks sources as DISCONNECTED, // allowing seamless pod recycling without customer-visible downtime if e.EngineConfig.HeartbeatOptions != nil && e.EngineConfig.HeartbeatOptions.Frequency > 0 { e.heartbeatStatusMutex.RLock() lastSuccessfulHeartbeat := e.lastSuccessfulHeartbeat lastHeartbeatError := e.lastHeartbeatError e.heartbeatStatusMutex.RUnlock() // Only check if we've had at least one successful heartbeat // This allows initial startup grace period if !lastSuccessfulHeartbeat.IsZero() { // Healthz fails at 2.0x frequency, api-server marks DISCONNECTED at 2.5x // This 0.5x buffer allows time for pod recycling healthzFailureThreshold := lastSuccessfulHeartbeat.Add(time.Duration(float64(e.EngineConfig.HeartbeatOptions.Frequency) * 2.0)) now := time.Now() if now.After(healthzFailureThreshold) && lastHeartbeatError != nil { return fmt.Errorf("heartbeat submission to api-server has been failing: %w (last successful heartbeat: %v, threshold: %v)", lastHeartbeatError, lastSuccessfulHeartbeat, healthzFailureThreshold) } } } return nil } // ReadinessHealthCheck reports whether adapters are ready to serve requests. // It must not call LivenessHealthCheck; readiness should reflect adapter health only. // See: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-readiness-probes func (e *Engine) ReadinessHealthCheck(ctx context.Context) error { span := trace.SpanFromContext(ctx) span.SetAttributes( attribute.String("ovm.healthcheck.type", "readiness"), ) if !e.AreAdaptersInitialized() { return errors.New("adapters not yet initialized") } // Check for persistent initialization errors first if initErr := e.GetInitError(); initErr != nil { return fmt.Errorf("source initialization failed: %w", initErr) } // Check adapter-specific health using the ReadinessCheck function if e.EngineConfig.HeartbeatOptions != nil && e.EngineConfig.HeartbeatOptions.ReadinessCheck != nil { if err := e.EngineConfig.HeartbeatOptions.ReadinessCheck(ctx); err != nil { return err } } return nil } // HandleCancelQuery Takes a CancelQuery and cancels that query if it exists func (e *Engine) HandleCancelQuery(ctx context.Context, cancelQuery *sdp.CancelQuery) { span := trace.SpanFromContext(ctx) span.SetName("HandleCancelQuery") span.SetAttributes( attribute.String("ovm.sdp.source_name", e.EngineConfig.SourceName), ) u, err := uuid.FromBytes(cancelQuery.GetUUID()) if err != nil { log.Errorf("Error parsing UUID for cancel query: %v", err) return } rt, err := e.GetTrackedQuery(u) if err != nil { log.WithFields(log.Fields{ "UUID": u.String(), }).Debug("Received cancel query for unknown UUID") return } if rt != nil && rt.Cancel != nil { log.WithFields(log.Fields{ "UUID": u.String(), }).Debug("Cancelling query") rt.Cancel() } } func (e *Engine) HandleLogRecordsRequest(ctx context.Context, replyTo string, request *sdp.NATSGetLogRecordsRequest) { span := trace.SpanFromContext(ctx) span.SetName("HandleLogRecordsRequest") span.SetAttributes( attribute.String("ovm.sdp.source_name", e.EngineConfig.SourceName), ) if !strings.HasPrefix(replyTo, "logs.records.") { sentry.CaptureException(fmt.Errorf("received log records request with invalid reply-to header: %s", replyTo)) return } err := e.natsConnection.Publish(ctx, replyTo, &sdp.NATSGetLogRecordsResponse{ Content: &sdp.NATSGetLogRecordsResponse_Status{ Status: &sdp.NATSGetLogRecordsResponseStatus{ Status: sdp.NATSGetLogRecordsResponseStatus_STARTED, }, }, }) if err != nil { sentry.CaptureException(fmt.Errorf("error publishing log records STARTED response: %w", err)) return } // ensure that we send an error response if the HandleLogRecordsRequestWithErrors call panics defer func() { if r := recover(); r != nil { sentry.CaptureException(fmt.Errorf("panic in log records request handler: %v", r)) err = e.natsConnection.Publish(ctx, replyTo, &sdp.NATSGetLogRecordsResponse{ Content: &sdp.NATSGetLogRecordsResponse_Status{ Status: &sdp.NATSGetLogRecordsResponseStatus{ Status: sdp.NATSGetLogRecordsResponseStatus_ERRORED, Error: sdp.NewLocalSourceError(connect.CodeInternal, "panic in log records request handler"), }, }, }) if err != nil { sentry.CaptureException(fmt.Errorf("error publishing log records FINISHED response: %w", err)) return } } }() srcErr := e.HandleLogRecordsRequestWithErrors(ctx, replyTo, request) if srcErr != nil { err = e.natsConnection.Publish(ctx, replyTo, &sdp.NATSGetLogRecordsResponse{ Content: &sdp.NATSGetLogRecordsResponse_Status{ Status: &sdp.NATSGetLogRecordsResponseStatus{ Status: sdp.NATSGetLogRecordsResponseStatus_ERRORED, Error: srcErr, }, }, }) if err != nil { sentry.CaptureException(fmt.Errorf("error publishing log records FINISHED response: %w", err)) return } return } err = e.natsConnection.Publish(ctx, replyTo, &sdp.NATSGetLogRecordsResponse{ Content: &sdp.NATSGetLogRecordsResponse_Status{ Status: &sdp.NATSGetLogRecordsResponseStatus{ Status: sdp.NATSGetLogRecordsResponseStatus_FINISHED, }, }, }) if err != nil { sentry.CaptureException(fmt.Errorf("error publishing log records FINISHED response: %w", err)) return } } func (e *Engine) HandleLogRecordsRequestWithErrors(ctx context.Context, replyTo string, natsRequest *sdp.NATSGetLogRecordsRequest) *sdp.SourceError { if e.logAdapter == nil { return sdp.NewLocalSourceError(connect.CodeInvalidArgument, "no logs adapter registered") } if natsRequest == nil { return sdp.NewLocalSourceError(connect.CodeInvalidArgument, "received nil log records request") } req := natsRequest.GetRequest() if req == nil { return sdp.NewLocalSourceError(connect.CodeInvalidArgument, "received nil log records request body") } err := req.Validate() if err != nil { return sdp.NewLocalSourceError(connect.CodeInvalidArgument, fmt.Sprintf("invalid log records request: %v", err)) } if !slices.Contains(e.logAdapter.Scopes(), req.GetScope()) { return sdp.NewLocalSourceError(connect.CodeInvalidArgument, fmt.Sprintf("scope %s is not available", req.GetScope())) } span := trace.SpanFromContext(ctx) span.SetAttributes( attribute.String("ovm.logs.replyTo", replyTo), attribute.String("ovm.logs.scope", req.GetScope()), attribute.String("ovm.logs.query", req.GetQuery()), attribute.String("ovm.logs.from", req.GetFrom().String()), attribute.String("ovm.logs.to", req.GetTo().String()), attribute.Int("ovm.logs.maxRecords", int(req.GetMaxRecords())), attribute.Bool("ovm.logs.startFromOldest", req.GetStartFromOldest()), attribute.String("ovm.sdp.source_name", e.EngineConfig.SourceName), ) stream := &LogRecordsStreamImpl{ subject: replyTo, stream: e.natsConnection, } err = e.logAdapter.Get(ctx, req, stream) span.SetAttributes( attribute.Int("ovm.logs.numResponses", stream.responses), attribute.Int("ovm.logs.numRecords", stream.records), ) srcErr := &sdp.SourceError{} if errors.As(err, &srcErr) { return srcErr } if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded { return sdp.NewLocalSourceError(connect.CodeDeadlineExceeded, "log records request deadline exceeded") } if err != nil { return sdp.NewLocalSourceError(connect.CodeInternal, fmt.Sprintf("error handling log records request: %v", err)) } return nil } // ClearAdapters Deletes all adapters from the engine, allowing new adapters to be // added using `AddAdapter()`. Note that this requires a restart using // `Restart()` in order to take effect func (e *Engine) ClearAdapters() { e.sh.ClearAllAdapters() } // IsWildcard checks if a string is the wildcard. Use this instead of // implementing the wildcard check everywhere so that if we need to change the // wildcard at a later date we can do so here func IsWildcard(s string) bool { return s == sdp.WILDCARD } // SetLogAdapter registers a single LogAdapter with the engine. // Returns an error when there is already a log adapter registered. func (e *Engine) SetLogAdapter(adapter LogAdapter) error { if adapter == nil { return errors.New("log adapter cannot be nil") } e.logAdapterMu.Lock() defer e.logAdapterMu.Unlock() if e.logAdapter != nil { return errors.New("log adapter already registered") } e.logAdapter = adapter return nil } // GetAvailableScopesAndMetadata returns the available scopes and adapter metadata // from all visible adapters. This is useful for heartbeats and other reporting. func (e *Engine) GetAvailableScopesAndMetadata() ([]string, []*sdp.AdapterMetadata) { // Get available types and scopes availableScopesMap := map[string]bool{} adapterMetadata := []*sdp.AdapterMetadata{} for _, adapter := range e.sh.VisibleAdapters() { for _, scope := range adapter.Scopes() { availableScopesMap[scope] = true } adapterMetadata = append(adapterMetadata, adapter.Metadata()) } // Extract slices from maps availableScopes := []string{} for s := range availableScopesMap { availableScopes = append(availableScopes, s) } return availableScopes, adapterMetadata } // AdaptersByType returns adapters of the specified type. This is useful for health checks. func (e *Engine) AdaptersByType(typ string) []Adapter { return e.sh.AdaptersByType(typ) } // SetInitError stores a persistent initialization error that will be reported via heartbeat and readiness checks. // This should be called when source initialization fails in a way that prevents adapters from being added, // but the process should continue running to serve probes and heartbeats (avoiding CrashLoopBackOff). // // Pass nil to clear a previously set error (e.g. after successful retry/restart). // // Example usage: // // if err := initializeAdapters(); err != nil { // e.SetInitError(fmt.Errorf("adapter initialization failed: %w", err)) // // Continue running - pod stays Running with readiness failing // } func (e *Engine) SetInitError(err error) { e.initErrorMutex.Lock() defer e.initErrorMutex.Unlock() e.initError = err } // GetInitError returns the persistent initialization error if any. // Returns nil if no init error is set or if it was cleared via SetInitError(nil). func (e *Engine) GetInitError() error { e.initErrorMutex.RLock() defer e.initErrorMutex.RUnlock() return e.initError } // MarkAdaptersInitialized records that adapters have been successfully registered // and the source is ready to serve queries. This is called automatically by // InitialiseAdapters on success. Sources that do their own initialization // (without InitialiseAdapters) must call this explicitly after adding adapters. func (e *Engine) MarkAdaptersInitialized() { e.adaptersInitialized.Store(true) } // AreAdaptersInitialized reports whether adapters have been successfully registered. func (e *Engine) AreAdaptersInitialized() bool { return e.adaptersInitialized.Load() } // InitialiseAdapters retries initFn with exponential backoff (capped at // 5 minutes) until it succeeds or ctx is cancelled. It blocks the caller. // // This is intended for adapter initialization that makes API calls to upstream // services and may fail transiently. Because it blocks, the caller can // safely set up namespace watches or other reload mechanisms after it returns // without racing against a background retry goroutine. // // On each attempt: // - ClearAdapters() is called to remove any leftovers from previous attempts. // - initFn is called. The init error is updated via SetInitError immediately // (cleared on success, set on failure) and then a heartbeat is sent so the // API/UI always reflects the current status. // - On success, StartSendingHeartbeats is called and the function returns. // // The caller should have already called Start() before calling this. func (e *Engine) InitialiseAdapters(ctx context.Context, initFn func(ctx context.Context) error) { b := backoff.NewExponentialBackOff() b.MaxInterval = 5 * time.Minute tick := backoff.NewTicker(b) defer tick.Stop() for { select { case <-ctx.Done(): return case _, ok := <-tick.C: if !ok { // Backoff exhausted (shouldn't happen with default MaxElapsedTime=0) return } e.ClearAdapters() err := initFn(ctx) if err != nil { e.SetInitError(fmt.Errorf("adapter initialisation failed: %w", err)) log.WithError(err).Warn("Adapter initialisation failed, will retry") } else { // Clear any previous init error before the heartbeat so the // API/UI immediately sees the healthy status. e.SetInitError(nil) e.MarkAdaptersInitialized() } // Send heartbeat regardless of outcome so the API/UI reflects current status if hbErr := e.SendHeartbeat(ctx, nil); hbErr != nil { log.WithError(hbErr).Error("Error sending heartbeat during adapter initialisation") } if err != nil { continue } e.StartSendingHeartbeats(ctx) return } } } // LivenessProbeHandlerFunc returns an HTTP handler function for liveness probes. // This checks only engine initialization (NATS connection, heartbeats) and does NOT check adapter-specific health. func (e *Engine) LivenessProbeHandlerFunc() func(http.ResponseWriter, *http.Request) { return func(rw http.ResponseWriter, r *http.Request) { ctx, span := tracing.Tracer().Start(r.Context(), "healthcheck.liveness") defer span.End() err := e.LivenessHealthCheck(ctx) if err != nil { log.WithContext(ctx).WithError(err).Error("Liveness check failed") http.Error(rw, err.Error(), http.StatusServiceUnavailable) return } fmt.Fprint(rw, "ok") } } // SetReadinessCheck sets the readiness check and ensures HeartbeatOptions is initialized. func (e *Engine) SetReadinessCheck(check func(context.Context) error) { if e.EngineConfig.HeartbeatOptions == nil { e.EngineConfig.HeartbeatOptions = &HeartbeatOptions{} } e.EngineConfig.HeartbeatOptions.ReadinessCheck = check } // ReadinessProbeHandlerFunc returns an HTTP handler function for readiness probes. // This checks adapter-specific health only (not engine/liveness). func (e *Engine) ReadinessProbeHandlerFunc() func(http.ResponseWriter, *http.Request) { return func(rw http.ResponseWriter, r *http.Request) { ctx, span := tracing.Tracer().Start(r.Context(), "healthcheck.readiness") defer span.End() err := e.ReadinessHealthCheck(ctx) if err != nil { log.WithContext(ctx).WithError(err).Error("Readiness check failed") http.Error(rw, err.Error(), http.StatusServiceUnavailable) return } fmt.Fprint(rw, "ok") } } // ServeHealthProbes starts an HTTP server for Kubernetes health probes on the given port. // Registers /healthz/alive (liveness) and /healthz/ready (readiness). // Runs in a goroutine. Use for sources that only need health checks on the given port. func (e *Engine) ServeHealthProbes(port int) { mux := http.NewServeMux() mux.HandleFunc("/healthz/alive", e.LivenessProbeHandlerFunc()) mux.HandleFunc("/healthz/ready", e.ReadinessProbeHandlerFunc()) logFields := log.Fields{"port": port} if e.EngineConfig != nil { logFields["ovm.engine.type"] = e.EngineConfig.EngineType logFields["ovm.engine.name"] = e.EngineConfig.SourceName } log.WithFields(logFields).Debug("Starting healthcheck server with endpoints: /healthz/alive, /healthz/ready") go func() { defer sentry.Recover() server := &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, } err := server.ListenAndServe() log.WithError(err).WithFields(logFields).Error("Could not start HTTP server for health checks") }() } ================================================ FILE: go/discovery/engine_initerror_test.go ================================================ package discovery import ( "context" "errors" "fmt" "strings" "sync" "testing" "time" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" ) func TestSetInitError(t *testing.T) { e := &Engine{ initError: nil, initErrorMutex: sync.RWMutex{}, } testErr := errors.New("initialization failed") e.SetInitError(testErr) // Direct pointer comparison is intentional here - we want to verify the exact error object is stored if e.initError == nil || e.initError.Error() != testErr.Error() { t.Errorf("expected initError to be %v, got %v", testErr, e.initError) } } func TestGetInitError(t *testing.T) { e := &Engine{ initError: nil, initErrorMutex: sync.RWMutex{}, } // Test nil case if err := e.GetInitError(); err != nil { t.Errorf("expected nil error, got %v", err) } // Test with error set testErr := errors.New("test error") e.initError = testErr if err := e.GetInitError(); err == nil || err.Error() != testErr.Error() { t.Errorf("expected error to be %v, got %v", testErr, err) } } func TestSetInitErrorNil(t *testing.T) { e := &Engine{ initError: errors.New("previous error"), initErrorMutex: sync.RWMutex{}, } // Clear the error e.SetInitError(nil) if e.initError != nil { t.Errorf("expected initError to be nil after clearing, got %v", e.initError) } if err := e.GetInitError(); err != nil { t.Errorf("expected GetInitError to return nil after clearing, got %v", err) } } func TestInitErrorConcurrentAccess(t *testing.T) { e := &Engine{ initError: nil, initErrorMutex: sync.RWMutex{}, } // Test concurrent access from multiple goroutines var wg sync.WaitGroup iterations := 100 // Writers for i := range 10 { wg.Add(1) go func(id int) { defer wg.Done() for j := range iterations { e.SetInitError(fmt.Errorf("error from goroutine %d iteration %d", id, j)) } }(i) } // Readers for range 10 { wg.Go(func() { for range iterations { _ = e.GetInitError() } }) } wg.Wait() // Should not panic - error should be one of the written values or nil finalErr := e.GetInitError() if finalErr == nil { t.Log("Final error is nil (acceptable in concurrent test)") } else { t.Logf("Final error: %v", finalErr) } } func TestReadinessHealthCheckWithInitError(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ ReadinessCheck: func(ctx context.Context) error { // Adapter health is fine return nil }, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } // Mark adapters initialized so we're only testing initError behavior e.MarkAdaptersInitialized() ctx := context.Background() // Readiness should pass when no init error if err := e.ReadinessHealthCheck(ctx); err != nil { t.Errorf("expected readiness to pass with no init error, got: %v", err) } // Set an init error testErr := errors.New("AWS AssumeRole denied") e.SetInitError(testErr) // Readiness should now fail with the init error err = e.ReadinessHealthCheck(ctx) if err == nil { t.Error("expected readiness to fail with init error, got nil") } else if !errors.Is(err, testErr) { t.Errorf("expected readiness error to wrap init error, got: %v", err) } // Clear the init error e.SetInitError(nil) // Readiness should pass again if err := e.ReadinessHealthCheck(ctx); err != nil { t.Errorf("expected readiness to pass after clearing init error, got: %v", err) } } func TestSendHeartbeatWithInitError(t *testing.T) { requests := make(chan *connect.Request[sdp.SubmitSourceHeartbeatRequest], 10) responses := make(chan *connect.Response[sdp.SubmitSourceHeartbeatResponse], 10) ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ ManagementClient: testHeartbeatClient{ Requests: requests, Responses: responses, }, Frequency: 0, // Disable automatic heartbeats ReadinessCheck: func(ctx context.Context) error { return nil // Adapters are fine }, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } // Mark adapters initialized so we're only testing initError behavior e.MarkAdaptersInitialized() ctx := context.Background() // Send heartbeat with init error testErr := errors.New("configuration error: invalid credentials") e.SetInitError(testErr) responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } err = e.SendHeartbeat(ctx, nil) if err != nil { t.Errorf("expected SendHeartbeat to succeed, got: %v", err) } // Verify the heartbeat included the init error req := <-requests if req.Msg.GetError() == "" { t.Error("expected heartbeat to include error, got empty string") } else if !strings.Contains(req.Msg.GetError(), testErr.Error()) { t.Errorf("expected heartbeat error to contain %q, got: %q", testErr.Error(), req.Msg.GetError()) } } func TestSendHeartbeatWithInitErrorAndCustomError(t *testing.T) { requests := make(chan *connect.Request[sdp.SubmitSourceHeartbeatRequest], 10) responses := make(chan *connect.Response[sdp.SubmitSourceHeartbeatResponse], 10) ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ ManagementClient: testHeartbeatClient{ Requests: requests, Responses: responses, }, Frequency: 0, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } // Mark adapters initialized so we're only testing initError + custom error behavior e.MarkAdaptersInitialized() ctx := context.Background() // Set init error and send heartbeat with custom error initErr := errors.New("init failed: invalid config") customErr := errors.New("custom error: readiness failed") e.SetInitError(initErr) responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } err = e.SendHeartbeat(ctx, customErr) if err != nil { t.Errorf("expected SendHeartbeat to succeed, got: %v", err) } // Verify both errors are included in the heartbeat req := <-requests if req.Msg.GetError() == "" { t.Error("expected heartbeat to include errors, got empty string") } else { errMsg := req.Msg.GetError() // Both errors should be in the joined error string if !strings.Contains(errMsg, initErr.Error()) { t.Errorf("expected heartbeat error to include init error %q, got: %q", initErr.Error(), errMsg) } if !strings.Contains(errMsg, customErr.Error()) { t.Errorf("expected heartbeat error to include custom error %q, got: %q", customErr.Error(), errMsg) } } } func TestInitialiseAdapters_Success(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ Frequency: 0, // Disable automatic heartbeats from StartSendingHeartbeats }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } // Set an init error to verify it gets cleared on success e.SetInitError(errors.New("previous error")) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var called bool e.InitialiseAdapters(ctx, func(ctx context.Context) error { called = true return nil }) if !called { t.Error("initFn was not called") } if err := e.GetInitError(); err != nil { t.Errorf("expected init error to be cleared after success, got: %v", err) } if !e.AreAdaptersInitialized() { t.Error("expected adaptersInitialized to be true after successful InitialiseAdapters") } } func TestInitialiseAdapters_RetryThenSuccess(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ Frequency: 0, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() attempts := 0 e.InitialiseAdapters(ctx, func(ctx context.Context) error { attempts++ if attempts < 3 { return fmt.Errorf("transient error attempt %d", attempts) } return nil }) if attempts < 3 { t.Errorf("expected at least 3 attempts, got %d", attempts) } if err := e.GetInitError(); err != nil { t.Errorf("expected init error to be cleared after eventual success, got: %v", err) } } func TestInitialiseAdapters_ContextCancelled(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ Frequency: 0, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } ctx, cancel := context.WithCancel(context.Background()) var callCount int // InitialiseAdapters blocks; cancel ctx after a short delay so it returns time.AfterFunc(500*time.Millisecond, cancel) done := make(chan struct{}) go func() { e.InitialiseAdapters(ctx, func(ctx context.Context) error { callCount++ return errors.New("always fails") }) close(done) }() select { case <-done: // InitialiseAdapters returned (ctx was cancelled) case <-time.After(5 * time.Second): t.Fatal("InitialiseAdapters did not return after context cancellation") } if callCount == 0 { t.Error("expected initFn to be called at least once before context cancellation") } if err := e.GetInitError(); err == nil { t.Error("expected init error to be set after context cancellation with failures") } } func TestReadinessFailsBeforeInitialization(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ ReadinessCheck: func(ctx context.Context) error { return nil }, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } ctx := context.Background() err = e.ReadinessHealthCheck(ctx) if err == nil { t.Fatal("expected readiness to fail before adapters initialized, got nil") } if !strings.Contains(err.Error(), "adapters not yet initialized") { t.Errorf("expected error to contain 'adapters not yet initialized', got: %v", err) } } func TestReadinessPassesAfterInitialization(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ ReadinessCheck: func(ctx context.Context) error { return nil }, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } e.MarkAdaptersInitialized() ctx := context.Background() if err := e.ReadinessHealthCheck(ctx); err != nil { t.Errorf("expected readiness to pass after MarkAdaptersInitialized, got: %v", err) } } func TestHeartbeatIncludesUninitializedError(t *testing.T) { requests := make(chan *connect.Request[sdp.SubmitSourceHeartbeatRequest], 10) responses := make(chan *connect.Response[sdp.SubmitSourceHeartbeatResponse], 10) ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ ManagementClient: testHeartbeatClient{ Requests: requests, Responses: responses, }, Frequency: 0, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } // Do NOT call MarkAdaptersInitialized -- engine is freshly created responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } ctx := context.Background() err = e.SendHeartbeat(ctx, nil) if err != nil { t.Fatalf("expected SendHeartbeat to succeed, got: %v", err) } req := <-requests if req.Msg.GetError() == "" { t.Fatal("expected heartbeat to include error before initialization, got empty string") } if !strings.Contains(req.Msg.GetError(), "adapters not yet initialized") { t.Errorf("expected heartbeat error to contain 'adapters not yet initialized', got: %q", req.Msg.GetError()) } } func TestHeartbeatClearsAfterInitialization(t *testing.T) { requests := make(chan *connect.Request[sdp.SubmitSourceHeartbeatRequest], 10) responses := make(chan *connect.Response[sdp.SubmitSourceHeartbeatResponse], 10) ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ ManagementClient: testHeartbeatClient{ Requests: requests, Responses: responses, }, Frequency: 0, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } e.MarkAdaptersInitialized() responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } ctx := context.Background() err = e.SendHeartbeat(ctx, nil) if err != nil { t.Fatalf("expected SendHeartbeat to succeed, got: %v", err) } req := <-requests if req.Msg.GetError() != "" { t.Errorf("expected heartbeat to have no error after initialization, got: %q", req.Msg.GetError()) } } func TestInitialiseAdapters_SetsInitializedFlag(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ Frequency: 0, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } if e.AreAdaptersInitialized() { t.Fatal("expected adaptersInitialized to be false on new engine") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() e.InitialiseAdapters(ctx, func(ctx context.Context) error { return nil }) if !e.AreAdaptersInitialized() { t.Error("expected adaptersInitialized to be true after InitialiseAdapters success") } } func TestInitialiseAdapters_DoesNotSetFlagOnFailure(t *testing.T) { ec := &EngineConfig{ EngineType: "test", SourceName: "test-source", HeartbeatOptions: &HeartbeatOptions{ Frequency: 0, }, } e, err := NewEngine(ec) if err != nil { t.Fatalf("failed to create engine: %v", err) } ctx, cancel := context.WithCancel(context.Background()) time.AfterFunc(500*time.Millisecond, cancel) done := make(chan struct{}) go func() { e.InitialiseAdapters(ctx, func(ctx context.Context) error { return errors.New("always fails") }) close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("InitialiseAdapters did not return after context cancellation") } if e.AreAdaptersInitialized() { t.Error("expected adaptersInitialized to remain false when init always fails") } } ================================================ FILE: go/discovery/engine_test.go ================================================ package discovery import ( "context" "fmt" "os" "sync" "testing" "time" "github.com/google/uuid" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats-server/v2/test" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "golang.org/x/oauth2" ) func newEngine(t *testing.T, name string, no *auth.NATSOptions, eConn sdp.EncodedConnection, adapters ...Adapter) *Engine { t.Helper() if no != nil && eConn != nil { t.Fatal("Cannot provide both NATSOptions and EncodedConnection") } ec := EngineConfig{ MaxParallelExecutions: 10, SourceName: name, NATSQueueName: "test", } if no != nil { ec.NATSOptions = no if no.TokenClient == nil { ec.Unauthenticated = true } } else if eConn == nil { ec.NATSOptions = &auth.NATSOptions{ NumRetries: 5, RetryDelay: time.Second, Servers: NatsTestURLs, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, TokenClient: GetTestOAuthTokenClient(t, "org_hdeUXbB55sMMvJLa"), } } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } if eConn != nil { e.natsConnection = eConn } if err := e.AddAdapters(adapters...); err != nil { t.Fatalf("Error adding adapters: %v", err) } return e } func newStartedEngine(t *testing.T, name string, no *auth.NATSOptions, eConn sdp.EncodedConnection, adapters ...Adapter) *Engine { t.Helper() e := newEngine(t, name, no, eConn, adapters...) err := e.Start(t.Context()) if err != nil { t.Fatalf("Error starting Engine: %v", err) } t.Cleanup(func() { err = e.Stop() if err != nil { t.Errorf("Error stopping Engine: %v", err) } }) return e } func TestTrackQuery(t *testing.T) { t.Run("With normal query", func(t *testing.T) { t.Parallel() e := newStartedEngine(t, "TestTrackQuery_normal", nil, nil) u := uuid.New() qt := QueryTracker{ Engine: e, Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_LIST, RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 10, }, UUID: u[:], }, } e.TrackQuery(u, &qt) if got, err := e.GetTrackedQuery(u); err == nil { if got != &qt { t.Errorf("Got mismatched QueryTracker objects %v and %v", got, &qt) } } else { t.Error(err) } }) t.Run("With many queries", func(t *testing.T) { t.Parallel() e := newStartedEngine(t, "TestTrackQuery_many", nil, nil) var wg sync.WaitGroup for i := range 1000 { wg.Add(1) go func(i int) { defer wg.Done() u := uuid.New() qt := QueryTracker{ Engine: e, Query: &sdp.Query{ Type: "person", Query: fmt.Sprintf("person-%v", i), Method: sdp.QueryMethod_GET, RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 10, }, UUID: u[:], }, } e.TrackQuery(u, &qt) }(i) } wg.Wait() if len(e.trackedQueries) != 1000 { t.Errorf("Expected 1000 tracked queries, got %v", len(e.trackedQueries)) } }) } func TestDeleteTrackedQuery(t *testing.T) { t.Parallel() e := newStartedEngine(t, "TestDeleteTrackedQuery", nil, nil) var wg sync.WaitGroup // Add and delete many query in parallel for i := 1; i < 1000; i++ { wg.Add(1) go func(i int) { defer wg.Done() u := uuid.New() qt := QueryTracker{ Engine: e, Query: &sdp.Query{ Type: "person", Query: fmt.Sprintf("person-%v", i), Method: sdp.QueryMethod_GET, RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 10, }, UUID: u[:], }, } e.TrackQuery(u, &qt) wg.Add(1) go func(u uuid.UUID) { defer wg.Done() e.DeleteTrackedQuery(u) }(u) }(i) } wg.Wait() if len(e.trackedQueries) != 0 { t.Errorf("Expected 0 tracked queries, got %v", len(e.trackedQueries)) } } func TestNats(t *testing.T) { SkipWithoutNats(t) ec := EngineConfig{ MaxParallelExecutions: 10, SourceName: "nats-test", Unauthenticated: true, NATSOptions: &auth.NATSOptions{ NumRetries: 5, RetryDelay: time.Second, Servers: NatsTestURLs, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, NATSQueueName: "test", } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } adapter := TestAdapter{} adapter.cache = sdpcache.NewNoOpCache() err = e.AddAdapters( &adapter, &TestAdapter{ ReturnScopes: []string{ sdp.WILDCARD, }, ReturnName: "test-adapter", ReturnType: "test", cache: sdpcache.NewNoOpCache(), }, ) if err != nil { t.Fatal(err) } t.Run("Starting", func(t *testing.T) { err := e.Start(t.Context()) if err != nil { t.Error(err) } if e.natsConnection.Underlying().NumSubscriptions() != 4 { t.Errorf("Expected engine to have 4 subscriptions, got %v", e.natsConnection.Underlying().NumSubscriptions()) } }) t.Run("Restarting", func(t *testing.T) { err := e.Stop() if err != nil { t.Error(err) } err = e.Start(t.Context()) if err != nil { t.Error(err) } if e.natsConnection.Underlying().NumSubscriptions() != 4 { t.Errorf("Expected engine to have 4 subscriptions, got %v", e.natsConnection.Underlying().NumSubscriptions()) } }) t.Run("Handling a basic query", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) query := &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "basic", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "test", } _, _, _, err := sdp.RunSourceQuerySync(context.Background(), query, sdp.DefaultStartTimeout, e.natsConnection) if err != nil { t.Error(err) } if len(adapter.GetCalls) != 1 { t.Errorf("expected 1 get call, got %v: %v", len(adapter.GetCalls), adapter.GetCalls) } }) t.Run("stopping", func(t *testing.T) { err := e.Stop() if err != nil { t.Error(err) } }) } func TestNatsCancel(t *testing.T) { SkipWithoutNats(t) ec := EngineConfig{ MaxParallelExecutions: 1, SourceName: "nats-test", Unauthenticated: true, NATSOptions: &auth.NATSOptions{ NumRetries: 5, RetryDelay: time.Second, Servers: NatsTestURLs, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, NATSQueueName: "test", } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } adapter := SpeedTestAdapter{ QueryDelay: 2 * time.Second, ReturnType: "person", ReturnScopes: []string{"test"}, } if err := e.AddAdapters(&adapter); err != nil { t.Fatalf("Error adding adapters: %v", err) } t.Run("Starting", func(t *testing.T) { err := e.Start(t.Context()) if err != nil { t.Error(err) } }) t.Run("Cancelling queries", func(t *testing.T) { conn := e.natsConnection u := uuid.New() query := &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "foo", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 100, }, Scope: "*", UUID: u[:], } responses := make(chan *sdp.QueryResponse, 1000) progress, err := sdp.RunSourceQuery(t.Context(), query, sdp.DefaultStartTimeout, conn, responses) if err != nil { t.Error(err) } time.Sleep(250 * time.Millisecond) err = conn.Publish(context.Background(), "cancel.all", &sdp.CancelQuery{ UUID: u[:], }) if err != nil { t.Error(err) } // Read and discard all items and errors until they are closed for range responses { } time.Sleep(250 * time.Millisecond) if progress.Progress().Cancelled != 1 { t.Errorf("Expected query to be cancelled, got\n%v", progress.String()) } }) t.Run("stopping", func(t *testing.T) { err := e.Stop() if err != nil { t.Error(err) } }) } func TestNatsConnections(t *testing.T) { t.Run("with a bad hostname", func(t *testing.T) { ec := EngineConfig{ MaxParallelExecutions: 1, SourceName: "nats-test", Unauthenticated: true, NATSOptions: &auth.NATSOptions{ Servers: []string{"nats://bad.server"}, ConnectionName: "test-disconnection", ConnectionTimeout: time.Second, MaxReconnects: 1, }, NATSQueueName: "test", } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } err = e.Start(t.Context()) if err == nil { t.Error("expected error but got nil") } }) t.Run("with a server that disconnects", func(t *testing.T) { // We are running a custom server here so that we can control its lifecycle opts := test.DefaultTestOptions // Need to change this to avoid port clashes in github actions opts.Port = 4111 s := test.RunServer(&opts) if !s.ReadyForConnections(10 * time.Second) { t.Fatal("Could not start goroutine NATS server") } t.Cleanup(func() { if s != nil { s.Shutdown() } }) ec := EngineConfig{ MaxParallelExecutions: 1, SourceName: "nats-test", Unauthenticated: true, NATSOptions: &auth.NATSOptions{ NumRetries: 5, RetryDelay: time.Second, Servers: []string{"127.0.0.1:4111"}, ConnectionName: "test-disconnection", ConnectionTimeout: time.Second, MaxReconnects: 10, ReconnectWait: time.Second, ReconnectJitter: time.Second, }, NATSQueueName: "test", } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } err = e.Start(t.Context()) if err != nil { t.Fatal(err) } t.Log("Stopping NATS server") s.Shutdown() for i := range 21 { if i == 20 { t.Errorf("Engine did not report a NATS disconnect after %v tries", i) } if !e.IsNATSConnected() { break } time.Sleep(time.Second) } // Reset the server s = test.RunServer(&opts) // Wait for the server to start s.ReadyForConnections(10 * time.Second) // Wait 2 more seconds for a reconnect time.Sleep(2 * time.Second) for range 21 { if e.IsNATSConnected() { return } time.Sleep(time.Second) } t.Error("Engine should have reconnected but hasn't") }) t.Run("with a server that takes a while to start", func(t *testing.T) { // We are running a custom server here so that we can control its lifecycle opts := test.DefaultTestOptions // Need to change this to avoid port clashes in github actions opts.Port = 4112 ec := EngineConfig{ MaxParallelExecutions: 1, SourceName: "nats-test", Unauthenticated: true, NATSOptions: &auth.NATSOptions{ NumRetries: 10, RetryDelay: time.Second, Servers: []string{"127.0.0.1:4112"}, ConnectionName: "test-disconnection", ConnectionTimeout: time.Second, MaxReconnects: 10, ReconnectWait: time.Second, ReconnectJitter: time.Second, }, NATSQueueName: "test", } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } var s *server.Server go func() { // Start the server after a delay time.Sleep(2 * time.Second) // We are running a custom server here so that we can control its lifecycle s = test.RunServer(&opts) t.Cleanup(func() { if s != nil { s.Shutdown() } }) }() err = e.Start(t.Context()) if err != nil { t.Fatal(err) } }) } func TestNATSFailureRestart(t *testing.T) { restartTestOption := test.DefaultTestOptions restartTestOption.Port = 4113 // We are running a custom server here so that we can control its lifecycle s := test.RunServer(&restartTestOption) if !s.ReadyForConnections(10 * time.Second) { t.Fatal("Could not start goroutine NATS server") } ec := EngineConfig{ MaxParallelExecutions: 1, SourceName: "nats-test", Unauthenticated: true, NATSOptions: &auth.NATSOptions{ NumRetries: 10, RetryDelay: time.Second, Servers: []string{"127.0.0.1:4113"}, ConnectionName: "test-disconnection", ConnectionTimeout: time.Second, MaxReconnects: 10, ReconnectWait: 100 * time.Millisecond, ReconnectJitter: 10 * time.Millisecond, }, NATSQueueName: "test", } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } e.ConnectionWatchInterval = 1 * time.Second // Connect successfully err = e.Start(t.Context()) if err != nil { t.Fatal(err) } t.Cleanup(func() { err = e.Stop() if err != nil { t.Fatal(err) } }) // Lose the connection t.Log("Stopping NATS server") s.Shutdown() s.WaitForShutdown() // The watcher should keep watching while the nats connection is // RECONNECTING, once it's CLOSED however it won't keep trying to connect so // we want to make sure that the watcher detects this and kills the whole // thing time.Sleep(2 * time.Second) s = test.RunServer(&restartTestOption) if !s.ReadyForConnections(10 * time.Second) { t.Fatal("Could not start goroutine NATS server a second time") } t.Cleanup(func() { s.Shutdown() }) time.Sleep(3 * time.Second) if !e.IsNATSConnected() { t.Error("NATS didn't manage to reconnect") } } func TestNatsAuth(t *testing.T) { SkipWithoutNatsAuth(t) ec := EngineConfig{ MaxParallelExecutions: 1, SourceName: "nats-test", NATSOptions: &auth.NATSOptions{ NumRetries: 5, RetryDelay: time.Second, Servers: NatsTestURLs, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, TokenClient: GetTestOAuthTokenClient(t, "org_hdeUXbB55sMMvJLa"), }, NATSQueueName: "test", } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } adapter := TestAdapter{} adapter.cache = sdpcache.NewNoOpCache() if err := e.AddAdapters( &adapter, &TestAdapter{ ReturnScopes: []string{ sdp.WILDCARD, }, ReturnType: "test", ReturnName: "test-adapter", cache: sdpcache.NewNoOpCache(), }, ); err != nil { t.Fatalf("Error adding adapters: %v", err) } t.Run("Starting", func(t *testing.T) { err := e.Start(t.Context()) if err != nil { t.Fatal(err) } if e.natsConnection.Underlying().NumSubscriptions() != 4 { t.Errorf("Expected engine to have 4 subscriptions, got %v", e.natsConnection.Underlying().NumSubscriptions()) } }) t.Run("Handling a basic query", func(t *testing.T) { t.Cleanup(func() { adapter.ClearCalls() }) query := &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "basic", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "test", } _, _, _, err := sdp.RunSourceQuerySync(t.Context(), query, sdp.DefaultStartTimeout, e.natsConnection) if err != nil { t.Error(err) } if len(adapter.GetCalls) != 1 { t.Errorf("expected 1 get call, got %v: %v", len(adapter.GetCalls), adapter.GetCalls) } }) t.Run("stopping", func(t *testing.T) { err := e.Stop() if err != nil { t.Error(err) } }) } func TestSetupMaxQueryTimeout(t *testing.T) { t.Run("with no value", func(t *testing.T) { ec := EngineConfig{} e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } if e.MaxRequestTimeout != DefaultMaxRequestTimeout { t.Errorf("max request timeout did not default. Got %v expected %v", e.MaxRequestTimeout.String(), DefaultMaxRequestTimeout.String()) } }) t.Run("with a value", func(t *testing.T) { ec := EngineConfig{} e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } e.MaxRequestTimeout = 1 * time.Second if e.MaxRequestTimeout != 1*time.Second { t.Errorf("max request timeout did not take provided value. Got %v expected %v", e.MaxRequestTimeout.String(), (1 * time.Second).String()) } }) } func TestEngineHealthCheckHandlesNilConnection(t *testing.T) { t.Parallel() t.Run("without connection", func(t *testing.T) { t.Parallel() e := newEngine(t, "TestEngineHealthCheckHandlesNilConnection_NoConn", &auth.NATSOptions{}, nil) assertHealthCheckDoesNotPanic(t, e) }) t.Run("with dropped underlying connection", func(t *testing.T) { t.Parallel() e := newEngine(t, "TestEngineHealthCheckHandlesNilConnection_Dropped", &auth.NATSOptions{}, nil) e.natsConnection = &sdp.EncodedConnectionImpl{} assertHealthCheckDoesNotPanic(t, e) }) } func assertHealthCheckDoesNotPanic(t *testing.T, e *Engine) { t.Helper() defer func() { if r := recover(); r != nil { t.Fatalf("LivenessHealthCheck panic: %v", r) } }() ctx := context.Background() if err := e.LivenessHealthCheck(ctx); err == nil { t.Fatalf("expected LivenessHealthCheck to report disconnected NATS") } } var ( testTokenSource oauth2.TokenSource testTokenSourceMu sync.Mutex ) func GetTestOAuthTokenClient(t *testing.T, account string) auth.TokenClient { var domain string var clientID string var clientSecret string var exists bool errorFormat := "environment variable %v not found. Set up your test environment first. See: https://github.com/overmindtech/cli/go/auth0-test-data" // Read secrets form the environment if domain, exists = os.LookupEnv("OVERMIND_NTE_ALLPERMS_DOMAIN"); !exists || domain == "" { t.Errorf(errorFormat, "OVERMIND_NTE_ALLPERMS_DOMAIN") t.Skip("Skipping due to missing environment setup") } if clientID, exists = os.LookupEnv("OVERMIND_NTE_ALLPERMS_CLIENT_ID"); !exists || clientID == "" { t.Errorf(errorFormat, "OVERMIND_NTE_ALLPERMS_CLIENT_ID") t.Skip("Skipping due to missing environment setup") } if clientSecret, exists = os.LookupEnv("OVERMIND_NTE_ALLPERMS_CLIENT_SECRET"); !exists || clientSecret == "" { t.Errorf(errorFormat, "OVERMIND_NTE_ALLPERMS_CLIENT_SECRET") t.Skip("Skipping due to missing environment setup") } exchangeURL, err := GetWorkingTokenExchange() if err != nil { t.Skipf("Token exchange API server not available: %v", err) return nil } testTokenSourceMu.Lock() defer testTokenSourceMu.Unlock() if testTokenSource == nil { ccc := auth.ClientCredentialsConfig{ ClientID: clientID, ClientSecret: clientSecret, } testTokenSource = ccc.TokenSource( t.Context(), fmt.Sprintf("https://%v/oauth/token", domain), os.Getenv("API_SERVER_AUDIENCE"), ) } return auth.NewOAuthTokenClient( exchangeURL, account, testTokenSource, ) } ================================================ FILE: go/discovery/enginerequests.go ================================================ package discovery import ( "bytes" "context" "errors" "fmt" "regexp" "runtime" "runtime/pprof" "strings" "sync" "sync/atomic" "time" "github.com/google/uuid" "github.com/nats-io/nats.go" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/pool" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "golang.org/x/sync/singleflight" "google.golang.org/protobuf/types/known/timestamppb" ) // NewItemSubject Generates a random subject name for returning items e.g. // return.item._INBOX.712ab421 func NewItemSubject() string { return fmt.Sprintf("return.item.%v", nats.NewInbox()) } // NewResponseSubject Generates a random subject name for returning responses // e.g. return.response._INBOX.978af6de func NewResponseSubject() string { return fmt.Sprintf("return.response.%v", nats.NewInbox()) } // HandleQuery Handles a single query. This includes responses, linking // etc. func (e *Engine) HandleQuery(ctx context.Context, query *sdp.Query) { var deadlineOverride bool // Respond saying we've got it responder := sdp.ResponseSender{ ResponseSubject: query.Subject(), } var pub sdp.EncodedConnection if e.IsNATSConnected() { pub = e.natsConnection } else { pub = NilConnection{} } ru := uuid.New() responder.Start( ctx, pub, e.EngineConfig.SourceName, ru, ) // Ensure responder ends exactly once (prevents double-ending on panic) var responderEndOnce sync.Once defer func() { // Safety net: if we panic before explicitly ending, mark as error responderEndOnce.Do(func() { responder.ErrorWithContext(ctx) }) }() // If there is no deadline OR further in the future than MaxRequestTimeout, clamp the deadline to MaxRequestTimeout maxRequestDeadline := time.Now().Add(e.MaxRequestTimeout) if query.GetDeadline() == nil || query.GetDeadline().AsTime().After(maxRequestDeadline) { query.Deadline = timestamppb.New(maxRequestDeadline) deadlineOverride = true log.WithContext(ctx).WithField("ovm.deadline", query.GetDeadline().AsTime()).Debug("capping deadline to MaxRequestTimeout") } // Add the query timeout to the context stack ctx, cancel := query.TimeoutContext(ctx) defer cancel() numExpandedQueries := len(e.sh.ExpandQuery(query)) if numExpandedQueries == 0 { // If we don't have any relevant adapters, mark as done (OK) and exit responderEndOnce.Do(func() { responder.DoneWithContext(ctx) }) return } // Extract and parse the UUID u, uuidErr := uuid.FromBytes(query.GetUUID()) // Only start the span if we actually have something that will respond ctx, span := getTracer().Start(ctx, "HandleQuery", trace.WithAttributes( attribute.Int("ovm.discovery.numExpandedQueries", numExpandedQueries), attribute.Bool("ovm.sdp.deadlineOverridden", deadlineOverride), attribute.String("ovm.sdp.source_name", e.EngineConfig.SourceName), attribute.String("ovm.engine.type", e.EngineConfig.EngineType), attribute.String("ovm.engine.version", e.EngineConfig.Version), )) defer span.End() query.SetSpanAttributes(span) deadline, ok := ctx.Deadline() if ok { span.SetAttributes( attribute.String("ovm.sdp.ctxDeadline", deadline.String()), ) } if query.GetRecursionBehaviour() != nil { span.SetAttributes( attribute.Int("ovm.sdp.linkDepth", int(query.GetRecursionBehaviour().GetLinkDepth())), ) } qt := QueryTracker{ Query: query, Engine: e, Context: ctx, Cancel: cancel, } if uuidErr == nil { e.TrackQuery(u, &qt) defer e.DeleteTrackedQuery(u) } // the query tracker will send responses directly through the embedded // engine's nats connection _, _, _, err := qt.Execute(ctx) // End responder based on execution result if err != nil { if errors.Is(err, context.Canceled) { responderEndOnce.Do(func() { responder.CancelWithContext(ctx) }) } else { responderEndOnce.Do(func() { responder.ErrorWithContext(ctx) }) } span.SetAttributes( attribute.String("ovm.sdp.errorType", "OTHER"), attribute.String("ovm.sdp.errorString", err.Error()), ) } else { responderEndOnce.Do(func() { responder.DoneWithContext(ctx) }) } } var ( goroutineProfileGroup singleflight.Group // Compiled once; used by compactGoroutineProfile to strip noise from // pprof debug=1 output while keeping it human-readable. profileAddrList = regexp.MustCompile(` @ (?:0x[0-9a-f]+ ?)+`) profileHexAddr = regexp.MustCompile(`#\t0x[0-9a-f]+\t`) profileFuncOffset = regexp.MustCompile(`\+0x[0-9a-f]+`) profileVersion = regexp.MustCompile(`@v[0-9]+\.[0-9]+\.[0-9]+[-\w.]*`) ) // compactGoroutineProfile removes noise from a pprof debug=1 goroutine dump // without losing readability. Typical compression is ~50%, effectively doubling // how much fits in the Honeycomb 49 KB string attribute limit. func compactGoroutineProfile(s string) string { s = strings.ReplaceAll(s, "github.com/overmindtech/workspace/", "g.c/o/w/") s = strings.ReplaceAll(s, "github.com/", "g.c/") s = profileAddrList.ReplaceAllString(s, "") // "32257 @ 0x9484c ..." → "32257" s = profileHexAddr.ReplaceAllString(s, "#\t") // "#\t0xaeda7b\tfoo" → "#\tfoo" s = profileFuncOffset.ReplaceAllString(s, "") // "Execute+0x4cb" → "Execute" s = profileVersion.ReplaceAllString(s, "") // "@v1.49.0" → "" return s } // captureGoroutineSummary returns a compacted goroutine profile (pprof debug=1) // truncated to maxBytes, deduplicated via singleflight. When many ExecuteQuery // goroutines hit the stuck timeout simultaneously, only one runs the // (stop-the-world) pprof capture; the rest share its result. func captureGoroutineSummary(maxBytes int) string { v, _, _ := goroutineProfileGroup.Do("goroutine-profile", func() (any, error) { var buf bytes.Buffer _ = pprof.Lookup("goroutine").WriteTo(&buf, 1) s := compactGoroutineProfile(buf.String()) if len(s) > maxBytes { s = s[:maxBytes-20] + "\n...[truncated]..." } return s, nil }) return v.(string) } var ( listExecutionPoolCount atomic.Int32 getExecutionPoolCount atomic.Int32 // executeQueryLongRunningAdaptersTimeout is how long ExecuteQuery waits after // ctx cancellation before giving up on the per-query WaitGroup. It is a // package-level variable so tests can set a shorter duration without // waiting two minutes. Production uses the default. executeQueryLongRunningAdaptersTimeout = 2 * time.Minute // executeQuerySafetyTimeout is the absolute upper bound on how long // ExecuteQuery waits for all workers before closing the responses // channel. It is a package-level variable so tests can override it // without waiting 10 minutes. executeQuerySafetyTimeout = 10 * time.Minute ) // ExecuteQuery Executes a single Query and returns the results without any // linking. Will return an error if the Query couldn't be run. // // Items and errors will be sent to the supplied channels as they are found. // Note that if these channels are not buffered, something will need to be // receiving the results or this method will never finish. If results are not // required the channels can be nil func (e *Engine) ExecuteQuery(ctx context.Context, query *sdp.Query, responses chan<- *sdp.QueryResponse) error { span := trace.SpanFromContext(ctx) // responses is closed after all pool workers finish (see waitGroupDone below), // not when this function returns. Deferring close here races with workers that // are still running after the stuck-timeout path returns. if ctx.Err() != nil { if responses != nil { close(responses) } return ctx.Err() } expanded := e.sh.ExpandQuery(query) span.SetAttributes( attribute.Int("ovm.adapter.numExpandedQueries", len(expanded)), ) if len(expanded) == 0 { if responses != nil { responses <- sdp.NewQueryResponseFromError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "no matching adapters found", Scope: query.GetScope(), }) close(responses) } return errors.New("no matching adapters found") } // Since we need to wait for only the processing of this query's executions, we need a separate WaitGroup here // Overall MaxParallelExecutions evaluation is handled by e.executionPool wg := sync.WaitGroup{} expandedMutex := sync.RWMutex{} totalQueries := len(expanded) var poolWaitMaxNs atomic.Int64 // Workers send to workerCh (never closed by safety timeout). A forwarder // goroutine copies workerCh → responses, and is the sole closer of // responses. This eliminates send-on-closed-channel races: when the // safety timeout fires we close responses (unblocking the reader) and // then drain workerCh so late-finishing workers can still call wg.Done() // instead of blocking on a full/unread channel. var workerCh chan<- *sdp.QueryResponse if responses != nil { proxy := make(chan *sdp.QueryResponse, cap(responses)) workerCh = proxy go func() { timer := time.NewTimer(executeQuerySafetyTimeout) defer timer.Stop() for { select { case r, ok := <-proxy: if !ok { close(responses) return } responses <- r case <-timer.C: close(responses) for range proxy { } return } } }() } expandedMutex.RLock() for q, adapter := range expanded { wg.Add(1) // localize values for the closure below localQ, localAdapter := q, adapter var p *pool.Pool if localQ.GetMethod() == sdp.QueryMethod_LIST { p = e.listExecutionPool listExecutionPoolCount.Add(1) } else { p = e.getExecutionPool getExecutionPoolCount.Add(1) } // push all queued items through a goroutine to avoid blocking `ExecuteQuery` from progressing // as `executionPool.Go()` will block once the max parallelism is hit go func() { // queue everything into the execution pool defer tracing.LogRecoverToReturn(ctx, "ExecuteQuery outer") span.SetAttributes( attribute.Int("ovm.discovery.listExecutionPoolCount", int(listExecutionPoolCount.Load())), attribute.Int("ovm.discovery.getExecutionPoolCount", int(getExecutionPoolCount.Load())), ) poolSubmitTime := time.Now() p.Go(func() { defer tracing.LogRecoverToReturn(ctx, "ExecuteQuery inner") waitNs := time.Since(poolSubmitTime).Nanoseconds() for { old := poolWaitMaxNs.Load() if waitNs <= old || poolWaitMaxNs.CompareAndSwap(old, waitNs) { break } } defer func() { // Mark the work as done. This happens before we start // waiting on `expandedMutex` below, to ensure that the // queues can continue executing even if we are waiting on // the mutex. wg.Done() // Delete our query from the map so that we can track which // ones are still running expandedMutex.Lock() defer expandedMutex.Unlock() delete(expanded, localQ) }() defer func() { if localQ.GetMethod() == sdp.QueryMethod_LIST { listExecutionPoolCount.Add(-1) } else { getExecutionPoolCount.Add(-1) } }() // If the context is cancelled, don't even bother doing // anything. Since the `p.Go` will block, it's possible that if // the pool was exhausted, the context could be cancelled before // the goroutine is executed if ctx.Err() != nil { return } // Execute the query against the adapter e.Execute(ctx, localQ, localAdapter, workerCh) }) }() } expandedMutex.RUnlock() waitGroupDone := make(chan struct{}) go func() { wg.Wait() if workerCh != nil { close(workerCh) } close(waitGroupDone) }() select { case <-waitGroupDone: // All adapters have finished case <-ctx.Done(): // The context was cancelled, this should have propagated to all the // adapters and therefore we should see the wait group finish very // quickly now. We will check this though to make sure. This will wait // until we reach Change Analysis SLO violation territory. If this is // too quick, we are only spamming logs for nothing. longRunningAdaptersTimeout := executeQueryLongRunningAdaptersTimeout // Wait for the wait group, but ping the logs if it's taking // too long func() { for { select { case <-waitGroupDone: return case <-time.After(longRunningAdaptersTimeout): // If we're here, then the wait group didn't finish in time goroutineSummary := captureGoroutineSummary(48_000) expandedMutex.RLock() span.AddEvent("waitgroup.stuck", trace.WithAttributes( attribute.Int("ovm.stuck.goroutineCount", runtime.NumGoroutine()), attribute.Int("ovm.stuck.totalQueries", totalQueries), attribute.Int("ovm.stuck.remainingQueries", len(expanded)), attribute.String("ovm.stuck.goroutineProfile", goroutineSummary), )) for q, adapter := range expanded { span.AddEvent("waitgroup.stuck.adapter", trace.WithAttributes( attribute.String("ovm.stuck.adapter", adapter.Name()), attribute.String("ovm.stuck.type", q.GetType()), attribute.String("ovm.stuck.scope", q.GetScope()), attribute.String("ovm.stuck.method", q.GetMethod().String()), )) // There is a honeycomb trigger for this message: // // https://ui.honeycomb.io/overmind/environments/prod/datasets/kubernetes-metrics/triggers/saWNAnCAXNb // // This is to ensure we are aware of any adapters that // are taking too long to respond to a query, which // could indicate a bug in the adapter. Make sure to // keep the trigger and this message in sync. log.WithContext(ctx).WithFields(log.Fields{ "ovm.sdp.uuid": q.GetUUIDParsed().String(), "ovm.sdp.type": q.GetType(), "ovm.sdp.scope": q.GetScope(), "ovm.sdp.method": q.GetMethod().String(), "ovm.adapter.name": adapter.Name(), }).Errorf("Wait group still running %v after context cancelled", longRunningAdaptersTimeout) } expandedMutex.RUnlock() // the query is already bolloxed up, we don't need continue to wait and spam the logs any more return } } }() } span.SetAttributes( attribute.Float64("ovm.discovery.poolWaitMaxMs", float64(poolWaitMaxNs.Load())/1e6), ) // If the context is cancelled, return that error if ctx.Err() != nil { return ctx.Err() } return nil } // Runs a query against an adapter. Returns an error if the query fails in a // "fatal" way that should consider the query as failed. Other non-fatal errors // should be sent on the stream. Channels for items and errors will NOT be // closed by this function, the caller should do that as this will likely be // called in parallel with other queries and the results should be merged func (e *Engine) Execute(ctx context.Context, q *sdp.Query, adapter Adapter, responses chan<- *sdp.QueryResponse) { ctx, span := getTracer().Start(ctx, "Execute", trace.WithAttributes( attribute.String("ovm.adapter.name", adapter.Name()), attribute.String("ovm.engine.type", e.EngineConfig.EngineType), attribute.String("ovm.engine.version", e.EngineConfig.Version), attribute.String("ovm.sdp.source_name", e.EngineConfig.SourceName), // deprecated, we are keeping these here for data integrity of old queries until 2026-03-01 attribute.String("ovm.adapter.queryMethod", q.GetMethod().String()), attribute.String("ovm.adapter.queryType", q.GetType()), attribute.String("ovm.adapter.queryScope", q.GetScope()), attribute.String("ovm.adapter.query", q.GetQuery()), )) defer span.End() q.SetSpanAttributes(span) // We want to avoid having a Get and a List running at the same time, we'd // rather run the List first, populate the cache, then have the Get just // grab the value from the cache. To this end we use a GetListMutex to allow // a List to block all subsequent Get queries until it is done mutexWaitStart := time.Now() switch q.GetMethod() { case sdp.QueryMethod_GET: e.gfm.GetLock(q.GetScope(), q.GetType()) defer e.gfm.GetUnlock(q.GetScope(), q.GetType()) case sdp.QueryMethod_LIST: e.gfm.ListLock(q.GetScope(), q.GetType()) defer e.gfm.ListUnlock(q.GetScope(), q.GetType()) case sdp.QueryMethod_SEARCH: // We don't need to lock for a search since they are independent and // will only ever have a cache hit if the query is identical } span.SetAttributes( attribute.Float64("ovm.discovery.mutexWaitMs", float64(time.Since(mutexWaitStart).Milliseconds())), attribute.String("ovm.discovery.mutexKey", q.GetScope()+"."+q.GetType()), ) // Ensure that the span is closed when the context is done. This is based on // the assumption that some adapters may not respect the context deadline and // may run indefinitely. This ensures that we at least get notified about // it. go func() { <-ctx.Done() if ctx.Err() != nil { // get a fresh copy of the span to avoid data races span := trace.SpanFromContext(ctx) span.RecordError(ctx.Err()) span.SetAttributes( attribute.Bool("ovm.discover.hang", true), ) span.End() } }() // Set up handling for the items and errors that are returned before they // are passed back to the caller var numItems atomic.Int32 var numErrs atomic.Int32 // Per-Execute *sdp.QueryError telemetry: fold the first into span aggregates only // (no RecordError) to reduce Honeycomb exception-event volume; record 2+ as // exception events so rare multi-error tails keep detail. var numSDPQueryErrors atomic.Int32 var numSDPQueryErrorRecordErrors atomic.Int32 var channelSendMaxNs atomic.Int64 var channelSendTotalNs atomic.Int64 var itemHandler ItemHandler = func(item *sdp.Item) { if item == nil { return } if err := item.Validate(); err != nil { span.RecordError(err) sendStart := time.Now() responses <- sdp.NewQueryResponseFromError(&sdp.QueryError{ UUID: q.GetUUID(), ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: q.GetScope(), ResponderName: e.EngineConfig.SourceName, ItemType: q.GetType(), }) sendNs := time.Since(sendStart).Nanoseconds() channelSendTotalNs.Add(sendNs) for { old := channelSendMaxNs.Load() if sendNs <= old || channelSendMaxNs.CompareAndSwap(old, sendNs) { break } } return } // Store metadata item.Metadata = &sdp.Metadata{ Timestamp: timestamppb.New(time.Now()), SourceName: adapter.Name(), SourceQuery: q, } // Mark the item as hidden if the adapter is hidden if hs, ok := adapter.(HiddenAdapter); ok { item.Metadata.Hidden = hs.Hidden() } // Send the item back to the caller numItems.Add(1) sendStart := time.Now() responses <- sdp.NewQueryResponseFromItem(item) sendNs := time.Since(sendStart).Nanoseconds() channelSendTotalNs.Add(sendNs) for { old := channelSendMaxNs.Load() if sendNs <= old || channelSendMaxNs.CompareAndSwap(old, sendNs) { break } } } var errHandler ErrHandler = func(err error) { if err == nil { return } // add a recover to prevent panic from stream error handler. defer tracing.LogRecoverToReturn(ctx, "StreamErrorHandler") var sdpErr *sdp.QueryError if errors.As(err, &sdpErr) && sdpErr != nil { n := numSDPQueryErrors.Add(1) if n == 1 { // Fold first QueryError: do not emit a per-error exception event. } else { // Rare multi-error Execute: keep per-error exception rows without stacks // (stacks are expensive and the first error is the high-volume case). span.RecordError(sdpErr, trace.WithStackTrace(false)) numSDPQueryErrorRecordErrors.Add(1) } } else { span.RecordError(err, trace.WithStackTrace(true)) } // Send the error back to the caller numErrs.Add(1) sendStart := time.Now() responses <- queryResponseFromError(err, q, adapter, e.EngineConfig.SourceName) sendNs := time.Since(sendStart).Nanoseconds() channelSendTotalNs.Add(sendNs) for { old := channelSendMaxNs.Load() if sendNs <= old || channelSendMaxNs.CompareAndSwap(old, sendNs) { break } } } stream := NewQueryResultStream(itemHandler, errHandler) // Check that our context is okay before doing anything expensive if ctx.Err() != nil { span.RecordError(ctx.Err()) responses <- sdp.NewQueryResponseFromError(&sdp.QueryError{ UUID: q.GetUUID(), ErrorType: sdp.QueryError_OTHER, ErrorString: ctx.Err().Error(), Scope: q.GetScope(), ResponderName: e.EngineConfig.SourceName, ItemType: q.GetType(), }) return } switch q.GetMethod() { case sdp.QueryMethod_GET: span.SetAttributes(attribute.Bool("ovm.sdp.streaming", false)) newItem, err := adapter.Get(ctx, q.GetScope(), q.GetQuery(), q.GetIgnoreCache()) if newItem != nil { stream.SendItem(newItem) } if err != nil { stream.SendError(err) } case sdp.QueryMethod_LIST: if listStreamingAdapter, ok := adapter.(ListStreamableAdapter); ok { // Prefer the streaming methods if they are available span.SetAttributes(attribute.Bool("ovm.sdp.streaming", true)) listStreamingAdapter.ListStream(ctx, q.GetScope(), q.GetIgnoreCache(), stream) } else if listableAdapter, ok := adapter.(ListableAdapter); ok { // Fall back to the non-streaming methods span.SetAttributes(attribute.Bool("ovm.sdp.streaming", false)) resultItems, err := listableAdapter.List(ctx, q.GetScope(), q.GetIgnoreCache()) for _, i := range resultItems { stream.SendItem(i) } if err != nil { stream.SendError(err) } } else { // Log the error instead of sending it over the stream log.WithContext(ctx).WithFields(log.Fields{ "ovm.adapter.name": adapter.Name(), "ovm.sdp.type": q.GetType(), "ovm.sdp.scope": q.GetScope(), }).Warn("adapter is not listable") } case sdp.QueryMethod_SEARCH: if searchStreamingAdapter, ok := adapter.(SearchStreamableAdapter); ok { // Prefer the streaming methods if they are available span.SetAttributes(attribute.Bool("ovm.sdp.streaming", true)) searchStreamingAdapter.SearchStream(ctx, q.GetScope(), q.GetQuery(), q.GetIgnoreCache(), stream) } else if searchableAdapter, ok := adapter.(SearchableAdapter); ok { // Fall back to the non-streaming methods span.SetAttributes(attribute.Bool("ovm.sdp.streaming", false)) resultItems, err := searchableAdapter.Search(ctx, q.GetScope(), q.GetQuery(), q.GetIgnoreCache()) for _, i := range resultItems { stream.SendItem(i) } if err != nil { stream.SendError(err) } } else { // Log the error instead of sending it over the stream log.WithContext(ctx).WithFields(log.Fields{ "ovm.adapter.name": adapter.Name(), "ovm.sdp.type": q.GetType(), "ovm.sdp.scope": q.GetScope(), }).Warn("adapter is not searchable") } } span.SetAttributes( attribute.Int("ovm.adapter.numItems", int(numItems.Load())), attribute.Int("ovm.adapter.numErrors", int(numErrs.Load())), attribute.Int("ovm.adapter.sdpQueryErrorCount", int(numSDPQueryErrors.Load())), attribute.Int("ovm.adapter.sdpQueryErrorRecordErrorCount", int(numSDPQueryErrorRecordErrors.Load())), attribute.Float64("ovm.discovery.channelSendMaxMs", float64(channelSendMaxNs.Load())/1e6), attribute.Float64("ovm.discovery.channelSendTotalMs", float64(channelSendTotalNs.Load())/1e6), ) } // queryResponseFromError converts an error into a QueryResponse. This takes // care to not double-wrap `sdp.QueryError` errors. func queryResponseFromError(err error, q *sdp.Query, adapter Adapter, sourceName string) *sdp.QueryResponse { var sdpErr *sdp.QueryError if !errors.As(err, &sdpErr) { sdpErr = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } // Add details that might not be populated by the adapter sdpErr.Scope = q.GetScope() sdpErr.UUID = q.GetUUID() sdpErr.SourceName = adapter.Name() sdpErr.ItemType = adapter.Metadata().GetType() sdpErr.ResponderName = sourceName return sdp.NewQueryResponseFromError(sdpErr) } ================================================ FILE: go/discovery/enginerequests_test.go ================================================ package discovery import ( "context" "errors" "reflect" "sync" "testing" "time" "github.com/google/uuid" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/go/tracing" "github.com/sourcegraph/conc/pool" "google.golang.org/protobuf/types/known/timestamppb" ) // executeQuerySync Executes a Query, waiting for all results, then returns them // along with the error, rather than using channels. The singular error sill only // be returned if the query could not be executed, otherwise all errors will be // in the slice func (e *Engine) executeQuerySync(ctx context.Context, q *sdp.Query) ([]*sdp.Item, []*sdp.Edge, []*sdp.QueryError, error) { responseChan := make(chan *sdp.QueryResponse, 100_000) items := make([]*sdp.Item, 0) edges := make([]*sdp.Edge, 0) errs := make([]*sdp.QueryError, 0) err := e.ExecuteQuery(ctx, q, responseChan) for r := range responseChan { switch r := r.GetResponseType().(type) { case *sdp.QueryResponse_NewItem: items = append(items, r.NewItem) case *sdp.QueryResponse_Edge: edges = append(edges, r.Edge) case *sdp.QueryResponse_Error: errs = append(errs, r.Error) } } return items, edges, errs, err } // cancelBlockingGetAdapter blocks in Get until the query context is cancelled. // Used to exercise ExecuteQuery returning after the stuck-timeout path while // a worker may still send on responses (must not close the channel until // wg.Done). type cancelBlockingGetAdapter struct { ready sync.Once // started is closed the first time Get begins waiting on ctx.Done(). started chan struct{} } func newCancelBlockingGetAdapter() *cancelBlockingGetAdapter { return &cancelBlockingGetAdapter{ started: make(chan struct{}), } } func (a *cancelBlockingGetAdapter) Type() string { return "blockingcancel" } func (a *cancelBlockingGetAdapter) Name() string { return "cancelBlockingGetAdapter" } func (a *cancelBlockingGetAdapter) Scopes() []string { return []string{"test"} } func (a *cancelBlockingGetAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: a.Type(), DescriptiveName: "Blocking cancel test", } } func (a *cancelBlockingGetAdapter) Get(ctx context.Context, scope, query string, _ bool) (*sdp.Item, error) { a.ready.Do(func() { close(a.started) }) <-ctx.Done() return nil, ctx.Err() } func TestExecuteQuery_CancelledContextDoesNotPanicOnChannelClose(t *testing.T) { natsURL := startEmbeddedNATSServer(t) prev := executeQueryLongRunningAdaptersTimeout executeQueryLongRunningAdaptersTimeout = 50 * time.Millisecond t.Cleanup(func() { executeQueryLongRunningAdaptersTimeout = prev }) adapter := newCancelBlockingGetAdapter() e := newStartedEngine(t, "TestExecuteQueryCancelClose", &auth.NATSOptions{ Servers: []string{natsURL}, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, nil, adapter, ) ctx, cancel := context.WithCancel(context.Background()) u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: adapter.Type(), Method: sdp.QueryMethod_GET, Query: "q", Scope: "test", Deadline: timestamppb.New(time.Now().Add(10 * time.Minute)), RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, } responses := make(chan *sdp.QueryResponse, 10) errCh := make(chan error, 1) go func() { errCh <- e.ExecuteQuery(ctx, q, responses) }() <-adapter.started cancel() err := <-errCh if !errors.Is(err, context.Canceled) { t.Fatalf("ExecuteQuery() err = %v, want %v", err, context.Canceled) } for range responses { } } // foreverBlockingGetAdapter ignores context cancellation and blocks in Get // until an external signal. Used to exercise the safety timeout path. type foreverBlockingGetAdapter struct { ready sync.Once // started is closed when Get begins blocking. started chan struct{} // release is closed by the test to let Get return. release chan struct{} } func newForeverBlockingGetAdapter() *foreverBlockingGetAdapter { return &foreverBlockingGetAdapter{ started: make(chan struct{}), release: make(chan struct{}), } } func (a *foreverBlockingGetAdapter) Type() string { return "foreverblocking" } func (a *foreverBlockingGetAdapter) Name() string { return "foreverBlockingGetAdapter" } func (a *foreverBlockingGetAdapter) Scopes() []string { return []string{"test"} } func (a *foreverBlockingGetAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: a.Type(), DescriptiveName: "Forever blocking test", } } func (a *foreverBlockingGetAdapter) Get(_ context.Context, _, _ string, _ bool) (*sdp.Item, error) { a.ready.Do(func() { close(a.started) }) <-a.release return nil, errors.New("released") } func TestExecuteQuery_SafetyTimeoutClosesResponsesWithoutPanic(t *testing.T) { natsURL := startEmbeddedNATSServer(t) prevLong := executeQueryLongRunningAdaptersTimeout executeQueryLongRunningAdaptersTimeout = 10 * time.Millisecond prevSafety := executeQuerySafetyTimeout executeQuerySafetyTimeout = 100 * time.Millisecond t.Cleanup(func() { executeQueryLongRunningAdaptersTimeout = prevLong executeQuerySafetyTimeout = prevSafety }) adapter := newForeverBlockingGetAdapter() t.Cleanup(func() { close(adapter.release) }) e := newStartedEngine(t, "TestExecuteQuerySafetyTimeout", &auth.NATSOptions{ Servers: []string{natsURL}, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, nil, adapter, ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: adapter.Type(), Method: sdp.QueryMethod_GET, Query: "q", Scope: "test", Deadline: timestamppb.New(time.Now().Add(10 * time.Minute)), RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, } responses := make(chan *sdp.QueryResponse, 10) errCh := make(chan error, 1) go func() { errCh <- e.ExecuteQuery(ctx, q, responses) }() <-adapter.started cancel() // Drain responses — the safety timeout should close the channel without // panicking, even though the worker is still blocked in Get. for range responses { } // ExecuteQuery should have returned after the stuck-timeout path. select { case err := <-errCh: if !errors.Is(err, context.Canceled) { t.Fatalf("ExecuteQuery() err = %v, want %v", err, context.Canceled) } case <-time.After(5 * time.Second): t.Fatal("timed out waiting for ExecuteQuery to return") } } func TestExecuteQuery(t *testing.T) { adapter := TestAdapter{ ReturnType: "person", ReturnScopes: []string{"test"}, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestExecuteQuery", &auth.NATSOptions{ Servers: NatsTestURLs, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, nil, &adapter, ) t.Run("Basic happy-path Get query", func(t *testing.T) { u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: "person", Method: sdp.QueryMethod_GET, Query: "foo", Scope: "test", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 3, }, } items, _, errs, err := e.executeQuerySync(context.Background(), q) if err != nil { t.Error(err) } for _, e := range errs { t.Error(e) } if x := len(adapter.GetCalls); x != 1 { t.Errorf("expected adapter's Get() to have been called 1 time, got %v", x) } if len(items) == 0 { t.Fatal("expected 1 item, got none") } if len(items) > 1 { t.Errorf("expected 1 item, got %v", items) } item := items[0] if !reflect.DeepEqual(item.GetMetadata().GetSourceQuery(), q) { t.Logf("adapter query: %+v", item.GetMetadata().GetSourceQuery()) t.Logf("expected query: %+v", q) t.Error("adapter query mismatch") } }) t.Run("Wrong scope Get query", func(t *testing.T) { q := &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "foo", Scope: "wrong", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, } _, _, errs, err := e.executeQuerySync(context.Background(), q) if err == nil { t.Error("expected error but got nil") } if len(errs) == 1 { if errs[0].GetErrorType() != sdp.QueryError_NOSCOPE { t.Errorf("expected error type to be NOSCOPE, got %v", errs[0].GetErrorType()) } } else { t.Errorf("expected 1 error, got %v", len(errs)) } }) t.Run("Wrong type Get query", func(t *testing.T) { q := &sdp.Query{ Type: "house", Method: sdp.QueryMethod_GET, Query: "foo", Scope: "test", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, } _, _, errs, err := e.executeQuerySync(context.Background(), q) if err == nil { t.Error("expected error but got nil") } if len(errs) == 1 { if errs[0].GetErrorType() != sdp.QueryError_NOSCOPE { t.Errorf("expected error type to be NOSCOPE, got %v", errs[0].GetErrorType()) } } else { t.Errorf("expected 1 error, got %v", len(errs)) } }) t.Run("Basic List query", func(t *testing.T) { q := &sdp.Query{ Type: "person", Method: sdp.QueryMethod_LIST, Scope: "test", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 5, }, } items, _, errs, err := e.executeQuerySync(context.Background(), q) if err != nil { t.Error(err) } for _, e := range errs { t.Error(e) } if len(items) < 1 { t.Error("expected at least one item") } }) t.Run("Basic Search query", func(t *testing.T) { q := &sdp.Query{ Type: "person", Method: sdp.QueryMethod_SEARCH, Query: "TEST", Scope: "test", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 5, }, } items, _, errs, err := e.executeQuerySync(context.Background(), q) if err != nil { t.Error(err) } for _, e := range errs { t.Error(e) } if len(items) < 1 { t.Error("expected at least one item") } }) } func TestHandleQuery(t *testing.T) { personAdapter := TestAdapter{ ReturnType: "person", ReturnScopes: []string{ "test1", "test2", }, cache: sdpcache.NewNoOpCache(), } dogAdapter := TestAdapter{ ReturnType: "dog", ReturnScopes: []string{ "test1", "testA", "testB", }, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestHandleQuery", nil, nil, &personAdapter, &dogAdapter) t.Run("Wildcard type should be expanded", func(t *testing.T) { t.Cleanup(func() { personAdapter.ClearCalls() dogAdapter.ClearCalls() }) req := sdp.Query{ Type: sdp.WILDCARD, Method: sdp.QueryMethod_GET, Query: "Dylan", Scope: "test1", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, } // Run the handler e.HandleQuery(context.Background(), &req) // I'm expecting both adapter to get a query since the type was * if l := len(personAdapter.GetCalls); l != 1 { t.Errorf("expected person backend to have 1 Get call, got %v", l) } if l := len(dogAdapter.GetCalls); l != 1 { t.Errorf("expected dog backend to have 1 Get call, got %v", l) } }) t.Run("Wildcard scope should be expanded", func(t *testing.T) { t.Cleanup(func() { personAdapter.ClearCalls() dogAdapter.ClearCalls() }) req := sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Dylan1", Scope: sdp.WILDCARD, RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, } // Run the handler e.HandleQuery(context.Background(), &req) if l := len(personAdapter.GetCalls); l != 2 { t.Errorf("expected person backend to have 2 Get calls, got %v", l) } if l := len(dogAdapter.GetCalls); l != 0 { t.Errorf("expected dog backend to have 0 Get calls, got %v", l) } }) } func TestWildcardAdapterExpansion(t *testing.T) { personAdapter := TestAdapter{ ReturnType: "person", ReturnScopes: []string{ sdp.WILDCARD, }, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestWildcardAdapterExpansion", nil, nil, &personAdapter) t.Run("query scope should be preserved", func(t *testing.T) { req := sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Dylan1", Scope: "something.specific", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, } // Run the handler e.HandleQuery(context.Background(), &req) if len(personAdapter.GetCalls) != 1 { t.Errorf("expected 1 get call got %v", len(personAdapter.GetCalls)) } if len(personAdapter.GetCalls) == 0 { t.Fatal("Can't continue without calls") } call := personAdapter.GetCalls[0] if expected := "something.specific"; call[0] != expected { t.Errorf("expected scope to be %v, got %v", expected, call[0]) } if expected := "Dylan1"; call[1] != expected { t.Errorf("expected query to be %v, got %v", expected, call[1]) } }) } func TestSendQuerySync(t *testing.T) { SkipWithoutNats(t) ctx := context.Background() ctx, span := tracing.Tracer().Start(ctx, "TestSendQuerySync") defer span.End() adapter := TestAdapter{ ReturnType: "person", ReturnScopes: []string{ "test", }, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestSendQuerySync", nil, nil, &adapter) p := pool.New() for range 250 { p.Go(func() { u := uuid.New() t.Log("starting query: ", u) var items []*sdp.Item query := &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Dylan", Scope: "test", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, IgnoreCache: false, UUID: u[:], Deadline: timestamppb.New(time.Now().Add(10 * time.Minute)), } items, _, errs, err := sdp.RunSourceQuerySync(ctx, query, 1*time.Second, e.natsConnection) if err != nil { t.Error(err) } if len(errs) != 0 { for _, err := range errs { t.Error(err) } } if len(items) != 1 { t.Fatalf("expected 1 item, got %v: %v", len(items), items) } }) } p.Wait() } func TestExpandQuery(t *testing.T) { t.Run("with a single adapter with a single scope", func(t *testing.T) { simple := TestAdapter{ ReturnScopes: []string{ "test1", }, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestExpandQuery", nil, nil, &simple) e.HandleQuery(context.Background(), &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Debby", Scope: "*", }) if expected := 1; len(simple.GetCalls) != expected { t.Errorf("Expected %v calls, got %v", expected, len(simple.GetCalls)) } }) t.Run("with a single adapter with many scopes", func(t *testing.T) { many := TestAdapter{ ReturnName: "many", ReturnScopes: []string{ "test1", "test2", "test3", }, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestExpandQuery", nil, nil, &many) e.HandleQuery(context.Background(), &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Debby", Scope: "*", }) if expected := 3; len(many.GetCalls) != expected { t.Errorf("Expected %v calls, got %v", expected, many.GetCalls) } }) t.Run("with a single wildcard adapter", func(t *testing.T) { sx := TestAdapter{ ReturnType: "person", ReturnName: "sx", ReturnScopes: []string{ sdp.WILDCARD, }, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestExpandQuery", nil, nil, &sx) e.HandleQuery(context.Background(), &sdp.Query{ Type: "person", Method: sdp.QueryMethod_LIST, Query: "Rachel", Scope: "*", }) if expected := 1; len(sx.ListCalls) != expected { t.Errorf("Expected %v calls, got %v", expected, sx.ListCalls) } }) } ================================================ FILE: go/discovery/execute_query_trace_test.go ================================================ package discovery import ( "context" "fmt" "testing" "time" "github.com/google/uuid" "github.com/nats-io/nats-server/v2/test" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "go.opentelemetry.io/otel" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "google.golang.org/protobuf/types/known/timestamppb" ) // startEmbeddedNATSServer runs an in-process NATS for tests that need a live Engine Start. func startEmbeddedNATSServer(t *testing.T) string { t.Helper() opts := test.DefaultTestOptions opts.Port = 4739 s := test.RunServer(&opts) if !s.ReadyForConnections(10 * time.Second) { s.Shutdown() t.Fatal("could not start embedded NATS server") } t.Cleanup(func() { s.Shutdown() }) return s.ClientURL() } func setupTestTracer(t *testing.T) *tracetest.InMemoryExporter { t.Helper() exp := tracetest.NewInMemoryExporter() tp := sdktrace.NewTracerProvider( sdktrace.WithSyncer(exp), sdktrace.WithSampler(sdktrace.AlwaysSample()), ) prev := otel.GetTracerProvider() otel.SetTracerProvider(tp) t.Cleanup(func() { _ = tp.Shutdown(context.Background()) otel.SetTracerProvider(prev) }) return exp } func countExceptionEvents(spans []tracetest.SpanStub) int { n := 0 for _, s := range spans { if s.Name != "Execute" { continue } for _, ev := range s.Events { if ev.Name == semconv.ExceptionEventName { n++ } } } return n } // streamTwoSDPQueryErrorsAdapter implements ListStreamableAdapter and emits two *sdp.QueryError // values on LIST (for multi-error Execute telemetry tests). type streamTwoSDPQueryErrorsAdapter struct { *TestAdapter } func (a *streamTwoSDPQueryErrorsAdapter) ListStream(ctx context.Context, scope string, ignoreCache bool, stream QueryResultStream) { _ = ctx _ = scope _ = ignoreCache stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "first sdp query error", }) stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "second sdp query error", }) } // plainErrOnGetAdapter returns a non-QueryError from Get for every call. type plainErrOnGetAdapter struct { *TestAdapter } func (a *plainErrOnGetAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { _ = ctx _ = scope _ = query _ = ignoreCache return nil, fmt.Errorf("plain non-sdp error") } func TestExecute_FirstSDPQueryErrorDoesNotRecordExceptionEvent(t *testing.T) { exp := setupTestTracer(t) natsURL := startEmbeddedNATSServer(t) adapter := TestAdapter{ ReturnType: "person", ReturnScopes: []string{"test", "error"}, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestExecuteTraceSDPQueryError", &auth.NATSOptions{ Servers: []string{natsURL}, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, nil, &adapter) u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: "person", Method: sdp.QueryMethod_GET, Query: "foo", Scope: "error", Deadline: timestamppb.New(time.Now().Add(time.Minute)), RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 3, }, } ch := make(chan *sdp.QueryResponse, 10) err := e.ExecuteQuery(context.Background(), q, ch) if err != nil { t.Fatal(err) } if n := countExceptionEvents(exp.GetSpans()); n != 0 { t.Fatalf("expected 0 exception events on Execute for first *sdp.QueryError, got %d", n) } } func TestExecute_SecondSDPQueryErrorRecordsExceptionEvent(t *testing.T) { exp := setupTestTracer(t) natsURL := startEmbeddedNATSServer(t) base := &TestAdapter{ ReturnType: "person", ReturnScopes: []string{"test"}, cache: sdpcache.NewNoOpCache(), } adapter := &streamTwoSDPQueryErrorsAdapter{TestAdapter: base} e := newStartedEngine(t, "TestExecuteTraceMultiSDPQueryError", &auth.NATSOptions{ Servers: []string{natsURL}, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, nil, adapter) u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: "person", Method: sdp.QueryMethod_LIST, Scope: "test", Deadline: timestamppb.New(time.Now().Add(time.Minute)), RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 3, }, } ch := make(chan *sdp.QueryResponse, 10) err := e.ExecuteQuery(context.Background(), q, ch) if err != nil { t.Fatal(err) } if n := countExceptionEvents(exp.GetSpans()); n != 1 { t.Fatalf("expected 1 exception event on Execute (2nd *sdp.QueryError only), got %d", n) } } func TestExecute_PlainErrorStillRecordsExceptionEvent(t *testing.T) { exp := setupTestTracer(t) natsURL := startEmbeddedNATSServer(t) base := &TestAdapter{ ReturnType: "person", ReturnScopes: []string{"test"}, cache: sdpcache.NewNoOpCache(), } adapter := &plainErrOnGetAdapter{TestAdapter: base} e := newStartedEngine(t, "TestExecuteTracePlainErr", &auth.NATSOptions{ Servers: []string{natsURL}, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, nil, adapter) u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: "person", Method: sdp.QueryMethod_GET, Query: "foo", Scope: "test", Deadline: timestamppb.New(time.Now().Add(time.Minute)), RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 3, }, } ch := make(chan *sdp.QueryResponse, 10) err := e.ExecuteQuery(context.Background(), q, ch) if err != nil { t.Fatal(err) } if n := countExceptionEvents(exp.GetSpans()); n != 1 { t.Fatalf("expected 1 exception event for plain error, got %d", n) } } ================================================ FILE: go/discovery/getfindmutex.go ================================================ package discovery import ( "fmt" "sync" ) // GetListMutex A modified version of a RWMutex. Many get locks can be held but // only one List lock. A waiting List lock (even if it hasn't been locked, just // if someone is waiting) blocks all other get locks until it unlocks. // // The intended usage of this is that it will allow an adapter which is trying to // process many queries at once, to process a LIST query before any GET // queries, since it's likely that once LIST has been run, subsequent GET // queries will be able to be served from cache type GetListMutex struct { mutexMap map[string]*sync.RWMutex mapLock sync.Mutex } // GetLock Gets a lock that can be held by an unlimited number of goroutines, // these locks are only blocked by ListLocks. A type and scope must be // provided since a Get in one type (or scope) should not be blocked by a List // in another func (g *GetListMutex) GetLock(scope string, typ string) { g.mutexFor(scope, typ).RLock() } // GetUnlock Unlocks the GetLock. This must be called once for each GetLock // otherwise it will be impossible to ever obtain a ListLock func (g *GetListMutex) GetUnlock(scope string, typ string) { g.mutexFor(scope, typ).RUnlock() } // ListLock An exclusive lock. Ensure that all GetLocks have been unlocked and // stops any more from being obtained. Provide a type and scope to ensure that // the lock is only help for that type and scope combination rather than // locking the whole engine func (g *GetListMutex) ListLock(scope string, typ string) { g.mutexFor(scope, typ).Lock() } // ListUnlock Unlocks a ListLock func (g *GetListMutex) ListUnlock(scope string, typ string) { g.mutexFor(scope, typ).Unlock() } // mutexFor Returns the relevant RWMutex for a given scope and type, creating // and storing a new one if needed func (g *GetListMutex) mutexFor(scope string, typ string) *sync.RWMutex { var mutex *sync.RWMutex var ok bool keyName := g.keyName(scope, typ) g.mapLock.Lock() defer g.mapLock.Unlock() // Create the map if needed if g.mutexMap == nil { g.mutexMap = make(map[string]*sync.RWMutex) } // Get the mutex from storage mutex, ok = g.mutexMap[keyName] // If the mutex wasn't found for this key, create a new one if !ok { mutex = &sync.RWMutex{} g.mutexMap[keyName] = mutex } return mutex } // keyName Returns the name of the key for a given scope and type combo for // use with the mutexMap func (g *GetListMutex) keyName(scope string, typ string) string { return fmt.Sprintf("%v.%v", scope, typ) } ================================================ FILE: go/discovery/getfindmutex_test.go ================================================ package discovery import ( "context" "sync" "testing" "time" ) func TestGetLock(t *testing.T) { t.Run("many get locks can be held at once", func(t *testing.T) { var gfm GetListMutex ctx, cancel := context.WithTimeout(context.Background(), (1 * time.Second)) doneChan := make(chan bool) go func() { gfm.GetLock("testScope", "testType") gfm.GetLock("testScope", "testType") gfm.GetLock("testScope", "testType") gfm.GetUnlock("testScope", "testType") gfm.GetUnlock("testScope", "testType") gfm.GetUnlock("testScope", "testType") doneChan <- true }() select { case <-ctx.Done(): t.Error("Timeout") case <-doneChan: } cancel() }) t.Run("many find locks from different types and scopes can be held at once", func(t *testing.T) { var gfm GetListMutex ctx, cancel := context.WithTimeout(context.Background(), (1 * time.Second)) doneChan := make(chan bool) go func() { gfm.ListLock("testScope1", "testType1") gfm.ListLock("testScope1", "testType2") gfm.ListLock("testScope2", "testType") gfm.ListLock("testScope3", "testType") gfm.ListUnlock("testScope1", "testType1") gfm.ListUnlock("testScope1", "testType2") gfm.ListUnlock("testScope2", "testType") gfm.ListUnlock("testScope3", "testType") doneChan <- true }() select { case <-ctx.Done(): t.Error("Timeout") case <-doneChan: } cancel() }) t.Run("get locks are blocked by a find lock", func(t *testing.T) { var gfm GetListMutex ctx, cancel := context.WithTimeout(context.Background(), (1 * time.Second)) getChan := make(chan bool) findChan := make(chan bool) gfm.ListLock("testScope", "testType") go func() { gfm.GetLock("testScope", "testType") gfm.GetLock("testScope", "testType") gfm.GetLock("testScope", "testType") gfm.GetUnlock("testScope", "testType") gfm.GetUnlock("testScope", "testType") gfm.GetUnlock("testScope", "testType") getChan <- true }() go func() { // Seep for long enough to allow the above goroutine to complete if not // blocked time.Sleep(10 * time.Millisecond) findChan <- true }() select { case <-ctx.Done(): t.Error("Timeout") case <-getChan: t.Error("Get locks were not blocked") case <-findChan: // This is the expected path } cancel() }) t.Run("active gets block finds", func(t *testing.T) { var gfm GetListMutex var actionWG sync.WaitGroup ctx, cancel := context.WithTimeout(context.Background(), (1 * time.Second)) order := make([]string, 0) actionChan := make(chan string) doneChan := make(chan bool) var wg sync.WaitGroup wg.Add(3) go func() { defer wg.Done() gfm.GetLock("testScope", "testType") actionChan <- "getLock1" // do some work time.Sleep(50 * time.Millisecond) gfm.GetUnlock("testScope", "testType") }() go func() { defer wg.Done() time.Sleep(10 * time.Millisecond) gfm.ListLock("testScope", "testType") actionChan <- "findLock1" // do some work time.Sleep(50 * time.Millisecond) gfm.ListUnlock("testScope", "testType") }() go func() { defer wg.Done() time.Sleep(20 * time.Millisecond) gfm.GetLock("testScope", "testType") actionChan <- "getLock2" // do some work time.Sleep(50 * time.Millisecond) gfm.GetUnlock("testScope", "testType") }() actionWG.Go(func() { for action := range actionChan { order = append(order, action) } }) go func(t *testing.T) { wg.Wait() close(actionChan) actionWG.Wait() // The expected order is: Firstly getLock1 since nothing else is waiting // for a lock. While this one is working there is a query for a // findLock, then a getLock. The findLock should block the getLock until // it is done if order[0] != "getLock1" { t.Errorf("expected getLock1 to be first. Order was: %v", order) } if order[1] != "findLock1" { t.Errorf("expected findLock1 to be middle. Order was: %v", order) } if order[2] != "getLock2" { t.Errorf("expected getLock2 to be last. Order was: %v", order) } doneChan <- true }(t) select { case <-ctx.Done(): t.Errorf("timeout. Completed actions were: %v", order) case <-doneChan: // This is good } cancel() }) } ================================================ FILE: go/discovery/heartbeat.go ================================================ package discovery import ( "context" "errors" "time" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "google.golang.org/protobuf/types/known/durationpb" ) const DefaultHeartbeatFrequency = 5 * time.Minute var ErrNoHealthcheckDefined = errors.New("no healthcheck defined") // HeartbeatSender sends a heartbeat to the management API, this is called at // `DefaultHeartbeatFrequency` by default when the engine is running, or // `StartSendingHeartbeats` has been called manually. Users can also call this // method to immediately send a heartbeat if required. Pass non-`nil` error // to indicate that the engine is in an error state, this will be sent to the // management API and will be displayed in the UI. func (e *Engine) SendHeartbeat(ctx context.Context, customErr error) error { ctx, span := getTracer().Start(ctx, "SendHeartbeat") defer span.End() // Read memory stats and add them to the span memStats := tracing.ReadMemoryStats() tracing.SetMemoryAttributes(span, "ovm.heartbeat", memStats) span.SetAttributes( attribute.String("ovm.sdp.source_name", e.EngineConfig.SourceName), attribute.String("ovm.engine.type", e.EngineConfig.EngineType), attribute.String("ovm.engine.version", e.EngineConfig.Version), ) if e.EngineConfig.HeartbeatOptions == nil { return ErrNoHealthcheckDefined } // No-op when running without management API (e.g. ALLOW_UNAUTHENTICATED local dev) if e.EngineConfig.HeartbeatOptions.ManagementClient == nil { log.WithFields(log.Fields{ "source_name": e.EngineConfig.SourceName, "engine_type": e.EngineConfig.EngineType, }).Info("Running in unauthenticated mode; no heartbeats will be sent") return nil } // Collect all health check errors var allErrors []error if customErr != nil { allErrors = append(allErrors, customErr) } // Check for persistent initialization errors first if initErr := e.GetInitError(); initErr != nil { allErrors = append(allErrors, initErr) } if !e.AreAdaptersInitialized() { allErrors = append(allErrors, errors.New("adapters not yet initialized")) } // Check adapter readiness (ReadinessCheck) - with timeout to prevent hanging if e.EngineConfig.HeartbeatOptions.ReadinessCheck != nil { // Add timeout for readiness checks to prevent hanging heartbeats readinessCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := e.EngineConfig.HeartbeatOptions.ReadinessCheck(readinessCtx); err != nil { allErrors = append(allErrors, err) } } // Combine all errors var heartbeatError *string if len(allErrors) > 0 { combinedError := errors.Join(allErrors...) heartbeatError = new(string) *heartbeatError = combinedError.Error() } var engineUUID []byte if e.EngineConfig.SourceUUID != uuid.Nil { engineUUID = e.EngineConfig.SourceUUID[:] } availableScopes, adapterMetadata := e.GetAvailableScopesAndMetadata() // Calculate the duration for the next heartbeat, based on the current // frequency x2.5 to give us some leeway nextHeartbeat := time.Duration(float64(e.EngineConfig.HeartbeatOptions.Frequency) * 2.5) _, err := e.EngineConfig.HeartbeatOptions.ManagementClient.SubmitSourceHeartbeat(ctx, &connect.Request[sdp.SubmitSourceHeartbeatRequest]{ Msg: &sdp.SubmitSourceHeartbeatRequest{ UUID: engineUUID, Version: e.EngineConfig.Version, Name: e.EngineConfig.SourceName, Type: e.EngineConfig.EngineType, AvailableScopes: availableScopes, AdapterMetadata: adapterMetadata, Managed: e.EngineConfig.OvermindManagedSource, Error: heartbeatError, NextHeartbeatMax: durationpb.New(nextHeartbeat), }, }) // Update heartbeat status tracking e.heartbeatStatusMutex.Lock() if err != nil { e.lastHeartbeatError = err } else { e.lastSuccessfulHeartbeat = time.Now() e.lastHeartbeatError = nil } e.heartbeatStatusMutex.Unlock() return err } // Starts sending heartbeats at the specified frequency. These will be sent in // the background and this function will return immediately. Heartbeats are // automatically started when the engine started, but if an adapter has startup // steps that take a long time, or are liable to fail, the user may want to // start the heartbeats first so that users can see that the adapter has failed // to start. // // If this is called multiple times, nothing will happen. Heartbeats will be // stopped when the engine is stopped, or when the provided context is canceled. // // This will send one heartbeat initially when the method is called, and will // then run in a background goroutine that sends heartbeats at the specified // frequency, and will stop when the provided context is canceled. func (e *Engine) StartSendingHeartbeats(ctx context.Context) { if e.EngineConfig.HeartbeatOptions == nil || e.EngineConfig.HeartbeatOptions.Frequency == 0 || e.heartbeatCancel != nil { return } var heartbeatContext context.Context heartbeatContext, e.heartbeatCancel = context.WithCancel(ctx) // Send one heartbeat at the beginning err := e.SendHeartbeat(heartbeatContext, nil) if err != nil { log.WithError(err).Error("Failed to send heartbeat") } go func() { ticker := time.NewTicker(e.EngineConfig.HeartbeatOptions.Frequency) defer ticker.Stop() for { select { case <-heartbeatContext.Done(): return case <-ticker.C: err := e.SendHeartbeat(heartbeatContext, nil) if err != nil { log.WithError(err).Error("Failed to send heartbeat") } } } }() } ================================================ FILE: go/discovery/heartbeat_test.go ================================================ package discovery import ( "context" "slices" "testing" "time" "connectrpc.com/connect" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" ) type testHeartbeatClient struct { // Requests will be sent to this channel Requests chan *connect.Request[sdp.SubmitSourceHeartbeatRequest] // Responses should be sent here Responses chan *connect.Response[sdp.SubmitSourceHeartbeatResponse] } func (t testHeartbeatClient) SubmitSourceHeartbeat(ctx context.Context, req *connect.Request[sdp.SubmitSourceHeartbeatRequest]) (*connect.Response[sdp.SubmitSourceHeartbeatResponse], error) { t.Requests <- req return <-t.Responses, nil } func TestHeartbeats(t *testing.T) { name := t.Name() u := uuid.New() version := "v0.0.0-test" engineType := "aws" requests := make(chan *connect.Request[sdp.SubmitSourceHeartbeatRequest], 1) responses := make(chan *connect.Response[sdp.SubmitSourceHeartbeatResponse], 1) heartbeatOptions := HeartbeatOptions{ ManagementClient: testHeartbeatClient{ Requests: requests, Responses: responses, }, } ec := EngineConfig{ SourceName: name, SourceUUID: u, Version: version, EngineType: engineType, HeartbeatOptions: &heartbeatOptions, } e, _ := NewEngine(&ec) e.MarkAdaptersInitialized() if err := e.AddAdapters( &TestAdapter{ ReturnScopes: []string{"test"}, ReturnType: "test-type", ReturnName: "test-name", }, &TestAdapter{ ReturnScopes: []string{"test"}, ReturnType: "test-type2", ReturnName: "test-name2", }, ); err != nil { t.Fatalf("unexpected error adding adapters: %v", err) } t.Run("sendHeartbeat when healthy", func(t *testing.T) { ec.HeartbeatOptions.ReadinessCheck = func(_ context.Context) error { return nil } responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } err := e.SendHeartbeat(context.Background(), nil) if err != nil { t.Fatalf("unexpected error: %v", err) } req := <-requests if reqUUID, err := uuid.FromBytes(req.Msg.GetUUID()); err == nil { if reqUUID != u { t.Errorf("expected uuid %v, got %v", u, reqUUID) } } else { t.Errorf("error parsing uuid: %v", err) } if req.Msg.GetVersion() != version { t.Errorf("expected version %v, got %v", version, req.Msg.GetVersion()) } if req.Msg.GetName() != name { t.Errorf("expected name %v, got %v", name, req.Msg.GetName()) } if req.Msg.GetType() != engineType { t.Errorf("expected type %v, got %v", engineType, req.Msg.GetType()) } if req.Msg.GetManaged() != sdp.SourceManaged_LOCAL { t.Errorf("expected managed %v, got %v", sdp.SourceManaged_LOCAL, req.Msg.GetManaged()) } if req.Msg.GetError() != "" { t.Errorf("expected no error, got %v", req.Msg.GetError()) } reqAvailableScopes := req.Msg.GetAvailableScopes() if len(reqAvailableScopes) != 1 { t.Errorf("expected 1 scope, got %v", len(reqAvailableScopes)) } if !slices.Contains(reqAvailableScopes, "test") { t.Errorf("expected scope 'test' to be present in the response") } reqAdapterMetadata := req.Msg.GetAdapterMetadata() if len(reqAdapterMetadata) != 2 { t.Errorf("expected 2 adapter metadata, got %v", len(reqAdapterMetadata)) } }) t.Run("sendHeartbeat when unhealthy", func(t *testing.T) { e.EngineConfig.HeartbeatOptions.ReadinessCheck = func(_ context.Context) error { return ErrNoHealthcheckDefined } responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } err := e.SendHeartbeat(context.Background(), nil) if err != nil { t.Fatalf("unexpected error: %v", err) } req := <-requests // Error message is no longer wrapped (wrapping removed to avoid double-prefixing) expectedError := "no healthcheck defined" if req.Msg.GetError() != expectedError { t.Errorf("expected error %q, got %q", expectedError, req.Msg.GetError()) } }) t.Run("startSendingHeartbeats", func(t *testing.T) { e.EngineConfig.HeartbeatOptions.Frequency = time.Millisecond * 250 e.EngineConfig.HeartbeatOptions.ReadinessCheck = func(_ context.Context) error { return nil } ctx, cancel := context.WithCancel(context.Background()) start := time.Now() responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } e.StartSendingHeartbeats(ctx) // Get the initial heartbeat <-requests // Get two responses <- &connect.Response[sdp.SubmitSourceHeartbeatResponse]{ Msg: &sdp.SubmitSourceHeartbeatResponse{}, } <-requests cancel() // Make sure that took the expected amount of time if elapsed := time.Since(start); elapsed < time.Millisecond*250 { t.Errorf("expected to take at least 500ms, took %v", elapsed) } if elapsed := time.Since(start); elapsed > time.Millisecond*500 { t.Errorf("expected to take at most 750ms, took %v", elapsed) } }) } // TestSendHeartbeatNilManagementClient ensures unauthenticated/local dev mode // (HeartbeatOptions set by SetReadinessCheck but ManagementClient nil) does not error. func TestSendHeartbeatNilManagementClient(t *testing.T) { ec := EngineConfig{ SourceName: t.Name(), SourceUUID: uuid.New(), Version: "v0.0.0-test", EngineType: "aws", Unauthenticated: true, HeartbeatOptions: &HeartbeatOptions{ ManagementClient: nil, // e.g. ALLOW_UNAUTHENTICATED - no API to send to Frequency: time.Second * 30, }, } e, err := NewEngine(&ec) if err != nil { t.Fatalf("NewEngine: %v", err) } err = e.SendHeartbeat(context.Background(), nil) if err != nil { t.Errorf("SendHeartbeat with nil ManagementClient should be no-op, got: %v", err) } } ================================================ FILE: go/discovery/item_tests.go ================================================ // Reusable testing libraries for testing adapters package discovery import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" ) var RFC1123 = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) // TestValidateItem Checks an item to ensure it is a valid SDP item. This includes // checking that all required attributes are populated func TestValidateItem(t *testing.T, i *sdp.Item) { // Ensure that the item has the required fields set i.e. // // * Type // * UniqueAttribute // * Attributes if i.GetType() == "" { t.Errorf("Item %v has an empty Type", i.GloballyUniqueName()) } // Validate that the pattern is RFC1123 if !RFC1123.MatchString(i.GetType()) { pattern := ` Type names should match RFC1123 (lower case). This means the name must: * contain at most 63 characters * contain only lowercase alphanumeric characters or '-' * start with an alphanumeric character * end with an alphanumeric character ` t.Errorf("Item type %v is invalid. %v", i.GetType(), pattern) } if i.GetUniqueAttribute() == "" { t.Errorf("Item %v has an empty UniqueAttribute", i.GloballyUniqueName()) } attrMap := i.GetAttributes().GetAttrStruct().AsMap() if len(attrMap) == 0 { t.Errorf("Attributes for item %v are empty", i.GloballyUniqueName()) } // Check the attributes themselves for validity for k := range attrMap { if k == "" { t.Errorf("Item %v has an attribute with an empty name", i.GloballyUniqueName()) } } // Make sure that the UniqueAttributeValue is populated if i.UniqueAttributeValue() == "" { t.Errorf("UniqueAttribute %v for item %v is empty", i.GetUniqueAttribute(), i.GloballyUniqueName()) } // TODO(LIQs): delete this for index, linkedItem := range i.GetLinkedItems() { item := linkedItem.GetItem() if item.GetType() == "" { t.Errorf("LinkedItem %v of item %v has empty type", index, i.GloballyUniqueName()) } if item.GetUniqueAttributeValue() == "" { t.Errorf("LinkedItem %v of item %v has empty UniqueAttributeValue", index, i.GloballyUniqueName()) } // We don't need to check for an empty scope here since if it's empty // it will just inherit the scope of the parent } // TODO(LIQs): delete this for index, linkedItemQuery := range i.GetLinkedItemQueries() { query := linkedItemQuery.GetQuery() if query.GetType() == "" { t.Errorf("LinkedItemQueries %v of item %v has empty type", index, i.GloballyUniqueName()) } if query.GetMethod() != sdp.QueryMethod_LIST { if query.GetQuery() == "" { t.Errorf("LinkedItemQueries %v of item %v has empty query. This is not allowed unless the method is LIST", index, i.GloballyUniqueName()) } } if query.GetScope() == "" { t.Errorf("LinkedItemQueries %v of item %v has empty scope", index, i.GloballyUniqueName()) } } } // TestValidateItems Runs TestValidateItem on many items func TestValidateItems(t *testing.T, is []*sdp.Item) { for _, i := range is { TestValidateItem(t, i) } } ================================================ FILE: go/discovery/logs.go ================================================ package discovery import ( "context" "errors" "github.com/nats-io/nats.go" "github.com/overmindtech/cli/go/sdp-go" ) // LogAdapter is a singleton from the source that handles GetLogRecordsRequest // that come in via NATS. The discovery Engine takes care of the common // implementation details like subscribing to NATS, unpacking the request, // framing the responses, and error handling. Implementors only need to pass // results into the LogRecordsStream. type LogAdapter interface { // Get gets called when a GetLogRecordsRequest needs to be processed. To // return data to the requestor, use the provided `stream` to send // `GetLogRecordsResponse` messages back. // // If the implementation encounters an error, it should return the error as // `SourceError`. To indicate that the error is within the source, set the // `SourceError.Upstream` field to `false`. To indicate that the error is // with the upstream API, set the `SourceError.Upstream` field to `true`. // Always make sure that the error detail is set to a human-readable string // that is helpful for debugging. // // Implementations must not hold on to or share the `stream` object outside // of the scope of a single call. // // Concurrency: Every invocation of this method will happen in its own // goroutine, so implementors need to take care of ensuring thread safety. // // Cancellation: The context passed to this method will be cancelled when // any errors are encountered, like the NATS connection closing, the // requestor going away, or hitting a deadline. Implementations are expected // to timely detect the cancellation and clean up on the way out. After // `ctx` is cancelled, the implementation should not attempt to send any // more messages to the stream. Get(ctx context.Context, req *sdp.GetLogRecordsRequest, stream LogRecordsStream) error // Scopes returns all scopes this adapter is capable of handling. This is // used by the Engine to subscribe to the correct subjects. The Engine will // only call this method once, so implementors don't need to cache the // result. Scopes() []string } type LogRecordsStream interface { // Send takes a GetLogRecordsResponse, and forwards it to the caller over // NATS. Note that the order of responses is relevant and will be preserved. // // Errors returned from this method should be treated as fatal, and the // stream should be closed. The caller should not attempt to send any more // messages after this method returns an error. Basically, treat this like a // context cancellation on the `LogAdapter.Get` method. // // Concurrency: This method is not thread safe. The caller needs to ensure // that There is only one call of Send active at any time. Send(ctx context.Context, r *sdp.GetLogRecordsResponse) error } type LogRecordsStreamImpl struct { // The NATS stream that is used to send messages stream sdp.EncodedConnection // The NATS subject that is used to send messages subject string // responder has gone away responderGone bool responses int records int } // assert interface implementation var _ LogRecordsStream = (*LogRecordsStreamImpl)(nil) func (s *LogRecordsStreamImpl) Send(ctx context.Context, r *sdp.GetLogRecordsResponse) error { // immediately return if the gateway is gone if s.responderGone { return nats.ErrNoResponders } s.responses += 1 s.records += len(r.GetRecords()) // Send the message to the NATS stream err := s.stream.Publish(ctx, s.subject, &sdp.NATSGetLogRecordsResponse{ Content: &sdp.NATSGetLogRecordsResponse_Response{ Response: r, }, }) if errors.Is(err, nats.ErrNoResponders) { s.responderGone = true return err } if err != nil { return err } return nil } ================================================ FILE: go/discovery/logs_test.go ================================================ package discovery import ( "context" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" "google.golang.org/protobuf/types/known/timestamppb" ) type testLogAdapter struct { t *testing.T expected *sdp.GetLogRecordsRequest responses []*sdp.GetLogRecordsResponse err error } // assert interface implementation var _ LogAdapter = (*testLogAdapter)(nil) func (t *testLogAdapter) Get(ctx context.Context, request *sdp.GetLogRecordsRequest, stream LogRecordsStream) error { if t.expected == nil { t.t.Fatalf("expected LogAdapter to not get called, but got %v", request) } if t.expected.GetScope() != request.GetScope() { t.t.Errorf("expected scope %s but got %s", t.expected.GetScope(), request.GetScope()) } if t.expected.GetQuery() != request.GetQuery() { t.t.Errorf("expected query %s but got %s", t.expected.GetQuery(), request.GetQuery()) } // Compare timestamp values correctly if (t.expected.GetFrom() == nil) != (request.GetFrom() == nil) { t.t.Errorf("timestamp nullability mismatch: expected from is nil: %v, got from is nil: %v", t.expected.GetFrom() == nil, request.GetFrom() == nil) } else if t.expected.GetFrom() != nil && !t.expected.GetFrom().AsTime().Equal(request.GetFrom().AsTime()) { t.t.Errorf("expected from %s but got %s", t.expected.GetFrom().AsTime(), request.GetFrom().AsTime()) } if (t.expected.GetTo() == nil) != (request.GetTo() == nil) { t.t.Errorf("timestamp nullability mismatch: expected to is nil: %v, got to is nil: %v", t.expected.GetTo() == nil, request.GetTo() == nil) } else if t.expected.GetTo() != nil && !t.expected.GetTo().AsTime().Equal(request.GetTo().AsTime()) { t.t.Errorf("expected to %s but got %s", t.expected.GetTo().AsTime(), request.GetTo().AsTime()) } if t.expected.GetMaxRecords() != request.GetMaxRecords() { t.t.Errorf("expected maxRecords %d but got %d", t.expected.GetMaxRecords(), request.GetMaxRecords()) } if t.expected.GetStartFromOldest() != request.GetStartFromOldest() { t.t.Errorf("expected startFromOldest %v but got %v", t.expected.GetStartFromOldest(), request.GetStartFromOldest()) } for _, r := range t.responses { err := stream.Send(ctx, r) if err != nil { return err } } return t.err } func (t *testLogAdapter) Scopes() []string { return []string{"test"} } func TestLogAdapter_HappyPath(t *testing.T) { t.Parallel() ts := timestamppb.Now() tla := &testLogAdapter{ t: t, expected: &sdp.GetLogRecordsRequest{ Scope: "test", Query: "test", From: ts, To: ts, MaxRecords: 10, StartFromOldest: false, }, responses: []*sdp.GetLogRecordsResponse{ { Records: []*sdp.LogRecord{ { CreatedAt: timestamppb.Now(), ObservedAt: timestamppb.Now(), Severity: sdp.LogSeverity_INFO, Body: "page1/record1", }, { CreatedAt: timestamppb.Now(), ObservedAt: timestamppb.Now(), Severity: sdp.LogSeverity_INFO, Body: "page1/record2", }, }, }, { Records: []*sdp.LogRecord{ { CreatedAt: timestamppb.Now(), ObservedAt: timestamppb.Now(), Severity: sdp.LogSeverity_INFO, Body: "page2/record1", }, { CreatedAt: timestamppb.Now(), ObservedAt: timestamppb.Now(), Severity: sdp.LogSeverity_INFO, Body: "page2/record2", }, }, }, }, } tc := &sdp.TestConnection{ Messages: make([]sdp.ResponseMessage, 0), } e := newEngine(t, "logs.happyPath", nil, tc) if e == nil { t.Fatal("failed to create engine") } err := e.SetLogAdapter(tla) if err != nil { t.Fatal(err) } err = e.Start(t.Context()) if err != nil { t.Fatal(err) } defer func() { _ = e.Stop() }() _, _ = tc.Subscribe("logs.records.test", sdp.NewNATSGetLogRecordsResponseHandler( "", func(ctx context.Context, msg *sdp.NATSGetLogRecordsResponse) { t.Log("Received message:", msg) }, )) err = tc.PublishRequest(t.Context(), "logs.scope.test", "logs.records.test", &sdp.NATSGetLogRecordsRequest{ Request: &sdp.GetLogRecordsRequest{ Scope: "test", Query: "test", From: ts, To: ts, MaxRecords: 10, StartFromOldest: false, }, }) if err != nil { t.Log("Subscriptions:", tc.Subscriptions) t.Fatal(err) } // TODO: properly sync the test to wait for the messages to be sent time.Sleep(1 * time.Second) tc.MessagesMu.Lock() defer tc.MessagesMu.Unlock() if len(tc.Messages) != 5 { t.Fatalf("expected 5 messages but got %d: %v", len(tc.Messages), tc.Messages) } started := tc.Messages[1] if started.V.(*sdp.NATSGetLogRecordsResponse).GetStatus().GetStatus() != sdp.NATSGetLogRecordsResponseStatus_STARTED { t.Errorf("expected status STARTED but got %v", started.V) } page1 := tc.Messages[2] records := page1.V.(*sdp.NATSGetLogRecordsResponse).GetResponse().GetRecords() if len(records) != 2 { t.Errorf("expected 2 records but got %d: %v", len(records), records) } if records[0].GetBody() != "page1/record1" { t.Errorf("expected page1/record1 but got %v", page1.V) } page2 := tc.Messages[3] records = page2.V.(*sdp.NATSGetLogRecordsResponse).GetResponse().GetRecords() if len(records) != 2 { t.Errorf("expected 2 records but got %d: %v", len(records), records) } if records[0].GetBody() != "page2/record1" { t.Errorf("expected page2/record1 but got %v", page2.V) } finished := tc.Messages[4] if finished.V.(*sdp.NATSGetLogRecordsResponse).GetStatus().GetStatus() != sdp.NATSGetLogRecordsResponseStatus_FINISHED { t.Errorf("expected status FINISHED but got %v", finished.V) } } func TestLogAdapter_Validation_Scope(t *testing.T) { t.Parallel() ts := timestamppb.Now() tla := &testLogAdapter{ t: t, expected: nil, } tc := &sdp.TestConnection{ Messages: make([]sdp.ResponseMessage, 0), } e := newEngine(t, "logs.validation_scope", nil, tc) if e == nil { t.Fatal("failed to create engine") } err := e.SetLogAdapter(tla) if err != nil { t.Fatal(err) } err = e.Start(t.Context()) if err != nil { t.Fatal(err) } defer func() { _ = e.Stop() }() _, _ = tc.Subscribe("logs.records.test", sdp.NewNATSGetLogRecordsResponseHandler( "", func(ctx context.Context, msg *sdp.NATSGetLogRecordsResponse) { t.Log("Received message:", msg) }, )) err = tc.PublishRequest(t.Context(), "logs.scope.test", "logs.records.test", &sdp.NATSGetLogRecordsRequest{ Request: &sdp.GetLogRecordsRequest{ Scope: "different-scope", Query: "test", From: ts, To: ts, MaxRecords: 10, StartFromOldest: false, }, }) if err != nil { t.Log("Subscriptions:", tc.Subscriptions) t.Fatal(err) } // TODO: properly sync the test to wait for the messages to be sent time.Sleep(1 * time.Second) tc.MessagesMu.Lock() defer tc.MessagesMu.Unlock() if len(tc.Messages) == 0 { t.Fatalf("expected messages but got none: %v", tc.Messages) } msg := tc.Messages[len(tc.Messages)-1] if msg.V.(*sdp.NATSGetLogRecordsResponse).GetStatus().GetStatus() != sdp.NATSGetLogRecordsResponseStatus_ERRORED { t.Errorf("expected status ERRORED but got %v", msg.V) } } func TestLogAdapter_Validation_Empty(t *testing.T) { t.Parallel() ts := timestamppb.Now() tla := &testLogAdapter{ t: t, expected: nil, } tc := &sdp.TestConnection{ Messages: make([]sdp.ResponseMessage, 0), } e := newEngine(t, "logs.validation_scope", nil, tc) if e == nil { t.Fatal("failed to create engine") } err := e.SetLogAdapter(tla) if err != nil { t.Fatal(err) } err = e.Start(t.Context()) if err != nil { t.Fatal(err) } defer func() { _ = e.Stop() }() _, _ = tc.Subscribe("logs.records.test", sdp.NewNATSGetLogRecordsResponseHandler( "", func(ctx context.Context, msg *sdp.NATSGetLogRecordsResponse) { t.Log("Received message:", msg) }, )) err = tc.PublishRequest(t.Context(), "logs.scope.test", "logs.records.test", &sdp.NATSGetLogRecordsRequest{ Request: &sdp.GetLogRecordsRequest{ Scope: "test", Query: "", From: ts, To: ts, MaxRecords: 10, StartFromOldest: false, }, }) if err != nil { t.Log("Subscriptions:", tc.Subscriptions) t.Fatal(err) } // TODO: properly sync the test to wait for the messages to be sent time.Sleep(1 * time.Second) tc.MessagesMu.Lock() defer tc.MessagesMu.Unlock() if len(tc.Messages) == 0 { t.Fatalf("expected messages but got none: %v", tc.Messages) } msg := tc.Messages[len(tc.Messages)-1] if msg.V.(*sdp.NATSGetLogRecordsResponse).GetStatus().GetStatus() != sdp.NATSGetLogRecordsResponseStatus_ERRORED { t.Errorf("expected status ERRORED but got %v", msg.V) } } func TestLogAdapter_Validation_NoReplyTo(t *testing.T) { t.Parallel() ts := timestamppb.Now() tla := &testLogAdapter{ t: t, expected: nil, } tc := &sdp.TestConnection{ Messages: make([]sdp.ResponseMessage, 0), } e := newEngine(t, "logs.validation_scope", nil, tc) if e == nil { t.Fatal("failed to create engine") } err := e.SetLogAdapter(tla) if err != nil { t.Fatal(err) } err = e.Start(t.Context()) if err != nil { t.Fatal(err) } defer func() { _ = e.Stop() }() _, _ = tc.Subscribe("logs.records.test", sdp.NewNATSGetLogRecordsResponseHandler( "", func(ctx context.Context, msg *sdp.NATSGetLogRecordsResponse) { t.Log("Received message:", msg) }, )) err = tc.Publish(t.Context(), "logs.scope.test", &sdp.NATSGetLogRecordsRequest{ Request: &sdp.GetLogRecordsRequest{ Scope: "test", Query: "test", From: ts, To: ts, MaxRecords: 10, StartFromOldest: false, }, }) if err != nil { t.Log("Subscriptions:", tc.Subscriptions) t.Fatal(err) } // TODO: properly sync the test to wait for the messages to be sent time.Sleep(1 * time.Second) tc.MessagesMu.Lock() defer tc.MessagesMu.Unlock() // only the Request message should be sent, no responses if len(tc.Messages) != 1 { t.Fatalf("expected 1 message but got %d: %v", len(tc.Messages), tc.Messages) } } ================================================ FILE: go/discovery/main_test.go ================================================ package discovery import ( "context" "log" "os" "testing" "github.com/overmindtech/cli/go/tracing" ) func TestMain(m *testing.M) { exitCode := func() int { defer tracing.ShutdownTracer(context.Background()) if err := tracing.InitTracerWithUpstreams("discovery-tests", os.Getenv("HONEYCOMB_API_KEY"), ""); err != nil { log.Fatal(err) } return m.Run() }() os.Exit(exitCode) } ================================================ FILE: go/discovery/nats_shared_test.go ================================================ package discovery import ( "context" "fmt" "net" "net/url" "testing" "time" ) var NatsTestURLs = []string{ "nats://nats:4222", "nats://localhost:4222", } var NatsAuthTestURLs = []string{ "nats://nats-auth:4222", "nats://localhost:4223", } var tokenExchangeURLs = []string{ "http://api-server:8080/api", "http://localhost:8080/api", } // SkipWithoutNats Skips a test if NATS is not available func SkipWithoutNats(t *testing.T) { var err error for _, url := range NatsTestURLs { err = testURL(url) if err == nil { return } } if err != nil { t.Error(err) t.Skip("NATS not available") } } // SkipWithoutNatsAuth Skips a test if authenticated NATS is not available func SkipWithoutNatsAuth(t *testing.T) { var err error for _, url := range NatsAuthTestURLs { err = testURL(url) if err == nil { return } } if err != nil { t.Error(err) t.Skip("NATS not available") } } // SkipWithoutTokenExchange Skips a test if the token exchange API server is not available func SkipWithoutTokenExchange(t *testing.T) { var err error for _, url := range tokenExchangeURLs { err = testURL(url) if err == nil { return } } if err != nil { t.Error(err) t.Skip("Token exchange API server not available") } } func GetWorkingTokenExchange() (string, error) { var err error for _, url := range tokenExchangeURLs { if err = testURL(url); err == nil { return url, nil } } return "", fmt.Errorf("no working token exchanges found: %w", err) } func testURL(testURL string) error { url, err := url.Parse(testURL) if err != nil { return fmt.Errorf("could not parse NATS URL: %v. Error: %w", testURL, err) } dialer := &net.Dialer{ Timeout: time.Second, } conn, err := dialer.DialContext(context.Background(), "tcp", net.JoinHostPort(url.Hostname(), url.Port())) if err == nil { conn.Close() return nil } return err } ================================================ FILE: go/discovery/nats_watcher.go ================================================ package discovery import ( "context" "sync" "time" "github.com/nats-io/nats.go" log "github.com/sirupsen/logrus" ) // WatchableConnection Is ususally a *nats.Conn, we are using an interface here // to allow easier testing type WatchableConnection interface { Status() nats.Status Stats() nats.Statistics LastError() error } type NATSWatcher struct { // Connection The NATS connection to watch Connection WatchableConnection // FailureHandler will be called when the connection has been closed and is // no longer trying to reconnect, or when the connection has been in a // non-CONNECTED state for longer than ReconnectionTimeout. FailureHandler func() // ReconnectionTimeout is the maximum duration to wait for a reconnection // before triggering the FailureHandler. If set to 0, no timeout is applied // and the watcher only triggers on CLOSED status (legacy behavior). // Recommended value: 5 minutes. ReconnectionTimeout time.Duration watcherContext context.Context watcherCancel context.CancelFunc watcherTicker *time.Ticker watchingMutex sync.Mutex disconnectedSince time.Time hasBeenDisconnected bool failureHandlerTriggered bool } func (w *NATSWatcher) Start(checkInterval time.Duration) { if w == nil || w.Connection == nil { return } w.watcherContext, w.watcherCancel = context.WithCancel(context.Background()) w.watcherTicker = time.NewTicker(checkInterval) w.watchingMutex.Lock() go func(ctx context.Context) { defer w.watchingMutex.Unlock() for { select { case <-w.watcherTicker.C: status := w.Connection.Status() if status != nats.CONNECTED { // Track when we first became disconnected if !w.hasBeenDisconnected { w.disconnectedSince = time.Now() w.hasBeenDisconnected = true w.failureHandlerTriggered = false } disconnectedDuration := time.Since(w.disconnectedSince) log.WithFields(log.Fields{ "status": status.String(), "inBytes": w.Connection.Stats().InBytes, "outBytes": w.Connection.Stats().OutBytes, "reconnects": w.Connection.Stats().Reconnects, "lastError": w.Connection.LastError(), "disconnectedDuration": disconnectedDuration.String(), }).Warn("NATS not connected") // Trigger failure handler if connection is CLOSED (won't retry) // or if we've been disconnected for too long. Only trigger once // per disconnection period to avoid repeated calls while the // handler is working on reconnection. if !w.failureHandlerTriggered { shouldTriggerFailure := false if status == nats.CLOSED { log.Warn("NATS connection is CLOSED, triggering failure handler") shouldTriggerFailure = true } else if w.ReconnectionTimeout > 0 && disconnectedDuration > w.ReconnectionTimeout { log.WithFields(log.Fields{ "disconnectedDuration": disconnectedDuration.String(), "reconnectionTimeout": w.ReconnectionTimeout.String(), }).Error("NATS connection has been disconnected for too long, triggering failure handler") shouldTriggerFailure = true } if shouldTriggerFailure { // Mark that we've triggered the handler for this disconnection // period to prevent repeated calls w.failureHandlerTriggered = true w.FailureHandler() } } } else { // Reset the disconnection tracking when we're connected w.hasBeenDisconnected = false w.failureHandlerTriggered = false } case <-ctx.Done(): w.watcherTicker.Stop() return } } }(w.watcherContext) } func (w *NATSWatcher) Stop() { if w.watcherCancel != nil { w.watcherCancel() // Once we have sent the signal, wait until it's unlocked so we know // it's completely stopped w.watchingMutex.Lock() defer w.watchingMutex.Unlock() } } ================================================ FILE: go/discovery/nats_watcher_test.go ================================================ package discovery import ( "sync" "testing" "time" "github.com/nats-io/nats.go" ) type TestConnection struct { ReturnStatus nats.Status ReturnStats nats.Statistics ReturnError error Mutex sync.Mutex } func (t *TestConnection) Status() nats.Status { t.Mutex.Lock() defer t.Mutex.Unlock() return t.ReturnStatus } func (t *TestConnection) Stats() nats.Statistics { t.Mutex.Lock() defer t.Mutex.Unlock() return t.ReturnStats } func (t *TestConnection) LastError() error { t.Mutex.Lock() defer t.Mutex.Unlock() return t.ReturnError } func TestNATSWatcher(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTING, ReturnStats: nats.Statistics{}, ReturnError: nil, } fail := make(chan bool) w := NATSWatcher{ Connection: &c, FailureHandler: func() { fail <- true }, } interval := 10 * time.Millisecond w.Start(interval) time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.CONNECTED c.Mutex.Unlock() time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.CONNECTED c.Mutex.Unlock() time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.CLOSED c.Mutex.Unlock() select { case <-time.After(interval * 2): t.Errorf("FailureHandler not called in %v", (interval * 2).String()) case <-fail: // The fail handler has been called! t.Log("Fail handler called successfully 🥳") } } func TestFailureHandler(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTING, ReturnStats: nats.Statistics{}, ReturnError: nil, } var w *NATSWatcher done := make(chan bool, 1024) w = &NATSWatcher{ Connection: &c, FailureHandler: func() { go w.Stop() done <- true }, } interval := 100 * time.Millisecond w.Start(interval) time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.CLOSED c.Mutex.Unlock() time.Sleep(interval * 2) select { case <-time.After(interval * 2): t.Errorf("FailureHandler not completed in %v", (interval * 2).String()) case <-done: if len(done) != 0 { t.Errorf("Handler was called more than once") } // The fail handler has been called! t.Log("Fail handler called successfully 🥳") } } func TestReconnectionTimeout(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } fail := make(chan bool) w := NATSWatcher{ Connection: &c, // Set a short timeout for testing ReconnectionTimeout: 100 * time.Millisecond, FailureHandler: func() { fail <- true }, } interval := 10 * time.Millisecond w.Start(interval) // Start connected time.Sleep(interval * 2) // Transition to RECONNECTING state c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // Wait for the timeout to trigger (100ms + some buffer) select { case <-time.After(200 * time.Millisecond): t.Error("FailureHandler not called after reconnection timeout") case <-fail: t.Log("Fail handler called successfully after reconnection timeout 🥳") } w.Stop() } func TestReconnectionTimeoutNotTriggeredWhenConnected(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } fail := make(chan bool) w := NATSWatcher{ Connection: &c, // Set a short timeout for testing ReconnectionTimeout: 50 * time.Millisecond, FailureHandler: func() { fail <- true }, } interval := 10 * time.Millisecond w.Start(interval) // Briefly go to RECONNECTING state time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // But reconnect before timeout time.Sleep(20 * time.Millisecond) c.Mutex.Lock() c.ReturnStatus = nats.CONNECTED c.Mutex.Unlock() // Wait longer than the timeout to ensure it doesn't trigger select { case <-time.After(100 * time.Millisecond): t.Log("Timeout not triggered as expected when connection recovered 🥳") case <-fail: t.Error("FailureHandler should not be called when connection recovers before timeout") } w.Stop() } func TestReconnectionTimeoutDisabled(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } fail := make(chan bool) w := NATSWatcher{ Connection: &c, // No timeout set (0 means disabled) ReconnectionTimeout: 0, FailureHandler: func() { fail <- true }, } interval := 10 * time.Millisecond w.Start(interval) // Transition to RECONNECTING state time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // Wait for a while - should not trigger failure handler select { case <-time.After(100 * time.Millisecond): t.Log("Timeout correctly disabled, failure handler not called 🥳") case <-fail: t.Error("FailureHandler should not be called when timeout is disabled") } w.Stop() } func TestFailureHandlerNotCalledRepeatedly(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } failCount := 0 var mu sync.Mutex w := NATSWatcher{ Connection: &c, // Set a short timeout for testing ReconnectionTimeout: 50 * time.Millisecond, FailureHandler: func() { mu.Lock() failCount++ mu.Unlock() }, } interval := 10 * time.Millisecond w.Start(interval) // Transition to RECONNECTING state time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // Wait for timeout to trigger (50ms timeout + buffer) time.Sleep(80 * time.Millisecond) // Give it more time to ensure handler isn't called again time.Sleep(50 * time.Millisecond) w.Stop() mu.Lock() count := failCount mu.Unlock() if count != 1 { t.Errorf("FailureHandler should be called exactly once, but was called %d times", count) } else { t.Log("Failure handler called exactly once as expected 🥳") } } func TestStartWithNilConnection(t *testing.T) { w := NATSWatcher{ Connection: nil, FailureHandler: func() { t.Error("FailureHandler should not be called when connection is nil") }, } // Should not panic and should return early w.Start(10 * time.Millisecond) time.Sleep(20 * time.Millisecond) // If we get here without panicking, the test passes t.Log("Start with nil connection handled gracefully 🥳") } func TestStartWithNilWatcher(t *testing.T) { var w *NATSWatcher // Should not panic w.Start(10 * time.Millisecond) time.Sleep(20 * time.Millisecond) // If we get here without panicking, the test passes t.Log("Start with nil watcher handled gracefully 🥳") } func TestReconnectionTimeoutWithConnectingState(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } fail := make(chan bool) w := NATSWatcher{ Connection: &c, // Set a short timeout for testing ReconnectionTimeout: 100 * time.Millisecond, FailureHandler: func() { fail <- true }, } interval := 10 * time.Millisecond w.Start(interval) // Start connected time.Sleep(interval * 2) // Transition to CONNECTING state (not just RECONNECTING) c.Mutex.Lock() c.ReturnStatus = nats.CONNECTING c.Mutex.Unlock() // Wait for the timeout to trigger (100ms + some buffer) select { case <-time.After(200 * time.Millisecond): t.Error("FailureHandler not called after reconnection timeout with CONNECTING state") case <-fail: t.Log("Fail handler called successfully after reconnection timeout with CONNECTING state 🥳") } w.Stop() } func TestMultipleDisconnectionCycles(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } failCount := 0 var mu sync.Mutex w := NATSWatcher{ Connection: &c, // Set a short timeout for testing ReconnectionTimeout: 50 * time.Millisecond, FailureHandler: func() { mu.Lock() failCount++ mu.Unlock() }, } interval := 10 * time.Millisecond w.Start(interval) // First disconnection cycle time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // Wait for timeout to trigger time.Sleep(80 * time.Millisecond) // Reconnect c.Mutex.Lock() c.ReturnStatus = nats.CONNECTED c.Mutex.Unlock() time.Sleep(interval * 2) // Second disconnection cycle - should reset and allow handler to be called again c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // Wait for timeout to trigger again time.Sleep(80 * time.Millisecond) w.Stop() mu.Lock() count := failCount mu.Unlock() if count != 2 { t.Errorf("FailureHandler should be called twice (once per disconnection cycle), but was called %d times", count) } else { t.Log("Failure handler called correctly for multiple disconnection cycles 🥳") } } func TestStopBeforeStart(t *testing.T) { w := NATSWatcher{ Connection: &TestConnection{ ReturnStatus: nats.CONNECTED, }, } // Should not panic if Stop is called before Start w.Stop() t.Log("Stop before Start handled gracefully 🥳") } func TestStopMultipleTimes(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } w := NATSWatcher{ Connection: &c, FailureHandler: func() {}, } interval := 10 * time.Millisecond w.Start(interval) time.Sleep(interval * 2) // Stop multiple times should not panic w.Stop() w.Stop() w.Stop() t.Log("Multiple Stop calls handled gracefully 🥳") } func TestHandlerResetAfterReconnection(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } failCount := 0 var mu sync.Mutex w := NATSWatcher{ Connection: &c, // Set a short timeout for testing ReconnectionTimeout: 50 * time.Millisecond, FailureHandler: func() { mu.Lock() failCount++ mu.Unlock() }, } interval := 10 * time.Millisecond w.Start(interval) // First disconnection - trigger timeout time.Sleep(interval * 2) c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // Wait for timeout time.Sleep(80 * time.Millisecond) // Reconnect - this should reset the tracking c.Mutex.Lock() c.ReturnStatus = nats.CONNECTED c.Mutex.Unlock() time.Sleep(interval * 2) // Disconnect again - should be able to trigger handler again c.Mutex.Lock() c.ReturnStatus = nats.RECONNECTING c.Mutex.Unlock() // Wait for timeout again time.Sleep(80 * time.Millisecond) w.Stop() mu.Lock() count := failCount mu.Unlock() if count != 2 { t.Errorf("FailureHandler should be called twice after reconnection reset, but was called %d times", count) } else { t.Log("Handler reset correctly after reconnection 🥳") } } func TestCLOSEDStatusTriggersImmediately(t *testing.T) { c := TestConnection{ ReturnStatus: nats.CONNECTED, ReturnStats: nats.Statistics{}, ReturnError: nil, } fail := make(chan bool) w := NATSWatcher{ Connection: &c, // Even with timeout set, CLOSED should trigger immediately ReconnectionTimeout: 100 * time.Millisecond, FailureHandler: func() { fail <- true }, } interval := 10 * time.Millisecond w.Start(interval) // Start connected time.Sleep(interval * 2) // Transition directly to CLOSED (should trigger immediately, not wait for timeout) c.Mutex.Lock() c.ReturnStatus = nats.CLOSED c.Mutex.Unlock() // Should trigger much faster than the timeout select { case <-time.After(50 * time.Millisecond): t.Error("FailureHandler not called immediately for CLOSED status") case <-fail: t.Log("Fail handler called immediately for CLOSED status 🥳") } w.Stop() } ================================================ FILE: go/discovery/nil_publisher.go ================================================ package discovery import ( "context" "fmt" "github.com/nats-io/nats.go" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/proto" ) // When testing this library, or running without a real NATS connection, it is // necessary to create a fake publisher rather than pass in a nil pointer. This // is due to the fact that the NATS libraries will panic if a method is called // on a nil pointer type NilConnection struct{} // assert interface implementation var _ sdp.EncodedConnection = (*NilConnection)(nil) // Publish Does nothing except log an error func (n NilConnection) Publish(ctx context.Context, subj string, m proto.Message) error { log.WithFields(log.Fields{ "subject": subj, "message": fmt.Sprint(m), }).Error("Could not publish NATS message due to no connection") return nil } // PublishRequest Does nothing except log an error func (n NilConnection) PublishRequest(ctx context.Context, subj, replyTo string, m proto.Message) error { log.WithFields(log.Fields{ "subject": subj, "replyTo": replyTo, "message": fmt.Sprint(m), }).Error("Could not publish NATS message request due to no connection") return nil } // PublishMsg Does nothing except log an error func (n NilConnection) PublishMsg(ctx context.Context, msg *nats.Msg) error { log.WithFields(log.Fields{ "subject": msg.Subject, "message": fmt.Sprint(msg), }).Error("Could not publish NATS message due to no connection") return nil } // Subscribe Does nothing except log an error func (n NilConnection) Subscribe(subj string, cb nats.MsgHandler) (*nats.Subscription, error) { log.WithFields(log.Fields{ "subject": subj, }).Error("Could not subscribe to NAT subject due to no connection") return nil, nil } // QueueSubscribe Does nothing except log an error func (n NilConnection) QueueSubscribe(subj, queue string, cb nats.MsgHandler) (*nats.Subscription, error) { log.WithFields(log.Fields{ "subject": subj, "queue": queue, }).Error("Could not subscribe to NAT subject queue due to no connection") return nil, nil } // Request Does nothing except log an error func (n NilConnection) RequestMsg(ctx context.Context, msg *nats.Msg) (*nats.Msg, error) { log.WithFields(log.Fields{ "subject": msg.Subject, "message": fmt.Sprint(msg), }).Error("Could not publish NATS request due to no connection") return nil, nil } // Status Always returns nats.CONNECTED func (n NilConnection) Status() nats.Status { return nats.CONNECTED } // Stats Always returns empty/zero nats.Statistics func (n NilConnection) Stats() nats.Statistics { return nats.Statistics{} } // LastError Always returns nil func (n NilConnection) LastError() error { return nil } // Drain Always returns nil func (n NilConnection) Drain() error { return nil } // Close Does nothing func (n NilConnection) Close() {} // Underlying Always returns nil func (n NilConnection) Underlying() *nats.Conn { return nil } // Drop Does nothing func (n NilConnection) Drop() {} ================================================ FILE: go/discovery/performance_test.go ================================================ package discovery import ( "context" "math" "os" "sync" "testing" "time" "github.com/overmindtech/cli/go/auth" "github.com/overmindtech/cli/go/sdp-go" ) type SlowAdapter struct { QueryDuration time.Duration } func (s *SlowAdapter) Type() string { return "person" } func (s *SlowAdapter) Name() string { return "slow-adapter" } func (s *SlowAdapter) DefaultCacheDuration() time.Duration { return 10 * time.Minute } func (s *SlowAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{} } func (s *SlowAdapter) Scopes() []string { return []string{"test"} } func (s *SlowAdapter) Hidden() bool { return false } func (s *SlowAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { end := time.Now().Add(s.QueryDuration) attributes, _ := sdp.ToAttributes(map[string]any{ "name": query, }) item := sdp.Item{ Type: "person", UniqueAttribute: "name", Attributes: attributes, Scope: "test", // TODO(LIQs): delete this LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // TODO(LIQs): convert to returning edges for i := 0; i != 2; i++ { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: RandomName(), Scope: "test", }}) } time.Sleep(time.Until(end)) return &item, nil } func (s *SlowAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return []*sdp.Item{}, nil } func (s *SlowAdapter) Weight() int { return 100 } func TestParallelQueryPerformance(t *testing.T) { if os.Getenv("GITHUB_ACTIONS") != "" { t.Skip("Performance tests under github actions are too unreliable") } // This test is designed to ensure that query duration is linear up to a // certain point. Above that point the overhead caused by having so many // goroutines running will start to make the response times non-linear which // maybe isn't ideal but given realistic loads we probably don't care. t.Run("Without linking", func(t *testing.T) { RunLinearPerformanceTest(t, "1 query", 1, 0, 1) RunLinearPerformanceTest(t, "10 queries", 10, 0, 1) RunLinearPerformanceTest(t, "100 queries", 100, 0, 10) RunLinearPerformanceTest(t, "1,000 queries", 1000, 0, 100) }) } // RunLinearPerformanceTest Runs a test with a given number in input queries, // link depth and parallelization limit. Expected results and expected duration // are determined automatically meaning all this is testing for is the fact that // the performance continues to be linear and predictable func RunLinearPerformanceTest(t *testing.T, name string, numQueries int, linkDepth int, numParallel int) { t.Helper() t.Run(name, func(t *testing.T) { result := TimeQueries(t, numQueries, linkDepth, numParallel) if len(result.Results) != result.ExpectedItems { t.Errorf("Expected %v items, got %v (%v errors)", result.ExpectedItems, len(result.Results), len(result.Errors)) } if result.TimeTaken > result.MaxTime { t.Errorf("Queries took too long: %v Max: %v", result.TimeTaken.String(), result.MaxTime.String()) } }) } type TimedResults struct { ExpectedItems int MaxTime time.Duration TimeTaken time.Duration Results []*sdp.Item Errors []*sdp.QueryError } func TimeQueries(t *testing.T, numQueries int, linkDepth int, numParallel int) TimedResults { ec := EngineConfig{ MaxParallelExecutions: numParallel, Unauthenticated: true, NATSOptions: &auth.NATSOptions{ NumRetries: 5, RetryDelay: time.Second, Servers: NatsTestURLs, ConnectionName: "test-connection", ConnectionTimeout: time.Second, MaxReconnects: 5, }, } e, err := NewEngine(&ec) if err != nil { t.Fatalf("Error initializing Engine: %v", err) } err = e.AddAdapters(&SlowAdapter{ QueryDuration: 100 * time.Millisecond, }) if err != nil { t.Fatalf("Error adding adapter: %v", err) } err = e.Start(t.Context()) if err != nil { t.Fatalf("Error starting Engine: %v", err) } defer func() { err = e.Stop() if err != nil { t.Fatalf("Error stopping Engine: %v", err) } }() // Calculate how many items to expect and the expected duration var expectedItems int var expectedDuration time.Duration for i := 0; i <= linkDepth; i++ { thisLayer := int(math.Pow(2, float64(i))) * numQueries // Expect that it'll take no longer that 120% of the sleep time. thisDuration := 120 * math.Ceil(float64(thisLayer)/float64(numParallel)) expectedDuration = expectedDuration + (time.Duration(thisDuration) * time.Millisecond) expectedItems = expectedItems + thisLayer } results := make([]*sdp.Item, 0) errors := make([]*sdp.QueryError, 0) resultsMutex := sync.Mutex{} wg := sync.WaitGroup{} start := time.Now() for range numQueries { qt := QueryTracker{ Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: RandomName(), Scope: "test", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: uint32(linkDepth), }, }, Engine: e, } wg.Add(1) go func(qt *QueryTracker) { defer wg.Done() items, _, errs, _ := qt.Execute(context.Background()) resultsMutex.Lock() results = append(results, items...) errors = append(errors, errs...) resultsMutex.Unlock() }(&qt) } wg.Wait() return TimedResults{ ExpectedItems: expectedItems, MaxTime: expectedDuration, TimeTaken: time.Since(start), Results: results, Errors: errors, } } ================================================ FILE: go/discovery/querytracker.go ================================================ package discovery import ( "context" "errors" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // QueryTracker is used for tracking the progress of a single query. This // is used because a single query could have a link depth that results in many // additional queries being executed meaning that we need to not only track the first // query, but also all other queries and items that result from linking type QueryTracker struct { // The query to track Query *sdp.Query Context context.Context // The context that this query is running in Cancel context.CancelFunc // The cancel function for the context // The engine that this is connected to, used for sending NATS messages Engine *Engine } // Execute Executes a given item query and publishes results and errors on the // relevant nats subjects. Returns the full list of items, errors, and a final // error. The final error will be populated if all adapters failed, or some other // error was encountered while trying run the query // // If the context is cancelled, all query work will stop func (qt *QueryTracker) Execute(ctx context.Context) ([]*sdp.Item, []*sdp.Edge, []*sdp.QueryError, error) { if qt.Query == nil { return nil, nil, nil, nil } if qt.Engine == nil { return nil, nil, nil, errors.New("no engine supplied, cannot execute") } span := trace.SpanFromContext(ctx) span.SetAttributes( attribute.String("ovm.sdp.source_name", qt.Engine.EngineConfig.SourceName), attribute.String("ovm.engine.type", qt.Engine.EngineConfig.EngineType), attribute.String("ovm.engine.version", qt.Engine.EngineConfig.Version), ) responses := make(chan *sdp.QueryResponse) errChan := make(chan error, 1) sdpItems := make([]*sdp.Item, 0) sdpEdges := make([]*sdp.Edge, 0) sdpErrs := make([]*sdp.QueryError, 0) // Run the query in the background go func(e chan error) { defer tracing.LogRecoverToReturn(ctx, "Execute -> ExecuteQuery") defer close(e) e <- qt.Engine.ExecuteQuery(ctx, qt.Query, responses) }(errChan) // Process the responses as they come in var natsPublishMaxNs int64 var natsPublishTotalNs int64 var natsPublishCount int for response := range responses { if qt.Query.Subject() != "" && qt.Engine.natsConnection != nil { publishStart := time.Now() err := qt.Engine.natsConnection.Publish(ctx, qt.Query.Subject(), response) publishNs := time.Since(publishStart).Nanoseconds() natsPublishTotalNs += publishNs natsPublishCount++ if publishNs > natsPublishMaxNs { natsPublishMaxNs = publishNs } if err != nil { span.RecordError(err) log.WithError(err).Error("Response publishing error") } } switch response := response.GetResponseType().(type) { case *sdp.QueryResponse_NewItem: sdpItems = append(sdpItems, response.NewItem) case *sdp.QueryResponse_Edge: sdpEdges = append(sdpEdges, response.Edge) case *sdp.QueryResponse_Error: sdpErrs = append(sdpErrs, response.Error) } } span.SetAttributes( attribute.Float64("ovm.nats.publishMaxMs", float64(natsPublishMaxNs)/1e6), attribute.Float64("ovm.nats.publishTotalMs", float64(natsPublishTotalNs)/1e6), attribute.Int("ovm.nats.publishCount", natsPublishCount), ) // Get the result of the execution err := <-errChan if err != nil { return sdpItems, sdpEdges, sdpErrs, err } return sdpItems, sdpEdges, sdpErrs, ctx.Err() } ================================================ FILE: go/discovery/querytracker_test.go ================================================ package discovery import ( "context" "sync" "testing" "time" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "google.golang.org/protobuf/types/known/structpb" ) type SpeedTestAdapter struct { QueryDelay time.Duration ReturnType string ReturnScopes []string } func (s *SpeedTestAdapter) Type() string { if s.ReturnType != "" { return s.ReturnType } return "person" } func (s *SpeedTestAdapter) Name() string { return "SpeedTestAdapter" } func (s *SpeedTestAdapter) Scopes() []string { if len(s.ReturnScopes) > 0 { return s.ReturnScopes } return []string{"test"} } func (s *SpeedTestAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{} } func (s *SpeedTestAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { select { case <-time.After(s.QueryDelay): return &sdp.Item{ Type: s.Type(), UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": { Kind: &structpb.Value_StringValue{ StringValue: query, }, }, }, }, }, // TODO(LIQs): convert to returning edges LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: query + time.Now().String(), Scope: scope, }, }, }, Scope: scope, }, nil case <-ctx.Done(): return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_TIMEOUT, ErrorString: ctx.Err().Error(), Scope: scope, } } } func (s *SpeedTestAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { item, err := s.Get(ctx, scope, "dylan", ignoreCache) return []*sdp.Item{item}, err } func (s *SpeedTestAdapter) Weight() int { return 10 } func TestExecute(t *testing.T) { adapter := TestAdapter{ ReturnType: "person", ReturnScopes: []string{ "test", }, cache: sdpcache.NewNoOpCache(), } e := newStartedEngine(t, "TestExecute", nil, nil, &adapter) t.Run("Without linking", func(t *testing.T) { t.Parallel() qt := QueryTracker{ Engine: e, Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Dylan", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "test", }, } items, edges, errs, err := qt.Execute(context.Background()) if err != nil { t.Error(err) } for _, e := range errs { t.Error(e) } if l := len(items); l != 1 { t.Errorf("expected 1 items, got %v: %v", l, items) } if l := len(edges); l != 0 { t.Errorf("expected 0 items, got %v: %v", l, edges) } }) t.Run("With no engine", func(t *testing.T) { t.Parallel() qt := QueryTracker{ Engine: nil, Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Dylan", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 10, }, Scope: "test", }, } _, _, _, err := qt.Execute(context.Background()) if err == nil { t.Error("expected error but got nil") } }) t.Run("With no queries", func(t *testing.T) { t.Parallel() qt := QueryTracker{ Engine: e, } _, _, _, err := qt.Execute(context.Background()) if err != nil { t.Error(err) } }) } func TestTimeout(t *testing.T) { adapter := SpeedTestAdapter{ QueryDelay: 100 * time.Millisecond, } e := newStartedEngine(t, "TestTimeout", nil, nil, &adapter) t.Run("With a timeout, but not exceeding it", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) qt := QueryTracker{ Engine: e, Context: ctx, Cancel: cancel, Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "Dylan", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "test", }, } items, edges, errs, err := qt.Execute(context.Background()) if err != nil { t.Error(err) } for _, e := range errs { t.Error(e) } if l := len(items); l != 1 { t.Errorf("expected 1 items, got %v: %v", l, items) } if l := len(edges); l != 0 { t.Errorf("expected 0 edges, got %v: %v", l, edges) } }) t.Run("With a timeout that is exceeded", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) qt := QueryTracker{ Engine: e, Context: ctx, Cancel: cancel, Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "somethingElse", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "test", }, } _, _, _, err := qt.Execute(ctx) if err == nil { t.Error("Expected timeout but got no error") } }) } func TestCancel(t *testing.T) { e := newStartedEngine(t, "TestCancel", nil, nil) u := uuid.New() ctx, cancel := context.WithCancel(context.Background()) qt := QueryTracker{ Engine: e, Context: ctx, Cancel: cancel, Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: "somethingElse1", RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 10, }, Scope: "test", UUID: u[:], }, } items := make([]*sdp.Item, 0) edges := make([]*sdp.Edge, 0) var wg sync.WaitGroup var err error wg.Go(func() { items, edges, _, err = qt.Execute(context.Background()) }) // Give it some time to populate the cancelFunc time.Sleep(100 * time.Millisecond) qt.Cancel() wg.Wait() if err == nil { t.Error("expected error but got none") } if len(items) != 0 { t.Errorf("Expected no items but got %v", items) } if len(edges) != 0 { t.Errorf("Expected no edges but got %v", edges) } } ================================================ FILE: go/discovery/shared_test.go ================================================ package discovery import ( "context" "fmt" "math/rand" "sync" "sync/atomic" "time" "github.com/goombaio/namegenerator" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "google.golang.org/protobuf/types/known/structpb" ) const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" func randString(length int) string { var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) b := make([]byte, length) for i := range b { b[i] = charset[seededRand.Intn(len(charset))] } return string(b) } func RandomName() string { seed := time.Now().UTC().UnixNano() nameGenerator := namegenerator.NewNameGenerator(seed) name := nameGenerator.Generate() randGarbage := randString(10) return fmt.Sprintf("%v-%v", name, randGarbage) } var generation atomic.Int32 func (s *TestAdapter) NewTestItem(scope string, query string) *sdp.Item { gen := generation.Add(1) return &sdp.Item{ Type: s.Type(), Scope: scope, UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue(query), "age": structpb.NewNumberValue(28), "generation": structpb.NewNumberValue(float64(gen)), }, }, }, // TODO(LIQs): convert to returning edges LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "person", Method: sdp.QueryMethod_GET, Query: RandomName(), Scope: scope, }, }, }, } } type TestAdapter struct { ReturnScopes []string ReturnType string GetCalls [][]string ListCalls [][]string SearchCalls [][]string IsHidden bool ReturnWeight int // Weight to be returned ReturnName string // The name of the Adapter mutex sync.Mutex CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // This is mandatory } // NewTestAdapter creates a new TestAdapter with cache initialized func NewTestAdapter() *TestAdapter { return &TestAdapter{ cache: sdpcache.NewNoOpCache(), // Initialize with NoOpCache to avoid nil pointer dereferences } } // ClearCalls Clears the call counters between tests func (s *TestAdapter) ClearCalls() { s.mutex.Lock() defer s.mutex.Unlock() s.ListCalls = make([][]string, 0) s.SearchCalls = make([][]string, 0) s.GetCalls = make([][]string, 0) if s.cache != nil { s.cache.Clear() } } func (s *TestAdapter) Type() string { if s.ReturnType != "" { return s.ReturnType } return "person" } func (s *TestAdapter) Name() string { return fmt.Sprintf("testAdapter-%v", s.ReturnName) } func (s *TestAdapter) DefaultCacheDuration() time.Duration { return 100 * time.Millisecond } func (s *TestAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: "Person", } } func (s *TestAdapter) Scopes() []string { if len(s.ReturnScopes) > 0 { return s.ReturnScopes } return []string{"test"} } func (s *TestAdapter) Hidden() bool { return s.IsHidden } func (s *TestAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { s.mutex.Lock() defer s.mutex.Unlock() var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() cacheHit, ck, cachedItems, qErr, done = s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) > 0 { return cachedItems[0], nil } else { return nil, nil } } s.GetCalls = append(s.GetCalls, []string{scope, query}) switch scope { case "empty": err := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no items found", Scope: scope, } s.cache.StoreUnavailableItem(ctx, err, s.DefaultCacheDuration(), ck) return nil, err case "error": return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Error for testing", Scope: scope, } default: item := s.NewTestItem(scope, query) s.cache.StoreItem(ctx, item, s.DefaultCacheDuration(), ck) return item, nil } } func (s *TestAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { s.mutex.Lock() defer s.mutex.Unlock() var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() cacheHit, ck, cachedItems, qErr, done = s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.Type(), "", ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { return cachedItems, nil } s.ListCalls = append(s.ListCalls, []string{scope}) switch scope { case "empty": err := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no items found", Scope: scope, } s.cache.StoreUnavailableItem(ctx, err, s.DefaultCacheDuration(), ck) return nil, err case "error": return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Error for testing", Scope: scope, } default: item := s.NewTestItem(scope, "Dylan") items := []*sdp.Item{item} s.cache.StoreItem(ctx, item, s.DefaultCacheDuration(), ck) return items, nil } } func (s *TestAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { s.mutex.Lock() defer s.mutex.Unlock() var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() cacheHit, ck, cachedItems, qErr, done = s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { return cachedItems, nil } s.SearchCalls = append(s.SearchCalls, []string{scope, query}) switch scope { case "empty": err := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no items found", Scope: scope, } s.cache.StoreUnavailableItem(ctx, err, s.DefaultCacheDuration(), ck) return nil, err case "error": return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Error for testing", Scope: scope, } default: item := s.NewTestItem(scope, "Dylan") items := []*sdp.Item{item} s.cache.StoreItem(ctx, item, s.DefaultCacheDuration(), ck) return items, nil } } func (s *TestAdapter) Weight() int { return s.ReturnWeight } ================================================ FILE: go/discovery/tracing.go ================================================ package discovery import ( "go.opentelemetry.io/otel" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "go.opentelemetry.io/otel/trace" ) const ( instrumentationName = "github.com/overmindtech/cli/go/discovery/discovery" instrumentationVersion = "0.0.1" ) // getTracer returns the discovery tracer from the current global TracerProvider. // Call this at span creation time (not once at init) so tests can install an // in-memory TracerProvider before running discovery code. func getTracer() trace.Tracer { return otel.GetTracerProvider().Tracer( instrumentationName, trace.WithInstrumentationVersion(instrumentationVersion), trace.WithSchemaURL(semconv.SchemaURL), ) } ================================================ FILE: go/logging/logging.go ================================================ package logging import ( log "github.com/sirupsen/logrus" ) // ConfigureLogrusJSON sets the logger to emit JSON logs with a GCP severity field. func ConfigureLogrusJSON(logger *log.Logger) { if logger == nil { return } logger.SetFormatter(&log.JSONFormatter{}) logger.AddHook(OtelSeverityHook{}) } // OtelSeverityHook adds a GCP-compatible severity field to log entries. type OtelSeverityHook struct{} func (OtelSeverityHook) Levels() []log.Level { return log.AllLevels } func (OtelSeverityHook) Fire(entry *log.Entry) error { if entry == nil { return nil } if _, ok := entry.Data["severity"]; ok { return nil } entry.Data["severity"] = severityForLevel(entry.Level) return nil } func severityForLevel(level log.Level) string { switch level { case log.PanicLevel: return "emergency" case log.FatalLevel: return "critical" case log.ErrorLevel: return "error" case log.WarnLevel: return "warning" case log.InfoLevel: return "info" case log.DebugLevel, log.TraceLevel: return "debug" default: return "default" } } ================================================ FILE: go/logging/logging_test.go ================================================ package logging import ( "bytes" "encoding/json" "testing" log "github.com/sirupsen/logrus" ) func TestSeverityForLevel(t *testing.T) { t.Parallel() tests := []struct { name string level log.Level want string }{ {name: "panic", level: log.PanicLevel, want: "emergency"}, {name: "fatal", level: log.FatalLevel, want: "critical"}, {name: "error", level: log.ErrorLevel, want: "error"}, {name: "warn", level: log.WarnLevel, want: "warning"}, {name: "info", level: log.InfoLevel, want: "info"}, {name: "debug", level: log.DebugLevel, want: "debug"}, {name: "trace", level: log.TraceLevel, want: "debug"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := severityForLevel(tt.level) if got != tt.want { t.Errorf("severityForLevel(%v) = %q, want %q", tt.level, got, tt.want) } }) } } func TestConfigureLogrusJSONAddsSeverity(t *testing.T) { t.Parallel() logger := log.New() var buf bytes.Buffer logger.SetOutput(&buf) ConfigureLogrusJSON(logger) logger.WithField("component", "test").Info("hello") var payload map[string]any if err := json.Unmarshal(buf.Bytes(), &payload); err != nil { t.Fatalf("unmarshal log payload: %v", err) } got, ok := payload["severity"] if !ok { t.Fatalf("expected severity field in log payload, got: %#v", payload) } if got != "info" { t.Fatalf("expected severity %q, got %v", "info", got) } } func TestConfigureLogrusJSONRespectsExistingSeverity(t *testing.T) { t.Parallel() logger := log.New() var buf bytes.Buffer logger.SetOutput(&buf) ConfigureLogrusJSON(logger) logger.WithField("severity", "SPECIAL").Info("hello") var payload map[string]any if err := json.Unmarshal(buf.Bytes(), &payload); err != nil { t.Fatalf("unmarshal log payload: %v", err) } got, ok := payload["severity"] if !ok { t.Fatalf("expected severity field in log payload, got: %#v", payload) } if got != "SPECIAL" { t.Fatalf("expected severity %q, got %v", "SPECIAL", got) } } ================================================ FILE: go/sdp-go/.gitignore ================================================ vendor .DS_Store ================================================ FILE: go/sdp-go/account.go ================================================ package sdp import "github.com/google/uuid" func (a *SourceMetadata) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetUUID()) if err != nil { return nil } return &u } func (a *SourceHealth) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetUUID()) if err != nil { return nil } return &u } ================================================ FILE: go/sdp-go/account.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: account.proto package sdp import ( _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" structpb "google.golang.org/protobuf/types/known/structpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type SourceStatus int32 const ( SourceStatus_STATUS_UNSPECIFIED SourceStatus = 0 // The source is starting or updating. This is only applicable to managed // sources where Overmind manages the source's lifecycle SourceStatus_STATUS_PROGRESSING SourceStatus = 1 // The source is healthy SourceStatus_STATUS_HEALTHY SourceStatus = 2 // The source is unhealthy SourceStatus_STATUS_UNHEALTHY SourceStatus = 3 // The source is sleeping due to inactivity. It will be woken up before it // is needed. This is only applicable to managed sources where Overmind // manages the source's lifecycle SourceStatus_STATUS_SLEEPING SourceStatus = 4 // The source is disconnected and therefore not able to handle requests. // This will only be returned for non-managed sources that have recently // stopped sending heartbeats such as a user running the CLI that has // recently disconnected SourceStatus_STATUS_DISCONNECTED SourceStatus = 5 ) // Enum value maps for SourceStatus. var ( SourceStatus_name = map[int32]string{ 0: "STATUS_UNSPECIFIED", 1: "STATUS_PROGRESSING", 2: "STATUS_HEALTHY", 3: "STATUS_UNHEALTHY", 4: "STATUS_SLEEPING", 5: "STATUS_DISCONNECTED", } SourceStatus_value = map[string]int32{ "STATUS_UNSPECIFIED": 0, "STATUS_PROGRESSING": 1, "STATUS_HEALTHY": 2, "STATUS_UNHEALTHY": 3, "STATUS_SLEEPING": 4, "STATUS_DISCONNECTED": 5, } ) func (x SourceStatus) Enum() *SourceStatus { p := new(SourceStatus) *p = x return p } func (x SourceStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (SourceStatus) Descriptor() protoreflect.EnumDescriptor { return file_account_proto_enumTypes[0].Descriptor() } func (SourceStatus) Type() protoreflect.EnumType { return &file_account_proto_enumTypes[0] } func (x SourceStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use SourceStatus.Descriptor instead. func (SourceStatus) EnumDescriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{0} } type RepositoryStatus int32 const ( RepositoryStatus_REPOSITORY_STATUS_UNSPECIFIED RepositoryStatus = 0 // Repository has had changes within the defined activity window RepositoryStatus_REPOSITORY_STATUS_ACTIVE RepositoryStatus = 1 // Repository has not had changes within the defined activity window RepositoryStatus_REPOSITORY_STATUS_INACTIVE RepositoryStatus = 2 ) // Enum value maps for RepositoryStatus. var ( RepositoryStatus_name = map[int32]string{ 0: "REPOSITORY_STATUS_UNSPECIFIED", 1: "REPOSITORY_STATUS_ACTIVE", 2: "REPOSITORY_STATUS_INACTIVE", } RepositoryStatus_value = map[string]int32{ "REPOSITORY_STATUS_UNSPECIFIED": 0, "REPOSITORY_STATUS_ACTIVE": 1, "REPOSITORY_STATUS_INACTIVE": 2, } ) func (x RepositoryStatus) Enum() *RepositoryStatus { p := new(RepositoryStatus) *p = x return p } func (x RepositoryStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (RepositoryStatus) Descriptor() protoreflect.EnumDescriptor { return file_account_proto_enumTypes[1].Descriptor() } func (RepositoryStatus) Type() protoreflect.EnumType { return &file_account_proto_enumTypes[1] } func (x RepositoryStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use RepositoryStatus.Descriptor instead. func (RepositoryStatus) EnumDescriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{1} } type AccountPlan int32 const ( AccountPlan_ACCOUNT_PLAN_UNSPECIFIED AccountPlan = 0 // Free plan with one repo AccountPlan_ACCOUNT_PLAN_FREE AccountPlan = 1 // Enterprise plan with unlimited repos AccountPlan_ACCOUNT_PLAN_ENTERPRISE AccountPlan = 2 ) // Enum value maps for AccountPlan. var ( AccountPlan_name = map[int32]string{ 0: "ACCOUNT_PLAN_UNSPECIFIED", 1: "ACCOUNT_PLAN_FREE", 2: "ACCOUNT_PLAN_ENTERPRISE", } AccountPlan_value = map[string]int32{ "ACCOUNT_PLAN_UNSPECIFIED": 0, "ACCOUNT_PLAN_FREE": 1, "ACCOUNT_PLAN_ENTERPRISE": 2, } ) func (x AccountPlan) Enum() *AccountPlan { p := new(AccountPlan) *p = x return p } func (x AccountPlan) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (AccountPlan) Descriptor() protoreflect.EnumDescriptor { return file_account_proto_enumTypes[2].Descriptor() } func (AccountPlan) Type() protoreflect.EnumType { return &file_account_proto_enumTypes[2] } func (x AccountPlan) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use AccountPlan.Descriptor instead. func (AccountPlan) EnumDescriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{2} } // Whether the source is managed by srcman or was created by the user locally type SourceManaged int32 const ( SourceManaged_LOCAL SourceManaged = 0 // Local is the default SourceManaged_MANAGED SourceManaged = 1 ) // Enum value maps for SourceManaged. var ( SourceManaged_name = map[int32]string{ 0: "LOCAL", 1: "MANAGED", } SourceManaged_value = map[string]int32{ "LOCAL": 0, "MANAGED": 1, } ) func (x SourceManaged) Enum() *SourceManaged { p := new(SourceManaged) *p = x return p } func (x SourceManaged) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (SourceManaged) Descriptor() protoreflect.EnumDescriptor { return file_account_proto_enumTypes[3].Descriptor() } func (SourceManaged) Type() protoreflect.EnumType { return &file_account_proto_enumTypes[3] } func (x SourceManaged) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use SourceManaged.Descriptor instead. func (SourceManaged) EnumDescriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{3} } type AdapterCategory int32 const ( // Fall-back category for resources that do not fit into any other category AdapterCategory_ADAPTER_CATEGORY_OTHER AdapterCategory = 0 // This category includes resources that provide processing power and host // applications or services. Examples are virtual machines, containers, // serverless functions, and application hosting platforms. If the primary // purpose of a resource is to execute workloads, run code, or host // applications, it should belong here. AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION AdapterCategory = 1 // Encompassing resources designed to store, archive, and manage data, this // category includes object storage, block storage, file storage, and data // backup solutions. Select this category when the core function of a // resource is persistent data storage or management AdapterCategory_ADAPTER_CATEGORY_STORAGE AdapterCategory = 2 // This category covers resources that facilitate connectivity and // communication within cloud environments. Typical resources include // virtual networks, load balancers, VPNs, and DNS services. Assign // resources here if their primary role is related to communication, // connectivity, or traffic management AdapterCategory_ADAPTER_CATEGORY_NETWORK AdapterCategory = 3 // Resources in this category focus on safeguarding data, applications, and // cloud infrastructure. Examples include firewalls, identity and access // management, encryption services, and security monitoring tools. Choose // this category if a resource's main function is security, access control, // or compliance AdapterCategory_ADAPTER_CATEGORY_SECURITY AdapterCategory = 4 // This category includes resources aimed at monitoring, tracing, and // logging applications and cloud infrastructure. Examples are monitoring // tools, logging services, and performance management solutions. Use this // category for resources that provide insights into system performance and // health AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY AdapterCategory = 5 // Focused on structured data storage and management, this category includes // relational, NoSQL, and in-memory databases, along with data warehousing // solutions. Choose this category for resources specifically designed for // data querying, transaction processing, or complex data operations. This // differs from "storage" in that "databases" have compute associated with // them rather than just storing data. AdapterCategory_ADAPTER_CATEGORY_DATABASE AdapterCategory = 6 // This category includes resources designed for managing configurations and // deployments. Examples are infrastructure as code tools, configuration // management services, and deployment orchestration solutions. Classify // resources here if they primarily handle configuration, environment // management, or automated deployment AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION AdapterCategory = 7 // This category is dedicated to resources for developing, training, and // deploying artificial intelligence models and machine learning // applications. Include machine learning platforms, AI services, and data // labeling tools here. Select this category if a resource's principal // function involves AI or machine learning processes AdapterCategory_ADAPTER_CATEGORY_AI AdapterCategory = 8 ) // Enum value maps for AdapterCategory. var ( AdapterCategory_name = map[int32]string{ 0: "ADAPTER_CATEGORY_OTHER", 1: "ADAPTER_CATEGORY_COMPUTE_APPLICATION", 2: "ADAPTER_CATEGORY_STORAGE", 3: "ADAPTER_CATEGORY_NETWORK", 4: "ADAPTER_CATEGORY_SECURITY", 5: "ADAPTER_CATEGORY_OBSERVABILITY", 6: "ADAPTER_CATEGORY_DATABASE", 7: "ADAPTER_CATEGORY_CONFIGURATION", 8: "ADAPTER_CATEGORY_AI", } AdapterCategory_value = map[string]int32{ "ADAPTER_CATEGORY_OTHER": 0, "ADAPTER_CATEGORY_COMPUTE_APPLICATION": 1, "ADAPTER_CATEGORY_STORAGE": 2, "ADAPTER_CATEGORY_NETWORK": 3, "ADAPTER_CATEGORY_SECURITY": 4, "ADAPTER_CATEGORY_OBSERVABILITY": 5, "ADAPTER_CATEGORY_DATABASE": 6, "ADAPTER_CATEGORY_CONFIGURATION": 7, "ADAPTER_CATEGORY_AI": 8, } ) func (x AdapterCategory) Enum() *AdapterCategory { p := new(AdapterCategory) *p = x return p } func (x AdapterCategory) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (AdapterCategory) Descriptor() protoreflect.EnumDescriptor { return file_account_proto_enumTypes[4].Descriptor() } func (AdapterCategory) Type() protoreflect.EnumType { return &file_account_proto_enumTypes[4] } func (x AdapterCategory) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use AdapterCategory.Descriptor instead. func (AdapterCategory) EnumDescriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{4} } type ListAccountsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAccountsRequest) Reset() { *x = ListAccountsRequest{} mi := &file_account_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAccountsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAccountsRequest) ProtoMessage() {} func (x *ListAccountsRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAccountsRequest.ProtoReflect.Descriptor instead. func (*ListAccountsRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{0} } type ListAccountsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Accounts []*Account `protobuf:"bytes,1,rep,name=accounts,proto3" json:"accounts,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAccountsResponse) Reset() { *x = ListAccountsResponse{} mi := &file_account_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAccountsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAccountsResponse) ProtoMessage() {} func (x *ListAccountsResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAccountsResponse.ProtoReflect.Descriptor instead. func (*ListAccountsResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{1} } func (x *ListAccountsResponse) GetAccounts() []*Account { if x != nil { return x.Accounts } return nil } type CreateAccountRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Properties *AccountProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateAccountRequest) Reset() { *x = CreateAccountRequest{} mi := &file_account_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateAccountRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateAccountRequest) ProtoMessage() {} func (x *CreateAccountRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateAccountRequest.ProtoReflect.Descriptor instead. func (*CreateAccountRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{2} } func (x *CreateAccountRequest) GetProperties() *AccountProperties { if x != nil { return x.Properties } return nil } type CreateAccountResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Account *Account `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateAccountResponse) Reset() { *x = CreateAccountResponse{} mi := &file_account_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateAccountResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateAccountResponse) ProtoMessage() {} func (x *CreateAccountResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateAccountResponse.ProtoReflect.Descriptor instead. func (*CreateAccountResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{3} } func (x *CreateAccountResponse) GetAccount() *Account { if x != nil { return x.Account } return nil } type UpdateAccountRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Properties *AccountProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateAccountRequest) Reset() { *x = UpdateAccountRequest{} mi := &file_account_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateAccountRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateAccountRequest) ProtoMessage() {} func (x *UpdateAccountRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateAccountRequest.ProtoReflect.Descriptor instead. func (*UpdateAccountRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{4} } func (x *UpdateAccountRequest) GetProperties() *AccountProperties { if x != nil { return x.Properties } return nil } type UpdateAccountResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Account *Account `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateAccountResponse) Reset() { *x = UpdateAccountResponse{} mi := &file_account_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateAccountResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateAccountResponse) ProtoMessage() {} func (x *UpdateAccountResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateAccountResponse.ProtoReflect.Descriptor instead. func (*UpdateAccountResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{5} } func (x *UpdateAccountResponse) GetAccount() *Account { if x != nil { return x.Account } return nil } type AdminUpdateAccountRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the account to update Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Request *UpdateAccountRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminUpdateAccountRequest) Reset() { *x = AdminUpdateAccountRequest{} mi := &file_account_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminUpdateAccountRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminUpdateAccountRequest) ProtoMessage() {} func (x *AdminUpdateAccountRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminUpdateAccountRequest.ProtoReflect.Descriptor instead. func (*AdminUpdateAccountRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{6} } func (x *AdminUpdateAccountRequest) GetName() string { if x != nil { return x.Name } return "" } func (x *AdminUpdateAccountRequest) GetRequest() *UpdateAccountRequest { if x != nil { return x.Request } return nil } type AdminGetAccountRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the account to get Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminGetAccountRequest) Reset() { *x = AdminGetAccountRequest{} mi := &file_account_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminGetAccountRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminGetAccountRequest) ProtoMessage() {} func (x *AdminGetAccountRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminGetAccountRequest.ProtoReflect.Descriptor instead. func (*AdminGetAccountRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{7} } func (x *AdminGetAccountRequest) GetName() string { if x != nil { return x.Name } return "" } type AdminDeleteAccountRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the account to delete Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminDeleteAccountRequest) Reset() { *x = AdminDeleteAccountRequest{} mi := &file_account_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminDeleteAccountRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminDeleteAccountRequest) ProtoMessage() {} func (x *AdminDeleteAccountRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminDeleteAccountRequest.ProtoReflect.Descriptor instead. func (*AdminDeleteAccountRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{8} } func (x *AdminDeleteAccountRequest) GetName() string { if x != nil { return x.Name } return "" } type AdminDeleteAccountResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminDeleteAccountResponse) Reset() { *x = AdminDeleteAccountResponse{} mi := &file_account_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminDeleteAccountResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminDeleteAccountResponse) ProtoMessage() {} func (x *AdminDeleteAccountResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminDeleteAccountResponse.ProtoReflect.Descriptor instead. func (*AdminDeleteAccountResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{9} } type AdminListSourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Request *ListSourcesRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminListSourcesRequest) Reset() { *x = AdminListSourcesRequest{} mi := &file_account_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminListSourcesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminListSourcesRequest) ProtoMessage() {} func (x *AdminListSourcesRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminListSourcesRequest.ProtoReflect.Descriptor instead. func (*AdminListSourcesRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{10} } func (x *AdminListSourcesRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *AdminListSourcesRequest) GetRequest() *ListSourcesRequest { if x != nil { return x.Request } return nil } type AdminCreateSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Request *CreateSourceRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminCreateSourceRequest) Reset() { *x = AdminCreateSourceRequest{} mi := &file_account_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminCreateSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminCreateSourceRequest) ProtoMessage() {} func (x *AdminCreateSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminCreateSourceRequest.ProtoReflect.Descriptor instead. func (*AdminCreateSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{11} } func (x *AdminCreateSourceRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *AdminCreateSourceRequest) GetRequest() *CreateSourceRequest { if x != nil { return x.Request } return nil } type AdminGetSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Request *GetSourceRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminGetSourceRequest) Reset() { *x = AdminGetSourceRequest{} mi := &file_account_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminGetSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminGetSourceRequest) ProtoMessage() {} func (x *AdminGetSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminGetSourceRequest.ProtoReflect.Descriptor instead. func (*AdminGetSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{12} } func (x *AdminGetSourceRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *AdminGetSourceRequest) GetRequest() *GetSourceRequest { if x != nil { return x.Request } return nil } type AdminUpdateSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Request *UpdateSourceRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminUpdateSourceRequest) Reset() { *x = AdminUpdateSourceRequest{} mi := &file_account_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminUpdateSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminUpdateSourceRequest) ProtoMessage() {} func (x *AdminUpdateSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminUpdateSourceRequest.ProtoReflect.Descriptor instead. func (*AdminUpdateSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{13} } func (x *AdminUpdateSourceRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *AdminUpdateSourceRequest) GetRequest() *UpdateSourceRequest { if x != nil { return x.Request } return nil } type AdminDeleteSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Request *DeleteSourceRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminDeleteSourceRequest) Reset() { *x = AdminDeleteSourceRequest{} mi := &file_account_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminDeleteSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminDeleteSourceRequest) ProtoMessage() {} func (x *AdminDeleteSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminDeleteSourceRequest.ProtoReflect.Descriptor instead. func (*AdminDeleteSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{14} } func (x *AdminDeleteSourceRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *AdminDeleteSourceRequest) GetRequest() *DeleteSourceRequest { if x != nil { return x.Request } return nil } type AdminKeepaliveSourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Request *KeepaliveSourcesRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminKeepaliveSourcesRequest) Reset() { *x = AdminKeepaliveSourcesRequest{} mi := &file_account_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminKeepaliveSourcesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminKeepaliveSourcesRequest) ProtoMessage() {} func (x *AdminKeepaliveSourcesRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminKeepaliveSourcesRequest.ProtoReflect.Descriptor instead. func (*AdminKeepaliveSourcesRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{15} } func (x *AdminKeepaliveSourcesRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *AdminKeepaliveSourcesRequest) GetRequest() *KeepaliveSourcesRequest { if x != nil { return x.Request } return nil } type AdminCreateTokenRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` Request *CreateTokenRequest `protobuf:"bytes,2,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdminCreateTokenRequest) Reset() { *x = AdminCreateTokenRequest{} mi := &file_account_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdminCreateTokenRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdminCreateTokenRequest) ProtoMessage() {} func (x *AdminCreateTokenRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdminCreateTokenRequest.ProtoReflect.Descriptor instead. func (*AdminCreateTokenRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{16} } func (x *AdminCreateTokenRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *AdminCreateTokenRequest) GetRequest() *CreateTokenRequest { if x != nil { return x.Request } return nil } type Source struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *SourceMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Properties *SourceProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Source) Reset() { *x = Source{} mi := &file_account_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Source) String() string { return protoimpl.X.MessageStringOf(x) } func (*Source) ProtoMessage() {} func (x *Source) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Source.ProtoReflect.Descriptor instead. func (*Source) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{17} } func (x *Source) GetMetadata() *SourceMetadata { if x != nil { return x.Metadata } return nil } func (x *Source) GetProperties() *SourceProperties { if x != nil { return x.Properties } return nil } type SourceMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // TODO: Change to ID along with everything else // The name of the NATS JWT that has been generated for this source TokenName string `protobuf:"bytes,2,opt,name=TokenName,proto3" json:"TokenName,omitempty"` // When the NATS JWT expires (unix time) TokenExpiry *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=TokenExpiry,proto3" json:"TokenExpiry,omitempty"` // The public NKey associated with the NATS JWT PublicNkey string `protobuf:"bytes,5,opt,name=PublicNkey,proto3" json:"PublicNkey,omitempty"` // Status of the source Status SourceStatus `protobuf:"varint,9,opt,name=Status,proto3,enum=account.SourceStatus" json:"Status,omitempty"` // The error message if the source is unhealthy Error string `protobuf:"bytes,10,opt,name=Error,proto3" json:"Error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SourceMetadata) Reset() { *x = SourceMetadata{} mi := &file_account_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SourceMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*SourceMetadata) ProtoMessage() {} func (x *SourceMetadata) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SourceMetadata.ProtoReflect.Descriptor instead. func (*SourceMetadata) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{18} } func (x *SourceMetadata) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *SourceMetadata) GetTokenName() string { if x != nil { return x.TokenName } return "" } func (x *SourceMetadata) GetTokenExpiry() *timestamppb.Timestamp { if x != nil { return x.TokenExpiry } return nil } func (x *SourceMetadata) GetPublicNkey() string { if x != nil { return x.PublicNkey } return "" } func (x *SourceMetadata) GetStatus() SourceStatus { if x != nil { return x.Status } return SourceStatus_STATUS_UNSPECIFIED } func (x *SourceMetadata) GetError() string { if x != nil { return x.Error } return "" } // A source that is capable of discovering items type SourceProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // The descriptive name of the source DescriptiveName string `protobuf:"bytes,1,opt,name=DescriptiveName,proto3" json:"DescriptiveName,omitempty"` // What source to configure. Can be "stdlib", "aws", or "gcp". Type string `protobuf:"bytes,2,opt,name=Type,proto3" json:"Type,omitempty"` // Config for this source. See the source documentation for what // source-specific config is available/required. This will be supplied // directly to viper via a config file at `/etc/srcman/config/source.yaml` Config *structpb.Struct `protobuf:"bytes,3,opt,name=Config,proto3" json:"Config,omitempty"` // Additional config options that should be passed to the source. The keys // of this object should be file names, and the values should be their // content. These files will be made available to the source at runtime. // Check the source's documentation for what to configure here if required AdditionalConfig *structpb.Struct `protobuf:"bytes,4,opt,name=AdditionalConfig,proto3" json:"AdditionalConfig,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SourceProperties) Reset() { *x = SourceProperties{} mi := &file_account_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SourceProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*SourceProperties) ProtoMessage() {} func (x *SourceProperties) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SourceProperties.ProtoReflect.Descriptor instead. func (*SourceProperties) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{19} } func (x *SourceProperties) GetDescriptiveName() string { if x != nil { return x.DescriptiveName } return "" } func (x *SourceProperties) GetType() string { if x != nil { return x.Type } return "" } func (x *SourceProperties) GetConfig() *structpb.Struct { if x != nil { return x.Config } return nil } func (x *SourceProperties) GetAdditionalConfig() *structpb.Struct { if x != nil { return x.AdditionalConfig } return nil } type Account struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *AccountMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Properties *AccountProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Account) Reset() { *x = Account{} mi := &file_account_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Account) String() string { return protoimpl.X.MessageStringOf(x) } func (*Account) ProtoMessage() {} func (x *Account) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Account.ProtoReflect.Descriptor instead. func (*Account) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{20} } func (x *Account) GetMetadata() *AccountMetadata { if x != nil { return x.Metadata } return nil } func (x *Account) GetProperties() *AccountProperties { if x != nil { return x.Properties } return nil } type AccountMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // The public Nkey which signs all NATS "user" tokens PublicNkey string `protobuf:"bytes,2,opt,name=PublicNkey,proto3" json:"PublicNkey,omitempty"` // Repositories that have been used in this account Repositories []*Repository `protobuf:"bytes,3,rep,name=repositories,proto3" json:"repositories,omitempty"` // The total number of repositories associated with this account TotalRepositories uint32 `protobuf:"varint,4,opt,name=totalRepositories,proto3" json:"totalRepositories,omitempty"` // The number of active repositories (for billing purposes) ActiveRepositories uint32 `protobuf:"varint,5,opt,name=activeRepositories,proto3" json:"activeRepositories,omitempty"` // The billing plan for this account Plan AccountPlan `protobuf:"varint,6,opt,name=Plan,proto3,enum=account.AccountPlan" json:"Plan,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AccountMetadata) Reset() { *x = AccountMetadata{} mi := &file_account_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AccountMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*AccountMetadata) ProtoMessage() {} func (x *AccountMetadata) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AccountMetadata.ProtoReflect.Descriptor instead. func (*AccountMetadata) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{21} } func (x *AccountMetadata) GetPublicNkey() string { if x != nil { return x.PublicNkey } return "" } func (x *AccountMetadata) GetRepositories() []*Repository { if x != nil { return x.Repositories } return nil } func (x *AccountMetadata) GetTotalRepositories() uint32 { if x != nil { return x.TotalRepositories } return 0 } func (x *AccountMetadata) GetActiveRepositories() uint32 { if x != nil { return x.ActiveRepositories } return 0 } func (x *AccountMetadata) GetPlan() AccountPlan { if x != nil { return x.Plan } return AccountPlan_ACCOUNT_PLAN_UNSPECIFIED } type Repository struct { state protoimpl.MessageState `protogen:"open.v1"` // Repository identifier; can be a URL, name, or any string identifier. Not necessarily a URL. CLI attempts auto-population, but users can override. Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // The number of changes that have been recorded in this repository NumChanges int64 `protobuf:"varint,2,opt,name=numChanges,proto3" json:"numChanges,omitempty"` // The last time a change was recorded in this repository LastChangeAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=lastChangeAt,proto3" json:"lastChangeAt,omitempty"` // The status of the repository (active or inactive). This is determined // based on the last change that was recorded. Status RepositoryStatus `protobuf:"varint,4,opt,name=status,proto3,enum=account.RepositoryStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Repository) Reset() { *x = Repository{} mi := &file_account_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Repository) String() string { return protoimpl.X.MessageStringOf(x) } func (*Repository) ProtoMessage() {} func (x *Repository) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Repository.ProtoReflect.Descriptor instead. func (*Repository) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{22} } func (x *Repository) GetName() string { if x != nil { return x.Name } return "" } func (x *Repository) GetNumChanges() int64 { if x != nil { return x.NumChanges } return 0 } func (x *Repository) GetLastChangeAt() *timestamppb.Timestamp { if x != nil { return x.LastChangeAt } return nil } func (x *Repository) GetStatus() RepositoryStatus { if x != nil { return x.Status } return RepositoryStatus_REPOSITORY_STATUS_UNSPECIFIED } type AccountProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the account Name string `protobuf:"bytes,1,opt,name=Name,proto3" json:"Name,omitempty"` // The Customer ID within Stripe StripeCustomerID string `protobuf:"bytes,2,opt,name=StripeCustomerID,proto3" json:"StripeCustomerID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AccountProperties) Reset() { *x = AccountProperties{} mi := &file_account_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AccountProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*AccountProperties) ProtoMessage() {} func (x *AccountProperties) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AccountProperties.ProtoReflect.Descriptor instead. func (*AccountProperties) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{23} } func (x *AccountProperties) GetName() string { if x != nil { return x.Name } return "" } func (x *AccountProperties) GetStripeCustomerID() string { if x != nil { return x.StripeCustomerID } return "" } type GetAccountRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAccountRequest) Reset() { *x = GetAccountRequest{} mi := &file_account_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAccountRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAccountRequest) ProtoMessage() {} func (x *GetAccountRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAccountRequest.ProtoReflect.Descriptor instead. func (*GetAccountRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{24} } type GetAccountResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Account *Account `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAccountResponse) Reset() { *x = GetAccountResponse{} mi := &file_account_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAccountResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAccountResponse) ProtoMessage() {} func (x *GetAccountResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAccountResponse.ProtoReflect.Descriptor instead. func (*GetAccountResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{25} } func (x *GetAccountResponse) GetAccount() *Account { if x != nil { return x.Account } return nil } type DeleteAccountRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Set to true to confirm that the user is sure they want to delete their // account. This is to prevent accidental deletions IAmSure bool `protobuf:"varint,1,opt,name=iAmSure,proto3" json:"iAmSure,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteAccountRequest) Reset() { *x = DeleteAccountRequest{} mi := &file_account_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteAccountRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteAccountRequest) ProtoMessage() {} func (x *DeleteAccountRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteAccountRequest.ProtoReflect.Descriptor instead. func (*DeleteAccountRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{26} } func (x *DeleteAccountRequest) GetIAmSure() bool { if x != nil { return x.IAmSure } return false } type DeleteAccountResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteAccountResponse) Reset() { *x = DeleteAccountResponse{} mi := &file_account_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteAccountResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteAccountResponse) ProtoMessage() {} func (x *DeleteAccountResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteAccountResponse.ProtoReflect.Descriptor instead. func (*DeleteAccountResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{27} } type ListSourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListSourcesRequest) Reset() { *x = ListSourcesRequest{} mi := &file_account_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListSourcesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListSourcesRequest) ProtoMessage() {} func (x *ListSourcesRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListSourcesRequest.ProtoReflect.Descriptor instead. func (*ListSourcesRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{28} } type ListSourcesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Sources []*Source `protobuf:"bytes,1,rep,name=Sources,proto3" json:"Sources,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListSourcesResponse) Reset() { *x = ListSourcesResponse{} mi := &file_account_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListSourcesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListSourcesResponse) ProtoMessage() {} func (x *ListSourcesResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListSourcesResponse.ProtoReflect.Descriptor instead. func (*ListSourcesResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{29} } func (x *ListSourcesResponse) GetSources() []*Source { if x != nil { return x.Sources } return nil } type CreateSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Properties *SourceProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateSourceRequest) Reset() { *x = CreateSourceRequest{} mi := &file_account_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateSourceRequest) ProtoMessage() {} func (x *CreateSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateSourceRequest.ProtoReflect.Descriptor instead. func (*CreateSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{30} } func (x *CreateSourceRequest) GetProperties() *SourceProperties { if x != nil { return x.Properties } return nil } type CreateSourceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Source *Source `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateSourceResponse) Reset() { *x = CreateSourceResponse{} mi := &file_account_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateSourceResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateSourceResponse) ProtoMessage() {} func (x *CreateSourceResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateSourceResponse.ProtoReflect.Descriptor instead. func (*CreateSourceResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{31} } func (x *CreateSourceResponse) GetSource() *Source { if x != nil { return x.Source } return nil } type GetSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSourceRequest) Reset() { *x = GetSourceRequest{} mi := &file_account_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSourceRequest) ProtoMessage() {} func (x *GetSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSourceRequest.ProtoReflect.Descriptor instead. func (*GetSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{32} } func (x *GetSourceRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type GetSourceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Source *Source `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSourceResponse) Reset() { *x = GetSourceResponse{} mi := &file_account_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSourceResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSourceResponse) ProtoMessage() {} func (x *GetSourceResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSourceResponse.ProtoReflect.Descriptor instead. func (*GetSourceResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{33} } func (x *GetSourceResponse) GetSource() *Source { if x != nil { return x.Source } return nil } type UpdateSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // ID of the source to update UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // Properties to update Properties *SourceProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateSourceRequest) Reset() { *x = UpdateSourceRequest{} mi := &file_account_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateSourceRequest) ProtoMessage() {} func (x *UpdateSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateSourceRequest.ProtoReflect.Descriptor instead. func (*UpdateSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{34} } func (x *UpdateSourceRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *UpdateSourceRequest) GetProperties() *SourceProperties { if x != nil { return x.Properties } return nil } type UpdateSourceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Source *Source `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateSourceResponse) Reset() { *x = UpdateSourceResponse{} mi := &file_account_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateSourceResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateSourceResponse) ProtoMessage() {} func (x *UpdateSourceResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateSourceResponse.ProtoReflect.Descriptor instead. func (*UpdateSourceResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{35} } func (x *UpdateSourceResponse) GetSource() *Source { if x != nil { return x.Source } return nil } type DeleteSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // ID if the source to delete UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteSourceRequest) Reset() { *x = DeleteSourceRequest{} mi := &file_account_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteSourceRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteSourceRequest) ProtoMessage() {} func (x *DeleteSourceRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteSourceRequest.ProtoReflect.Descriptor instead. func (*DeleteSourceRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{36} } func (x *DeleteSourceRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type DeleteSourceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteSourceResponse) Reset() { *x = DeleteSourceResponse{} mi := &file_account_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteSourceResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteSourceResponse) ProtoMessage() {} func (x *DeleteSourceResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteSourceResponse.ProtoReflect.Descriptor instead. func (*DeleteSourceResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{37} } type SourceKeepaliveResult struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the source that was kept alive UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // The status of the source Status SourceStatus `protobuf:"varint,2,opt,name=Status,proto3,enum=account.SourceStatus" json:"Status,omitempty"` // The error message if the source is unhealthy Error string `protobuf:"bytes,3,opt,name=Error,proto3" json:"Error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SourceKeepaliveResult) Reset() { *x = SourceKeepaliveResult{} mi := &file_account_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SourceKeepaliveResult) String() string { return protoimpl.X.MessageStringOf(x) } func (*SourceKeepaliveResult) ProtoMessage() {} func (x *SourceKeepaliveResult) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SourceKeepaliveResult.ProtoReflect.Descriptor instead. func (*SourceKeepaliveResult) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{38} } func (x *SourceKeepaliveResult) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *SourceKeepaliveResult) GetStatus() SourceStatus { if x != nil { return x.Status } return SourceStatus_STATUS_UNSPECIFIED } func (x *SourceKeepaliveResult) GetError() string { if x != nil { return x.Error } return "" } type ListAllSourcesStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAllSourcesStatusRequest) Reset() { *x = ListAllSourcesStatusRequest{} mi := &file_account_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAllSourcesStatusRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAllSourcesStatusRequest) ProtoMessage() {} func (x *ListAllSourcesStatusRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAllSourcesStatusRequest.ProtoReflect.Descriptor instead. func (*ListAllSourcesStatusRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{39} } type SourceHealth struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the source UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // The version of the source Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` // The name of the source Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` // The error message if the source is unhealthy Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` // The status of the source, this is calculated based on the last heartbeat received and if there is an error Status SourceStatus `protobuf:"varint,5,opt,name=status,proto3,enum=account.SourceStatus" json:"status,omitempty"` // Created at time CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=createdAt,proto3" json:"createdAt,omitempty"` // The last time we received a heartbeat from the source LastHeartbeat *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=lastHeartbeat,proto3" json:"lastHeartbeat,omitempty"` // The next time we expect to receive a heartbeat from the source NextHeartbeat *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=nextHeartbeat,proto3" json:"nextHeartbeat,omitempty"` // The type of the source, AWS or Stdlib or Kubernetes Type string `protobuf:"bytes,9,opt,name=type,proto3" json:"type,omitempty"` // Whether the source is managed, or local Managed SourceManaged `protobuf:"varint,10,opt,name=managed,proto3,enum=account.SourceManaged" json:"managed,omitempty"` // The types of sources that this source can discover AvailableTypes []string `protobuf:"bytes,11,rep,name=availableTypes,proto3" json:"availableTypes,omitempty"` // The scopes that this source can discover AvailableScopes []string `protobuf:"bytes,12,rep,name=availableScopes,proto3" json:"availableScopes,omitempty"` // AdapterMetadata is a map of metadata that the source can send to the API AdapterMetadata []*AdapterMetadata `protobuf:"bytes,13,rep,name=adapterMetadata,proto3" json:"adapterMetadata,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SourceHealth) Reset() { *x = SourceHealth{} mi := &file_account_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SourceHealth) String() string { return protoimpl.X.MessageStringOf(x) } func (*SourceHealth) ProtoMessage() {} func (x *SourceHealth) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SourceHealth.ProtoReflect.Descriptor instead. func (*SourceHealth) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{40} } func (x *SourceHealth) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *SourceHealth) GetVersion() string { if x != nil { return x.Version } return "" } func (x *SourceHealth) GetName() string { if x != nil { return x.Name } return "" } func (x *SourceHealth) GetError() string { if x != nil && x.Error != nil { return *x.Error } return "" } func (x *SourceHealth) GetStatus() SourceStatus { if x != nil { return x.Status } return SourceStatus_STATUS_UNSPECIFIED } func (x *SourceHealth) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt } return nil } func (x *SourceHealth) GetLastHeartbeat() *timestamppb.Timestamp { if x != nil { return x.LastHeartbeat } return nil } func (x *SourceHealth) GetNextHeartbeat() *timestamppb.Timestamp { if x != nil { return x.NextHeartbeat } return nil } func (x *SourceHealth) GetType() string { if x != nil { return x.Type } return "" } func (x *SourceHealth) GetManaged() SourceManaged { if x != nil { return x.Managed } return SourceManaged_LOCAL } func (x *SourceHealth) GetAvailableTypes() []string { if x != nil { return x.AvailableTypes } return nil } func (x *SourceHealth) GetAvailableScopes() []string { if x != nil { return x.AvailableScopes } return nil } func (x *SourceHealth) GetAdapterMetadata() []*AdapterMetadata { if x != nil { return x.AdapterMetadata } return nil } type ListAllSourcesStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Sources []*SourceHealth `protobuf:"bytes,1,rep,name=sources,proto3" json:"sources,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAllSourcesStatusResponse) Reset() { *x = ListAllSourcesStatusResponse{} mi := &file_account_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAllSourcesStatusResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAllSourcesStatusResponse) ProtoMessage() {} func (x *ListAllSourcesStatusResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAllSourcesStatusResponse.ProtoReflect.Descriptor instead. func (*ListAllSourcesStatusResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{41} } func (x *ListAllSourcesStatusResponse) GetSources() []*SourceHealth { if x != nil { return x.Sources } return nil } // The source sends a heartbeat to the API to let it know that it is still alive, note it does not give a status. type SubmitSourceHeartbeatRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the source that is sending the heartbeat UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // The version of the source Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` // The name of the source Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` // The error message if the source is unhealthy Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` // The maximum time between heartbeats that the source can send to the api-server. Otherwise, the source will be marked as unhealthy. eg 30s NextHeartbeatMax *durationpb.Duration `protobuf:"bytes,5,opt,name=nextHeartbeatMax,proto3" json:"nextHeartbeatMax,omitempty"` // The type of the source, AWS or Stdlib or Kubernetes Type string `protobuf:"bytes,6,opt,name=type,proto3" json:"type,omitempty"` // Whether the source is managed, or local Managed SourceManaged `protobuf:"varint,7,opt,name=managed,proto3,enum=account.SourceManaged" json:"managed,omitempty"` // The scopes that this source can discover AvailableScopes []string `protobuf:"bytes,9,rep,name=availableScopes,proto3" json:"availableScopes,omitempty"` // AdapterMetadata is a map of metadata that the source can send to the API AdapterMetadata []*AdapterMetadata `protobuf:"bytes,10,rep,name=adapterMetadata,proto3" json:"adapterMetadata,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SubmitSourceHeartbeatRequest) Reset() { *x = SubmitSourceHeartbeatRequest{} mi := &file_account_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SubmitSourceHeartbeatRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubmitSourceHeartbeatRequest) ProtoMessage() {} func (x *SubmitSourceHeartbeatRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubmitSourceHeartbeatRequest.ProtoReflect.Descriptor instead. func (*SubmitSourceHeartbeatRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{42} } func (x *SubmitSourceHeartbeatRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *SubmitSourceHeartbeatRequest) GetVersion() string { if x != nil { return x.Version } return "" } func (x *SubmitSourceHeartbeatRequest) GetName() string { if x != nil { return x.Name } return "" } func (x *SubmitSourceHeartbeatRequest) GetError() string { if x != nil && x.Error != nil { return *x.Error } return "" } func (x *SubmitSourceHeartbeatRequest) GetNextHeartbeatMax() *durationpb.Duration { if x != nil { return x.NextHeartbeatMax } return nil } func (x *SubmitSourceHeartbeatRequest) GetType() string { if x != nil { return x.Type } return "" } func (x *SubmitSourceHeartbeatRequest) GetManaged() SourceManaged { if x != nil { return x.Managed } return SourceManaged_LOCAL } func (x *SubmitSourceHeartbeatRequest) GetAvailableScopes() []string { if x != nil { return x.AvailableScopes } return nil } func (x *SubmitSourceHeartbeatRequest) GetAdapterMetadata() []*AdapterMetadata { if x != nil { return x.AdapterMetadata } return nil } type AdapterMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // The type of item that this adapter returns e.g. eks-cluster Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` // The category that these items fall under Category AdapterCategory `protobuf:"varint,2,opt,name=category,proto3,enum=account.AdapterCategory" json:"category,omitempty"` // The list of other types that this can be linked to, eg eks-cluster -> // eks-node-group PotentialLinks []string `protobuf:"bytes,3,rep,name=potentialLinks,proto3" json:"potentialLinks,omitempty"` // A descriptive name of the types of items that are returned by this // adapter e.g. "EKS Cluster" DescriptiveName string `protobuf:"bytes,4,opt,name=descriptiveName,proto3" json:"descriptiveName,omitempty"` // The supported query methods for this adapter SupportedQueryMethods *AdapterSupportedQueryMethods `protobuf:"bytes,5,opt,name=supportedQueryMethods,proto3" json:"supportedQueryMethods,omitempty"` // The terraform mappings for this adapter, this is optional TerraformMappings []*TerraformMapping `protobuf:"bytes,6,rep,name=terraformMappings,proto3" json:"terraformMappings,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdapterMetadata) Reset() { *x = AdapterMetadata{} mi := &file_account_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdapterMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdapterMetadata) ProtoMessage() {} func (x *AdapterMetadata) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdapterMetadata.ProtoReflect.Descriptor instead. func (*AdapterMetadata) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{43} } func (x *AdapterMetadata) GetType() string { if x != nil { return x.Type } return "" } func (x *AdapterMetadata) GetCategory() AdapterCategory { if x != nil { return x.Category } return AdapterCategory_ADAPTER_CATEGORY_OTHER } func (x *AdapterMetadata) GetPotentialLinks() []string { if x != nil { return x.PotentialLinks } return nil } func (x *AdapterMetadata) GetDescriptiveName() string { if x != nil { return x.DescriptiveName } return "" } func (x *AdapterMetadata) GetSupportedQueryMethods() *AdapterSupportedQueryMethods { if x != nil { return x.SupportedQueryMethods } return nil } func (x *AdapterMetadata) GetTerraformMappings() []*TerraformMapping { if x != nil { return x.TerraformMappings } return nil } // The methods that this adapter supports, and the description of how to use // them type AdapterSupportedQueryMethods struct { state protoimpl.MessageState `protogen:"open.v1"` // Whether or not the GET method is supported Get bool `protobuf:"varint,1,opt,name=get,proto3" json:"get,omitempty"` // Description of what data the GET query expects. GetDescription string `protobuf:"bytes,2,opt,name=getDescription,proto3" json:"getDescription,omitempty"` // Whether or not the LIST method is supported List bool `protobuf:"varint,3,opt,name=list,proto3" json:"list,omitempty"` // Description of how the LIST method works ListDescription string `protobuf:"bytes,4,opt,name=listDescription,proto3" json:"listDescription,omitempty"` // Whether or not the SEARCH method is supported Search bool `protobuf:"varint,5,opt,name=search,proto3" json:"search,omitempty"` // Description of the query that should be passed to the SEARCH method SearchDescription string `protobuf:"bytes,6,opt,name=searchDescription,proto3" json:"searchDescription,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AdapterSupportedQueryMethods) Reset() { *x = AdapterSupportedQueryMethods{} mi := &file_account_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AdapterSupportedQueryMethods) String() string { return protoimpl.X.MessageStringOf(x) } func (*AdapterSupportedQueryMethods) ProtoMessage() {} func (x *AdapterSupportedQueryMethods) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AdapterSupportedQueryMethods.ProtoReflect.Descriptor instead. func (*AdapterSupportedQueryMethods) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{44} } func (x *AdapterSupportedQueryMethods) GetGet() bool { if x != nil { return x.Get } return false } func (x *AdapterSupportedQueryMethods) GetGetDescription() string { if x != nil { return x.GetDescription } return "" } func (x *AdapterSupportedQueryMethods) GetList() bool { if x != nil { return x.List } return false } func (x *AdapterSupportedQueryMethods) GetListDescription() string { if x != nil { return x.ListDescription } return "" } func (x *AdapterSupportedQueryMethods) GetSearch() bool { if x != nil { return x.Search } return false } func (x *AdapterSupportedQueryMethods) GetSearchDescription() string { if x != nil { return x.SearchDescription } return "" } // When Overmind ingests Terraform changes, it needs to be able to map from a // given Terraform resource, to that same resource in Overmind. This is achieved // by using the TerraformMapping object. It translates the details of a Terraform // resource into a query that Overmind can run. // // NOTE: The queries that are generated by this mapping use the wildcard scope // `*` and therefore could return multiple items. Overmind will compare the // attributes of these items to determine the most likely candidate for a mch // and select that. type TerraformMapping struct { state protoimpl.MessageState `protogen:"open.v1"` // The method that the query should use TerraformMethod QueryMethod `protobuf:"varint,1,opt,name=terraformMethod,proto3,enum=QueryMethod" json:"terraformMethod,omitempty"` // How to map data from the terraform resource to the "query" field in the // resulting mapping query. This uses HCL syntax e.g. // resource_type.attribute_name // // Usually this will be the attribute that uniquely identifies the resource // such as `aws_instance.id` or `aws_iam_role.arn`. You can also index into // arrays e.g. `kubernetes_replication_controller.metadata[0].name` TerraformQueryMap string `protobuf:"bytes,2,opt,name=terraformQueryMap,proto3" json:"terraformQueryMap,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TerraformMapping) Reset() { *x = TerraformMapping{} mi := &file_account_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *TerraformMapping) String() string { return protoimpl.X.MessageStringOf(x) } func (*TerraformMapping) ProtoMessage() {} func (x *TerraformMapping) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TerraformMapping.ProtoReflect.Descriptor instead. func (*TerraformMapping) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{45} } func (x *TerraformMapping) GetTerraformMethod() QueryMethod { if x != nil { return x.TerraformMethod } return QueryMethod_GET } func (x *TerraformMapping) GetTerraformQueryMap() string { if x != nil { return x.TerraformQueryMap } return "" } type SubmitSourceHeartbeatResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SubmitSourceHeartbeatResponse) Reset() { *x = SubmitSourceHeartbeatResponse{} mi := &file_account_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SubmitSourceHeartbeatResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubmitSourceHeartbeatResponse) ProtoMessage() {} func (x *SubmitSourceHeartbeatResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubmitSourceHeartbeatResponse.ProtoReflect.Descriptor instead. func (*SubmitSourceHeartbeatResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{46} } type KeepaliveSourcesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Set to true to have the API call wait until the source is up and healthy WaitForHealthy bool `protobuf:"varint,1,opt,name=waitForHealthy,proto3" json:"waitForHealthy,omitempty"` // Maximum time to wait for sources to reach a final state. Only used when // waitForHealthy is true. If not specified, defaults to 4 minutes. // After this timeout, the API will return the current state of all sources // regardless of whether they have reached a final state. Timeout *durationpb.Duration `protobuf:"bytes,2,opt,name=timeout,proto3" json:"timeout,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *KeepaliveSourcesRequest) Reset() { *x = KeepaliveSourcesRequest{} mi := &file_account_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *KeepaliveSourcesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*KeepaliveSourcesRequest) ProtoMessage() {} func (x *KeepaliveSourcesRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use KeepaliveSourcesRequest.ProtoReflect.Descriptor instead. func (*KeepaliveSourcesRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{47} } func (x *KeepaliveSourcesRequest) GetWaitForHealthy() bool { if x != nil { return x.WaitForHealthy } return false } func (x *KeepaliveSourcesRequest) GetTimeout() *durationpb.Duration { if x != nil { return x.Timeout } return nil } type KeepaliveSourcesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // If the user requested to wait for the sources to be healthy, this will // contain information about the sources that came up. If the user did not // request to wait, this will be empty Sources []*SourceKeepaliveResult `protobuf:"bytes,1,rep,name=sources,proto3" json:"sources,omitempty"` // If all sources are healthy, this will be true. If any source is unhealthy, // this will be false. If the user did not request to wait for sources to // become healthy, this will be false. AllSourcesHealthy bool `protobuf:"varint,2,opt,name=allSourcesHealthy,proto3" json:"allSourcesHealthy,omitempty"` // If any source is healthy, this will be true. If all sources are unhealthy, // this will be false. If the user did not request to wait for sources to // become healthy, this will be false. AnySourcesHealthy bool `protobuf:"varint,3,opt,name=anySourcesHealthy,proto3" json:"anySourcesHealthy,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *KeepaliveSourcesResponse) Reset() { *x = KeepaliveSourcesResponse{} mi := &file_account_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *KeepaliveSourcesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*KeepaliveSourcesResponse) ProtoMessage() {} func (x *KeepaliveSourcesResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use KeepaliveSourcesResponse.ProtoReflect.Descriptor instead. func (*KeepaliveSourcesResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{48} } func (x *KeepaliveSourcesResponse) GetSources() []*SourceKeepaliveResult { if x != nil { return x.Sources } return nil } func (x *KeepaliveSourcesResponse) GetAllSourcesHealthy() bool { if x != nil { return x.AllSourcesHealthy } return false } func (x *KeepaliveSourcesResponse) GetAnySourcesHealthy() bool { if x != nil { return x.AnySourcesHealthy } return false } type CreateTokenRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The Public NKey of the user that is requesting a token UserPublicNkey string `protobuf:"bytes,1,opt,name=userPublicNkey,proto3" json:"userPublicNkey,omitempty"` // Friendly user name UserName string `protobuf:"bytes,2,opt,name=userName,proto3" json:"userName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateTokenRequest) Reset() { *x = CreateTokenRequest{} mi := &file_account_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateTokenRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateTokenRequest) ProtoMessage() {} func (x *CreateTokenRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateTokenRequest.ProtoReflect.Descriptor instead. func (*CreateTokenRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{49} } func (x *CreateTokenRequest) GetUserPublicNkey() string { if x != nil { return x.UserPublicNkey } return "" } func (x *CreateTokenRequest) GetUserName() string { if x != nil { return x.UserName } return "" } type CreateTokenResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The JWT as a raw string Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateTokenResponse) Reset() { *x = CreateTokenResponse{} mi := &file_account_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateTokenResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateTokenResponse) ProtoMessage() {} func (x *CreateTokenResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateTokenResponse.ProtoReflect.Descriptor instead. func (*CreateTokenResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{50} } func (x *CreateTokenResponse) GetToken() string { if x != nil { return x.Token } return "" } type RevlinkWarmupRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RevlinkWarmupRequest) Reset() { *x = RevlinkWarmupRequest{} mi := &file_account_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RevlinkWarmupRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*RevlinkWarmupRequest) ProtoMessage() {} func (x *RevlinkWarmupRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RevlinkWarmupRequest.ProtoReflect.Descriptor instead. func (*RevlinkWarmupRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{51} } type RevlinkWarmupResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` Items int32 `protobuf:"varint,2,opt,name=items,proto3" json:"items,omitempty"` Edges int32 `protobuf:"varint,3,opt,name=edges,proto3" json:"edges,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RevlinkWarmupResponse) Reset() { *x = RevlinkWarmupResponse{} mi := &file_account_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RevlinkWarmupResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*RevlinkWarmupResponse) ProtoMessage() {} func (x *RevlinkWarmupResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RevlinkWarmupResponse.ProtoReflect.Descriptor instead. func (*RevlinkWarmupResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{52} } func (x *RevlinkWarmupResponse) GetStatus() string { if x != nil { return x.Status } return "" } func (x *RevlinkWarmupResponse) GetItems() int32 { if x != nil { return x.Items } return 0 } func (x *RevlinkWarmupResponse) GetEdges() int32 { if x != nil { return x.Edges } return 0 } type AvailableItemType struct { state protoimpl.MessageState `protogen:"open.v1"` // The type of item that this adapter returns e.g. eks-cluster Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` // The category that these items fall under Category AdapterCategory `protobuf:"varint,2,opt,name=category,proto3,enum=account.AdapterCategory" json:"category,omitempty"` // A descriptive name of the types of items that are returned by this // adapter e.g. "EKS Cluster" DescriptiveName string `protobuf:"bytes,3,opt,name=descriptiveName,proto3" json:"descriptiveName,omitempty"` // The supported query methods for this adapter SupportedQueryMethods *AdapterSupportedQueryMethods `protobuf:"bytes,4,opt,name=supportedQueryMethods,proto3" json:"supportedQueryMethods,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AvailableItemType) Reset() { *x = AvailableItemType{} mi := &file_account_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AvailableItemType) String() string { return protoimpl.X.MessageStringOf(x) } func (*AvailableItemType) ProtoMessage() {} func (x *AvailableItemType) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AvailableItemType.ProtoReflect.Descriptor instead. func (*AvailableItemType) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{53} } func (x *AvailableItemType) GetType() string { if x != nil { return x.Type } return "" } func (x *AvailableItemType) GetCategory() AdapterCategory { if x != nil { return x.Category } return AdapterCategory_ADAPTER_CATEGORY_OTHER } func (x *AvailableItemType) GetDescriptiveName() string { if x != nil { return x.DescriptiveName } return "" } func (x *AvailableItemType) GetSupportedQueryMethods() *AdapterSupportedQueryMethods { if x != nil { return x.SupportedQueryMethods } return nil } type ListAvailableItemTypesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAvailableItemTypesRequest) Reset() { *x = ListAvailableItemTypesRequest{} mi := &file_account_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAvailableItemTypesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAvailableItemTypesRequest) ProtoMessage() {} func (x *ListAvailableItemTypesRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAvailableItemTypesRequest.ProtoReflect.Descriptor instead. func (*ListAvailableItemTypesRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{54} } type ListAvailableItemTypesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Types []*AvailableItemType `protobuf:"bytes,1,rep,name=types,proto3" json:"types,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAvailableItemTypesResponse) Reset() { *x = ListAvailableItemTypesResponse{} mi := &file_account_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAvailableItemTypesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAvailableItemTypesResponse) ProtoMessage() {} func (x *ListAvailableItemTypesResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAvailableItemTypesResponse.ProtoReflect.Descriptor instead. func (*ListAvailableItemTypesResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{55} } func (x *ListAvailableItemTypesResponse) GetTypes() []*AvailableItemType { if x != nil { return x.Types } return nil } type GetSourceStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // UUID of the source to get status for SourceUuid []byte `protobuf:"bytes,1,opt,name=source_uuid,json=sourceUuid,proto3" json:"source_uuid,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSourceStatusRequest) Reset() { *x = GetSourceStatusRequest{} mi := &file_account_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSourceStatusRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSourceStatusRequest) ProtoMessage() {} func (x *GetSourceStatusRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSourceStatusRequest.ProtoReflect.Descriptor instead. func (*GetSourceStatusRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{56} } func (x *GetSourceStatusRequest) GetSourceUuid() []byte { if x != nil { return x.SourceUuid } return nil } type GetSourceStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Source *SourceHealth `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSourceStatusResponse) Reset() { *x = GetSourceStatusResponse{} mi := &file_account_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSourceStatusResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSourceStatusResponse) ProtoMessage() {} func (x *GetSourceStatusResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSourceStatusResponse.ProtoReflect.Descriptor instead. func (*GetSourceStatusResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{57} } func (x *GetSourceStatusResponse) GetSource() *SourceHealth { if x != nil { return x.Source } return nil } type GetUserOnboardingStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetUserOnboardingStatusRequest) Reset() { *x = GetUserOnboardingStatusRequest{} mi := &file_account_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetUserOnboardingStatusRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetUserOnboardingStatusRequest) ProtoMessage() {} func (x *GetUserOnboardingStatusRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetUserOnboardingStatusRequest.ProtoReflect.Descriptor instead. func (*GetUserOnboardingStatusRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{58} } type GetUserOnboardingStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` OnboardingComplete bool `protobuf:"varint,1,opt,name=onboarding_complete,json=onboardingComplete,proto3" json:"onboarding_complete,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetUserOnboardingStatusResponse) Reset() { *x = GetUserOnboardingStatusResponse{} mi := &file_account_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetUserOnboardingStatusResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetUserOnboardingStatusResponse) ProtoMessage() {} func (x *GetUserOnboardingStatusResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetUserOnboardingStatusResponse.ProtoReflect.Descriptor instead. func (*GetUserOnboardingStatusResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{59} } func (x *GetUserOnboardingStatusResponse) GetOnboardingComplete() bool { if x != nil { return x.OnboardingComplete } return false } type SetUserOnboardingStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OnboardingComplete bool `protobuf:"varint,1,opt,name=onboarding_complete,json=onboardingComplete,proto3" json:"onboarding_complete,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SetUserOnboardingStatusRequest) Reset() { *x = SetUserOnboardingStatusRequest{} mi := &file_account_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SetUserOnboardingStatusRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*SetUserOnboardingStatusRequest) ProtoMessage() {} func (x *SetUserOnboardingStatusRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SetUserOnboardingStatusRequest.ProtoReflect.Descriptor instead. func (*SetUserOnboardingStatusRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{60} } func (x *SetUserOnboardingStatusRequest) GetOnboardingComplete() bool { if x != nil { return x.OnboardingComplete } return false } type SetUserOnboardingStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SetUserOnboardingStatusResponse) Reset() { *x = SetUserOnboardingStatusResponse{} mi := &file_account_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SetUserOnboardingStatusResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*SetUserOnboardingStatusResponse) ProtoMessage() {} func (x *SetUserOnboardingStatusResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SetUserOnboardingStatusResponse.ProtoReflect.Descriptor instead. func (*SetUserOnboardingStatusResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{61} } type SetGithubInstallationIDRequest struct { state protoimpl.MessageState `protogen:"open.v1"` GithubInstallationId int64 `protobuf:"varint,1,opt,name=github_installation_id,json=githubInstallationId,proto3" json:"github_installation_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SetGithubInstallationIDRequest) Reset() { *x = SetGithubInstallationIDRequest{} mi := &file_account_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SetGithubInstallationIDRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*SetGithubInstallationIDRequest) ProtoMessage() {} func (x *SetGithubInstallationIDRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SetGithubInstallationIDRequest.ProtoReflect.Descriptor instead. func (*SetGithubInstallationIDRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{62} } func (x *SetGithubInstallationIDRequest) GetGithubInstallationId() int64 { if x != nil { return x.GithubInstallationId } return 0 } type SetGithubInstallationIDResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SetGithubInstallationIDResponse) Reset() { *x = SetGithubInstallationIDResponse{} mi := &file_account_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SetGithubInstallationIDResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*SetGithubInstallationIDResponse) ProtoMessage() {} func (x *SetGithubInstallationIDResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SetGithubInstallationIDResponse.ProtoReflect.Descriptor instead. func (*SetGithubInstallationIDResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{63} } type UnsetGithubInstallationIDRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UnsetGithubInstallationIDRequest) Reset() { *x = UnsetGithubInstallationIDRequest{} mi := &file_account_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UnsetGithubInstallationIDRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UnsetGithubInstallationIDRequest) ProtoMessage() {} func (x *UnsetGithubInstallationIDRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UnsetGithubInstallationIDRequest.ProtoReflect.Descriptor instead. func (*UnsetGithubInstallationIDRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{64} } type UnsetGithubInstallationIDResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UnsetGithubInstallationIDResponse) Reset() { *x = UnsetGithubInstallationIDResponse{} mi := &file_account_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UnsetGithubInstallationIDResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UnsetGithubInstallationIDResponse) ProtoMessage() {} func (x *UnsetGithubInstallationIDResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UnsetGithubInstallationIDResponse.ProtoReflect.Descriptor instead. func (*UnsetGithubInstallationIDResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{65} } type GetOrCreateAWSExternalIdRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetOrCreateAWSExternalIdRequest) Reset() { *x = GetOrCreateAWSExternalIdRequest{} mi := &file_account_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetOrCreateAWSExternalIdRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetOrCreateAWSExternalIdRequest) ProtoMessage() {} func (x *GetOrCreateAWSExternalIdRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetOrCreateAWSExternalIdRequest.ProtoReflect.Descriptor instead. func (*GetOrCreateAWSExternalIdRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{66} } type GetOrCreateAWSExternalIdResponse struct { state protoimpl.MessageState `protogen:"open.v1"` AwsExternalId string `protobuf:"bytes,1,opt,name=aws_external_id,json=awsExternalId,proto3" json:"aws_external_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetOrCreateAWSExternalIdResponse) Reset() { *x = GetOrCreateAWSExternalIdResponse{} mi := &file_account_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetOrCreateAWSExternalIdResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetOrCreateAWSExternalIdResponse) ProtoMessage() {} func (x *GetOrCreateAWSExternalIdResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetOrCreateAWSExternalIdResponse.ProtoReflect.Descriptor instead. func (*GetOrCreateAWSExternalIdResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{67} } func (x *GetOrCreateAWSExternalIdResponse) GetAwsExternalId() string { if x != nil { return x.AwsExternalId } return "" } // Team member related messages type ListTeamMembersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListTeamMembersRequest) Reset() { *x = ListTeamMembersRequest{} mi := &file_account_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListTeamMembersRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListTeamMembersRequest) ProtoMessage() {} func (x *ListTeamMembersRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListTeamMembersRequest.ProtoReflect.Descriptor instead. func (*ListTeamMembersRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{68} } type ListTeamMembersResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Members []*TeamMember `protobuf:"bytes,1,rep,name=members,proto3" json:"members,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListTeamMembersResponse) Reset() { *x = ListTeamMembersResponse{} mi := &file_account_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListTeamMembersResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListTeamMembersResponse) ProtoMessage() {} func (x *ListTeamMembersResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListTeamMembersResponse.ProtoReflect.Descriptor instead. func (*ListTeamMembersResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{69} } func (x *ListTeamMembersResponse) GetMembers() []*TeamMember { if x != nil { return x.Members } return nil } type TeamMember struct { state protoimpl.MessageState `protogen:"open.v1"` // Unique identifier for the team member UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // Team member's display name Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // URL to the team member's profile picture PictureUrl string `protobuf:"bytes,3,opt,name=picture_url,json=pictureUrl,proto3" json:"picture_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TeamMember) Reset() { *x = TeamMember{} mi := &file_account_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *TeamMember) String() string { return protoimpl.X.MessageStringOf(x) } func (*TeamMember) ProtoMessage() {} func (x *TeamMember) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TeamMember.ProtoReflect.Descriptor instead. func (*TeamMember) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{70} } func (x *TeamMember) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *TeamMember) GetName() string { if x != nil { return x.Name } return "" } func (x *TeamMember) GetPictureUrl() string { if x != nil { return x.PictureUrl } return "" } type GetWelcomeScreenInformationRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetWelcomeScreenInformationRequest) Reset() { *x = GetWelcomeScreenInformationRequest{} mi := &file_account_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetWelcomeScreenInformationRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetWelcomeScreenInformationRequest) ProtoMessage() {} func (x *GetWelcomeScreenInformationRequest) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetWelcomeScreenInformationRequest.ProtoReflect.Descriptor instead. func (*GetWelcomeScreenInformationRequest) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{71} } type InviterInformation struct { state protoimpl.MessageState `protogen:"open.v1"` Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` PictureUrl string `protobuf:"bytes,3,opt,name=picture_url,json=pictureUrl,proto3" json:"picture_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *InviterInformation) Reset() { *x = InviterInformation{} mi := &file_account_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *InviterInformation) String() string { return protoimpl.X.MessageStringOf(x) } func (*InviterInformation) ProtoMessage() {} func (x *InviterInformation) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use InviterInformation.ProtoReflect.Descriptor instead. func (*InviterInformation) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{72} } func (x *InviterInformation) GetEmail() string { if x != nil { return x.Email } return "" } func (x *InviterInformation) GetName() string { if x != nil { return x.Name } return "" } func (x *InviterInformation) GetPictureUrl() string { if x != nil { return x.PictureUrl } return "" } type GetWelcomeScreenInformationResponse struct { state protoimpl.MessageState `protogen:"open.v1"` InviterInformation *InviterInformation `protobuf:"bytes,1,opt,name=inviter_information,json=inviterInformation,proto3" json:"inviter_information,omitempty"` // potentially we can return account / organisation information here unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetWelcomeScreenInformationResponse) Reset() { *x = GetWelcomeScreenInformationResponse{} mi := &file_account_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetWelcomeScreenInformationResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetWelcomeScreenInformationResponse) ProtoMessage() {} func (x *GetWelcomeScreenInformationResponse) ProtoReflect() protoreflect.Message { mi := &file_account_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetWelcomeScreenInformationResponse.ProtoReflect.Descriptor instead. func (*GetWelcomeScreenInformationResponse) Descriptor() ([]byte, []int) { return file_account_proto_rawDescGZIP(), []int{73} } func (x *GetWelcomeScreenInformationResponse) GetInviterInformation() *InviterInformation { if x != nil { return x.InviterInformation } return nil } var File_account_proto protoreflect.FileDescriptor const file_account_proto_rawDesc = "" + "\n" + "\raccount.proto\x12\aaccount\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\vitems.proto\x1a\x1bbuf/validate/validate.proto\"\x15\n" + "\x13ListAccountsRequest\"D\n" + "\x14ListAccountsResponse\x12,\n" + "\baccounts\x18\x01 \x03(\v2\x10.account.AccountR\baccounts\"R\n" + "\x14CreateAccountRequest\x12:\n" + "\n" + "properties\x18\x01 \x01(\v2\x1a.account.AccountPropertiesR\n" + "properties\"C\n" + "\x15CreateAccountResponse\x12*\n" + "\aaccount\x18\x01 \x01(\v2\x10.account.AccountR\aaccount\"R\n" + "\x14UpdateAccountRequest\x12:\n" + "\n" + "properties\x18\x01 \x01(\v2\x1a.account.AccountPropertiesR\n" + "properties\"C\n" + "\x15UpdateAccountResponse\x12*\n" + "\aaccount\x18\x01 \x01(\v2\x10.account.AccountR\aaccount\"h\n" + "\x19AdminUpdateAccountRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x127\n" + "\arequest\x18\x02 \x01(\v2\x1d.account.UpdateAccountRequestR\arequest\",\n" + "\x16AdminGetAccountRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"/\n" + "\x19AdminDeleteAccountRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"\x1c\n" + "\x1aAdminDeleteAccountResponse\"j\n" + "\x17AdminListSourcesRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x125\n" + "\arequest\x18\x02 \x01(\v2\x1b.account.ListSourcesRequestR\arequest\"l\n" + "\x18AdminCreateSourceRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x126\n" + "\arequest\x18\x02 \x01(\v2\x1c.account.CreateSourceRequestR\arequest\"f\n" + "\x15AdminGetSourceRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x123\n" + "\arequest\x18\x02 \x01(\v2\x19.account.GetSourceRequestR\arequest\"l\n" + "\x18AdminUpdateSourceRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x126\n" + "\arequest\x18\x02 \x01(\v2\x1c.account.UpdateSourceRequestR\arequest\"l\n" + "\x18AdminDeleteSourceRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x126\n" + "\arequest\x18\x02 \x01(\v2\x1c.account.DeleteSourceRequestR\arequest\"t\n" + "\x1cAdminKeepaliveSourcesRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x12:\n" + "\arequest\x18\x02 \x01(\v2 .account.KeepaliveSourcesRequestR\arequest\"j\n" + "\x17AdminCreateTokenRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x125\n" + "\arequest\x18\x02 \x01(\v2\x1b.account.CreateTokenRequestR\arequest\"x\n" + "\x06Source\x123\n" + "\bmetadata\x18\x01 \x01(\v2\x17.account.SourceMetadataR\bmetadata\x129\n" + "\n" + "properties\x18\x02 \x01(\v2\x19.account.SourcePropertiesR\n" + "properties\"\xe5\x01\n" + "\x0eSourceMetadata\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x1c\n" + "\tTokenName\x18\x02 \x01(\tR\tTokenName\x12<\n" + "\vTokenExpiry\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vTokenExpiry\x12\x1e\n" + "\n" + "PublicNkey\x18\x05 \x01(\tR\n" + "PublicNkey\x12-\n" + "\x06Status\x18\t \x01(\x0e2\x15.account.SourceStatusR\x06Status\x12\x14\n" + "\x05Error\x18\n" + " \x01(\tR\x05Error\"\xc6\x01\n" + "\x10SourceProperties\x12(\n" + "\x0fDescriptiveName\x18\x01 \x01(\tR\x0fDescriptiveName\x12\x12\n" + "\x04Type\x18\x02 \x01(\tR\x04Type\x12/\n" + "\x06Config\x18\x03 \x01(\v2\x17.google.protobuf.StructR\x06Config\x12C\n" + "\x10AdditionalConfig\x18\x04 \x01(\v2\x17.google.protobuf.StructR\x10AdditionalConfig\"{\n" + "\aAccount\x124\n" + "\bmetadata\x18\x01 \x01(\v2\x18.account.AccountMetadataR\bmetadata\x12:\n" + "\n" + "properties\x18\x02 \x01(\v2\x1a.account.AccountPropertiesR\n" + "properties\"\xfc\x01\n" + "\x0fAccountMetadata\x12\x1e\n" + "\n" + "PublicNkey\x18\x02 \x01(\tR\n" + "PublicNkey\x127\n" + "\frepositories\x18\x03 \x03(\v2\x13.account.RepositoryR\frepositories\x12,\n" + "\x11totalRepositories\x18\x04 \x01(\rR\x11totalRepositories\x12.\n" + "\x12activeRepositories\x18\x05 \x01(\rR\x12activeRepositories\x122\n" + "\x04Plan\x18\x06 \x01(\x0e2\x14.account.AccountPlanB\b\xbaH\x05\x82\x01\x02\x10\x01R\x04Plan\"\xbd\x01\n" + "\n" + "Repository\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1e\n" + "\n" + "numChanges\x18\x02 \x01(\x03R\n" + "numChanges\x12>\n" + "\flastChangeAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\flastChangeAt\x12;\n" + "\x06status\x18\x04 \x01(\x0e2\x19.account.RepositoryStatusB\b\xbaH\x05\x82\x01\x02\x10\x01R\x06status\"S\n" + "\x11AccountProperties\x12\x12\n" + "\x04Name\x18\x01 \x01(\tR\x04Name\x12*\n" + "\x10StripeCustomerID\x18\x02 \x01(\tR\x10StripeCustomerID\"\x13\n" + "\x11GetAccountRequest\"@\n" + "\x12GetAccountResponse\x12*\n" + "\aaccount\x18\x01 \x01(\v2\x10.account.AccountR\aaccount\"0\n" + "\x14DeleteAccountRequest\x12\x18\n" + "\aiAmSure\x18\x01 \x01(\bR\aiAmSure\"\x17\n" + "\x15DeleteAccountResponse\"\x14\n" + "\x12ListSourcesRequest\"@\n" + "\x13ListSourcesResponse\x12)\n" + "\aSources\x18\x01 \x03(\v2\x0f.account.SourceR\aSources\"P\n" + "\x13CreateSourceRequest\x129\n" + "\n" + "properties\x18\x01 \x01(\v2\x19.account.SourcePropertiesR\n" + "properties\"?\n" + "\x14CreateSourceResponse\x12'\n" + "\x06source\x18\x01 \x01(\v2\x0f.account.SourceR\x06source\"&\n" + "\x10GetSourceRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"<\n" + "\x11GetSourceResponse\x12'\n" + "\x06source\x18\x01 \x01(\v2\x0f.account.SourceR\x06source\"d\n" + "\x13UpdateSourceRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x129\n" + "\n" + "properties\x18\x02 \x01(\v2\x19.account.SourcePropertiesR\n" + "properties\"?\n" + "\x14UpdateSourceResponse\x12'\n" + "\x06source\x18\x01 \x01(\v2\x0f.account.SourceR\x06source\")\n" + "\x13DeleteSourceRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"\x16\n" + "\x14DeleteSourceResponse\"p\n" + "\x15SourceKeepaliveResult\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12-\n" + "\x06Status\x18\x02 \x01(\x0e2\x15.account.SourceStatusR\x06Status\x12\x14\n" + "\x05Error\x18\x03 \x01(\tR\x05Error\"\x1d\n" + "\x1bListAllSourcesStatusRequest\"\xbe\x04\n" + "\fSourceHealth\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion\x12\x12\n" + "\x04name\x18\x03 \x01(\tR\x04name\x12\x19\n" + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01\x12-\n" + "\x06status\x18\x05 \x01(\x0e2\x15.account.SourceStatusR\x06status\x128\n" + "\tcreatedAt\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12@\n" + "\rlastHeartbeat\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\rlastHeartbeat\x12@\n" + "\rnextHeartbeat\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\rnextHeartbeat\x12\x12\n" + "\x04type\x18\t \x01(\tR\x04type\x120\n" + "\amanaged\x18\n" + " \x01(\x0e2\x16.account.SourceManagedR\amanaged\x12&\n" + "\x0eavailableTypes\x18\v \x03(\tR\x0eavailableTypes\x12(\n" + "\x0favailableScopes\x18\f \x03(\tR\x0favailableScopes\x12B\n" + "\x0fadapterMetadata\x18\r \x03(\v2\x18.account.AdapterMetadataR\x0fadapterMetadataB\b\n" + "\x06_error\"O\n" + "\x1cListAllSourcesStatusResponse\x12/\n" + "\asources\x18\x01 \x03(\v2\x15.account.SourceHealthR\asources\"\x86\x03\n" + "\x1cSubmitSourceHeartbeatRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion\x12\x12\n" + "\x04name\x18\x03 \x01(\tR\x04name\x12\x19\n" + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01\x12E\n" + "\x10nextHeartbeatMax\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\x10nextHeartbeatMax\x12\x12\n" + "\x04type\x18\x06 \x01(\tR\x04type\x120\n" + "\amanaged\x18\a \x01(\x0e2\x16.account.SourceManagedR\amanaged\x12(\n" + "\x0favailableScopes\x18\t \x03(\tR\x0favailableScopes\x12B\n" + "\x0fadapterMetadata\x18\n" + " \x03(\v2\x18.account.AdapterMetadataR\x0fadapterMetadataB\b\n" + "\x06_errorJ\x04\b\b\x10\t\"\x9d\x05\n" + "\x0fAdapterMetadata\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12>\n" + "\bcategory\x18\x02 \x01(\x0e2\x18.account.AdapterCategoryB\b\xbaH\x05\x82\x01\x02\x10\x01R\bcategory\x12\xc9\x01\n" + "\x0epotentialLinks\x18\x03 \x03(\tB\xa0\x01\xbaH\x9c\x01\xba\x01\x98\x01\n" + "\x18potentialLinksValidation\x12MIf 'potentialLinks' is not empty, none of its members should be empty strings\x1a-this.size() == 0 || this.all(x, x.size() > 0)R\x0epotentialLinks\x124\n" + "\x0fdescriptiveName\x18\x04 \x01(\tB\n" + "\xbaH\a\xc8\x01\x01r\x02\x10\x01R\x0fdescriptiveName\x12c\n" + "\x15supportedQueryMethods\x18\x05 \x01(\v2%.account.AdapterSupportedQueryMethodsB\x06\xbaH\x03\xc8\x01\x01R\x15supportedQueryMethods\x12\xce\x01\n" + "\x11terraformMappings\x18\x06 \x03(\v2\x19.account.TerraformMappingB\x84\x01\xbaH\x80\x01\xba\x01}\n" + "\x1bterraformMappingsValidation\x12FIf 'terraformMappings' is not empty, none of its members should be nil\x1a\x16this.all(x, x != null)R\x11terraformMappings\"\xd7\x05\n" + "\x1cAdapterSupportedQueryMethods\x12\x10\n" + "\x03get\x18\x01 \x01(\bR\x03get\x12&\n" + "\x0egetDescription\x18\x02 \x01(\tR\x0egetDescription\x12\x12\n" + "\x04list\x18\x03 \x01(\bR\x04list\x12(\n" + "\x0flistDescription\x18\x04 \x01(\tR\x0flistDescription\x12\x16\n" + "\x06search\x18\x05 \x01(\bR\x06search\x12,\n" + "\x11searchDescription\x18\x06 \x01(\tR\x11searchDescription:\xf8\x03\xbaH\xf4\x03\x1a\x9d\x01\n" + "*AdapterSupportedQueryMethods.getValidation\x12BIf 'get' is true, 'getDescription' must have more than 1 character\x1a+!this.get || this.getDescription.size() > 1\x1a\xa2\x01\n" + "+AdapterSupportedQueryMethods.listValidation\x12DIf 'list' is true, 'listDescription' must have more than 1 character\x1a-!this.list || this.listDescription.size() > 1\x1a\xac\x01\n" + "-AdapterSupportedQueryMethods.searchValidation\x12HIf 'search' is true, 'searchDescription' must have more than 1 character\x1a1!this.search || this.searchDescription.size() > 1\"\xad\x02\n" + "\x10TerraformMapping\x12@\n" + "\x0fterraformMethod\x18\x01 \x01(\x0e2\f.QueryMethodB\b\xbaH\x05\x82\x01\x02\x10\x01R\x0fterraformMethod\x12\xd0\x01\n" + "\x11terraformQueryMap\x18\x02 \x01(\tB\xa1\x01\xbaH\x9d\x01\xba\x01\x92\x01\n" + "\x17terraformQueryMapFormat\x12ZThe value must be in the format '.' (dot notation with exactly two items)\x1a\x1bthis.split('.').size() == 2\xc8\x01\x01r\x02\x10\x03R\x11terraformQueryMapJ\x04\b\x03\x10\x04\"\x1f\n" + "\x1dSubmitSourceHeartbeatResponse\"v\n" + "\x17KeepaliveSourcesRequest\x12&\n" + "\x0ewaitForHealthy\x18\x01 \x01(\bR\x0ewaitForHealthy\x123\n" + "\atimeout\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\xb0\x01\n" + "\x18KeepaliveSourcesResponse\x128\n" + "\asources\x18\x01 \x03(\v2\x1e.account.SourceKeepaliveResultR\asources\x12,\n" + "\x11allSourcesHealthy\x18\x02 \x01(\bR\x11allSourcesHealthy\x12,\n" + "\x11anySourcesHealthy\x18\x03 \x01(\bR\x11anySourcesHealthy\"X\n" + "\x12CreateTokenRequest\x12&\n" + "\x0euserPublicNkey\x18\x01 \x01(\tR\x0euserPublicNkey\x12\x1a\n" + "\buserName\x18\x02 \x01(\tR\buserName\"+\n" + "\x13CreateTokenResponse\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\"\x16\n" + "\x14RevlinkWarmupRequest\"[\n" + "\x15RevlinkWarmupResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\x12\x14\n" + "\x05items\x18\x02 \x01(\x05R\x05items\x12\x14\n" + "\x05edges\x18\x03 \x01(\x05R\x05edges\"\xe4\x01\n" + "\x11AvailableItemType\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x124\n" + "\bcategory\x18\x02 \x01(\x0e2\x18.account.AdapterCategoryR\bcategory\x12(\n" + "\x0fdescriptiveName\x18\x03 \x01(\tR\x0fdescriptiveName\x12[\n" + "\x15supportedQueryMethods\x18\x04 \x01(\v2%.account.AdapterSupportedQueryMethodsR\x15supportedQueryMethods\"\x1f\n" + "\x1dListAvailableItemTypesRequest\"R\n" + "\x1eListAvailableItemTypesResponse\x120\n" + "\x05types\x18\x01 \x03(\v2\x1a.account.AvailableItemTypeR\x05types\"9\n" + "\x16GetSourceStatusRequest\x12\x1f\n" + "\vsource_uuid\x18\x01 \x01(\fR\n" + "sourceUuid\"H\n" + "\x17GetSourceStatusResponse\x12-\n" + "\x06source\x18\x01 \x01(\v2\x15.account.SourceHealthR\x06source\" \n" + "\x1eGetUserOnboardingStatusRequest\"R\n" + "\x1fGetUserOnboardingStatusResponse\x12/\n" + "\x13onboarding_complete\x18\x01 \x01(\bR\x12onboardingComplete\"Q\n" + "\x1eSetUserOnboardingStatusRequest\x12/\n" + "\x13onboarding_complete\x18\x01 \x01(\bR\x12onboardingComplete\"!\n" + "\x1fSetUserOnboardingStatusResponse\"V\n" + "\x1eSetGithubInstallationIDRequest\x124\n" + "\x16github_installation_id\x18\x01 \x01(\x03R\x14githubInstallationId\"!\n" + "\x1fSetGithubInstallationIDResponse\"\"\n" + " UnsetGithubInstallationIDRequest\"#\n" + "!UnsetGithubInstallationIDResponse\"!\n" + "\x1fGetOrCreateAWSExternalIdRequest\"J\n" + " GetOrCreateAWSExternalIdResponse\x12&\n" + "\x0faws_external_id\x18\x01 \x01(\tR\rawsExternalId\"\x18\n" + "\x16ListTeamMembersRequest\"H\n" + "\x17ListTeamMembersResponse\x12-\n" + "\amembers\x18\x01 \x03(\v2\x13.account.TeamMemberR\amembers\"U\n" + "\n" + "TeamMember\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + "\vpicture_url\x18\x03 \x01(\tR\n" + "pictureUrl\"$\n" + "\"GetWelcomeScreenInformationRequest\"_\n" + "\x12InviterInformation\x12\x14\n" + "\x05email\x18\x01 \x01(\tR\x05email\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + "\vpicture_url\x18\x03 \x01(\tR\n" + "pictureUrl\"s\n" + "#GetWelcomeScreenInformationResponse\x12L\n" + "\x13inviter_information\x18\x01 \x01(\v2\x1b.account.InviterInformationR\x12inviterInformation*\x96\x01\n" + "\fSourceStatus\x12\x16\n" + "\x12STATUS_UNSPECIFIED\x10\x00\x12\x16\n" + "\x12STATUS_PROGRESSING\x10\x01\x12\x12\n" + "\x0eSTATUS_HEALTHY\x10\x02\x12\x14\n" + "\x10STATUS_UNHEALTHY\x10\x03\x12\x13\n" + "\x0fSTATUS_SLEEPING\x10\x04\x12\x17\n" + "\x13STATUS_DISCONNECTED\x10\x05*s\n" + "\x10RepositoryStatus\x12!\n" + "\x1dREPOSITORY_STATUS_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18REPOSITORY_STATUS_ACTIVE\x10\x01\x12\x1e\n" + "\x1aREPOSITORY_STATUS_INACTIVE\x10\x02*_\n" + "\vAccountPlan\x12\x1c\n" + "\x18ACCOUNT_PLAN_UNSPECIFIED\x10\x00\x12\x15\n" + "\x11ACCOUNT_PLAN_FREE\x10\x01\x12\x1b\n" + "\x17ACCOUNT_PLAN_ENTERPRISE\x10\x02*'\n" + "\rSourceManaged\x12\t\n" + "\x05LOCAL\x10\x00\x12\v\n" + "\aMANAGED\x10\x01*\xb2\x02\n" + "\x0fAdapterCategory\x12\x1a\n" + "\x16ADAPTER_CATEGORY_OTHER\x10\x00\x12(\n" + "$ADAPTER_CATEGORY_COMPUTE_APPLICATION\x10\x01\x12\x1c\n" + "\x18ADAPTER_CATEGORY_STORAGE\x10\x02\x12\x1c\n" + "\x18ADAPTER_CATEGORY_NETWORK\x10\x03\x12\x1d\n" + "\x19ADAPTER_CATEGORY_SECURITY\x10\x04\x12\"\n" + "\x1eADAPTER_CATEGORY_OBSERVABILITY\x10\x05\x12\x1d\n" + "\x19ADAPTER_CATEGORY_DATABASE\x10\x06\x12\"\n" + "\x1eADAPTER_CATEGORY_CONFIGURATION\x10\a\x12\x17\n" + "\x13ADAPTER_CATEGORY_AI\x10\b2\xe1\a\n" + "\fAdminService\x12K\n" + "\fListAccounts\x12\x1c.account.ListAccountsRequest\x1a\x1d.account.ListAccountsResponse\x12N\n" + "\rCreateAccount\x12\x1d.account.CreateAccountRequest\x1a\x1e.account.CreateAccountResponse\x12S\n" + "\rUpdateAccount\x12\".account.AdminUpdateAccountRequest\x1a\x1e.account.UpdateAccountResponse\x12J\n" + "\n" + "GetAccount\x12\x1f.account.AdminGetAccountRequest\x1a\x1b.account.GetAccountResponse\x12X\n" + "\rDeleteAccount\x12\".account.AdminDeleteAccountRequest\x1a#.account.AdminDeleteAccountResponse\x12M\n" + "\vListSources\x12 .account.AdminListSourcesRequest\x1a\x1c.account.ListSourcesResponse\x12P\n" + "\fCreateSource\x12!.account.AdminCreateSourceRequest\x1a\x1d.account.CreateSourceResponse\x12G\n" + "\tGetSource\x12\x1e.account.AdminGetSourceRequest\x1a\x1a.account.GetSourceResponse\x12P\n" + "\fUpdateSource\x12!.account.AdminUpdateSourceRequest\x1a\x1d.account.UpdateSourceResponse\x12P\n" + "\fDeleteSource\x12!.account.AdminDeleteSourceRequest\x1a\x1d.account.DeleteSourceResponse\x12\\\n" + "\x10KeepaliveSources\x12%.account.AdminKeepaliveSourcesRequest\x1a!.account.KeepaliveSourcesResponse\x12M\n" + "\vCreateToken\x12 .account.AdminCreateTokenRequest\x1a\x1c.account.CreateTokenResponse2\x89\x10\n" + "\x11ManagementService\x12E\n" + "\n" + "GetAccount\x12\x1a.account.GetAccountRequest\x1a\x1b.account.GetAccountResponse\x12N\n" + "\rDeleteAccount\x12\x1d.account.DeleteAccountRequest\x1a\x1e.account.DeleteAccountResponse\x12H\n" + "\vListSources\x12\x1b.account.ListSourcesRequest\x1a\x1c.account.ListSourcesResponse\x12K\n" + "\fCreateSource\x12\x1c.account.CreateSourceRequest\x1a\x1d.account.CreateSourceResponse\x12B\n" + "\tGetSource\x12\x19.account.GetSourceRequest\x1a\x1a.account.GetSourceResponse\x12K\n" + "\fUpdateSource\x12\x1c.account.UpdateSourceRequest\x1a\x1d.account.UpdateSourceResponse\x12K\n" + "\fDeleteSource\x12\x1c.account.DeleteSourceRequest\x1a\x1d.account.DeleteSourceResponse\x12c\n" + "\x14ListAllSourcesStatus\x12$.account.ListAllSourcesStatusRequest\x1a%.account.ListAllSourcesStatusResponse\x12f\n" + "\x17ListActiveSourcesStatus\x12$.account.ListAllSourcesStatusRequest\x1a%.account.ListAllSourcesStatusResponse\x12f\n" + "\x15SubmitSourceHeartbeat\x12%.account.SubmitSourceHeartbeatRequest\x1a&.account.SubmitSourceHeartbeatResponse\x12W\n" + "\x10KeepaliveSources\x12 .account.KeepaliveSourcesRequest\x1a!.account.KeepaliveSourcesResponse\x12H\n" + "\vCreateToken\x12\x1b.account.CreateTokenRequest\x1a\x1c.account.CreateTokenResponse\x12P\n" + "\rRevlinkWarmup\x12\x1d.account.RevlinkWarmupRequest\x1a\x1e.account.RevlinkWarmupResponse0\x01\x12i\n" + "\x16ListAvailableItemTypes\x12&.account.ListAvailableItemTypesRequest\x1a'.account.ListAvailableItemTypesResponse\x12T\n" + "\x0fGetSourceStatus\x12\x1f.account.GetSourceStatusRequest\x1a .account.GetSourceStatusResponse\x12l\n" + "\x17GetUserOnboardingStatus\x12'.account.GetUserOnboardingStatusRequest\x1a(.account.GetUserOnboardingStatusResponse\x12l\n" + "\x17SetUserOnboardingStatus\x12'.account.SetUserOnboardingStatusRequest\x1a(.account.SetUserOnboardingStatusResponse\x12T\n" + "\x0fListTeamMembers\x12\x1f.account.ListTeamMembersRequest\x1a .account.ListTeamMembersResponse\x12x\n" + "\x1bGetWelcomeScreenInformation\x12+.account.GetWelcomeScreenInformationRequest\x1a,.account.GetWelcomeScreenInformationResponse\x12l\n" + "\x17SetGithubInstallationID\x12'.account.SetGithubInstallationIDRequest\x1a(.account.SetGithubInstallationIDResponse\x12r\n" + "\x19UnsetGithubInstallationID\x12).account.UnsetGithubInstallationIDRequest\x1a*.account.UnsetGithubInstallationIDResponse\x12o\n" + "\x18GetOrCreateAWSExternalId\x12(.account.GetOrCreateAWSExternalIdRequest\x1a).account.GetOrCreateAWSExternalIdResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_account_proto_rawDescOnce sync.Once file_account_proto_rawDescData []byte ) func file_account_proto_rawDescGZIP() []byte { file_account_proto_rawDescOnce.Do(func() { file_account_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_account_proto_rawDesc), len(file_account_proto_rawDesc))) }) return file_account_proto_rawDescData } var file_account_proto_enumTypes = make([]protoimpl.EnumInfo, 5) var file_account_proto_msgTypes = make([]protoimpl.MessageInfo, 74) var file_account_proto_goTypes = []any{ (SourceStatus)(0), // 0: account.SourceStatus (RepositoryStatus)(0), // 1: account.RepositoryStatus (AccountPlan)(0), // 2: account.AccountPlan (SourceManaged)(0), // 3: account.SourceManaged (AdapterCategory)(0), // 4: account.AdapterCategory (*ListAccountsRequest)(nil), // 5: account.ListAccountsRequest (*ListAccountsResponse)(nil), // 6: account.ListAccountsResponse (*CreateAccountRequest)(nil), // 7: account.CreateAccountRequest (*CreateAccountResponse)(nil), // 8: account.CreateAccountResponse (*UpdateAccountRequest)(nil), // 9: account.UpdateAccountRequest (*UpdateAccountResponse)(nil), // 10: account.UpdateAccountResponse (*AdminUpdateAccountRequest)(nil), // 11: account.AdminUpdateAccountRequest (*AdminGetAccountRequest)(nil), // 12: account.AdminGetAccountRequest (*AdminDeleteAccountRequest)(nil), // 13: account.AdminDeleteAccountRequest (*AdminDeleteAccountResponse)(nil), // 14: account.AdminDeleteAccountResponse (*AdminListSourcesRequest)(nil), // 15: account.AdminListSourcesRequest (*AdminCreateSourceRequest)(nil), // 16: account.AdminCreateSourceRequest (*AdminGetSourceRequest)(nil), // 17: account.AdminGetSourceRequest (*AdminUpdateSourceRequest)(nil), // 18: account.AdminUpdateSourceRequest (*AdminDeleteSourceRequest)(nil), // 19: account.AdminDeleteSourceRequest (*AdminKeepaliveSourcesRequest)(nil), // 20: account.AdminKeepaliveSourcesRequest (*AdminCreateTokenRequest)(nil), // 21: account.AdminCreateTokenRequest (*Source)(nil), // 22: account.Source (*SourceMetadata)(nil), // 23: account.SourceMetadata (*SourceProperties)(nil), // 24: account.SourceProperties (*Account)(nil), // 25: account.Account (*AccountMetadata)(nil), // 26: account.AccountMetadata (*Repository)(nil), // 27: account.Repository (*AccountProperties)(nil), // 28: account.AccountProperties (*GetAccountRequest)(nil), // 29: account.GetAccountRequest (*GetAccountResponse)(nil), // 30: account.GetAccountResponse (*DeleteAccountRequest)(nil), // 31: account.DeleteAccountRequest (*DeleteAccountResponse)(nil), // 32: account.DeleteAccountResponse (*ListSourcesRequest)(nil), // 33: account.ListSourcesRequest (*ListSourcesResponse)(nil), // 34: account.ListSourcesResponse (*CreateSourceRequest)(nil), // 35: account.CreateSourceRequest (*CreateSourceResponse)(nil), // 36: account.CreateSourceResponse (*GetSourceRequest)(nil), // 37: account.GetSourceRequest (*GetSourceResponse)(nil), // 38: account.GetSourceResponse (*UpdateSourceRequest)(nil), // 39: account.UpdateSourceRequest (*UpdateSourceResponse)(nil), // 40: account.UpdateSourceResponse (*DeleteSourceRequest)(nil), // 41: account.DeleteSourceRequest (*DeleteSourceResponse)(nil), // 42: account.DeleteSourceResponse (*SourceKeepaliveResult)(nil), // 43: account.SourceKeepaliveResult (*ListAllSourcesStatusRequest)(nil), // 44: account.ListAllSourcesStatusRequest (*SourceHealth)(nil), // 45: account.SourceHealth (*ListAllSourcesStatusResponse)(nil), // 46: account.ListAllSourcesStatusResponse (*SubmitSourceHeartbeatRequest)(nil), // 47: account.SubmitSourceHeartbeatRequest (*AdapterMetadata)(nil), // 48: account.AdapterMetadata (*AdapterSupportedQueryMethods)(nil), // 49: account.AdapterSupportedQueryMethods (*TerraformMapping)(nil), // 50: account.TerraformMapping (*SubmitSourceHeartbeatResponse)(nil), // 51: account.SubmitSourceHeartbeatResponse (*KeepaliveSourcesRequest)(nil), // 52: account.KeepaliveSourcesRequest (*KeepaliveSourcesResponse)(nil), // 53: account.KeepaliveSourcesResponse (*CreateTokenRequest)(nil), // 54: account.CreateTokenRequest (*CreateTokenResponse)(nil), // 55: account.CreateTokenResponse (*RevlinkWarmupRequest)(nil), // 56: account.RevlinkWarmupRequest (*RevlinkWarmupResponse)(nil), // 57: account.RevlinkWarmupResponse (*AvailableItemType)(nil), // 58: account.AvailableItemType (*ListAvailableItemTypesRequest)(nil), // 59: account.ListAvailableItemTypesRequest (*ListAvailableItemTypesResponse)(nil), // 60: account.ListAvailableItemTypesResponse (*GetSourceStatusRequest)(nil), // 61: account.GetSourceStatusRequest (*GetSourceStatusResponse)(nil), // 62: account.GetSourceStatusResponse (*GetUserOnboardingStatusRequest)(nil), // 63: account.GetUserOnboardingStatusRequest (*GetUserOnboardingStatusResponse)(nil), // 64: account.GetUserOnboardingStatusResponse (*SetUserOnboardingStatusRequest)(nil), // 65: account.SetUserOnboardingStatusRequest (*SetUserOnboardingStatusResponse)(nil), // 66: account.SetUserOnboardingStatusResponse (*SetGithubInstallationIDRequest)(nil), // 67: account.SetGithubInstallationIDRequest (*SetGithubInstallationIDResponse)(nil), // 68: account.SetGithubInstallationIDResponse (*UnsetGithubInstallationIDRequest)(nil), // 69: account.UnsetGithubInstallationIDRequest (*UnsetGithubInstallationIDResponse)(nil), // 70: account.UnsetGithubInstallationIDResponse (*GetOrCreateAWSExternalIdRequest)(nil), // 71: account.GetOrCreateAWSExternalIdRequest (*GetOrCreateAWSExternalIdResponse)(nil), // 72: account.GetOrCreateAWSExternalIdResponse (*ListTeamMembersRequest)(nil), // 73: account.ListTeamMembersRequest (*ListTeamMembersResponse)(nil), // 74: account.ListTeamMembersResponse (*TeamMember)(nil), // 75: account.TeamMember (*GetWelcomeScreenInformationRequest)(nil), // 76: account.GetWelcomeScreenInformationRequest (*InviterInformation)(nil), // 77: account.InviterInformation (*GetWelcomeScreenInformationResponse)(nil), // 78: account.GetWelcomeScreenInformationResponse (*timestamppb.Timestamp)(nil), // 79: google.protobuf.Timestamp (*structpb.Struct)(nil), // 80: google.protobuf.Struct (*durationpb.Duration)(nil), // 81: google.protobuf.Duration (QueryMethod)(0), // 82: QueryMethod } var file_account_proto_depIdxs = []int32{ 25, // 0: account.ListAccountsResponse.accounts:type_name -> account.Account 28, // 1: account.CreateAccountRequest.properties:type_name -> account.AccountProperties 25, // 2: account.CreateAccountResponse.account:type_name -> account.Account 28, // 3: account.UpdateAccountRequest.properties:type_name -> account.AccountProperties 25, // 4: account.UpdateAccountResponse.account:type_name -> account.Account 9, // 5: account.AdminUpdateAccountRequest.request:type_name -> account.UpdateAccountRequest 33, // 6: account.AdminListSourcesRequest.request:type_name -> account.ListSourcesRequest 35, // 7: account.AdminCreateSourceRequest.request:type_name -> account.CreateSourceRequest 37, // 8: account.AdminGetSourceRequest.request:type_name -> account.GetSourceRequest 39, // 9: account.AdminUpdateSourceRequest.request:type_name -> account.UpdateSourceRequest 41, // 10: account.AdminDeleteSourceRequest.request:type_name -> account.DeleteSourceRequest 52, // 11: account.AdminKeepaliveSourcesRequest.request:type_name -> account.KeepaliveSourcesRequest 54, // 12: account.AdminCreateTokenRequest.request:type_name -> account.CreateTokenRequest 23, // 13: account.Source.metadata:type_name -> account.SourceMetadata 24, // 14: account.Source.properties:type_name -> account.SourceProperties 79, // 15: account.SourceMetadata.TokenExpiry:type_name -> google.protobuf.Timestamp 0, // 16: account.SourceMetadata.Status:type_name -> account.SourceStatus 80, // 17: account.SourceProperties.Config:type_name -> google.protobuf.Struct 80, // 18: account.SourceProperties.AdditionalConfig:type_name -> google.protobuf.Struct 26, // 19: account.Account.metadata:type_name -> account.AccountMetadata 28, // 20: account.Account.properties:type_name -> account.AccountProperties 27, // 21: account.AccountMetadata.repositories:type_name -> account.Repository 2, // 22: account.AccountMetadata.Plan:type_name -> account.AccountPlan 79, // 23: account.Repository.lastChangeAt:type_name -> google.protobuf.Timestamp 1, // 24: account.Repository.status:type_name -> account.RepositoryStatus 25, // 25: account.GetAccountResponse.account:type_name -> account.Account 22, // 26: account.ListSourcesResponse.Sources:type_name -> account.Source 24, // 27: account.CreateSourceRequest.properties:type_name -> account.SourceProperties 22, // 28: account.CreateSourceResponse.source:type_name -> account.Source 22, // 29: account.GetSourceResponse.source:type_name -> account.Source 24, // 30: account.UpdateSourceRequest.properties:type_name -> account.SourceProperties 22, // 31: account.UpdateSourceResponse.source:type_name -> account.Source 0, // 32: account.SourceKeepaliveResult.Status:type_name -> account.SourceStatus 0, // 33: account.SourceHealth.status:type_name -> account.SourceStatus 79, // 34: account.SourceHealth.createdAt:type_name -> google.protobuf.Timestamp 79, // 35: account.SourceHealth.lastHeartbeat:type_name -> google.protobuf.Timestamp 79, // 36: account.SourceHealth.nextHeartbeat:type_name -> google.protobuf.Timestamp 3, // 37: account.SourceHealth.managed:type_name -> account.SourceManaged 48, // 38: account.SourceHealth.adapterMetadata:type_name -> account.AdapterMetadata 45, // 39: account.ListAllSourcesStatusResponse.sources:type_name -> account.SourceHealth 81, // 40: account.SubmitSourceHeartbeatRequest.nextHeartbeatMax:type_name -> google.protobuf.Duration 3, // 41: account.SubmitSourceHeartbeatRequest.managed:type_name -> account.SourceManaged 48, // 42: account.SubmitSourceHeartbeatRequest.adapterMetadata:type_name -> account.AdapterMetadata 4, // 43: account.AdapterMetadata.category:type_name -> account.AdapterCategory 49, // 44: account.AdapterMetadata.supportedQueryMethods:type_name -> account.AdapterSupportedQueryMethods 50, // 45: account.AdapterMetadata.terraformMappings:type_name -> account.TerraformMapping 82, // 46: account.TerraformMapping.terraformMethod:type_name -> QueryMethod 81, // 47: account.KeepaliveSourcesRequest.timeout:type_name -> google.protobuf.Duration 43, // 48: account.KeepaliveSourcesResponse.sources:type_name -> account.SourceKeepaliveResult 4, // 49: account.AvailableItemType.category:type_name -> account.AdapterCategory 49, // 50: account.AvailableItemType.supportedQueryMethods:type_name -> account.AdapterSupportedQueryMethods 58, // 51: account.ListAvailableItemTypesResponse.types:type_name -> account.AvailableItemType 45, // 52: account.GetSourceStatusResponse.source:type_name -> account.SourceHealth 75, // 53: account.ListTeamMembersResponse.members:type_name -> account.TeamMember 77, // 54: account.GetWelcomeScreenInformationResponse.inviter_information:type_name -> account.InviterInformation 5, // 55: account.AdminService.ListAccounts:input_type -> account.ListAccountsRequest 7, // 56: account.AdminService.CreateAccount:input_type -> account.CreateAccountRequest 11, // 57: account.AdminService.UpdateAccount:input_type -> account.AdminUpdateAccountRequest 12, // 58: account.AdminService.GetAccount:input_type -> account.AdminGetAccountRequest 13, // 59: account.AdminService.DeleteAccount:input_type -> account.AdminDeleteAccountRequest 15, // 60: account.AdminService.ListSources:input_type -> account.AdminListSourcesRequest 16, // 61: account.AdminService.CreateSource:input_type -> account.AdminCreateSourceRequest 17, // 62: account.AdminService.GetSource:input_type -> account.AdminGetSourceRequest 18, // 63: account.AdminService.UpdateSource:input_type -> account.AdminUpdateSourceRequest 19, // 64: account.AdminService.DeleteSource:input_type -> account.AdminDeleteSourceRequest 20, // 65: account.AdminService.KeepaliveSources:input_type -> account.AdminKeepaliveSourcesRequest 21, // 66: account.AdminService.CreateToken:input_type -> account.AdminCreateTokenRequest 29, // 67: account.ManagementService.GetAccount:input_type -> account.GetAccountRequest 31, // 68: account.ManagementService.DeleteAccount:input_type -> account.DeleteAccountRequest 33, // 69: account.ManagementService.ListSources:input_type -> account.ListSourcesRequest 35, // 70: account.ManagementService.CreateSource:input_type -> account.CreateSourceRequest 37, // 71: account.ManagementService.GetSource:input_type -> account.GetSourceRequest 39, // 72: account.ManagementService.UpdateSource:input_type -> account.UpdateSourceRequest 41, // 73: account.ManagementService.DeleteSource:input_type -> account.DeleteSourceRequest 44, // 74: account.ManagementService.ListAllSourcesStatus:input_type -> account.ListAllSourcesStatusRequest 44, // 75: account.ManagementService.ListActiveSourcesStatus:input_type -> account.ListAllSourcesStatusRequest 47, // 76: account.ManagementService.SubmitSourceHeartbeat:input_type -> account.SubmitSourceHeartbeatRequest 52, // 77: account.ManagementService.KeepaliveSources:input_type -> account.KeepaliveSourcesRequest 54, // 78: account.ManagementService.CreateToken:input_type -> account.CreateTokenRequest 56, // 79: account.ManagementService.RevlinkWarmup:input_type -> account.RevlinkWarmupRequest 59, // 80: account.ManagementService.ListAvailableItemTypes:input_type -> account.ListAvailableItemTypesRequest 61, // 81: account.ManagementService.GetSourceStatus:input_type -> account.GetSourceStatusRequest 63, // 82: account.ManagementService.GetUserOnboardingStatus:input_type -> account.GetUserOnboardingStatusRequest 65, // 83: account.ManagementService.SetUserOnboardingStatus:input_type -> account.SetUserOnboardingStatusRequest 73, // 84: account.ManagementService.ListTeamMembers:input_type -> account.ListTeamMembersRequest 76, // 85: account.ManagementService.GetWelcomeScreenInformation:input_type -> account.GetWelcomeScreenInformationRequest 67, // 86: account.ManagementService.SetGithubInstallationID:input_type -> account.SetGithubInstallationIDRequest 69, // 87: account.ManagementService.UnsetGithubInstallationID:input_type -> account.UnsetGithubInstallationIDRequest 71, // 88: account.ManagementService.GetOrCreateAWSExternalId:input_type -> account.GetOrCreateAWSExternalIdRequest 6, // 89: account.AdminService.ListAccounts:output_type -> account.ListAccountsResponse 8, // 90: account.AdminService.CreateAccount:output_type -> account.CreateAccountResponse 10, // 91: account.AdminService.UpdateAccount:output_type -> account.UpdateAccountResponse 30, // 92: account.AdminService.GetAccount:output_type -> account.GetAccountResponse 14, // 93: account.AdminService.DeleteAccount:output_type -> account.AdminDeleteAccountResponse 34, // 94: account.AdminService.ListSources:output_type -> account.ListSourcesResponse 36, // 95: account.AdminService.CreateSource:output_type -> account.CreateSourceResponse 38, // 96: account.AdminService.GetSource:output_type -> account.GetSourceResponse 40, // 97: account.AdminService.UpdateSource:output_type -> account.UpdateSourceResponse 42, // 98: account.AdminService.DeleteSource:output_type -> account.DeleteSourceResponse 53, // 99: account.AdminService.KeepaliveSources:output_type -> account.KeepaliveSourcesResponse 55, // 100: account.AdminService.CreateToken:output_type -> account.CreateTokenResponse 30, // 101: account.ManagementService.GetAccount:output_type -> account.GetAccountResponse 32, // 102: account.ManagementService.DeleteAccount:output_type -> account.DeleteAccountResponse 34, // 103: account.ManagementService.ListSources:output_type -> account.ListSourcesResponse 36, // 104: account.ManagementService.CreateSource:output_type -> account.CreateSourceResponse 38, // 105: account.ManagementService.GetSource:output_type -> account.GetSourceResponse 40, // 106: account.ManagementService.UpdateSource:output_type -> account.UpdateSourceResponse 42, // 107: account.ManagementService.DeleteSource:output_type -> account.DeleteSourceResponse 46, // 108: account.ManagementService.ListAllSourcesStatus:output_type -> account.ListAllSourcesStatusResponse 46, // 109: account.ManagementService.ListActiveSourcesStatus:output_type -> account.ListAllSourcesStatusResponse 51, // 110: account.ManagementService.SubmitSourceHeartbeat:output_type -> account.SubmitSourceHeartbeatResponse 53, // 111: account.ManagementService.KeepaliveSources:output_type -> account.KeepaliveSourcesResponse 55, // 112: account.ManagementService.CreateToken:output_type -> account.CreateTokenResponse 57, // 113: account.ManagementService.RevlinkWarmup:output_type -> account.RevlinkWarmupResponse 60, // 114: account.ManagementService.ListAvailableItemTypes:output_type -> account.ListAvailableItemTypesResponse 62, // 115: account.ManagementService.GetSourceStatus:output_type -> account.GetSourceStatusResponse 64, // 116: account.ManagementService.GetUserOnboardingStatus:output_type -> account.GetUserOnboardingStatusResponse 66, // 117: account.ManagementService.SetUserOnboardingStatus:output_type -> account.SetUserOnboardingStatusResponse 74, // 118: account.ManagementService.ListTeamMembers:output_type -> account.ListTeamMembersResponse 78, // 119: account.ManagementService.GetWelcomeScreenInformation:output_type -> account.GetWelcomeScreenInformationResponse 68, // 120: account.ManagementService.SetGithubInstallationID:output_type -> account.SetGithubInstallationIDResponse 70, // 121: account.ManagementService.UnsetGithubInstallationID:output_type -> account.UnsetGithubInstallationIDResponse 72, // 122: account.ManagementService.GetOrCreateAWSExternalId:output_type -> account.GetOrCreateAWSExternalIdResponse 89, // [89:123] is the sub-list for method output_type 55, // [55:89] is the sub-list for method input_type 55, // [55:55] is the sub-list for extension type_name 55, // [55:55] is the sub-list for extension extendee 0, // [0:55] is the sub-list for field type_name } func init() { file_account_proto_init() } func file_account_proto_init() { if File_account_proto != nil { return } file_items_proto_init() file_account_proto_msgTypes[40].OneofWrappers = []any{} file_account_proto_msgTypes[42].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_account_proto_rawDesc), len(file_account_proto_rawDesc)), NumEnums: 5, NumMessages: 74, NumExtensions: 0, NumServices: 2, }, GoTypes: file_account_proto_goTypes, DependencyIndexes: file_account_proto_depIdxs, EnumInfos: file_account_proto_enumTypes, MessageInfos: file_account_proto_msgTypes, }.Build() File_account_proto = out.File file_account_proto_goTypes = nil file_account_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/apikey.go ================================================ package sdp import "github.com/google/uuid" func (a *APIKeyMetadata) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetUuid()) if err != nil { return nil } return &u } ================================================ FILE: go/sdp-go/apikeys.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: apikeys.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type KeyStatus int32 const ( KeyStatus_KEY_STATUS_UNKNOWN KeyStatus = 0 // This means the key has been created but we have not yet received the // callback from Auth0 which allows us to fetch the access token KeyStatus_KEY_STATUS_UNAUTHORIZED KeyStatus = 1 // Key is ready for use KeyStatus_KEY_STATUS_READY KeyStatus = 2 // There was an error getting the access token from Auth0 KeyStatus_KEY_STATUS_ERROR KeyStatus = 3 // The API key has been revoked KeyStatus_KEY_STATUS_REVOKED KeyStatus = 4 ) // Enum value maps for KeyStatus. var ( KeyStatus_name = map[int32]string{ 0: "KEY_STATUS_UNKNOWN", 1: "KEY_STATUS_UNAUTHORIZED", 2: "KEY_STATUS_READY", 3: "KEY_STATUS_ERROR", 4: "KEY_STATUS_REVOKED", } KeyStatus_value = map[string]int32{ "KEY_STATUS_UNKNOWN": 0, "KEY_STATUS_UNAUTHORIZED": 1, "KEY_STATUS_READY": 2, "KEY_STATUS_ERROR": 3, "KEY_STATUS_REVOKED": 4, } ) func (x KeyStatus) Enum() *KeyStatus { p := new(KeyStatus) *p = x return p } func (x KeyStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (KeyStatus) Descriptor() protoreflect.EnumDescriptor { return file_apikeys_proto_enumTypes[0].Descriptor() } func (KeyStatus) Type() protoreflect.EnumType { return &file_apikeys_proto_enumTypes[0] } func (x KeyStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use KeyStatus.Descriptor instead. func (KeyStatus) EnumDescriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{0} } type APIKey struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *APIKeyMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Properties *APIKeyProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *APIKey) Reset() { *x = APIKey{} mi := &file_apikeys_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *APIKey) String() string { return protoimpl.X.MessageStringOf(x) } func (*APIKey) ProtoMessage() {} func (x *APIKey) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use APIKey.ProtoReflect.Descriptor instead. func (*APIKey) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{0} } func (x *APIKey) GetMetadata() *APIKeyMetadata { if x != nil { return x.Metadata } return nil } func (x *APIKey) GetProperties() *APIKeyProperties { if x != nil { return x.Properties } return nil } type APIKeyMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // The ID of this API key Uuid []byte `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` // When the API Key was created Created *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created,proto3" json:"created,omitempty"` // The last time the API key was exchanged for an access token LastUsed *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=lastUsed,proto3" json:"lastUsed,omitempty"` // The actual API key Key string `protobuf:"bytes,4,opt,name=key,proto3" json:"key,omitempty"` // The list of scopes that this token has access to Scopes []string `protobuf:"bytes,5,rep,name=scopes,proto3" json:"scopes,omitempty"` // The status of the key Status KeyStatus `protobuf:"varint,6,opt,name=status,proto3,enum=apikeys.KeyStatus" json:"status,omitempty"` // The error encountered when authorizing the key. This will only be set if // the status is ERROR Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *APIKeyMetadata) Reset() { *x = APIKeyMetadata{} mi := &file_apikeys_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *APIKeyMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*APIKeyMetadata) ProtoMessage() {} func (x *APIKeyMetadata) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use APIKeyMetadata.ProtoReflect.Descriptor instead. func (*APIKeyMetadata) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{1} } func (x *APIKeyMetadata) GetUuid() []byte { if x != nil { return x.Uuid } return nil } func (x *APIKeyMetadata) GetCreated() *timestamppb.Timestamp { if x != nil { return x.Created } return nil } func (x *APIKeyMetadata) GetLastUsed() *timestamppb.Timestamp { if x != nil { return x.LastUsed } return nil } func (x *APIKeyMetadata) GetKey() string { if x != nil { return x.Key } return "" } func (x *APIKeyMetadata) GetScopes() []string { if x != nil { return x.Scopes } return nil } func (x *APIKeyMetadata) GetStatus() KeyStatus { if x != nil { return x.Status } return KeyStatus_KEY_STATUS_UNKNOWN } func (x *APIKeyMetadata) GetError() string { if x != nil { return x.Error } return "" } type APIKeyProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the API key Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *APIKeyProperties) Reset() { *x = APIKeyProperties{} mi := &file_apikeys_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *APIKeyProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*APIKeyProperties) ProtoMessage() {} func (x *APIKeyProperties) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use APIKeyProperties.ProtoReflect.Descriptor instead. func (*APIKeyProperties) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{2} } func (x *APIKeyProperties) GetName() string { if x != nil { return x.Name } return "" } type CreateAPIKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the key to create Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // The scopes that the key should have Scopes []string `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` // The URL that the user should be redirected to after the whole process is // over. This should be a page in the frontend, probably the one they // started from, but could also be a detail page for this particular API key FinalFrontendRedirect string `protobuf:"bytes,3,opt,name=finalFrontendRedirect,proto3" json:"finalFrontendRedirect,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateAPIKeyRequest) Reset() { *x = CreateAPIKeyRequest{} mi := &file_apikeys_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateAPIKeyRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateAPIKeyRequest) ProtoMessage() {} func (x *CreateAPIKeyRequest) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateAPIKeyRequest.ProtoReflect.Descriptor instead. func (*CreateAPIKeyRequest) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{3} } func (x *CreateAPIKeyRequest) GetName() string { if x != nil { return x.Name } return "" } func (x *CreateAPIKeyRequest) GetScopes() []string { if x != nil { return x.Scopes } return nil } func (x *CreateAPIKeyRequest) GetFinalFrontendRedirect() string { if x != nil { return x.FinalFrontendRedirect } return "" } type CreateAPIKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Details of the newly created API Key Key *APIKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // The URL that the user should visit in order to authorize the newly // created key. This will allow Auth0 to generate a code that will be passed // to the API server via a callback. This code is then exchanged by the API // server for an access token and refresh token. The user will be redirected // back to the frontend once this process is complete. // // The authorizeURL will contain a `state` paremeter which is a UUID that // can be used to look up the API key in the database once the callback is // received AuthorizeURL string `protobuf:"bytes,2,opt,name=authorizeURL,proto3" json:"authorizeURL,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateAPIKeyResponse) Reset() { *x = CreateAPIKeyResponse{} mi := &file_apikeys_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateAPIKeyResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateAPIKeyResponse) ProtoMessage() {} func (x *CreateAPIKeyResponse) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateAPIKeyResponse.ProtoReflect.Descriptor instead. func (*CreateAPIKeyResponse) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{4} } func (x *CreateAPIKeyResponse) GetKey() *APIKey { if x != nil { return x.Key } return nil } func (x *CreateAPIKeyResponse) GetAuthorizeURL() string { if x != nil { return x.AuthorizeURL } return "" } type RefreshAPIKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the API key to refresh Uuid []byte `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` // The URL that the user should be redirected to after the whole process is // over. This should be a page in the frontend, probably the one they // started from, but could also be a detail page for this particular API key FinalFrontendRedirect string `protobuf:"bytes,2,opt,name=finalFrontendRedirect,proto3" json:"finalFrontendRedirect,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RefreshAPIKeyRequest) Reset() { *x = RefreshAPIKeyRequest{} mi := &file_apikeys_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RefreshAPIKeyRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*RefreshAPIKeyRequest) ProtoMessage() {} func (x *RefreshAPIKeyRequest) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RefreshAPIKeyRequest.ProtoReflect.Descriptor instead. func (*RefreshAPIKeyRequest) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{5} } func (x *RefreshAPIKeyRequest) GetUuid() []byte { if x != nil { return x.Uuid } return nil } func (x *RefreshAPIKeyRequest) GetFinalFrontendRedirect() string { if x != nil { return x.FinalFrontendRedirect } return "" } type RefreshAPIKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Refreshing the API key will return the same response as CreateAPIKey, as // it is basically the a new Key, just under the same UUID and reusing the // old info. Response *CreateAPIKeyResponse `protobuf:"bytes,1,opt,name=response,proto3" json:"response,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RefreshAPIKeyResponse) Reset() { *x = RefreshAPIKeyResponse{} mi := &file_apikeys_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RefreshAPIKeyResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*RefreshAPIKeyResponse) ProtoMessage() {} func (x *RefreshAPIKeyResponse) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RefreshAPIKeyResponse.ProtoReflect.Descriptor instead. func (*RefreshAPIKeyResponse) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{6} } func (x *RefreshAPIKeyResponse) GetResponse() *CreateAPIKeyResponse { if x != nil { return x.Response } return nil } type GetAPIKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the API Key to get Uuid []byte `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAPIKeyRequest) Reset() { *x = GetAPIKeyRequest{} mi := &file_apikeys_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAPIKeyRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAPIKeyRequest) ProtoMessage() {} func (x *GetAPIKeyRequest) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAPIKeyRequest.ProtoReflect.Descriptor instead. func (*GetAPIKeyRequest) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{7} } func (x *GetAPIKeyRequest) GetUuid() []byte { if x != nil { return x.Uuid } return nil } type GetAPIKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Key *APIKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAPIKeyResponse) Reset() { *x = GetAPIKeyResponse{} mi := &file_apikeys_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAPIKeyResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAPIKeyResponse) ProtoMessage() {} func (x *GetAPIKeyResponse) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAPIKeyResponse.ProtoReflect.Descriptor instead. func (*GetAPIKeyResponse) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{8} } func (x *GetAPIKeyResponse) GetKey() *APIKey { if x != nil { return x.Key } return nil } type UpdateAPIKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the API key to update Uuid []byte `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` // New properties to update Properties *APIKeyProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateAPIKeyRequest) Reset() { *x = UpdateAPIKeyRequest{} mi := &file_apikeys_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateAPIKeyRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateAPIKeyRequest) ProtoMessage() {} func (x *UpdateAPIKeyRequest) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateAPIKeyRequest.ProtoReflect.Descriptor instead. func (*UpdateAPIKeyRequest) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{9} } func (x *UpdateAPIKeyRequest) GetUuid() []byte { if x != nil { return x.Uuid } return nil } func (x *UpdateAPIKeyRequest) GetProperties() *APIKeyProperties { if x != nil { return x.Properties } return nil } type UpdateAPIKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The updated API key Key *APIKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateAPIKeyResponse) Reset() { *x = UpdateAPIKeyResponse{} mi := &file_apikeys_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateAPIKeyResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateAPIKeyResponse) ProtoMessage() {} func (x *UpdateAPIKeyResponse) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateAPIKeyResponse.ProtoReflect.Descriptor instead. func (*UpdateAPIKeyResponse) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{10} } func (x *UpdateAPIKeyResponse) GetKey() *APIKey { if x != nil { return x.Key } return nil } type ListAPIKeysRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAPIKeysRequest) Reset() { *x = ListAPIKeysRequest{} mi := &file_apikeys_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAPIKeysRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAPIKeysRequest) ProtoMessage() {} func (x *ListAPIKeysRequest) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAPIKeysRequest.ProtoReflect.Descriptor instead. func (*ListAPIKeysRequest) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{11} } type ListAPIKeysResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Keys []*APIKey `protobuf:"bytes,1,rep,name=keys,proto3" json:"keys,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListAPIKeysResponse) Reset() { *x = ListAPIKeysResponse{} mi := &file_apikeys_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListAPIKeysResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListAPIKeysResponse) ProtoMessage() {} func (x *ListAPIKeysResponse) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListAPIKeysResponse.ProtoReflect.Descriptor instead. func (*ListAPIKeysResponse) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{12} } func (x *ListAPIKeysResponse) GetKeys() []*APIKey { if x != nil { return x.Keys } return nil } type DeleteAPIKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the API key to delete Uuid []byte `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteAPIKeyRequest) Reset() { *x = DeleteAPIKeyRequest{} mi := &file_apikeys_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteAPIKeyRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteAPIKeyRequest) ProtoMessage() {} func (x *DeleteAPIKeyRequest) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteAPIKeyRequest.ProtoReflect.Descriptor instead. func (*DeleteAPIKeyRequest) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{13} } func (x *DeleteAPIKeyRequest) GetUuid() []byte { if x != nil { return x.Uuid } return nil } type DeleteAPIKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteAPIKeyResponse) Reset() { *x = DeleteAPIKeyResponse{} mi := &file_apikeys_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteAPIKeyResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteAPIKeyResponse) ProtoMessage() {} func (x *DeleteAPIKeyResponse) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteAPIKeyResponse.ProtoReflect.Descriptor instead. func (*DeleteAPIKeyResponse) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{14} } type ExchangeKeyForTokenRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The API Key that you want to exchange for an Oauth access token ApiKey string `protobuf:"bytes,1,opt,name=apiKey,proto3" json:"apiKey,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ExchangeKeyForTokenRequest) Reset() { *x = ExchangeKeyForTokenRequest{} mi := &file_apikeys_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ExchangeKeyForTokenRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ExchangeKeyForTokenRequest) ProtoMessage() {} func (x *ExchangeKeyForTokenRequest) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ExchangeKeyForTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeKeyForTokenRequest) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{15} } func (x *ExchangeKeyForTokenRequest) GetApiKey() string { if x != nil { return x.ApiKey } return "" } type ExchangeKeyForTokenResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The access token that can now be use to authenticate to Overmind and its // APIs AccessToken string `protobuf:"bytes,1,opt,name=accessToken,proto3" json:"accessToken,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ExchangeKeyForTokenResponse) Reset() { *x = ExchangeKeyForTokenResponse{} mi := &file_apikeys_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ExchangeKeyForTokenResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ExchangeKeyForTokenResponse) ProtoMessage() {} func (x *ExchangeKeyForTokenResponse) ProtoReflect() protoreflect.Message { mi := &file_apikeys_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ExchangeKeyForTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeKeyForTokenResponse) Descriptor() ([]byte, []int) { return file_apikeys_proto_rawDescGZIP(), []int{16} } func (x *ExchangeKeyForTokenResponse) GetAccessToken() string { if x != nil { return x.AccessToken } return "" } var File_apikeys_proto protoreflect.FileDescriptor const file_apikeys_proto_rawDesc = "" + "\n" + "\rapikeys.proto\x12\aapikeys\x1a\x1fgoogle/protobuf/timestamp.proto\"x\n" + "\x06APIKey\x123\n" + "\bmetadata\x18\x01 \x01(\v2\x17.apikeys.APIKeyMetadataR\bmetadata\x129\n" + "\n" + "properties\x18\x02 \x01(\v2\x19.apikeys.APIKeyPropertiesR\n" + "properties\"\xfe\x01\n" + "\x0eAPIKeyMetadata\x12\x12\n" + "\x04uuid\x18\x01 \x01(\fR\x04uuid\x124\n" + "\acreated\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\acreated\x126\n" + "\blastUsed\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\blastUsed\x12\x10\n" + "\x03key\x18\x04 \x01(\tR\x03key\x12\x16\n" + "\x06scopes\x18\x05 \x03(\tR\x06scopes\x12*\n" + "\x06status\x18\x06 \x01(\x0e2\x12.apikeys.KeyStatusR\x06status\x12\x14\n" + "\x05error\x18\a \x01(\tR\x05error\"&\n" + "\x10APIKeyProperties\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"w\n" + "\x13CreateAPIKeyRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + "\x06scopes\x18\x02 \x03(\tR\x06scopes\x124\n" + "\x15finalFrontendRedirect\x18\x03 \x01(\tR\x15finalFrontendRedirect\"]\n" + "\x14CreateAPIKeyResponse\x12!\n" + "\x03key\x18\x01 \x01(\v2\x0f.apikeys.APIKeyR\x03key\x12\"\n" + "\fauthorizeURL\x18\x02 \x01(\tR\fauthorizeURL\"`\n" + "\x14RefreshAPIKeyRequest\x12\x12\n" + "\x04uuid\x18\x01 \x01(\fR\x04uuid\x124\n" + "\x15finalFrontendRedirect\x18\x02 \x01(\tR\x15finalFrontendRedirect\"R\n" + "\x15RefreshAPIKeyResponse\x129\n" + "\bresponse\x18\x01 \x01(\v2\x1d.apikeys.CreateAPIKeyResponseR\bresponse\"&\n" + "\x10GetAPIKeyRequest\x12\x12\n" + "\x04uuid\x18\x01 \x01(\fR\x04uuid\"6\n" + "\x11GetAPIKeyResponse\x12!\n" + "\x03key\x18\x01 \x01(\v2\x0f.apikeys.APIKeyR\x03key\"d\n" + "\x13UpdateAPIKeyRequest\x12\x12\n" + "\x04uuid\x18\x01 \x01(\fR\x04uuid\x129\n" + "\n" + "properties\x18\x02 \x01(\v2\x19.apikeys.APIKeyPropertiesR\n" + "properties\"9\n" + "\x14UpdateAPIKeyResponse\x12!\n" + "\x03key\x18\x01 \x01(\v2\x0f.apikeys.APIKeyR\x03key\"\x14\n" + "\x12ListAPIKeysRequest\":\n" + "\x13ListAPIKeysResponse\x12#\n" + "\x04keys\x18\x01 \x03(\v2\x0f.apikeys.APIKeyR\x04keys\")\n" + "\x13DeleteAPIKeyRequest\x12\x12\n" + "\x04uuid\x18\x01 \x01(\fR\x04uuid\"\x16\n" + "\x14DeleteAPIKeyResponse\"4\n" + "\x1aExchangeKeyForTokenRequest\x12\x16\n" + "\x06apiKey\x18\x01 \x01(\tR\x06apiKey\"?\n" + "\x1bExchangeKeyForTokenResponse\x12 \n" + "\vaccessToken\x18\x01 \x01(\tR\vaccessToken*\x84\x01\n" + "\tKeyStatus\x12\x16\n" + "\x12KEY_STATUS_UNKNOWN\x10\x00\x12\x1b\n" + "\x17KEY_STATUS_UNAUTHORIZED\x10\x01\x12\x14\n" + "\x10KEY_STATUS_READY\x10\x02\x12\x14\n" + "\x10KEY_STATUS_ERROR\x10\x03\x12\x16\n" + "\x12KEY_STATUS_REVOKED\x10\x042\xb6\x04\n" + "\rApiKeyService\x12K\n" + "\fCreateAPIKey\x12\x1c.apikeys.CreateAPIKeyRequest\x1a\x1d.apikeys.CreateAPIKeyResponse\x12N\n" + "\rRefreshAPIKey\x12\x1d.apikeys.RefreshAPIKeyRequest\x1a\x1e.apikeys.RefreshAPIKeyResponse\x12B\n" + "\tGetAPIKey\x12\x19.apikeys.GetAPIKeyRequest\x1a\x1a.apikeys.GetAPIKeyResponse\x12K\n" + "\fUpdateAPIKey\x12\x1c.apikeys.UpdateAPIKeyRequest\x1a\x1d.apikeys.UpdateAPIKeyResponse\x12H\n" + "\vListAPIKeys\x12\x1b.apikeys.ListAPIKeysRequest\x1a\x1c.apikeys.ListAPIKeysResponse\x12K\n" + "\fDeleteAPIKey\x12\x1c.apikeys.DeleteAPIKeyRequest\x1a\x1d.apikeys.DeleteAPIKeyResponse\x12`\n" + "\x13ExchangeKeyForToken\x12#.apikeys.ExchangeKeyForTokenRequest\x1a$.apikeys.ExchangeKeyForTokenResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_apikeys_proto_rawDescOnce sync.Once file_apikeys_proto_rawDescData []byte ) func file_apikeys_proto_rawDescGZIP() []byte { file_apikeys_proto_rawDescOnce.Do(func() { file_apikeys_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_apikeys_proto_rawDesc), len(file_apikeys_proto_rawDesc))) }) return file_apikeys_proto_rawDescData } var file_apikeys_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_apikeys_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_apikeys_proto_goTypes = []any{ (KeyStatus)(0), // 0: apikeys.KeyStatus (*APIKey)(nil), // 1: apikeys.APIKey (*APIKeyMetadata)(nil), // 2: apikeys.APIKeyMetadata (*APIKeyProperties)(nil), // 3: apikeys.APIKeyProperties (*CreateAPIKeyRequest)(nil), // 4: apikeys.CreateAPIKeyRequest (*CreateAPIKeyResponse)(nil), // 5: apikeys.CreateAPIKeyResponse (*RefreshAPIKeyRequest)(nil), // 6: apikeys.RefreshAPIKeyRequest (*RefreshAPIKeyResponse)(nil), // 7: apikeys.RefreshAPIKeyResponse (*GetAPIKeyRequest)(nil), // 8: apikeys.GetAPIKeyRequest (*GetAPIKeyResponse)(nil), // 9: apikeys.GetAPIKeyResponse (*UpdateAPIKeyRequest)(nil), // 10: apikeys.UpdateAPIKeyRequest (*UpdateAPIKeyResponse)(nil), // 11: apikeys.UpdateAPIKeyResponse (*ListAPIKeysRequest)(nil), // 12: apikeys.ListAPIKeysRequest (*ListAPIKeysResponse)(nil), // 13: apikeys.ListAPIKeysResponse (*DeleteAPIKeyRequest)(nil), // 14: apikeys.DeleteAPIKeyRequest (*DeleteAPIKeyResponse)(nil), // 15: apikeys.DeleteAPIKeyResponse (*ExchangeKeyForTokenRequest)(nil), // 16: apikeys.ExchangeKeyForTokenRequest (*ExchangeKeyForTokenResponse)(nil), // 17: apikeys.ExchangeKeyForTokenResponse (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp } var file_apikeys_proto_depIdxs = []int32{ 2, // 0: apikeys.APIKey.metadata:type_name -> apikeys.APIKeyMetadata 3, // 1: apikeys.APIKey.properties:type_name -> apikeys.APIKeyProperties 18, // 2: apikeys.APIKeyMetadata.created:type_name -> google.protobuf.Timestamp 18, // 3: apikeys.APIKeyMetadata.lastUsed:type_name -> google.protobuf.Timestamp 0, // 4: apikeys.APIKeyMetadata.status:type_name -> apikeys.KeyStatus 1, // 5: apikeys.CreateAPIKeyResponse.key:type_name -> apikeys.APIKey 5, // 6: apikeys.RefreshAPIKeyResponse.response:type_name -> apikeys.CreateAPIKeyResponse 1, // 7: apikeys.GetAPIKeyResponse.key:type_name -> apikeys.APIKey 3, // 8: apikeys.UpdateAPIKeyRequest.properties:type_name -> apikeys.APIKeyProperties 1, // 9: apikeys.UpdateAPIKeyResponse.key:type_name -> apikeys.APIKey 1, // 10: apikeys.ListAPIKeysResponse.keys:type_name -> apikeys.APIKey 4, // 11: apikeys.ApiKeyService.CreateAPIKey:input_type -> apikeys.CreateAPIKeyRequest 6, // 12: apikeys.ApiKeyService.RefreshAPIKey:input_type -> apikeys.RefreshAPIKeyRequest 8, // 13: apikeys.ApiKeyService.GetAPIKey:input_type -> apikeys.GetAPIKeyRequest 10, // 14: apikeys.ApiKeyService.UpdateAPIKey:input_type -> apikeys.UpdateAPIKeyRequest 12, // 15: apikeys.ApiKeyService.ListAPIKeys:input_type -> apikeys.ListAPIKeysRequest 14, // 16: apikeys.ApiKeyService.DeleteAPIKey:input_type -> apikeys.DeleteAPIKeyRequest 16, // 17: apikeys.ApiKeyService.ExchangeKeyForToken:input_type -> apikeys.ExchangeKeyForTokenRequest 5, // 18: apikeys.ApiKeyService.CreateAPIKey:output_type -> apikeys.CreateAPIKeyResponse 7, // 19: apikeys.ApiKeyService.RefreshAPIKey:output_type -> apikeys.RefreshAPIKeyResponse 9, // 20: apikeys.ApiKeyService.GetAPIKey:output_type -> apikeys.GetAPIKeyResponse 11, // 21: apikeys.ApiKeyService.UpdateAPIKey:output_type -> apikeys.UpdateAPIKeyResponse 13, // 22: apikeys.ApiKeyService.ListAPIKeys:output_type -> apikeys.ListAPIKeysResponse 15, // 23: apikeys.ApiKeyService.DeleteAPIKey:output_type -> apikeys.DeleteAPIKeyResponse 17, // 24: apikeys.ApiKeyService.ExchangeKeyForToken:output_type -> apikeys.ExchangeKeyForTokenResponse 18, // [18:25] is the sub-list for method output_type 11, // [11:18] is the sub-list for method input_type 11, // [11:11] is the sub-list for extension type_name 11, // [11:11] is the sub-list for extension extendee 0, // [0:11] is the sub-list for field type_name } func init() { file_apikeys_proto_init() } func file_apikeys_proto_init() { if File_apikeys_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_apikeys_proto_rawDesc), len(file_apikeys_proto_rawDesc)), NumEnums: 1, NumMessages: 17, NumExtensions: 0, NumServices: 1, }, GoTypes: file_apikeys_proto_goTypes, DependencyIndexes: file_apikeys_proto_depIdxs, EnumInfos: file_apikeys_proto_enumTypes, MessageInfos: file_apikeys_proto_msgTypes, }.Build() File_apikeys_proto = out.File file_apikeys_proto_goTypes = nil file_apikeys_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/area51.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: area51.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type ChangeArchive struct { state protoimpl.MessageState `protogen:"open.v1"` Change *Change `protobuf:"bytes,1,opt,name=Change,proto3" json:"Change,omitempty"` ChangingItemsBookmark *Bookmark `protobuf:"bytes,2,opt,name=changingItemsBookmark,proto3,oneof" json:"changingItemsBookmark,omitempty"` BlastRadiusSnapshot *Snapshot `protobuf:"bytes,3,opt,name=blastRadiusSnapshot,proto3,oneof" json:"blastRadiusSnapshot,omitempty"` SystemBeforeSnapshot *Snapshot `protobuf:"bytes,4,opt,name=systemBeforeSnapshot,proto3,oneof" json:"systemBeforeSnapshot,omitempty"` SystemAfterSnapshot *Snapshot `protobuf:"bytes,5,opt,name=systemAfterSnapshot,proto3,oneof" json:"systemAfterSnapshot,omitempty"` ChangeRiskMetadata *ChangeRiskMetadata `protobuf:"bytes,6,opt,name=changeRiskMetadata,proto3" json:"changeRiskMetadata,omitempty"` PlannedChanges []*MappedItemDiff `protobuf:"bytes,7,rep,name=plannedChanges,proto3" json:"plannedChanges,omitempty"` TimelineV2 []*ChangeTimelineEntryV2 `protobuf:"bytes,8,rep,name=timelineV2,proto3" json:"timelineV2,omitempty"` Signals []*Signal `protobuf:"bytes,9,rep,name=signals,proto3" json:"signals,omitempty"` Hypotheses []*HypothesesDetails `protobuf:"bytes,10,rep,name=hypotheses,proto3" json:"hypotheses,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeArchive) Reset() { *x = ChangeArchive{} mi := &file_area51_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeArchive) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeArchive) ProtoMessage() {} func (x *ChangeArchive) ProtoReflect() protoreflect.Message { mi := &file_area51_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeArchive.ProtoReflect.Descriptor instead. func (*ChangeArchive) Descriptor() ([]byte, []int) { return file_area51_proto_rawDescGZIP(), []int{0} } func (x *ChangeArchive) GetChange() *Change { if x != nil { return x.Change } return nil } func (x *ChangeArchive) GetChangingItemsBookmark() *Bookmark { if x != nil { return x.ChangingItemsBookmark } return nil } func (x *ChangeArchive) GetBlastRadiusSnapshot() *Snapshot { if x != nil { return x.BlastRadiusSnapshot } return nil } func (x *ChangeArchive) GetSystemBeforeSnapshot() *Snapshot { if x != nil { return x.SystemBeforeSnapshot } return nil } func (x *ChangeArchive) GetSystemAfterSnapshot() *Snapshot { if x != nil { return x.SystemAfterSnapshot } return nil } func (x *ChangeArchive) GetChangeRiskMetadata() *ChangeRiskMetadata { if x != nil { return x.ChangeRiskMetadata } return nil } func (x *ChangeArchive) GetPlannedChanges() []*MappedItemDiff { if x != nil { return x.PlannedChanges } return nil } func (x *ChangeArchive) GetTimelineV2() []*ChangeTimelineEntryV2 { if x != nil { return x.TimelineV2 } return nil } func (x *ChangeArchive) GetSignals() []*Signal { if x != nil { return x.Signals } return nil } func (x *ChangeArchive) GetHypotheses() []*HypothesesDetails { if x != nil { return x.Hypotheses } return nil } type GetChangeArchiveRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeArchiveRequest) Reset() { *x = GetChangeArchiveRequest{} mi := &file_area51_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeArchiveRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeArchiveRequest) ProtoMessage() {} func (x *GetChangeArchiveRequest) ProtoReflect() protoreflect.Message { mi := &file_area51_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeArchiveRequest.ProtoReflect.Descriptor instead. func (*GetChangeArchiveRequest) Descriptor() ([]byte, []int) { return file_area51_proto_rawDescGZIP(), []int{1} } func (x *GetChangeArchiveRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type GetChangeArchiveResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeArchive *ChangeArchive `protobuf:"bytes,1,opt,name=changeArchive,proto3" json:"changeArchive,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeArchiveResponse) Reset() { *x = GetChangeArchiveResponse{} mi := &file_area51_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeArchiveResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeArchiveResponse) ProtoMessage() {} func (x *GetChangeArchiveResponse) ProtoReflect() protoreflect.Message { mi := &file_area51_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeArchiveResponse.ProtoReflect.Descriptor instead. func (*GetChangeArchiveResponse) Descriptor() ([]byte, []int) { return file_area51_proto_rawDescGZIP(), []int{2} } func (x *GetChangeArchiveResponse) GetChangeArchive() *ChangeArchive { if x != nil { return x.ChangeArchive } return nil } var File_area51_proto protoreflect.FileDescriptor const file_area51_proto_rawDesc = "" + "\n" + "\farea51.proto\x12\x06area51\x1a\x0fbookmarks.proto\x1a\rchanges.proto\x1a\fsignal.proto\x1a\x0fsnapshots.proto\x1a\n" + "util.proto\"\x85\x06\n" + "\rChangeArchive\x12'\n" + "\x06Change\x18\x01 \x01(\v2\x0f.changes.ChangeR\x06Change\x12N\n" + "\x15changingItemsBookmark\x18\x02 \x01(\v2\x13.bookmarks.BookmarkH\x00R\x15changingItemsBookmark\x88\x01\x01\x12J\n" + "\x13blastRadiusSnapshot\x18\x03 \x01(\v2\x13.snapshots.SnapshotH\x01R\x13blastRadiusSnapshot\x88\x01\x01\x12L\n" + "\x14systemBeforeSnapshot\x18\x04 \x01(\v2\x13.snapshots.SnapshotH\x02R\x14systemBeforeSnapshot\x88\x01\x01\x12J\n" + "\x13systemAfterSnapshot\x18\x05 \x01(\v2\x13.snapshots.SnapshotH\x03R\x13systemAfterSnapshot\x88\x01\x01\x12K\n" + "\x12changeRiskMetadata\x18\x06 \x01(\v2\x1b.changes.ChangeRiskMetadataR\x12changeRiskMetadata\x12?\n" + "\x0eplannedChanges\x18\a \x03(\v2\x17.changes.MappedItemDiffR\x0eplannedChanges\x12>\n" + "\n" + "timelineV2\x18\b \x03(\v2\x1e.changes.ChangeTimelineEntryV2R\n" + "timelineV2\x12(\n" + "\asignals\x18\t \x03(\v2\x0e.signal.SignalR\asignals\x12:\n" + "\n" + "hypotheses\x18\n" + " \x03(\v2\x1a.changes.HypothesesDetailsR\n" + "hypothesesB\x18\n" + "\x16_changingItemsBookmarkB\x16\n" + "\x14_blastRadiusSnapshotB\x17\n" + "\x15_systemBeforeSnapshotB\x16\n" + "\x14_systemAfterSnapshot\"-\n" + "\x17GetChangeArchiveRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"W\n" + "\x18GetChangeArchiveResponse\x12;\n" + "\rchangeArchive\x18\x01 \x01(\v2\x15.area51.ChangeArchiveR\rchangeArchive2f\n" + "\rArea51Service\x12U\n" + "\x10GetChangeArchive\x12\x1f.area51.GetChangeArchiveRequest\x1a .area51.GetChangeArchiveResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_area51_proto_rawDescOnce sync.Once file_area51_proto_rawDescData []byte ) func file_area51_proto_rawDescGZIP() []byte { file_area51_proto_rawDescOnce.Do(func() { file_area51_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_area51_proto_rawDesc), len(file_area51_proto_rawDesc))) }) return file_area51_proto_rawDescData } var file_area51_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_area51_proto_goTypes = []any{ (*ChangeArchive)(nil), // 0: area51.ChangeArchive (*GetChangeArchiveRequest)(nil), // 1: area51.GetChangeArchiveRequest (*GetChangeArchiveResponse)(nil), // 2: area51.GetChangeArchiveResponse (*Change)(nil), // 3: changes.Change (*Bookmark)(nil), // 4: bookmarks.Bookmark (*Snapshot)(nil), // 5: snapshots.Snapshot (*ChangeRiskMetadata)(nil), // 6: changes.ChangeRiskMetadata (*MappedItemDiff)(nil), // 7: changes.MappedItemDiff (*ChangeTimelineEntryV2)(nil), // 8: changes.ChangeTimelineEntryV2 (*Signal)(nil), // 9: signal.Signal (*HypothesesDetails)(nil), // 10: changes.HypothesesDetails } var file_area51_proto_depIdxs = []int32{ 3, // 0: area51.ChangeArchive.Change:type_name -> changes.Change 4, // 1: area51.ChangeArchive.changingItemsBookmark:type_name -> bookmarks.Bookmark 5, // 2: area51.ChangeArchive.blastRadiusSnapshot:type_name -> snapshots.Snapshot 5, // 3: area51.ChangeArchive.systemBeforeSnapshot:type_name -> snapshots.Snapshot 5, // 4: area51.ChangeArchive.systemAfterSnapshot:type_name -> snapshots.Snapshot 6, // 5: area51.ChangeArchive.changeRiskMetadata:type_name -> changes.ChangeRiskMetadata 7, // 6: area51.ChangeArchive.plannedChanges:type_name -> changes.MappedItemDiff 8, // 7: area51.ChangeArchive.timelineV2:type_name -> changes.ChangeTimelineEntryV2 9, // 8: area51.ChangeArchive.signals:type_name -> signal.Signal 10, // 9: area51.ChangeArchive.hypotheses:type_name -> changes.HypothesesDetails 0, // 10: area51.GetChangeArchiveResponse.changeArchive:type_name -> area51.ChangeArchive 1, // 11: area51.Area51Service.GetChangeArchive:input_type -> area51.GetChangeArchiveRequest 2, // 12: area51.Area51Service.GetChangeArchive:output_type -> area51.GetChangeArchiveResponse 12, // [12:13] is the sub-list for method output_type 11, // [11:12] is the sub-list for method input_type 11, // [11:11] is the sub-list for extension type_name 11, // [11:11] is the sub-list for extension extendee 0, // [0:11] is the sub-list for field type_name } func init() { file_area51_proto_init() } func file_area51_proto_init() { if File_area51_proto != nil { return } file_bookmarks_proto_init() file_changes_proto_init() file_signal_proto_init() file_snapshots_proto_init() file_util_proto_init() file_area51_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_area51_proto_rawDesc), len(file_area51_proto_rawDesc)), NumEnums: 0, NumMessages: 3, NumExtensions: 0, NumServices: 1, }, GoTypes: file_area51_proto_goTypes, DependencyIndexes: file_area51_proto_depIdxs, MessageInfos: file_area51_proto_msgTypes, }.Build() File_area51_proto = out.File file_area51_proto_goTypes = nil file_area51_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/auth0support.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: auth0support.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" _ "google.golang.org/protobuf/types/known/structpb" _ "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Auth0CreateUserRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The Auth0 User ID UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` // The user's email address Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"` // The user's full name. This will be split and stored as first_name and // last_name internally. It is provided for convenience since some social // providers do not provide first_name and last_name fields. If `first_name` // and `last_name` are provided, this field will be ignored. Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` // Whether the user's email address has been verified EmailVerified bool `protobuf:"varint,4,opt,name=email_verified,json=emailVerified,proto3" json:"email_verified,omitempty"` // The user's first name FirstName string `protobuf:"bytes,5,opt,name=first_name,json=firstName,proto3" json:"first_name,omitempty"` // The user's last name LastName string `protobuf:"bytes,6,opt,name=last_name,json=lastName,proto3" json:"last_name,omitempty"` // The user's connection id ConnectionId string `protobuf:"bytes,7,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` // The user's profile picture URL PictureUrl string `protobuf:"bytes,8,opt,name=picture_url,json=pictureUrl,proto3" json:"picture_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Auth0CreateUserRequest) Reset() { *x = Auth0CreateUserRequest{} mi := &file_auth0support_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Auth0CreateUserRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*Auth0CreateUserRequest) ProtoMessage() {} func (x *Auth0CreateUserRequest) ProtoReflect() protoreflect.Message { mi := &file_auth0support_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Auth0CreateUserRequest.ProtoReflect.Descriptor instead. func (*Auth0CreateUserRequest) Descriptor() ([]byte, []int) { return file_auth0support_proto_rawDescGZIP(), []int{0} } func (x *Auth0CreateUserRequest) GetUserId() string { if x != nil { return x.UserId } return "" } func (x *Auth0CreateUserRequest) GetEmail() string { if x != nil { return x.Email } return "" } func (x *Auth0CreateUserRequest) GetName() string { if x != nil { return x.Name } return "" } func (x *Auth0CreateUserRequest) GetEmailVerified() bool { if x != nil { return x.EmailVerified } return false } func (x *Auth0CreateUserRequest) GetFirstName() string { if x != nil { return x.FirstName } return "" } func (x *Auth0CreateUserRequest) GetLastName() string { if x != nil { return x.LastName } return "" } func (x *Auth0CreateUserRequest) GetConnectionId() string { if x != nil { return x.ConnectionId } return "" } func (x *Auth0CreateUserRequest) GetPictureUrl() string { if x != nil { return x.PictureUrl } return "" } type Auth0CreateUserResponse struct { state protoimpl.MessageState `protogen:"open.v1"` OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Auth0CreateUserResponse) Reset() { *x = Auth0CreateUserResponse{} mi := &file_auth0support_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Auth0CreateUserResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*Auth0CreateUserResponse) ProtoMessage() {} func (x *Auth0CreateUserResponse) ProtoReflect() protoreflect.Message { mi := &file_auth0support_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Auth0CreateUserResponse.ProtoReflect.Descriptor instead. func (*Auth0CreateUserResponse) Descriptor() ([]byte, []int) { return file_auth0support_proto_rawDescGZIP(), []int{1} } func (x *Auth0CreateUserResponse) GetOrgId() string { if x != nil { return x.OrgId } return "" } var File_auth0support_proto protoreflect.FileDescriptor const file_auth0support_proto_rawDesc = "" + "\n" + "\x12auth0support.proto\x12\fauth0support\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\raccount.proto\"\x84\x02\n" + "\x16Auth0CreateUserRequest\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x14\n" + "\x05email\x18\x02 \x01(\tR\x05email\x12\x12\n" + "\x04name\x18\x03 \x01(\tR\x04name\x12%\n" + "\x0eemail_verified\x18\x04 \x01(\bR\remailVerified\x12\x1d\n" + "\n" + "first_name\x18\x05 \x01(\tR\tfirstName\x12\x1b\n" + "\tlast_name\x18\x06 \x01(\tR\blastName\x12#\n" + "\rconnection_id\x18\a \x01(\tR\fconnectionId\x12\x1f\n" + "\vpicture_url\x18\b \x01(\tR\n" + "pictureUrl\"0\n" + "\x17Auth0CreateUserResponse\x12\x15\n" + "\x06org_id\x18\x01 \x01(\tR\x05orgId2\xc7\x01\n" + "\fAuth0Support\x12Y\n" + "\n" + "CreateUser\x12$.auth0support.Auth0CreateUserRequest\x1a%.auth0support.Auth0CreateUserResponse\x12\\\n" + "\x10KeepaliveSources\x12%.account.AdminKeepaliveSourcesRequest\x1a!.account.KeepaliveSourcesResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_auth0support_proto_rawDescOnce sync.Once file_auth0support_proto_rawDescData []byte ) func file_auth0support_proto_rawDescGZIP() []byte { file_auth0support_proto_rawDescOnce.Do(func() { file_auth0support_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth0support_proto_rawDesc), len(file_auth0support_proto_rawDesc))) }) return file_auth0support_proto_rawDescData } var file_auth0support_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_auth0support_proto_goTypes = []any{ (*Auth0CreateUserRequest)(nil), // 0: auth0support.Auth0CreateUserRequest (*Auth0CreateUserResponse)(nil), // 1: auth0support.Auth0CreateUserResponse (*AdminKeepaliveSourcesRequest)(nil), // 2: account.AdminKeepaliveSourcesRequest (*KeepaliveSourcesResponse)(nil), // 3: account.KeepaliveSourcesResponse } var file_auth0support_proto_depIdxs = []int32{ 0, // 0: auth0support.Auth0Support.CreateUser:input_type -> auth0support.Auth0CreateUserRequest 2, // 1: auth0support.Auth0Support.KeepaliveSources:input_type -> account.AdminKeepaliveSourcesRequest 1, // 2: auth0support.Auth0Support.CreateUser:output_type -> auth0support.Auth0CreateUserResponse 3, // 3: auth0support.Auth0Support.KeepaliveSources:output_type -> account.KeepaliveSourcesResponse 2, // [2:4] is the sub-list for method output_type 0, // [0:2] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_auth0support_proto_init() } func file_auth0support_proto_init() { if File_auth0support_proto != nil { return } file_account_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth0support_proto_rawDesc), len(file_auth0support_proto_rawDesc)), NumEnums: 0, NumMessages: 2, NumExtensions: 0, NumServices: 1, }, GoTypes: file_auth0support_proto_goTypes, DependencyIndexes: file_auth0support_proto_depIdxs, MessageInfos: file_auth0support_proto_msgTypes, }.Build() File_auth0support_proto = out.File file_auth0support_proto_goTypes = nil file_auth0support_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/bookmarks.go ================================================ package sdp func (b *Bookmark) ToMap() map[string]any { return map[string]any{ "metadata": b.GetMetadata().ToMap(), "properties": b.GetProperties().ToMap(), } } func (bm *BookmarkMetadata) ToMap() map[string]any { return map[string]any{ "UUID": stringFromUuidBytes(bm.GetUUID()), "created": bm.GetCreated().AsTime(), } } func (bp *BookmarkProperties) ToMap() map[string]any { return map[string]any{ "name": bp.GetName(), "description": bp.GetDescription(), "queries": bp.GetQueries(), } } ================================================ FILE: go/sdp-go/bookmarks.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: bookmarks.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // a complete Bookmark with user-supplied and machine-supplied values type Bookmark struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *BookmarkMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Properties *BookmarkProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Bookmark) Reset() { *x = Bookmark{} mi := &file_bookmarks_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Bookmark) String() string { return protoimpl.X.MessageStringOf(x) } func (*Bookmark) ProtoMessage() {} func (x *Bookmark) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Bookmark.ProtoReflect.Descriptor instead. func (*Bookmark) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{0} } func (x *Bookmark) GetMetadata() *BookmarkMetadata { if x != nil { return x.Metadata } return nil } func (x *Bookmark) GetProperties() *BookmarkProperties { if x != nil { return x.Properties } return nil } // The user-editable parts of a Bookmark type BookmarkProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // user supplied name of this bookmark Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // user supplied description of this bookmark Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` // queries that make up the bookmark Queries []*Query `protobuf:"bytes,3,rep,name=queries,proto3" json:"queries,omitempty"` // Whether this bookmark is a system bookmark. System bookmarks are hidden // from list results and can therefore only be accessed by their UUID. // Bookmarks created by users are not system bookmarks. IsSystem bool `protobuf:"varint,5,opt,name=isSystem,proto3" json:"isSystem,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BookmarkProperties) Reset() { *x = BookmarkProperties{} mi := &file_bookmarks_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BookmarkProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*BookmarkProperties) ProtoMessage() {} func (x *BookmarkProperties) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BookmarkProperties.ProtoReflect.Descriptor instead. func (*BookmarkProperties) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{1} } func (x *BookmarkProperties) GetName() string { if x != nil { return x.Name } return "" } func (x *BookmarkProperties) GetDescription() string { if x != nil { return x.Description } return "" } func (x *BookmarkProperties) GetQueries() []*Query { if x != nil { return x.Queries } return nil } func (x *BookmarkProperties) GetIsSystem() bool { if x != nil { return x.IsSystem } return false } // Descriptor for a bookmark type BookmarkMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id to identify this bookmark UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // timestamp when this bookmark was created Created *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created,proto3" json:"created,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BookmarkMetadata) Reset() { *x = BookmarkMetadata{} mi := &file_bookmarks_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BookmarkMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*BookmarkMetadata) ProtoMessage() {} func (x *BookmarkMetadata) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BookmarkMetadata.ProtoReflect.Descriptor instead. func (*BookmarkMetadata) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{2} } func (x *BookmarkMetadata) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *BookmarkMetadata) GetCreated() *timestamppb.Timestamp { if x != nil { return x.Created } return nil } // list all bookmarks type ListBookmarksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListBookmarksRequest) Reset() { *x = ListBookmarksRequest{} mi := &file_bookmarks_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListBookmarksRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListBookmarksRequest) ProtoMessage() {} func (x *ListBookmarksRequest) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListBookmarksRequest.ProtoReflect.Descriptor instead. func (*ListBookmarksRequest) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{3} } type ListBookmarkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Bookmarks []*Bookmark `protobuf:"bytes,3,rep,name=bookmarks,proto3" json:"bookmarks,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListBookmarkResponse) Reset() { *x = ListBookmarkResponse{} mi := &file_bookmarks_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListBookmarkResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListBookmarkResponse) ProtoMessage() {} func (x *ListBookmarkResponse) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListBookmarkResponse.ProtoReflect.Descriptor instead. func (*ListBookmarkResponse) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{4} } func (x *ListBookmarkResponse) GetBookmarks() []*Bookmark { if x != nil { return x.Bookmarks } return nil } // creates a new bookmark type CreateBookmarkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Properties *BookmarkProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateBookmarkRequest) Reset() { *x = CreateBookmarkRequest{} mi := &file_bookmarks_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateBookmarkRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateBookmarkRequest) ProtoMessage() {} func (x *CreateBookmarkRequest) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateBookmarkRequest.ProtoReflect.Descriptor instead. func (*CreateBookmarkRequest) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{5} } func (x *CreateBookmarkRequest) GetProperties() *BookmarkProperties { if x != nil { return x.Properties } return nil } type CreateBookmarkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Bookmark *Bookmark `protobuf:"bytes,1,opt,name=bookmark,proto3" json:"bookmark,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateBookmarkResponse) Reset() { *x = CreateBookmarkResponse{} mi := &file_bookmarks_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateBookmarkResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateBookmarkResponse) ProtoMessage() {} func (x *CreateBookmarkResponse) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateBookmarkResponse.ProtoReflect.Descriptor instead. func (*CreateBookmarkResponse) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{6} } func (x *CreateBookmarkResponse) GetBookmark() *Bookmark { if x != nil { return x.Bookmark } return nil } // gets a specific bookmark type GetBookmarkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetBookmarkRequest) Reset() { *x = GetBookmarkRequest{} mi := &file_bookmarks_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetBookmarkRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetBookmarkRequest) ProtoMessage() {} func (x *GetBookmarkRequest) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetBookmarkRequest.ProtoReflect.Descriptor instead. func (*GetBookmarkRequest) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{7} } func (x *GetBookmarkRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type GetBookmarkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Bookmark *Bookmark `protobuf:"bytes,1,opt,name=bookmark,proto3" json:"bookmark,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetBookmarkResponse) Reset() { *x = GetBookmarkResponse{} mi := &file_bookmarks_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetBookmarkResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetBookmarkResponse) ProtoMessage() {} func (x *GetBookmarkResponse) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetBookmarkResponse.ProtoReflect.Descriptor instead. func (*GetBookmarkResponse) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{8} } func (x *GetBookmarkResponse) GetBookmark() *Bookmark { if x != nil { return x.Bookmark } return nil } // updates an existing bookmark type UpdateBookmarkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id to identify this bookmark UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // new attributes for this bookmark Properties *BookmarkProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateBookmarkRequest) Reset() { *x = UpdateBookmarkRequest{} mi := &file_bookmarks_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateBookmarkRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateBookmarkRequest) ProtoMessage() {} func (x *UpdateBookmarkRequest) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateBookmarkRequest.ProtoReflect.Descriptor instead. func (*UpdateBookmarkRequest) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{9} } func (x *UpdateBookmarkRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *UpdateBookmarkRequest) GetProperties() *BookmarkProperties { if x != nil { return x.Properties } return nil } type UpdateBookmarkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Bookmark *Bookmark `protobuf:"bytes,3,opt,name=bookmark,proto3" json:"bookmark,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateBookmarkResponse) Reset() { *x = UpdateBookmarkResponse{} mi := &file_bookmarks_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateBookmarkResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateBookmarkResponse) ProtoMessage() {} func (x *UpdateBookmarkResponse) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateBookmarkResponse.ProtoReflect.Descriptor instead. func (*UpdateBookmarkResponse) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{10} } func (x *UpdateBookmarkResponse) GetBookmark() *Bookmark { if x != nil { return x.Bookmark } return nil } // Delete the bookmark with the specified ID. type DeleteBookmarkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id of the bookmark to delete UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteBookmarkRequest) Reset() { *x = DeleteBookmarkRequest{} mi := &file_bookmarks_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteBookmarkRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteBookmarkRequest) ProtoMessage() {} func (x *DeleteBookmarkRequest) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteBookmarkRequest.ProtoReflect.Descriptor instead. func (*DeleteBookmarkRequest) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{11} } func (x *DeleteBookmarkRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type DeleteBookmarkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteBookmarkResponse) Reset() { *x = DeleteBookmarkResponse{} mi := &file_bookmarks_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteBookmarkResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteBookmarkResponse) ProtoMessage() {} func (x *DeleteBookmarkResponse) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteBookmarkResponse.ProtoReflect.Descriptor instead. func (*DeleteBookmarkResponse) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{12} } type GetAffectedBookmarksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // the snapshot to consider SnapshotUUID []byte `protobuf:"bytes,1,opt,name=snapshotUUID,proto3" json:"snapshotUUID,omitempty"` // the bookmarks to filter BookmarkUUIDs [][]byte `protobuf:"bytes,2,rep,name=bookmarkUUIDs,proto3" json:"bookmarkUUIDs,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAffectedBookmarksRequest) Reset() { *x = GetAffectedBookmarksRequest{} mi := &file_bookmarks_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAffectedBookmarksRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAffectedBookmarksRequest) ProtoMessage() {} func (x *GetAffectedBookmarksRequest) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAffectedBookmarksRequest.ProtoReflect.Descriptor instead. func (*GetAffectedBookmarksRequest) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{13} } func (x *GetAffectedBookmarksRequest) GetSnapshotUUID() []byte { if x != nil { return x.SnapshotUUID } return nil } func (x *GetAffectedBookmarksRequest) GetBookmarkUUIDs() [][]byte { if x != nil { return x.BookmarkUUIDs } return nil } type GetAffectedBookmarksResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // the bookmarks that intersected with the snapshot BookmarkUUIDs [][]byte `protobuf:"bytes,1,rep,name=bookmarkUUIDs,proto3" json:"bookmarkUUIDs,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAffectedBookmarksResponse) Reset() { *x = GetAffectedBookmarksResponse{} mi := &file_bookmarks_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAffectedBookmarksResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAffectedBookmarksResponse) ProtoMessage() {} func (x *GetAffectedBookmarksResponse) ProtoReflect() protoreflect.Message { mi := &file_bookmarks_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAffectedBookmarksResponse.ProtoReflect.Descriptor instead. func (*GetAffectedBookmarksResponse) Descriptor() ([]byte, []int) { return file_bookmarks_proto_rawDescGZIP(), []int{14} } func (x *GetAffectedBookmarksResponse) GetBookmarkUUIDs() [][]byte { if x != nil { return x.BookmarkUUIDs } return nil } var File_bookmarks_proto protoreflect.FileDescriptor const file_bookmarks_proto_rawDesc = "" + "\n" + "\x0fbookmarks.proto\x12\tbookmarks\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\vitems.proto\"\x82\x01\n" + "\bBookmark\x127\n" + "\bmetadata\x18\x01 \x01(\v2\x1b.bookmarks.BookmarkMetadataR\bmetadata\x12=\n" + "\n" + "properties\x18\x02 \x01(\v2\x1d.bookmarks.BookmarkPropertiesR\n" + "properties\"\x8e\x01\n" + "\x12BookmarkProperties\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12 \n" + "\aqueries\x18\x03 \x03(\v2\x06.QueryR\aqueries\x12\x1a\n" + "\bisSystem\x18\x05 \x01(\bR\bisSystemJ\x04\b\x04\x10\x05\"\\\n" + "\x10BookmarkMetadata\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x124\n" + "\acreated\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\acreated\"\x16\n" + "\x14ListBookmarksRequest\"I\n" + "\x14ListBookmarkResponse\x121\n" + "\tbookmarks\x18\x03 \x03(\v2\x13.bookmarks.BookmarkR\tbookmarks\"V\n" + "\x15CreateBookmarkRequest\x12=\n" + "\n" + "properties\x18\x01 \x01(\v2\x1d.bookmarks.BookmarkPropertiesR\n" + "properties\"I\n" + "\x16CreateBookmarkResponse\x12/\n" + "\bbookmark\x18\x01 \x01(\v2\x13.bookmarks.BookmarkR\bbookmark\"(\n" + "\x12GetBookmarkRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"F\n" + "\x13GetBookmarkResponse\x12/\n" + "\bbookmark\x18\x01 \x01(\v2\x13.bookmarks.BookmarkR\bbookmark\"j\n" + "\x15UpdateBookmarkRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12=\n" + "\n" + "properties\x18\x02 \x01(\v2\x1d.bookmarks.BookmarkPropertiesR\n" + "properties\"I\n" + "\x16UpdateBookmarkResponse\x12/\n" + "\bbookmark\x18\x03 \x01(\v2\x13.bookmarks.BookmarkR\bbookmark\"+\n" + "\x15DeleteBookmarkRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"\x18\n" + "\x16DeleteBookmarkResponse\"g\n" + "\x1bGetAffectedBookmarksRequest\x12\"\n" + "\fsnapshotUUID\x18\x01 \x01(\fR\fsnapshotUUID\x12$\n" + "\rbookmarkUUIDs\x18\x02 \x03(\fR\rbookmarkUUIDs\"D\n" + "\x1cGetAffectedBookmarksResponse\x12$\n" + "\rbookmarkUUIDs\x18\x01 \x03(\fR\rbookmarkUUIDs2\xa1\x04\n" + "\x10BookmarksService\x12Q\n" + "\rListBookmarks\x12\x1f.bookmarks.ListBookmarksRequest\x1a\x1f.bookmarks.ListBookmarkResponse\x12U\n" + "\x0eCreateBookmark\x12 .bookmarks.CreateBookmarkRequest\x1a!.bookmarks.CreateBookmarkResponse\x12L\n" + "\vGetBookmark\x12\x1d.bookmarks.GetBookmarkRequest\x1a\x1e.bookmarks.GetBookmarkResponse\x12U\n" + "\x0eUpdateBookmark\x12 .bookmarks.UpdateBookmarkRequest\x1a!.bookmarks.UpdateBookmarkResponse\x12U\n" + "\x0eDeleteBookmark\x12 .bookmarks.DeleteBookmarkRequest\x1a!.bookmarks.DeleteBookmarkResponse\x12g\n" + "\x14GetAffectedBookmarks\x12&.bookmarks.GetAffectedBookmarksRequest\x1a'.bookmarks.GetAffectedBookmarksResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_bookmarks_proto_rawDescOnce sync.Once file_bookmarks_proto_rawDescData []byte ) func file_bookmarks_proto_rawDescGZIP() []byte { file_bookmarks_proto_rawDescOnce.Do(func() { file_bookmarks_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bookmarks_proto_rawDesc), len(file_bookmarks_proto_rawDesc))) }) return file_bookmarks_proto_rawDescData } var file_bookmarks_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_bookmarks_proto_goTypes = []any{ (*Bookmark)(nil), // 0: bookmarks.Bookmark (*BookmarkProperties)(nil), // 1: bookmarks.BookmarkProperties (*BookmarkMetadata)(nil), // 2: bookmarks.BookmarkMetadata (*ListBookmarksRequest)(nil), // 3: bookmarks.ListBookmarksRequest (*ListBookmarkResponse)(nil), // 4: bookmarks.ListBookmarkResponse (*CreateBookmarkRequest)(nil), // 5: bookmarks.CreateBookmarkRequest (*CreateBookmarkResponse)(nil), // 6: bookmarks.CreateBookmarkResponse (*GetBookmarkRequest)(nil), // 7: bookmarks.GetBookmarkRequest (*GetBookmarkResponse)(nil), // 8: bookmarks.GetBookmarkResponse (*UpdateBookmarkRequest)(nil), // 9: bookmarks.UpdateBookmarkRequest (*UpdateBookmarkResponse)(nil), // 10: bookmarks.UpdateBookmarkResponse (*DeleteBookmarkRequest)(nil), // 11: bookmarks.DeleteBookmarkRequest (*DeleteBookmarkResponse)(nil), // 12: bookmarks.DeleteBookmarkResponse (*GetAffectedBookmarksRequest)(nil), // 13: bookmarks.GetAffectedBookmarksRequest (*GetAffectedBookmarksResponse)(nil), // 14: bookmarks.GetAffectedBookmarksResponse (*Query)(nil), // 15: Query (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp } var file_bookmarks_proto_depIdxs = []int32{ 2, // 0: bookmarks.Bookmark.metadata:type_name -> bookmarks.BookmarkMetadata 1, // 1: bookmarks.Bookmark.properties:type_name -> bookmarks.BookmarkProperties 15, // 2: bookmarks.BookmarkProperties.queries:type_name -> Query 16, // 3: bookmarks.BookmarkMetadata.created:type_name -> google.protobuf.Timestamp 0, // 4: bookmarks.ListBookmarkResponse.bookmarks:type_name -> bookmarks.Bookmark 1, // 5: bookmarks.CreateBookmarkRequest.properties:type_name -> bookmarks.BookmarkProperties 0, // 6: bookmarks.CreateBookmarkResponse.bookmark:type_name -> bookmarks.Bookmark 0, // 7: bookmarks.GetBookmarkResponse.bookmark:type_name -> bookmarks.Bookmark 1, // 8: bookmarks.UpdateBookmarkRequest.properties:type_name -> bookmarks.BookmarkProperties 0, // 9: bookmarks.UpdateBookmarkResponse.bookmark:type_name -> bookmarks.Bookmark 3, // 10: bookmarks.BookmarksService.ListBookmarks:input_type -> bookmarks.ListBookmarksRequest 5, // 11: bookmarks.BookmarksService.CreateBookmark:input_type -> bookmarks.CreateBookmarkRequest 7, // 12: bookmarks.BookmarksService.GetBookmark:input_type -> bookmarks.GetBookmarkRequest 9, // 13: bookmarks.BookmarksService.UpdateBookmark:input_type -> bookmarks.UpdateBookmarkRequest 11, // 14: bookmarks.BookmarksService.DeleteBookmark:input_type -> bookmarks.DeleteBookmarkRequest 13, // 15: bookmarks.BookmarksService.GetAffectedBookmarks:input_type -> bookmarks.GetAffectedBookmarksRequest 4, // 16: bookmarks.BookmarksService.ListBookmarks:output_type -> bookmarks.ListBookmarkResponse 6, // 17: bookmarks.BookmarksService.CreateBookmark:output_type -> bookmarks.CreateBookmarkResponse 8, // 18: bookmarks.BookmarksService.GetBookmark:output_type -> bookmarks.GetBookmarkResponse 10, // 19: bookmarks.BookmarksService.UpdateBookmark:output_type -> bookmarks.UpdateBookmarkResponse 12, // 20: bookmarks.BookmarksService.DeleteBookmark:output_type -> bookmarks.DeleteBookmarkResponse 14, // 21: bookmarks.BookmarksService.GetAffectedBookmarks:output_type -> bookmarks.GetAffectedBookmarksResponse 16, // [16:22] is the sub-list for method output_type 10, // [10:16] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name 10, // [10:10] is the sub-list for extension extendee 0, // [0:10] is the sub-list for field type_name } func init() { file_bookmarks_proto_init() } func file_bookmarks_proto_init() { if File_bookmarks_proto != nil { return } file_items_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_bookmarks_proto_rawDesc), len(file_bookmarks_proto_rawDesc)), NumEnums: 0, NumMessages: 15, NumExtensions: 0, NumServices: 1, }, GoTypes: file_bookmarks_proto_goTypes, DependencyIndexes: file_bookmarks_proto_depIdxs, MessageInfos: file_bookmarks_proto_msgTypes, }.Build() File_bookmarks_proto = out.File file_bookmarks_proto_goTypes = nil file_bookmarks_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/cached_entry.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: cached_entry.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // CachedEntry represents a cached result in the BoltDB cache type CachedEntry struct { state protoimpl.MessageState `protogen:"open.v1"` // The cached item (nil/empty for errors) Item *Item `protobuf:"bytes,1,opt,name=item,proto3" json:"item,omitempty"` // The cached error (nil/empty for items) Error *QueryError `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Expiry timestamp in Unix nanoseconds ExpiryUnixNano int64 `protobuf:"varint,3,opt,name=expiry_unix_nano,json=expiryUnixNano,proto3" json:"expiry_unix_nano,omitempty"` // Index values for efficient lookup UniqueAttributeValue string `protobuf:"bytes,4,opt,name=unique_attribute_value,json=uniqueAttributeValue,proto3" json:"unique_attribute_value,omitempty"` Method QueryMethod `protobuf:"varint,5,opt,name=method,proto3,enum=QueryMethod" json:"method,omitempty"` Query string `protobuf:"bytes,6,opt,name=query,proto3" json:"query,omitempty"` SstHash string `protobuf:"bytes,7,opt,name=sst_hash,json=sstHash,proto3" json:"sst_hash,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CachedEntry) Reset() { *x = CachedEntry{} mi := &file_cached_entry_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CachedEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*CachedEntry) ProtoMessage() {} func (x *CachedEntry) ProtoReflect() protoreflect.Message { mi := &file_cached_entry_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CachedEntry.ProtoReflect.Descriptor instead. func (*CachedEntry) Descriptor() ([]byte, []int) { return file_cached_entry_proto_rawDescGZIP(), []int{0} } func (x *CachedEntry) GetItem() *Item { if x != nil { return x.Item } return nil } func (x *CachedEntry) GetError() *QueryError { if x != nil { return x.Error } return nil } func (x *CachedEntry) GetExpiryUnixNano() int64 { if x != nil { return x.ExpiryUnixNano } return 0 } func (x *CachedEntry) GetUniqueAttributeValue() string { if x != nil { return x.UniqueAttributeValue } return "" } func (x *CachedEntry) GetMethod() QueryMethod { if x != nil { return x.Method } return QueryMethod_GET } func (x *CachedEntry) GetQuery() string { if x != nil { return x.Query } return "" } func (x *CachedEntry) GetSstHash() string { if x != nil { return x.SstHash } return "" } var File_cached_entry_proto protoreflect.FileDescriptor const file_cached_entry_proto_rawDesc = "" + "\n" + "\x12cached_entry.proto\x1a\vitems.proto\"\x82\x02\n" + "\vCachedEntry\x12\x19\n" + "\x04item\x18\x01 \x01(\v2\x05.ItemR\x04item\x12!\n" + "\x05error\x18\x02 \x01(\v2\v.QueryErrorR\x05error\x12(\n" + "\x10expiry_unix_nano\x18\x03 \x01(\x03R\x0eexpiryUnixNano\x124\n" + "\x16unique_attribute_value\x18\x04 \x01(\tR\x14uniqueAttributeValue\x12$\n" + "\x06method\x18\x05 \x01(\x0e2\f.QueryMethodR\x06method\x12\x14\n" + "\x05query\x18\x06 \x01(\tR\x05query\x12\x19\n" + "\bsst_hash\x18\a \x01(\tR\asstHashB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_cached_entry_proto_rawDescOnce sync.Once file_cached_entry_proto_rawDescData []byte ) func file_cached_entry_proto_rawDescGZIP() []byte { file_cached_entry_proto_rawDescOnce.Do(func() { file_cached_entry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_cached_entry_proto_rawDesc), len(file_cached_entry_proto_rawDesc))) }) return file_cached_entry_proto_rawDescData } var file_cached_entry_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_cached_entry_proto_goTypes = []any{ (*CachedEntry)(nil), // 0: CachedEntry (*Item)(nil), // 1: Item (*QueryError)(nil), // 2: QueryError (QueryMethod)(0), // 3: QueryMethod } var file_cached_entry_proto_depIdxs = []int32{ 1, // 0: CachedEntry.item:type_name -> Item 2, // 1: CachedEntry.error:type_name -> QueryError 3, // 2: CachedEntry.method:type_name -> QueryMethod 3, // [3:3] is the sub-list for method output_type 3, // [3:3] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension extendee 0, // [0:3] is the sub-list for field type_name } func init() { file_cached_entry_proto_init() } func file_cached_entry_proto_init() { if File_cached_entry_proto != nil { return } file_items_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cached_entry_proto_rawDesc), len(file_cached_entry_proto_rawDesc)), NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_cached_entry_proto_goTypes, DependencyIndexes: file_cached_entry_proto_depIdxs, MessageInfos: file_cached_entry_proto_msgTypes, }.Build() File_cached_entry_proto = out.File file_cached_entry_proto_goTypes = nil file_cached_entry_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/changes.go ================================================ package sdp import ( "errors" "fmt" "github.com/google/uuid" "go.yaml.in/yaml/v3" ) // GetUUIDParsed returns the parsed UUID from the ChangeMetadata, or nil if invalid. func (a *ChangeMetadata) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetUUID()) if err != nil { return nil } return &u } // GetNullUUID returns the UUID as a nullable type for database operations. func (a *ChangeMetadata) GetNullUUID() uuid.NullUUID { u := a.GetUUIDParsed() if u == nil { return uuid.NullUUID{Valid: false} } return uuid.NullUUID{UUID: *u, Valid: true} } // GetChangingItemsBookmarkUUIDParsed returns the parsed UUID for the bookmark // containing the directly affected items, or nil if invalid. func (a *ChangeProperties) GetChangingItemsBookmarkUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetChangingItemsBookmarkUUID()) if err != nil { return nil } return &u } // GetSystemBeforeSnapshotUUIDParsed returns the parsed UUID for the whole-system // snapshot taken before the change, or nil if invalid. func (a *ChangeProperties) GetSystemBeforeSnapshotUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetSystemBeforeSnapshotUUID()) if err != nil { return nil } return &u } // GetSystemAfterSnapshotUUIDParsed returns the parsed UUID for the whole-system // snapshot taken after the change, or nil if invalid. func (a *ChangeProperties) GetSystemAfterSnapshotUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetSystemAfterSnapshotUUID()) if err != nil { return nil } return &u } // GetUUIDParsed returns the parsed UUID from GetChangeRequest, or nil if invalid. func (a *GetChangeRequest) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetUUID()) if err != nil { return nil } return &u } // GetUUIDParsed returns the parsed UUID from UpdateChangeRequest, or nil if invalid. func (a *UpdateChangeRequest) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetUUID()) if err != nil { return nil } return &u } // GetUUIDParsed returns the parsed UUID from DeleteChangeRequest, or nil if invalid. func (a *DeleteChangeRequest) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(a.GetUUID()) if err != nil { return nil } return &u } // GetChangeUUIDParsed returns the parsed change UUID from GetDiffRequest, or nil if invalid. func (x *GetDiffRequest) GetChangeUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetChangeUUID()) if err != nil { return nil } return &u } // GetChangeUUIDParsed returns the parsed change UUID from ListChangingItemsSummaryRequest, or nil if invalid. func (x *ListChangingItemsSummaryRequest) GetChangeUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetChangeUUID()) if err != nil { return nil } return &u } // GetChangeUUIDParsed returns the parsed change UUID from StartChangeRequest, or nil if invalid. func (x *StartChangeRequest) GetChangeUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetChangeUUID()) if err != nil { return nil } return &u } // GetChangeUUIDParsed returns the parsed change UUID from EndChangeRequest, or nil if invalid. func (x *EndChangeRequest) GetChangeUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetChangeUUID()) if err != nil { return nil } return &u } // ToMap converts a Change to a map for serialization (e.g., for LLM templates). func (c *Change) ToMap() map[string]any { return map[string]any{ "metadata": c.GetMetadata().ToMap(), "properties": c.GetProperties().ToMap(), } } // stringFromUuidBytes converts UUID bytes to a string, returning empty string on error. func stringFromUuidBytes(b []byte) string { u, err := uuid.FromBytes(b) if err != nil { return "" } return u.String() } // ToMap converts a Reference to a map for serialization. func (r *Reference) ToMap() map[string]any { return map[string]any{ "type": r.GetType(), "uniqueAttributeValue": r.GetUniqueAttributeValue(), "scope": r.GetScope(), } } // ToMap converts a Risk to a map for serialization, including related items. func (r *Risk) ToMap() map[string]any { relatedItems := make([]map[string]any, len(r.GetRelatedItemRefs())) for i, ri := range r.GetRelatedItemRefs() { relatedItems[i] = ri.ToMap() } return map[string]any{ "uuid": stringFromUuidBytes(r.GetUUID()), "title": r.GetTitle(), "severity": r.GetSeverity().String(), "description": r.GetDescription(), "relatedItemRefs": relatedItems, } } // ToMap converts a GetChangeRisksResponse to a map for serialization. func (r *GetChangeRisksResponse) ToMap() map[string]any { rmd := r.GetChangeRiskMetadata() risks := make([]map[string]any, len(rmd.GetRisks())) for i, ri := range rmd.GetRisks() { risks[i] = ri.ToMap() } return map[string]any{ "risks": risks, "numHighRisk": rmd.GetNumHighRisk(), "numMediumRisk": rmd.GetNumMediumRisk(), "numLowRisk": rmd.GetNumLowRisk(), "changeAnalysisStatus": rmd.GetChangeAnalysisStatus().ToMap(), } } // ToMap converts ChangeMetadata to a map for serialization. func (cm *ChangeMetadata) ToMap() map[string]any { return map[string]any{ "UUID": stringFromUuidBytes(cm.GetUUID()), "createdAt": cm.GetCreatedAt().AsTime(), "updatedAt": cm.GetUpdatedAt().AsTime(), "status": cm.GetStatus().String(), "creatorName": cm.GetCreatorName(), "numAffectedItems": cm.GetNumAffectedItems(), "numAffectedEdges": cm.GetNumAffectedEdges(), "numUnchangedItems": cm.GetNumUnchangedItems(), "numCreatedItems": cm.GetNumCreatedItems(), "numUpdatedItems": cm.GetNumUpdatedItems(), "numDeletedItems": cm.GetNumDeletedItems(), "UnknownHealthChange": cm.GetUnknownHealthChange(), "OkHealthChange": cm.GetOkHealthChange(), "WarningHealthChange": cm.GetWarningHealthChange(), "ErrorHealthChange": cm.GetErrorHealthChange(), "PendingHealthChange": cm.GetPendingHealthChange(), } } // ToMap converts an Item to a map for serialization. func (i *Item) ToMap() map[string]any { return map[string]any{ "type": i.GetType(), "uniqueAttributeValue": i.UniqueAttributeValue(), "scope": i.GetScope(), "attributes": i.GetAttributes().GetAttrStruct().GetFields(), } } // ToMap converts an ItemDiff to a map for serialization. func (id *ItemDiff) ToMap() map[string]any { result := map[string]any{ "status": id.GetStatus().String(), } if id.GetItem() != nil { result["item"] = id.GetItem().ToMap() } if id.GetBefore() != nil { result["before"] = id.GetBefore().ToMap() } if id.GetAfter() != nil { result["after"] = id.GetAfter().ToMap() } return result } // GloballyUniqueName returns the GUN from the item, before, or after state, // whichever is available (in that order of preference). func (id *ItemDiff) GloballyUniqueName() string { if id.GetItem() != nil { return id.GetItem().GloballyUniqueName() } else if id.GetBefore() != nil { return id.GetBefore().GloballyUniqueName() } else if id.GetAfter() != nil { return id.GetAfter().GloballyUniqueName() } else { return "empty item diff" } } // ToMap converts ChangeProperties to a map for serialization. func (cp *ChangeProperties) ToMap() map[string]any { plannedChanges := make([]map[string]any, len(cp.GetPlannedChanges())) for i, id := range cp.GetPlannedChanges() { plannedChanges[i] = id.ToMap() } return map[string]any{ "title": cp.GetTitle(), "description": cp.GetDescription(), "ticketLink": cp.GetTicketLink(), "owner": cp.GetOwner(), "ccEmails": cp.GetCcEmails(), "changingItemsBookmarkUUID": stringFromUuidBytes(cp.GetChangingItemsBookmarkUUID()), "blastRadiusSnapshotUUID": stringFromUuidBytes(cp.GetBlastRadiusSnapshotUUID()), "systemBeforeSnapshotUUID": stringFromUuidBytes(cp.GetSystemBeforeSnapshotUUID()), "systemAfterSnapshotUUID": stringFromUuidBytes(cp.GetSystemAfterSnapshotUUID()), "plannedChanges": cp.GetPlannedChanges(), "rawPlan": cp.GetRawPlan(), "codeChanges": cp.GetCodeChanges(), "repo": cp.GetRepo(), "tags": cp.GetEnrichedTags(), } } // ToMap converts ChangeAnalysisStatus to a map for serialization. func (rcs *ChangeAnalysisStatus) ToMap() map[string]any { if rcs == nil { return map[string]any{} } return map[string]any{ "status": rcs.GetStatus().String(), } } // ToMessage converts a StartChangeResponse_State enum to a human-readable message. func (s StartChangeResponse_State) ToMessage() string { switch s { case StartChangeResponse_STATE_UNSPECIFIED: return "unknown" case StartChangeResponse_STATE_TAKING_SNAPSHOT: return "Snapshot is being taken" case StartChangeResponse_STATE_SAVING_SNAPSHOT: return "Snapshot is being saved" case StartChangeResponse_STATE_DONE: return "Everything is complete" default: return "unknown" } } // ToMessage converts an EndChangeResponse_State enum to a human-readable message. func (s EndChangeResponse_State) ToMessage() string { switch s { case EndChangeResponse_STATE_UNSPECIFIED: return "unknown" case EndChangeResponse_STATE_TAKING_SNAPSHOT: return "Snapshot is being taken" case EndChangeResponse_STATE_SAVING_SNAPSHOT: return "Snapshot is being saved" case EndChangeResponse_STATE_DONE: return "Everything is complete" default: return "unknown" } } // RoutineChangesYAML represents the YAML structure for routine changes configuration. // It defines parameters for detecting routine changes in infrastructure: // - Sensitivity: Threshold for determining what constitutes a routine change (0 or higher) // - DurationInDays: Time window in days to analyze for routine patterns (must be >= 1) // - EventsPerDay: Expected number of change events per day for routine detection (must be >= 1) type RoutineChangesYAML struct { Sensitivity float32 `yaml:"sensitivity"` DurationInDays float32 `yaml:"duration_in_days"` EventsPerDay float32 `yaml:"events_per_day"` } // GithubOrganisationYAML represents the YAML structure for GitHub organization profile configuration. // It contains organization-specific settings such as the primary branch name used for // change detection and analysis. type GithubOrganisationYAML struct { PrimaryBranchName string `yaml:"primary_branch_name"` } // SignalConfigYAML represents the root YAML structure for signal configuration files. // It can contain either or both of: // - RoutineChangesConfig: Configuration for routine change detection // - GithubOrganisationProfile: GitHub organization-specific settings // At least one section must be provided in the YAML file. type SignalConfigYAML struct { RoutineChangesConfig *RoutineChangesYAML `yaml:"routine_changes_config,omitempty"` GithubOrganisationProfile *GithubOrganisationYAML `yaml:"github_organisation_profile,omitempty"` } // SignalConfigFile represents the internal, parsed signal configuration structure. // This is the converted form of SignalConfigYAML, where YAML-specific types are // transformed into their corresponding protocol buffer types for use in the application. type SignalConfigFile struct { RoutineChangesConfig *RoutineChangesConfig GithubOrganisationProfile *GithubOrganisationProfile } // YamlStringToSignalConfig parses a YAML string containing signal configuration and converts it // into a SignalConfigFile. It validates that at least one configuration section is provided // and performs validation on the routine changes configuration if present. // // The function handles conversion from YAML-friendly types (e.g., float32 for durations) // to the internal protocol buffer types (e.g., RoutineChangesConfig with unit specifications). // // Returns an error if: // - The YAML is invalid or cannot be unmarshaled // - No configuration sections are provided // - Routine changes configuration validation fails func YamlStringToSignalConfig(yamlString string) (*SignalConfigFile, error) { var signalConfigYAML SignalConfigYAML err := yaml.Unmarshal([]byte(yamlString), &signalConfigYAML) if err != nil { return nil, fmt.Errorf("error unmarshalling yaml to signal config: %w", err) } // check that at least one section is provided if signalConfigYAML.RoutineChangesConfig == nil && signalConfigYAML.GithubOrganisationProfile == nil { return nil, fmt.Errorf("signal config file must contain at least one of: routine_changes_config or github_organisation_profile") } // validate the routine changes config if signalConfigYAML.RoutineChangesConfig != nil { if err := validateRoutineChangesConfig(signalConfigYAML.RoutineChangesConfig); err != nil { return nil, err } } var routineCfg *RoutineChangesConfig if signalConfigYAML.RoutineChangesConfig != nil { routineCfg = &RoutineChangesConfig{ Sensitivity: signalConfigYAML.RoutineChangesConfig.Sensitivity, EventsPer: signalConfigYAML.RoutineChangesConfig.EventsPerDay, EventsPerUnit: RoutineChangesConfig_DAYS, Duration: signalConfigYAML.RoutineChangesConfig.DurationInDays, DurationUnit: RoutineChangesConfig_DAYS, } } var githubProfile *GithubOrganisationProfile if signalConfigYAML.GithubOrganisationProfile != nil { githubProfile = &GithubOrganisationProfile{ PrimaryBranchName: signalConfigYAML.GithubOrganisationProfile.PrimaryBranchName, } } signalConfigFile := &SignalConfigFile{ RoutineChangesConfig: routineCfg, GithubOrganisationProfile: githubProfile, } return signalConfigFile, nil } // validateRoutineChangesConfig validates the routine changes configuration values. // It ensures that: // - EventsPerDay is at least 1 // - DurationInDays is at least 1 // - Sensitivity is 0 or higher // // Returns an error with a descriptive message if any validation fails. func validateRoutineChangesConfig(routineChangesConfigYAML *RoutineChangesYAML) error { if routineChangesConfigYAML.EventsPerDay < 1 { return fmt.Errorf("events_per_day must be greater than 1, got %v", routineChangesConfigYAML.EventsPerDay) } if routineChangesConfigYAML.DurationInDays < 1 { return fmt.Errorf("duration_in_days must be greater than 1, got %v", routineChangesConfigYAML.DurationInDays) } if routineChangesConfigYAML.Sensitivity < 0 { return fmt.Errorf("sensitivity must be 0 or higher, got %v", routineChangesConfigYAML.Sensitivity) } return nil } // TimelineEntryContentDescription returns a human-readable description of the // entry's content based on its type. func TimelineEntryContentDescription(entry *ChangeTimelineEntryV2) string { switch c := entry.GetContent().(type) { case *ChangeTimelineEntryV2_MappedItems: return fmt.Sprintf("%d mapped items", len(c.MappedItems.GetMappedItems())) case *ChangeTimelineEntryV2_CalculatedBlastRadius: return fmt.Sprintf("%d items, %d edges", c.CalculatedBlastRadius.GetNumItems(), c.CalculatedBlastRadius.GetNumEdges()) case *ChangeTimelineEntryV2_CalculatedRisks: return fmt.Sprintf("%d risks", len(c.CalculatedRisks.GetRisks())) case *ChangeTimelineEntryV2_CalculatedLabels: return fmt.Sprintf("%d labels", len(c.CalculatedLabels.GetLabels())) case *ChangeTimelineEntryV2_ChangeValidation: return fmt.Sprintf("%d validation categories", len(c.ChangeValidation.GetValidationChecklist())) case *ChangeTimelineEntryV2_FormHypotheses: return fmt.Sprintf("%d hypotheses", c.FormHypotheses.GetNumHypotheses()) case *ChangeTimelineEntryV2_InvestigateHypotheses: return fmt.Sprintf("%d proven, %d disproven, %d investigating", c.InvestigateHypotheses.GetNumProven(), c.InvestigateHypotheses.GetNumDisproven(), c.InvestigateHypotheses.GetNumInvestigating()) case *ChangeTimelineEntryV2_RecordObservations: return fmt.Sprintf("%d observations", c.RecordObservations.GetNumObservations()) case *ChangeTimelineEntryV2_Error: return c.Error case *ChangeTimelineEntryV2_StatusMessage: return c.StatusMessage case *ChangeTimelineEntryV2_Empty, nil: return "" default: return "" } } // TimelineFindInProgressEntry returns the current running entry in the list of entries // The function handles the following cases: // - If the input slice is nil or empty, it returns an error. // - The first entry that has a status of IN_PROGRESS, PENDING, or ERROR, it returns the entry's name, content description, status, and a nil error. // - If an entry has an unknown status, it returns an error. // - If the timeline is complete it returns an empty string, empty content description, DONE status, and a nil error. func TimelineFindInProgressEntry(entries []*ChangeTimelineEntryV2) (string, string, ChangeTimelineEntryStatus, error) { if entries == nil { return "", "", ChangeTimelineEntryStatus_UNSPECIFIED, errors.New("entries is nil") } if len(entries) == 0 { return "", "", ChangeTimelineEntryStatus_UNSPECIFIED, errors.New("entries is empty") } for _, entry := range entries { switch entry.GetStatus() { case ChangeTimelineEntryStatus_IN_PROGRESS, ChangeTimelineEntryStatus_PENDING, ChangeTimelineEntryStatus_ERROR: // if the entry is in progress or about to start, or has an error(to be retried) return entry.GetName(), TimelineEntryContentDescription(entry), entry.GetStatus(), nil case ChangeTimelineEntryStatus_UNSPECIFIED, ChangeTimelineEntryStatus_DONE: // do nothing default: return "", "", ChangeTimelineEntryStatus_UNSPECIFIED, fmt.Errorf("unknown status: %s", entry.GetStatus().String()) } } return "", "", ChangeTimelineEntryStatus_DONE, nil } ================================================ FILE: go/sdp-go/changes.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: changes.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // Status of a mapped item in the timeline type MappedItemTimelineStatus int32 const ( MappedItemTimelineStatus_MAPPED_ITEM_TIMELINE_STATUS_UNSPECIFIED MappedItemTimelineStatus = 0 MappedItemTimelineStatus_MAPPED_ITEM_TIMELINE_STATUS_SUCCESS MappedItemTimelineStatus = 1 MappedItemTimelineStatus_MAPPED_ITEM_TIMELINE_STATUS_ERROR MappedItemTimelineStatus = 2 MappedItemTimelineStatus_MAPPED_ITEM_TIMELINE_STATUS_UNSUPPORTED MappedItemTimelineStatus = 3 MappedItemTimelineStatus_MAPPED_ITEM_TIMELINE_STATUS_PENDING_CREATION MappedItemTimelineStatus = 4 ) // Enum value maps for MappedItemTimelineStatus. var ( MappedItemTimelineStatus_name = map[int32]string{ 0: "MAPPED_ITEM_TIMELINE_STATUS_UNSPECIFIED", 1: "MAPPED_ITEM_TIMELINE_STATUS_SUCCESS", 2: "MAPPED_ITEM_TIMELINE_STATUS_ERROR", 3: "MAPPED_ITEM_TIMELINE_STATUS_UNSUPPORTED", 4: "MAPPED_ITEM_TIMELINE_STATUS_PENDING_CREATION", } MappedItemTimelineStatus_value = map[string]int32{ "MAPPED_ITEM_TIMELINE_STATUS_UNSPECIFIED": 0, "MAPPED_ITEM_TIMELINE_STATUS_SUCCESS": 1, "MAPPED_ITEM_TIMELINE_STATUS_ERROR": 2, "MAPPED_ITEM_TIMELINE_STATUS_UNSUPPORTED": 3, "MAPPED_ITEM_TIMELINE_STATUS_PENDING_CREATION": 4, } ) func (x MappedItemTimelineStatus) Enum() *MappedItemTimelineStatus { p := new(MappedItemTimelineStatus) *p = x return p } func (x MappedItemTimelineStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (MappedItemTimelineStatus) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[0].Descriptor() } func (MappedItemTimelineStatus) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[0] } func (x MappedItemTimelineStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use MappedItemTimelineStatus.Descriptor instead. func (MappedItemTimelineStatus) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{0} } // Explicit mapping status from CLI - allows CLI to communicate state instead of API inferring type MappedItemMappingStatus int32 const ( MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_UNSPECIFIED MappedItemMappingStatus = 0 MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_SUCCESS MappedItemMappingStatus = 1 MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_UNSUPPORTED MappedItemMappingStatus = 2 MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_PENDING_CREATION MappedItemMappingStatus = 3 MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_ERROR MappedItemMappingStatus = 4 ) // Enum value maps for MappedItemMappingStatus. var ( MappedItemMappingStatus_name = map[int32]string{ 0: "MAPPED_ITEM_MAPPING_STATUS_UNSPECIFIED", 1: "MAPPED_ITEM_MAPPING_STATUS_SUCCESS", 2: "MAPPED_ITEM_MAPPING_STATUS_UNSUPPORTED", 3: "MAPPED_ITEM_MAPPING_STATUS_PENDING_CREATION", 4: "MAPPED_ITEM_MAPPING_STATUS_ERROR", } MappedItemMappingStatus_value = map[string]int32{ "MAPPED_ITEM_MAPPING_STATUS_UNSPECIFIED": 0, "MAPPED_ITEM_MAPPING_STATUS_SUCCESS": 1, "MAPPED_ITEM_MAPPING_STATUS_UNSUPPORTED": 2, "MAPPED_ITEM_MAPPING_STATUS_PENDING_CREATION": 3, "MAPPED_ITEM_MAPPING_STATUS_ERROR": 4, } ) func (x MappedItemMappingStatus) Enum() *MappedItemMappingStatus { p := new(MappedItemMappingStatus) *p = x return p } func (x MappedItemMappingStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (MappedItemMappingStatus) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[1].Descriptor() } func (MappedItemMappingStatus) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[1] } func (x MappedItemMappingStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use MappedItemMappingStatus.Descriptor instead. func (MappedItemMappingStatus) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{1} } type HypothesisStatus int32 const ( HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_UNSPECIFIED HypothesisStatus = 0 // The hypothesis is being formed, the detail may change as more observations // are recorded HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_FORMING HypothesisStatus = 1 // The hypotheses is being investigated, the detail will be available once the // investigation is complete HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_INVESTIGATING HypothesisStatus = 2 // They hypothesis has been proven to be a risk HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_PROVEN HypothesisStatus = 3 // The hypothesis has been disproven, no risk has been found HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_DISPROVEN HypothesisStatus = 4 // The hypothesis was skipped and not investigated HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_SKIPPED HypothesisStatus = 5 ) // Enum value maps for HypothesisStatus. var ( HypothesisStatus_name = map[int32]string{ 0: "INVESTIGATED_HYPOTHESIS_STATUS_UNSPECIFIED", 1: "INVESTIGATED_HYPOTHESIS_STATUS_FORMING", 2: "INVESTIGATED_HYPOTHESIS_STATUS_INVESTIGATING", 3: "INVESTIGATED_HYPOTHESIS_STATUS_PROVEN", 4: "INVESTIGATED_HYPOTHESIS_STATUS_DISPROVEN", 5: "INVESTIGATED_HYPOTHESIS_STATUS_SKIPPED", } HypothesisStatus_value = map[string]int32{ "INVESTIGATED_HYPOTHESIS_STATUS_UNSPECIFIED": 0, "INVESTIGATED_HYPOTHESIS_STATUS_FORMING": 1, "INVESTIGATED_HYPOTHESIS_STATUS_INVESTIGATING": 2, "INVESTIGATED_HYPOTHESIS_STATUS_PROVEN": 3, "INVESTIGATED_HYPOTHESIS_STATUS_DISPROVEN": 4, "INVESTIGATED_HYPOTHESIS_STATUS_SKIPPED": 5, } ) func (x HypothesisStatus) Enum() *HypothesisStatus { p := new(HypothesisStatus) *p = x return p } func (x HypothesisStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (HypothesisStatus) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[2].Descriptor() } func (HypothesisStatus) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[2] } func (x HypothesisStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use HypothesisStatus.Descriptor instead. func (HypothesisStatus) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{2} } type ChangeTimelineEntryStatus int32 const ( // This should never be used, it is the default value ChangeTimelineEntryStatus_UNSPECIFIED ChangeTimelineEntryStatus = 0 // This step has not yet started ChangeTimelineEntryStatus_PENDING ChangeTimelineEntryStatus = 1 // This step is currently happening ChangeTimelineEntryStatus_IN_PROGRESS ChangeTimelineEntryStatus = 2 // The step is completed ChangeTimelineEntryStatus_DONE ChangeTimelineEntryStatus = 3 // The step has an error and cannot be completed ChangeTimelineEntryStatus_ERROR ChangeTimelineEntryStatus = 4 ) // Enum value maps for ChangeTimelineEntryStatus. var ( ChangeTimelineEntryStatus_name = map[int32]string{ 0: "UNSPECIFIED", 1: "PENDING", 2: "IN_PROGRESS", 3: "DONE", 4: "ERROR", } ChangeTimelineEntryStatus_value = map[string]int32{ "UNSPECIFIED": 0, "PENDING": 1, "IN_PROGRESS": 2, "DONE": 3, "ERROR": 4, } ) func (x ChangeTimelineEntryStatus) Enum() *ChangeTimelineEntryStatus { p := new(ChangeTimelineEntryStatus) *p = x return p } func (x ChangeTimelineEntryStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (ChangeTimelineEntryStatus) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[3].Descriptor() } func (ChangeTimelineEntryStatus) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[3] } func (x ChangeTimelineEntryStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use ChangeTimelineEntryStatus.Descriptor instead. func (ChangeTimelineEntryStatus) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{3} } type ItemDiffStatus int32 const ( ItemDiffStatus_ITEM_DIFF_STATUS_UNSPECIFIED ItemDiffStatus = 0 ItemDiffStatus_ITEM_DIFF_STATUS_UNCHANGED ItemDiffStatus = 1 ItemDiffStatus_ITEM_DIFF_STATUS_CREATED ItemDiffStatus = 2 ItemDiffStatus_ITEM_DIFF_STATUS_UPDATED ItemDiffStatus = 3 ItemDiffStatus_ITEM_DIFF_STATUS_DELETED ItemDiffStatus = 4 ItemDiffStatus_ITEM_DIFF_STATUS_REPLACED ItemDiffStatus = 5 ) // Enum value maps for ItemDiffStatus. var ( ItemDiffStatus_name = map[int32]string{ 0: "ITEM_DIFF_STATUS_UNSPECIFIED", 1: "ITEM_DIFF_STATUS_UNCHANGED", 2: "ITEM_DIFF_STATUS_CREATED", 3: "ITEM_DIFF_STATUS_UPDATED", 4: "ITEM_DIFF_STATUS_DELETED", 5: "ITEM_DIFF_STATUS_REPLACED", } ItemDiffStatus_value = map[string]int32{ "ITEM_DIFF_STATUS_UNSPECIFIED": 0, "ITEM_DIFF_STATUS_UNCHANGED": 1, "ITEM_DIFF_STATUS_CREATED": 2, "ITEM_DIFF_STATUS_UPDATED": 3, "ITEM_DIFF_STATUS_DELETED": 4, "ITEM_DIFF_STATUS_REPLACED": 5, } ) func (x ItemDiffStatus) Enum() *ItemDiffStatus { p := new(ItemDiffStatus) *p = x return p } func (x ItemDiffStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (ItemDiffStatus) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[4].Descriptor() } func (ItemDiffStatus) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[4] } func (x ItemDiffStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use ItemDiffStatus.Descriptor instead. func (ItemDiffStatus) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{4} } type ChangeOutputFormat int32 const ( ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_UNSPECIFIED ChangeOutputFormat = 0 ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_JSON ChangeOutputFormat = 1 ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_MARKDOWN ChangeOutputFormat = 2 ) // Enum value maps for ChangeOutputFormat. var ( ChangeOutputFormat_name = map[int32]string{ 0: "CHANGE_OUTPUT_FORMAT_UNSPECIFIED", 1: "CHANGE_OUTPUT_FORMAT_JSON", 2: "CHANGE_OUTPUT_FORMAT_MARKDOWN", } ChangeOutputFormat_value = map[string]int32{ "CHANGE_OUTPUT_FORMAT_UNSPECIFIED": 0, "CHANGE_OUTPUT_FORMAT_JSON": 1, "CHANGE_OUTPUT_FORMAT_MARKDOWN": 2, } ) func (x ChangeOutputFormat) Enum() *ChangeOutputFormat { p := new(ChangeOutputFormat) *p = x return p } func (x ChangeOutputFormat) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (ChangeOutputFormat) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[5].Descriptor() } func (ChangeOutputFormat) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[5] } func (x ChangeOutputFormat) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use ChangeOutputFormat.Descriptor instead. func (ChangeOutputFormat) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{5} } type LabelType int32 const ( LabelType_LABEL_TYPE_UNSPECIFIED LabelType = 0 LabelType_LABEL_TYPE_AUTO LabelType = 1 LabelType_LABEL_TYPE_USER LabelType = 2 ) // Enum value maps for LabelType. var ( LabelType_name = map[int32]string{ 0: "LABEL_TYPE_UNSPECIFIED", 1: "LABEL_TYPE_AUTO", 2: "LABEL_TYPE_USER", } LabelType_value = map[string]int32{ "LABEL_TYPE_UNSPECIFIED": 0, "LABEL_TYPE_AUTO": 1, "LABEL_TYPE_USER": 2, } ) func (x LabelType) Enum() *LabelType { p := new(LabelType) *p = x return p } func (x LabelType) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (LabelType) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[6].Descriptor() } func (LabelType) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[6] } func (x LabelType) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use LabelType.Descriptor instead. func (LabelType) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{6} } type ChangeStatus int32 const ( // Reserved for truly unspecified states. Should not be used for newly created changes. ChangeStatus_CHANGE_STATUS_UNSPECIFIED ChangeStatus = 0 // The change has been created and is ready for change analysis to be started. // Or change analysis is in progress. // Or change analysis is complete and the change is ready to be started. ChangeStatus_CHANGE_STATUS_DEFINING ChangeStatus = 1 // The change is in progress or deployment is in progress. The change can be ended using the `EndChange` // RPC. ChangeStatus_CHANGE_STATUS_HAPPENING ChangeStatus = 2 // DEPRECATED: This status is no longer used and should not be used in new code. // It will be removed in a future version. Use other appropriate status values instead. // Deprecated as part of https://linear.app/overmind/issue/ENG-1520/change-status-processing-is-no-longer-used-in-a-meaningful-way // // Deprecated: Marked as deprecated in changes.proto. ChangeStatus_CHANGE_STATUS_PROCESSING ChangeStatus = 3 // The change has been ended and the results have been processed. ChangeStatus_CHANGE_STATUS_DONE ChangeStatus = 4 ) // Enum value maps for ChangeStatus. var ( ChangeStatus_name = map[int32]string{ 0: "CHANGE_STATUS_UNSPECIFIED", 1: "CHANGE_STATUS_DEFINING", 2: "CHANGE_STATUS_HAPPENING", 3: "CHANGE_STATUS_PROCESSING", 4: "CHANGE_STATUS_DONE", } ChangeStatus_value = map[string]int32{ "CHANGE_STATUS_UNSPECIFIED": 0, "CHANGE_STATUS_DEFINING": 1, "CHANGE_STATUS_HAPPENING": 2, "CHANGE_STATUS_PROCESSING": 3, "CHANGE_STATUS_DONE": 4, } ) func (x ChangeStatus) Enum() *ChangeStatus { p := new(ChangeStatus) *p = x return p } func (x ChangeStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (ChangeStatus) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[7].Descriptor() } func (ChangeStatus) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[7] } func (x ChangeStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use ChangeStatus.Descriptor instead. func (ChangeStatus) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{7} } // Risk feedback sentiment values type RiskFeedbackSentiment int32 const ( RiskFeedbackSentiment_RISK_FEEDBACK_SENTIMENT_UNSPECIFIED RiskFeedbackSentiment = 0 RiskFeedbackSentiment_RISK_FEEDBACK_SENTIMENT_POSITIVE RiskFeedbackSentiment = 1 RiskFeedbackSentiment_RISK_FEEDBACK_SENTIMENT_NEGATIVE RiskFeedbackSentiment = 2 ) // Enum value maps for RiskFeedbackSentiment. var ( RiskFeedbackSentiment_name = map[int32]string{ 0: "RISK_FEEDBACK_SENTIMENT_UNSPECIFIED", 1: "RISK_FEEDBACK_SENTIMENT_POSITIVE", 2: "RISK_FEEDBACK_SENTIMENT_NEGATIVE", } RiskFeedbackSentiment_value = map[string]int32{ "RISK_FEEDBACK_SENTIMENT_UNSPECIFIED": 0, "RISK_FEEDBACK_SENTIMENT_POSITIVE": 1, "RISK_FEEDBACK_SENTIMENT_NEGATIVE": 2, } ) func (x RiskFeedbackSentiment) Enum() *RiskFeedbackSentiment { p := new(RiskFeedbackSentiment) *p = x return p } func (x RiskFeedbackSentiment) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (RiskFeedbackSentiment) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[8].Descriptor() } func (RiskFeedbackSentiment) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[8] } func (x RiskFeedbackSentiment) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use RiskFeedbackSentiment.Descriptor instead. func (RiskFeedbackSentiment) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{8} } type StartChangeResponse_State int32 const ( // No state has been specified StartChangeResponse_STATE_UNSPECIFIED StartChangeResponse_State = 0 // Snapshot is being taken StartChangeResponse_STATE_TAKING_SNAPSHOT StartChangeResponse_State = 1 // Snapshot is being saved StartChangeResponse_STATE_SAVING_SNAPSHOT StartChangeResponse_State = 2 // Everything is complete StartChangeResponse_STATE_DONE StartChangeResponse_State = 3 ) // Enum value maps for StartChangeResponse_State. var ( StartChangeResponse_State_name = map[int32]string{ 0: "STATE_UNSPECIFIED", 1: "STATE_TAKING_SNAPSHOT", 2: "STATE_SAVING_SNAPSHOT", 3: "STATE_DONE", } StartChangeResponse_State_value = map[string]int32{ "STATE_UNSPECIFIED": 0, "STATE_TAKING_SNAPSHOT": 1, "STATE_SAVING_SNAPSHOT": 2, "STATE_DONE": 3, } ) func (x StartChangeResponse_State) Enum() *StartChangeResponse_State { p := new(StartChangeResponse_State) *p = x return p } func (x StartChangeResponse_State) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (StartChangeResponse_State) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[9].Descriptor() } func (StartChangeResponse_State) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[9] } func (x StartChangeResponse_State) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use StartChangeResponse_State.Descriptor instead. func (StartChangeResponse_State) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{88, 0} } type EndChangeResponse_State int32 const ( // No state has been specified EndChangeResponse_STATE_UNSPECIFIED EndChangeResponse_State = 0 // Snapshot is being taken EndChangeResponse_STATE_TAKING_SNAPSHOT EndChangeResponse_State = 1 // Snapshot is being saved EndChangeResponse_STATE_SAVING_SNAPSHOT EndChangeResponse_State = 2 // Everything is complete EndChangeResponse_STATE_DONE EndChangeResponse_State = 3 ) // Enum value maps for EndChangeResponse_State. var ( EndChangeResponse_State_name = map[int32]string{ 0: "STATE_UNSPECIFIED", 1: "STATE_TAKING_SNAPSHOT", 2: "STATE_SAVING_SNAPSHOT", 3: "STATE_DONE", } EndChangeResponse_State_value = map[string]int32{ "STATE_UNSPECIFIED": 0, "STATE_TAKING_SNAPSHOT": 1, "STATE_SAVING_SNAPSHOT": 2, "STATE_DONE": 3, } ) func (x EndChangeResponse_State) Enum() *EndChangeResponse_State { p := new(EndChangeResponse_State) *p = x return p } func (x EndChangeResponse_State) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (EndChangeResponse_State) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[10].Descriptor() } func (EndChangeResponse_State) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[10] } func (x EndChangeResponse_State) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use EndChangeResponse_State.Descriptor instead. func (EndChangeResponse_State) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{90, 0} } type Risk_Severity int32 const ( Risk_SEVERITY_UNSPECIFIED Risk_Severity = 0 Risk_SEVERITY_LOW Risk_Severity = 1 Risk_SEVERITY_MEDIUM Risk_Severity = 2 Risk_SEVERITY_HIGH Risk_Severity = 3 ) // Enum value maps for Risk_Severity. var ( Risk_Severity_name = map[int32]string{ 0: "SEVERITY_UNSPECIFIED", 1: "SEVERITY_LOW", 2: "SEVERITY_MEDIUM", 3: "SEVERITY_HIGH", } Risk_Severity_value = map[string]int32{ "SEVERITY_UNSPECIFIED": 0, "SEVERITY_LOW": 1, "SEVERITY_MEDIUM": 2, "SEVERITY_HIGH": 3, } ) func (x Risk_Severity) Enum() *Risk_Severity { p := new(Risk_Severity) *p = x return p } func (x Risk_Severity) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (Risk_Severity) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[11].Descriptor() } func (Risk_Severity) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[11] } func (x Risk_Severity) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use Risk_Severity.Descriptor instead. func (Risk_Severity) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{93, 0} } type ChangeAnalysisStatus_Status int32 const ( ChangeAnalysisStatus_STATUS_UNSPECIFIED ChangeAnalysisStatus_Status = 0 ChangeAnalysisStatus_STATUS_INPROGRESS ChangeAnalysisStatus_Status = 1 ChangeAnalysisStatus_STATUS_SKIPPED ChangeAnalysisStatus_Status = 2 ChangeAnalysisStatus_STATUS_DONE ChangeAnalysisStatus_Status = 3 ChangeAnalysisStatus_STATUS_ERROR ChangeAnalysisStatus_Status = 4 ) // Enum value maps for ChangeAnalysisStatus_Status. var ( ChangeAnalysisStatus_Status_name = map[int32]string{ 0: "STATUS_UNSPECIFIED", 1: "STATUS_INPROGRESS", 2: "STATUS_SKIPPED", 3: "STATUS_DONE", 4: "STATUS_ERROR", } ChangeAnalysisStatus_Status_value = map[string]int32{ "STATUS_UNSPECIFIED": 0, "STATUS_INPROGRESS": 1, "STATUS_SKIPPED": 2, "STATUS_DONE": 3, "STATUS_ERROR": 4, } ) func (x ChangeAnalysisStatus_Status) Enum() *ChangeAnalysisStatus_Status { p := new(ChangeAnalysisStatus_Status) *p = x return p } func (x ChangeAnalysisStatus_Status) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (ChangeAnalysisStatus_Status) Descriptor() protoreflect.EnumDescriptor { return file_changes_proto_enumTypes[12].Descriptor() } func (ChangeAnalysisStatus_Status) Type() protoreflect.EnumType { return &file_changes_proto_enumTypes[12] } func (x ChangeAnalysisStatus_Status) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use ChangeAnalysisStatus_Status.Descriptor instead. func (ChangeAnalysisStatus_Status) EnumDescriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{94, 0} } type LabelRule struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *LabelRuleMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Properties *LabelRuleProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LabelRule) Reset() { *x = LabelRule{} mi := &file_changes_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LabelRule) String() string { return protoimpl.X.MessageStringOf(x) } func (*LabelRule) ProtoMessage() {} func (x *LabelRule) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LabelRule.ProtoReflect.Descriptor instead. func (*LabelRule) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{0} } func (x *LabelRule) GetMetadata() *LabelRuleMetadata { if x != nil { return x.Metadata } return nil } func (x *LabelRule) GetProperties() *LabelRuleProperties { if x != nil { return x.Properties } return nil } type LabelRuleMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // The unique identifier for this rule, it is required LabelRuleUUID []byte `protobuf:"bytes,1,opt,name=LabelRuleUUID,proto3" json:"LabelRuleUUID,omitempty"` // The time that this rule was created, set to the current time when the rule is created CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=createdAt,proto3" json:"createdAt,omitempty"` // The time the rule was last updated, set to the current time when the rule is created UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=updatedAt,proto3" json:"updatedAt,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LabelRuleMetadata) Reset() { *x = LabelRuleMetadata{} mi := &file_changes_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LabelRuleMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*LabelRuleMetadata) ProtoMessage() {} func (x *LabelRuleMetadata) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LabelRuleMetadata.ProtoReflect.Descriptor instead. func (*LabelRuleMetadata) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{1} } func (x *LabelRuleMetadata) GetLabelRuleUUID() []byte { if x != nil { return x.LabelRuleUUID } return nil } func (x *LabelRuleMetadata) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt } return nil } func (x *LabelRuleMetadata) GetUpdatedAt() *timestamppb.Timestamp { if x != nil { return x.UpdatedAt } return nil } type LabelRuleProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the rule, friendly for users, it is required Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // The colour of the label, it is required Colour string `protobuf:"bytes,2,opt,name=colour,proto3" json:"colour,omitempty"` // The instructions for the rule, this is the logic that will be used to determine if the label should be applied to a change, it is required Instructions string `protobuf:"bytes,3,opt,name=instructions,proto3" json:"instructions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LabelRuleProperties) Reset() { *x = LabelRuleProperties{} mi := &file_changes_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LabelRuleProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*LabelRuleProperties) ProtoMessage() {} func (x *LabelRuleProperties) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LabelRuleProperties.ProtoReflect.Descriptor instead. func (*LabelRuleProperties) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{2} } func (x *LabelRuleProperties) GetName() string { if x != nil { return x.Name } return "" } func (x *LabelRuleProperties) GetColour() string { if x != nil { return x.Colour } return "" } func (x *LabelRuleProperties) GetInstructions() string { if x != nil { return x.Instructions } return "" } type ListLabelRulesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListLabelRulesRequest) Reset() { *x = ListLabelRulesRequest{} mi := &file_changes_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListLabelRulesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListLabelRulesRequest) ProtoMessage() {} func (x *ListLabelRulesRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListLabelRulesRequest.ProtoReflect.Descriptor instead. func (*ListLabelRulesRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{3} } type ListLabelRulesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Rules []*LabelRule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListLabelRulesResponse) Reset() { *x = ListLabelRulesResponse{} mi := &file_changes_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListLabelRulesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListLabelRulesResponse) ProtoMessage() {} func (x *ListLabelRulesResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListLabelRulesResponse.ProtoReflect.Descriptor instead. func (*ListLabelRulesResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{4} } func (x *ListLabelRulesResponse) GetRules() []*LabelRule { if x != nil { return x.Rules } return nil } type CreateLabelRuleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Properties *LabelRuleProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateLabelRuleRequest) Reset() { *x = CreateLabelRuleRequest{} mi := &file_changes_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateLabelRuleRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateLabelRuleRequest) ProtoMessage() {} func (x *CreateLabelRuleRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateLabelRuleRequest.ProtoReflect.Descriptor instead. func (*CreateLabelRuleRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{5} } func (x *CreateLabelRuleRequest) GetProperties() *LabelRuleProperties { if x != nil { return x.Properties } return nil } type CreateLabelRuleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Rule *LabelRule `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateLabelRuleResponse) Reset() { *x = CreateLabelRuleResponse{} mi := &file_changes_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateLabelRuleResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateLabelRuleResponse) ProtoMessage() {} func (x *CreateLabelRuleResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateLabelRuleResponse.ProtoReflect.Descriptor instead. func (*CreateLabelRuleResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{6} } func (x *CreateLabelRuleResponse) GetRule() *LabelRule { if x != nil { return x.Rule } return nil } type GetLabelRuleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetLabelRuleRequest) Reset() { *x = GetLabelRuleRequest{} mi := &file_changes_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetLabelRuleRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetLabelRuleRequest) ProtoMessage() {} func (x *GetLabelRuleRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetLabelRuleRequest.ProtoReflect.Descriptor instead. func (*GetLabelRuleRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{7} } func (x *GetLabelRuleRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type GetLabelRuleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Rule *LabelRule `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetLabelRuleResponse) Reset() { *x = GetLabelRuleResponse{} mi := &file_changes_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetLabelRuleResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetLabelRuleResponse) ProtoMessage() {} func (x *GetLabelRuleResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetLabelRuleResponse.ProtoReflect.Descriptor instead. func (*GetLabelRuleResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{8} } func (x *GetLabelRuleResponse) GetRule() *LabelRule { if x != nil { return x.Rule } return nil } type UpdateLabelRuleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` Properties *LabelRuleProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateLabelRuleRequest) Reset() { *x = UpdateLabelRuleRequest{} mi := &file_changes_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateLabelRuleRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateLabelRuleRequest) ProtoMessage() {} func (x *UpdateLabelRuleRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateLabelRuleRequest.ProtoReflect.Descriptor instead. func (*UpdateLabelRuleRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{9} } func (x *UpdateLabelRuleRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *UpdateLabelRuleRequest) GetProperties() *LabelRuleProperties { if x != nil { return x.Properties } return nil } type UpdateLabelRuleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Rule *LabelRule `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateLabelRuleResponse) Reset() { *x = UpdateLabelRuleResponse{} mi := &file_changes_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateLabelRuleResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateLabelRuleResponse) ProtoMessage() {} func (x *UpdateLabelRuleResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateLabelRuleResponse.ProtoReflect.Descriptor instead. func (*UpdateLabelRuleResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{10} } func (x *UpdateLabelRuleResponse) GetRule() *LabelRule { if x != nil { return x.Rule } return nil } type DeleteLabelRuleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteLabelRuleRequest) Reset() { *x = DeleteLabelRuleRequest{} mi := &file_changes_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteLabelRuleRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteLabelRuleRequest) ProtoMessage() {} func (x *DeleteLabelRuleRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteLabelRuleRequest.ProtoReflect.Descriptor instead. func (*DeleteLabelRuleRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{11} } func (x *DeleteLabelRuleRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type DeleteLabelRuleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteLabelRuleResponse) Reset() { *x = DeleteLabelRuleResponse{} mi := &file_changes_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteLabelRuleResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteLabelRuleResponse) ProtoMessage() {} func (x *DeleteLabelRuleResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteLabelRuleResponse.ProtoReflect.Descriptor instead. func (*DeleteLabelRuleResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{12} } type TestLabelRuleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Properties *LabelRuleProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` ChangeUUID [][]byte `protobuf:"bytes,2,rep,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TestLabelRuleRequest) Reset() { *x = TestLabelRuleRequest{} mi := &file_changes_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *TestLabelRuleRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*TestLabelRuleRequest) ProtoMessage() {} func (x *TestLabelRuleRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TestLabelRuleRequest.ProtoReflect.Descriptor instead. func (*TestLabelRuleRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{13} } func (x *TestLabelRuleRequest) GetProperties() *LabelRuleProperties { if x != nil { return x.Properties } return nil } func (x *TestLabelRuleRequest) GetChangeUUID() [][]byte { if x != nil { return x.ChangeUUID } return nil } type TestLabelRuleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` Applied bool `protobuf:"varint,2,opt,name=applied,proto3" json:"applied,omitempty"` Label *Label `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TestLabelRuleResponse) Reset() { *x = TestLabelRuleResponse{} mi := &file_changes_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *TestLabelRuleResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*TestLabelRuleResponse) ProtoMessage() {} func (x *TestLabelRuleResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TestLabelRuleResponse.ProtoReflect.Descriptor instead. func (*TestLabelRuleResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{14} } func (x *TestLabelRuleResponse) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } func (x *TestLabelRuleResponse) GetApplied() bool { if x != nil { return x.Applied } return false } func (x *TestLabelRuleResponse) GetLabel() *Label { if x != nil { return x.Label } return nil } type ReapplyLabelRuleInTimeRangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` StartAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=startAt,proto3" json:"startAt,omitempty"` EndAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=endAt,proto3" json:"endAt,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ReapplyLabelRuleInTimeRangeRequest) Reset() { *x = ReapplyLabelRuleInTimeRangeRequest{} mi := &file_changes_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ReapplyLabelRuleInTimeRangeRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ReapplyLabelRuleInTimeRangeRequest) ProtoMessage() {} func (x *ReapplyLabelRuleInTimeRangeRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ReapplyLabelRuleInTimeRangeRequest.ProtoReflect.Descriptor instead. func (*ReapplyLabelRuleInTimeRangeRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{15} } func (x *ReapplyLabelRuleInTimeRangeRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *ReapplyLabelRuleInTimeRangeRequest) GetStartAt() *timestamppb.Timestamp { if x != nil { return x.StartAt } return nil } func (x *ReapplyLabelRuleInTimeRangeRequest) GetEndAt() *timestamppb.Timestamp { if x != nil { return x.EndAt } return nil } type ReapplyLabelRuleInTimeRangeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID [][]byte `protobuf:"bytes,1,rep,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ReapplyLabelRuleInTimeRangeResponse) Reset() { *x = ReapplyLabelRuleInTimeRangeResponse{} mi := &file_changes_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ReapplyLabelRuleInTimeRangeResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ReapplyLabelRuleInTimeRangeResponse) ProtoMessage() {} func (x *ReapplyLabelRuleInTimeRangeResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ReapplyLabelRuleInTimeRangeResponse.ProtoReflect.Descriptor instead. func (*ReapplyLabelRuleInTimeRangeResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{16} } func (x *ReapplyLabelRuleInTimeRangeResponse) GetChangeUUID() [][]byte { if x != nil { return x.ChangeUUID } return nil } type KnowledgeReference struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` FileName string `protobuf:"bytes,2,opt,name=fileName,proto3" json:"fileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *KnowledgeReference) Reset() { *x = KnowledgeReference{} mi := &file_changes_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *KnowledgeReference) String() string { return protoimpl.X.MessageStringOf(x) } func (*KnowledgeReference) ProtoMessage() {} func (x *KnowledgeReference) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use KnowledgeReference.ProtoReflect.Descriptor instead. func (*KnowledgeReference) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{17} } func (x *KnowledgeReference) GetName() string { if x != nil { return x.Name } return "" } func (x *KnowledgeReference) GetFileName() string { if x != nil { return x.FileName } return "" } type Knowledge struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` FileName string `protobuf:"bytes,4,opt,name=fileName,proto3" json:"fileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Knowledge) Reset() { *x = Knowledge{} mi := &file_changes_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Knowledge) String() string { return protoimpl.X.MessageStringOf(x) } func (*Knowledge) ProtoMessage() {} func (x *Knowledge) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Knowledge.ProtoReflect.Descriptor instead. func (*Knowledge) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{18} } func (x *Knowledge) GetName() string { if x != nil { return x.Name } return "" } func (x *Knowledge) GetDescription() string { if x != nil { return x.Description } return "" } func (x *Knowledge) GetContent() string { if x != nil { return x.Content } return "" } func (x *Knowledge) GetFileName() string { if x != nil { return x.FileName } return "" } type GetHypothesesDetailsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetHypothesesDetailsRequest) Reset() { *x = GetHypothesesDetailsRequest{} mi := &file_changes_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetHypothesesDetailsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetHypothesesDetailsRequest) ProtoMessage() {} func (x *GetHypothesesDetailsRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetHypothesesDetailsRequest.ProtoReflect.Descriptor instead. func (*GetHypothesesDetailsRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{19} } func (x *GetHypothesesDetailsRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type GetHypothesesDetailsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Hypotheses []*HypothesesDetails `protobuf:"bytes,1,rep,name=hypotheses,proto3" json:"hypotheses,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetHypothesesDetailsResponse) Reset() { *x = GetHypothesesDetailsResponse{} mi := &file_changes_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetHypothesesDetailsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetHypothesesDetailsResponse) ProtoMessage() {} func (x *GetHypothesesDetailsResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetHypothesesDetailsResponse.ProtoReflect.Descriptor instead. func (*GetHypothesesDetailsResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{20} } func (x *GetHypothesesDetailsResponse) GetHypotheses() []*HypothesesDetails { if x != nil { return x.Hypotheses } return nil } type HypothesesDetails struct { state protoimpl.MessageState `protogen:"open.v1"` // The title of the hypothesis Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` // The number of observations that were combined to form the hypothesis NumObservations uint32 `protobuf:"varint,2,opt,name=numObservations,proto3" json:"numObservations,omitempty"` // The detail of the hypothesis Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` // The status of the hypothesis Status HypothesisStatus `protobuf:"varint,4,opt,name=status,proto3,enum=changes.HypothesisStatus" json:"status,omitempty"` // The results of the investigation of the hypothesis InvestigationResults string `protobuf:"bytes,5,opt,name=investigationResults,proto3" json:"investigationResults,omitempty"` // Knowledge used when investigating this hypothesis KnowledgeUsed []*KnowledgeReference `protobuf:"bytes,6,rep,name=knowledgeUsed,proto3" json:"knowledgeUsed,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HypothesesDetails) Reset() { *x = HypothesesDetails{} mi := &file_changes_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HypothesesDetails) String() string { return protoimpl.X.MessageStringOf(x) } func (*HypothesesDetails) ProtoMessage() {} func (x *HypothesesDetails) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HypothesesDetails.ProtoReflect.Descriptor instead. func (*HypothesesDetails) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{21} } func (x *HypothesesDetails) GetTitle() string { if x != nil { return x.Title } return "" } func (x *HypothesesDetails) GetNumObservations() uint32 { if x != nil { return x.NumObservations } return 0 } func (x *HypothesesDetails) GetDetail() string { if x != nil { return x.Detail } return "" } func (x *HypothesesDetails) GetStatus() HypothesisStatus { if x != nil { return x.Status } return HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_UNSPECIFIED } func (x *HypothesesDetails) GetInvestigationResults() string { if x != nil { return x.InvestigationResults } return "" } func (x *HypothesesDetails) GetKnowledgeUsed() []*KnowledgeReference { if x != nil { return x.KnowledgeUsed } return nil } type GetChangeTimelineV2Request struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeTimelineV2Request) Reset() { *x = GetChangeTimelineV2Request{} mi := &file_changes_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeTimelineV2Request) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeTimelineV2Request) ProtoMessage() {} func (x *GetChangeTimelineV2Request) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeTimelineV2Request.ProtoReflect.Descriptor instead. func (*GetChangeTimelineV2Request) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{22} } func (x *GetChangeTimelineV2Request) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type GetChangeTimelineV2Response struct { state protoimpl.MessageState `protogen:"open.v1"` // The entries of this timeline, in chronological order (oldest first) Entries []*ChangeTimelineEntryV2 `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeTimelineV2Response) Reset() { *x = GetChangeTimelineV2Response{} mi := &file_changes_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeTimelineV2Response) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeTimelineV2Response) ProtoMessage() {} func (x *GetChangeTimelineV2Response) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeTimelineV2Response.ProtoReflect.Descriptor instead. func (*GetChangeTimelineV2Response) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{23} } func (x *GetChangeTimelineV2Response) GetEntries() []*ChangeTimelineEntryV2 { if x != nil { return x.Entries } return nil } // Contains all the information about a step in the Change Analysis workflow // to show the user the historical, current and future of this Change. // to show the user the historical, current and future. type ChangeTimelineEntryV2 struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of this step, this will be shown to the user Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // The little icon that will be shown for the timeline entry to indicate // it's status Status ChangeTimelineEntryStatus `protobuf:"varint,2,opt,name=status,proto3,enum=changes.ChangeTimelineEntryStatus" json:"status,omitempty"` // The time that this step started, this will be used to calculate the // duration this step. If `startedAt` is set, but `endedAt` is not, then the // step is currently in progress. If `startedAt` is not set, the step is still // pending. StartedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=startedAt,proto3,oneof" json:"startedAt,omitempty"` // When this step ended, this allows us to calculate how long it took, and // allows users to see when certain things happened when looking back. If // `endedAt` is set but `startedAt` is not, or `endedAt` is before `startedAt` // then the step will be considered done, but we will be unable to calculate // the duration. If `startedAt` and `endedAt` are the same timestamp, or only // `endedAt` is populated, this entry does not have a duration, but should be // interpreted as a point-in-time event. EndedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=endedAt,proto3,oneof" json:"endedAt,omitempty"` // Who performed this step, this will be shown to the user Actor *string `protobuf:"bytes,5,opt,name=actor,proto3,oneof" json:"actor,omitempty"` // The actual content of this step. This will be displayed to the user // within the timeline and rendered differently depending on the type // // Types that are valid to be assigned to Content: // // *ChangeTimelineEntryV2_MappedItems // *ChangeTimelineEntryV2_CalculatedBlastRadius // *ChangeTimelineEntryV2_CalculatedRisks // *ChangeTimelineEntryV2_Error // *ChangeTimelineEntryV2_StatusMessage // *ChangeTimelineEntryV2_Empty // *ChangeTimelineEntryV2_ChangeValidation // *ChangeTimelineEntryV2_CalculatedLabels // *ChangeTimelineEntryV2_FormHypotheses // *ChangeTimelineEntryV2_InvestigateHypotheses // *ChangeTimelineEntryV2_RecordObservations Content isChangeTimelineEntryV2_Content `protobuf_oneof:"content"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeTimelineEntryV2) Reset() { *x = ChangeTimelineEntryV2{} mi := &file_changes_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeTimelineEntryV2) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeTimelineEntryV2) ProtoMessage() {} func (x *ChangeTimelineEntryV2) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeTimelineEntryV2.ProtoReflect.Descriptor instead. func (*ChangeTimelineEntryV2) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{24} } func (x *ChangeTimelineEntryV2) GetName() string { if x != nil { return x.Name } return "" } func (x *ChangeTimelineEntryV2) GetStatus() ChangeTimelineEntryStatus { if x != nil { return x.Status } return ChangeTimelineEntryStatus_UNSPECIFIED } func (x *ChangeTimelineEntryV2) GetStartedAt() *timestamppb.Timestamp { if x != nil { return x.StartedAt } return nil } func (x *ChangeTimelineEntryV2) GetEndedAt() *timestamppb.Timestamp { if x != nil { return x.EndedAt } return nil } func (x *ChangeTimelineEntryV2) GetActor() string { if x != nil && x.Actor != nil { return *x.Actor } return "" } func (x *ChangeTimelineEntryV2) GetContent() isChangeTimelineEntryV2_Content { if x != nil { return x.Content } return nil } func (x *ChangeTimelineEntryV2) GetMappedItems() *MappedItemsTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_MappedItems); ok { return x.MappedItems } } return nil } func (x *ChangeTimelineEntryV2) GetCalculatedBlastRadius() *CalculatedBlastRadiusTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_CalculatedBlastRadius); ok { return x.CalculatedBlastRadius } } return nil } func (x *ChangeTimelineEntryV2) GetCalculatedRisks() *CalculatedRisksTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_CalculatedRisks); ok { return x.CalculatedRisks } } return nil } func (x *ChangeTimelineEntryV2) GetError() string { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_Error); ok { return x.Error } } return "" } func (x *ChangeTimelineEntryV2) GetStatusMessage() string { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_StatusMessage); ok { return x.StatusMessage } } return "" } func (x *ChangeTimelineEntryV2) GetEmpty() *EmptyContent { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_Empty); ok { return x.Empty } } return nil } func (x *ChangeTimelineEntryV2) GetChangeValidation() *ChangeValidationTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_ChangeValidation); ok { return x.ChangeValidation } } return nil } func (x *ChangeTimelineEntryV2) GetCalculatedLabels() *CalculatedLabelsTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_CalculatedLabels); ok { return x.CalculatedLabels } } return nil } func (x *ChangeTimelineEntryV2) GetFormHypotheses() *FormHypothesesTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_FormHypotheses); ok { return x.FormHypotheses } } return nil } func (x *ChangeTimelineEntryV2) GetInvestigateHypotheses() *InvestigateHypothesesTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_InvestigateHypotheses); ok { return x.InvestigateHypotheses } } return nil } func (x *ChangeTimelineEntryV2) GetRecordObservations() *RecordObservationsTimelineEntry { if x != nil { if x, ok := x.Content.(*ChangeTimelineEntryV2_RecordObservations); ok { return x.RecordObservations } } return nil } type isChangeTimelineEntryV2_Content interface { isChangeTimelineEntryV2_Content() } type ChangeTimelineEntryV2_MappedItems struct { // Shows the mapping results so that user can see why items didn't map // successfully MappedItems *MappedItemsTimelineEntry `protobuf:"bytes,7,opt,name=mappedItems,proto3,oneof"` } type ChangeTimelineEntryV2_CalculatedBlastRadius struct { // The number of items in the blast radius CalculatedBlastRadius *CalculatedBlastRadiusTimelineEntry `protobuf:"bytes,8,opt,name=calculatedBlastRadius,proto3,oneof"` } type ChangeTimelineEntryV2_CalculatedRisks struct { // The list of risks CalculatedRisks *CalculatedRisksTimelineEntry `protobuf:"bytes,9,opt,name=calculatedRisks,proto3,oneof"` } type ChangeTimelineEntryV2_Error struct { // An error that will be shown to the user Error string `protobuf:"bytes,11,opt,name=error,proto3,oneof"` } type ChangeTimelineEntryV2_StatusMessage struct { // A generic message that will be rendered as a paragraph StatusMessage string `protobuf:"bytes,12,opt,name=statusMessage,proto3,oneof"` } type ChangeTimelineEntryV2_Empty struct { // A message that will be shown to the user, but will not have any content // associated with it. Examples of this include "Change Created", "Change // Started" etc. Empty *EmptyContent `protobuf:"bytes,13,opt,name=empty,proto3,oneof"` } type ChangeTimelineEntryV2_ChangeValidation struct { // A list of validation steps that should be performed on the change ChangeValidation *ChangeValidationTimelineEntry `protobuf:"bytes,14,opt,name=changeValidation,proto3,oneof"` } type ChangeTimelineEntryV2_CalculatedLabels struct { // The list of labels that have been calculated for this change, or that were assigned by a user CalculatedLabels *CalculatedLabelsTimelineEntry `protobuf:"bytes,15,opt,name=calculatedLabels,proto3,oneof"` } type ChangeTimelineEntryV2_FormHypotheses struct { // The list of hypotheses that have been formed FormHypotheses *FormHypothesesTimelineEntry `protobuf:"bytes,16,opt,name=formHypotheses,proto3,oneof"` } type ChangeTimelineEntryV2_InvestigateHypotheses struct { // The list of hypotheses that have been investigated InvestigateHypotheses *InvestigateHypothesesTimelineEntry `protobuf:"bytes,17,opt,name=investigateHypotheses,proto3,oneof"` } type ChangeTimelineEntryV2_RecordObservations struct { // The number of observations that were found as part of calculating the blast // radius RecordObservations *RecordObservationsTimelineEntry `protobuf:"bytes,18,opt,name=recordObservations,proto3,oneof"` } func (*ChangeTimelineEntryV2_MappedItems) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_CalculatedBlastRadius) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_CalculatedRisks) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_Error) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_StatusMessage) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_Empty) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_ChangeValidation) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_CalculatedLabels) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_FormHypotheses) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_InvestigateHypotheses) isChangeTimelineEntryV2_Content() {} func (*ChangeTimelineEntryV2_RecordObservations) isChangeTimelineEntryV2_Content() {} // This is a message that can be used to signal that a step in the timeline // should be empty. This is useful for when we want to show a step in the // timeline, but there is no content to show type EmptyContent struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *EmptyContent) Reset() { *x = EmptyContent{} mi := &file_changes_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *EmptyContent) String() string { return protoimpl.X.MessageStringOf(x) } func (*EmptyContent) ProtoMessage() {} func (x *EmptyContent) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use EmptyContent.ProtoReflect.Descriptor instead. func (*EmptyContent) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{25} } // Per-item summary for timeline display - only what the UI needs type MappedItemTimelineSummary struct { state protoimpl.MessageState `protogen:"open.v1"` // The display name (unique attribute value) shown to the user DisplayName string `protobuf:"bytes,1,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` // The status of the mapping result Status MappedItemTimelineStatus `protobuf:"varint,2,opt,name=status,proto3,enum=changes.MappedItemTimelineStatus" json:"status,omitempty"` // Only populated when status == ERROR ErrorMessage *string `protobuf:"bytes,3,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MappedItemTimelineSummary) Reset() { *x = MappedItemTimelineSummary{} mi := &file_changes_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MappedItemTimelineSummary) String() string { return protoimpl.X.MessageStringOf(x) } func (*MappedItemTimelineSummary) ProtoMessage() {} func (x *MappedItemTimelineSummary) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MappedItemTimelineSummary.ProtoReflect.Descriptor instead. func (*MappedItemTimelineSummary) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{26} } func (x *MappedItemTimelineSummary) GetDisplayName() string { if x != nil { return x.DisplayName } return "" } func (x *MappedItemTimelineSummary) GetStatus() MappedItemTimelineStatus { if x != nil { return x.Status } return MappedItemTimelineStatus_MAPPED_ITEM_TIMELINE_STATUS_UNSPECIFIED } func (x *MappedItemTimelineSummary) GetErrorMessage() string { if x != nil && x.ErrorMessage != nil { return *x.ErrorMessage } return "" } type MappedItemsTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` // Deprecated: This field is for backwards compatibility with old change archives. // When unmarshaling old archives with field number 1, this will be populated. // The timeline is reconstructed from the database anyway, so this data is ignored. // // Deprecated: Marked as deprecated in changes.proto. MappedItems []*MappedItemDiff `protobuf:"bytes,1,rep,name=mappedItems,proto3" json:"mappedItems,omitempty"` // New simplified timeline summary - only what the UI needs Items []*MappedItemTimelineSummary `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MappedItemsTimelineEntry) Reset() { *x = MappedItemsTimelineEntry{} mi := &file_changes_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MappedItemsTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*MappedItemsTimelineEntry) ProtoMessage() {} func (x *MappedItemsTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MappedItemsTimelineEntry.ProtoReflect.Descriptor instead. func (*MappedItemsTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{27} } // Deprecated: Marked as deprecated in changes.proto. func (x *MappedItemsTimelineEntry) GetMappedItems() []*MappedItemDiff { if x != nil { return x.MappedItems } return nil } func (x *MappedItemsTimelineEntry) GetItems() []*MappedItemTimelineSummary { if x != nil { return x.Items } return nil } type CalculatedBlastRadiusTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` NumItems uint32 `protobuf:"varint,1,opt,name=numItems,proto3" json:"numItems,omitempty"` NumEdges uint32 `protobuf:"varint,2,opt,name=numEdges,proto3" json:"numEdges,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CalculatedBlastRadiusTimelineEntry) Reset() { *x = CalculatedBlastRadiusTimelineEntry{} mi := &file_changes_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CalculatedBlastRadiusTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*CalculatedBlastRadiusTimelineEntry) ProtoMessage() {} func (x *CalculatedBlastRadiusTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CalculatedBlastRadiusTimelineEntry.ProtoReflect.Descriptor instead. func (*CalculatedBlastRadiusTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{28} } func (x *CalculatedBlastRadiusTimelineEntry) GetNumItems() uint32 { if x != nil { return x.NumItems } return 0 } func (x *CalculatedBlastRadiusTimelineEntry) GetNumEdges() uint32 { if x != nil { return x.NumEdges } return 0 } type RecordObservationsTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of observations that were found as part of calculating the blast // radius NumObservations uint32 `protobuf:"varint,1,opt,name=numObservations,proto3" json:"numObservations,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RecordObservationsTimelineEntry) Reset() { *x = RecordObservationsTimelineEntry{} mi := &file_changes_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RecordObservationsTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*RecordObservationsTimelineEntry) ProtoMessage() {} func (x *RecordObservationsTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RecordObservationsTimelineEntry.ProtoReflect.Descriptor instead. func (*RecordObservationsTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{29} } func (x *RecordObservationsTimelineEntry) GetNumObservations() uint32 { if x != nil { return x.NumObservations } return 0 } // Timeline entry: Forming hypotheses by grouping observations type FormHypothesesTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` // Total number of hypotheses formed NumHypotheses uint32 `protobuf:"varint,1,opt,name=numHypotheses,proto3" json:"numHypotheses,omitempty"` // The current state of the hypotheses under investigation Hypotheses []*HypothesisSummary `protobuf:"bytes,2,rep,name=hypotheses,proto3" json:"hypotheses,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *FormHypothesesTimelineEntry) Reset() { *x = FormHypothesesTimelineEntry{} mi := &file_changes_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *FormHypothesesTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*FormHypothesesTimelineEntry) ProtoMessage() {} func (x *FormHypothesesTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use FormHypothesesTimelineEntry.ProtoReflect.Descriptor instead. func (*FormHypothesesTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{30} } func (x *FormHypothesesTimelineEntry) GetNumHypotheses() uint32 { if x != nil { return x.NumHypotheses } return 0 } func (x *FormHypothesesTimelineEntry) GetHypotheses() []*HypothesisSummary { if x != nil { return x.Hypotheses } return nil } type InvestigateHypothesesTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` // Number of hypotheses that became real risks NumProven uint32 `protobuf:"varint,1,opt,name=numProven,proto3" json:"numProven,omitempty"` // Number of hypotheses that were disproven (verified safe) NumDisproven uint32 `protobuf:"varint,2,opt,name=numDisproven,proto3" json:"numDisproven,omitempty"` // Number of hypotheses that are still being investigated NumInvestigating uint32 `protobuf:"varint,3,opt,name=numInvestigating,proto3" json:"numInvestigating,omitempty"` // The current state of the hypotheses under investigation Hypotheses []*HypothesisSummary `protobuf:"bytes,4,rep,name=hypotheses,proto3" json:"hypotheses,omitempty"` // Number of hypotheses that were skipped NumSkipped uint32 `protobuf:"varint,5,opt,name=numSkipped,proto3" json:"numSkipped,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *InvestigateHypothesesTimelineEntry) Reset() { *x = InvestigateHypothesesTimelineEntry{} mi := &file_changes_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *InvestigateHypothesesTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*InvestigateHypothesesTimelineEntry) ProtoMessage() {} func (x *InvestigateHypothesesTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use InvestigateHypothesesTimelineEntry.ProtoReflect.Descriptor instead. func (*InvestigateHypothesesTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{31} } func (x *InvestigateHypothesesTimelineEntry) GetNumProven() uint32 { if x != nil { return x.NumProven } return 0 } func (x *InvestigateHypothesesTimelineEntry) GetNumDisproven() uint32 { if x != nil { return x.NumDisproven } return 0 } func (x *InvestigateHypothesesTimelineEntry) GetNumInvestigating() uint32 { if x != nil { return x.NumInvestigating } return 0 } func (x *InvestigateHypothesesTimelineEntry) GetHypotheses() []*HypothesisSummary { if x != nil { return x.Hypotheses } return nil } func (x *InvestigateHypothesesTimelineEntry) GetNumSkipped() uint32 { if x != nil { return x.NumSkipped } return 0 } type HypothesisSummary struct { state protoimpl.MessageState `protogen:"open.v1"` // The status of the investigation Status HypothesisStatus `protobuf:"varint,1,opt,name=status,proto3,enum=changes.HypothesisStatus" json:"status,omitempty"` // The title of the hypothesis Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` // The current detail to show the user, While the hypothesis is being // investigated, this could be the description of the hypothesis itself. And // then once the investigation is finished, it could be the conclusion of the // investigation. So it could update. This will be limited to two or three // lines and truncated after that. Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HypothesisSummary) Reset() { *x = HypothesisSummary{} mi := &file_changes_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HypothesisSummary) String() string { return protoimpl.X.MessageStringOf(x) } func (*HypothesisSummary) ProtoMessage() {} func (x *HypothesisSummary) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HypothesisSummary.ProtoReflect.Descriptor instead. func (*HypothesisSummary) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{32} } func (x *HypothesisSummary) GetStatus() HypothesisStatus { if x != nil { return x.Status } return HypothesisStatus_INVESTIGATED_HYPOTHESIS_STATUS_UNSPECIFIED } func (x *HypothesisSummary) GetTitle() string { if x != nil { return x.Title } return "" } func (x *HypothesisSummary) GetDetail() string { if x != nil { return x.Detail } return "" } type CalculatedRisksTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` Risks []*Risk `protobuf:"bytes,1,rep,name=risks,proto3" json:"risks,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CalculatedRisksTimelineEntry) Reset() { *x = CalculatedRisksTimelineEntry{} mi := &file_changes_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CalculatedRisksTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*CalculatedRisksTimelineEntry) ProtoMessage() {} func (x *CalculatedRisksTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CalculatedRisksTimelineEntry.ProtoReflect.Descriptor instead. func (*CalculatedRisksTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{33} } func (x *CalculatedRisksTimelineEntry) GetRisks() []*Risk { if x != nil { return x.Risks } return nil } // The list of labels that have been calculated for this change, or that were assigned by a user type CalculatedLabelsTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` Labels []*Label `protobuf:"bytes,1,rep,name=labels,proto3" json:"labels,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CalculatedLabelsTimelineEntry) Reset() { *x = CalculatedLabelsTimelineEntry{} mi := &file_changes_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CalculatedLabelsTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*CalculatedLabelsTimelineEntry) ProtoMessage() {} func (x *CalculatedLabelsTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CalculatedLabelsTimelineEntry.ProtoReflect.Descriptor instead. func (*CalculatedLabelsTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{34} } func (x *CalculatedLabelsTimelineEntry) GetLabels() []*Label { if x != nil { return x.Labels } return nil } // The list of validation steps that are to be performed on the change type ChangeValidationTimelineEntry struct { state protoimpl.MessageState `protogen:"open.v1"` // "A concise overview of the proposed changes and their potential impact (2-3 sentences) BriefAnalysis string `protobuf:"bytes,1,opt,name=briefAnalysis,proto3" json:"briefAnalysis,omitempty"` // For the first stage of the change validation implementation we are only returning Validation Checklist Category ValidationChecklist []*ChangeValidationCategory `protobuf:"bytes,2,rep,name=validationChecklist,proto3" json:"validationChecklist,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeValidationTimelineEntry) Reset() { *x = ChangeValidationTimelineEntry{} mi := &file_changes_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeValidationTimelineEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeValidationTimelineEntry) ProtoMessage() {} func (x *ChangeValidationTimelineEntry) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeValidationTimelineEntry.ProtoReflect.Descriptor instead. func (*ChangeValidationTimelineEntry) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{35} } func (x *ChangeValidationTimelineEntry) GetBriefAnalysis() string { if x != nil { return x.BriefAnalysis } return "" } func (x *ChangeValidationTimelineEntry) GetValidationChecklist() []*ChangeValidationCategory { if x != nil { return x.ValidationChecklist } return nil } // Description with specific commands/API calls to execute (1-2 sentences).Add commentMore actions // - Include exact AWS CLI commands with parameters and resource IDs // - Focus on must-have verification steps only type ChangeValidationCategory struct { state protoimpl.MessageState `protogen:"open.v1"` // The category title (e.g., 'Security Validation', 'Configuration Verification') Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` // Description with specific AWS CLI commands/API calls to execute (1-2 sentences) Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeValidationCategory) Reset() { *x = ChangeValidationCategory{} mi := &file_changes_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeValidationCategory) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeValidationCategory) ProtoMessage() {} func (x *ChangeValidationCategory) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeValidationCategory.ProtoReflect.Descriptor instead. func (*ChangeValidationCategory) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{36} } func (x *ChangeValidationCategory) GetTitle() string { if x != nil { return x.Title } return "" } func (x *ChangeValidationCategory) GetDescription() string { if x != nil { return x.Description } return "" } type GetDiffRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetDiffRequest) Reset() { *x = GetDiffRequest{} mi := &file_changes_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetDiffRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetDiffRequest) ProtoMessage() {} func (x *GetDiffRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetDiffRequest.ProtoReflect.Descriptor instead. func (*GetDiffRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{37} } func (x *GetDiffRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type GetDiffResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Items that were planned to be changed, and were changed ExpectedItems []*ItemDiff `protobuf:"bytes,1,rep,name=expectedItems,proto3" json:"expectedItems,omitempty"` // Items that were changed, but were not planned to be changed UnexpectedItems []*ItemDiff `protobuf:"bytes,3,rep,name=unexpectedItems,proto3" json:"unexpectedItems,omitempty"` Edges []*Edge `protobuf:"bytes,2,rep,name=edges,proto3" json:"edges,omitempty"` // Items that were planned to be changed, but were not changed MissingItems []*ItemDiff `protobuf:"bytes,4,rep,name=missingItems,proto3" json:"missingItems,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetDiffResponse) Reset() { *x = GetDiffResponse{} mi := &file_changes_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetDiffResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetDiffResponse) ProtoMessage() {} func (x *GetDiffResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetDiffResponse.ProtoReflect.Descriptor instead. func (*GetDiffResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{38} } func (x *GetDiffResponse) GetExpectedItems() []*ItemDiff { if x != nil { return x.ExpectedItems } return nil } func (x *GetDiffResponse) GetUnexpectedItems() []*ItemDiff { if x != nil { return x.UnexpectedItems } return nil } func (x *GetDiffResponse) GetEdges() []*Edge { if x != nil { return x.Edges } return nil } func (x *GetDiffResponse) GetMissingItems() []*ItemDiff { if x != nil { return x.MissingItems } return nil } type ListChangingItemsSummaryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangingItemsSummaryRequest) Reset() { *x = ListChangingItemsSummaryRequest{} mi := &file_changes_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangingItemsSummaryRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangingItemsSummaryRequest) ProtoMessage() {} func (x *ListChangingItemsSummaryRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangingItemsSummaryRequest.ProtoReflect.Descriptor instead. func (*ListChangingItemsSummaryRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{39} } func (x *ListChangingItemsSummaryRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type ListChangingItemsSummaryResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Items []*ItemDiffSummary `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangingItemsSummaryResponse) Reset() { *x = ListChangingItemsSummaryResponse{} mi := &file_changes_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangingItemsSummaryResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangingItemsSummaryResponse) ProtoMessage() {} func (x *ListChangingItemsSummaryResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangingItemsSummaryResponse.ProtoReflect.Descriptor instead. func (*ListChangingItemsSummaryResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{40} } func (x *ListChangingItemsSummaryResponse) GetItems() []*ItemDiffSummary { if x != nil { return x.Items } return nil } type MappedItemDiff struct { state protoimpl.MessageState `protogen:"open.v1"` // The item that is changing and any known changes to it Item *ItemDiff `protobuf:"bytes,1,opt,name=item,proto3" json:"item,omitempty"` // a mapping query that can be used to find the item. this can be empty if the // submitter does not know how to map this item. MappingQuery *Query `protobuf:"bytes,2,opt,name=mappingQuery,proto3,oneof" json:"mappingQuery,omitempty"` // The error that was returned as part of the mapping process. This will be // empty if the mapping was successful. MappingError *QueryError `protobuf:"bytes,3,opt,name=mappingError,proto3,oneof" json:"mappingError,omitempty"` // Explicit status from CLI - when set, API uses this instead of inferring. // This allows CLI to distinguish between "unsupported resource type", // "pending creation (doesn't exist yet)", and "actual mapping error". MappingStatus *MappedItemMappingStatus `protobuf:"varint,4,opt,name=mapping_status,json=mappingStatus,proto3,enum=changes.MappedItemMappingStatus,oneof" json:"mapping_status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MappedItemDiff) Reset() { *x = MappedItemDiff{} mi := &file_changes_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MappedItemDiff) String() string { return protoimpl.X.MessageStringOf(x) } func (*MappedItemDiff) ProtoMessage() {} func (x *MappedItemDiff) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MappedItemDiff.ProtoReflect.Descriptor instead. func (*MappedItemDiff) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{41} } func (x *MappedItemDiff) GetItem() *ItemDiff { if x != nil { return x.Item } return nil } func (x *MappedItemDiff) GetMappingQuery() *Query { if x != nil { return x.MappingQuery } return nil } func (x *MappedItemDiff) GetMappingError() *QueryError { if x != nil { return x.MappingError } return nil } func (x *MappedItemDiff) GetMappingStatus() MappedItemMappingStatus { if x != nil && x.MappingStatus != nil { return *x.MappingStatus } return MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_UNSPECIFIED } // StartChangeAnalysisRequest is used to start the change analysis process. This // will calculate various things blast radius, risks, auto-tagging etc. This // it contains overrides for the auto-tagging rules and the blast radius config type StartChangeAnalysisRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The change to update ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` // The changing items ChangingItems []*MappedItemDiff `protobuf:"bytes,2,rep,name=changingItems,proto3" json:"changingItems,omitempty"` // Overrides the stored blast radius config for this change BlastRadiusConfigOverride *BlastRadiusConfig `protobuf:"bytes,3,opt,name=blastRadiusConfigOverride,proto3,oneof" json:"blastRadiusConfigOverride,omitempty"` // The routine config that should be used for this change. If this is empty // the routine config that has been configured in the UI will be used RoutineChangesConfigOverride *RoutineChangesConfig `protobuf:"bytes,5,opt,name=routineChangesConfigOverride,proto3,oneof" json:"routineChangesConfigOverride,omitempty"` // github organisation profile to use for this change GithubOrganisationProfileOverride *GithubOrganisationProfile `protobuf:"bytes,6,opt,name=githubOrganisationProfileOverride,proto3,oneof" json:"githubOrganisationProfileOverride,omitempty"` // Knowledge to be used for change analysis Knowledge []*Knowledge `protobuf:"bytes,7,rep,name=knowledge,proto3" json:"knowledge,omitempty"` // When true, the backend will attempt to post analysis results as a GitHub // PR comment via the installed GitHub App. Requires the account to have a // GitHub App installation with pull_requests:write permission. PostGithubComment bool `protobuf:"varint,8,opt,name=post_github_comment,json=postGithubComment,proto3" json:"post_github_comment,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartChangeAnalysisRequest) Reset() { *x = StartChangeAnalysisRequest{} mi := &file_changes_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StartChangeAnalysisRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*StartChangeAnalysisRequest) ProtoMessage() {} func (x *StartChangeAnalysisRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StartChangeAnalysisRequest.ProtoReflect.Descriptor instead. func (*StartChangeAnalysisRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{42} } func (x *StartChangeAnalysisRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } func (x *StartChangeAnalysisRequest) GetChangingItems() []*MappedItemDiff { if x != nil { return x.ChangingItems } return nil } func (x *StartChangeAnalysisRequest) GetBlastRadiusConfigOverride() *BlastRadiusConfig { if x != nil { return x.BlastRadiusConfigOverride } return nil } func (x *StartChangeAnalysisRequest) GetRoutineChangesConfigOverride() *RoutineChangesConfig { if x != nil { return x.RoutineChangesConfigOverride } return nil } func (x *StartChangeAnalysisRequest) GetGithubOrganisationProfileOverride() *GithubOrganisationProfile { if x != nil { return x.GithubOrganisationProfileOverride } return nil } func (x *StartChangeAnalysisRequest) GetKnowledge() []*Knowledge { if x != nil { return x.Knowledge } return nil } func (x *StartChangeAnalysisRequest) GetPostGithubComment() bool { if x != nil { return x.PostGithubComment } return false } // StartChangeAnalysisResponse is used to signal that the change analysis has been successfully started // we use HTTP response codes to signal errors type StartChangeAnalysisResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // True when the account has a GitHub App installation with sufficient // permissions to post PR comments. The CLI/Action can use this to decide // whether it needs to post its own comment. GithubAppActive bool `protobuf:"varint,1,opt,name=github_app_active,json=githubAppActive,proto3" json:"github_app_active,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartChangeAnalysisResponse) Reset() { *x = StartChangeAnalysisResponse{} mi := &file_changes_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StartChangeAnalysisResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*StartChangeAnalysisResponse) ProtoMessage() {} func (x *StartChangeAnalysisResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StartChangeAnalysisResponse.ProtoReflect.Descriptor instead. func (*StartChangeAnalysisResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{43} } func (x *StartChangeAnalysisResponse) GetGithubAppActive() bool { if x != nil { return x.GithubAppActive } return false } // AddPlannedChangesRequest appends a batch of planned changes to an existing // change without triggering analysis. Used by multi-plan workflows (e.g. // Atlantis parallel planning) where each plan step submits independently. type AddPlannedChangesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The change to append items to ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` // The planned change items to append ChangingItems []*MappedItemDiff `protobuf:"bytes,2,rep,name=changingItems,proto3" json:"changingItems,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AddPlannedChangesRequest) Reset() { *x = AddPlannedChangesRequest{} mi := &file_changes_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AddPlannedChangesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AddPlannedChangesRequest) ProtoMessage() {} func (x *AddPlannedChangesRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AddPlannedChangesRequest.ProtoReflect.Descriptor instead. func (*AddPlannedChangesRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{44} } func (x *AddPlannedChangesRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } func (x *AddPlannedChangesRequest) GetChangingItems() []*MappedItemDiff { if x != nil { return x.ChangingItems } return nil } // AddPlannedChangesResponse is intentionally empty; errors use ConnectRPC codes. type AddPlannedChangesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AddPlannedChangesResponse) Reset() { *x = AddPlannedChangesResponse{} mi := &file_changes_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AddPlannedChangesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*AddPlannedChangesResponse) ProtoMessage() {} func (x *AddPlannedChangesResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AddPlannedChangesResponse.ProtoReflect.Descriptor instead. func (*AddPlannedChangesResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{45} } type ListHomeChangesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Pagination *PaginationRequest `protobuf:"bytes,1,opt,name=pagination,proto3" json:"pagination,omitempty"` Filters *ChangeFiltersRequest `protobuf:"bytes,2,opt,name=filters,proto3,oneof" json:"filters,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListHomeChangesRequest) Reset() { *x = ListHomeChangesRequest{} mi := &file_changes_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListHomeChangesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListHomeChangesRequest) ProtoMessage() {} func (x *ListHomeChangesRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListHomeChangesRequest.ProtoReflect.Descriptor instead. func (*ListHomeChangesRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{46} } func (x *ListHomeChangesRequest) GetPagination() *PaginationRequest { if x != nil { return x.Pagination } return nil } func (x *ListHomeChangesRequest) GetFilters() *ChangeFiltersRequest { if x != nil { return x.Filters } return nil } // ChangeFiltersRequest is used for filtering on the changes page. // Repeated entries of the same type are used to represent OR conditions. eg if repo is ["a", "b"] then the filter is (repo == "a" OR repo == "b") // The filters are ANDed together. eg if repo is ["a", "b"] and author is ["c"] then the filter is (repo == "a" OR repo == "b") AND author == "c" type ChangeFiltersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Repos []string `protobuf:"bytes,1,rep,name=repos,proto3" json:"repos,omitempty"` Risks []Risk_Severity `protobuf:"varint,3,rep,packed,name=risks,proto3,enum=changes.Risk_Severity" json:"risks,omitempty"` Authors []string `protobuf:"bytes,4,rep,name=authors,proto3" json:"authors,omitempty"` Statuses []ChangeStatus `protobuf:"varint,5,rep,packed,name=statuses,proto3,enum=changes.ChangeStatus" json:"statuses,omitempty"` SortOrder *SortOrder `protobuf:"varint,6,opt,name=sortOrder,proto3,enum=SortOrder,oneof" json:"sortOrder,omitempty"` // the default is SortOrder.DATE_DESCENDING (newest first) Labels []string `protobuf:"bytes,7,rep,name=labels,proto3" json:"labels,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeFiltersRequest) Reset() { *x = ChangeFiltersRequest{} mi := &file_changes_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeFiltersRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeFiltersRequest) ProtoMessage() {} func (x *ChangeFiltersRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeFiltersRequest.ProtoReflect.Descriptor instead. func (*ChangeFiltersRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{47} } func (x *ChangeFiltersRequest) GetRepos() []string { if x != nil { return x.Repos } return nil } func (x *ChangeFiltersRequest) GetRisks() []Risk_Severity { if x != nil { return x.Risks } return nil } func (x *ChangeFiltersRequest) GetAuthors() []string { if x != nil { return x.Authors } return nil } func (x *ChangeFiltersRequest) GetStatuses() []ChangeStatus { if x != nil { return x.Statuses } return nil } func (x *ChangeFiltersRequest) GetSortOrder() SortOrder { if x != nil && x.SortOrder != nil { return *x.SortOrder } return SortOrder_ALPHABETICAL_ASCENDING } func (x *ChangeFiltersRequest) GetLabels() []string { if x != nil { return x.Labels } return nil } type ListHomeChangesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Changes []*ChangeSummary `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` Pagination *PaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListHomeChangesResponse) Reset() { *x = ListHomeChangesResponse{} mi := &file_changes_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListHomeChangesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListHomeChangesResponse) ProtoMessage() {} func (x *ListHomeChangesResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListHomeChangesResponse.ProtoReflect.Descriptor instead. func (*ListHomeChangesResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{48} } func (x *ListHomeChangesResponse) GetChanges() []*ChangeSummary { if x != nil { return x.Changes } return nil } func (x *ListHomeChangesResponse) GetPagination() *PaginationResponse { if x != nil { return x.Pagination } return nil } type PopulateChangeFiltersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PopulateChangeFiltersRequest) Reset() { *x = PopulateChangeFiltersRequest{} mi := &file_changes_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *PopulateChangeFiltersRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*PopulateChangeFiltersRequest) ProtoMessage() {} func (x *PopulateChangeFiltersRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use PopulateChangeFiltersRequest.ProtoReflect.Descriptor instead. func (*PopulateChangeFiltersRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{49} } type PopulateChangeFiltersResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Repos []string `protobuf:"bytes,1,rep,name=repos,proto3" json:"repos,omitempty"` Authors []string `protobuf:"bytes,2,rep,name=authors,proto3" json:"authors,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PopulateChangeFiltersResponse) Reset() { *x = PopulateChangeFiltersResponse{} mi := &file_changes_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *PopulateChangeFiltersResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*PopulateChangeFiltersResponse) ProtoMessage() {} func (x *PopulateChangeFiltersResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use PopulateChangeFiltersResponse.ProtoReflect.Descriptor instead. func (*PopulateChangeFiltersResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{50} } func (x *PopulateChangeFiltersResponse) GetRepos() []string { if x != nil { return x.Repos } return nil } func (x *PopulateChangeFiltersResponse) GetAuthors() []string { if x != nil { return x.Authors } return nil } type ItemDiffSummary struct { state protoimpl.MessageState `protogen:"open.v1"` // A reference to the item that this diff is related to ItemRef *Reference `protobuf:"bytes,1,opt,name=itemRef,proto3" json:"itemRef,omitempty"` // The status of the item Status ItemDiffStatus `protobuf:"varint,4,opt,name=status,proto3,enum=changes.ItemDiffStatus" json:"status,omitempty"` // The health of the item currently (as opposed to before the change) HealthAfter Health `protobuf:"varint,5,opt,name=healthAfter,proto3,enum=Health" json:"healthAfter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ItemDiffSummary) Reset() { *x = ItemDiffSummary{} mi := &file_changes_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ItemDiffSummary) String() string { return protoimpl.X.MessageStringOf(x) } func (*ItemDiffSummary) ProtoMessage() {} func (x *ItemDiffSummary) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ItemDiffSummary.ProtoReflect.Descriptor instead. func (*ItemDiffSummary) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{51} } func (x *ItemDiffSummary) GetItemRef() *Reference { if x != nil { return x.ItemRef } return nil } func (x *ItemDiffSummary) GetStatus() ItemDiffStatus { if x != nil { return x.Status } return ItemDiffStatus_ITEM_DIFF_STATUS_UNSPECIFIED } func (x *ItemDiffSummary) GetHealthAfter() Health { if x != nil { return x.HealthAfter } return Health_HEALTH_UNKNOWN } type ItemDiff struct { state protoimpl.MessageState `protogen:"open.v1"` // A reference to the item that this diff is related to, if this exists in the // real infrastructure. If this is blank it represents a change that we were // unable to find a matching item for Item *Reference `protobuf:"bytes,1,opt,name=item,proto3,oneof" json:"item,omitempty"` // The status of the item Status ItemDiffStatus `protobuf:"varint,2,opt,name=status,proto3,enum=changes.ItemDiffStatus" json:"status,omitempty"` Before *Item `protobuf:"bytes,3,opt,name=before,proto3" json:"before,omitempty"` After *Item `protobuf:"bytes,4,opt,name=after,proto3" json:"after,omitempty"` // A summary of how often the GUN's have had similar changes for individual attributes along with planned and unplanned changes ModificationSummary string `protobuf:"bytes,5,opt,name=modificationSummary,proto3" json:"modificationSummary,omitempty"` // Reference to the live infrastructure item this diff was mapped to via // LLM mapping. Only set when the mapped item differs from the plan item // (i.e., the plan resource type has no static mapping and the LLM found // a matching live item of a different type). The frontend uses this to // draw a synthetic edge in the blast radius graph connecting the plan // item node to the mapped live item node. MappedItemRef *Reference `protobuf:"bytes,6,opt,name=mappedItemRef,proto3,oneof" json:"mappedItemRef,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ItemDiff) Reset() { *x = ItemDiff{} mi := &file_changes_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ItemDiff) String() string { return protoimpl.X.MessageStringOf(x) } func (*ItemDiff) ProtoMessage() {} func (x *ItemDiff) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ItemDiff.ProtoReflect.Descriptor instead. func (*ItemDiff) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{52} } func (x *ItemDiff) GetItem() *Reference { if x != nil { return x.Item } return nil } func (x *ItemDiff) GetStatus() ItemDiffStatus { if x != nil { return x.Status } return ItemDiffStatus_ITEM_DIFF_STATUS_UNSPECIFIED } func (x *ItemDiff) GetBefore() *Item { if x != nil { return x.Before } return nil } func (x *ItemDiff) GetAfter() *Item { if x != nil { return x.After } return nil } func (x *ItemDiff) GetModificationSummary() string { if x != nil { return x.ModificationSummary } return "" } func (x *ItemDiff) GetMappedItemRef() *Reference { if x != nil { return x.MappedItemRef } return nil } type EnrichedTags struct { state protoimpl.MessageState `protogen:"open.v1"` TagValue map[string]*TagValue `protobuf:"bytes,18,rep,name=tagValue,proto3" json:"tagValue,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *EnrichedTags) Reset() { *x = EnrichedTags{} mi := &file_changes_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *EnrichedTags) String() string { return protoimpl.X.MessageStringOf(x) } func (*EnrichedTags) ProtoMessage() {} func (x *EnrichedTags) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use EnrichedTags.ProtoReflect.Descriptor instead. func (*EnrichedTags) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{53} } func (x *EnrichedTags) GetTagValue() map[string]*TagValue { if x != nil { return x.TagValue } return nil } type TagValue struct { state protoimpl.MessageState `protogen:"open.v1"` // The value of the tag, this can be user-defined or auto-generated // // Types that are valid to be assigned to Value: // // *TagValue_UserTagValue // *TagValue_AutoTagValue Value isTagValue_Value `protobuf_oneof:"value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TagValue) Reset() { *x = TagValue{} mi := &file_changes_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *TagValue) String() string { return protoimpl.X.MessageStringOf(x) } func (*TagValue) ProtoMessage() {} func (x *TagValue) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TagValue.ProtoReflect.Descriptor instead. func (*TagValue) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{54} } func (x *TagValue) GetValue() isTagValue_Value { if x != nil { return x.Value } return nil } func (x *TagValue) GetUserTagValue() *UserTagValue { if x != nil { if x, ok := x.Value.(*TagValue_UserTagValue); ok { return x.UserTagValue } } return nil } func (x *TagValue) GetAutoTagValue() *AutoTagValue { if x != nil { if x, ok := x.Value.(*TagValue_AutoTagValue); ok { return x.AutoTagValue } } return nil } type isTagValue_Value interface { isTagValue_Value() } type TagValue_UserTagValue struct { UserTagValue *UserTagValue `protobuf:"bytes,1,opt,name=userTagValue,proto3,oneof"` } type TagValue_AutoTagValue struct { AutoTagValue *AutoTagValue `protobuf:"bytes,2,opt,name=autoTagValue,proto3,oneof"` } func (*TagValue_UserTagValue) isTagValue_Value() {} func (*TagValue_AutoTagValue) isTagValue_Value() {} type UserTagValue struct { state protoimpl.MessageState `protogen:"open.v1"` // The value of the tag that was set by the user. Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UserTagValue) Reset() { *x = UserTagValue{} mi := &file_changes_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UserTagValue) String() string { return protoimpl.X.MessageStringOf(x) } func (*UserTagValue) ProtoMessage() {} func (x *UserTagValue) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UserTagValue.ProtoReflect.Descriptor instead. func (*UserTagValue) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{55} } func (x *UserTagValue) GetValue() string { if x != nil { return x.Value } return "" } type AutoTagValue struct { state protoimpl.MessageState `protogen:"open.v1"` // The value of the tag Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` // Reasoning for this decision Reasoning string `protobuf:"bytes,2,opt,name=reasoning,proto3" json:"reasoning,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AutoTagValue) Reset() { *x = AutoTagValue{} mi := &file_changes_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AutoTagValue) String() string { return protoimpl.X.MessageStringOf(x) } func (*AutoTagValue) ProtoMessage() {} func (x *AutoTagValue) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AutoTagValue.ProtoReflect.Descriptor instead. func (*AutoTagValue) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{56} } func (x *AutoTagValue) GetValue() string { if x != nil { return x.Value } return "" } func (x *AutoTagValue) GetReasoning() string { if x != nil { return x.Reasoning } return "" } // a label that can be applied to a change // note that it keeps the colour / name based on the rule that was used at the time of creation // if the rule is updated, the colour / name will not be updated, unless // 1. the change is re-run, in which case the label will be updated to the new colour / name. // 2. the user will have to manually re-run the rule to get the new colour / name. this may also remove the label from the change if the rule is no longer applied. type Label struct { state protoimpl.MessageState `protogen:"open.v1"` // The type of the label, it is required Type LabelType `protobuf:"varint,1,opt,name=type,proto3,enum=changes.LabelType" json:"type,omitempty"` // name of the label, it is required Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // colour of the label // discussed with the UI team and we will use a hex code for the colour, it is required Colour string `protobuf:"bytes,3,opt,name=colour,proto3" json:"colour,omitempty"` // the label rule that was used to generate this label, this is only populated for auto-generated labels LabelRuleUUID []byte `protobuf:"bytes,4,opt,name=labelRuleUUID,proto3" json:"labelRuleUUID,omitempty"` // reasoning for this label, this is only populated for auto-generated labels AutoLabelReasoning string `protobuf:"bytes,5,opt,name=autoLabelReasoning,proto3" json:"autoLabelReasoning,omitempty"` // skipped if the label rule was not applied to the change, this is only populated for auto-generated labels Skipped bool `protobuf:"varint,6,opt,name=skipped,proto3" json:"skipped,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Label) Reset() { *x = Label{} mi := &file_changes_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Label) String() string { return protoimpl.X.MessageStringOf(x) } func (*Label) ProtoMessage() {} func (x *Label) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Label.ProtoReflect.Descriptor instead. func (*Label) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{57} } func (x *Label) GetType() LabelType { if x != nil { return x.Type } return LabelType_LABEL_TYPE_UNSPECIFIED } func (x *Label) GetName() string { if x != nil { return x.Name } return "" } func (x *Label) GetColour() string { if x != nil { return x.Colour } return "" } func (x *Label) GetLabelRuleUUID() []byte { if x != nil { return x.LabelRuleUUID } return nil } func (x *Label) GetAutoLabelReasoning() string { if x != nil { return x.AutoLabelReasoning } return "" } func (x *Label) GetSkipped() bool { if x != nil { return x.Skipped } return false } // A smaller summary of a change type ChangeSummary struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id to identify this change UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // Short title for this change. // Example: "database upgrade" Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` // The current status of this change. This is changed by the lifecycle // functions such as `StartChange` and `EndChange`. Status ChangeStatus `protobuf:"varint,3,opt,name=status,proto3,enum=changes.ChangeStatus" json:"status,omitempty"` // Link to the ticket for this change. // Example: "http://jira.contoso-engineering.com/browse/CM-1337" TicketLink string `protobuf:"bytes,4,opt,name=ticketLink,proto3" json:"ticketLink,omitempty"` // timestamp when this change was created CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=createdAt,proto3" json:"createdAt,omitempty"` // The name of the user that created the change CreatorName string `protobuf:"bytes,6,opt,name=creatorName,proto3" json:"creatorName,omitempty"` // The email of the user that created the change CreatorEmail string `protobuf:"bytes,15,opt,name=creatorEmail,proto3" json:"creatorEmail,omitempty"` // The number of items in the blast radius of this change NumAffectedItems int32 `protobuf:"varint,9,opt,name=numAffectedItems,proto3" json:"numAffectedItems,omitempty"` // The number of edges in the blast radius of this change NumAffectedEdges int32 `protobuf:"varint,10,opt,name=numAffectedEdges,proto3" json:"numAffectedEdges,omitempty"` // The number of low risks in this change NumLowRisk int32 `protobuf:"varint,11,opt,name=numLowRisk,proto3" json:"numLowRisk,omitempty"` // The number of medium risks in this change NumMediumRisk int32 `protobuf:"varint,12,opt,name=numMediumRisk,proto3" json:"numMediumRisk,omitempty"` // The number of high risks in this change NumHighRisk int32 `protobuf:"varint,13,opt,name=numHighRisk,proto3" json:"numHighRisk,omitempty"` // Quick description of the change. // Example: "upgrade of the database to get access to the new contoso management processor" Description string `protobuf:"bytes,14,opt,name=description,proto3" json:"description,omitempty"` // Repo information; can be an empty string. CLI attempts auto-population, but // users can override. Not necessarily a URL. The UI will be responsible for // any formatting/shortening/sprucing up should it be required. Repo string `protobuf:"bytes,16,opt,name=repo,proto3" json:"repo,omitempty"` // Deprecated: Use enrichedTags instead // // Deprecated: Marked as deprecated in changes.proto. Tags map[string]string `protobuf:"bytes,17,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Tags associated with this change EnrichedTags *EnrichedTags `protobuf:"bytes,18,opt,name=enrichedTags,proto3" json:"enrichedTags,omitempty"` // labels Labels []*Label `protobuf:"bytes,19,rep,name=labels,proto3" json:"labels,omitempty"` // Github change information // contains information about the author GithubChangeInfo *GithubChangeInfo `protobuf:"bytes,20,opt,name=githubChangeInfo,proto3" json:"githubChangeInfo,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeSummary) Reset() { *x = ChangeSummary{} mi := &file_changes_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeSummary) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeSummary) ProtoMessage() {} func (x *ChangeSummary) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeSummary.ProtoReflect.Descriptor instead. func (*ChangeSummary) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{58} } func (x *ChangeSummary) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *ChangeSummary) GetTitle() string { if x != nil { return x.Title } return "" } func (x *ChangeSummary) GetStatus() ChangeStatus { if x != nil { return x.Status } return ChangeStatus_CHANGE_STATUS_UNSPECIFIED } func (x *ChangeSummary) GetTicketLink() string { if x != nil { return x.TicketLink } return "" } func (x *ChangeSummary) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt } return nil } func (x *ChangeSummary) GetCreatorName() string { if x != nil { return x.CreatorName } return "" } func (x *ChangeSummary) GetCreatorEmail() string { if x != nil { return x.CreatorEmail } return "" } func (x *ChangeSummary) GetNumAffectedItems() int32 { if x != nil { return x.NumAffectedItems } return 0 } func (x *ChangeSummary) GetNumAffectedEdges() int32 { if x != nil { return x.NumAffectedEdges } return 0 } func (x *ChangeSummary) GetNumLowRisk() int32 { if x != nil { return x.NumLowRisk } return 0 } func (x *ChangeSummary) GetNumMediumRisk() int32 { if x != nil { return x.NumMediumRisk } return 0 } func (x *ChangeSummary) GetNumHighRisk() int32 { if x != nil { return x.NumHighRisk } return 0 } func (x *ChangeSummary) GetDescription() string { if x != nil { return x.Description } return "" } func (x *ChangeSummary) GetRepo() string { if x != nil { return x.Repo } return "" } // Deprecated: Marked as deprecated in changes.proto. func (x *ChangeSummary) GetTags() map[string]string { if x != nil { return x.Tags } return nil } func (x *ChangeSummary) GetEnrichedTags() *EnrichedTags { if x != nil { return x.EnrichedTags } return nil } func (x *ChangeSummary) GetLabels() []*Label { if x != nil { return x.Labels } return nil } func (x *ChangeSummary) GetGithubChangeInfo() *GithubChangeInfo { if x != nil { return x.GithubChangeInfo } return nil } // a complete Change with machine-supplied and user-supplied values type Change struct { state protoimpl.MessageState `protogen:"open.v1"` // machine-generated metadata of this change Metadata *ChangeMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` // user-supplied properties of this change Properties *ChangeProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Change) Reset() { *x = Change{} mi := &file_changes_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Change) String() string { return protoimpl.X.MessageStringOf(x) } func (*Change) ProtoMessage() {} func (x *Change) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Change.ProtoReflect.Descriptor instead. func (*Change) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{59} } func (x *Change) GetMetadata() *ChangeMetadata { if x != nil { return x.Metadata } return nil } func (x *Change) GetProperties() *ChangeProperties { if x != nil { return x.Properties } return nil } // machine-generated metadata of this change type ChangeMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id to identify this change UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // timestamp when this change was created CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=createdAt,proto3" json:"createdAt,omitempty"` // timestamp when this change was last updated UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=updatedAt,proto3" json:"updatedAt,omitempty"` // The current status of this change. This is changed by the lifecycle // functions such as `StartChange` and `EndChange`. Status ChangeStatus `protobuf:"varint,4,opt,name=status,proto3,enum=changes.ChangeStatus" json:"status,omitempty"` // The name of the user that created the change CreatorName string `protobuf:"bytes,5,opt,name=creatorName,proto3" json:"creatorName,omitempty"` // The email of the user that created the change CreatorEmail string `protobuf:"bytes,19,opt,name=creatorEmail,proto3" json:"creatorEmail,omitempty"` // The number of items in the blast radius if this change NumAffectedItems int32 `protobuf:"varint,7,opt,name=numAffectedItems,proto3" json:"numAffectedItems,omitempty"` // The number of edges in the blast radius if this change NumAffectedEdges int32 `protobuf:"varint,17,opt,name=numAffectedEdges,proto3" json:"numAffectedEdges,omitempty"` // The number of items within the blast radius that were not affected by this // change NumUnchangedItems int32 `protobuf:"varint,8,opt,name=numUnchangedItems,proto3" json:"numUnchangedItems,omitempty"` // The number of items that were created as part of this change NumCreatedItems int32 `protobuf:"varint,9,opt,name=numCreatedItems,proto3" json:"numCreatedItems,omitempty"` // The number of items that were updated as part of this change NumUpdatedItems int32 `protobuf:"varint,10,opt,name=numUpdatedItems,proto3" json:"numUpdatedItems,omitempty"` // The number of items that were replaced as part of this change NumReplacedItems int32 `protobuf:"varint,18,opt,name=numReplacedItems,proto3" json:"numReplacedItems,omitempty"` // The number of items that were deleted as part of this change NumDeletedItems int32 `protobuf:"varint,11,opt,name=numDeletedItems,proto3" json:"numDeletedItems,omitempty"` UnknownHealthChange *ChangeMetadata_HealthChange `protobuf:"bytes,12,opt,name=UnknownHealthChange,proto3" json:"UnknownHealthChange,omitempty"` OkHealthChange *ChangeMetadata_HealthChange `protobuf:"bytes,13,opt,name=OkHealthChange,proto3" json:"OkHealthChange,omitempty"` WarningHealthChange *ChangeMetadata_HealthChange `protobuf:"bytes,14,opt,name=WarningHealthChange,proto3" json:"WarningHealthChange,omitempty"` ErrorHealthChange *ChangeMetadata_HealthChange `protobuf:"bytes,15,opt,name=ErrorHealthChange,proto3" json:"ErrorHealthChange,omitempty"` PendingHealthChange *ChangeMetadata_HealthChange `protobuf:"bytes,16,opt,name=PendingHealthChange,proto3" json:"PendingHealthChange,omitempty"` // Github change information // contains information about the author from github GithubChangeInfo *GithubChangeInfo `protobuf:"bytes,20,opt,name=githubChangeInfo,proto3" json:"githubChangeInfo,omitempty"` // The total number of observations recorded for this change during blast radius analysis. // This is null/undefined for legacy changes where observations were not tracked. // This count increments immediately as observations are added, providing fast feedback. TotalObservations *uint32 `protobuf:"varint,21,opt,name=total_observations,json=totalObservations,proto3,oneof" json:"total_observations,omitempty"` // Persisted change analysis completion status (single source of truth for GetChange/CLI). ChangeAnalysisStatus *ChangeAnalysisStatus `protobuf:"bytes,22,opt,name=changeAnalysisStatus,proto3" json:"changeAnalysisStatus,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeMetadata) Reset() { *x = ChangeMetadata{} mi := &file_changes_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeMetadata) ProtoMessage() {} func (x *ChangeMetadata) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeMetadata.ProtoReflect.Descriptor instead. func (*ChangeMetadata) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{60} } func (x *ChangeMetadata) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *ChangeMetadata) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt } return nil } func (x *ChangeMetadata) GetUpdatedAt() *timestamppb.Timestamp { if x != nil { return x.UpdatedAt } return nil } func (x *ChangeMetadata) GetStatus() ChangeStatus { if x != nil { return x.Status } return ChangeStatus_CHANGE_STATUS_UNSPECIFIED } func (x *ChangeMetadata) GetCreatorName() string { if x != nil { return x.CreatorName } return "" } func (x *ChangeMetadata) GetCreatorEmail() string { if x != nil { return x.CreatorEmail } return "" } func (x *ChangeMetadata) GetNumAffectedItems() int32 { if x != nil { return x.NumAffectedItems } return 0 } func (x *ChangeMetadata) GetNumAffectedEdges() int32 { if x != nil { return x.NumAffectedEdges } return 0 } func (x *ChangeMetadata) GetNumUnchangedItems() int32 { if x != nil { return x.NumUnchangedItems } return 0 } func (x *ChangeMetadata) GetNumCreatedItems() int32 { if x != nil { return x.NumCreatedItems } return 0 } func (x *ChangeMetadata) GetNumUpdatedItems() int32 { if x != nil { return x.NumUpdatedItems } return 0 } func (x *ChangeMetadata) GetNumReplacedItems() int32 { if x != nil { return x.NumReplacedItems } return 0 } func (x *ChangeMetadata) GetNumDeletedItems() int32 { if x != nil { return x.NumDeletedItems } return 0 } func (x *ChangeMetadata) GetUnknownHealthChange() *ChangeMetadata_HealthChange { if x != nil { return x.UnknownHealthChange } return nil } func (x *ChangeMetadata) GetOkHealthChange() *ChangeMetadata_HealthChange { if x != nil { return x.OkHealthChange } return nil } func (x *ChangeMetadata) GetWarningHealthChange() *ChangeMetadata_HealthChange { if x != nil { return x.WarningHealthChange } return nil } func (x *ChangeMetadata) GetErrorHealthChange() *ChangeMetadata_HealthChange { if x != nil { return x.ErrorHealthChange } return nil } func (x *ChangeMetadata) GetPendingHealthChange() *ChangeMetadata_HealthChange { if x != nil { return x.PendingHealthChange } return nil } func (x *ChangeMetadata) GetGithubChangeInfo() *GithubChangeInfo { if x != nil { return x.GithubChangeInfo } return nil } func (x *ChangeMetadata) GetTotalObservations() uint32 { if x != nil && x.TotalObservations != nil { return *x.TotalObservations } return 0 } func (x *ChangeMetadata) GetChangeAnalysisStatus() *ChangeAnalysisStatus { if x != nil { return x.ChangeAnalysisStatus } return nil } // user-supplied properties of this change type ChangeProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // Short title for this change. // Example: "database upgrade" Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` // Quick description of the change. // Example: "upgrade of the database to get access to the new contoso management processor" Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` // Link to the ticket for this change. // Example: "http://jira.contoso-engineering.com/browse/CM-1337" TicketLink string `protobuf:"bytes,4,opt,name=ticketLink,proto3" json:"ticketLink,omitempty"` // The owner of this change. // Example: Susan Owner string `protobuf:"bytes,5,opt,name=owner,proto3" json:"owner,omitempty"` // A comma-separated list of emails to keep updated with the status of this change. // Example: susan@contoso.com, jimmy@contoso.com CcEmails string `protobuf:"bytes,6,opt,name=ccEmails,proto3" json:"ccEmails,omitempty"` // UUID of a bookmark for the item queries of the items *directly* affected by // this change. This might be parsed from a terraform plan, added from the API, // parsed from a freeform ticket description etc. ChangingItemsBookmarkUUID []byte `protobuf:"bytes,7,opt,name=changingItemsBookmarkUUID,proto3" json:"changingItemsBookmarkUUID,omitempty"` // UUID of a snapshot for the item queries of the items *indirectly* affected // by this change i.e. the blast radius. The initial selection will be determined // automatically based off changingItemsBookmark, but can refined by the user. BlastRadiusSnapshotUUID []byte `protobuf:"bytes,11,opt,name=blastRadiusSnapshotUUID,proto3" json:"blastRadiusSnapshotUUID,omitempty"` // UUID of the whole-system snapshot created before the change has started. SystemBeforeSnapshotUUID []byte `protobuf:"bytes,8,opt,name=systemBeforeSnapshotUUID,proto3" json:"systemBeforeSnapshotUUID,omitempty"` // UUID of the whole-system snapshot created after the change has finished. SystemAfterSnapshotUUID []byte `protobuf:"bytes,9,opt,name=systemAfterSnapshotUUID,proto3" json:"systemAfterSnapshotUUID,omitempty"` // a list of item diffs that were planned to be changed as part of this change. For all items that we could map, the ItemDiff.Reference will be set to the actual item found. PlannedChanges []*ItemDiff `protobuf:"bytes,12,rep,name=plannedChanges,proto3" json:"plannedChanges,omitempty"` // The raw plan output for calculating the change's risks. RawPlan string `protobuf:"bytes,13,opt,name=rawPlan,proto3" json:"rawPlan,omitempty"` // The code changes of this change for calculating the change's risks. CodeChanges string `protobuf:"bytes,14,opt,name=codeChanges,proto3" json:"codeChanges,omitempty"` // Repo information; can be an empty string. CLI attempts auto-population, but users can override. Not necessarily a URL. The UI will be responsible for any formatting/shortening/sprucing up should it be required. Repo string `protobuf:"bytes,15,opt,name=repo,proto3" json:"repo,omitempty"` // Tags that were set bu the user when the tag was created // // Deprecated: Use enrichedTags instead // // Deprecated: Marked as deprecated in changes.proto. Tags map[string]string `protobuf:"bytes,16,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Tags associated with this change EnrichedTags *EnrichedTags `protobuf:"bytes,18,opt,name=enrichedTags,proto3" json:"enrichedTags,omitempty"` // labels // note we keep track of the label type in the label struct itself Labels []*Label `protobuf:"bytes,21,rep,name=labels,proto3" json:"labels,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeProperties) Reset() { *x = ChangeProperties{} mi := &file_changes_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeProperties) ProtoMessage() {} func (x *ChangeProperties) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeProperties.ProtoReflect.Descriptor instead. func (*ChangeProperties) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{61} } func (x *ChangeProperties) GetTitle() string { if x != nil { return x.Title } return "" } func (x *ChangeProperties) GetDescription() string { if x != nil { return x.Description } return "" } func (x *ChangeProperties) GetTicketLink() string { if x != nil { return x.TicketLink } return "" } func (x *ChangeProperties) GetOwner() string { if x != nil { return x.Owner } return "" } func (x *ChangeProperties) GetCcEmails() string { if x != nil { return x.CcEmails } return "" } func (x *ChangeProperties) GetChangingItemsBookmarkUUID() []byte { if x != nil { return x.ChangingItemsBookmarkUUID } return nil } func (x *ChangeProperties) GetBlastRadiusSnapshotUUID() []byte { if x != nil { return x.BlastRadiusSnapshotUUID } return nil } func (x *ChangeProperties) GetSystemBeforeSnapshotUUID() []byte { if x != nil { return x.SystemBeforeSnapshotUUID } return nil } func (x *ChangeProperties) GetSystemAfterSnapshotUUID() []byte { if x != nil { return x.SystemAfterSnapshotUUID } return nil } func (x *ChangeProperties) GetPlannedChanges() []*ItemDiff { if x != nil { return x.PlannedChanges } return nil } func (x *ChangeProperties) GetRawPlan() string { if x != nil { return x.RawPlan } return "" } func (x *ChangeProperties) GetCodeChanges() string { if x != nil { return x.CodeChanges } return "" } func (x *ChangeProperties) GetRepo() string { if x != nil { return x.Repo } return "" } // Deprecated: Marked as deprecated in changes.proto. func (x *ChangeProperties) GetTags() map[string]string { if x != nil { return x.Tags } return nil } func (x *ChangeProperties) GetEnrichedTags() *EnrichedTags { if x != nil { return x.EnrichedTags } return nil } func (x *ChangeProperties) GetLabels() []*Label { if x != nil { return x.Labels } return nil } // GithubChangeInfo contains information about a change that originated from GitHub // contains mostly author information. type GithubChangeInfo struct { state protoimpl.MessageState `protogen:"open.v1"` // The GitHub username of the author of the change AuthorUsername string `protobuf:"bytes,1,opt,name=authorUsername,proto3" json:"authorUsername,omitempty"` // The author full name AuthorFullName string `protobuf:"bytes,2,opt,name=authorFullName,proto3" json:"authorFullName,omitempty"` // The link to the author's avatar AuthorAvatarLink string `protobuf:"bytes,3,opt,name=authorAvatarLink,proto3" json:"authorAvatarLink,omitempty"` // The email of the author AuthorEmail string `protobuf:"bytes,4,opt,name=authorEmail,proto3" json:"authorEmail,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GithubChangeInfo) Reset() { *x = GithubChangeInfo{} mi := &file_changes_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GithubChangeInfo) String() string { return protoimpl.X.MessageStringOf(x) } func (*GithubChangeInfo) ProtoMessage() {} func (x *GithubChangeInfo) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GithubChangeInfo.ProtoReflect.Descriptor instead. func (*GithubChangeInfo) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{62} } func (x *GithubChangeInfo) GetAuthorUsername() string { if x != nil { return x.AuthorUsername } return "" } func (x *GithubChangeInfo) GetAuthorFullName() string { if x != nil { return x.AuthorFullName } return "" } func (x *GithubChangeInfo) GetAuthorAvatarLink() string { if x != nil { return x.AuthorAvatarLink } return "" } func (x *GithubChangeInfo) GetAuthorEmail() string { if x != nil { return x.AuthorEmail } return "" } // list all changes type ListChangesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangesRequest) Reset() { *x = ListChangesRequest{} mi := &file_changes_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangesRequest) ProtoMessage() {} func (x *ListChangesRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangesRequest.ProtoReflect.Descriptor instead. func (*ListChangesRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{63} } type ListChangesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Changes []*Change `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangesResponse) Reset() { *x = ListChangesResponse{} mi := &file_changes_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangesResponse) ProtoMessage() {} func (x *ListChangesResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangesResponse.ProtoReflect.Descriptor instead. func (*ListChangesResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{64} } func (x *ListChangesResponse) GetChanges() []*Change { if x != nil { return x.Changes } return nil } // list all changes in a specific status type ListChangesByStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Status ChangeStatus `protobuf:"varint,1,opt,name=status,proto3,enum=changes.ChangeStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangesByStatusRequest) Reset() { *x = ListChangesByStatusRequest{} mi := &file_changes_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangesByStatusRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangesByStatusRequest) ProtoMessage() {} func (x *ListChangesByStatusRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangesByStatusRequest.ProtoReflect.Descriptor instead. func (*ListChangesByStatusRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{65} } func (x *ListChangesByStatusRequest) GetStatus() ChangeStatus { if x != nil { return x.Status } return ChangeStatus_CHANGE_STATUS_UNSPECIFIED } type ListChangesByStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Changes []*Change `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangesByStatusResponse) Reset() { *x = ListChangesByStatusResponse{} mi := &file_changes_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangesByStatusResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangesByStatusResponse) ProtoMessage() {} func (x *ListChangesByStatusResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangesByStatusResponse.ProtoReflect.Descriptor instead. func (*ListChangesByStatusResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{66} } func (x *ListChangesByStatusResponse) GetChanges() []*Change { if x != nil { return x.Changes } return nil } // create a new change type CreateChangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Properties *ChangeProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateChangeRequest) Reset() { *x = CreateChangeRequest{} mi := &file_changes_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateChangeRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateChangeRequest) ProtoMessage() {} func (x *CreateChangeRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateChangeRequest.ProtoReflect.Descriptor instead. func (*CreateChangeRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{67} } func (x *CreateChangeRequest) GetProperties() *ChangeProperties { if x != nil { return x.Properties } return nil } type CreateChangeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Change *Change `protobuf:"bytes,1,opt,name=change,proto3" json:"change,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateChangeResponse) Reset() { *x = CreateChangeResponse{} mi := &file_changes_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateChangeResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateChangeResponse) ProtoMessage() {} func (x *CreateChangeResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateChangeResponse.ProtoReflect.Descriptor instead. func (*CreateChangeResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{68} } func (x *CreateChangeResponse) GetChange() *Change { if x != nil { return x.Change } return nil } // get the details of a specific change type GetChangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // Return a slimmed down version of the change. This will exclude the // following data: // * `rawPlan`: The entire Terraform plan output // * `codeChanges`: The code changes that created this change Slim bool `protobuf:"varint,2,opt,name=slim,proto3" json:"slim,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeRequest) Reset() { *x = GetChangeRequest{} mi := &file_changes_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeRequest) ProtoMessage() {} func (x *GetChangeRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeRequest.ProtoReflect.Descriptor instead. func (*GetChangeRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{69} } func (x *GetChangeRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *GetChangeRequest) GetSlim() bool { if x != nil { return x.Slim } return false } type GetChangeByTicketLinkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` TicketLink string `protobuf:"bytes,1,opt,name=ticketLink,proto3" json:"ticketLink,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeByTicketLinkRequest) Reset() { *x = GetChangeByTicketLinkRequest{} mi := &file_changes_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeByTicketLinkRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeByTicketLinkRequest) ProtoMessage() {} func (x *GetChangeByTicketLinkRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeByTicketLinkRequest.ProtoReflect.Descriptor instead. func (*GetChangeByTicketLinkRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{70} } func (x *GetChangeByTicketLinkRequest) GetTicketLink() string { if x != nil { return x.TicketLink } return "" } type GetChangeSummaryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // Return a slimmed down version of the change. This will exclude the // following data: // * `rawPlan`: The entire Terraform plan output // * `codeChanges`: The code changes that created this change Slim bool `protobuf:"varint,2,opt,name=slim,proto3" json:"slim,omitempty"` // currently json or markdown ChangeOutputFormat ChangeOutputFormat `protobuf:"varint,3,opt,name=changeOutputFormat,proto3,enum=changes.ChangeOutputFormat" json:"changeOutputFormat,omitempty"` // For filtering risks from display in the output RiskSeverityFilter []Risk_Severity `protobuf:"varint,4,rep,packed,name=riskSeverityFilter,proto3,enum=changes.Risk_Severity" json:"riskSeverityFilter,omitempty"` // this is the app url the user has been today AppURL string `protobuf:"bytes,5,opt,name=appURL,proto3" json:"appURL,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeSummaryRequest) Reset() { *x = GetChangeSummaryRequest{} mi := &file_changes_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeSummaryRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeSummaryRequest) ProtoMessage() {} func (x *GetChangeSummaryRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeSummaryRequest.ProtoReflect.Descriptor instead. func (*GetChangeSummaryRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{71} } func (x *GetChangeSummaryRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *GetChangeSummaryRequest) GetSlim() bool { if x != nil { return x.Slim } return false } func (x *GetChangeSummaryRequest) GetChangeOutputFormat() ChangeOutputFormat { if x != nil { return x.ChangeOutputFormat } return ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_UNSPECIFIED } func (x *GetChangeSummaryRequest) GetRiskSeverityFilter() []Risk_Severity { if x != nil { return x.RiskSeverityFilter } return nil } func (x *GetChangeSummaryRequest) GetAppURL() string { if x != nil { return x.AppURL } return "" } type GetChangeSummaryResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Change string `protobuf:"bytes,1,opt,name=change,proto3" json:"change,omitempty"` // True when the GitHub App has successfully posted (or updated) a PR // comment for this change. Allows the CLI/Action to skip its own comment. GithubAppCommentPosted bool `protobuf:"varint,2,opt,name=github_app_comment_posted,json=githubAppCommentPosted,proto3" json:"github_app_comment_posted,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeSummaryResponse) Reset() { *x = GetChangeSummaryResponse{} mi := &file_changes_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeSummaryResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeSummaryResponse) ProtoMessage() {} func (x *GetChangeSummaryResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeSummaryResponse.ProtoReflect.Descriptor instead. func (*GetChangeSummaryResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{72} } func (x *GetChangeSummaryResponse) GetChange() string { if x != nil { return x.Change } return "" } func (x *GetChangeSummaryResponse) GetGithubAppCommentPosted() bool { if x != nil { return x.GithubAppCommentPosted } return false } type GetChangeSignalsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // Output format for the signals data (json by default) ChangeOutputFormat ChangeOutputFormat `protobuf:"varint,2,opt,name=changeOutputFormat,proto3,enum=changes.ChangeOutputFormat" json:"changeOutputFormat,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeSignalsRequest) Reset() { *x = GetChangeSignalsRequest{} mi := &file_changes_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeSignalsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeSignalsRequest) ProtoMessage() {} func (x *GetChangeSignalsRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeSignalsRequest.ProtoReflect.Descriptor instead. func (*GetChangeSignalsRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{73} } func (x *GetChangeSignalsRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *GetChangeSignalsRequest) GetChangeOutputFormat() ChangeOutputFormat { if x != nil { return x.ChangeOutputFormat } return ChangeOutputFormat_CHANGE_OUTPUT_FORMAT_UNSPECIFIED } type GetChangeSignalsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Signals string `protobuf:"bytes,1,opt,name=signals,proto3" json:"signals,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeSignalsResponse) Reset() { *x = GetChangeSignalsResponse{} mi := &file_changes_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeSignalsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeSignalsResponse) ProtoMessage() {} func (x *GetChangeSignalsResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeSignalsResponse.ProtoReflect.Descriptor instead. func (*GetChangeSignalsResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{74} } func (x *GetChangeSignalsResponse) GetSignals() string { if x != nil { return x.Signals } return "" } type GetChangeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Change *Change `protobuf:"bytes,1,opt,name=change,proto3" json:"change,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeResponse) Reset() { *x = GetChangeResponse{} mi := &file_changes_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeResponse) ProtoMessage() {} func (x *GetChangeResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeResponse.ProtoReflect.Descriptor instead. func (*GetChangeResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{75} } func (x *GetChangeResponse) GetChange() *Change { if x != nil { return x.Change } return nil } // get the details of a specific change type GetChangeRisksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeRisksRequest) Reset() { *x = GetChangeRisksRequest{} mi := &file_changes_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeRisksRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeRisksRequest) ProtoMessage() {} func (x *GetChangeRisksRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeRisksRequest.ProtoReflect.Descriptor instead. func (*GetChangeRisksRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{76} } func (x *GetChangeRisksRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type ChangeRiskMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // The status of the risk calculation ChangeAnalysisStatus *ChangeAnalysisStatus `protobuf:"bytes,1,opt,name=changeAnalysisStatus,proto3" json:"changeAnalysisStatus,omitempty"` // The risks that are related to this change Risks []*Risk `protobuf:"bytes,5,rep,name=risks,proto3" json:"risks,omitempty"` // The number of low risks in this change NumLowRisk int32 `protobuf:"varint,6,opt,name=numLowRisk,proto3" json:"numLowRisk,omitempty"` // The number of medium risks in this change NumMediumRisk int32 `protobuf:"varint,7,opt,name=numMediumRisk,proto3" json:"numMediumRisk,omitempty"` // The number of high risks in this change NumHighRisk int32 `protobuf:"varint,8,opt,name=numHighRisk,proto3" json:"numHighRisk,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeRiskMetadata) Reset() { *x = ChangeRiskMetadata{} mi := &file_changes_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeRiskMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeRiskMetadata) ProtoMessage() {} func (x *ChangeRiskMetadata) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeRiskMetadata.ProtoReflect.Descriptor instead. func (*ChangeRiskMetadata) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{77} } func (x *ChangeRiskMetadata) GetChangeAnalysisStatus() *ChangeAnalysisStatus { if x != nil { return x.ChangeAnalysisStatus } return nil } func (x *ChangeRiskMetadata) GetRisks() []*Risk { if x != nil { return x.Risks } return nil } func (x *ChangeRiskMetadata) GetNumLowRisk() int32 { if x != nil { return x.NumLowRisk } return 0 } func (x *ChangeRiskMetadata) GetNumMediumRisk() int32 { if x != nil { return x.NumMediumRisk } return 0 } func (x *ChangeRiskMetadata) GetNumHighRisk() int32 { if x != nil { return x.NumHighRisk } return 0 } type GetChangeRisksResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeRiskMetadata *ChangeRiskMetadata `protobuf:"bytes,1,opt,name=changeRiskMetadata,proto3" json:"changeRiskMetadata,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeRisksResponse) Reset() { *x = GetChangeRisksResponse{} mi := &file_changes_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeRisksResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeRisksResponse) ProtoMessage() {} func (x *GetChangeRisksResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeRisksResponse.ProtoReflect.Descriptor instead. func (*GetChangeRisksResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{78} } func (x *GetChangeRisksResponse) GetChangeRiskMetadata() *ChangeRiskMetadata { if x != nil { return x.ChangeRiskMetadata } return nil } // update an existing change type UpdateChangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` Properties *ChangeProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateChangeRequest) Reset() { *x = UpdateChangeRequest{} mi := &file_changes_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateChangeRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateChangeRequest) ProtoMessage() {} func (x *UpdateChangeRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateChangeRequest.ProtoReflect.Descriptor instead. func (*UpdateChangeRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{79} } func (x *UpdateChangeRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *UpdateChangeRequest) GetProperties() *ChangeProperties { if x != nil { return x.Properties } return nil } type UpdateChangeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Change *Change `protobuf:"bytes,1,opt,name=change,proto3" json:"change,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateChangeResponse) Reset() { *x = UpdateChangeResponse{} mi := &file_changes_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateChangeResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateChangeResponse) ProtoMessage() {} func (x *UpdateChangeResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateChangeResponse.ProtoReflect.Descriptor instead. func (*UpdateChangeResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{80} } func (x *UpdateChangeResponse) GetChange() *Change { if x != nil { return x.Change } return nil } // delete a change type DeleteChangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteChangeRequest) Reset() { *x = DeleteChangeRequest{} mi := &file_changes_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteChangeRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteChangeRequest) ProtoMessage() {} func (x *DeleteChangeRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteChangeRequest.ProtoReflect.Descriptor instead. func (*DeleteChangeRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{81} } func (x *DeleteChangeRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } // list changes for a snapshot UUID type ListChangesBySnapshotUUIDRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangesBySnapshotUUIDRequest) Reset() { *x = ListChangesBySnapshotUUIDRequest{} mi := &file_changes_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangesBySnapshotUUIDRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangesBySnapshotUUIDRequest) ProtoMessage() {} func (x *ListChangesBySnapshotUUIDRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangesBySnapshotUUIDRequest.ProtoReflect.Descriptor instead. func (*ListChangesBySnapshotUUIDRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{82} } func (x *ListChangesBySnapshotUUIDRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type ListChangesBySnapshotUUIDResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Changes []*Change `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListChangesBySnapshotUUIDResponse) Reset() { *x = ListChangesBySnapshotUUIDResponse{} mi := &file_changes_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListChangesBySnapshotUUIDResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListChangesBySnapshotUUIDResponse) ProtoMessage() {} func (x *ListChangesBySnapshotUUIDResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListChangesBySnapshotUUIDResponse.ProtoReflect.Descriptor instead. func (*ListChangesBySnapshotUUIDResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{83} } func (x *ListChangesBySnapshotUUIDResponse) GetChanges() []*Change { if x != nil { return x.Changes } return nil } type DeleteChangeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteChangeResponse) Reset() { *x = DeleteChangeResponse{} mi := &file_changes_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteChangeResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteChangeResponse) ProtoMessage() {} func (x *DeleteChangeResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteChangeResponse.ProtoReflect.Descriptor instead. func (*DeleteChangeResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{84} } type RefreshStateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RefreshStateRequest) Reset() { *x = RefreshStateRequest{} mi := &file_changes_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RefreshStateRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*RefreshStateRequest) ProtoMessage() {} func (x *RefreshStateRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RefreshStateRequest.ProtoReflect.Descriptor instead. func (*RefreshStateRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{85} } type RefreshStateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RefreshStateResponse) Reset() { *x = RefreshStateResponse{} mi := &file_changes_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RefreshStateResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*RefreshStateResponse) ProtoMessage() {} func (x *RefreshStateResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RefreshStateResponse.ProtoReflect.Descriptor instead. func (*RefreshStateResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{86} } type StartChangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartChangeRequest) Reset() { *x = StartChangeRequest{} mi := &file_changes_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StartChangeRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*StartChangeRequest) ProtoMessage() {} func (x *StartChangeRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StartChangeRequest.ProtoReflect.Descriptor instead. func (*StartChangeRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{87} } func (x *StartChangeRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type StartChangeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` State StartChangeResponse_State `protobuf:"varint,1,opt,name=state,proto3,enum=changes.StartChangeResponse_State" json:"state,omitempty"` NumItems uint32 `protobuf:"varint,2,opt,name=numItems,proto3" json:"numItems,omitempty"` NumEdges uint32 `protobuf:"varint,3,opt,name=NumEdges,proto3" json:"NumEdges,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartChangeResponse) Reset() { *x = StartChangeResponse{} mi := &file_changes_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StartChangeResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*StartChangeResponse) ProtoMessage() {} func (x *StartChangeResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StartChangeResponse.ProtoReflect.Descriptor instead. func (*StartChangeResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{88} } func (x *StartChangeResponse) GetState() StartChangeResponse_State { if x != nil { return x.State } return StartChangeResponse_STATE_UNSPECIFIED } func (x *StartChangeResponse) GetNumItems() uint32 { if x != nil { return x.NumItems } return 0 } func (x *StartChangeResponse) GetNumEdges() uint32 { if x != nil { return x.NumEdges } return 0 } type EndChangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *EndChangeRequest) Reset() { *x = EndChangeRequest{} mi := &file_changes_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *EndChangeRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*EndChangeRequest) ProtoMessage() {} func (x *EndChangeRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use EndChangeRequest.ProtoReflect.Descriptor instead. func (*EndChangeRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{89} } func (x *EndChangeRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type EndChangeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` State EndChangeResponse_State `protobuf:"varint,1,opt,name=state,proto3,enum=changes.EndChangeResponse_State" json:"state,omitempty"` NumItems uint32 `protobuf:"varint,2,opt,name=numItems,proto3" json:"numItems,omitempty"` NumEdges uint32 `protobuf:"varint,3,opt,name=NumEdges,proto3" json:"NumEdges,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *EndChangeResponse) Reset() { *x = EndChangeResponse{} mi := &file_changes_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *EndChangeResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*EndChangeResponse) ProtoMessage() {} func (x *EndChangeResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use EndChangeResponse.ProtoReflect.Descriptor instead. func (*EndChangeResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{90} } func (x *EndChangeResponse) GetState() EndChangeResponse_State { if x != nil { return x.State } return EndChangeResponse_STATE_UNSPECIFIED } func (x *EndChangeResponse) GetNumItems() uint32 { if x != nil { return x.NumItems } return 0 } func (x *EndChangeResponse) GetNumEdges() uint32 { if x != nil { return x.NumEdges } return 0 } type StartChangeSimpleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartChangeSimpleResponse) Reset() { *x = StartChangeSimpleResponse{} mi := &file_changes_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StartChangeSimpleResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*StartChangeSimpleResponse) ProtoMessage() {} func (x *StartChangeSimpleResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StartChangeSimpleResponse.ProtoReflect.Descriptor instead. func (*StartChangeSimpleResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{91} } type EndChangeSimpleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // True if the job was successfully enqueued (or queued to run after start-change) Queued bool `protobuf:"varint,1,opt,name=queued,proto3" json:"queued,omitempty"` // True if end-change was queued to run after start-change completes QueuedAfterStart bool `protobuf:"varint,2,opt,name=queued_after_start,json=queuedAfterStart,proto3" json:"queued_after_start,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *EndChangeSimpleResponse) Reset() { *x = EndChangeSimpleResponse{} mi := &file_changes_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *EndChangeSimpleResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*EndChangeSimpleResponse) ProtoMessage() {} func (x *EndChangeSimpleResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use EndChangeSimpleResponse.ProtoReflect.Descriptor instead. func (*EndChangeSimpleResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{92} } func (x *EndChangeSimpleResponse) GetQueued() bool { if x != nil { return x.Queued } return false } func (x *EndChangeSimpleResponse) GetQueuedAfterStart() bool { if x != nil { return x.QueuedAfterStart } return false } type Risk struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,5,opt,name=UUID,proto3" json:"UUID,omitempty"` Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` Severity Risk_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=changes.Risk_Severity" json:"severity,omitempty"` Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` RelatedItemRefs []*Reference `protobuf:"bytes,4,rep,name=relatedItemRefs,proto3" json:"relatedItemRefs,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Risk) Reset() { *x = Risk{} mi := &file_changes_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Risk) String() string { return protoimpl.X.MessageStringOf(x) } func (*Risk) ProtoMessage() {} func (x *Risk) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Risk.ProtoReflect.Descriptor instead. func (*Risk) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{93} } func (x *Risk) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *Risk) GetTitle() string { if x != nil { return x.Title } return "" } func (x *Risk) GetSeverity() Risk_Severity { if x != nil { return x.Severity } return Risk_SEVERITY_UNSPECIFIED } func (x *Risk) GetDescription() string { if x != nil { return x.Description } return "" } func (x *Risk) GetRelatedItemRefs() []*Reference { if x != nil { return x.RelatedItemRefs } return nil } type ChangeAnalysisStatus struct { state protoimpl.MessageState `protogen:"open.v1"` Status ChangeAnalysisStatus_Status `protobuf:"varint,1,opt,name=status,proto3,enum=changes.ChangeAnalysisStatus_Status" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeAnalysisStatus) Reset() { *x = ChangeAnalysisStatus{} mi := &file_changes_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeAnalysisStatus) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeAnalysisStatus) ProtoMessage() {} func (x *ChangeAnalysisStatus) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeAnalysisStatus.ProtoReflect.Descriptor instead. func (*ChangeAnalysisStatus) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{94} } func (x *ChangeAnalysisStatus) GetStatus() ChangeAnalysisStatus_Status { if x != nil { return x.Status } return ChangeAnalysisStatus_STATUS_UNSPECIFIED } // Generate fix suggestion for a risk type GenerateRiskFixRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The UUID of the risk to generate a fix for RiskUUID []byte `protobuf:"bytes,1,opt,name=riskUUID,proto3" json:"riskUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GenerateRiskFixRequest) Reset() { *x = GenerateRiskFixRequest{} mi := &file_changes_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GenerateRiskFixRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GenerateRiskFixRequest) ProtoMessage() {} func (x *GenerateRiskFixRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GenerateRiskFixRequest.ProtoReflect.Descriptor instead. func (*GenerateRiskFixRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{95} } func (x *GenerateRiskFixRequest) GetRiskUUID() []byte { if x != nil { return x.RiskUUID } return nil } type GenerateRiskFixResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The generated fix suggestion text FixSuggestion string `protobuf:"bytes,1,opt,name=fixSuggestion,proto3" json:"fixSuggestion,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GenerateRiskFixResponse) Reset() { *x = GenerateRiskFixResponse{} mi := &file_changes_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GenerateRiskFixResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GenerateRiskFixResponse) ProtoMessage() {} func (x *GenerateRiskFixResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GenerateRiskFixResponse.ProtoReflect.Descriptor instead. func (*GenerateRiskFixResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{96} } func (x *GenerateRiskFixResponse) GetFixSuggestion() string { if x != nil { return x.FixSuggestion } return "" } // Submit user feedback on a risk type SubmitRiskFeedbackRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RiskUuid []byte `protobuf:"bytes,1,opt,name=risk_uuid,json=riskUuid,proto3" json:"risk_uuid,omitempty"` Sentiment RiskFeedbackSentiment `protobuf:"varint,2,opt,name=sentiment,proto3,enum=changes.RiskFeedbackSentiment" json:"sentiment,omitempty"` FeedbackText string `protobuf:"bytes,3,opt,name=feedback_text,json=feedbackText,proto3" json:"feedback_text,omitempty"` Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // extensible key-value pairs (e.g. utm_source, surface) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SubmitRiskFeedbackRequest) Reset() { *x = SubmitRiskFeedbackRequest{} mi := &file_changes_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SubmitRiskFeedbackRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubmitRiskFeedbackRequest) ProtoMessage() {} func (x *SubmitRiskFeedbackRequest) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubmitRiskFeedbackRequest.ProtoReflect.Descriptor instead. func (*SubmitRiskFeedbackRequest) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{97} } func (x *SubmitRiskFeedbackRequest) GetRiskUuid() []byte { if x != nil { return x.RiskUuid } return nil } func (x *SubmitRiskFeedbackRequest) GetSentiment() RiskFeedbackSentiment { if x != nil { return x.Sentiment } return RiskFeedbackSentiment_RISK_FEEDBACK_SENTIMENT_UNSPECIFIED } func (x *SubmitRiskFeedbackRequest) GetFeedbackText() string { if x != nil { return x.FeedbackText } return "" } func (x *SubmitRiskFeedbackRequest) GetMetadata() map[string]string { if x != nil { return x.Metadata } return nil } type SubmitRiskFeedbackResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SubmitRiskFeedbackResponse) Reset() { *x = SubmitRiskFeedbackResponse{} mi := &file_changes_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SubmitRiskFeedbackResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubmitRiskFeedbackResponse) ProtoMessage() {} func (x *SubmitRiskFeedbackResponse) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubmitRiskFeedbackResponse.ProtoReflect.Descriptor instead. func (*SubmitRiskFeedbackResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{98} } // Represents the current state of a given health state, and the amount that // it has changed. This doesn't just look at the change in total number of // items, but also the number of items that have been added and removed, even // if they were to add to the same number type ChangeMetadata_HealthChange struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of items that were added to this health state as part of the // change Added int32 `protobuf:"varint,1,opt,name=added,proto3" json:"added,omitempty"` // The number of items that were removed them this health state as part of // the change Removed int32 `protobuf:"varint,2,opt,name=removed,proto3" json:"removed,omitempty"` // The final number of items that were in this health state FinalTotal int32 `protobuf:"varint,3,opt,name=finalTotal,proto3" json:"finalTotal,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeMetadata_HealthChange) Reset() { *x = ChangeMetadata_HealthChange{} mi := &file_changes_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeMetadata_HealthChange) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeMetadata_HealthChange) ProtoMessage() {} func (x *ChangeMetadata_HealthChange) ProtoReflect() protoreflect.Message { mi := &file_changes_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeMetadata_HealthChange.ProtoReflect.Descriptor instead. func (*ChangeMetadata_HealthChange) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{60, 0} } func (x *ChangeMetadata_HealthChange) GetAdded() int32 { if x != nil { return x.Added } return 0 } func (x *ChangeMetadata_HealthChange) GetRemoved() int32 { if x != nil { return x.Removed } return 0 } func (x *ChangeMetadata_HealthChange) GetFinalTotal() int32 { if x != nil { return x.FinalTotal } return 0 } var File_changes_proto protoreflect.FileDescriptor const file_changes_proto_rawDesc = "" + "\n" + "\rchanges.proto\x12\achanges\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\fconfig.proto\x1a\vitems.proto\x1a\n" + "util.proto\"\x81\x01\n" + "\tLabelRule\x126\n" + "\bmetadata\x18\x01 \x01(\v2\x1a.changes.LabelRuleMetadataR\bmetadata\x12<\n" + "\n" + "properties\x18\x02 \x01(\v2\x1c.changes.LabelRulePropertiesR\n" + "properties\"\xad\x01\n" + "\x11LabelRuleMetadata\x12$\n" + "\rLabelRuleUUID\x18\x01 \x01(\fR\rLabelRuleUUID\x128\n" + "\tcreatedAt\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x128\n" + "\tupdatedAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"e\n" + "\x13LabelRuleProperties\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + "\x06colour\x18\x02 \x01(\tR\x06colour\x12\"\n" + "\finstructions\x18\x03 \x01(\tR\finstructions\"\x17\n" + "\x15ListLabelRulesRequest\"B\n" + "\x16ListLabelRulesResponse\x12(\n" + "\x05rules\x18\x01 \x03(\v2\x12.changes.LabelRuleR\x05rules\"V\n" + "\x16CreateLabelRuleRequest\x12<\n" + "\n" + "properties\x18\x01 \x01(\v2\x1c.changes.LabelRulePropertiesR\n" + "properties\"A\n" + "\x17CreateLabelRuleResponse\x12&\n" + "\x04rule\x18\x01 \x01(\v2\x12.changes.LabelRuleR\x04rule\")\n" + "\x13GetLabelRuleRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\">\n" + "\x14GetLabelRuleResponse\x12&\n" + "\x04rule\x18\x01 \x01(\v2\x12.changes.LabelRuleR\x04rule\"j\n" + "\x16UpdateLabelRuleRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12<\n" + "\n" + "properties\x18\x02 \x01(\v2\x1c.changes.LabelRulePropertiesR\n" + "properties\"A\n" + "\x17UpdateLabelRuleResponse\x12&\n" + "\x04rule\x18\x01 \x01(\v2\x12.changes.LabelRuleR\x04rule\",\n" + "\x16DeleteLabelRuleRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"\x19\n" + "\x17DeleteLabelRuleResponse\"t\n" + "\x14TestLabelRuleRequest\x12<\n" + "\n" + "properties\x18\x01 \x01(\v2\x1c.changes.LabelRulePropertiesR\n" + "properties\x12\x1e\n" + "\n" + "changeUUID\x18\x02 \x03(\fR\n" + "changeUUID\"w\n" + "\x15TestLabelRuleResponse\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\x12\x18\n" + "\aapplied\x18\x02 \x01(\bR\aapplied\x12$\n" + "\x05label\x18\x03 \x01(\v2\x0e.changes.LabelR\x05label\"\xa0\x01\n" + "\"ReapplyLabelRuleInTimeRangeRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x124\n" + "\astartAt\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\astartAt\x120\n" + "\x05endAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x05endAt\"E\n" + "#ReapplyLabelRuleInTimeRangeResponse\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x03(\fR\n" + "changeUUID\"D\n" + "\x12KnowledgeReference\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n" + "\bfileName\x18\x02 \x01(\tR\bfileName\"w\n" + "\tKnowledge\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x18\n" + "\acontent\x18\x03 \x01(\tR\acontent\x12\x1a\n" + "\bfileName\x18\x04 \x01(\tR\bfileName\"=\n" + "\x1bGetHypothesesDetailsRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"Z\n" + "\x1cGetHypothesesDetailsResponse\x12:\n" + "\n" + "hypotheses\x18\x01 \x03(\v2\x1a.changes.HypothesesDetailsR\n" + "hypotheses\"\x95\x02\n" + "\x11HypothesesDetails\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12(\n" + "\x0fnumObservations\x18\x02 \x01(\rR\x0fnumObservations\x12\x16\n" + "\x06detail\x18\x03 \x01(\tR\x06detail\x121\n" + "\x06status\x18\x04 \x01(\x0e2\x19.changes.HypothesisStatusR\x06status\x122\n" + "\x14investigationResults\x18\x05 \x01(\tR\x14investigationResults\x12A\n" + "\rknowledgeUsed\x18\x06 \x03(\v2\x1b.changes.KnowledgeReferenceR\rknowledgeUsed\"<\n" + "\x1aGetChangeTimelineV2Request\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"W\n" + "\x1bGetChangeTimelineV2Response\x128\n" + "\aentries\x18\x01 \x03(\v2\x1e.changes.ChangeTimelineEntryV2R\aentries\"\xd6\b\n" + "\x15ChangeTimelineEntryV2\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12:\n" + "\x06status\x18\x02 \x01(\x0e2\".changes.ChangeTimelineEntryStatusR\x06status\x12=\n" + "\tstartedAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\tstartedAt\x88\x01\x01\x129\n" + "\aendedAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampH\x02R\aendedAt\x88\x01\x01\x12\x19\n" + "\x05actor\x18\x05 \x01(\tH\x03R\x05actor\x88\x01\x01\x12E\n" + "\vmappedItems\x18\a \x01(\v2!.changes.MappedItemsTimelineEntryH\x00R\vmappedItems\x12c\n" + "\x15calculatedBlastRadius\x18\b \x01(\v2+.changes.CalculatedBlastRadiusTimelineEntryH\x00R\x15calculatedBlastRadius\x12Q\n" + "\x0fcalculatedRisks\x18\t \x01(\v2%.changes.CalculatedRisksTimelineEntryH\x00R\x0fcalculatedRisks\x12\x16\n" + "\x05error\x18\v \x01(\tH\x00R\x05error\x12&\n" + "\rstatusMessage\x18\f \x01(\tH\x00R\rstatusMessage\x12-\n" + "\x05empty\x18\r \x01(\v2\x15.changes.EmptyContentH\x00R\x05empty\x12T\n" + "\x10changeValidation\x18\x0e \x01(\v2&.changes.ChangeValidationTimelineEntryH\x00R\x10changeValidation\x12T\n" + "\x10calculatedLabels\x18\x0f \x01(\v2&.changes.CalculatedLabelsTimelineEntryH\x00R\x10calculatedLabels\x12N\n" + "\x0eformHypotheses\x18\x10 \x01(\v2$.changes.FormHypothesesTimelineEntryH\x00R\x0eformHypotheses\x12c\n" + "\x15investigateHypotheses\x18\x11 \x01(\v2+.changes.InvestigateHypothesesTimelineEntryH\x00R\x15investigateHypotheses\x12Z\n" + "\x12recordObservations\x18\x12 \x01(\v2(.changes.RecordObservationsTimelineEntryH\x00R\x12recordObservationsB\t\n" + "\acontentB\f\n" + "\n" + "_startedAtB\n" + "\n" + "\b_endedAtB\b\n" + "\x06_actor\"\x0e\n" + "\fEmptyContent\"\xb5\x01\n" + "\x19MappedItemTimelineSummary\x12!\n" + "\fdisplay_name\x18\x01 \x01(\tR\vdisplayName\x129\n" + "\x06status\x18\x02 \x01(\x0e2!.changes.MappedItemTimelineStatusR\x06status\x12(\n" + "\rerror_message\x18\x03 \x01(\tH\x00R\ferrorMessage\x88\x01\x01B\x10\n" + "\x0e_error_message\"\x93\x01\n" + "\x18MappedItemsTimelineEntry\x12=\n" + "\vmappedItems\x18\x01 \x03(\v2\x17.changes.MappedItemDiffB\x02\x18\x01R\vmappedItems\x128\n" + "\x05items\x18\x02 \x03(\v2\".changes.MappedItemTimelineSummaryR\x05items\"b\n" + "\"CalculatedBlastRadiusTimelineEntry\x12\x1a\n" + "\bnumItems\x18\x01 \x01(\rR\bnumItems\x12\x1a\n" + "\bnumEdges\x18\x02 \x01(\rR\bnumEdgesJ\x04\b\x04\x10\x05\"K\n" + "\x1fRecordObservationsTimelineEntry\x12(\n" + "\x0fnumObservations\x18\x01 \x01(\rR\x0fnumObservations\"\x7f\n" + "\x1bFormHypothesesTimelineEntry\x12$\n" + "\rnumHypotheses\x18\x01 \x01(\rR\rnumHypotheses\x12:\n" + "\n" + "hypotheses\x18\x02 \x03(\v2\x1a.changes.HypothesisSummaryR\n" + "hypotheses\"\xee\x01\n" + "\"InvestigateHypothesesTimelineEntry\x12\x1c\n" + "\tnumProven\x18\x01 \x01(\rR\tnumProven\x12\"\n" + "\fnumDisproven\x18\x02 \x01(\rR\fnumDisproven\x12*\n" + "\x10numInvestigating\x18\x03 \x01(\rR\x10numInvestigating\x12:\n" + "\n" + "hypotheses\x18\x04 \x03(\v2\x1a.changes.HypothesisSummaryR\n" + "hypotheses\x12\x1e\n" + "\n" + "numSkipped\x18\x05 \x01(\rR\n" + "numSkipped\"t\n" + "\x11HypothesisSummary\x121\n" + "\x06status\x18\x01 \x01(\x0e2\x19.changes.HypothesisStatusR\x06status\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12\x16\n" + "\x06detail\x18\x03 \x01(\tR\x06detail\"C\n" + "\x1cCalculatedRisksTimelineEntry\x12#\n" + "\x05risks\x18\x01 \x03(\v2\r.changes.RiskR\x05risks\"G\n" + "\x1dCalculatedLabelsTimelineEntry\x12&\n" + "\x06labels\x18\x01 \x03(\v2\x0e.changes.LabelR\x06labels\"\x9a\x01\n" + "\x1dChangeValidationTimelineEntry\x12$\n" + "\rbriefAnalysis\x18\x01 \x01(\tR\rbriefAnalysis\x12S\n" + "\x13validationChecklist\x18\x02 \x03(\v2!.changes.ChangeValidationCategoryR\x13validationChecklist\"R\n" + "\x18ChangeValidationCategory\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\"0\n" + "\x0eGetDiffRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"\xdb\x01\n" + "\x0fGetDiffResponse\x127\n" + "\rexpectedItems\x18\x01 \x03(\v2\x11.changes.ItemDiffR\rexpectedItems\x12;\n" + "\x0funexpectedItems\x18\x03 \x03(\v2\x11.changes.ItemDiffR\x0funexpectedItems\x12\x1b\n" + "\x05edges\x18\x02 \x03(\v2\x05.EdgeR\x05edges\x125\n" + "\fmissingItems\x18\x04 \x03(\v2\x11.changes.ItemDiffR\fmissingItems\"A\n" + "\x1fListChangingItemsSummaryRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"R\n" + " ListChangingItemsSummaryResponse\x12.\n" + "\x05items\x18\x01 \x03(\v2\x18.changes.ItemDiffSummaryR\x05items\"\xa1\x02\n" + "\x0eMappedItemDiff\x12%\n" + "\x04item\x18\x01 \x01(\v2\x11.changes.ItemDiffR\x04item\x12/\n" + "\fmappingQuery\x18\x02 \x01(\v2\x06.QueryH\x00R\fmappingQuery\x88\x01\x01\x124\n" + "\fmappingError\x18\x03 \x01(\v2\v.QueryErrorH\x01R\fmappingError\x88\x01\x01\x12L\n" + "\x0emapping_status\x18\x04 \x01(\x0e2 .changes.MappedItemMappingStatusH\x02R\rmappingStatus\x88\x01\x01B\x0f\n" + "\r_mappingQueryB\x0f\n" + "\r_mappingErrorB\x11\n" + "\x0f_mapping_status\"\x83\x05\n" + "\x1aStartChangeAnalysisRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\x12=\n" + "\rchangingItems\x18\x02 \x03(\v2\x17.changes.MappedItemDiffR\rchangingItems\x12\\\n" + "\x19blastRadiusConfigOverride\x18\x03 \x01(\v2\x19.config.BlastRadiusConfigH\x00R\x19blastRadiusConfigOverride\x88\x01\x01\x12e\n" + "\x1croutineChangesConfigOverride\x18\x05 \x01(\v2\x1c.config.RoutineChangesConfigH\x01R\x1croutineChangesConfigOverride\x88\x01\x01\x12t\n" + "!githubOrganisationProfileOverride\x18\x06 \x01(\v2!.config.GithubOrganisationProfileH\x02R!githubOrganisationProfileOverride\x88\x01\x01\x120\n" + "\tknowledge\x18\a \x03(\v2\x12.changes.KnowledgeR\tknowledge\x12.\n" + "\x13post_github_comment\x18\b \x01(\bR\x11postGithubCommentB\x1c\n" + "\x1a_blastRadiusConfigOverrideB\x1f\n" + "\x1d_routineChangesConfigOverrideB$\n" + "\"_githubOrganisationProfileOverrideJ\x04\b\x04\x10\x05\"I\n" + "\x1bStartChangeAnalysisResponse\x12*\n" + "\x11github_app_active\x18\x01 \x01(\bR\x0fgithubAppActive\"y\n" + "\x18AddPlannedChangesRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\x12=\n" + "\rchangingItems\x18\x02 \x03(\v2\x17.changes.MappedItemDiffR\rchangingItems\"\x1b\n" + "\x19AddPlannedChangesResponse\"\x96\x01\n" + "\x16ListHomeChangesRequest\x122\n" + "\n" + "pagination\x18\x01 \x01(\v2\x12.PaginationRequestR\n" + "pagination\x12<\n" + "\afilters\x18\x02 \x01(\v2\x1d.changes.ChangeFiltersRequestH\x00R\afilters\x88\x01\x01B\n" + "\n" + "\b_filters\"\x82\x02\n" + "\x14ChangeFiltersRequest\x12\x14\n" + "\x05repos\x18\x01 \x03(\tR\x05repos\x12,\n" + "\x05risks\x18\x03 \x03(\x0e2\x16.changes.Risk.SeverityR\x05risks\x12\x18\n" + "\aauthors\x18\x04 \x03(\tR\aauthors\x121\n" + "\bstatuses\x18\x05 \x03(\x0e2\x15.changes.ChangeStatusR\bstatuses\x12-\n" + "\tsortOrder\x18\x06 \x01(\x0e2\n" + ".SortOrderH\x00R\tsortOrder\x88\x01\x01\x12\x16\n" + "\x06labels\x18\a \x03(\tR\x06labelsB\f\n" + "\n" + "_sortOrderJ\x04\b\x02\x10\x03\"\x80\x01\n" + "\x17ListHomeChangesResponse\x120\n" + "\achanges\x18\x01 \x03(\v2\x16.changes.ChangeSummaryR\achanges\x123\n" + "\n" + "pagination\x18\x02 \x01(\v2\x13.PaginationResponseR\n" + "pagination\"\x1e\n" + "\x1cPopulateChangeFiltersRequest\"O\n" + "\x1dPopulateChangeFiltersResponse\x12\x14\n" + "\x05repos\x18\x01 \x03(\tR\x05repos\x12\x18\n" + "\aauthors\x18\x02 \x03(\tR\aauthors\"\x93\x01\n" + "\x0fItemDiffSummary\x12$\n" + "\aitemRef\x18\x01 \x01(\v2\n" + ".ReferenceR\aitemRef\x12/\n" + "\x06status\x18\x04 \x01(\x0e2\x17.changes.ItemDiffStatusR\x06status\x12)\n" + "\vhealthAfter\x18\x05 \x01(\x0e2\a.HealthR\vhealthAfter\"\xa0\x02\n" + "\bItemDiff\x12#\n" + "\x04item\x18\x01 \x01(\v2\n" + ".ReferenceH\x00R\x04item\x88\x01\x01\x12/\n" + "\x06status\x18\x02 \x01(\x0e2\x17.changes.ItemDiffStatusR\x06status\x12\x1d\n" + "\x06before\x18\x03 \x01(\v2\x05.ItemR\x06before\x12\x1b\n" + "\x05after\x18\x04 \x01(\v2\x05.ItemR\x05after\x120\n" + "\x13modificationSummary\x18\x05 \x01(\tR\x13modificationSummary\x125\n" + "\rmappedItemRef\x18\x06 \x01(\v2\n" + ".ReferenceH\x01R\rmappedItemRef\x88\x01\x01B\a\n" + "\x05_itemB\x10\n" + "\x0e_mappedItemRef\"\x9f\x01\n" + "\fEnrichedTags\x12?\n" + "\btagValue\x18\x12 \x03(\v2#.changes.EnrichedTags.TagValueEntryR\btagValue\x1aN\n" + "\rTagValueEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12'\n" + "\x05value\x18\x02 \x01(\v2\x11.changes.TagValueR\x05value:\x028\x01\"\x8d\x01\n" + "\bTagValue\x12;\n" + "\fuserTagValue\x18\x01 \x01(\v2\x15.changes.UserTagValueH\x00R\fuserTagValue\x12;\n" + "\fautoTagValue\x18\x02 \x01(\v2\x15.changes.AutoTagValueH\x00R\fautoTagValueB\a\n" + "\x05value\"$\n" + "\fUserTagValue\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\"B\n" + "\fAutoTagValue\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12\x1c\n" + "\treasoning\x18\x02 \x01(\tR\treasoning\"\xcb\x01\n" + "\x05Label\x12&\n" + "\x04type\x18\x01 \x01(\x0e2\x12.changes.LabelTypeR\x04type\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + "\x06colour\x18\x03 \x01(\tR\x06colour\x12$\n" + "\rlabelRuleUUID\x18\x04 \x01(\fR\rlabelRuleUUID\x12.\n" + "\x12autoLabelReasoning\x18\x05 \x01(\tR\x12autoLabelReasoning\x12\x18\n" + "\askipped\x18\x06 \x01(\bR\askipped\"\xa1\x06\n" + "\rChangeSummary\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12-\n" + "\x06status\x18\x03 \x01(\x0e2\x15.changes.ChangeStatusR\x06status\x12\x1e\n" + "\n" + "ticketLink\x18\x04 \x01(\tR\n" + "ticketLink\x128\n" + "\tcreatedAt\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12 \n" + "\vcreatorName\x18\x06 \x01(\tR\vcreatorName\x12\"\n" + "\fcreatorEmail\x18\x0f \x01(\tR\fcreatorEmail\x12*\n" + "\x10numAffectedItems\x18\t \x01(\x05R\x10numAffectedItems\x12*\n" + "\x10numAffectedEdges\x18\n" + " \x01(\x05R\x10numAffectedEdges\x12\x1e\n" + "\n" + "numLowRisk\x18\v \x01(\x05R\n" + "numLowRisk\x12$\n" + "\rnumMediumRisk\x18\f \x01(\x05R\rnumMediumRisk\x12 \n" + "\vnumHighRisk\x18\r \x01(\x05R\vnumHighRisk\x12 \n" + "\vdescription\x18\x0e \x01(\tR\vdescription\x12\x12\n" + "\x04repo\x18\x10 \x01(\tR\x04repo\x128\n" + "\x04tags\x18\x11 \x03(\v2 .changes.ChangeSummary.TagsEntryB\x02\x18\x01R\x04tags\x129\n" + "\fenrichedTags\x18\x12 \x01(\v2\x15.changes.EnrichedTagsR\fenrichedTags\x12&\n" + "\x06labels\x18\x13 \x03(\v2\x0e.changes.LabelR\x06labels\x12E\n" + "\x10githubChangeInfo\x18\x14 \x01(\v2\x19.changes.GithubChangeInfoR\x10githubChangeInfo\x1a7\n" + "\tTagsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\b\x10\t\"x\n" + "\x06Change\x123\n" + "\bmetadata\x18\x01 \x01(\v2\x17.changes.ChangeMetadataR\bmetadata\x129\n" + "\n" + "properties\x18\x02 \x01(\v2\x19.changes.ChangePropertiesR\n" + "properties\"\xb2\n" + "\n" + "\x0eChangeMetadata\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x128\n" + "\tcreatedAt\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x128\n" + "\tupdatedAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12-\n" + "\x06status\x18\x04 \x01(\x0e2\x15.changes.ChangeStatusR\x06status\x12 \n" + "\vcreatorName\x18\x05 \x01(\tR\vcreatorName\x12\"\n" + "\fcreatorEmail\x18\x13 \x01(\tR\fcreatorEmail\x12*\n" + "\x10numAffectedItems\x18\a \x01(\x05R\x10numAffectedItems\x12*\n" + "\x10numAffectedEdges\x18\x11 \x01(\x05R\x10numAffectedEdges\x12,\n" + "\x11numUnchangedItems\x18\b \x01(\x05R\x11numUnchangedItems\x12(\n" + "\x0fnumCreatedItems\x18\t \x01(\x05R\x0fnumCreatedItems\x12(\n" + "\x0fnumUpdatedItems\x18\n" + " \x01(\x05R\x0fnumUpdatedItems\x12*\n" + "\x10numReplacedItems\x18\x12 \x01(\x05R\x10numReplacedItems\x12(\n" + "\x0fnumDeletedItems\x18\v \x01(\x05R\x0fnumDeletedItems\x12V\n" + "\x13UnknownHealthChange\x18\f \x01(\v2$.changes.ChangeMetadata.HealthChangeR\x13UnknownHealthChange\x12L\n" + "\x0eOkHealthChange\x18\r \x01(\v2$.changes.ChangeMetadata.HealthChangeR\x0eOkHealthChange\x12V\n" + "\x13WarningHealthChange\x18\x0e \x01(\v2$.changes.ChangeMetadata.HealthChangeR\x13WarningHealthChange\x12R\n" + "\x11ErrorHealthChange\x18\x0f \x01(\v2$.changes.ChangeMetadata.HealthChangeR\x11ErrorHealthChange\x12V\n" + "\x13PendingHealthChange\x18\x10 \x01(\v2$.changes.ChangeMetadata.HealthChangeR\x13PendingHealthChange\x12E\n" + "\x10githubChangeInfo\x18\x14 \x01(\v2\x19.changes.GithubChangeInfoR\x10githubChangeInfo\x122\n" + "\x12total_observations\x18\x15 \x01(\rH\x00R\x11totalObservations\x88\x01\x01\x12Q\n" + "\x14changeAnalysisStatus\x18\x16 \x01(\v2\x1d.changes.ChangeAnalysisStatusR\x14changeAnalysisStatus\x1a^\n" + "\fHealthChange\x12\x14\n" + "\x05added\x18\x01 \x01(\x05R\x05added\x12\x18\n" + "\aremoved\x18\x02 \x01(\x05R\aremoved\x12\x1e\n" + "\n" + "finalTotal\x18\x03 \x01(\x05R\n" + "finalTotalB\x15\n" + "\x13_total_observationsJ\x04\b\x06\x10\a\"\x8c\x06\n" + "\x10ChangeProperties\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x1e\n" + "\n" + "ticketLink\x18\x04 \x01(\tR\n" + "ticketLink\x12\x14\n" + "\x05owner\x18\x05 \x01(\tR\x05owner\x12\x1a\n" + "\bccEmails\x18\x06 \x01(\tR\bccEmails\x12<\n" + "\x19changingItemsBookmarkUUID\x18\a \x01(\fR\x19changingItemsBookmarkUUID\x128\n" + "\x17blastRadiusSnapshotUUID\x18\v \x01(\fR\x17blastRadiusSnapshotUUID\x12:\n" + "\x18systemBeforeSnapshotUUID\x18\b \x01(\fR\x18systemBeforeSnapshotUUID\x128\n" + "\x17systemAfterSnapshotUUID\x18\t \x01(\fR\x17systemAfterSnapshotUUID\x129\n" + "\x0eplannedChanges\x18\f \x03(\v2\x11.changes.ItemDiffR\x0eplannedChanges\x12\x18\n" + "\arawPlan\x18\r \x01(\tR\arawPlan\x12 \n" + "\vcodeChanges\x18\x0e \x01(\tR\vcodeChanges\x12\x12\n" + "\x04repo\x18\x0f \x01(\tR\x04repo\x12;\n" + "\x04tags\x18\x10 \x03(\v2#.changes.ChangeProperties.TagsEntryB\x02\x18\x01R\x04tags\x129\n" + "\fenrichedTags\x18\x12 \x01(\v2\x15.changes.EnrichedTagsR\fenrichedTags\x12&\n" + "\x06labels\x18\x15 \x03(\v2\x0e.changes.LabelR\x06labels\x1a7\n" + "\tTagsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x01\x10\x02J\x04\b\n" + "\x10\vJ\x04\b\x11\x10\x12J\x04\b\x13\x10\x14J\x04\b\x14\x10\x15\"\xb0\x01\n" + "\x10GithubChangeInfo\x12&\n" + "\x0eauthorUsername\x18\x01 \x01(\tR\x0eauthorUsername\x12&\n" + "\x0eauthorFullName\x18\x02 \x01(\tR\x0eauthorFullName\x12*\n" + "\x10authorAvatarLink\x18\x03 \x01(\tR\x10authorAvatarLink\x12 \n" + "\vauthorEmail\x18\x04 \x01(\tR\vauthorEmail\"\x14\n" + "\x12ListChangesRequest\"@\n" + "\x13ListChangesResponse\x12)\n" + "\achanges\x18\x01 \x03(\v2\x0f.changes.ChangeR\achanges\"K\n" + "\x1aListChangesByStatusRequest\x12-\n" + "\x06status\x18\x01 \x01(\x0e2\x15.changes.ChangeStatusR\x06status\"H\n" + "\x1bListChangesByStatusResponse\x12)\n" + "\achanges\x18\x01 \x03(\v2\x0f.changes.ChangeR\achanges\"P\n" + "\x13CreateChangeRequest\x129\n" + "\n" + "properties\x18\x01 \x01(\v2\x19.changes.ChangePropertiesR\n" + "properties\"?\n" + "\x14CreateChangeResponse\x12'\n" + "\x06change\x18\x01 \x01(\v2\x0f.changes.ChangeR\x06change\":\n" + "\x10GetChangeRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x12\n" + "\x04slim\x18\x02 \x01(\bR\x04slim\">\n" + "\x1cGetChangeByTicketLinkRequest\x12\x1e\n" + "\n" + "ticketLink\x18\x01 \x01(\tR\n" + "ticketLink\"\xee\x01\n" + "\x17GetChangeSummaryRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x12\n" + "\x04slim\x18\x02 \x01(\bR\x04slim\x12K\n" + "\x12changeOutputFormat\x18\x03 \x01(\x0e2\x1b.changes.ChangeOutputFormatR\x12changeOutputFormat\x12F\n" + "\x12riskSeverityFilter\x18\x04 \x03(\x0e2\x16.changes.Risk.SeverityR\x12riskSeverityFilter\x12\x16\n" + "\x06appURL\x18\x05 \x01(\tR\x06appURL\"m\n" + "\x18GetChangeSummaryResponse\x12\x16\n" + "\x06change\x18\x01 \x01(\tR\x06change\x129\n" + "\x19github_app_comment_posted\x18\x02 \x01(\bR\x16githubAppCommentPosted\"z\n" + "\x17GetChangeSignalsRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12K\n" + "\x12changeOutputFormat\x18\x02 \x01(\x0e2\x1b.changes.ChangeOutputFormatR\x12changeOutputFormat\"4\n" + "\x18GetChangeSignalsResponse\x12\x18\n" + "\asignals\x18\x01 \x01(\tR\asignals\"<\n" + "\x11GetChangeResponse\x12'\n" + "\x06change\x18\x01 \x01(\v2\x0f.changes.ChangeR\x06change\"+\n" + "\x15GetChangeRisksRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"\xf4\x01\n" + "\x12ChangeRiskMetadata\x12Q\n" + "\x14changeAnalysisStatus\x18\x01 \x01(\v2\x1d.changes.ChangeAnalysisStatusR\x14changeAnalysisStatus\x12#\n" + "\x05risks\x18\x05 \x03(\v2\r.changes.RiskR\x05risks\x12\x1e\n" + "\n" + "numLowRisk\x18\x06 \x01(\x05R\n" + "numLowRisk\x12$\n" + "\rnumMediumRisk\x18\a \x01(\x05R\rnumMediumRisk\x12 \n" + "\vnumHighRisk\x18\b \x01(\x05R\vnumHighRisk\"e\n" + "\x16GetChangeRisksResponse\x12K\n" + "\x12changeRiskMetadata\x18\x01 \x01(\v2\x1b.changes.ChangeRiskMetadataR\x12changeRiskMetadata\"d\n" + "\x13UpdateChangeRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x129\n" + "\n" + "properties\x18\x02 \x01(\v2\x19.changes.ChangePropertiesR\n" + "properties\"?\n" + "\x14UpdateChangeResponse\x12'\n" + "\x06change\x18\x01 \x01(\v2\x0f.changes.ChangeR\x06change\")\n" + "\x13DeleteChangeRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"6\n" + " ListChangesBySnapshotUUIDRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"N\n" + "!ListChangesBySnapshotUUIDResponse\x12)\n" + "\achanges\x18\x01 \x03(\v2\x0f.changes.ChangeR\achanges\"\x16\n" + "\x14DeleteChangeResponse\"\x15\n" + "\x13RefreshStateRequest\"\x16\n" + "\x14RefreshStateResponse\"4\n" + "\x12StartChangeRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"\xed\x01\n" + "\x13StartChangeResponse\x128\n" + "\x05state\x18\x01 \x01(\x0e2\".changes.StartChangeResponse.StateR\x05state\x12\x1a\n" + "\bnumItems\x18\x02 \x01(\rR\bnumItems\x12\x1a\n" + "\bNumEdges\x18\x03 \x01(\rR\bNumEdges\"d\n" + "\x05State\x12\x15\n" + "\x11STATE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15STATE_TAKING_SNAPSHOT\x10\x01\x12\x19\n" + "\x15STATE_SAVING_SNAPSHOT\x10\x02\x12\x0e\n" + "\n" + "STATE_DONE\x10\x03\"2\n" + "\x10EndChangeRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"\xe9\x01\n" + "\x11EndChangeResponse\x126\n" + "\x05state\x18\x01 \x01(\x0e2 .changes.EndChangeResponse.StateR\x05state\x12\x1a\n" + "\bnumItems\x18\x02 \x01(\rR\bnumItems\x12\x1a\n" + "\bNumEdges\x18\x03 \x01(\rR\bNumEdges\"d\n" + "\x05State\x12\x15\n" + "\x11STATE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15STATE_TAKING_SNAPSHOT\x10\x01\x12\x19\n" + "\x15STATE_SAVING_SNAPSHOT\x10\x02\x12\x0e\n" + "\n" + "STATE_DONE\x10\x03\"\x1b\n" + "\x19StartChangeSimpleResponse\"_\n" + "\x17EndChangeSimpleResponse\x12\x16\n" + "\x06queued\x18\x01 \x01(\bR\x06queued\x12,\n" + "\x12queued_after_start\x18\x02 \x01(\bR\x10queuedAfterStart\"\x9c\x02\n" + "\x04Risk\x12\x12\n" + "\x04UUID\x18\x05 \x01(\fR\x04UUID\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x122\n" + "\bseverity\x18\x02 \x01(\x0e2\x16.changes.Risk.SeverityR\bseverity\x12 \n" + "\vdescription\x18\x03 \x01(\tR\vdescription\x124\n" + "\x0frelatedItemRefs\x18\x04 \x03(\v2\n" + ".ReferenceR\x0frelatedItemRefs\"^\n" + "\bSeverity\x12\x18\n" + "\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x10\n" + "\fSEVERITY_LOW\x10\x01\x12\x13\n" + "\x0fSEVERITY_MEDIUM\x10\x02\x12\x11\n" + "\rSEVERITY_HIGH\x10\x03\"\xca\x01\n" + "\x14ChangeAnalysisStatus\x12<\n" + "\x06status\x18\x01 \x01(\x0e2$.changes.ChangeAnalysisStatus.StatusR\x06status\"n\n" + "\x06Status\x12\x16\n" + "\x12STATUS_UNSPECIFIED\x10\x00\x12\x15\n" + "\x11STATUS_INPROGRESS\x10\x01\x12\x12\n" + "\x0eSTATUS_SKIPPED\x10\x02\x12\x0f\n" + "\vSTATUS_DONE\x10\x03\x12\x10\n" + "\fSTATUS_ERROR\x10\x04J\x04\b\x05\x10\x06\"4\n" + "\x16GenerateRiskFixRequest\x12\x1a\n" + "\briskUUID\x18\x01 \x01(\fR\briskUUID\"?\n" + "\x17GenerateRiskFixResponse\x12$\n" + "\rfixSuggestion\x18\x01 \x01(\tR\rfixSuggestion\"\xa6\x02\n" + "\x19SubmitRiskFeedbackRequest\x12\x1b\n" + "\trisk_uuid\x18\x01 \x01(\fR\briskUuid\x12<\n" + "\tsentiment\x18\x02 \x01(\x0e2\x1e.changes.RiskFeedbackSentimentR\tsentiment\x12#\n" + "\rfeedback_text\x18\x03 \x01(\tR\ffeedbackText\x12L\n" + "\bmetadata\x18\x04 \x03(\v20.changes.SubmitRiskFeedbackRequest.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x1c\n" + "\x1aSubmitRiskFeedbackResponse*\xf6\x01\n" + "\x18MappedItemTimelineStatus\x12+\n" + "'MAPPED_ITEM_TIMELINE_STATUS_UNSPECIFIED\x10\x00\x12'\n" + "#MAPPED_ITEM_TIMELINE_STATUS_SUCCESS\x10\x01\x12%\n" + "!MAPPED_ITEM_TIMELINE_STATUS_ERROR\x10\x02\x12+\n" + "'MAPPED_ITEM_TIMELINE_STATUS_UNSUPPORTED\x10\x03\x120\n" + ",MAPPED_ITEM_TIMELINE_STATUS_PENDING_CREATION\x10\x04*\xf0\x01\n" + "\x17MappedItemMappingStatus\x12*\n" + "&MAPPED_ITEM_MAPPING_STATUS_UNSPECIFIED\x10\x00\x12&\n" + "\"MAPPED_ITEM_MAPPING_STATUS_SUCCESS\x10\x01\x12*\n" + "&MAPPED_ITEM_MAPPING_STATUS_UNSUPPORTED\x10\x02\x12/\n" + "+MAPPED_ITEM_MAPPING_STATUS_PENDING_CREATION\x10\x03\x12$\n" + " MAPPED_ITEM_MAPPING_STATUS_ERROR\x10\x04*\xa5\x02\n" + "\x10HypothesisStatus\x12.\n" + "*INVESTIGATED_HYPOTHESIS_STATUS_UNSPECIFIED\x10\x00\x12*\n" + "&INVESTIGATED_HYPOTHESIS_STATUS_FORMING\x10\x01\x120\n" + ",INVESTIGATED_HYPOTHESIS_STATUS_INVESTIGATING\x10\x02\x12)\n" + "%INVESTIGATED_HYPOTHESIS_STATUS_PROVEN\x10\x03\x12,\n" + "(INVESTIGATED_HYPOTHESIS_STATUS_DISPROVEN\x10\x04\x12*\n" + "&INVESTIGATED_HYPOTHESIS_STATUS_SKIPPED\x10\x05*_\n" + "\x19ChangeTimelineEntryStatus\x12\x0f\n" + "\vUNSPECIFIED\x10\x00\x12\v\n" + "\aPENDING\x10\x01\x12\x0f\n" + "\vIN_PROGRESS\x10\x02\x12\b\n" + "\x04DONE\x10\x03\x12\t\n" + "\x05ERROR\x10\x04*\xcb\x01\n" + "\x0eItemDiffStatus\x12 \n" + "\x1cITEM_DIFF_STATUS_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aITEM_DIFF_STATUS_UNCHANGED\x10\x01\x12\x1c\n" + "\x18ITEM_DIFF_STATUS_CREATED\x10\x02\x12\x1c\n" + "\x18ITEM_DIFF_STATUS_UPDATED\x10\x03\x12\x1c\n" + "\x18ITEM_DIFF_STATUS_DELETED\x10\x04\x12\x1d\n" + "\x19ITEM_DIFF_STATUS_REPLACED\x10\x05*|\n" + "\x12ChangeOutputFormat\x12$\n" + " CHANGE_OUTPUT_FORMAT_UNSPECIFIED\x10\x00\x12\x1d\n" + "\x19CHANGE_OUTPUT_FORMAT_JSON\x10\x01\x12!\n" + "\x1dCHANGE_OUTPUT_FORMAT_MARKDOWN\x10\x02*Q\n" + "\tLabelType\x12\x1a\n" + "\x16LABEL_TYPE_UNSPECIFIED\x10\x00\x12\x13\n" + "\x0fLABEL_TYPE_AUTO\x10\x01\x12\x13\n" + "\x0fLABEL_TYPE_USER\x10\x02*\xa0\x01\n" + "\fChangeStatus\x12\x1d\n" + "\x19CHANGE_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n" + "\x16CHANGE_STATUS_DEFINING\x10\x01\x12\x1b\n" + "\x17CHANGE_STATUS_HAPPENING\x10\x02\x12 \n" + "\x18CHANGE_STATUS_PROCESSING\x10\x03\x1a\x02\b\x01\x12\x16\n" + "\x12CHANGE_STATUS_DONE\x10\x04*\x8c\x01\n" + "\x15RiskFeedbackSentiment\x12'\n" + "#RISK_FEEDBACK_SENTIMENT_UNSPECIFIED\x10\x00\x12$\n" + " RISK_FEEDBACK_SENTIMENT_POSITIVE\x10\x01\x12$\n" + " RISK_FEEDBACK_SENTIMENT_NEGATIVE\x10\x022\xe8\x11\n" + "\x0eChangesService\x12H\n" + "\vListChanges\x12\x1b.changes.ListChangesRequest\x1a\x1c.changes.ListChangesResponse\x12`\n" + "\x13ListChangesByStatus\x12#.changes.ListChangesByStatusRequest\x1a$.changes.ListChangesByStatusResponse\x12K\n" + "\fCreateChange\x12\x1c.changes.CreateChangeRequest\x1a\x1d.changes.CreateChangeResponse\x12B\n" + "\tGetChange\x12\x19.changes.GetChangeRequest\x1a\x1a.changes.GetChangeResponse\x12Z\n" + "\x15GetChangeByTicketLink\x12%.changes.GetChangeByTicketLinkRequest\x1a\x1a.changes.GetChangeResponse\x12W\n" + "\x10GetChangeSummary\x12 .changes.GetChangeSummaryRequest\x1a!.changes.GetChangeSummaryResponse\x12`\n" + "\x13GetChangeTimelineV2\x12#.changes.GetChangeTimelineV2Request\x1a$.changes.GetChangeTimelineV2Response\x12Q\n" + "\x0eGetChangeRisks\x12\x1e.changes.GetChangeRisksRequest\x1a\x1f.changes.GetChangeRisksResponse\x12K\n" + "\fUpdateChange\x12\x1c.changes.UpdateChangeRequest\x1a\x1d.changes.UpdateChangeResponse\x12K\n" + "\fDeleteChange\x12\x1c.changes.DeleteChangeRequest\x1a\x1d.changes.DeleteChangeResponse\x12r\n" + "\x19ListChangesBySnapshotUUID\x12).changes.ListChangesBySnapshotUUIDRequest\x1a*.changes.ListChangesBySnapshotUUIDResponse\x12K\n" + "\fRefreshState\x12\x1c.changes.RefreshStateRequest\x1a\x1d.changes.RefreshStateResponse\x12J\n" + "\vStartChange\x12\x1b.changes.StartChangeRequest\x1a\x1c.changes.StartChangeResponse0\x01\x12D\n" + "\tEndChange\x12\x19.changes.EndChangeRequest\x1a\x1a.changes.EndChangeResponse0\x01\x12T\n" + "\x11StartChangeSimple\x12\x1b.changes.StartChangeRequest\x1a\".changes.StartChangeSimpleResponse\x12N\n" + "\x0fEndChangeSimple\x12\x19.changes.EndChangeRequest\x1a .changes.EndChangeSimpleResponse\x12T\n" + "\x0fListHomeChanges\x12\x1f.changes.ListHomeChangesRequest\x1a .changes.ListHomeChangesResponse\x12`\n" + "\x13StartChangeAnalysis\x12#.changes.StartChangeAnalysisRequest\x1a$.changes.StartChangeAnalysisResponse\x12o\n" + "\x18ListChangingItemsSummary\x12(.changes.ListChangingItemsSummaryRequest\x1a).changes.ListChangingItemsSummaryResponse\x12<\n" + "\aGetDiff\x12\x17.changes.GetDiffRequest\x1a\x18.changes.GetDiffResponse\x12f\n" + "\x15PopulateChangeFilters\x12%.changes.PopulateChangeFiltersRequest\x1a&.changes.PopulateChangeFiltersResponse\x12T\n" + "\x0fGenerateRiskFix\x12\x1f.changes.GenerateRiskFixRequest\x1a .changes.GenerateRiskFixResponse\x12]\n" + "\x12SubmitRiskFeedback\x12\".changes.SubmitRiskFeedbackRequest\x1a#.changes.SubmitRiskFeedbackResponse\x12c\n" + "\x14GetHypothesesDetails\x12$.changes.GetHypothesesDetailsRequest\x1a%.changes.GetHypothesesDetailsResponse\x12W\n" + "\x10GetChangeSignals\x12 .changes.GetChangeSignalsRequest\x1a!.changes.GetChangeSignalsResponse\x12Z\n" + "\x11AddPlannedChanges\x12!.changes.AddPlannedChangesRequest\x1a\".changes.AddPlannedChangesResponse2\xfc\x04\n" + "\fLabelService\x12Q\n" + "\x0eListLabelRules\x12\x1e.changes.ListLabelRulesRequest\x1a\x1f.changes.ListLabelRulesResponse\x12T\n" + "\x0fCreateLabelRule\x12\x1f.changes.CreateLabelRuleRequest\x1a .changes.CreateLabelRuleResponse\x12K\n" + "\fGetLabelRule\x12\x1c.changes.GetLabelRuleRequest\x1a\x1d.changes.GetLabelRuleResponse\x12T\n" + "\x0fUpdateLabelRule\x12\x1f.changes.UpdateLabelRuleRequest\x1a .changes.UpdateLabelRuleResponse\x12T\n" + "\x0fDeleteLabelRule\x12\x1f.changes.DeleteLabelRuleRequest\x1a .changes.DeleteLabelRuleResponse\x12P\n" + "\rTestLabelRule\x12\x1d.changes.TestLabelRuleRequest\x1a\x1e.changes.TestLabelRuleResponse0\x01\x12x\n" + "\x1bReapplyLabelRuleInTimeRange\x12+.changes.ReapplyLabelRuleInTimeRangeRequest\x1a,.changes.ReapplyLabelRuleInTimeRangeResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_changes_proto_rawDescOnce sync.Once file_changes_proto_rawDescData []byte ) func file_changes_proto_rawDescGZIP() []byte { file_changes_proto_rawDescOnce.Do(func() { file_changes_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_changes_proto_rawDesc), len(file_changes_proto_rawDesc))) }) return file_changes_proto_rawDescData } var file_changes_proto_enumTypes = make([]protoimpl.EnumInfo, 13) var file_changes_proto_msgTypes = make([]protoimpl.MessageInfo, 104) var file_changes_proto_goTypes = []any{ (MappedItemTimelineStatus)(0), // 0: changes.MappedItemTimelineStatus (MappedItemMappingStatus)(0), // 1: changes.MappedItemMappingStatus (HypothesisStatus)(0), // 2: changes.HypothesisStatus (ChangeTimelineEntryStatus)(0), // 3: changes.ChangeTimelineEntryStatus (ItemDiffStatus)(0), // 4: changes.ItemDiffStatus (ChangeOutputFormat)(0), // 5: changes.ChangeOutputFormat (LabelType)(0), // 6: changes.LabelType (ChangeStatus)(0), // 7: changes.ChangeStatus (RiskFeedbackSentiment)(0), // 8: changes.RiskFeedbackSentiment (StartChangeResponse_State)(0), // 9: changes.StartChangeResponse.State (EndChangeResponse_State)(0), // 10: changes.EndChangeResponse.State (Risk_Severity)(0), // 11: changes.Risk.Severity (ChangeAnalysisStatus_Status)(0), // 12: changes.ChangeAnalysisStatus.Status (*LabelRule)(nil), // 13: changes.LabelRule (*LabelRuleMetadata)(nil), // 14: changes.LabelRuleMetadata (*LabelRuleProperties)(nil), // 15: changes.LabelRuleProperties (*ListLabelRulesRequest)(nil), // 16: changes.ListLabelRulesRequest (*ListLabelRulesResponse)(nil), // 17: changes.ListLabelRulesResponse (*CreateLabelRuleRequest)(nil), // 18: changes.CreateLabelRuleRequest (*CreateLabelRuleResponse)(nil), // 19: changes.CreateLabelRuleResponse (*GetLabelRuleRequest)(nil), // 20: changes.GetLabelRuleRequest (*GetLabelRuleResponse)(nil), // 21: changes.GetLabelRuleResponse (*UpdateLabelRuleRequest)(nil), // 22: changes.UpdateLabelRuleRequest (*UpdateLabelRuleResponse)(nil), // 23: changes.UpdateLabelRuleResponse (*DeleteLabelRuleRequest)(nil), // 24: changes.DeleteLabelRuleRequest (*DeleteLabelRuleResponse)(nil), // 25: changes.DeleteLabelRuleResponse (*TestLabelRuleRequest)(nil), // 26: changes.TestLabelRuleRequest (*TestLabelRuleResponse)(nil), // 27: changes.TestLabelRuleResponse (*ReapplyLabelRuleInTimeRangeRequest)(nil), // 28: changes.ReapplyLabelRuleInTimeRangeRequest (*ReapplyLabelRuleInTimeRangeResponse)(nil), // 29: changes.ReapplyLabelRuleInTimeRangeResponse (*KnowledgeReference)(nil), // 30: changes.KnowledgeReference (*Knowledge)(nil), // 31: changes.Knowledge (*GetHypothesesDetailsRequest)(nil), // 32: changes.GetHypothesesDetailsRequest (*GetHypothesesDetailsResponse)(nil), // 33: changes.GetHypothesesDetailsResponse (*HypothesesDetails)(nil), // 34: changes.HypothesesDetails (*GetChangeTimelineV2Request)(nil), // 35: changes.GetChangeTimelineV2Request (*GetChangeTimelineV2Response)(nil), // 36: changes.GetChangeTimelineV2Response (*ChangeTimelineEntryV2)(nil), // 37: changes.ChangeTimelineEntryV2 (*EmptyContent)(nil), // 38: changes.EmptyContent (*MappedItemTimelineSummary)(nil), // 39: changes.MappedItemTimelineSummary (*MappedItemsTimelineEntry)(nil), // 40: changes.MappedItemsTimelineEntry (*CalculatedBlastRadiusTimelineEntry)(nil), // 41: changes.CalculatedBlastRadiusTimelineEntry (*RecordObservationsTimelineEntry)(nil), // 42: changes.RecordObservationsTimelineEntry (*FormHypothesesTimelineEntry)(nil), // 43: changes.FormHypothesesTimelineEntry (*InvestigateHypothesesTimelineEntry)(nil), // 44: changes.InvestigateHypothesesTimelineEntry (*HypothesisSummary)(nil), // 45: changes.HypothesisSummary (*CalculatedRisksTimelineEntry)(nil), // 46: changes.CalculatedRisksTimelineEntry (*CalculatedLabelsTimelineEntry)(nil), // 47: changes.CalculatedLabelsTimelineEntry (*ChangeValidationTimelineEntry)(nil), // 48: changes.ChangeValidationTimelineEntry (*ChangeValidationCategory)(nil), // 49: changes.ChangeValidationCategory (*GetDiffRequest)(nil), // 50: changes.GetDiffRequest (*GetDiffResponse)(nil), // 51: changes.GetDiffResponse (*ListChangingItemsSummaryRequest)(nil), // 52: changes.ListChangingItemsSummaryRequest (*ListChangingItemsSummaryResponse)(nil), // 53: changes.ListChangingItemsSummaryResponse (*MappedItemDiff)(nil), // 54: changes.MappedItemDiff (*StartChangeAnalysisRequest)(nil), // 55: changes.StartChangeAnalysisRequest (*StartChangeAnalysisResponse)(nil), // 56: changes.StartChangeAnalysisResponse (*AddPlannedChangesRequest)(nil), // 57: changes.AddPlannedChangesRequest (*AddPlannedChangesResponse)(nil), // 58: changes.AddPlannedChangesResponse (*ListHomeChangesRequest)(nil), // 59: changes.ListHomeChangesRequest (*ChangeFiltersRequest)(nil), // 60: changes.ChangeFiltersRequest (*ListHomeChangesResponse)(nil), // 61: changes.ListHomeChangesResponse (*PopulateChangeFiltersRequest)(nil), // 62: changes.PopulateChangeFiltersRequest (*PopulateChangeFiltersResponse)(nil), // 63: changes.PopulateChangeFiltersResponse (*ItemDiffSummary)(nil), // 64: changes.ItemDiffSummary (*ItemDiff)(nil), // 65: changes.ItemDiff (*EnrichedTags)(nil), // 66: changes.EnrichedTags (*TagValue)(nil), // 67: changes.TagValue (*UserTagValue)(nil), // 68: changes.UserTagValue (*AutoTagValue)(nil), // 69: changes.AutoTagValue (*Label)(nil), // 70: changes.Label (*ChangeSummary)(nil), // 71: changes.ChangeSummary (*Change)(nil), // 72: changes.Change (*ChangeMetadata)(nil), // 73: changes.ChangeMetadata (*ChangeProperties)(nil), // 74: changes.ChangeProperties (*GithubChangeInfo)(nil), // 75: changes.GithubChangeInfo (*ListChangesRequest)(nil), // 76: changes.ListChangesRequest (*ListChangesResponse)(nil), // 77: changes.ListChangesResponse (*ListChangesByStatusRequest)(nil), // 78: changes.ListChangesByStatusRequest (*ListChangesByStatusResponse)(nil), // 79: changes.ListChangesByStatusResponse (*CreateChangeRequest)(nil), // 80: changes.CreateChangeRequest (*CreateChangeResponse)(nil), // 81: changes.CreateChangeResponse (*GetChangeRequest)(nil), // 82: changes.GetChangeRequest (*GetChangeByTicketLinkRequest)(nil), // 83: changes.GetChangeByTicketLinkRequest (*GetChangeSummaryRequest)(nil), // 84: changes.GetChangeSummaryRequest (*GetChangeSummaryResponse)(nil), // 85: changes.GetChangeSummaryResponse (*GetChangeSignalsRequest)(nil), // 86: changes.GetChangeSignalsRequest (*GetChangeSignalsResponse)(nil), // 87: changes.GetChangeSignalsResponse (*GetChangeResponse)(nil), // 88: changes.GetChangeResponse (*GetChangeRisksRequest)(nil), // 89: changes.GetChangeRisksRequest (*ChangeRiskMetadata)(nil), // 90: changes.ChangeRiskMetadata (*GetChangeRisksResponse)(nil), // 91: changes.GetChangeRisksResponse (*UpdateChangeRequest)(nil), // 92: changes.UpdateChangeRequest (*UpdateChangeResponse)(nil), // 93: changes.UpdateChangeResponse (*DeleteChangeRequest)(nil), // 94: changes.DeleteChangeRequest (*ListChangesBySnapshotUUIDRequest)(nil), // 95: changes.ListChangesBySnapshotUUIDRequest (*ListChangesBySnapshotUUIDResponse)(nil), // 96: changes.ListChangesBySnapshotUUIDResponse (*DeleteChangeResponse)(nil), // 97: changes.DeleteChangeResponse (*RefreshStateRequest)(nil), // 98: changes.RefreshStateRequest (*RefreshStateResponse)(nil), // 99: changes.RefreshStateResponse (*StartChangeRequest)(nil), // 100: changes.StartChangeRequest (*StartChangeResponse)(nil), // 101: changes.StartChangeResponse (*EndChangeRequest)(nil), // 102: changes.EndChangeRequest (*EndChangeResponse)(nil), // 103: changes.EndChangeResponse (*StartChangeSimpleResponse)(nil), // 104: changes.StartChangeSimpleResponse (*EndChangeSimpleResponse)(nil), // 105: changes.EndChangeSimpleResponse (*Risk)(nil), // 106: changes.Risk (*ChangeAnalysisStatus)(nil), // 107: changes.ChangeAnalysisStatus (*GenerateRiskFixRequest)(nil), // 108: changes.GenerateRiskFixRequest (*GenerateRiskFixResponse)(nil), // 109: changes.GenerateRiskFixResponse (*SubmitRiskFeedbackRequest)(nil), // 110: changes.SubmitRiskFeedbackRequest (*SubmitRiskFeedbackResponse)(nil), // 111: changes.SubmitRiskFeedbackResponse nil, // 112: changes.EnrichedTags.TagValueEntry nil, // 113: changes.ChangeSummary.TagsEntry (*ChangeMetadata_HealthChange)(nil), // 114: changes.ChangeMetadata.HealthChange nil, // 115: changes.ChangeProperties.TagsEntry nil, // 116: changes.SubmitRiskFeedbackRequest.MetadataEntry (*timestamppb.Timestamp)(nil), // 117: google.protobuf.Timestamp (*Edge)(nil), // 118: Edge (*Query)(nil), // 119: Query (*QueryError)(nil), // 120: QueryError (*BlastRadiusConfig)(nil), // 121: config.BlastRadiusConfig (*RoutineChangesConfig)(nil), // 122: config.RoutineChangesConfig (*GithubOrganisationProfile)(nil), // 123: config.GithubOrganisationProfile (*PaginationRequest)(nil), // 124: PaginationRequest (SortOrder)(0), // 125: SortOrder (*PaginationResponse)(nil), // 126: PaginationResponse (*Reference)(nil), // 127: Reference (Health)(0), // 128: Health (*Item)(nil), // 129: Item } var file_changes_proto_depIdxs = []int32{ 14, // 0: changes.LabelRule.metadata:type_name -> changes.LabelRuleMetadata 15, // 1: changes.LabelRule.properties:type_name -> changes.LabelRuleProperties 117, // 2: changes.LabelRuleMetadata.createdAt:type_name -> google.protobuf.Timestamp 117, // 3: changes.LabelRuleMetadata.updatedAt:type_name -> google.protobuf.Timestamp 13, // 4: changes.ListLabelRulesResponse.rules:type_name -> changes.LabelRule 15, // 5: changes.CreateLabelRuleRequest.properties:type_name -> changes.LabelRuleProperties 13, // 6: changes.CreateLabelRuleResponse.rule:type_name -> changes.LabelRule 13, // 7: changes.GetLabelRuleResponse.rule:type_name -> changes.LabelRule 15, // 8: changes.UpdateLabelRuleRequest.properties:type_name -> changes.LabelRuleProperties 13, // 9: changes.UpdateLabelRuleResponse.rule:type_name -> changes.LabelRule 15, // 10: changes.TestLabelRuleRequest.properties:type_name -> changes.LabelRuleProperties 70, // 11: changes.TestLabelRuleResponse.label:type_name -> changes.Label 117, // 12: changes.ReapplyLabelRuleInTimeRangeRequest.startAt:type_name -> google.protobuf.Timestamp 117, // 13: changes.ReapplyLabelRuleInTimeRangeRequest.endAt:type_name -> google.protobuf.Timestamp 34, // 14: changes.GetHypothesesDetailsResponse.hypotheses:type_name -> changes.HypothesesDetails 2, // 15: changes.HypothesesDetails.status:type_name -> changes.HypothesisStatus 30, // 16: changes.HypothesesDetails.knowledgeUsed:type_name -> changes.KnowledgeReference 37, // 17: changes.GetChangeTimelineV2Response.entries:type_name -> changes.ChangeTimelineEntryV2 3, // 18: changes.ChangeTimelineEntryV2.status:type_name -> changes.ChangeTimelineEntryStatus 117, // 19: changes.ChangeTimelineEntryV2.startedAt:type_name -> google.protobuf.Timestamp 117, // 20: changes.ChangeTimelineEntryV2.endedAt:type_name -> google.protobuf.Timestamp 40, // 21: changes.ChangeTimelineEntryV2.mappedItems:type_name -> changes.MappedItemsTimelineEntry 41, // 22: changes.ChangeTimelineEntryV2.calculatedBlastRadius:type_name -> changes.CalculatedBlastRadiusTimelineEntry 46, // 23: changes.ChangeTimelineEntryV2.calculatedRisks:type_name -> changes.CalculatedRisksTimelineEntry 38, // 24: changes.ChangeTimelineEntryV2.empty:type_name -> changes.EmptyContent 48, // 25: changes.ChangeTimelineEntryV2.changeValidation:type_name -> changes.ChangeValidationTimelineEntry 47, // 26: changes.ChangeTimelineEntryV2.calculatedLabels:type_name -> changes.CalculatedLabelsTimelineEntry 43, // 27: changes.ChangeTimelineEntryV2.formHypotheses:type_name -> changes.FormHypothesesTimelineEntry 44, // 28: changes.ChangeTimelineEntryV2.investigateHypotheses:type_name -> changes.InvestigateHypothesesTimelineEntry 42, // 29: changes.ChangeTimelineEntryV2.recordObservations:type_name -> changes.RecordObservationsTimelineEntry 0, // 30: changes.MappedItemTimelineSummary.status:type_name -> changes.MappedItemTimelineStatus 54, // 31: changes.MappedItemsTimelineEntry.mappedItems:type_name -> changes.MappedItemDiff 39, // 32: changes.MappedItemsTimelineEntry.items:type_name -> changes.MappedItemTimelineSummary 45, // 33: changes.FormHypothesesTimelineEntry.hypotheses:type_name -> changes.HypothesisSummary 45, // 34: changes.InvestigateHypothesesTimelineEntry.hypotheses:type_name -> changes.HypothesisSummary 2, // 35: changes.HypothesisSummary.status:type_name -> changes.HypothesisStatus 106, // 36: changes.CalculatedRisksTimelineEntry.risks:type_name -> changes.Risk 70, // 37: changes.CalculatedLabelsTimelineEntry.labels:type_name -> changes.Label 49, // 38: changes.ChangeValidationTimelineEntry.validationChecklist:type_name -> changes.ChangeValidationCategory 65, // 39: changes.GetDiffResponse.expectedItems:type_name -> changes.ItemDiff 65, // 40: changes.GetDiffResponse.unexpectedItems:type_name -> changes.ItemDiff 118, // 41: changes.GetDiffResponse.edges:type_name -> Edge 65, // 42: changes.GetDiffResponse.missingItems:type_name -> changes.ItemDiff 64, // 43: changes.ListChangingItemsSummaryResponse.items:type_name -> changes.ItemDiffSummary 65, // 44: changes.MappedItemDiff.item:type_name -> changes.ItemDiff 119, // 45: changes.MappedItemDiff.mappingQuery:type_name -> Query 120, // 46: changes.MappedItemDiff.mappingError:type_name -> QueryError 1, // 47: changes.MappedItemDiff.mapping_status:type_name -> changes.MappedItemMappingStatus 54, // 48: changes.StartChangeAnalysisRequest.changingItems:type_name -> changes.MappedItemDiff 121, // 49: changes.StartChangeAnalysisRequest.blastRadiusConfigOverride:type_name -> config.BlastRadiusConfig 122, // 50: changes.StartChangeAnalysisRequest.routineChangesConfigOverride:type_name -> config.RoutineChangesConfig 123, // 51: changes.StartChangeAnalysisRequest.githubOrganisationProfileOverride:type_name -> config.GithubOrganisationProfile 31, // 52: changes.StartChangeAnalysisRequest.knowledge:type_name -> changes.Knowledge 54, // 53: changes.AddPlannedChangesRequest.changingItems:type_name -> changes.MappedItemDiff 124, // 54: changes.ListHomeChangesRequest.pagination:type_name -> PaginationRequest 60, // 55: changes.ListHomeChangesRequest.filters:type_name -> changes.ChangeFiltersRequest 11, // 56: changes.ChangeFiltersRequest.risks:type_name -> changes.Risk.Severity 7, // 57: changes.ChangeFiltersRequest.statuses:type_name -> changes.ChangeStatus 125, // 58: changes.ChangeFiltersRequest.sortOrder:type_name -> SortOrder 71, // 59: changes.ListHomeChangesResponse.changes:type_name -> changes.ChangeSummary 126, // 60: changes.ListHomeChangesResponse.pagination:type_name -> PaginationResponse 127, // 61: changes.ItemDiffSummary.itemRef:type_name -> Reference 4, // 62: changes.ItemDiffSummary.status:type_name -> changes.ItemDiffStatus 128, // 63: changes.ItemDiffSummary.healthAfter:type_name -> Health 127, // 64: changes.ItemDiff.item:type_name -> Reference 4, // 65: changes.ItemDiff.status:type_name -> changes.ItemDiffStatus 129, // 66: changes.ItemDiff.before:type_name -> Item 129, // 67: changes.ItemDiff.after:type_name -> Item 127, // 68: changes.ItemDiff.mappedItemRef:type_name -> Reference 112, // 69: changes.EnrichedTags.tagValue:type_name -> changes.EnrichedTags.TagValueEntry 68, // 70: changes.TagValue.userTagValue:type_name -> changes.UserTagValue 69, // 71: changes.TagValue.autoTagValue:type_name -> changes.AutoTagValue 6, // 72: changes.Label.type:type_name -> changes.LabelType 7, // 73: changes.ChangeSummary.status:type_name -> changes.ChangeStatus 117, // 74: changes.ChangeSummary.createdAt:type_name -> google.protobuf.Timestamp 113, // 75: changes.ChangeSummary.tags:type_name -> changes.ChangeSummary.TagsEntry 66, // 76: changes.ChangeSummary.enrichedTags:type_name -> changes.EnrichedTags 70, // 77: changes.ChangeSummary.labels:type_name -> changes.Label 75, // 78: changes.ChangeSummary.githubChangeInfo:type_name -> changes.GithubChangeInfo 73, // 79: changes.Change.metadata:type_name -> changes.ChangeMetadata 74, // 80: changes.Change.properties:type_name -> changes.ChangeProperties 117, // 81: changes.ChangeMetadata.createdAt:type_name -> google.protobuf.Timestamp 117, // 82: changes.ChangeMetadata.updatedAt:type_name -> google.protobuf.Timestamp 7, // 83: changes.ChangeMetadata.status:type_name -> changes.ChangeStatus 114, // 84: changes.ChangeMetadata.UnknownHealthChange:type_name -> changes.ChangeMetadata.HealthChange 114, // 85: changes.ChangeMetadata.OkHealthChange:type_name -> changes.ChangeMetadata.HealthChange 114, // 86: changes.ChangeMetadata.WarningHealthChange:type_name -> changes.ChangeMetadata.HealthChange 114, // 87: changes.ChangeMetadata.ErrorHealthChange:type_name -> changes.ChangeMetadata.HealthChange 114, // 88: changes.ChangeMetadata.PendingHealthChange:type_name -> changes.ChangeMetadata.HealthChange 75, // 89: changes.ChangeMetadata.githubChangeInfo:type_name -> changes.GithubChangeInfo 107, // 90: changes.ChangeMetadata.changeAnalysisStatus:type_name -> changes.ChangeAnalysisStatus 65, // 91: changes.ChangeProperties.plannedChanges:type_name -> changes.ItemDiff 115, // 92: changes.ChangeProperties.tags:type_name -> changes.ChangeProperties.TagsEntry 66, // 93: changes.ChangeProperties.enrichedTags:type_name -> changes.EnrichedTags 70, // 94: changes.ChangeProperties.labels:type_name -> changes.Label 72, // 95: changes.ListChangesResponse.changes:type_name -> changes.Change 7, // 96: changes.ListChangesByStatusRequest.status:type_name -> changes.ChangeStatus 72, // 97: changes.ListChangesByStatusResponse.changes:type_name -> changes.Change 74, // 98: changes.CreateChangeRequest.properties:type_name -> changes.ChangeProperties 72, // 99: changes.CreateChangeResponse.change:type_name -> changes.Change 5, // 100: changes.GetChangeSummaryRequest.changeOutputFormat:type_name -> changes.ChangeOutputFormat 11, // 101: changes.GetChangeSummaryRequest.riskSeverityFilter:type_name -> changes.Risk.Severity 5, // 102: changes.GetChangeSignalsRequest.changeOutputFormat:type_name -> changes.ChangeOutputFormat 72, // 103: changes.GetChangeResponse.change:type_name -> changes.Change 107, // 104: changes.ChangeRiskMetadata.changeAnalysisStatus:type_name -> changes.ChangeAnalysisStatus 106, // 105: changes.ChangeRiskMetadata.risks:type_name -> changes.Risk 90, // 106: changes.GetChangeRisksResponse.changeRiskMetadata:type_name -> changes.ChangeRiskMetadata 74, // 107: changes.UpdateChangeRequest.properties:type_name -> changes.ChangeProperties 72, // 108: changes.UpdateChangeResponse.change:type_name -> changes.Change 72, // 109: changes.ListChangesBySnapshotUUIDResponse.changes:type_name -> changes.Change 9, // 110: changes.StartChangeResponse.state:type_name -> changes.StartChangeResponse.State 10, // 111: changes.EndChangeResponse.state:type_name -> changes.EndChangeResponse.State 11, // 112: changes.Risk.severity:type_name -> changes.Risk.Severity 127, // 113: changes.Risk.relatedItemRefs:type_name -> Reference 12, // 114: changes.ChangeAnalysisStatus.status:type_name -> changes.ChangeAnalysisStatus.Status 8, // 115: changes.SubmitRiskFeedbackRequest.sentiment:type_name -> changes.RiskFeedbackSentiment 116, // 116: changes.SubmitRiskFeedbackRequest.metadata:type_name -> changes.SubmitRiskFeedbackRequest.MetadataEntry 67, // 117: changes.EnrichedTags.TagValueEntry.value:type_name -> changes.TagValue 76, // 118: changes.ChangesService.ListChanges:input_type -> changes.ListChangesRequest 78, // 119: changes.ChangesService.ListChangesByStatus:input_type -> changes.ListChangesByStatusRequest 80, // 120: changes.ChangesService.CreateChange:input_type -> changes.CreateChangeRequest 82, // 121: changes.ChangesService.GetChange:input_type -> changes.GetChangeRequest 83, // 122: changes.ChangesService.GetChangeByTicketLink:input_type -> changes.GetChangeByTicketLinkRequest 84, // 123: changes.ChangesService.GetChangeSummary:input_type -> changes.GetChangeSummaryRequest 35, // 124: changes.ChangesService.GetChangeTimelineV2:input_type -> changes.GetChangeTimelineV2Request 89, // 125: changes.ChangesService.GetChangeRisks:input_type -> changes.GetChangeRisksRequest 92, // 126: changes.ChangesService.UpdateChange:input_type -> changes.UpdateChangeRequest 94, // 127: changes.ChangesService.DeleteChange:input_type -> changes.DeleteChangeRequest 95, // 128: changes.ChangesService.ListChangesBySnapshotUUID:input_type -> changes.ListChangesBySnapshotUUIDRequest 98, // 129: changes.ChangesService.RefreshState:input_type -> changes.RefreshStateRequest 100, // 130: changes.ChangesService.StartChange:input_type -> changes.StartChangeRequest 102, // 131: changes.ChangesService.EndChange:input_type -> changes.EndChangeRequest 100, // 132: changes.ChangesService.StartChangeSimple:input_type -> changes.StartChangeRequest 102, // 133: changes.ChangesService.EndChangeSimple:input_type -> changes.EndChangeRequest 59, // 134: changes.ChangesService.ListHomeChanges:input_type -> changes.ListHomeChangesRequest 55, // 135: changes.ChangesService.StartChangeAnalysis:input_type -> changes.StartChangeAnalysisRequest 52, // 136: changes.ChangesService.ListChangingItemsSummary:input_type -> changes.ListChangingItemsSummaryRequest 50, // 137: changes.ChangesService.GetDiff:input_type -> changes.GetDiffRequest 62, // 138: changes.ChangesService.PopulateChangeFilters:input_type -> changes.PopulateChangeFiltersRequest 108, // 139: changes.ChangesService.GenerateRiskFix:input_type -> changes.GenerateRiskFixRequest 110, // 140: changes.ChangesService.SubmitRiskFeedback:input_type -> changes.SubmitRiskFeedbackRequest 32, // 141: changes.ChangesService.GetHypothesesDetails:input_type -> changes.GetHypothesesDetailsRequest 86, // 142: changes.ChangesService.GetChangeSignals:input_type -> changes.GetChangeSignalsRequest 57, // 143: changes.ChangesService.AddPlannedChanges:input_type -> changes.AddPlannedChangesRequest 16, // 144: changes.LabelService.ListLabelRules:input_type -> changes.ListLabelRulesRequest 18, // 145: changes.LabelService.CreateLabelRule:input_type -> changes.CreateLabelRuleRequest 20, // 146: changes.LabelService.GetLabelRule:input_type -> changes.GetLabelRuleRequest 22, // 147: changes.LabelService.UpdateLabelRule:input_type -> changes.UpdateLabelRuleRequest 24, // 148: changes.LabelService.DeleteLabelRule:input_type -> changes.DeleteLabelRuleRequest 26, // 149: changes.LabelService.TestLabelRule:input_type -> changes.TestLabelRuleRequest 28, // 150: changes.LabelService.ReapplyLabelRuleInTimeRange:input_type -> changes.ReapplyLabelRuleInTimeRangeRequest 77, // 151: changes.ChangesService.ListChanges:output_type -> changes.ListChangesResponse 79, // 152: changes.ChangesService.ListChangesByStatus:output_type -> changes.ListChangesByStatusResponse 81, // 153: changes.ChangesService.CreateChange:output_type -> changes.CreateChangeResponse 88, // 154: changes.ChangesService.GetChange:output_type -> changes.GetChangeResponse 88, // 155: changes.ChangesService.GetChangeByTicketLink:output_type -> changes.GetChangeResponse 85, // 156: changes.ChangesService.GetChangeSummary:output_type -> changes.GetChangeSummaryResponse 36, // 157: changes.ChangesService.GetChangeTimelineV2:output_type -> changes.GetChangeTimelineV2Response 91, // 158: changes.ChangesService.GetChangeRisks:output_type -> changes.GetChangeRisksResponse 93, // 159: changes.ChangesService.UpdateChange:output_type -> changes.UpdateChangeResponse 97, // 160: changes.ChangesService.DeleteChange:output_type -> changes.DeleteChangeResponse 96, // 161: changes.ChangesService.ListChangesBySnapshotUUID:output_type -> changes.ListChangesBySnapshotUUIDResponse 99, // 162: changes.ChangesService.RefreshState:output_type -> changes.RefreshStateResponse 101, // 163: changes.ChangesService.StartChange:output_type -> changes.StartChangeResponse 103, // 164: changes.ChangesService.EndChange:output_type -> changes.EndChangeResponse 104, // 165: changes.ChangesService.StartChangeSimple:output_type -> changes.StartChangeSimpleResponse 105, // 166: changes.ChangesService.EndChangeSimple:output_type -> changes.EndChangeSimpleResponse 61, // 167: changes.ChangesService.ListHomeChanges:output_type -> changes.ListHomeChangesResponse 56, // 168: changes.ChangesService.StartChangeAnalysis:output_type -> changes.StartChangeAnalysisResponse 53, // 169: changes.ChangesService.ListChangingItemsSummary:output_type -> changes.ListChangingItemsSummaryResponse 51, // 170: changes.ChangesService.GetDiff:output_type -> changes.GetDiffResponse 63, // 171: changes.ChangesService.PopulateChangeFilters:output_type -> changes.PopulateChangeFiltersResponse 109, // 172: changes.ChangesService.GenerateRiskFix:output_type -> changes.GenerateRiskFixResponse 111, // 173: changes.ChangesService.SubmitRiskFeedback:output_type -> changes.SubmitRiskFeedbackResponse 33, // 174: changes.ChangesService.GetHypothesesDetails:output_type -> changes.GetHypothesesDetailsResponse 87, // 175: changes.ChangesService.GetChangeSignals:output_type -> changes.GetChangeSignalsResponse 58, // 176: changes.ChangesService.AddPlannedChanges:output_type -> changes.AddPlannedChangesResponse 17, // 177: changes.LabelService.ListLabelRules:output_type -> changes.ListLabelRulesResponse 19, // 178: changes.LabelService.CreateLabelRule:output_type -> changes.CreateLabelRuleResponse 21, // 179: changes.LabelService.GetLabelRule:output_type -> changes.GetLabelRuleResponse 23, // 180: changes.LabelService.UpdateLabelRule:output_type -> changes.UpdateLabelRuleResponse 25, // 181: changes.LabelService.DeleteLabelRule:output_type -> changes.DeleteLabelRuleResponse 27, // 182: changes.LabelService.TestLabelRule:output_type -> changes.TestLabelRuleResponse 29, // 183: changes.LabelService.ReapplyLabelRuleInTimeRange:output_type -> changes.ReapplyLabelRuleInTimeRangeResponse 151, // [151:184] is the sub-list for method output_type 118, // [118:151] is the sub-list for method input_type 118, // [118:118] is the sub-list for extension type_name 118, // [118:118] is the sub-list for extension extendee 0, // [0:118] is the sub-list for field type_name } func init() { file_changes_proto_init() } func file_changes_proto_init() { if File_changes_proto != nil { return } file_config_proto_init() file_items_proto_init() file_util_proto_init() file_changes_proto_msgTypes[24].OneofWrappers = []any{ (*ChangeTimelineEntryV2_MappedItems)(nil), (*ChangeTimelineEntryV2_CalculatedBlastRadius)(nil), (*ChangeTimelineEntryV2_CalculatedRisks)(nil), (*ChangeTimelineEntryV2_Error)(nil), (*ChangeTimelineEntryV2_StatusMessage)(nil), (*ChangeTimelineEntryV2_Empty)(nil), (*ChangeTimelineEntryV2_ChangeValidation)(nil), (*ChangeTimelineEntryV2_CalculatedLabels)(nil), (*ChangeTimelineEntryV2_FormHypotheses)(nil), (*ChangeTimelineEntryV2_InvestigateHypotheses)(nil), (*ChangeTimelineEntryV2_RecordObservations)(nil), } file_changes_proto_msgTypes[26].OneofWrappers = []any{} file_changes_proto_msgTypes[41].OneofWrappers = []any{} file_changes_proto_msgTypes[42].OneofWrappers = []any{} file_changes_proto_msgTypes[46].OneofWrappers = []any{} file_changes_proto_msgTypes[47].OneofWrappers = []any{} file_changes_proto_msgTypes[52].OneofWrappers = []any{} file_changes_proto_msgTypes[54].OneofWrappers = []any{ (*TagValue_UserTagValue)(nil), (*TagValue_AutoTagValue)(nil), } file_changes_proto_msgTypes[60].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_changes_proto_rawDesc), len(file_changes_proto_rawDesc)), NumEnums: 13, NumMessages: 104, NumExtensions: 0, NumServices: 2, }, GoTypes: file_changes_proto_goTypes, DependencyIndexes: file_changes_proto_depIdxs, EnumInfos: file_changes_proto_enumTypes, MessageInfos: file_changes_proto_msgTypes, }.Build() File_changes_proto = out.File file_changes_proto_goTypes = nil file_changes_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/changes_test.go ================================================ package sdp import ( "strings" "testing" ) func TestFindInProgressEntry(t *testing.T) { t.Parallel() tests := []struct { name string entries []*ChangeTimelineEntryV2 expectedName string expectedStatus ChangeTimelineEntryStatus expectError bool }{ { name: "nil entries", entries: nil, expectedName: "", expectedStatus: ChangeTimelineEntryStatus_UNSPECIFIED, expectError: true, }, { name: "empty entries", entries: []*ChangeTimelineEntryV2{}, expectedName: "", expectedStatus: ChangeTimelineEntryStatus_UNSPECIFIED, expectError: true, }, { name: "in progress entry", entries: []*ChangeTimelineEntryV2{ { Name: "entry1", Status: ChangeTimelineEntryStatus_IN_PROGRESS, }, { Name: "entry2", Status: ChangeTimelineEntryStatus_PENDING, }, }, expectedName: "entry1", expectedStatus: ChangeTimelineEntryStatus_IN_PROGRESS, expectError: false, }, { name: "pending entry", entries: []*ChangeTimelineEntryV2{ { Name: "entry1", Status: ChangeTimelineEntryStatus_DONE, }, { Name: "entry2", Status: ChangeTimelineEntryStatus_PENDING, }, }, expectedName: "entry2", expectedStatus: ChangeTimelineEntryStatus_PENDING, expectError: false, }, { name: "error entry", entries: []*ChangeTimelineEntryV2{ { Name: "entry1", Status: ChangeTimelineEntryStatus_DONE, }, { Name: "entry2", Status: ChangeTimelineEntryStatus_ERROR, }, }, expectedName: "entry2", expectedStatus: ChangeTimelineEntryStatus_ERROR, expectError: false, }, { name: "no in progress entry", entries: []*ChangeTimelineEntryV2{ { Name: "entry1", Status: ChangeTimelineEntryStatus_DONE, }, { Name: "entry2", Status: ChangeTimelineEntryStatus_UNSPECIFIED, }, }, expectedName: "", expectedStatus: ChangeTimelineEntryStatus_DONE, expectError: false, }, { name: "unknown status", entries: []*ChangeTimelineEntryV2{ { Name: "entry1", Status: 100, // some unknown status }, }, expectedName: "", expectedStatus: ChangeTimelineEntryStatus_UNSPECIFIED, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { name, _, status, err := TimelineFindInProgressEntry(tt.entries) if tt.expectError && err == nil { t.Errorf("Expected an error, got nil") } if !tt.expectError && err != nil { t.Errorf("Expected no error, got %v", err) } if name != tt.expectedName { t.Errorf("Expected name %s, got %s", tt.expectedName, name) } if status != tt.expectedStatus { t.Errorf("Expected status %s, got %s", tt.expectedStatus, status) } }) } } func TestTimelineEntryContentDescription(t *testing.T) { t.Parallel() tests := []struct { name string entry *ChangeTimelineEntryV2 expected string }{ { name: "mapped items", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_MappedItems{ MappedItems: &MappedItemsTimelineEntry{ MappedItems: []*MappedItemDiff{{}, {}, {}}, }, }, }, expected: "3 mapped items", }, { name: "calculated blast radius", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_CalculatedBlastRadius{ CalculatedBlastRadius: &CalculatedBlastRadiusTimelineEntry{ NumItems: 10, NumEdges: 25, }, }, }, expected: "10 items, 25 edges", }, { name: "calculated risks", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_CalculatedRisks{ CalculatedRisks: &CalculatedRisksTimelineEntry{ Risks: []*Risk{{}, {}}, }, }, }, expected: "2 risks", }, { name: "calculated labels", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_CalculatedLabels{ CalculatedLabels: &CalculatedLabelsTimelineEntry{ Labels: []*Label{{}, {}, {}, {}}, }, }, }, expected: "4 labels", }, { name: "change validation", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_ChangeValidation{ ChangeValidation: &ChangeValidationTimelineEntry{ ValidationChecklist: []*ChangeValidationCategory{{}}, }, }, }, expected: "1 validation categories", }, { name: "form hypotheses", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_FormHypotheses{ FormHypotheses: &FormHypothesesTimelineEntry{ NumHypotheses: 5, }, }, }, expected: "5 hypotheses", }, { name: "investigate hypotheses", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_InvestigateHypotheses{ InvestigateHypotheses: &InvestigateHypothesesTimelineEntry{ NumProven: 2, NumDisproven: 3, NumInvestigating: 1, }, }, }, expected: "2 proven, 3 disproven, 1 investigating", }, { name: "record observations", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_RecordObservations{ RecordObservations: &RecordObservationsTimelineEntry{ NumObservations: 42, }, }, }, expected: "42 observations", }, { name: "error content", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_Error{ Error: "something went wrong", }, }, expected: "something went wrong", }, { name: "status message", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_StatusMessage{ StatusMessage: "processing data", }, }, expected: "processing data", }, { name: "empty content", entry: &ChangeTimelineEntryV2{ Content: &ChangeTimelineEntryV2_Empty{ Empty: &EmptyContent{}, }, }, expected: "", }, { name: "nil content", entry: &ChangeTimelineEntryV2{ Content: nil, }, expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := TimelineEntryContentDescription(tt.entry) if result != tt.expected { t.Errorf("Expected %q, got %q", tt.expected, result) } }) } } func TestValidateRoutineChangesConfig(t *testing.T) { t.Parallel() tests := []struct { name string config *RoutineChangesYAML wantErr bool errContains string }{ { name: "valid config", config: &RoutineChangesYAML{ EventsPerDay: 10.0, DurationInDays: 7.0, Sensitivity: 0.5, }, wantErr: false, }, { name: "valid config with minimum values", config: &RoutineChangesYAML{ EventsPerDay: 1.0, DurationInDays: 1.0, Sensitivity: 0.0, }, wantErr: false, }, { name: "events_per_day less than 1", config: &RoutineChangesYAML{ EventsPerDay: 0.5, DurationInDays: 7.0, Sensitivity: 0.5, }, wantErr: true, errContains: "events_per_day must be greater than 1", }, { name: "events_per_day equals 0", config: &RoutineChangesYAML{ EventsPerDay: 0.0, DurationInDays: 7.0, Sensitivity: 0.5, }, wantErr: true, errContains: "events_per_day must be greater than 1", }, { name: "events_per_day negative", config: &RoutineChangesYAML{ EventsPerDay: -1.0, DurationInDays: 7.0, Sensitivity: 0.5, }, wantErr: true, errContains: "events_per_day must be greater than 1", }, { name: "duration_in_days less than 1", config: &RoutineChangesYAML{ EventsPerDay: 10.0, DurationInDays: 0.5, Sensitivity: 0.5, }, wantErr: true, errContains: "duration_in_days must be greater than 1", }, { name: "duration_in_days equals 0", config: &RoutineChangesYAML{ EventsPerDay: 10.0, DurationInDays: 0.0, Sensitivity: 0.5, }, wantErr: true, errContains: "duration_in_days must be greater than 1", }, { name: "duration_in_days negative", config: &RoutineChangesYAML{ EventsPerDay: 10.0, DurationInDays: -1.0, Sensitivity: 0.5, }, wantErr: true, errContains: "duration_in_days must be greater than 1", }, { name: "sensitivity negative", config: &RoutineChangesYAML{ EventsPerDay: 10.0, DurationInDays: 7.0, Sensitivity: -0.1, }, wantErr: true, errContains: "sensitivity must be 0 or higher", }, { name: "multiple invalid fields - events_per_day checked first", config: &RoutineChangesYAML{ EventsPerDay: 0.0, DurationInDays: 0.0, Sensitivity: -1.0, }, wantErr: true, errContains: "events_per_day must be greater than 1", }, { name: "multiple invalid fields - duration_in_days checked second", config: &RoutineChangesYAML{ EventsPerDay: 10.0, DurationInDays: 0.0, Sensitivity: -1.0, }, wantErr: true, errContains: "duration_in_days must be greater than 1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateRoutineChangesConfig(tt.config) if (err != nil) != tt.wantErr { t.Errorf("validateRoutineChangesConfig() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr && tt.errContains != "" { if err == nil { t.Errorf("validateRoutineChangesConfig() expected error containing %q, got nil", tt.errContains) } else if !strings.Contains(err.Error(), tt.errContains) { t.Errorf("validateRoutineChangesConfig() error = %v, want error containing %q", err, tt.errContains) } } }) } } func TestYamlStringToSignalConfig_NilCombinations(t *testing.T) { t.Parallel() tests := []struct { name string yamlString string wantErr bool wantRoutine bool wantGithub bool }{ { name: "both nil -> error", yamlString: "{}\n", wantErr: true, wantRoutine: false, wantGithub: false, }, { name: "only routine present", yamlString: "routine_changes_config:\n sensitivity: 0\n duration_in_days: 1\n events_per_day: 1\n", wantErr: false, wantRoutine: true, wantGithub: false, }, { name: "only github present", yamlString: "github_organisation_profile:\n primary_branch_name: main\n", wantErr: false, wantRoutine: false, wantGithub: true, }, { name: "both present", yamlString: "routine_changes_config:\n sensitivity: 0\n duration_in_days: 1\n events_per_day: 1\ngithub_organisation_profile:\n primary_branch_name: main\n", wantErr: false, wantRoutine: true, wantGithub: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := YamlStringToSignalConfig(tt.yamlString) if (err != nil) != tt.wantErr { t.Errorf("YamlStringToSignalConfig() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } if (got.RoutineChangesConfig != nil) != tt.wantRoutine { t.Errorf("RoutineChangesConfig presence = %v, want %v", got.RoutineChangesConfig != nil, tt.wantRoutine) } if (got.GithubOrganisationProfile != nil) != tt.wantGithub { t.Errorf("GithubOrganisationProfile presence = %v, want %v", got.GithubOrganisationProfile != nil, tt.wantGithub) } }) } } ================================================ FILE: go/sdp-go/changetimeline.go ================================================ package sdp // If you add/delete/move an entry here, make sure to update/check the following: // - the PopulateChangeTimelineV2 function // - GetChangeTimelineV2 in api-server/service/changesservice.go // - resetChangeAnalysisTables in api-server/service/changeanalysis/shared.go // - the cli tool if we are waiting for a change analysis to finish // - frontend/src/features/changes-v2/change-timeline/ChangeTimeline.tsx - also update the entryNames object as this is used for comparing entry names // All timeline entries are now defined using ChangeTimelineEntryV2ID variables below. // Use the .Label field for database lookups and the .Name field for user-facing display. type ChangeTimelineEntryV2ID struct { // The internal label for the entry, this is used to identify the entry in // the database and tell whether two entries are the same type of thing. // This means that if we want to change the way an entry behaves, we can // create a new label and keep the old one for backwards compatibility. Label string // The name of the entry, this is the user facing name of the entry and can // be changed safely. This is stored in both the code and the database, the // reason we store it in the code is so that we know what value to populate // in the database when we create the timeline entries in the first place, // when returning the timeline to the user we use the name from the database // which means that old changes will still show the old name. Name string } // if you add/delete/move an entry here, make sure to update/check the following: // - changeTimelineEntryNameInProgress // - changeTimelineEntryNameInProgressReverse // - allChangeTimelineEntryV2IDs var ( // This is the entry that is created when we map the resources for a change, // this happens before we start blast radius simulation, it involves taking // the mapping queries that were sent up, and running them against the // gateway to see whether any of them resolve into real items. ChangeTimelineEntryV2IDMapResources = ChangeTimelineEntryV2ID{ Label: "mapped_resources", Name: "Map resources", } // This is the entry that is created when we calculate the blast radius for a // change, this happens after we map the resources for a change, it involves // taking the mapped resources and running them through the blast radius // simulation to see how many items are in the blast radius. ChangeTimelineEntryV2IDCalculatedBlastRadius = ChangeTimelineEntryV2ID{ Label: "calculated_blast_radius", Name: "Simulate blast radius", } // we do not show this entry in the timeline anymore // This is the entry tracks the calculation of routine signals for all of // the modifications within this change ChangeTimelineEntryV2IDAnalyzedSignals = ChangeTimelineEntryV2ID{ Label: "calculated_routineness", Name: "Analyze signals", } // This is the entry that tracks the calculation of risks and returns them // in the timeline. At the time of writing this has been replaced and we are // no longer showing risks directly in the timeline. The risk calculation // still happens, but the timeline focuses on Observations -> Hypotheses -> // Investigations instead. This means that this step will be no longer used // after Dec '25 ChangeTimelineEntryV2IDCalculatedRisks = ChangeTimelineEntryV2ID{ Label: "calculated_risks", Name: "Calculated Risks", } // Tracks the application of auto-label rules for a change ChangeTimelineEntryV2IDCalculatedLabels = ChangeTimelineEntryV2ID{ Label: "calculated_labels", Name: "Apply auto labels", } // Tracks the validation of a change. This happens after the change is // complete and at time of writing is not generally available ChangeTimelineEntryV2IDChangeValidation = ChangeTimelineEntryV2ID{ Label: "change_validation", Name: "Change Validation", } // This is the entry that tracks observations being recorded during blast radius simulation ChangeTimelineEntryV2IDRecordObservations = ChangeTimelineEntryV2ID{ Label: "record_observations", Name: "Record observations", } // This is the entry that tracks hypotheses being formed from observations via batch processing ChangeTimelineEntryV2IDFormHypotheses = ChangeTimelineEntryV2ID{ Label: "form_hypotheses", Name: "Form hypotheses", } // This is the entry that tracks investigation of hypotheses via one-shot analysis ChangeTimelineEntryV2IDInvestigateHypotheses = ChangeTimelineEntryV2ID{ Label: "investigate_hypotheses", Name: "Investigate hypotheses", } ) // changeTimelineEntryNameInProgress maps default/done names to their in-progress equivalents. // This map is used to convert timeline entry names based on their status. var changeTimelineEntryNameInProgress = map[string]string{ "Map resources": "Mapping resources...", "Simulate blast radius": "Simulating blast radius...", "Record observations": "Recording observations...", "Form hypotheses": "Forming hypotheses...", "Investigate hypotheses": "Investigating hypotheses...", "Analyze signals": "Analyzing signals...", "Apply auto labels": "Applying auto labels...", } // changeTimelineEntryNameInProgressReverse maps in-progress names back to their default/done equivalents. // This is used for archive imports where we need to normalize names to look up labels. var changeTimelineEntryNameInProgressReverse = func() map[string]string { reverse := make(map[string]string, len(changeTimelineEntryNameInProgress)) for defaultName, inProgressName := range changeTimelineEntryNameInProgress { reverse[inProgressName] = defaultName } return reverse }() // allChangeTimelineEntryV2IDs is a slice of all timeline entry ID constants for iteration. var allChangeTimelineEntryV2IDs = []ChangeTimelineEntryV2ID{ ChangeTimelineEntryV2IDMapResources, ChangeTimelineEntryV2IDCalculatedBlastRadius, ChangeTimelineEntryV2IDAnalyzedSignals, ChangeTimelineEntryV2IDCalculatedRisks, ChangeTimelineEntryV2IDCalculatedLabels, ChangeTimelineEntryV2IDChangeValidation, ChangeTimelineEntryV2IDRecordObservations, ChangeTimelineEntryV2IDFormHypotheses, ChangeTimelineEntryV2IDInvestigateHypotheses, } // GetChangeTimelineEntryNameForStatus returns the appropriate name for a timeline entry // based on its status. If the status is IN_PROGRESS, it returns the in-progress name. // Otherwise, it returns the name as-is (which is the default/done name). func GetChangeTimelineEntryNameForStatus(name string, status ChangeTimelineEntryStatus) string { if status == ChangeTimelineEntryStatus_IN_PROGRESS { if inProgressName, ok := changeTimelineEntryNameInProgress[name]; ok { return inProgressName } } return name } // GetChangeTimelineEntryLabelFromName converts a timeline entry name (either in-progress or default/done) // to its corresponding label. This is used for archive imports where we need to match names to labels. // Returns an empty string if the name doesn't match any known timeline entry. func GetChangeTimelineEntryLabelFromName(name string) string { // First, normalize the name: if it's an in-progress name, convert it to default/done name normalizedName := name if defaultName, ok := changeTimelineEntryNameInProgressReverse[name]; ok { normalizedName = defaultName } // Then look up the label from the constants for _, entryID := range allChangeTimelineEntryV2IDs { if entryID.Name == normalizedName { return entryID.Label } } return "" } ================================================ FILE: go/sdp-go/changetimeline_test.go ================================================ package sdp import "testing" // TestChangeTimelineEntryNameConversion tests both GetChangeTimelineEntryNameForStatus // and GetChangeTimelineEntryLabelFromName together, including round-trip conversions. func TestChangeTimelineEntryNameConversion(t *testing.T) { t.Parallel() tests := []struct { name string entryID ChangeTimelineEntryV2ID hasInProgressVariant bool }{ { name: "Map resources", entryID: ChangeTimelineEntryV2IDMapResources, hasInProgressVariant: true, }, { name: "Simulate blast radius", entryID: ChangeTimelineEntryV2IDCalculatedBlastRadius, hasInProgressVariant: true, }, { name: "Record observations", entryID: ChangeTimelineEntryV2IDRecordObservations, hasInProgressVariant: true, }, { name: "Form hypotheses", entryID: ChangeTimelineEntryV2IDFormHypotheses, hasInProgressVariant: true, }, { name: "Investigate hypotheses", entryID: ChangeTimelineEntryV2IDInvestigateHypotheses, hasInProgressVariant: true, }, { name: "Analyze signals", entryID: ChangeTimelineEntryV2IDAnalyzedSignals, hasInProgressVariant: true, }, { name: "Apply auto labels", entryID: ChangeTimelineEntryV2IDCalculatedLabels, hasInProgressVariant: true, }, { name: "Calculated risks (no in-progress variant)", entryID: ChangeTimelineEntryV2IDCalculatedRisks, hasInProgressVariant: false, }, { name: "Change Validation (no in-progress variant)", entryID: ChangeTimelineEntryV2IDChangeValidation, hasInProgressVariant: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defaultName := tt.entryID.Name expectedLabel := tt.entryID.Label // Test 1: Default name -> IN_PROGRESS -> in-progress name if tt.hasInProgressVariant { gotInProgressName := GetChangeTimelineEntryNameForStatus(defaultName, ChangeTimelineEntryStatus_IN_PROGRESS) // Verify that the in-progress name is different from the default name if gotInProgressName == defaultName { t.Errorf("GetChangeTimelineEntryNameForStatus(%q, IN_PROGRESS) should return in-progress name, got %q", defaultName, gotInProgressName) } // Verify it ends with "..." to indicate in-progress if len(gotInProgressName) < 3 || gotInProgressName[len(gotInProgressName)-3:] != "..." { t.Errorf("GetChangeTimelineEntryNameForStatus(%q, IN_PROGRESS) = %q, expected in-progress name ending with '...'", defaultName, gotInProgressName) } expectedInProgressName := gotInProgressName // Use the function result as the expected value // Test 2: In-progress name -> label (for archive imports) gotLabelFromInProgress := GetChangeTimelineEntryLabelFromName(expectedInProgressName) if gotLabelFromInProgress != expectedLabel { t.Errorf("GetChangeTimelineEntryLabelFromName(%q) = %q, want %q", expectedInProgressName, gotLabelFromInProgress, expectedLabel) } // Test 3: Round-trip: default -> in-progress -> label -> should match expected label inProgressName := GetChangeTimelineEntryNameForStatus(defaultName, ChangeTimelineEntryStatus_IN_PROGRESS) labelFromRoundTrip := GetChangeTimelineEntryLabelFromName(inProgressName) if labelFromRoundTrip != expectedLabel { t.Errorf("Round-trip: default(%q) -> in-progress(%q) -> label(%q), want label %q", defaultName, inProgressName, labelFromRoundTrip, expectedLabel) } } // Test 4: Default name -> DONE status -> should return default name gotDoneName := GetChangeTimelineEntryNameForStatus(defaultName, ChangeTimelineEntryStatus_DONE) if gotDoneName != defaultName { t.Errorf("GetChangeTimelineEntryNameForStatus(%q, DONE) = %q, want %q", defaultName, gotDoneName, defaultName) } // Test 5: Default name -> PENDING status -> should return default name gotPendingName := GetChangeTimelineEntryNameForStatus(defaultName, ChangeTimelineEntryStatus_PENDING) if gotPendingName != defaultName { t.Errorf("GetChangeTimelineEntryNameForStatus(%q, PENDING) = %q, want %q", defaultName, gotPendingName, defaultName) } // Test 6: Default name -> ERROR status -> should return default name gotErrorName := GetChangeTimelineEntryNameForStatus(defaultName, ChangeTimelineEntryStatus_ERROR) if gotErrorName != defaultName { t.Errorf("GetChangeTimelineEntryNameForStatus(%q, ERROR) = %q, want %q", defaultName, gotErrorName, defaultName) } // Test 7: Default name -> UNSPECIFIED status -> should return default name gotUnspecifiedName := GetChangeTimelineEntryNameForStatus(defaultName, ChangeTimelineEntryStatus_UNSPECIFIED) if gotUnspecifiedName != defaultName { t.Errorf("GetChangeTimelineEntryNameForStatus(%q, UNSPECIFIED) = %q, want %q", defaultName, gotUnspecifiedName, defaultName) } // Test 8: Default name -> label (for archive imports) gotLabelFromDefault := GetChangeTimelineEntryLabelFromName(defaultName) if gotLabelFromDefault != expectedLabel { t.Errorf("GetChangeTimelineEntryLabelFromName(%q) = %q, want %q", defaultName, gotLabelFromDefault, expectedLabel) } }) } // Test edge cases t.Run("Unknown name with IN_PROGRESS returns name as-is", func(t *testing.T) { unknownName := "Unknown Entry" result := GetChangeTimelineEntryNameForStatus(unknownName, ChangeTimelineEntryStatus_IN_PROGRESS) if result != unknownName { t.Errorf("GetChangeTimelineEntryNameForStatus(%q, IN_PROGRESS) = %q, want %q", unknownName, result, unknownName) } }) t.Run("Unknown name returns empty label", func(t *testing.T) { unknownName := "Unknown Entry" result := GetChangeTimelineEntryLabelFromName(unknownName) if result != "" { t.Errorf("GetChangeTimelineEntryLabelFromName(%q) = %q, want empty string", unknownName, result) } }) t.Run("Empty string returns empty label", func(t *testing.T) { result := GetChangeTimelineEntryLabelFromName("") if result != "" { t.Errorf("GetChangeTimelineEntryLabelFromName(\"\") = %q, want empty string", result) } }) } ================================================ FILE: go/sdp-go/cli.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: cli.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type GetConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetConfigRequest) Reset() { *x = GetConfigRequest{} mi := &file_cli_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetConfigRequest) ProtoMessage() {} func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_cli_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead. func (*GetConfigRequest) Descriptor() ([]byte, []int) { return file_cli_proto_rawDescGZIP(), []int{0} } func (x *GetConfigRequest) GetKey() string { if x != nil { return x.Key } return "" } type GetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetConfigResponse) Reset() { *x = GetConfigResponse{} mi := &file_cli_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetConfigResponse) ProtoMessage() {} func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_cli_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. func (*GetConfigResponse) Descriptor() ([]byte, []int) { return file_cli_proto_rawDescGZIP(), []int{1} } func (x *GetConfigResponse) GetValue() string { if x != nil { return x.Value } return "" } type SetConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} mi := &file_cli_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SetConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_cli_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { return file_cli_proto_rawDescGZIP(), []int{2} } func (x *SetConfigRequest) GetKey() string { if x != nil { return x.Key } return "" } func (x *SetConfigRequest) GetValue() string { if x != nil { return x.Value } return "" } type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SetConfigResponse) Reset() { *x = SetConfigResponse{} mi := &file_cli_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SetConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*SetConfigResponse) ProtoMessage() {} func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_cli_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead. func (*SetConfigResponse) Descriptor() ([]byte, []int) { return file_cli_proto_rawDescGZIP(), []int{3} } var File_cli_proto protoreflect.FileDescriptor const file_cli_proto_rawDesc = "" + "\n" + "\tcli.proto\x12\x03cli\"$\n" + "\x10GetConfigRequest\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\")\n" + "\x11GetConfigResponse\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\":\n" + "\x10SetConfigRequest\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\"\x13\n" + "\x11SetConfigResponse2\x8b\x01\n" + "\rConfigService\x12<\n" + "\tGetConfig\x12\x15.cli.GetConfigRequest\x1a\x16.cli.GetConfigResponse\"\x00\x12<\n" + "\tSetConfig\x12\x15.cli.SetConfigRequest\x1a\x16.cli.SetConfigResponse\"\x00B1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_cli_proto_rawDescOnce sync.Once file_cli_proto_rawDescData []byte ) func file_cli_proto_rawDescGZIP() []byte { file_cli_proto_rawDescOnce.Do(func() { file_cli_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_cli_proto_rawDesc), len(file_cli_proto_rawDesc))) }) return file_cli_proto_rawDescData } var file_cli_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_cli_proto_goTypes = []any{ (*GetConfigRequest)(nil), // 0: cli.GetConfigRequest (*GetConfigResponse)(nil), // 1: cli.GetConfigResponse (*SetConfigRequest)(nil), // 2: cli.SetConfigRequest (*SetConfigResponse)(nil), // 3: cli.SetConfigResponse } var file_cli_proto_depIdxs = []int32{ 0, // 0: cli.ConfigService.GetConfig:input_type -> cli.GetConfigRequest 2, // 1: cli.ConfigService.SetConfig:input_type -> cli.SetConfigRequest 1, // 2: cli.ConfigService.GetConfig:output_type -> cli.GetConfigResponse 3, // 3: cli.ConfigService.SetConfig:output_type -> cli.SetConfigResponse 2, // [2:4] is the sub-list for method output_type 0, // [0:2] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_cli_proto_init() } func file_cli_proto_init() { if File_cli_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cli_proto_rawDesc), len(file_cli_proto_rawDesc)), NumEnums: 0, NumMessages: 4, NumExtensions: 0, NumServices: 1, }, GoTypes: file_cli_proto_goTypes, DependencyIndexes: file_cli_proto_depIdxs, MessageInfos: file_cli_proto_msgTypes, }.Build() File_cli_proto = out.File file_cli_proto_goTypes = nil file_cli_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/compare.go ================================================ package sdp import "fmt" // Comparer is an object that can be compared for the purposes of sorting. // Basically anything that implements this interface is sortable type Comparer interface { Compare(b *Item) int } // Compare compares two Items for the purposes of sorting. This sorts based on // the string conversion of the Type, followed by the UniqueAttribute func (i *Item) Compare(r *Item) (int, error) { // Convert to strings right := fmt.Sprintf("%v: %v", r.GetType(), r.UniqueAttributeValue()) left := fmt.Sprintf("%v: %v", i.GetType(), i.UniqueAttributeValue()) // Compare the strings and return the value switch { case left > right: return 1, nil case left < right: return -1, nil default: return 0, nil } } // CompareError is returned when two Items cannot be compared because their // UniqueAttributeValue() is not sortable type CompareError Item // Error returns the string when the error is handled func (c *CompareError) Error() string { return (fmt.Sprintf( "Item %v unique attribute: %v of type %v does not implement interface fmt.Stringer. Cannot sort.", c.Type, c.UniqueAttribute, c.Type, )) } ================================================ FILE: go/sdp-go/config.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: config.proto package sdp import ( _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // Controls when a GitHub Check Run concludes as failure vs success. type CheckRunMode int32 const ( // Always conclude as success regardless of risks found (default). CheckRunMode_CHECK_RUN_MODE_REPORT_ONLY CheckRunMode = 0 // Conclude as failure only when high-severity risks exist. CheckRunMode_CHECK_RUN_MODE_FAIL_HIGH_SEVERITY CheckRunMode = 1 // Conclude as failure when any risks exist. CheckRunMode_CHECK_RUN_MODE_FAIL_ANY_RISK CheckRunMode = 2 ) // Enum value maps for CheckRunMode. var ( CheckRunMode_name = map[int32]string{ 0: "CHECK_RUN_MODE_REPORT_ONLY", 1: "CHECK_RUN_MODE_FAIL_HIGH_SEVERITY", 2: "CHECK_RUN_MODE_FAIL_ANY_RISK", } CheckRunMode_value = map[string]int32{ "CHECK_RUN_MODE_REPORT_ONLY": 0, "CHECK_RUN_MODE_FAIL_HIGH_SEVERITY": 1, "CHECK_RUN_MODE_FAIL_ANY_RISK": 2, } ) func (x CheckRunMode) Enum() *CheckRunMode { p := new(CheckRunMode) *p = x return p } func (x CheckRunMode) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (CheckRunMode) Descriptor() protoreflect.EnumDescriptor { return file_config_proto_enumTypes[0].Descriptor() } func (CheckRunMode) Type() protoreflect.EnumType { return &file_config_proto_enumTypes[0] } func (x CheckRunMode) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use CheckRunMode.Descriptor instead. func (CheckRunMode) EnumDescriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{0} } type AccountConfig_BlastRadiusPreset int32 const ( // Unspecified preset - will be treated as DETAILED AccountConfig_UNSPECIFIED AccountConfig_BlastRadiusPreset = 0 // Runs a shallow scan for dependencies. Reduces time takes to calculate // blast radius, but might mean some dependencies are missed AccountConfig_QUICK AccountConfig_BlastRadiusPreset = 1 // An optimised balance between time taken and discovery. AccountConfig_DETAILED AccountConfig_BlastRadiusPreset = 2 // Discovers all possible dependencies, might take a long time and // discover items that are less likely to be relevant to a change. AccountConfig_FULL AccountConfig_BlastRadiusPreset = 3 ) // Enum value maps for AccountConfig_BlastRadiusPreset. var ( AccountConfig_BlastRadiusPreset_name = map[int32]string{ 0: "UNSPECIFIED", 1: "QUICK", 2: "DETAILED", 3: "FULL", } AccountConfig_BlastRadiusPreset_value = map[string]int32{ "UNSPECIFIED": 0, "QUICK": 1, "DETAILED": 2, "FULL": 3, } ) func (x AccountConfig_BlastRadiusPreset) Enum() *AccountConfig_BlastRadiusPreset { p := new(AccountConfig_BlastRadiusPreset) *p = x return p } func (x AccountConfig_BlastRadiusPreset) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (AccountConfig_BlastRadiusPreset) Descriptor() protoreflect.EnumDescriptor { return file_config_proto_enumTypes[1].Descriptor() } func (AccountConfig_BlastRadiusPreset) Type() protoreflect.EnumType { return &file_config_proto_enumTypes[1] } func (x AccountConfig_BlastRadiusPreset) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use AccountConfig_BlastRadiusPreset.Descriptor instead. func (AccountConfig_BlastRadiusPreset) EnumDescriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{1, 0} } type GetHcpConfigResponse_Status int32 const ( // The HCP Run Task configuration is active and can be used GetHcpConfigResponse_CONFIGURED GetHcpConfigResponse_Status = 0 // The HCP Run Task configuration is not fully configured and needs to // be recreated, this is usually due to the API key being revoked or the // user not completing the authorisation process GetHcpConfigResponse_ERROR GetHcpConfigResponse_Status = 1 ) // Enum value maps for GetHcpConfigResponse_Status. var ( GetHcpConfigResponse_Status_name = map[int32]string{ 0: "CONFIGURED", 1: "ERROR", } GetHcpConfigResponse_Status_value = map[string]int32{ "CONFIGURED": 0, "ERROR": 1, } ) func (x GetHcpConfigResponse_Status) Enum() *GetHcpConfigResponse_Status { p := new(GetHcpConfigResponse_Status) *p = x return p } func (x GetHcpConfigResponse_Status) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (GetHcpConfigResponse_Status) Descriptor() protoreflect.EnumDescriptor { return file_config_proto_enumTypes[2].Descriptor() } func (GetHcpConfigResponse_Status) Type() protoreflect.EnumType { return &file_config_proto_enumTypes[2] } func (x GetHcpConfigResponse_Status) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use GetHcpConfigResponse_Status.Descriptor instead. func (GetHcpConfigResponse_Status) EnumDescriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{10, 0} } type RoutineChangesConfig_DurationUnit int32 const ( // Days RoutineChangesConfig_DAYS RoutineChangesConfig_DurationUnit = 0 // Weeks RoutineChangesConfig_WEEKS RoutineChangesConfig_DurationUnit = 1 // Months RoutineChangesConfig_MONTHS RoutineChangesConfig_DurationUnit = 2 ) // Enum value maps for RoutineChangesConfig_DurationUnit. var ( RoutineChangesConfig_DurationUnit_name = map[int32]string{ 0: "DAYS", 1: "WEEKS", 2: "MONTHS", } RoutineChangesConfig_DurationUnit_value = map[string]int32{ "DAYS": 0, "WEEKS": 1, "MONTHS": 2, } ) func (x RoutineChangesConfig_DurationUnit) Enum() *RoutineChangesConfig_DurationUnit { p := new(RoutineChangesConfig_DurationUnit) *p = x return p } func (x RoutineChangesConfig_DurationUnit) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (RoutineChangesConfig_DurationUnit) Descriptor() protoreflect.EnumDescriptor { return file_config_proto_enumTypes[3].Descriptor() } func (RoutineChangesConfig_DurationUnit) Type() protoreflect.EnumType { return &file_config_proto_enumTypes[3] } func (x RoutineChangesConfig_DurationUnit) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use RoutineChangesConfig_DurationUnit.Descriptor instead. func (RoutineChangesConfig_DurationUnit) EnumDescriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{21, 0} } // The config that is used when calculating the blast radius for a change, this // does not affect manually requested blast radii vie the "Explore" view or the // API type BlastRadiusConfig struct { state protoimpl.MessageState `protogen:"open.v1"` // The maximum number of items that can be returned in a single blast radius // request. Once a request has hit this limit, all currently running // requests will be cancelled and the blast radius returned as-is MaxItems int32 `protobuf:"varint,1,opt,name=maxItems,proto3" json:"maxItems,omitempty"` // How deeply to link when calculating the blast radius for a change. This // is the maximum number of levels of links to traverse from the root item. // Different implementations may differ in how they handle this. LinkDepth int32 `protobuf:"varint,2,opt,name=linkDepth,proto3" json:"linkDepth,omitempty"` // Target duration for change analysis planning and blast radius soft timeout calculation. // This is NOT a hard deadline - it is used to compute when the blast radius phase should // stop gracefully (at 67% of this target) so the remaining steps can complete around the target time. // The actual job is only hard-limited by the service's maximum timeout (30 minutes). // If the analysis runs slightly over this target, results are still returned. // Minimum: 1 minute, Maximum: 30 minutes. ChangeAnalysisTargetDuration *durationpb.Duration `protobuf:"bytes,4,opt,name=changeAnalysisTargetDuration,proto3,oneof" json:"changeAnalysisTargetDuration,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BlastRadiusConfig) Reset() { *x = BlastRadiusConfig{} mi := &file_config_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BlastRadiusConfig) String() string { return protoimpl.X.MessageStringOf(x) } func (*BlastRadiusConfig) ProtoMessage() {} func (x *BlastRadiusConfig) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BlastRadiusConfig.ProtoReflect.Descriptor instead. func (*BlastRadiusConfig) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{0} } func (x *BlastRadiusConfig) GetMaxItems() int32 { if x != nil { return x.MaxItems } return 0 } func (x *BlastRadiusConfig) GetLinkDepth() int32 { if x != nil { return x.LinkDepth } return 0 } func (x *BlastRadiusConfig) GetChangeAnalysisTargetDuration() *durationpb.Duration { if x != nil { return x.ChangeAnalysisTargetDuration } return nil } // Account configuration for blast radius settings. The blast radius preset // is stored in the accounts table. Custom blast radius values are no longer // supported - only preset values are used. type AccountConfig struct { state protoimpl.MessageState `protogen:"open.v1"` // The preset that we should use when calculating the blast radius for a // change. UNSPECIFIED is treated as DETAILED. BlastRadiusPreset AccountConfig_BlastRadiusPreset `protobuf:"varint,2,opt,name=blastRadiusPreset,proto3,enum=config.AccountConfig_BlastRadiusPreset" json:"blastRadiusPreset,omitempty"` // The blast radius config for this account. This field is populated with // hardcoded values based on the preset when reading. Custom values are // ignored when writing - only the preset is stored. BlastRadius *BlastRadiusConfig `protobuf:"bytes,1,opt,name=blastRadius,proto3,oneof" json:"blastRadius,omitempty"` // If this is set to true, changes that weren't able to be mapped to real // infrastructure won't be considered for risk calculations. This usually // reduces the number low-quality and low-severity risks, and focuses more // on risks that we have additional context for. If you find that Overmind's // risks are "too obvious" then this might be a good setting to enable. SkipUnmappedChangesForRisks bool `protobuf:"varint,3,opt,name=skipUnmappedChangesForRisks,proto3" json:"skipUnmappedChangesForRisks,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AccountConfig) Reset() { *x = AccountConfig{} mi := &file_config_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AccountConfig) String() string { return protoimpl.X.MessageStringOf(x) } func (*AccountConfig) ProtoMessage() {} func (x *AccountConfig) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AccountConfig.ProtoReflect.Descriptor instead. func (*AccountConfig) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{1} } func (x *AccountConfig) GetBlastRadiusPreset() AccountConfig_BlastRadiusPreset { if x != nil { return x.BlastRadiusPreset } return AccountConfig_UNSPECIFIED } func (x *AccountConfig) GetBlastRadius() *BlastRadiusConfig { if x != nil { return x.BlastRadius } return nil } func (x *AccountConfig) GetSkipUnmappedChangesForRisks() bool { if x != nil { return x.SkipUnmappedChangesForRisks } return false } type GetAccountConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAccountConfigRequest) Reset() { *x = GetAccountConfigRequest{} mi := &file_config_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAccountConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAccountConfigRequest) ProtoMessage() {} func (x *GetAccountConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAccountConfigRequest.ProtoReflect.Descriptor instead. func (*GetAccountConfigRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{2} } type GetAccountConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *AccountConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAccountConfigResponse) Reset() { *x = GetAccountConfigResponse{} mi := &file_config_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetAccountConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetAccountConfigResponse) ProtoMessage() {} func (x *GetAccountConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetAccountConfigResponse.ProtoReflect.Descriptor instead. func (*GetAccountConfigResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{3} } func (x *GetAccountConfigResponse) GetConfig() *AccountConfig { if x != nil { return x.Config } return nil } // Updates the account config for the user's account. type UpdateAccountConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Config *AccountConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateAccountConfigRequest) Reset() { *x = UpdateAccountConfigRequest{} mi := &file_config_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateAccountConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateAccountConfigRequest) ProtoMessage() {} func (x *UpdateAccountConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateAccountConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateAccountConfigRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{4} } func (x *UpdateAccountConfigRequest) GetConfig() *AccountConfig { if x != nil { return x.Config } return nil } type UpdateAccountConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *AccountConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateAccountConfigResponse) Reset() { *x = UpdateAccountConfigResponse{} mi := &file_config_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateAccountConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateAccountConfigResponse) ProtoMessage() {} func (x *UpdateAccountConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateAccountConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateAccountConfigResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{5} } func (x *UpdateAccountConfigResponse) GetConfig() *AccountConfig { if x != nil { return x.Config } return nil } type CreateHcpConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The URL that the user should be redirected to after the whole process is // over. This should be a page in the frontend, probably the HCP Terraform // Integration page. FinalFrontendRedirect string `protobuf:"bytes,1,opt,name=finalFrontendRedirect,proto3" json:"finalFrontendRedirect,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateHcpConfigRequest) Reset() { *x = CreateHcpConfigRequest{} mi := &file_config_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateHcpConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateHcpConfigRequest) ProtoMessage() {} func (x *CreateHcpConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateHcpConfigRequest.ProtoReflect.Descriptor instead. func (*CreateHcpConfigRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{6} } func (x *CreateHcpConfigRequest) GetFinalFrontendRedirect() string { if x != nil { return x.FinalFrontendRedirect } return "" } type CreateHcpConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The configuration of the HCP Run Task that was created Config *HcpConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` // The API Key response for the API key that backs this integration. This // API will have been created but not yet authorised, the user must still be // redirected to the authorizeURL to complete the process. ApiKey *CreateAPIKeyResponse `protobuf:"bytes,2,opt,name=apiKey,proto3" json:"apiKey,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateHcpConfigResponse) Reset() { *x = CreateHcpConfigResponse{} mi := &file_config_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateHcpConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateHcpConfigResponse) ProtoMessage() {} func (x *CreateHcpConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateHcpConfigResponse.ProtoReflect.Descriptor instead. func (*CreateHcpConfigResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{7} } func (x *CreateHcpConfigResponse) GetConfig() *HcpConfig { if x != nil { return x.Config } return nil } func (x *CreateHcpConfigResponse) GetApiKey() *CreateAPIKeyResponse { if x != nil { return x.ApiKey } return nil } type HcpConfig struct { state protoimpl.MessageState `protogen:"open.v1"` // the Endpoint URL for the HCP Run Task configuration Endpoint string `protobuf:"bytes,1,opt,name=endpoint,proto3" json:"endpoint,omitempty"` // the HMAC secret for the HCP Run Task configuration Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HcpConfig) Reset() { *x = HcpConfig{} mi := &file_config_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HcpConfig) String() string { return protoimpl.X.MessageStringOf(x) } func (*HcpConfig) ProtoMessage() {} func (x *HcpConfig) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HcpConfig.ProtoReflect.Descriptor instead. func (*HcpConfig) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{8} } func (x *HcpConfig) GetEndpoint() string { if x != nil { return x.Endpoint } return "" } func (x *HcpConfig) GetSecret() string { if x != nil { return x.Secret } return "" } type GetHcpConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetHcpConfigRequest) Reset() { *x = GetHcpConfigRequest{} mi := &file_config_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetHcpConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetHcpConfigRequest) ProtoMessage() {} func (x *GetHcpConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetHcpConfigRequest.ProtoReflect.Descriptor instead. func (*GetHcpConfigRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{9} } type GetHcpConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *HcpConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` Status GetHcpConfigResponse_Status `protobuf:"varint,2,opt,name=status,proto3,enum=config.GetHcpConfigResponse_Status" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetHcpConfigResponse) Reset() { *x = GetHcpConfigResponse{} mi := &file_config_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetHcpConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetHcpConfigResponse) ProtoMessage() {} func (x *GetHcpConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetHcpConfigResponse.ProtoReflect.Descriptor instead. func (*GetHcpConfigResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{10} } func (x *GetHcpConfigResponse) GetConfig() *HcpConfig { if x != nil { return x.Config } return nil } func (x *GetHcpConfigResponse) GetStatus() GetHcpConfigResponse_Status { if x != nil { return x.Status } return GetHcpConfigResponse_CONFIGURED } type DeleteHcpConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteHcpConfigRequest) Reset() { *x = DeleteHcpConfigRequest{} mi := &file_config_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteHcpConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteHcpConfigRequest) ProtoMessage() {} func (x *DeleteHcpConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteHcpConfigRequest.ProtoReflect.Descriptor instead. func (*DeleteHcpConfigRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{11} } type DeleteHcpConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteHcpConfigResponse) Reset() { *x = DeleteHcpConfigResponse{} mi := &file_config_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteHcpConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteHcpConfigResponse) ProtoMessage() {} func (x *DeleteHcpConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteHcpConfigResponse.ProtoReflect.Descriptor instead. func (*DeleteHcpConfigResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{12} } type ReplaceHcpApiKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The URL that the user should be redirected to after the OAuth process is // over. This should be a page in the frontend, probably the HCP Terraform // Integration page. FinalFrontendRedirect string `protobuf:"bytes,1,opt,name=finalFrontendRedirect,proto3" json:"finalFrontendRedirect,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ReplaceHcpApiKeyRequest) Reset() { *x = ReplaceHcpApiKeyRequest{} mi := &file_config_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ReplaceHcpApiKeyRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ReplaceHcpApiKeyRequest) ProtoMessage() {} func (x *ReplaceHcpApiKeyRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ReplaceHcpApiKeyRequest.ProtoReflect.Descriptor instead. func (*ReplaceHcpApiKeyRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{13} } func (x *ReplaceHcpApiKeyRequest) GetFinalFrontendRedirect() string { if x != nil { return x.FinalFrontendRedirect } return "" } type ReplaceHcpApiKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The configuration of the HCP Run Task (endpoint URL and HMAC secret are // preserved from the existing config) Config *HcpConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` // The API Key response for the newly created API key. This API key has been // created but not yet authorised, the user must still be redirected to the // authorizeURL to complete the process. ApiKey *CreateAPIKeyResponse `protobuf:"bytes,2,opt,name=apiKey,proto3" json:"apiKey,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ReplaceHcpApiKeyResponse) Reset() { *x = ReplaceHcpApiKeyResponse{} mi := &file_config_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ReplaceHcpApiKeyResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ReplaceHcpApiKeyResponse) ProtoMessage() {} func (x *ReplaceHcpApiKeyResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ReplaceHcpApiKeyResponse.ProtoReflect.Descriptor instead. func (*ReplaceHcpApiKeyResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{14} } func (x *ReplaceHcpApiKeyResponse) GetConfig() *HcpConfig { if x != nil { return x.Config } return nil } func (x *ReplaceHcpApiKeyResponse) GetApiKey() *CreateAPIKeyResponse { if x != nil { return x.ApiKey } return nil } // Account Signal config type GetSignalConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSignalConfigRequest) Reset() { *x = GetSignalConfigRequest{} mi := &file_config_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSignalConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSignalConfigRequest) ProtoMessage() {} func (x *GetSignalConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSignalConfigRequest.ProtoReflect.Descriptor instead. func (*GetSignalConfigRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{15} } type GetSignalConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *SignalConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSignalConfigResponse) Reset() { *x = GetSignalConfigResponse{} mi := &file_config_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSignalConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSignalConfigResponse) ProtoMessage() {} func (x *GetSignalConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSignalConfigResponse.ProtoReflect.Descriptor instead. func (*GetSignalConfigResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{16} } func (x *GetSignalConfigResponse) GetConfig() *SignalConfig { if x != nil { return x.Config } return nil } // Updates the signal config for the account. type UpdateSignalConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Config *SignalConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateSignalConfigRequest) Reset() { *x = UpdateSignalConfigRequest{} mi := &file_config_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateSignalConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateSignalConfigRequest) ProtoMessage() {} func (x *UpdateSignalConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateSignalConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateSignalConfigRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{17} } func (x *UpdateSignalConfigRequest) GetConfig() *SignalConfig { if x != nil { return x.Config } return nil } type UpdateSignalConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *SignalConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateSignalConfigResponse) Reset() { *x = UpdateSignalConfigResponse{} mi := &file_config_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateSignalConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateSignalConfigResponse) ProtoMessage() {} func (x *UpdateSignalConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateSignalConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateSignalConfigResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{18} } func (x *UpdateSignalConfigResponse) GetConfig() *SignalConfig { if x != nil { return x.Config } return nil } type SignalConfig struct { state protoimpl.MessageState `protogen:"open.v1"` // Config for aggregation parameters, such as alpha AggregationConfig *AggregationConfig `protobuf:"bytes,1,opt,name=aggregationConfig,proto3" json:"aggregationConfig,omitempty"` // Config for routine changes, such as events per day and duration RoutineChangesConfig *RoutineChangesConfig `protobuf:"bytes,2,opt,name=routineChangesConfig,proto3" json:"routineChangesConfig,omitempty"` // Config for Github app profile, such as primary branch name GithubOrganisationProfile *GithubOrganisationProfile `protobuf:"bytes,3,opt,name=githubOrganisationProfile,proto3,oneof" json:"githubOrganisationProfile,omitempty"` // Controls the GitHub Check Run pass/fail conclusion criteria CheckRunMode CheckRunMode `protobuf:"varint,4,opt,name=check_run_mode,json=checkRunMode,proto3,enum=config.CheckRunMode" json:"check_run_mode,omitempty"` // Whether GitHub Check Runs are enabled for this account. // Defaults to false (disabled). Customer must explicitly enable via settings. CheckRunsEnabled bool `protobuf:"varint,5,opt,name=check_runs_enabled,json=checkRunsEnabled,proto3" json:"check_runs_enabled,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SignalConfig) Reset() { *x = SignalConfig{} mi := &file_config_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SignalConfig) String() string { return protoimpl.X.MessageStringOf(x) } func (*SignalConfig) ProtoMessage() {} func (x *SignalConfig) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SignalConfig.ProtoReflect.Descriptor instead. func (*SignalConfig) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{19} } func (x *SignalConfig) GetAggregationConfig() *AggregationConfig { if x != nil { return x.AggregationConfig } return nil } func (x *SignalConfig) GetRoutineChangesConfig() *RoutineChangesConfig { if x != nil { return x.RoutineChangesConfig } return nil } func (x *SignalConfig) GetGithubOrganisationProfile() *GithubOrganisationProfile { if x != nil { return x.GithubOrganisationProfile } return nil } func (x *SignalConfig) GetCheckRunMode() CheckRunMode { if x != nil { return x.CheckRunMode } return CheckRunMode_CHECK_RUN_MODE_REPORT_ONLY } func (x *SignalConfig) GetCheckRunsEnabled() bool { if x != nil { return x.CheckRunsEnabled } return false } type AggregationConfig struct { state protoimpl.MessageState `protogen:"open.v1"` // Alpha parameter for aggregation: controls the weighting of recent data versus older data // Must be positive (greater than 0) as it's the temperature parameter for exponential decay Alpha float32 `protobuf:"fixed32,1,opt,name=alpha,proto3" json:"alpha,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AggregationConfig) Reset() { *x = AggregationConfig{} mi := &file_config_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AggregationConfig) String() string { return protoimpl.X.MessageStringOf(x) } func (*AggregationConfig) ProtoMessage() {} func (x *AggregationConfig) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AggregationConfig.ProtoReflect.Descriptor instead. func (*AggregationConfig) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{20} } func (x *AggregationConfig) GetAlpha() float32 { if x != nil { return x.Alpha } return 0 } type RoutineChangesConfig struct { state protoimpl.MessageState `protogen:"open.v1"` // The user will see the format of "12 changes per day for 3 weeks" with the user able to change these values i.e. // Events per days, weeks, or months EventsPer float32 `protobuf:"fixed32,1,opt,name=eventsPer,proto3" json:"eventsPer,omitempty"` EventsPerUnit RoutineChangesConfig_DurationUnit `protobuf:"varint,2,opt,name=eventsPerUnit,proto3,enum=config.RoutineChangesConfig_DurationUnit" json:"eventsPerUnit,omitempty"` // Duration the number of days, weeks, or months over which routine changes are considered. Duration float32 `protobuf:"fixed32,3,opt,name=duration,proto3" json:"duration,omitempty"` // Specifies the unit of time for the duration field in routine changes. // The available units are days, weeks, and months. DurationUnit RoutineChangesConfig_DurationUnit `protobuf:"varint,4,opt,name=durationUnit,proto3,enum=config.RoutineChangesConfig_DurationUnit" json:"durationUnit,omitempty"` // Sensitivity parameter that controls the threshold for detecting routine changes. // A higher sensitivity value makes the detection more responsive to smaller changes, // while a lower value makes it less responsive. Sensitivity float32 `protobuf:"fixed32,5,opt,name=sensitivity,proto3" json:"sensitivity,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RoutineChangesConfig) Reset() { *x = RoutineChangesConfig{} mi := &file_config_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RoutineChangesConfig) String() string { return protoimpl.X.MessageStringOf(x) } func (*RoutineChangesConfig) ProtoMessage() {} func (x *RoutineChangesConfig) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RoutineChangesConfig.ProtoReflect.Descriptor instead. func (*RoutineChangesConfig) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{21} } func (x *RoutineChangesConfig) GetEventsPer() float32 { if x != nil { return x.EventsPer } return 0 } func (x *RoutineChangesConfig) GetEventsPerUnit() RoutineChangesConfig_DurationUnit { if x != nil { return x.EventsPerUnit } return RoutineChangesConfig_DAYS } func (x *RoutineChangesConfig) GetDuration() float32 { if x != nil { return x.Duration } return 0 } func (x *RoutineChangesConfig) GetDurationUnit() RoutineChangesConfig_DurationUnit { if x != nil { return x.DurationUnit } return RoutineChangesConfig_DAYS } func (x *RoutineChangesConfig) GetSensitivity() float32 { if x != nil { return x.Sensitivity } return 0 } // no parameters required, we will look up the installation ID from the account ID type GetGithubAppInformationRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetGithubAppInformationRequest) Reset() { *x = GetGithubAppInformationRequest{} mi := &file_config_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetGithubAppInformationRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetGithubAppInformationRequest) ProtoMessage() {} func (x *GetGithubAppInformationRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetGithubAppInformationRequest.ProtoReflect.Descriptor instead. func (*GetGithubAppInformationRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{22} } // it will be used to display information to the github integrations page, it is not used for signal processing type GithubAppInformation struct { state protoimpl.MessageState `protogen:"open.v1"` InstallationID int64 `protobuf:"varint,1,opt,name=installationID,proto3" json:"installationID,omitempty"` InstalledBy string `protobuf:"bytes,2,opt,name=installedBy,proto3" json:"installedBy,omitempty"` InstalledAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=installedAt,proto3" json:"installedAt,omitempty"` OrganisationName string `protobuf:"bytes,4,opt,name=organisationName,proto3" json:"organisationName,omitempty"` ActiveRepositoryCount int64 `protobuf:"varint,5,opt,name=activeRepositoryCount,proto3" json:"activeRepositoryCount,omitempty"` ContributorCount int64 `protobuf:"varint,6,opt,name=contributorCount,proto3" json:"contributorCount,omitempty"` BotAutomationPercentage int64 `protobuf:"varint,7,opt,name=botAutomationPercentage,proto3" json:"botAutomationPercentage,omitempty"` AverageMergeTime string `protobuf:"bytes,8,opt,name=averageMergeTime,proto3" json:"averageMergeTime,omitempty"` AverageCommitFrequency string `protobuf:"bytes,9,opt,name=averageCommitFrequency,proto3" json:"averageCommitFrequency,omitempty"` // Pending installation request fields (populated when a non-admin user // has requested the app but admin approval is still pending) RequestedOrgName *string `protobuf:"bytes,10,opt,name=requestedOrgName,proto3,oneof" json:"requestedOrgName,omitempty"` RequestedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=requestedAt,proto3,oneof" json:"requestedAt,omitempty"` RequestedBy *string `protobuf:"bytes,12,opt,name=requestedBy,proto3,oneof" json:"requestedBy,omitempty"` // Suspended status (true when GitHub org admin has suspended the installation) Suspended *bool `protobuf:"varint,13,opt,name=suspended,proto3,oneof" json:"suspended,omitempty"` // Whether the installation has checks:write permission. // Set by GetGithubAppInformation; used by the frontend to show // the check runs section vs a permission prompt. CanCreateChecks *bool `protobuf:"varint,14,opt,name=can_create_checks,json=canCreateChecks,proto3,oneof" json:"can_create_checks,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GithubAppInformation) Reset() { *x = GithubAppInformation{} mi := &file_config_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GithubAppInformation) String() string { return protoimpl.X.MessageStringOf(x) } func (*GithubAppInformation) ProtoMessage() {} func (x *GithubAppInformation) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GithubAppInformation.ProtoReflect.Descriptor instead. func (*GithubAppInformation) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{23} } func (x *GithubAppInformation) GetInstallationID() int64 { if x != nil { return x.InstallationID } return 0 } func (x *GithubAppInformation) GetInstalledBy() string { if x != nil { return x.InstalledBy } return "" } func (x *GithubAppInformation) GetInstalledAt() *timestamppb.Timestamp { if x != nil { return x.InstalledAt } return nil } func (x *GithubAppInformation) GetOrganisationName() string { if x != nil { return x.OrganisationName } return "" } func (x *GithubAppInformation) GetActiveRepositoryCount() int64 { if x != nil { return x.ActiveRepositoryCount } return 0 } func (x *GithubAppInformation) GetContributorCount() int64 { if x != nil { return x.ContributorCount } return 0 } func (x *GithubAppInformation) GetBotAutomationPercentage() int64 { if x != nil { return x.BotAutomationPercentage } return 0 } func (x *GithubAppInformation) GetAverageMergeTime() string { if x != nil { return x.AverageMergeTime } return "" } func (x *GithubAppInformation) GetAverageCommitFrequency() string { if x != nil { return x.AverageCommitFrequency } return "" } func (x *GithubAppInformation) GetRequestedOrgName() string { if x != nil && x.RequestedOrgName != nil { return *x.RequestedOrgName } return "" } func (x *GithubAppInformation) GetRequestedAt() *timestamppb.Timestamp { if x != nil { return x.RequestedAt } return nil } func (x *GithubAppInformation) GetRequestedBy() string { if x != nil && x.RequestedBy != nil { return *x.RequestedBy } return "" } func (x *GithubAppInformation) GetSuspended() bool { if x != nil && x.Suspended != nil { return *x.Suspended } return false } func (x *GithubAppInformation) GetCanCreateChecks() bool { if x != nil && x.CanCreateChecks != nil { return *x.CanCreateChecks } return false } // this is all the information required to display the github app information type GetGithubAppInformationResponse struct { state protoimpl.MessageState `protogen:"open.v1"` GithubAppInformation *GithubAppInformation `protobuf:"bytes,1,opt,name=githubAppInformation,proto3" json:"githubAppInformation,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetGithubAppInformationResponse) Reset() { *x = GetGithubAppInformationResponse{} mi := &file_config_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetGithubAppInformationResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetGithubAppInformationResponse) ProtoMessage() {} func (x *GetGithubAppInformationResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetGithubAppInformationResponse.ProtoReflect.Descriptor instead. func (*GetGithubAppInformationResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{24} } func (x *GetGithubAppInformationResponse) GetGithubAppInformation() *GithubAppInformation { if x != nil { return x.GithubAppInformation } return nil } // no parameters required, we will look up the installation ID from the account ID type RegenerateGithubAppProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RegenerateGithubAppProfileRequest) Reset() { *x = RegenerateGithubAppProfileRequest{} mi := &file_config_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RegenerateGithubAppProfileRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*RegenerateGithubAppProfileRequest) ProtoMessage() {} func (x *RegenerateGithubAppProfileRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RegenerateGithubAppProfileRequest.ProtoReflect.Descriptor instead. func (*RegenerateGithubAppProfileRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{25} } // information stored in the database, used by signal processing in change analysis type GithubOrganisationProfile struct { state protoimpl.MessageState `protogen:"open.v1"` PrimaryBranchName string `protobuf:"bytes,1,opt,name=primaryBranchName,proto3" json:"primaryBranchName,omitempty"` // signal scores that will be given for each hour of the day, 0-23, from -5.0 to 5.0 HourlyScores []float64 `protobuf:"fixed64,2,rep,packed,name=hourlyScores,proto3" json:"hourlyScores,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GithubOrganisationProfile) Reset() { *x = GithubOrganisationProfile{} mi := &file_config_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GithubOrganisationProfile) String() string { return protoimpl.X.MessageStringOf(x) } func (*GithubOrganisationProfile) ProtoMessage() {} func (x *GithubOrganisationProfile) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GithubOrganisationProfile.ProtoReflect.Descriptor instead. func (*GithubOrganisationProfile) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{26} } func (x *GithubOrganisationProfile) GetPrimaryBranchName() string { if x != nil { return x.PrimaryBranchName } return "" } func (x *GithubOrganisationProfile) GetHourlyScores() []float64 { if x != nil { return x.HourlyScores } return nil } type RegenerateGithubAppProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` GithubOrganisationProfile *GithubOrganisationProfile `protobuf:"bytes,1,opt,name=githubOrganisationProfile,proto3" json:"githubOrganisationProfile,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RegenerateGithubAppProfileResponse) Reset() { *x = RegenerateGithubAppProfileResponse{} mi := &file_config_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RegenerateGithubAppProfileResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*RegenerateGithubAppProfileResponse) ProtoMessage() {} func (x *RegenerateGithubAppProfileResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RegenerateGithubAppProfileResponse.ProtoReflect.Descriptor instead. func (*RegenerateGithubAppProfileResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{27} } func (x *RegenerateGithubAppProfileResponse) GetGithubOrganisationProfile() *GithubOrganisationProfile { if x != nil { return x.GithubOrganisationProfile } return nil } // No parameters required — the account is determined from the caller's auth context. type CreateGithubInstallURLRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateGithubInstallURLRequest) Reset() { *x = CreateGithubInstallURLRequest{} mi := &file_config_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateGithubInstallURLRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateGithubInstallURLRequest) ProtoMessage() {} func (x *CreateGithubInstallURLRequest) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateGithubInstallURLRequest.ProtoReflect.Descriptor instead. func (*CreateGithubInstallURLRequest) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{28} } type CreateGithubInstallURLResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The full GitHub App install URL including the state query parameter. // The URL is built from the server-configured GitHub App slug (which GitHub // restricts to [a-z0-9-]) and is NOT additionally URL-encoded. Consumers // (especially the frontend) should use this URL as-is for redirection and // must not assume it is pre-escaped. InstallUrl string `protobuf:"bytes,1,opt,name=install_url,json=installUrl,proto3" json:"install_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateGithubInstallURLResponse) Reset() { *x = CreateGithubInstallURLResponse{} mi := &file_config_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateGithubInstallURLResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateGithubInstallURLResponse) ProtoMessage() {} func (x *CreateGithubInstallURLResponse) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateGithubInstallURLResponse.ProtoReflect.Descriptor instead. func (*CreateGithubInstallURLResponse) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{29} } func (x *CreateGithubInstallURLResponse) GetInstallUrl() string { if x != nil { return x.InstallUrl } return "" } var File_config_proto protoreflect.FileDescriptor const file_config_proto_rawDesc = "" + "\n" + "\fconfig.proto\x12\x06config\x1a\rapikeys.proto\x1a\x1bbuf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd8\x01\n" + "\x11BlastRadiusConfig\x12\x1a\n" + "\bmaxItems\x18\x01 \x01(\x05R\bmaxItems\x12\x1c\n" + "\tlinkDepth\x18\x02 \x01(\x05R\tlinkDepth\x12b\n" + "\x1cchangeAnalysisTargetDuration\x18\x04 \x01(\v2\x19.google.protobuf.DurationH\x00R\x1cchangeAnalysisTargetDuration\x88\x01\x01B\x1f\n" + "\x1d_changeAnalysisTargetDurationJ\x04\b\x03\x10\x04\"\xc3\x02\n" + "\rAccountConfig\x12U\n" + "\x11blastRadiusPreset\x18\x02 \x01(\x0e2'.config.AccountConfig.BlastRadiusPresetR\x11blastRadiusPreset\x12@\n" + "\vblastRadius\x18\x01 \x01(\v2\x19.config.BlastRadiusConfigH\x00R\vblastRadius\x88\x01\x01\x12@\n" + "\x1bskipUnmappedChangesForRisks\x18\x03 \x01(\bR\x1bskipUnmappedChangesForRisks\"G\n" + "\x11BlastRadiusPreset\x12\x0f\n" + "\vUNSPECIFIED\x10\x00\x12\t\n" + "\x05QUICK\x10\x01\x12\f\n" + "\bDETAILED\x10\x02\x12\b\n" + "\x04FULL\x10\x03B\x0e\n" + "\f_blastRadius\"\x19\n" + "\x17GetAccountConfigRequest\"I\n" + "\x18GetAccountConfigResponse\x12-\n" + "\x06config\x18\x01 \x01(\v2\x15.config.AccountConfigR\x06config\"K\n" + "\x1aUpdateAccountConfigRequest\x12-\n" + "\x06config\x18\x01 \x01(\v2\x15.config.AccountConfigR\x06config\"L\n" + "\x1bUpdateAccountConfigResponse\x12-\n" + "\x06config\x18\x01 \x01(\v2\x15.config.AccountConfigR\x06config\"N\n" + "\x16CreateHcpConfigRequest\x124\n" + "\x15finalFrontendRedirect\x18\x01 \x01(\tR\x15finalFrontendRedirect\"{\n" + "\x17CreateHcpConfigResponse\x12)\n" + "\x06config\x18\x01 \x01(\v2\x11.config.HcpConfigR\x06config\x125\n" + "\x06apiKey\x18\x02 \x01(\v2\x1d.apikeys.CreateAPIKeyResponseR\x06apiKey\"?\n" + "\tHcpConfig\x12\x1a\n" + "\bendpoint\x18\x01 \x01(\tR\bendpoint\x12\x16\n" + "\x06secret\x18\x02 \x01(\tR\x06secret\"\x15\n" + "\x13GetHcpConfigRequest\"\xa3\x01\n" + "\x14GetHcpConfigResponse\x12)\n" + "\x06config\x18\x01 \x01(\v2\x11.config.HcpConfigR\x06config\x12;\n" + "\x06status\x18\x02 \x01(\x0e2#.config.GetHcpConfigResponse.StatusR\x06status\"#\n" + "\x06Status\x12\x0e\n" + "\n" + "CONFIGURED\x10\x00\x12\t\n" + "\x05ERROR\x10\x01\"\x18\n" + "\x16DeleteHcpConfigRequest\"\x19\n" + "\x17DeleteHcpConfigResponse\"O\n" + "\x17ReplaceHcpApiKeyRequest\x124\n" + "\x15finalFrontendRedirect\x18\x01 \x01(\tR\x15finalFrontendRedirect\"|\n" + "\x18ReplaceHcpApiKeyResponse\x12)\n" + "\x06config\x18\x01 \x01(\v2\x11.config.HcpConfigR\x06config\x125\n" + "\x06apiKey\x18\x02 \x01(\v2\x1d.apikeys.CreateAPIKeyResponseR\x06apiKey\"\x18\n" + "\x16GetSignalConfigRequest\"G\n" + "\x17GetSignalConfigResponse\x12,\n" + "\x06config\x18\x01 \x01(\v2\x14.config.SignalConfigR\x06config\"I\n" + "\x19UpdateSignalConfigRequest\x12,\n" + "\x06config\x18\x01 \x01(\v2\x14.config.SignalConfigR\x06config\"J\n" + "\x1aUpdateSignalConfigResponse\x12,\n" + "\x06config\x18\x01 \x01(\v2\x14.config.SignalConfigR\x06config\"\x97\x03\n" + "\fSignalConfig\x12G\n" + "\x11aggregationConfig\x18\x01 \x01(\v2\x19.config.AggregationConfigR\x11aggregationConfig\x12P\n" + "\x14routineChangesConfig\x18\x02 \x01(\v2\x1c.config.RoutineChangesConfigR\x14routineChangesConfig\x12d\n" + "\x19githubOrganisationProfile\x18\x03 \x01(\v2!.config.GithubOrganisationProfileH\x00R\x19githubOrganisationProfile\x88\x01\x01\x12:\n" + "\x0echeck_run_mode\x18\x04 \x01(\x0e2\x14.config.CheckRunModeR\fcheckRunMode\x12,\n" + "\x12check_runs_enabled\x18\x05 \x01(\bR\x10checkRunsEnabledB\x1c\n" + "\x1a_githubOrganisationProfile\"5\n" + "\x11AggregationConfig\x12 \n" + "\x05alpha\x18\x01 \x01(\x02B\n" + "\xbaH\a\n" + "\x05%\x00\x00\x00\x00R\x05alpha\"\xc3\x02\n" + "\x14RoutineChangesConfig\x12\x1c\n" + "\teventsPer\x18\x01 \x01(\x02R\teventsPer\x12O\n" + "\reventsPerUnit\x18\x02 \x01(\x0e2).config.RoutineChangesConfig.DurationUnitR\reventsPerUnit\x12\x1a\n" + "\bduration\x18\x03 \x01(\x02R\bduration\x12M\n" + "\fdurationUnit\x18\x04 \x01(\x0e2).config.RoutineChangesConfig.DurationUnitR\fdurationUnit\x12 \n" + "\vsensitivity\x18\x05 \x01(\x02R\vsensitivity\"/\n" + "\fDurationUnit\x12\b\n" + "\x04DAYS\x10\x00\x12\t\n" + "\x05WEEKS\x10\x01\x12\n" + "\n" + "\x06MONTHS\x10\x02\" \n" + "\x1eGetGithubAppInformationRequest\"\x92\x06\n" + "\x14GithubAppInformation\x12&\n" + "\x0einstallationID\x18\x01 \x01(\x03R\x0einstallationID\x12 \n" + "\vinstalledBy\x18\x02 \x01(\tR\vinstalledBy\x12<\n" + "\vinstalledAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\vinstalledAt\x12*\n" + "\x10organisationName\x18\x04 \x01(\tR\x10organisationName\x124\n" + "\x15activeRepositoryCount\x18\x05 \x01(\x03R\x15activeRepositoryCount\x12*\n" + "\x10contributorCount\x18\x06 \x01(\x03R\x10contributorCount\x128\n" + "\x17botAutomationPercentage\x18\a \x01(\x03R\x17botAutomationPercentage\x12*\n" + "\x10averageMergeTime\x18\b \x01(\tR\x10averageMergeTime\x126\n" + "\x16averageCommitFrequency\x18\t \x01(\tR\x16averageCommitFrequency\x12/\n" + "\x10requestedOrgName\x18\n" + " \x01(\tH\x00R\x10requestedOrgName\x88\x01\x01\x12A\n" + "\vrequestedAt\x18\v \x01(\v2\x1a.google.protobuf.TimestampH\x01R\vrequestedAt\x88\x01\x01\x12%\n" + "\vrequestedBy\x18\f \x01(\tH\x02R\vrequestedBy\x88\x01\x01\x12!\n" + "\tsuspended\x18\r \x01(\bH\x03R\tsuspended\x88\x01\x01\x12/\n" + "\x11can_create_checks\x18\x0e \x01(\bH\x04R\x0fcanCreateChecks\x88\x01\x01B\x13\n" + "\x11_requestedOrgNameB\x0e\n" + "\f_requestedAtB\x0e\n" + "\f_requestedByB\f\n" + "\n" + "_suspendedB\x14\n" + "\x12_can_create_checks\"s\n" + "\x1fGetGithubAppInformationResponse\x12P\n" + "\x14githubAppInformation\x18\x01 \x01(\v2\x1c.config.GithubAppInformationR\x14githubAppInformation\"#\n" + "!RegenerateGithubAppProfileRequest\"\x8f\x01\n" + "\x19GithubOrganisationProfile\x12,\n" + "\x11primaryBranchName\x18\x01 \x01(\tR\x11primaryBranchName\x12D\n" + "\fhourlyScores\x18\x02 \x03(\x01B \xbaH\x1d\x92\x01\x1a\b\x18\x10\x18\"\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x14@)\x00\x00\x00\x00\x00\x00\x14\xc0R\fhourlyScores\"\x85\x01\n" + "\"RegenerateGithubAppProfileResponse\x12_\n" + "\x19githubOrganisationProfile\x18\x01 \x01(\v2!.config.GithubOrganisationProfileR\x19githubOrganisationProfile\"\x1f\n" + "\x1dCreateGithubInstallURLRequest\"A\n" + "\x1eCreateGithubInstallURLResponse\x12\x1f\n" + "\vinstall_url\x18\x01 \x01(\tR\n" + "installUrl*w\n" + "\fCheckRunMode\x12\x1e\n" + "\x1aCHECK_RUN_MODE_REPORT_ONLY\x10\x00\x12%\n" + "!CHECK_RUN_MODE_FAIL_HIGH_SEVERITY\x10\x01\x12 \n" + "\x1cCHECK_RUN_MODE_FAIL_ANY_RISK\x10\x022\x92\b\n" + "\x14ConfigurationService\x12U\n" + "\x10GetAccountConfig\x12\x1f.config.GetAccountConfigRequest\x1a .config.GetAccountConfigResponse\x12^\n" + "\x13UpdateAccountConfig\x12\".config.UpdateAccountConfigRequest\x1a#.config.UpdateAccountConfigResponse\x12R\n" + "\x0fCreateHcpConfig\x12\x1e.config.CreateHcpConfigRequest\x1a\x1f.config.CreateHcpConfigResponse\x12I\n" + "\fGetHcpConfig\x12\x1b.config.GetHcpConfigRequest\x1a\x1c.config.GetHcpConfigResponse\x12R\n" + "\x0fDeleteHcpConfig\x12\x1e.config.DeleteHcpConfigRequest\x1a\x1f.config.DeleteHcpConfigResponse\x12U\n" + "\x10ReplaceHcpApiKey\x12\x1f.config.ReplaceHcpApiKeyRequest\x1a .config.ReplaceHcpApiKeyResponse\x12R\n" + "\x0fGetSignalConfig\x12\x1e.config.GetSignalConfigRequest\x1a\x1f.config.GetSignalConfigResponse\x12[\n" + "\x12UpdateSignalConfig\x12!.config.UpdateSignalConfigRequest\x1a\".config.UpdateSignalConfigResponse\x12j\n" + "\x17GetGithubAppInformation\x12&.config.GetGithubAppInformationRequest\x1a'.config.GetGithubAppInformationResponse\x12s\n" + "\x1aRegenerateGithubAppProfile\x12).config.RegenerateGithubAppProfileRequest\x1a*.config.RegenerateGithubAppProfileResponse\x12g\n" + "\x16CreateGithubInstallURL\x12%.config.CreateGithubInstallURLRequest\x1a&.config.CreateGithubInstallURLResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_config_proto_rawDescOnce sync.Once file_config_proto_rawDescData []byte ) func file_config_proto_rawDescGZIP() []byte { file_config_proto_rawDescOnce.Do(func() { file_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_config_proto_rawDesc), len(file_config_proto_rawDesc))) }) return file_config_proto_rawDescData } var file_config_proto_enumTypes = make([]protoimpl.EnumInfo, 4) var file_config_proto_msgTypes = make([]protoimpl.MessageInfo, 30) var file_config_proto_goTypes = []any{ (CheckRunMode)(0), // 0: config.CheckRunMode (AccountConfig_BlastRadiusPreset)(0), // 1: config.AccountConfig.BlastRadiusPreset (GetHcpConfigResponse_Status)(0), // 2: config.GetHcpConfigResponse.Status (RoutineChangesConfig_DurationUnit)(0), // 3: config.RoutineChangesConfig.DurationUnit (*BlastRadiusConfig)(nil), // 4: config.BlastRadiusConfig (*AccountConfig)(nil), // 5: config.AccountConfig (*GetAccountConfigRequest)(nil), // 6: config.GetAccountConfigRequest (*GetAccountConfigResponse)(nil), // 7: config.GetAccountConfigResponse (*UpdateAccountConfigRequest)(nil), // 8: config.UpdateAccountConfigRequest (*UpdateAccountConfigResponse)(nil), // 9: config.UpdateAccountConfigResponse (*CreateHcpConfigRequest)(nil), // 10: config.CreateHcpConfigRequest (*CreateHcpConfigResponse)(nil), // 11: config.CreateHcpConfigResponse (*HcpConfig)(nil), // 12: config.HcpConfig (*GetHcpConfigRequest)(nil), // 13: config.GetHcpConfigRequest (*GetHcpConfigResponse)(nil), // 14: config.GetHcpConfigResponse (*DeleteHcpConfigRequest)(nil), // 15: config.DeleteHcpConfigRequest (*DeleteHcpConfigResponse)(nil), // 16: config.DeleteHcpConfigResponse (*ReplaceHcpApiKeyRequest)(nil), // 17: config.ReplaceHcpApiKeyRequest (*ReplaceHcpApiKeyResponse)(nil), // 18: config.ReplaceHcpApiKeyResponse (*GetSignalConfigRequest)(nil), // 19: config.GetSignalConfigRequest (*GetSignalConfigResponse)(nil), // 20: config.GetSignalConfigResponse (*UpdateSignalConfigRequest)(nil), // 21: config.UpdateSignalConfigRequest (*UpdateSignalConfigResponse)(nil), // 22: config.UpdateSignalConfigResponse (*SignalConfig)(nil), // 23: config.SignalConfig (*AggregationConfig)(nil), // 24: config.AggregationConfig (*RoutineChangesConfig)(nil), // 25: config.RoutineChangesConfig (*GetGithubAppInformationRequest)(nil), // 26: config.GetGithubAppInformationRequest (*GithubAppInformation)(nil), // 27: config.GithubAppInformation (*GetGithubAppInformationResponse)(nil), // 28: config.GetGithubAppInformationResponse (*RegenerateGithubAppProfileRequest)(nil), // 29: config.RegenerateGithubAppProfileRequest (*GithubOrganisationProfile)(nil), // 30: config.GithubOrganisationProfile (*RegenerateGithubAppProfileResponse)(nil), // 31: config.RegenerateGithubAppProfileResponse (*CreateGithubInstallURLRequest)(nil), // 32: config.CreateGithubInstallURLRequest (*CreateGithubInstallURLResponse)(nil), // 33: config.CreateGithubInstallURLResponse (*durationpb.Duration)(nil), // 34: google.protobuf.Duration (*CreateAPIKeyResponse)(nil), // 35: apikeys.CreateAPIKeyResponse (*timestamppb.Timestamp)(nil), // 36: google.protobuf.Timestamp } var file_config_proto_depIdxs = []int32{ 34, // 0: config.BlastRadiusConfig.changeAnalysisTargetDuration:type_name -> google.protobuf.Duration 1, // 1: config.AccountConfig.blastRadiusPreset:type_name -> config.AccountConfig.BlastRadiusPreset 4, // 2: config.AccountConfig.blastRadius:type_name -> config.BlastRadiusConfig 5, // 3: config.GetAccountConfigResponse.config:type_name -> config.AccountConfig 5, // 4: config.UpdateAccountConfigRequest.config:type_name -> config.AccountConfig 5, // 5: config.UpdateAccountConfigResponse.config:type_name -> config.AccountConfig 12, // 6: config.CreateHcpConfigResponse.config:type_name -> config.HcpConfig 35, // 7: config.CreateHcpConfigResponse.apiKey:type_name -> apikeys.CreateAPIKeyResponse 12, // 8: config.GetHcpConfigResponse.config:type_name -> config.HcpConfig 2, // 9: config.GetHcpConfigResponse.status:type_name -> config.GetHcpConfigResponse.Status 12, // 10: config.ReplaceHcpApiKeyResponse.config:type_name -> config.HcpConfig 35, // 11: config.ReplaceHcpApiKeyResponse.apiKey:type_name -> apikeys.CreateAPIKeyResponse 23, // 12: config.GetSignalConfigResponse.config:type_name -> config.SignalConfig 23, // 13: config.UpdateSignalConfigRequest.config:type_name -> config.SignalConfig 23, // 14: config.UpdateSignalConfigResponse.config:type_name -> config.SignalConfig 24, // 15: config.SignalConfig.aggregationConfig:type_name -> config.AggregationConfig 25, // 16: config.SignalConfig.routineChangesConfig:type_name -> config.RoutineChangesConfig 30, // 17: config.SignalConfig.githubOrganisationProfile:type_name -> config.GithubOrganisationProfile 0, // 18: config.SignalConfig.check_run_mode:type_name -> config.CheckRunMode 3, // 19: config.RoutineChangesConfig.eventsPerUnit:type_name -> config.RoutineChangesConfig.DurationUnit 3, // 20: config.RoutineChangesConfig.durationUnit:type_name -> config.RoutineChangesConfig.DurationUnit 36, // 21: config.GithubAppInformation.installedAt:type_name -> google.protobuf.Timestamp 36, // 22: config.GithubAppInformation.requestedAt:type_name -> google.protobuf.Timestamp 27, // 23: config.GetGithubAppInformationResponse.githubAppInformation:type_name -> config.GithubAppInformation 30, // 24: config.RegenerateGithubAppProfileResponse.githubOrganisationProfile:type_name -> config.GithubOrganisationProfile 6, // 25: config.ConfigurationService.GetAccountConfig:input_type -> config.GetAccountConfigRequest 8, // 26: config.ConfigurationService.UpdateAccountConfig:input_type -> config.UpdateAccountConfigRequest 10, // 27: config.ConfigurationService.CreateHcpConfig:input_type -> config.CreateHcpConfigRequest 13, // 28: config.ConfigurationService.GetHcpConfig:input_type -> config.GetHcpConfigRequest 15, // 29: config.ConfigurationService.DeleteHcpConfig:input_type -> config.DeleteHcpConfigRequest 17, // 30: config.ConfigurationService.ReplaceHcpApiKey:input_type -> config.ReplaceHcpApiKeyRequest 19, // 31: config.ConfigurationService.GetSignalConfig:input_type -> config.GetSignalConfigRequest 21, // 32: config.ConfigurationService.UpdateSignalConfig:input_type -> config.UpdateSignalConfigRequest 26, // 33: config.ConfigurationService.GetGithubAppInformation:input_type -> config.GetGithubAppInformationRequest 29, // 34: config.ConfigurationService.RegenerateGithubAppProfile:input_type -> config.RegenerateGithubAppProfileRequest 32, // 35: config.ConfigurationService.CreateGithubInstallURL:input_type -> config.CreateGithubInstallURLRequest 7, // 36: config.ConfigurationService.GetAccountConfig:output_type -> config.GetAccountConfigResponse 9, // 37: config.ConfigurationService.UpdateAccountConfig:output_type -> config.UpdateAccountConfigResponse 11, // 38: config.ConfigurationService.CreateHcpConfig:output_type -> config.CreateHcpConfigResponse 14, // 39: config.ConfigurationService.GetHcpConfig:output_type -> config.GetHcpConfigResponse 16, // 40: config.ConfigurationService.DeleteHcpConfig:output_type -> config.DeleteHcpConfigResponse 18, // 41: config.ConfigurationService.ReplaceHcpApiKey:output_type -> config.ReplaceHcpApiKeyResponse 20, // 42: config.ConfigurationService.GetSignalConfig:output_type -> config.GetSignalConfigResponse 22, // 43: config.ConfigurationService.UpdateSignalConfig:output_type -> config.UpdateSignalConfigResponse 28, // 44: config.ConfigurationService.GetGithubAppInformation:output_type -> config.GetGithubAppInformationResponse 31, // 45: config.ConfigurationService.RegenerateGithubAppProfile:output_type -> config.RegenerateGithubAppProfileResponse 33, // 46: config.ConfigurationService.CreateGithubInstallURL:output_type -> config.CreateGithubInstallURLResponse 36, // [36:47] is the sub-list for method output_type 25, // [25:36] is the sub-list for method input_type 25, // [25:25] is the sub-list for extension type_name 25, // [25:25] is the sub-list for extension extendee 0, // [0:25] is the sub-list for field type_name } func init() { file_config_proto_init() } func file_config_proto_init() { if File_config_proto != nil { return } file_apikeys_proto_init() file_config_proto_msgTypes[0].OneofWrappers = []any{} file_config_proto_msgTypes[1].OneofWrappers = []any{} file_config_proto_msgTypes[19].OneofWrappers = []any{} file_config_proto_msgTypes[23].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_config_proto_rawDesc), len(file_config_proto_rawDesc)), NumEnums: 4, NumMessages: 30, NumExtensions: 0, NumServices: 1, }, GoTypes: file_config_proto_goTypes, DependencyIndexes: file_config_proto_depIdxs, EnumInfos: file_config_proto_enumTypes, MessageInfos: file_config_proto_msgTypes, }.Build() File_config_proto = out.File file_config_proto_goTypes = nil file_config_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/connection.go ================================================ package sdp import ( "context" "fmt" reflect "reflect" "github.com/nats-io/nats.go" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/proto" ) // EncodedConnection is an interface that allows messages to be published to it. // In production this would always be filled by a *nats.EncodedConn, however in // testing we will mock this with something that does nothing type EncodedConnection interface { Publish(ctx context.Context, subj string, m proto.Message) error PublishRequest(ctx context.Context, subj, replyTo string, m proto.Message) error PublishMsg(ctx context.Context, msg *nats.Msg) error Subscribe(subj string, cb nats.MsgHandler) (*nats.Subscription, error) QueueSubscribe(subj, queue string, cb nats.MsgHandler) (*nats.Subscription, error) RequestMsg(ctx context.Context, msg *nats.Msg) (*nats.Msg, error) Status() nats.Status Stats() nats.Statistics LastError() error Drain() error Close() Underlying() *nats.Conn Drop() } type EncodedConnectionImpl struct { Conn *nats.Conn } // assert interface implementation var _ EncodedConnection = (*EncodedConnectionImpl)(nil) func recordMessage(ctx context.Context, name, subj, typ, msg string) { log.WithContext(ctx).WithFields(log.Fields{ "msg": msg, "subj": subj, "typ": typ, }).Trace(name) // avoid spamming honeycomb if log.GetLevel() == log.TraceLevel { span := trace.SpanFromContext(ctx) span.AddEvent(name, trace.WithAttributes( attribute.String("ovm.sdp.subject", subj), attribute.String("ovm.sdp.message", msg), )) } } func (ec *EncodedConnectionImpl) Publish(ctx context.Context, subj string, m proto.Message) error { recordMessage(ctx, "Publish", subj, fmt.Sprint(reflect.TypeOf(m)), fmt.Sprintf("%d bytes", proto.Size(m))) data, err := proto.Marshal(m) if err != nil { return err } msg := &nats.Msg{ Subject: subj, Data: data, } InjectOtelTraceContext(ctx, msg) return ec.Conn.PublishMsg(msg) } func (ec *EncodedConnectionImpl) PublishRequest(ctx context.Context, subj, replyTo string, m proto.Message) error { recordMessage(ctx, "Publish", subj, fmt.Sprint(reflect.TypeOf(m)), fmt.Sprintf("%d bytes", proto.Size(m))) data, err := proto.Marshal(m) if err != nil { return err } msg := &nats.Msg{ Subject: subj, Data: data, } msg.Header.Add("reply-to", replyTo) InjectOtelTraceContext(ctx, msg) return ec.Conn.PublishMsg(msg) } func (ec *EncodedConnectionImpl) PublishMsg(ctx context.Context, msg *nats.Msg) error { recordMessage(ctx, "Publish", msg.Subject, "[]byte", "binary") InjectOtelTraceContext(ctx, msg) return ec.Conn.PublishMsg(msg) } // Subscribe Use genhandler to get a nats.MsgHandler with otel propagation and protobuf marshaling func (ec *EncodedConnectionImpl) Subscribe(subj string, cb nats.MsgHandler) (*nats.Subscription, error) { return ec.Conn.Subscribe(subj, cb) } // QueueSubscribe Use genhandler to get a nats.MsgHandler with otel propagation and protobuf marshaling func (ec *EncodedConnectionImpl) QueueSubscribe(subj, queue string, cb nats.MsgHandler) (*nats.Subscription, error) { return ec.Conn.QueueSubscribe(subj, queue, cb) } func (ec *EncodedConnectionImpl) RequestMsg(ctx context.Context, msg *nats.Msg) (*nats.Msg, error) { recordMessage(ctx, "RequestMsg", msg.Subject, "[]byte", "binary") InjectOtelTraceContext(ctx, msg) reply, err := ec.Conn.RequestMsgWithContext(ctx, msg) if err != nil { recordMessage(ctx, "RequestMsg Error", msg.Subject, fmt.Sprint(reflect.TypeOf(err)), err.Error()) } else { recordMessage(ctx, "RequestMsg Reply", msg.Subject, "[]byte", "binary") } return reply, err } func (ec *EncodedConnectionImpl) Drain() error { return ec.Conn.Drain() } func (ec *EncodedConnectionImpl) Close() { ec.Conn.Close() } func (ec *EncodedConnectionImpl) Status() nats.Status { return ec.Conn.Status() } func (ec *EncodedConnectionImpl) Stats() nats.Statistics { return ec.Conn.Stats() } func (ec *EncodedConnectionImpl) LastError() error { return ec.Conn.LastError() } func (ec *EncodedConnectionImpl) Underlying() *nats.Conn { return ec.Conn } // Drop Drops the underlying connection completely func (ec *EncodedConnectionImpl) Drop() { ec.Conn = nil } // Unmarshal Does a proto.Unmarshal and logs errors in a consistent way. The // user should still validate that the message is valid as it's possible to // unmarshal data from one message format into another without an error. // Validation should be based on the type that the data is being unmarshaled // into. func Unmarshal(ctx context.Context, b []byte, m proto.Message) error { err := proto.Unmarshal(b, m) if err != nil { recordMessage(ctx, "Unmarshal err", "unknown", fmt.Sprint(reflect.TypeOf(err)), err.Error()) log.WithContext(ctx).Errorf("Error parsing message: %v", err) trace.SpanFromContext(ctx).SetStatus(codes.Error, fmt.Sprintf("Error parsing message: %v", err)) return err } recordMessage(ctx, "Unmarshal", "unknown", fmt.Sprint(reflect.TypeOf(m)), fmt.Sprintf("%d bytes", proto.Size(m))) return nil } //go:generate go run genhandler.go Query //go:generate go run genhandler.go QueryResponse //go:generate go run genhandler.go CancelQuery //go:generate go run genhandler.go GatewayResponse //go:generate go run genhandler.go NATSGetLogRecordsRequest //go:generate go run genhandler.go NATSGetLogRecordsResponse ================================================ FILE: go/sdp-go/connection_test.go ================================================ package sdp import ( "context" "testing" ) // This is an example of a Query with a timeout. This attribute was removed and // replaced with a `reserved` field. Therefore it is being used to test how we // handle older messages var exampleRemovedAttribute = []byte{ 0xa, 0x3, 0x66, 0x6f, 0x6f, 0x1a, 0x3, 0x66, 0x6f, 0x6f, 0x22, 0x4, 0x8, 0xa, 0x10, 0x1, 0x2a, 0x3, 0x66, 0x6f, 0x6f, 0x30, 0x1, 0x3a, 0x10, 0x4e, 0x43, 0x68, 0xd9, 0x17, 0xd4, 0x4d, 0x83, 0xa9, 0xe6, 0xf5, 0x3a, 0xec, 0xc7, 0xe7, 0xf0, 0x42, 0x2, 0x8, 0xa, } func TestUnmarshal(t *testing.T) { ctx := context.Background() query := new(Query) err := Unmarshal(ctx, exampleRemovedAttribute, query) if err != nil { t.Error(err) } } ================================================ FILE: go/sdp-go/encoder_test.go ================================================ package sdp import ( "context" "testing" "time" "github.com/google/uuid" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) var _u = uuid.New() var query = Query{ Type: "user", Method: QueryMethod_LIST, RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 10, }, Scope: "test", UUID: _u[:], Deadline: timestamppb.New(time.Now().Add(10 * time.Second)), } var itemAttributes = ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "foo": { Kind: &structpb.Value_StringValue{ StringValue: "bar", }, }, }, }, } var metadata = Metadata{ SourceName: "users", SourceQuery: &Query{ Type: "user", Method: QueryMethod_LIST, Query: "*", RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 12, }, Scope: "testScope", }, Timestamp: timestamppb.Now(), SourceDuration: &durationpb.Duration{ Seconds: 1, Nanos: 1, }, SourceDurationPerItem: &durationpb.Duration{ Seconds: 0, Nanos: 500, }, } var item = Item{ Type: "user", UniqueAttribute: "name", Attributes: &itemAttributes, Metadata: &metadata, } var items = Items{ Items: []*Item{ &item, }, } var reference = Reference{ Type: "user", UniqueAttributeValue: "dylan", Scope: "test", } var queryError = QueryError{ ErrorType: QueryError_OTHER, ErrorString: "uh oh", Scope: "test", } var ru = uuid.New() var response = Response{ Responder: "test", ResponderUUID: ru[:], State: ResponderState_WORKING, NextUpdateIn: &durationpb.Duration{ Seconds: 10, Nanos: 0, }, } var messages = []proto.Message{ &query, &itemAttributes, &metadata, &item, &items, &reference, &queryError, &response, } // TestEncode Make sure that we can encode all of the message types without // raising any errors func TestEncode(t *testing.T) { for _, message := range messages { _, err := proto.Marshal(message) if err != nil { t.Error(err) } } } var decodeTests = []struct { Message proto.Message Target proto.Message }{ { Message: &query, Target: &Query{}, }, { Message: &itemAttributes, Target: &ItemAttributes{}, }, { Message: &metadata, Target: &Metadata{}, }, { Message: &item, Target: &Item{}, }, { Message: &items, Target: &Items{}, }, { Message: &reference, Target: &Reference{}, }, { Message: &queryError, Target: &QueryError{}, }, { Message: &response, Target: &Response{}, }, } // TestDecode Make sure that we can decode all of the message func TestDecode(t *testing.T) { for _, decTest := range decodeTests { // Marshal to binary b, err := proto.Marshal(decTest.Message) if err != nil { t.Fatal(err) } err = Unmarshal(context.Background(), b, decTest.Target) if err != nil { t.Error(err) } } } ================================================ FILE: go/sdp-go/errors.go ================================================ package sdp import ( "errors" "fmt" "github.com/google/uuid" ) const ErrorTemplate string = `%v ErrorType: %v Scope: %v SourceName: %v ItemType: %v ResponderName: %v` // assert interface var _ error = (*QueryError)(nil) func (e *QueryError) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(e.GetUUID()) if err != nil { return nil } return &u } // Ensure that the QueryError is seen as a valid error in golang func (e *QueryError) Error() string { return fmt.Sprintf( ErrorTemplate, e.GetErrorString(), e.GetErrorType().String(), e.GetScope(), e.GetSourceName(), e.GetItemType(), e.GetResponderName(), ) } // NewQueryError converts a regular error to a QueryError of type // OTHER. If the input error is already a QueryError then it is preserved func NewQueryError(err error) *QueryError { var sdpErr *QueryError if errors.As(err, &sdpErr) { return sdpErr } return &QueryError{ ErrorType: QueryError_OTHER, ErrorString: err.Error(), } } ================================================ FILE: go/sdp-go/gateway.go ================================================ package sdp import ( "encoding/hex" "github.com/google/uuid" ) // Equal Returns whether two statuses are functionally equal func (x *GatewayRequestStatus) Equal(y *GatewayRequestStatus) bool { if x == nil { if y == nil { return true } else { return false } } if (x.GetSummary() == nil || y.GetSummary() == nil) && x.GetSummary() != y.GetSummary() { // If one of them is nil, and they aren't both nil return false } if x.GetSummary() != nil && y.GetSummary() != nil { if x.GetSummary().GetWorking() != y.GetSummary().GetWorking() { return false } if x.GetSummary().GetStalled() != y.GetSummary().GetStalled() { return false } if x.GetSummary().GetComplete() != y.GetSummary().GetComplete() { return false } if x.GetSummary().GetError() != y.GetSummary().GetError() { return false } if x.GetSummary().GetCancelled() != y.GetSummary().GetCancelled() { return false } if x.GetSummary().GetResponders() != y.GetSummary().GetResponders() { return false } } if x.GetPostProcessingComplete() != y.GetPostProcessingComplete() { return false } return true } // Whether the gateway request is complete func (x *GatewayRequestStatus) Done() bool { return x.GetPostProcessingComplete() && x.GetSummary().GetWorking() == 0 } // GetMsgIDLogString returns the correlation ID as string for logging func (x *StoreBookmark) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err != nil { return "" } return u.String() } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *BookmarkStoreResult) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 0 { return "" } if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err == nil { return u.String() } } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *LoadBookmark) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 0 { return "" } if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err == nil { return u.String() } } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *BookmarkLoadResult) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 0 { return "" } if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err == nil { return u.String() } } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *StoreSnapshot) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 0 { return "" } if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err == nil { return u.String() } } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *SnapshotStoreResult) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 0 { return "" } if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err == nil { return u.String() } } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *LoadSnapshot) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 0 { return "" } if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err == nil { return u.String() } } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *SnapshotLoadResult) GetMsgIDLogString() string { bs := x.GetMsgID() if len(bs) == 0 { return "" } if len(bs) == 16 { u, err := uuid.FromBytes(bs) if err == nil { return u.String() } } return hex.EncodeToString(bs) } // GetMsgIDLogString returns the correlation ID as string for logging func (x *QueryStatus) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetUUID()) if err != nil { return nil } return &u } func (x *LoadSnapshot) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetUUID()) if err != nil { return nil } return &u } ================================================ FILE: go/sdp-go/gateway.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: gateway.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // A union of all request made to the gateway. type GatewayRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to RequestType: // // *GatewayRequest_Query // *GatewayRequest_CancelQuery // *GatewayRequest_Expand // *GatewayRequest_StoreSnapshot // *GatewayRequest_LoadSnapshot // *GatewayRequest_StoreBookmark // *GatewayRequest_LoadBookmark // *GatewayRequest_ChatMessage RequestType isGatewayRequest_RequestType `protobuf_oneof:"request_type"` MinStatusInterval *durationpb.Duration `protobuf:"bytes,2,opt,name=minStatusInterval,proto3,oneof" json:"minStatusInterval,omitempty"` // Minimum time between status updates. Setting this value too low can result in too many status messages unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GatewayRequest) Reset() { *x = GatewayRequest{} mi := &file_gateway_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GatewayRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GatewayRequest) ProtoMessage() {} func (x *GatewayRequest) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GatewayRequest.ProtoReflect.Descriptor instead. func (*GatewayRequest) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{0} } func (x *GatewayRequest) GetRequestType() isGatewayRequest_RequestType { if x != nil { return x.RequestType } return nil } func (x *GatewayRequest) GetQuery() *Query { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_Query); ok { return x.Query } } return nil } func (x *GatewayRequest) GetCancelQuery() *CancelQuery { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_CancelQuery); ok { return x.CancelQuery } } return nil } func (x *GatewayRequest) GetExpand() *Expand { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_Expand); ok { return x.Expand } } return nil } func (x *GatewayRequest) GetStoreSnapshot() *StoreSnapshot { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_StoreSnapshot); ok { return x.StoreSnapshot } } return nil } func (x *GatewayRequest) GetLoadSnapshot() *LoadSnapshot { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_LoadSnapshot); ok { return x.LoadSnapshot } } return nil } func (x *GatewayRequest) GetStoreBookmark() *StoreBookmark { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_StoreBookmark); ok { return x.StoreBookmark } } return nil } func (x *GatewayRequest) GetLoadBookmark() *LoadBookmark { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_LoadBookmark); ok { return x.LoadBookmark } } return nil } func (x *GatewayRequest) GetChatMessage() *ChatMessage { if x != nil { if x, ok := x.RequestType.(*GatewayRequest_ChatMessage); ok { return x.ChatMessage } } return nil } func (x *GatewayRequest) GetMinStatusInterval() *durationpb.Duration { if x != nil { return x.MinStatusInterval } return nil } type isGatewayRequest_RequestType interface { isGatewayRequest_RequestType() } type GatewayRequest_Query struct { // Adds a new query for items to the session, starting it immediately Query *Query `protobuf:"bytes,1,opt,name=query,proto3,oneof"` } type GatewayRequest_CancelQuery struct { // Cancel a running query CancelQuery *CancelQuery `protobuf:"bytes,3,opt,name=cancelQuery,proto3,oneof"` } type GatewayRequest_Expand struct { // Expand all linked items for the given item Expand *Expand `protobuf:"bytes,7,opt,name=expand,proto3,oneof"` } type GatewayRequest_StoreSnapshot struct { // store the current session state as snapshot StoreSnapshot *StoreSnapshot `protobuf:"bytes,10,opt,name=storeSnapshot,proto3,oneof"` } type GatewayRequest_LoadSnapshot struct { // load a snapshot into the current state LoadSnapshot *LoadSnapshot `protobuf:"bytes,11,opt,name=loadSnapshot,proto3,oneof"` } type GatewayRequest_StoreBookmark struct { // store the current set of queries as bookmarks StoreBookmark *StoreBookmark `protobuf:"bytes,14,opt,name=storeBookmark,proto3,oneof"` } type GatewayRequest_LoadBookmark struct { // load and execute a bookmark into the current state LoadBookmark *LoadBookmark `protobuf:"bytes,15,opt,name=loadBookmark,proto3,oneof"` } type GatewayRequest_ChatMessage struct { // // cancel the loading of a Bookmark // CancelLoadBookmark cancelLoadBookmark = ??; // // undo the loading of a Bookmark // UndoLoadBookmark undoLoadBookmark = ??; ChatMessage *ChatMessage `protobuf:"bytes,16,opt,name=chatMessage,proto3,oneof"` } func (*GatewayRequest_Query) isGatewayRequest_RequestType() {} func (*GatewayRequest_CancelQuery) isGatewayRequest_RequestType() {} func (*GatewayRequest_Expand) isGatewayRequest_RequestType() {} func (*GatewayRequest_StoreSnapshot) isGatewayRequest_RequestType() {} func (*GatewayRequest_LoadSnapshot) isGatewayRequest_RequestType() {} func (*GatewayRequest_StoreBookmark) isGatewayRequest_RequestType() {} func (*GatewayRequest_LoadBookmark) isGatewayRequest_RequestType() {} func (*GatewayRequest_ChatMessage) isGatewayRequest_RequestType() {} // The gateway will always respond with this type of message, // however the purpose of it is purely as a wrapper to the many different types // of messages that the gateway can send type GatewayResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to ResponseType: // // *GatewayResponse_NewItem // *GatewayResponse_NewEdge // *GatewayResponse_Status // *GatewayResponse_Error // *GatewayResponse_QueryError // *GatewayResponse_DeleteItemRef // *GatewayResponse_DeleteEdge // *GatewayResponse_UpdateItem // *GatewayResponse_SnapshotStoreResult // *GatewayResponse_SnapshotLoadResult // *GatewayResponse_BookmarkStoreResult // *GatewayResponse_BookmarkLoadResult // *GatewayResponse_QueryStatus // *GatewayResponse_ChatResponse // *GatewayResponse_ToolStart // *GatewayResponse_ToolFinish ResponseType isGatewayResponse_ResponseType `protobuf_oneof:"response_type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GatewayResponse) Reset() { *x = GatewayResponse{} mi := &file_gateway_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GatewayResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GatewayResponse) ProtoMessage() {} func (x *GatewayResponse) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GatewayResponse.ProtoReflect.Descriptor instead. func (*GatewayResponse) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{1} } func (x *GatewayResponse) GetResponseType() isGatewayResponse_ResponseType { if x != nil { return x.ResponseType } return nil } func (x *GatewayResponse) GetNewItem() *Item { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_NewItem); ok { return x.NewItem } } return nil } func (x *GatewayResponse) GetNewEdge() *Edge { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_NewEdge); ok { return x.NewEdge } } return nil } func (x *GatewayResponse) GetStatus() *GatewayRequestStatus { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_Status); ok { return x.Status } } return nil } func (x *GatewayResponse) GetError() string { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_Error); ok { return x.Error } } return "" } func (x *GatewayResponse) GetQueryError() *QueryError { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_QueryError); ok { return x.QueryError } } return nil } func (x *GatewayResponse) GetDeleteItemRef() *Reference { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_DeleteItemRef); ok { return x.DeleteItemRef } } return nil } func (x *GatewayResponse) GetDeleteEdge() *Edge { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_DeleteEdge); ok { return x.DeleteEdge } } return nil } func (x *GatewayResponse) GetUpdateItem() *Item { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_UpdateItem); ok { return x.UpdateItem } } return nil } func (x *GatewayResponse) GetSnapshotStoreResult() *SnapshotStoreResult { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_SnapshotStoreResult); ok { return x.SnapshotStoreResult } } return nil } func (x *GatewayResponse) GetSnapshotLoadResult() *SnapshotLoadResult { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_SnapshotLoadResult); ok { return x.SnapshotLoadResult } } return nil } func (x *GatewayResponse) GetBookmarkStoreResult() *BookmarkStoreResult { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_BookmarkStoreResult); ok { return x.BookmarkStoreResult } } return nil } func (x *GatewayResponse) GetBookmarkLoadResult() *BookmarkLoadResult { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_BookmarkLoadResult); ok { return x.BookmarkLoadResult } } return nil } func (x *GatewayResponse) GetQueryStatus() *QueryStatus { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_QueryStatus); ok { return x.QueryStatus } } return nil } func (x *GatewayResponse) GetChatResponse() *ChatResponse { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_ChatResponse); ok { return x.ChatResponse } } return nil } func (x *GatewayResponse) GetToolStart() *ToolStart { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_ToolStart); ok { return x.ToolStart } } return nil } func (x *GatewayResponse) GetToolFinish() *ToolFinish { if x != nil { if x, ok := x.ResponseType.(*GatewayResponse_ToolFinish); ok { return x.ToolFinish } } return nil } type isGatewayResponse_ResponseType interface { isGatewayResponse_ResponseType() } type GatewayResponse_NewItem struct { NewItem *Item `protobuf:"bytes,2,opt,name=newItem,proto3,oneof"` // A new item that has been discovered } type GatewayResponse_NewEdge struct { NewEdge *Edge `protobuf:"bytes,3,opt,name=newEdge,proto3,oneof"` // A new edge between two items } type GatewayResponse_Status struct { Status *GatewayRequestStatus `protobuf:"bytes,4,opt,name=status,proto3,oneof"` // Status of the overall request } type GatewayResponse_Error struct { Error string `protobuf:"bytes,5,opt,name=error,proto3,oneof"` // An error that means the request couldn't be executed } type GatewayResponse_QueryError struct { QueryError *QueryError `protobuf:"bytes,6,opt,name=queryError,proto3,oneof"` // A new error that was encountered as part of a query } type GatewayResponse_DeleteItemRef struct { DeleteItemRef *Reference `protobuf:"bytes,7,opt,name=deleteItemRef,proto3,oneof"` // An item that should be deleted from local state } type GatewayResponse_DeleteEdge struct { DeleteEdge *Edge `protobuf:"bytes,8,opt,name=deleteEdge,proto3,oneof"` // An edge that should be deleted form local state } type GatewayResponse_UpdateItem struct { UpdateItem *Item `protobuf:"bytes,9,opt,name=updateItem,proto3,oneof"` // An item that has already been sent, but contains new data, it should be updated to reflect this version } type GatewayResponse_SnapshotStoreResult struct { SnapshotStoreResult *SnapshotStoreResult `protobuf:"bytes,11,opt,name=snapshotStoreResult,proto3,oneof"` } type GatewayResponse_SnapshotLoadResult struct { SnapshotLoadResult *SnapshotLoadResult `protobuf:"bytes,12,opt,name=snapshotLoadResult,proto3,oneof"` } type GatewayResponse_BookmarkStoreResult struct { BookmarkStoreResult *BookmarkStoreResult `protobuf:"bytes,15,opt,name=bookmarkStoreResult,proto3,oneof"` } type GatewayResponse_BookmarkLoadResult struct { BookmarkLoadResult *BookmarkLoadResult `protobuf:"bytes,16,opt,name=bookmarkLoadResult,proto3,oneof"` } type GatewayResponse_QueryStatus struct { QueryStatus *QueryStatus `protobuf:"bytes,17,opt,name=queryStatus,proto3,oneof"` // Status of requested queries } type GatewayResponse_ChatResponse struct { ChatResponse *ChatResponse `protobuf:"bytes,18,opt,name=chatResponse,proto3,oneof"` } type GatewayResponse_ToolStart struct { ToolStart *ToolStart `protobuf:"bytes,19,opt,name=toolStart,proto3,oneof"` } type GatewayResponse_ToolFinish struct { ToolFinish *ToolFinish `protobuf:"bytes,20,opt,name=toolFinish,proto3,oneof"` } func (*GatewayResponse_NewItem) isGatewayResponse_ResponseType() {} func (*GatewayResponse_NewEdge) isGatewayResponse_ResponseType() {} func (*GatewayResponse_Status) isGatewayResponse_ResponseType() {} func (*GatewayResponse_Error) isGatewayResponse_ResponseType() {} func (*GatewayResponse_QueryError) isGatewayResponse_ResponseType() {} func (*GatewayResponse_DeleteItemRef) isGatewayResponse_ResponseType() {} func (*GatewayResponse_DeleteEdge) isGatewayResponse_ResponseType() {} func (*GatewayResponse_UpdateItem) isGatewayResponse_ResponseType() {} func (*GatewayResponse_SnapshotStoreResult) isGatewayResponse_ResponseType() {} func (*GatewayResponse_SnapshotLoadResult) isGatewayResponse_ResponseType() {} func (*GatewayResponse_BookmarkStoreResult) isGatewayResponse_ResponseType() {} func (*GatewayResponse_BookmarkLoadResult) isGatewayResponse_ResponseType() {} func (*GatewayResponse_QueryStatus) isGatewayResponse_ResponseType() {} func (*GatewayResponse_ChatResponse) isGatewayResponse_ResponseType() {} func (*GatewayResponse_ToolStart) isGatewayResponse_ResponseType() {} func (*GatewayResponse_ToolFinish) isGatewayResponse_ResponseType() {} // Contains the status of the gateway request. type GatewayRequestStatus struct { state protoimpl.MessageState `protogen:"open.v1"` Summary *GatewayRequestStatus_Summary `protobuf:"bytes,3,opt,name=summary,proto3" json:"summary,omitempty"` // Whether all items have finished being processed by the gateway. It is // possible for all responders to be complete, but the gateway is still // working. A request should only be considered complete when all working == // 0 and postProcessingComplete == true PostProcessingComplete bool `protobuf:"varint,4,opt,name=postProcessingComplete,proto3" json:"postProcessingComplete,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GatewayRequestStatus) Reset() { *x = GatewayRequestStatus{} mi := &file_gateway_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GatewayRequestStatus) String() string { return protoimpl.X.MessageStringOf(x) } func (*GatewayRequestStatus) ProtoMessage() {} func (x *GatewayRequestStatus) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GatewayRequestStatus.ProtoReflect.Descriptor instead. func (*GatewayRequestStatus) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{2} } func (x *GatewayRequestStatus) GetSummary() *GatewayRequestStatus_Summary { if x != nil { return x.Summary } return nil } func (x *GatewayRequestStatus) GetPostProcessingComplete() bool { if x != nil { return x.PostProcessingComplete } return false } // Ask the gateway to store the current state as bookmark with the specified details. // Returns a BookmarkStored message when the bookmark is stored type StoreBookmark struct { state protoimpl.MessageState `protogen:"open.v1"` // user supplied name of this bookmark Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // user supplied description of this bookmark Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` // a correlation ID to match up requests and responses. set this to a value unique per connection MsgID []byte `protobuf:"bytes,3,opt,name=msgID,proto3" json:"msgID,omitempty"` // whether this bookmark should be stored as a system bookmark. System // bookmarks are hidden and can only be returned via the UUID, they don't // show up in lists IsSystem bool `protobuf:"varint,4,opt,name=isSystem,proto3" json:"isSystem,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StoreBookmark) Reset() { *x = StoreBookmark{} mi := &file_gateway_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StoreBookmark) String() string { return protoimpl.X.MessageStringOf(x) } func (*StoreBookmark) ProtoMessage() {} func (x *StoreBookmark) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StoreBookmark.ProtoReflect.Descriptor instead. func (*StoreBookmark) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{3} } func (x *StoreBookmark) GetName() string { if x != nil { return x.Name } return "" } func (x *StoreBookmark) GetDescription() string { if x != nil { return x.Description } return "" } func (x *StoreBookmark) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } func (x *StoreBookmark) GetIsSystem() bool { if x != nil { return x.IsSystem } return false } // After a bookmark is successfully stored, this reply with the new bookmark's details is sent. type BookmarkStoreResult struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` ErrorMessage string `protobuf:"bytes,2,opt,name=errorMessage,proto3" json:"errorMessage,omitempty"` // a correlation ID to match up requests and responses. this field returns the contents of the request's msgID MsgID []byte `protobuf:"bytes,4,opt,name=msgID,proto3" json:"msgID,omitempty"` // UUID of the newly created bookmark BookmarkID []byte `protobuf:"bytes,5,opt,name=bookmarkID,proto3" json:"bookmarkID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BookmarkStoreResult) Reset() { *x = BookmarkStoreResult{} mi := &file_gateway_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BookmarkStoreResult) String() string { return protoimpl.X.MessageStringOf(x) } func (*BookmarkStoreResult) ProtoMessage() {} func (x *BookmarkStoreResult) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BookmarkStoreResult.ProtoReflect.Descriptor instead. func (*BookmarkStoreResult) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{4} } func (x *BookmarkStoreResult) GetSuccess() bool { if x != nil { return x.Success } return false } func (x *BookmarkStoreResult) GetErrorMessage() string { if x != nil { return x.ErrorMessage } return "" } func (x *BookmarkStoreResult) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } func (x *BookmarkStoreResult) GetBookmarkID() []byte { if x != nil { return x.BookmarkID } return nil } // Ask the gateway to load the specified bookmark into the current state. // Results are streamed to the client in the same way query results are. type LoadBookmark struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id of the bookmark to load UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // a correlation ID to match up requests and responses. set this to a value unique per connection MsgID []byte `protobuf:"bytes,2,opt,name=msgID,proto3" json:"msgID,omitempty"` // set to true to force fetching fresh data IgnoreCache bool `protobuf:"varint,3,opt,name=ignoreCache,proto3" json:"ignoreCache,omitempty"` // The time at which the gateway should stop processing the queries spawned by this request Deadline *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=deadline,proto3" json:"deadline,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LoadBookmark) Reset() { *x = LoadBookmark{} mi := &file_gateway_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LoadBookmark) String() string { return protoimpl.X.MessageStringOf(x) } func (*LoadBookmark) ProtoMessage() {} func (x *LoadBookmark) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LoadBookmark.ProtoReflect.Descriptor instead. func (*LoadBookmark) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{5} } func (x *LoadBookmark) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *LoadBookmark) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } func (x *LoadBookmark) GetIgnoreCache() bool { if x != nil { return x.IgnoreCache } return false } func (x *LoadBookmark) GetDeadline() *timestamppb.Timestamp { if x != nil { return x.Deadline } return nil } type BookmarkLoadResult struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` ErrorMessage string `protobuf:"bytes,2,opt,name=errorMessage,proto3" json:"errorMessage,omitempty"` // UUIDs of all queries that have been started as a result of loading this bookmark StartedQueryUUIDs [][]byte `protobuf:"bytes,3,rep,name=startedQueryUUIDs,proto3" json:"startedQueryUUIDs,omitempty"` // a correlation ID to match up requests and responses. this field returns the contents of the request's msgID MsgID []byte `protobuf:"bytes,4,opt,name=msgID,proto3" json:"msgID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BookmarkLoadResult) Reset() { *x = BookmarkLoadResult{} mi := &file_gateway_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BookmarkLoadResult) String() string { return protoimpl.X.MessageStringOf(x) } func (*BookmarkLoadResult) ProtoMessage() {} func (x *BookmarkLoadResult) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BookmarkLoadResult.ProtoReflect.Descriptor instead. func (*BookmarkLoadResult) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{6} } func (x *BookmarkLoadResult) GetSuccess() bool { if x != nil { return x.Success } return false } func (x *BookmarkLoadResult) GetErrorMessage() string { if x != nil { return x.ErrorMessage } return "" } func (x *BookmarkLoadResult) GetStartedQueryUUIDs() [][]byte { if x != nil { return x.StartedQueryUUIDs } return nil } func (x *BookmarkLoadResult) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } // Ask the gateway to store the current state as snapshot with the specified details. // Returns a SnapshotStored message when the snapshot is stored type StoreSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` // user supplied name of this snapshot Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // user supplied description of this snapshot Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` // a correlation ID to match up requests and responses. set this to a value unique per connection MsgID []byte `protobuf:"bytes,3,opt,name=msgID,proto3" json:"msgID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StoreSnapshot) Reset() { *x = StoreSnapshot{} mi := &file_gateway_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StoreSnapshot) String() string { return protoimpl.X.MessageStringOf(x) } func (*StoreSnapshot) ProtoMessage() {} func (x *StoreSnapshot) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StoreSnapshot.ProtoReflect.Descriptor instead. func (*StoreSnapshot) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{7} } func (x *StoreSnapshot) GetName() string { if x != nil { return x.Name } return "" } func (x *StoreSnapshot) GetDescription() string { if x != nil { return x.Description } return "" } func (x *StoreSnapshot) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } // After a snapshot is successfully stored, this reply with the new snapshot's details is sent. type SnapshotStoreResult struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` ErrorMessage string `protobuf:"bytes,2,opt,name=errorMessage,proto3" json:"errorMessage,omitempty"` // a correlation ID to match up requests and responses. this field returns the contents of the request's msgID MsgID []byte `protobuf:"bytes,4,opt,name=msgID,proto3" json:"msgID,omitempty"` SnapshotID []byte `protobuf:"bytes,5,opt,name=snapshotID,proto3" json:"snapshotID,omitempty"` // The UUID of the newly stored snapshot unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SnapshotStoreResult) Reset() { *x = SnapshotStoreResult{} mi := &file_gateway_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SnapshotStoreResult) String() string { return protoimpl.X.MessageStringOf(x) } func (*SnapshotStoreResult) ProtoMessage() {} func (x *SnapshotStoreResult) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SnapshotStoreResult.ProtoReflect.Descriptor instead. func (*SnapshotStoreResult) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{8} } func (x *SnapshotStoreResult) GetSuccess() bool { if x != nil { return x.Success } return false } func (x *SnapshotStoreResult) GetErrorMessage() string { if x != nil { return x.ErrorMessage } return "" } func (x *SnapshotStoreResult) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } func (x *SnapshotStoreResult) GetSnapshotID() []byte { if x != nil { return x.SnapshotID } return nil } // Ask the gateway to load the specified snapshot into the current state. // Results are streamed to the client in the same way query results are. type LoadSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id of the snapshot to load UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // a correlation ID to match up requests and responses. set this to a value unique per connection MsgID []byte `protobuf:"bytes,2,opt,name=msgID,proto3" json:"msgID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LoadSnapshot) Reset() { *x = LoadSnapshot{} mi := &file_gateway_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LoadSnapshot) String() string { return protoimpl.X.MessageStringOf(x) } func (*LoadSnapshot) ProtoMessage() {} func (x *LoadSnapshot) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LoadSnapshot.ProtoReflect.Descriptor instead. func (*LoadSnapshot) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{9} } func (x *LoadSnapshot) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *LoadSnapshot) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } type SnapshotLoadResult struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` ErrorMessage string `protobuf:"bytes,2,opt,name=errorMessage,proto3" json:"errorMessage,omitempty"` // a correlation ID to match up requests and responses. this field returns the contents of the request's msgID MsgID []byte `protobuf:"bytes,4,opt,name=msgID,proto3" json:"msgID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SnapshotLoadResult) Reset() { *x = SnapshotLoadResult{} mi := &file_gateway_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SnapshotLoadResult) String() string { return protoimpl.X.MessageStringOf(x) } func (*SnapshotLoadResult) ProtoMessage() {} func (x *SnapshotLoadResult) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SnapshotLoadResult.ProtoReflect.Descriptor instead. func (*SnapshotLoadResult) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{10} } func (x *SnapshotLoadResult) GetSuccess() bool { if x != nil { return x.Success } return false } func (x *SnapshotLoadResult) GetErrorMessage() string { if x != nil { return x.ErrorMessage } return "" } func (x *SnapshotLoadResult) GetMsgID() []byte { if x != nil { return x.MsgID } return nil } type ChatMessage struct { state protoimpl.MessageState `protogen:"open.v1"` // The message to create // // Types that are valid to be assigned to RequestType: // // *ChatMessage_Text // *ChatMessage_Cancel RequestType isChatMessage_RequestType `protobuf_oneof:"request_type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChatMessage) Reset() { *x = ChatMessage{} mi := &file_gateway_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChatMessage) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChatMessage) ProtoMessage() {} func (x *ChatMessage) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChatMessage.ProtoReflect.Descriptor instead. func (*ChatMessage) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{11} } func (x *ChatMessage) GetRequestType() isChatMessage_RequestType { if x != nil { return x.RequestType } return nil } func (x *ChatMessage) GetText() string { if x != nil { if x, ok := x.RequestType.(*ChatMessage_Text); ok { return x.Text } } return "" } func (x *ChatMessage) GetCancel() bool { if x != nil { if x, ok := x.RequestType.(*ChatMessage_Cancel); ok { return x.Cancel } } return false } type isChatMessage_RequestType interface { isChatMessage_RequestType() } type ChatMessage_Text struct { Text string `protobuf:"bytes,1,opt,name=text,proto3,oneof"` } type ChatMessage_Cancel struct { // Cancel the last message sent to openAI, includes the message and tools that were started Cancel bool `protobuf:"varint,2,opt,name=cancel,proto3,oneof"` } func (*ChatMessage_Text) isChatMessage_RequestType() {} func (*ChatMessage_Cancel) isChatMessage_RequestType() {} type ToolMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // A unique ID that tracks this tool call and can be used to correlate messages Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ToolMetadata) Reset() { *x = ToolMetadata{} mi := &file_gateway_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ToolMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*ToolMetadata) ProtoMessage() {} func (x *ToolMetadata) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ToolMetadata.ProtoReflect.Descriptor instead. func (*ToolMetadata) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{12} } func (x *ToolMetadata) GetId() string { if x != nil { return x.Id } return "" } type QueryToolStart struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` Method QueryMethod `protobuf:"varint,2,opt,name=method,proto3,enum=QueryMethod" json:"method,omitempty"` Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` Scope string `protobuf:"bytes,4,opt,name=scope,proto3" json:"scope,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *QueryToolStart) Reset() { *x = QueryToolStart{} mi := &file_gateway_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *QueryToolStart) String() string { return protoimpl.X.MessageStringOf(x) } func (*QueryToolStart) ProtoMessage() {} func (x *QueryToolStart) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use QueryToolStart.ProtoReflect.Descriptor instead. func (*QueryToolStart) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{13} } func (x *QueryToolStart) GetType() string { if x != nil { return x.Type } return "" } func (x *QueryToolStart) GetMethod() QueryMethod { if x != nil { return x.Method } return QueryMethod_GET } func (x *QueryToolStart) GetQuery() string { if x != nil { return x.Query } return "" } func (x *QueryToolStart) GetScope() string { if x != nil { return x.Scope } return "" } type QueryToolFinish struct { state protoimpl.MessageState `protogen:"open.v1"` NumItems int32 `protobuf:"varint,1,opt,name=numItems,proto3" json:"numItems,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *QueryToolFinish) Reset() { *x = QueryToolFinish{} mi := &file_gateway_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *QueryToolFinish) String() string { return protoimpl.X.MessageStringOf(x) } func (*QueryToolFinish) ProtoMessage() {} func (x *QueryToolFinish) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use QueryToolFinish.ProtoReflect.Descriptor instead. func (*QueryToolFinish) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{14} } func (x *QueryToolFinish) GetNumItems() int32 { if x != nil { return x.NumItems } return 0 } type RelationshipToolStart struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` UniqueAttributeValue string `protobuf:"bytes,2,opt,name=uniqueAttributeValue,proto3" json:"uniqueAttributeValue,omitempty"` Scope string `protobuf:"bytes,3,opt,name=scope,proto3" json:"scope,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RelationshipToolStart) Reset() { *x = RelationshipToolStart{} mi := &file_gateway_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RelationshipToolStart) String() string { return protoimpl.X.MessageStringOf(x) } func (*RelationshipToolStart) ProtoMessage() {} func (x *RelationshipToolStart) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RelationshipToolStart.ProtoReflect.Descriptor instead. func (*RelationshipToolStart) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{15} } func (x *RelationshipToolStart) GetType() string { if x != nil { return x.Type } return "" } func (x *RelationshipToolStart) GetUniqueAttributeValue() string { if x != nil { return x.UniqueAttributeValue } return "" } func (x *RelationshipToolStart) GetScope() string { if x != nil { return x.Scope } return "" } type RelationshipToolFinish struct { state protoimpl.MessageState `protogen:"open.v1"` NumItems int32 `protobuf:"varint,1,opt,name=numItems,proto3" json:"numItems,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RelationshipToolFinish) Reset() { *x = RelationshipToolFinish{} mi := &file_gateway_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RelationshipToolFinish) String() string { return protoimpl.X.MessageStringOf(x) } func (*RelationshipToolFinish) ProtoMessage() {} func (x *RelationshipToolFinish) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RelationshipToolFinish.ProtoReflect.Descriptor instead. func (*RelationshipToolFinish) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{16} } func (x *RelationshipToolFinish) GetNumItems() int32 { if x != nil { return x.NumItems } return 0 } type ChangesByReferenceToolStart struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` UniqueAttributeValue string `protobuf:"bytes,2,opt,name=uniqueAttributeValue,proto3" json:"uniqueAttributeValue,omitempty"` Scope string `protobuf:"bytes,3,opt,name=scope,proto3" json:"scope,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangesByReferenceToolStart) Reset() { *x = ChangesByReferenceToolStart{} mi := &file_gateway_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangesByReferenceToolStart) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangesByReferenceToolStart) ProtoMessage() {} func (x *ChangesByReferenceToolStart) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangesByReferenceToolStart.ProtoReflect.Descriptor instead. func (*ChangesByReferenceToolStart) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{17} } func (x *ChangesByReferenceToolStart) GetType() string { if x != nil { return x.Type } return "" } func (x *ChangesByReferenceToolStart) GetUniqueAttributeValue() string { if x != nil { return x.UniqueAttributeValue } return "" } func (x *ChangesByReferenceToolStart) GetScope() string { if x != nil { return x.Scope } return "" } type ChangeByReferenceSummary struct { state protoimpl.MessageState `protogen:"open.v1"` Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` // from ChangeProperties UUID []byte `protobuf:"bytes,2,opt,name=UUID,proto3" json:"UUID,omitempty"` // from ChangeMetadata CreatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=createdAt,proto3" json:"createdAt,omitempty"` // From ChangeMetadata Owner string `protobuf:"bytes,4,opt,name=owner,proto3" json:"owner,omitempty"` // From ChangeProperties NumAffectedItems int32 `protobuf:"varint,5,opt,name=numAffectedItems,proto3" json:"numAffectedItems,omitempty"` // From ChangeMetadata ChangeStatus ChangeStatus `protobuf:"varint,6,opt,name=changeStatus,proto3,enum=changes.ChangeStatus" json:"changeStatus,omitempty"` // From ChangeMetadata unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeByReferenceSummary) Reset() { *x = ChangeByReferenceSummary{} mi := &file_gateway_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeByReferenceSummary) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeByReferenceSummary) ProtoMessage() {} func (x *ChangeByReferenceSummary) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeByReferenceSummary.ProtoReflect.Descriptor instead. func (*ChangeByReferenceSummary) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{18} } func (x *ChangeByReferenceSummary) GetTitle() string { if x != nil { return x.Title } return "" } func (x *ChangeByReferenceSummary) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *ChangeByReferenceSummary) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt } return nil } func (x *ChangeByReferenceSummary) GetOwner() string { if x != nil { return x.Owner } return "" } func (x *ChangeByReferenceSummary) GetNumAffectedItems() int32 { if x != nil { return x.NumAffectedItems } return 0 } func (x *ChangeByReferenceSummary) GetChangeStatus() ChangeStatus { if x != nil { return x.ChangeStatus } return ChangeStatus_CHANGE_STATUS_UNSPECIFIED } type ChangesByReferenceToolFinish struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeSummaries []*ChangeByReferenceSummary `protobuf:"bytes,1,rep,name=changeSummaries,proto3" json:"changeSummaries,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangesByReferenceToolFinish) Reset() { *x = ChangesByReferenceToolFinish{} mi := &file_gateway_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangesByReferenceToolFinish) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangesByReferenceToolFinish) ProtoMessage() {} func (x *ChangesByReferenceToolFinish) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangesByReferenceToolFinish.ProtoReflect.Descriptor instead. func (*ChangesByReferenceToolFinish) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{19} } func (x *ChangesByReferenceToolFinish) GetChangeSummaries() []*ChangeByReferenceSummary { if x != nil { return x.ChangeSummaries } return nil } type ToolStart struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *ToolMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` // Types that are valid to be assigned to ToolType: // // *ToolStart_Query // *ToolStart_Relationship // *ToolStart_ChangesByReference ToolType isToolStart_ToolType `protobuf_oneof:"tool_type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ToolStart) Reset() { *x = ToolStart{} mi := &file_gateway_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ToolStart) String() string { return protoimpl.X.MessageStringOf(x) } func (*ToolStart) ProtoMessage() {} func (x *ToolStart) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ToolStart.ProtoReflect.Descriptor instead. func (*ToolStart) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{20} } func (x *ToolStart) GetMetadata() *ToolMetadata { if x != nil { return x.Metadata } return nil } func (x *ToolStart) GetToolType() isToolStart_ToolType { if x != nil { return x.ToolType } return nil } func (x *ToolStart) GetQuery() *QueryToolStart { if x != nil { if x, ok := x.ToolType.(*ToolStart_Query); ok { return x.Query } } return nil } func (x *ToolStart) GetRelationship() *RelationshipToolStart { if x != nil { if x, ok := x.ToolType.(*ToolStart_Relationship); ok { return x.Relationship } } return nil } func (x *ToolStart) GetChangesByReference() *ChangesByReferenceToolStart { if x != nil { if x, ok := x.ToolType.(*ToolStart_ChangesByReference); ok { return x.ChangesByReference } } return nil } type isToolStart_ToolType interface { isToolStart_ToolType() } type ToolStart_Query struct { Query *QueryToolStart `protobuf:"bytes,2,opt,name=query,proto3,oneof"` } type ToolStart_Relationship struct { Relationship *RelationshipToolStart `protobuf:"bytes,3,opt,name=relationship,proto3,oneof"` } type ToolStart_ChangesByReference struct { ChangesByReference *ChangesByReferenceToolStart `protobuf:"bytes,4,opt,name=changesByReference,proto3,oneof"` } func (*ToolStart_Query) isToolStart_ToolType() {} func (*ToolStart_Relationship) isToolStart_ToolType() {} func (*ToolStart_ChangesByReference) isToolStart_ToolType() {} type ToolFinish struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *ToolMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Types that are valid to be assigned to ToolType: // // *ToolFinish_Query // *ToolFinish_Relationship // *ToolFinish_ChangesByReference ToolType isToolFinish_ToolType `protobuf_oneof:"tool_type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ToolFinish) Reset() { *x = ToolFinish{} mi := &file_gateway_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ToolFinish) String() string { return protoimpl.X.MessageStringOf(x) } func (*ToolFinish) ProtoMessage() {} func (x *ToolFinish) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ToolFinish.ProtoReflect.Descriptor instead. func (*ToolFinish) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{21} } func (x *ToolFinish) GetMetadata() *ToolMetadata { if x != nil { return x.Metadata } return nil } func (x *ToolFinish) GetError() string { if x != nil { return x.Error } return "" } func (x *ToolFinish) GetToolType() isToolFinish_ToolType { if x != nil { return x.ToolType } return nil } func (x *ToolFinish) GetQuery() *QueryToolFinish { if x != nil { if x, ok := x.ToolType.(*ToolFinish_Query); ok { return x.Query } } return nil } func (x *ToolFinish) GetRelationship() *RelationshipToolFinish { if x != nil { if x, ok := x.ToolType.(*ToolFinish_Relationship); ok { return x.Relationship } } return nil } func (x *ToolFinish) GetChangesByReference() *ChangesByReferenceToolFinish { if x != nil { if x, ok := x.ToolType.(*ToolFinish_ChangesByReference); ok { return x.ChangesByReference } } return nil } type isToolFinish_ToolType interface { isToolFinish_ToolType() } type ToolFinish_Query struct { Query *QueryToolFinish `protobuf:"bytes,3,opt,name=query,proto3,oneof"` } type ToolFinish_Relationship struct { Relationship *RelationshipToolFinish `protobuf:"bytes,4,opt,name=relationship,proto3,oneof"` } type ToolFinish_ChangesByReference struct { ChangesByReference *ChangesByReferenceToolFinish `protobuf:"bytes,5,opt,name=changesByReference,proto3,oneof"` } func (*ToolFinish_Query) isToolFinish_ToolType() {} func (*ToolFinish_Relationship) isToolFinish_ToolType() {} func (*ToolFinish_ChangesByReference) isToolFinish_ToolType() {} type ChatResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChatResponse) Reset() { *x = ChatResponse{} mi := &file_gateway_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChatResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChatResponse) ProtoMessage() {} func (x *ChatResponse) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead. func (*ChatResponse) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{22} } func (x *ChatResponse) GetText() string { if x != nil { return x.Text } return "" } func (x *ChatResponse) GetError() string { if x != nil { return x.Error } return "" } type GatewayRequestStatus_Summary struct { state protoimpl.MessageState `protogen:"open.v1"` Working int32 `protobuf:"varint,1,opt,name=working,proto3" json:"working,omitempty"` Stalled int32 `protobuf:"varint,2,opt,name=stalled,proto3" json:"stalled,omitempty"` Complete int32 `protobuf:"varint,3,opt,name=complete,proto3" json:"complete,omitempty"` Error int32 `protobuf:"varint,4,opt,name=error,proto3" json:"error,omitempty"` Cancelled int32 `protobuf:"varint,5,opt,name=cancelled,proto3" json:"cancelled,omitempty"` Responders int32 `protobuf:"varint,6,opt,name=responders,proto3" json:"responders,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GatewayRequestStatus_Summary) Reset() { *x = GatewayRequestStatus_Summary{} mi := &file_gateway_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GatewayRequestStatus_Summary) String() string { return protoimpl.X.MessageStringOf(x) } func (*GatewayRequestStatus_Summary) ProtoMessage() {} func (x *GatewayRequestStatus_Summary) ProtoReflect() protoreflect.Message { mi := &file_gateway_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GatewayRequestStatus_Summary.ProtoReflect.Descriptor instead. func (*GatewayRequestStatus_Summary) Descriptor() ([]byte, []int) { return file_gateway_proto_rawDescGZIP(), []int{2, 0} } func (x *GatewayRequestStatus_Summary) GetWorking() int32 { if x != nil { return x.Working } return 0 } func (x *GatewayRequestStatus_Summary) GetStalled() int32 { if x != nil { return x.Stalled } return 0 } func (x *GatewayRequestStatus_Summary) GetComplete() int32 { if x != nil { return x.Complete } return 0 } func (x *GatewayRequestStatus_Summary) GetError() int32 { if x != nil { return x.Error } return 0 } func (x *GatewayRequestStatus_Summary) GetCancelled() int32 { if x != nil { return x.Cancelled } return 0 } func (x *GatewayRequestStatus_Summary) GetResponders() int32 { if x != nil { return x.Responders } return 0 } var File_gateway_proto protoreflect.FileDescriptor const file_gateway_proto_rawDesc = "" + "\n" + "\rgateway.proto\x12\agateway\x1a\rchanges.proto\x1a\vitems.proto\x1a\x0fresponses.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xad\x04\n" + "\x0eGatewayRequest\x12\x1e\n" + "\x05query\x18\x01 \x01(\v2\x06.QueryH\x00R\x05query\x120\n" + "\vcancelQuery\x18\x03 \x01(\v2\f.CancelQueryH\x00R\vcancelQuery\x12!\n" + "\x06expand\x18\a \x01(\v2\a.ExpandH\x00R\x06expand\x12>\n" + "\rstoreSnapshot\x18\n" + " \x01(\v2\x16.gateway.StoreSnapshotH\x00R\rstoreSnapshot\x12;\n" + "\floadSnapshot\x18\v \x01(\v2\x15.gateway.LoadSnapshotH\x00R\floadSnapshot\x12>\n" + "\rstoreBookmark\x18\x0e \x01(\v2\x16.gateway.StoreBookmarkH\x00R\rstoreBookmark\x12;\n" + "\floadBookmark\x18\x0f \x01(\v2\x15.gateway.LoadBookmarkH\x00R\floadBookmark\x128\n" + "\vchatMessage\x18\x10 \x01(\v2\x14.gateway.ChatMessageH\x00R\vchatMessage\x12L\n" + "\x11minStatusInterval\x18\x02 \x01(\v2\x19.google.protobuf.DurationH\x01R\x11minStatusInterval\x88\x01\x01B\x0e\n" + "\frequest_typeB\x14\n" + "\x12_minStatusInterval\"\x8a\a\n" + "\x0fGatewayResponse\x12!\n" + "\anewItem\x18\x02 \x01(\v2\x05.ItemH\x00R\anewItem\x12!\n" + "\anewEdge\x18\x03 \x01(\v2\x05.EdgeH\x00R\anewEdge\x127\n" + "\x06status\x18\x04 \x01(\v2\x1d.gateway.GatewayRequestStatusH\x00R\x06status\x12\x16\n" + "\x05error\x18\x05 \x01(\tH\x00R\x05error\x12-\n" + "\n" + "queryError\x18\x06 \x01(\v2\v.QueryErrorH\x00R\n" + "queryError\x122\n" + "\rdeleteItemRef\x18\a \x01(\v2\n" + ".ReferenceH\x00R\rdeleteItemRef\x12'\n" + "\n" + "deleteEdge\x18\b \x01(\v2\x05.EdgeH\x00R\n" + "deleteEdge\x12'\n" + "\n" + "updateItem\x18\t \x01(\v2\x05.ItemH\x00R\n" + "updateItem\x12P\n" + "\x13snapshotStoreResult\x18\v \x01(\v2\x1c.gateway.SnapshotStoreResultH\x00R\x13snapshotStoreResult\x12M\n" + "\x12snapshotLoadResult\x18\f \x01(\v2\x1b.gateway.SnapshotLoadResultH\x00R\x12snapshotLoadResult\x12P\n" + "\x13bookmarkStoreResult\x18\x0f \x01(\v2\x1c.gateway.BookmarkStoreResultH\x00R\x13bookmarkStoreResult\x12M\n" + "\x12bookmarkLoadResult\x18\x10 \x01(\v2\x1b.gateway.BookmarkLoadResultH\x00R\x12bookmarkLoadResult\x120\n" + "\vqueryStatus\x18\x11 \x01(\v2\f.QueryStatusH\x00R\vqueryStatus\x12;\n" + "\fchatResponse\x18\x12 \x01(\v2\x15.gateway.ChatResponseH\x00R\fchatResponse\x122\n" + "\ttoolStart\x18\x13 \x01(\v2\x12.gateway.ToolStartH\x00R\ttoolStart\x125\n" + "\n" + "toolFinish\x18\x14 \x01(\v2\x13.gateway.ToolFinishH\x00R\n" + "toolFinishB\x0f\n" + "\rresponse_type\"\xc5\x02\n" + "\x14GatewayRequestStatus\x12?\n" + "\asummary\x18\x03 \x01(\v2%.gateway.GatewayRequestStatus.SummaryR\asummary\x126\n" + "\x16postProcessingComplete\x18\x04 \x01(\bR\x16postProcessingComplete\x1a\xad\x01\n" + "\aSummary\x12\x18\n" + "\aworking\x18\x01 \x01(\x05R\aworking\x12\x18\n" + "\astalled\x18\x02 \x01(\x05R\astalled\x12\x1a\n" + "\bcomplete\x18\x03 \x01(\x05R\bcomplete\x12\x14\n" + "\x05error\x18\x04 \x01(\x05R\x05error\x12\x1c\n" + "\tcancelled\x18\x05 \x01(\x05R\tcancelled\x12\x1e\n" + "\n" + "responders\x18\x06 \x01(\x05R\n" + "respondersJ\x04\b\x01\x10\x02\"w\n" + "\rStoreBookmark\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x14\n" + "\x05msgID\x18\x03 \x01(\fR\x05msgID\x12\x1a\n" + "\bisSystem\x18\x04 \x01(\bR\bisSystem\"\x8f\x01\n" + "\x13BookmarkStoreResult\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\"\n" + "\ferrorMessage\x18\x02 \x01(\tR\ferrorMessage\x12\x14\n" + "\x05msgID\x18\x04 \x01(\fR\x05msgID\x12\x1e\n" + "\n" + "bookmarkID\x18\x05 \x01(\fR\n" + "bookmarkIDJ\x04\b\x03\x10\x04\"\x92\x01\n" + "\fLoadBookmark\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x14\n" + "\x05msgID\x18\x02 \x01(\fR\x05msgID\x12 \n" + "\vignoreCache\x18\x03 \x01(\bR\vignoreCache\x126\n" + "\bdeadline\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\bdeadline\"\x96\x01\n" + "\x12BookmarkLoadResult\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\"\n" + "\ferrorMessage\x18\x02 \x01(\tR\ferrorMessage\x12,\n" + "\x11startedQueryUUIDs\x18\x03 \x03(\fR\x11startedQueryUUIDs\x12\x14\n" + "\x05msgID\x18\x04 \x01(\fR\x05msgID\"[\n" + "\rStoreSnapshot\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x14\n" + "\x05msgID\x18\x03 \x01(\fR\x05msgID\"\x8f\x01\n" + "\x13SnapshotStoreResult\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\"\n" + "\ferrorMessage\x18\x02 \x01(\tR\ferrorMessage\x12\x14\n" + "\x05msgID\x18\x04 \x01(\fR\x05msgID\x12\x1e\n" + "\n" + "snapshotID\x18\x05 \x01(\fR\n" + "snapshotIDJ\x04\b\x03\x10\x04\"8\n" + "\fLoadSnapshot\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12\x14\n" + "\x05msgID\x18\x02 \x01(\fR\x05msgID\"h\n" + "\x12SnapshotLoadResult\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\"\n" + "\ferrorMessage\x18\x02 \x01(\tR\ferrorMessage\x12\x14\n" + "\x05msgID\x18\x04 \x01(\fR\x05msgID\"M\n" + "\vChatMessage\x12\x14\n" + "\x04text\x18\x01 \x01(\tH\x00R\x04text\x12\x18\n" + "\x06cancel\x18\x02 \x01(\bH\x00R\x06cancelB\x0e\n" + "\frequest_type\"\x1e\n" + "\fToolMetadata\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"v\n" + "\x0eQueryToolStart\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12$\n" + "\x06method\x18\x02 \x01(\x0e2\f.QueryMethodR\x06method\x12\x14\n" + "\x05query\x18\x03 \x01(\tR\x05query\x12\x14\n" + "\x05scope\x18\x04 \x01(\tR\x05scope\"-\n" + "\x0fQueryToolFinish\x12\x1a\n" + "\bnumItems\x18\x01 \x01(\x05R\bnumItems\"u\n" + "\x15RelationshipToolStart\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x122\n" + "\x14uniqueAttributeValue\x18\x02 \x01(\tR\x14uniqueAttributeValue\x12\x14\n" + "\x05scope\x18\x03 \x01(\tR\x05scope\"4\n" + "\x16RelationshipToolFinish\x12\x1a\n" + "\bnumItems\x18\x01 \x01(\x05R\bnumItems\"{\n" + "\x1bChangesByReferenceToolStart\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x122\n" + "\x14uniqueAttributeValue\x18\x02 \x01(\tR\x14uniqueAttributeValue\x12\x14\n" + "\x05scope\x18\x03 \x01(\tR\x05scope\"\xfb\x01\n" + "\x18ChangeByReferenceSummary\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" + "\x04UUID\x18\x02 \x01(\fR\x04UUID\x128\n" + "\tcreatedAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x14\n" + "\x05owner\x18\x04 \x01(\tR\x05owner\x12*\n" + "\x10numAffectedItems\x18\x05 \x01(\x05R\x10numAffectedItems\x129\n" + "\fchangeStatus\x18\x06 \x01(\x0e2\x15.changes.ChangeStatusR\fchangeStatus\"k\n" + "\x1cChangesByReferenceToolFinish\x12K\n" + "\x0fchangeSummaries\x18\x01 \x03(\v2!.gateway.ChangeByReferenceSummaryR\x0fchangeSummaries\"\x9a\x02\n" + "\tToolStart\x121\n" + "\bmetadata\x18\x01 \x01(\v2\x15.gateway.ToolMetadataR\bmetadata\x12/\n" + "\x05query\x18\x02 \x01(\v2\x17.gateway.QueryToolStartH\x00R\x05query\x12D\n" + "\frelationship\x18\x03 \x01(\v2\x1e.gateway.RelationshipToolStartH\x00R\frelationship\x12V\n" + "\x12changesByReference\x18\x04 \x01(\v2$.gateway.ChangesByReferenceToolStartH\x00R\x12changesByReferenceB\v\n" + "\ttool_type\"\xb4\x02\n" + "\n" + "ToolFinish\x121\n" + "\bmetadata\x18\x01 \x01(\v2\x15.gateway.ToolMetadataR\bmetadata\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x120\n" + "\x05query\x18\x03 \x01(\v2\x18.gateway.QueryToolFinishH\x00R\x05query\x12E\n" + "\frelationship\x18\x04 \x01(\v2\x1f.gateway.RelationshipToolFinishH\x00R\frelationship\x12W\n" + "\x12changesByReference\x18\x05 \x01(\v2%.gateway.ChangesByReferenceToolFinishH\x00R\x12changesByReferenceB\v\n" + "\ttool_type\"8\n" + "\fChatResponse\x12\x12\n" + "\x04text\x18\x01 \x01(\tR\x04text\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05errorB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_gateway_proto_rawDescOnce sync.Once file_gateway_proto_rawDescData []byte ) func file_gateway_proto_rawDescGZIP() []byte { file_gateway_proto_rawDescOnce.Do(func() { file_gateway_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_gateway_proto_rawDesc), len(file_gateway_proto_rawDesc))) }) return file_gateway_proto_rawDescData } var file_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_gateway_proto_goTypes = []any{ (*GatewayRequest)(nil), // 0: gateway.GatewayRequest (*GatewayResponse)(nil), // 1: gateway.GatewayResponse (*GatewayRequestStatus)(nil), // 2: gateway.GatewayRequestStatus (*StoreBookmark)(nil), // 3: gateway.StoreBookmark (*BookmarkStoreResult)(nil), // 4: gateway.BookmarkStoreResult (*LoadBookmark)(nil), // 5: gateway.LoadBookmark (*BookmarkLoadResult)(nil), // 6: gateway.BookmarkLoadResult (*StoreSnapshot)(nil), // 7: gateway.StoreSnapshot (*SnapshotStoreResult)(nil), // 8: gateway.SnapshotStoreResult (*LoadSnapshot)(nil), // 9: gateway.LoadSnapshot (*SnapshotLoadResult)(nil), // 10: gateway.SnapshotLoadResult (*ChatMessage)(nil), // 11: gateway.ChatMessage (*ToolMetadata)(nil), // 12: gateway.ToolMetadata (*QueryToolStart)(nil), // 13: gateway.QueryToolStart (*QueryToolFinish)(nil), // 14: gateway.QueryToolFinish (*RelationshipToolStart)(nil), // 15: gateway.RelationshipToolStart (*RelationshipToolFinish)(nil), // 16: gateway.RelationshipToolFinish (*ChangesByReferenceToolStart)(nil), // 17: gateway.ChangesByReferenceToolStart (*ChangeByReferenceSummary)(nil), // 18: gateway.ChangeByReferenceSummary (*ChangesByReferenceToolFinish)(nil), // 19: gateway.ChangesByReferenceToolFinish (*ToolStart)(nil), // 20: gateway.ToolStart (*ToolFinish)(nil), // 21: gateway.ToolFinish (*ChatResponse)(nil), // 22: gateway.ChatResponse (*GatewayRequestStatus_Summary)(nil), // 23: gateway.GatewayRequestStatus.Summary (*Query)(nil), // 24: Query (*CancelQuery)(nil), // 25: CancelQuery (*Expand)(nil), // 26: Expand (*durationpb.Duration)(nil), // 27: google.protobuf.Duration (*Item)(nil), // 28: Item (*Edge)(nil), // 29: Edge (*QueryError)(nil), // 30: QueryError (*Reference)(nil), // 31: Reference (*QueryStatus)(nil), // 32: QueryStatus (*timestamppb.Timestamp)(nil), // 33: google.protobuf.Timestamp (QueryMethod)(0), // 34: QueryMethod (ChangeStatus)(0), // 35: changes.ChangeStatus } var file_gateway_proto_depIdxs = []int32{ 24, // 0: gateway.GatewayRequest.query:type_name -> Query 25, // 1: gateway.GatewayRequest.cancelQuery:type_name -> CancelQuery 26, // 2: gateway.GatewayRequest.expand:type_name -> Expand 7, // 3: gateway.GatewayRequest.storeSnapshot:type_name -> gateway.StoreSnapshot 9, // 4: gateway.GatewayRequest.loadSnapshot:type_name -> gateway.LoadSnapshot 3, // 5: gateway.GatewayRequest.storeBookmark:type_name -> gateway.StoreBookmark 5, // 6: gateway.GatewayRequest.loadBookmark:type_name -> gateway.LoadBookmark 11, // 7: gateway.GatewayRequest.chatMessage:type_name -> gateway.ChatMessage 27, // 8: gateway.GatewayRequest.minStatusInterval:type_name -> google.protobuf.Duration 28, // 9: gateway.GatewayResponse.newItem:type_name -> Item 29, // 10: gateway.GatewayResponse.newEdge:type_name -> Edge 2, // 11: gateway.GatewayResponse.status:type_name -> gateway.GatewayRequestStatus 30, // 12: gateway.GatewayResponse.queryError:type_name -> QueryError 31, // 13: gateway.GatewayResponse.deleteItemRef:type_name -> Reference 29, // 14: gateway.GatewayResponse.deleteEdge:type_name -> Edge 28, // 15: gateway.GatewayResponse.updateItem:type_name -> Item 8, // 16: gateway.GatewayResponse.snapshotStoreResult:type_name -> gateway.SnapshotStoreResult 10, // 17: gateway.GatewayResponse.snapshotLoadResult:type_name -> gateway.SnapshotLoadResult 4, // 18: gateway.GatewayResponse.bookmarkStoreResult:type_name -> gateway.BookmarkStoreResult 6, // 19: gateway.GatewayResponse.bookmarkLoadResult:type_name -> gateway.BookmarkLoadResult 32, // 20: gateway.GatewayResponse.queryStatus:type_name -> QueryStatus 22, // 21: gateway.GatewayResponse.chatResponse:type_name -> gateway.ChatResponse 20, // 22: gateway.GatewayResponse.toolStart:type_name -> gateway.ToolStart 21, // 23: gateway.GatewayResponse.toolFinish:type_name -> gateway.ToolFinish 23, // 24: gateway.GatewayRequestStatus.summary:type_name -> gateway.GatewayRequestStatus.Summary 33, // 25: gateway.LoadBookmark.deadline:type_name -> google.protobuf.Timestamp 34, // 26: gateway.QueryToolStart.method:type_name -> QueryMethod 33, // 27: gateway.ChangeByReferenceSummary.createdAt:type_name -> google.protobuf.Timestamp 35, // 28: gateway.ChangeByReferenceSummary.changeStatus:type_name -> changes.ChangeStatus 18, // 29: gateway.ChangesByReferenceToolFinish.changeSummaries:type_name -> gateway.ChangeByReferenceSummary 12, // 30: gateway.ToolStart.metadata:type_name -> gateway.ToolMetadata 13, // 31: gateway.ToolStart.query:type_name -> gateway.QueryToolStart 15, // 32: gateway.ToolStart.relationship:type_name -> gateway.RelationshipToolStart 17, // 33: gateway.ToolStart.changesByReference:type_name -> gateway.ChangesByReferenceToolStart 12, // 34: gateway.ToolFinish.metadata:type_name -> gateway.ToolMetadata 14, // 35: gateway.ToolFinish.query:type_name -> gateway.QueryToolFinish 16, // 36: gateway.ToolFinish.relationship:type_name -> gateway.RelationshipToolFinish 19, // 37: gateway.ToolFinish.changesByReference:type_name -> gateway.ChangesByReferenceToolFinish 38, // [38:38] is the sub-list for method output_type 38, // [38:38] is the sub-list for method input_type 38, // [38:38] is the sub-list for extension type_name 38, // [38:38] is the sub-list for extension extendee 0, // [0:38] is the sub-list for field type_name } func init() { file_gateway_proto_init() } func file_gateway_proto_init() { if File_gateway_proto != nil { return } file_changes_proto_init() file_items_proto_init() file_responses_proto_init() file_gateway_proto_msgTypes[0].OneofWrappers = []any{ (*GatewayRequest_Query)(nil), (*GatewayRequest_CancelQuery)(nil), (*GatewayRequest_Expand)(nil), (*GatewayRequest_StoreSnapshot)(nil), (*GatewayRequest_LoadSnapshot)(nil), (*GatewayRequest_StoreBookmark)(nil), (*GatewayRequest_LoadBookmark)(nil), (*GatewayRequest_ChatMessage)(nil), } file_gateway_proto_msgTypes[1].OneofWrappers = []any{ (*GatewayResponse_NewItem)(nil), (*GatewayResponse_NewEdge)(nil), (*GatewayResponse_Status)(nil), (*GatewayResponse_Error)(nil), (*GatewayResponse_QueryError)(nil), (*GatewayResponse_DeleteItemRef)(nil), (*GatewayResponse_DeleteEdge)(nil), (*GatewayResponse_UpdateItem)(nil), (*GatewayResponse_SnapshotStoreResult)(nil), (*GatewayResponse_SnapshotLoadResult)(nil), (*GatewayResponse_BookmarkStoreResult)(nil), (*GatewayResponse_BookmarkLoadResult)(nil), (*GatewayResponse_QueryStatus)(nil), (*GatewayResponse_ChatResponse)(nil), (*GatewayResponse_ToolStart)(nil), (*GatewayResponse_ToolFinish)(nil), } file_gateway_proto_msgTypes[11].OneofWrappers = []any{ (*ChatMessage_Text)(nil), (*ChatMessage_Cancel)(nil), } file_gateway_proto_msgTypes[20].OneofWrappers = []any{ (*ToolStart_Query)(nil), (*ToolStart_Relationship)(nil), (*ToolStart_ChangesByReference)(nil), } file_gateway_proto_msgTypes[21].OneofWrappers = []any{ (*ToolFinish_Query)(nil), (*ToolFinish_Relationship)(nil), (*ToolFinish_ChangesByReference)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_gateway_proto_rawDesc), len(file_gateway_proto_rawDesc)), NumEnums: 0, NumMessages: 24, NumExtensions: 0, NumServices: 0, }, GoTypes: file_gateway_proto_goTypes, DependencyIndexes: file_gateway_proto_depIdxs, MessageInfos: file_gateway_proto_msgTypes, }.Build() File_gateway_proto = out.File file_gateway_proto_goTypes = nil file_gateway_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/gateway_test.go ================================================ package sdp import "testing" func TestEqual(t *testing.T) { x := &GatewayRequestStatus{ Summary: &GatewayRequestStatus_Summary{ Working: 1, Stalled: 0, Complete: 1, Error: 1, Cancelled: 0, Responders: 3, }, } t.Run("with nil summary", func(t *testing.T) { y := &GatewayRequestStatus{} if x.Equal(y) { t.Error("expected items to be nonequal") } }) t.Run("with mismatched summary", func(t *testing.T) { y := &GatewayRequestStatus{ Summary: &GatewayRequestStatus_Summary{ Working: 1, Stalled: 0, Complete: 3, Error: 1, Cancelled: 0, Responders: 3, }, } if x.Equal(y) { t.Error("expected items to be nonequal") } }) t.Run("with different postprocessing states", func(t *testing.T) { y := &GatewayRequestStatus{ Summary: &GatewayRequestStatus_Summary{ Working: 1, Stalled: 0, Complete: 1, Error: 1, Cancelled: 0, Responders: 3, }, PostProcessingComplete: true, } if x.Equal(y) { t.Error("expected items to be different") } }) t.Run("with same everything", func(t *testing.T) { y := &GatewayRequestStatus{ Summary: &GatewayRequestStatus_Summary{ Working: 1, Stalled: 0, Complete: 1, Error: 1, Cancelled: 0, Responders: 3, }, } if !x.Equal(y) { t.Error("expected items to be equal") } }) } func TestDone(t *testing.T) { t.Run("with a request that should be done", func(t *testing.T) { r := &GatewayRequestStatus{ Summary: &GatewayRequestStatus_Summary{ Working: 0, Stalled: 1, Complete: 1, Error: 1, Cancelled: 0, Responders: 3, }, PostProcessingComplete: true, } if !r.Done() { t.Error("expected request .Done() to be true") } }) t.Run("with a request that shouldn't be done", func(t *testing.T) { r := &GatewayRequestStatus{ Summary: &GatewayRequestStatus_Summary{ Working: 1, Stalled: 0, Complete: 1, Error: 1, Cancelled: 0, Responders: 3, }, PostProcessingComplete: false, } if r.Done() { t.Error("expected request .Done() to be false") } r.PostProcessingComplete = true if r.Done() { t.Error("expected request .Done() to be false") } }) } ================================================ FILE: go/sdp-go/genhandler.go ================================================ //go:build ignore package main import ( "fmt" "html/template" "os" "strings" ) type Args struct { Type string } func main() { fmt.Printf("Running %s go on %s\n", os.Args[0], os.Getenv("GOFILE")) cwd, err := os.Getwd() if err != nil { panic(err) } fmt.Printf(" cwd = %s\n", cwd) fmt.Printf(" os.Args = %#v\n", os.Args) for _, ev := range []string{"GOARCH", "GOOS", "GOFILE", "GOLINE", "GOPACKAGE", "DOLLAR"} { fmt.Println(" ", ev, "=", os.Getenv(ev)) } if len(os.Args) < 2 { panic("Missing argument, aborting") } v := Args{Type: os.Args[1]} t := template.New("simple") t, err = t.Parse(`// Code generated by "genhandler {{.Type}}"; DO NOT EDIT package sdp import ( "context" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/tracing" ) func New{{.Type}}Handler(spanName string, h func(ctx context.Context, i *{{.Type}}), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i {{.Type}} err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, &i) }, tracing.Tracer(), ) } func NewRaw{{.Type}}Handler(spanName string, h func(ctx context.Context, m *nats.Msg, i *{{.Type}}), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i {{.Type}} err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } func NewAsyncRaw{{.Type}}Handler(spanName string, h func(ctx context.Context, m *nats.Msg, i *{{.Type}}), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewAsyncOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i {{.Type}} err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } `) if err != nil { panic(err) } f, err := os.Create(fmt.Sprintf("handler_%v.go", strings.ToLower(v.Type))) if err != nil { panic(err) } defer f.Close() fmt.Printf("Generating handler for %v\n", v) err = t.Execute(f, v) if err != nil { panic(err) } } ================================================ FILE: go/sdp-go/graph/main.go ================================================ // This was written as part of an experiment That required the use of the // pagerank algorithm on Overmind data. This satisfies the interfaces inside the // gonum package, which means that we can use any of the code in // [gonum.org/v1/gonum/graph](https://pkg.go.dev/gonum.org/v1/gonum/graph@v0.15.0) // to analyse our data. package graph import ( "github.com/overmindtech/cli/go/sdp-go" "gonum.org/v1/gonum/graph" "gonum.org/v1/gonum/graph/set/uid" ) /////////// // Nodes // /////////// var _ graph.Node = &Node{} // A node is always an item type Node struct { Item *sdp.Item Weight float64 Id int64 } // A graph-unique integer ID func (n *Node) ID() int64 { return n.Id } var _ graph.Nodes = &Nodes{} type Nodes struct { // The nodes in the iterator nodes []*Node // The current position in the iterator i int } // Adds a new node to the list func (n *Nodes) Append(node *Node) { n.nodes = append(n.nodes, node) } // Next advances the iterator and returns whether the next call to the item // method will return a non-nil item. // // Next should be called prior to any call to the iterator's item retrieval // method after the iterator has been obtained or reset. // // The order of iteration is implementation dependent. func (n *Nodes) Next() bool { n.i++ return n.i-1 < len(n.nodes) } // Len returns the number of items remaining in the iterator. // // If the number of items in the iterator is unknown, too large to materialize // or too costly to calculate then Len may return a negative value. In this case // the consuming function must be able to operate on the items of the iterator // directly without materializing the items into a slice. The magnitude of a // negative length has implementation-dependent semantics. func (n *Nodes) Len() int { return len(n.nodes) - n.i } // Reset returns the iterator to its start position. func (n *Nodes) Reset() { n.i = 0 } // Node returns the current Node from the iterator. func (n *Nodes) Node() graph.Node { // The Next() function gets called *before* the first item is returned, so // we need to return the item at position i (e.g. 1 is 1st position) rather // than the actual index i. This allows us to start i at zero which makes a // lot more sense getIndex := n.i - 1 if getIndex >= len(n.nodes) || getIndex < 0 { return nil } return n.nodes[getIndex] } /////////// // Edges // /////////// var _ graph.WeightedEdge = &Edge{} type Edge struct { from *Node to *Node weight float64 } // Creates a new edge. The weight of an edge is the sum of the weights of the // two nodes func NewEdge(from, to *Node) *Edge { return &Edge{ from: from, to: to, weight: from.Weight + to.Weight, } } // From returns the from node of the edge. func (e *Edge) From() graph.Node { return e.from } // To returns the to node of the edge. func (e *Edge) To() graph.Node { return e.to } // ReversedEdge returns the edge reversal of the receiver if a reversal is valid // for the data type. When a reversal is valid an edge of the same type as the // receiver with nodes of the receiver swapped should be returned, otherwise the // receiver should be returned unaltered. func (e *Edge) ReversedEdge() graph.Edge { return nil } func (e *Edge) Weight() float64 { return e.weight } /////////// // Graph // /////////// // Assert that SDPGraph satisfies the graph.WeightedDirected interface var _ graph.WeightedDirected = &SDPGraph{} type SDPGraph struct { uidSet *uid.Set nodesByID map[int64]*Node nodesByGUN map[string]*Node // A map of items that have not been seen yet. The key is the GUN of the // "To" end of the edge, and the value is a slice of nodes that are the // "From" edges unseenEdges map[string][]*Node edges []*Edge undirected bool } // NewSDPGraph creates a new SDPGraph. If undirected is true, the graph will be // treated as undirected, meaning that all edges will be bidirectional func NewSDPGraph(undirected bool) *SDPGraph { return &SDPGraph{ uidSet: uid.NewSet(), nodesByID: make(map[int64]*Node), nodesByGUN: make(map[string]*Node), unseenEdges: make(map[string][]*Node), edges: make([]*Edge, 0), undirected: undirected, } } // AddItem adds an item to the graph including processing of its edges, returns // the ID the node was assigned. func (g *SDPGraph) AddItem(item *sdp.Item, weight float64) int64 { id := g.uidSet.NewID() g.uidSet.Use(id) // Add the node to the storage node := Node{ Item: item, Weight: weight, Id: id, } g.nodesByID[id] = &node g.nodesByGUN[item.GloballyUniqueName()] = &node // TODO(LIQs): https://github.com/overmindtech/workspace/issues/1228 // Find all edges and add them for _, linkedItem := range item.GetLinkedItems() { // Check if the linked item node exists linkedItemNode, exists := g.nodesByGUN[linkedItem.GetItem().GloballyUniqueName()] if exists { // Add the edge g.edges = append(g.edges, NewEdge(&node, linkedItemNode)) if g.undirected { // Also add the reverse edge g.edges = append(g.edges, NewEdge(linkedItemNode, &node)) } } else { // If the target for the edge doesn't exist, add this to the list to // be created later if _, exists := g.unseenEdges[linkedItem.GetItem().GloballyUniqueName()]; !exists { g.unseenEdges[linkedItem.GetItem().GloballyUniqueName()] = []*Node{&node} } else { g.unseenEdges[linkedItem.GetItem().GloballyUniqueName()] = append(g.unseenEdges[linkedItem.GetItem().GloballyUniqueName()], &node) } } } // If there are any unseen edges that are now seen, add them if unseenEdges, exists := g.unseenEdges[item.GloballyUniqueName()]; exists { for _, unseenEdge := range unseenEdges { // Add the edge g.edges = append(g.edges, NewEdge(unseenEdge, &node)) if g.undirected { // Also add the reverse edge g.edges = append(g.edges, NewEdge(&node, unseenEdge)) } } } return id } // HasEdgeFromTo returns whether an edge exists in the graph from u to v with // the IDs uid and vid. func (g *SDPGraph) HasEdgeFromTo(uid, vid int64) bool { for _, edge := range g.edges { if edge.from.Id == uid && edge.to.Id == vid { return true } } return false } // To returns all nodes that can reach directly to the node with the given ID. // // To must not return nil. func (g *SDPGraph) To(id int64) graph.Nodes { nodes := Nodes{} for _, edge := range g.edges { if edge.to.Id == id { nodes.Append(edge.to) } } return &nodes } // WeightedEdge returns the weighted edge from u to v with IDs uid and vid if // such an edge exists and nil otherwise. The node v must be directly reachable // from u as defined by the From method. func (g *SDPGraph) WeightedEdge(uid, vid int64) graph.WeightedEdge { for _, edge := range g.edges { if edge.from.Id == uid && edge.to.Id == vid { return edge } } return nil } // Weight returns the weight for the edge between x and y with IDs xid and yid // if Edge(xid, yid) returns a non-nil Edge. If x and y are the same node or // there is no joining edge between the two nodes the weight value returned is // implementation dependent. Weight returns true if an edge exists between x and // y or if x and y have the same ID, false otherwise. func (g *SDPGraph) Weight(xid, yid int64) (w float64, ok bool) { edge := g.WeightedEdge(xid, yid) if edge == nil { return 0, false } return edge.Weight(), true } // Node returns the node with the given ID if it exists in the graph, and nil // otherwise. func (g *SDPGraph) Node(id int64) graph.Node { node, exists := g.nodesByID[id] if !exists { return nil } return node } // Gets a node from the graph by it's globally unique name func (g *SDPGraph) NodeByGloballyUniqueName(globallyUniqueName string) *Node { node, exists := g.nodesByGUN[globallyUniqueName] if !exists { return nil } return node } // Nodes returns all the nodes in the graph. // // Nodes must not return nil. func (g *SDPGraph) Nodes() graph.Nodes { nodes := Nodes{} for _, node := range g.nodesByID { nodes.Append(node) } return &nodes } // From returns all nodes that can be reached directly from the node with the // given ID. // // From must not return nil. func (g *SDPGraph) From(id int64) graph.Nodes { nodes := Nodes{} for _, edge := range g.edges { if edge.From().ID() == id { nodes.Append(edge.to) } } return &nodes } // HasEdgeBetween returns whether an edge exists between nodes with IDs xid and // yid without considering direction. func (g *SDPGraph) HasEdgeBetween(xid, yid int64) bool { var fromID int64 var toID int64 for _, edge := range g.edges { fromID = edge.From().ID() toID = edge.To().ID() if (fromID == xid && toID == yid) || (fromID == yid && toID == xid) { return true } } return false } // Edge returns the edge from u to v, with IDs uid and vid, if such an edge // exists and nil otherwise. The node v must be directly reachable from u as // defined by the From method. func (g *SDPGraph) Edge(uid, vid int64) graph.Edge { for _, edge := range g.edges { if (edge.From().ID() == uid) && (edge.To().ID() == vid) { return edge } } return nil } ================================================ FILE: go/sdp-go/graph/main_test.go ================================================ package graph import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "gonum.org/v1/gonum/graph/network" "google.golang.org/protobuf/types/known/structpb" ) func makeTestItem(name string) *sdp.Item { return &sdp.Item{ Type: "test", UniqueAttribute: "name", Scope: "test", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": { Kind: &structpb.Value_StringValue{ StringValue: name, }, }, }, }, }, } } func TestNode(t *testing.T) { node := Node{ Item: makeTestItem("test"), Weight: 1.5, Id: 1, } if node.ID() != 1 { t.Errorf("expected ID to be 1, got %v", node.ID()) } } func TestNodes(t *testing.T) { nodes := Nodes{} nodes.Append(&Node{ Item: makeTestItem("a"), Weight: 1.5, Id: 1, }) nodes.Append(&Node{ Item: makeTestItem("b"), Weight: 1.5, Id: 2, }) if nodes.Len() != 2 { t.Errorf("expected length to be 2, got %v", nodes.Len()) } // Call node before next should return nil if nodes.Node() != nil { t.Errorf("expected Node to be nil") } // Call next if nodes.Next() != true { t.Errorf("expected Next to be true") } // A if nodes.Node().ID() != 1 { t.Errorf("expected ID to be 1, got %v", nodes.Node().ID()) } if nodes.Len() != 1 { t.Errorf("expected length to be 1, got %v", nodes.Len()) } if nodes.Next() != true { t.Errorf("expected Next to be true") } // B if nodes.Node().ID() != 2 { t.Errorf("expected ID to be 2, got %v", nodes.Node().ID()) } if nodes.Len() != 0 { t.Errorf("expected length to be 0, got %v", nodes.Len()) } if nodes.Next() != false { t.Errorf("expected Next to be false") } if nodes.Node() != nil { t.Errorf("expected Node to be nil") } nodes.Reset() if nodes.Len() != 2 { t.Errorf("expected length to be 2, got %v", nodes.Len()) } } func TestGraph(t *testing.T) { // A list of items that form the following graph: // // ┌────┐ // ┌──┤ A ├──┐ // │ └────┘ │ // │ │ // ┌──▼───┐ ┌──▼─┐ // │ B ├──►│ C │ // └──┬───┘ └────┘ // │ // │ // ┌──▼───┐ // │ D │ // └──────┘ // a := makeTestItem("a") b := makeTestItem("b") c := makeTestItem("c") d := makeTestItem("d") // TODO(LIQs): https://github.com/overmindtech/workspace/issues/1228 a.LinkedItems = []*sdp.LinkedItem{ { Item: b.Reference(), }, { Item: c.Reference(), }, } b.LinkedItems = []*sdp.LinkedItem{ { Item: c.Reference(), }, { Item: d.Reference(), }, } graph := NewSDPGraph(false) aID := graph.AddItem(a, 1) bID := graph.AddItem(b, 1) cID := graph.AddItem(c, 1) dID := graph.AddItem(d, 1) t.Run("To", func(t *testing.T) { nodes := graph.To(cID) if nodes.Len() != 2 { t.Errorf("expected length to be 2, got %v", nodes.Len()) } }) t.Run("WeightedEdge", func(t *testing.T) { t.Run("with a real edge", func(t *testing.T) { e := graph.WeightedEdge(aID, cID) if e == nil { t.Fatal("expected edge to be non-nil") } if e.Weight() != 2 { t.Errorf("expected weight to be 2, got %v", e.Weight()) } }) t.Run("with a non-existent edge", func(t *testing.T) { e := graph.WeightedEdge(aID, dID) if e != nil { t.Errorf("expected edge to be nil") } }) }) t.Run("Weight", func(t *testing.T) { t.Run("with a real edge", func(t *testing.T) { w, ok := graph.Weight(aID, cID) if !ok { t.Fatal("expected edge to be non-nil") } if w != 2 { t.Errorf("expected weight to be 2, got %v", w) } }) t.Run("with a non-existent edge", func(t *testing.T) { w, ok := graph.Weight(aID, dID) if ok { t.Errorf("expected edge to be nil") } if w != 0 { t.Errorf("expected weight to be 0, got %v", w) } }) }) t.Run("Node", func(t *testing.T) { t.Run("with a node that exists", func(t *testing.T) { n := graph.Node(aID) if n == nil { t.Fatal("expected node to be non-nil") } if n.ID() != aID { t.Errorf("expected ID to be %v, got %v", aID, n.ID()) } }) t.Run("with a node that doesn't exist", func(t *testing.T) { n := graph.Node(999) if n != nil { t.Errorf("expected node to be nil") } }) }) t.Run("Nodes", func(t *testing.T) { nodes := graph.Nodes() if nodes.Len() != 4 { t.Errorf("expected length to be 4, got %v", nodes.Len()) } }) t.Run("From", func(t *testing.T) { nodes := graph.From(bID) if nodes.Len() != 2 { t.Errorf("expected length to be 2, got %v", nodes.Len()) } }) t.Run("HasEdgeBetween", func(t *testing.T) { t.Run("with a real edge", func(t *testing.T) { ok := graph.HasEdgeBetween(aID, cID) if !ok { t.Fatal("expected edge to be non-nil") } }) t.Run("with a non-existent edge", func(t *testing.T) { ok := graph.HasEdgeBetween(aID, dID) if ok { t.Errorf("expected edge to be nil") } }) }) t.Run("Edge", func(t *testing.T) { e := graph.Edge(aID, cID) if e == nil { t.Fatal("expected edge to be non-nil") } }) t.Run("PageRank", func(t *testing.T) { ranks := network.PageRank(graph, 0.85, 0.0001) if len(ranks) != 4 { t.Errorf("expected length to be 4, got %v", len(ranks)) } }) t.Run("Undirected", func(t *testing.T) { directed := NewSDPGraph(false) undirected := NewSDPGraph(true) directed.AddItem(a, 1) directed.AddItem(b, 1) directed.AddItem(c, 1) directed.AddItem(d, 1) undirected.AddItem(a, 1) undirected.AddItem(b, 1) undirected.AddItem(c, 1) undirected.AddItem(d, 1) if len(undirected.edges) == 4 { t.Errorf("expected undirected graph to have > 4 edges, got %v", len(undirected.edges)) } }) } ================================================ FILE: go/sdp-go/handler_cancelquery.go ================================================ // Code generated by "genhandler CancelQuery"; DO NOT EDIT package sdp import ( "context" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/tracing" ) func NewCancelQueryHandler(spanName string, h func(ctx context.Context, i *CancelQuery), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i CancelQuery err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, &i) }, tracing.Tracer(), ) } func NewRawCancelQueryHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *CancelQuery), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i CancelQuery err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } func NewAsyncRawCancelQueryHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *CancelQuery), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewAsyncOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i CancelQuery err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } ================================================ FILE: go/sdp-go/handler_gatewayresponse.go ================================================ // Code generated by "genhandler GatewayResponse"; DO NOT EDIT package sdp import ( "context" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/tracing" ) func NewGatewayResponseHandler(spanName string, h func(ctx context.Context, i *GatewayResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i GatewayResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, &i) }, tracing.Tracer(), ) } func NewRawGatewayResponseHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *GatewayResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i GatewayResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } func NewAsyncRawGatewayResponseHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *GatewayResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewAsyncOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i GatewayResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } ================================================ FILE: go/sdp-go/handler_natsgetlogrecordsrequest.go ================================================ // Code generated by "genhandler NATSGetLogRecordsRequest"; DO NOT EDIT package sdp import ( "context" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/tracing" ) func NewNATSGetLogRecordsRequestHandler(spanName string, h func(ctx context.Context, i *NATSGetLogRecordsRequest), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i NATSGetLogRecordsRequest err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, &i) }, tracing.Tracer(), ) } func NewRawNATSGetLogRecordsRequestHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *NATSGetLogRecordsRequest), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i NATSGetLogRecordsRequest err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } func NewAsyncRawNATSGetLogRecordsRequestHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *NATSGetLogRecordsRequest), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewAsyncOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i NATSGetLogRecordsRequest err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } ================================================ FILE: go/sdp-go/handler_natsgetlogrecordsresponse.go ================================================ // Code generated by "genhandler NATSGetLogRecordsResponse"; DO NOT EDIT package sdp import ( "context" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/tracing" ) func NewNATSGetLogRecordsResponseHandler(spanName string, h func(ctx context.Context, i *NATSGetLogRecordsResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i NATSGetLogRecordsResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, &i) }, tracing.Tracer(), ) } func NewRawNATSGetLogRecordsResponseHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *NATSGetLogRecordsResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i NATSGetLogRecordsResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } func NewAsyncRawNATSGetLogRecordsResponseHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *NATSGetLogRecordsResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewAsyncOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i NATSGetLogRecordsResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } ================================================ FILE: go/sdp-go/handler_query.go ================================================ // Code generated by "genhandler Query"; DO NOT EDIT package sdp import ( "context" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/tracing" ) func NewQueryHandler(spanName string, h func(ctx context.Context, i *Query), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i Query err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, &i) }, tracing.Tracer(), ) } func NewRawQueryHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *Query), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i Query err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } func NewAsyncRawQueryHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *Query), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewAsyncOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i Query err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } ================================================ FILE: go/sdp-go/handler_queryresponse.go ================================================ // Code generated by "genhandler QueryResponse"; DO NOT EDIT package sdp import ( "context" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/tracing" ) func NewQueryResponseHandler(spanName string, h func(ctx context.Context, i *QueryResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i QueryResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, &i) }, tracing.Tracer(), ) } func NewRawQueryResponseHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *QueryResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i QueryResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } func NewAsyncRawQueryResponseHandler(spanName string, h func(ctx context.Context, m *nats.Msg, i *QueryResponse), spanOpts ...trace.SpanStartOption) nats.MsgHandler { return NewAsyncOtelExtractingHandler( spanName, func(ctx context.Context, m *nats.Msg) { var i QueryResponse err := Unmarshal(ctx, m.Data, &i) if err != nil { return } h(ctx, m, &i) }, tracing.Tracer(), ) } ================================================ FILE: go/sdp-go/host_trust.go ================================================ package sdp import ( "fmt" "net" "net/url" "slices" "strings" ) var trustedDomainSuffixes = []string{ ".overmind.tech", ".overmind-demo.com", } var trustedExactDomains = []string{ "overmind.tech", "overmind-demo.com", } // IsTrustedHost reports whether the given host (without port) belongs // to a known Overmind domain (*.overmind.tech, *.overmind-demo.com) or is a // local address. Callers should prompt for explicit user confirmation before // sending credentials to untrusted hosts. func IsTrustedHost(hostname string) bool { hostname = strings.ToLower(hostname) if IsLocalHost(hostname) { return true } if slices.Contains(trustedExactDomains, hostname) { return true } for _, suffix := range trustedDomainSuffixes { if strings.HasSuffix(hostname, suffix) { return true } } return false } // IsLocalHost reports whether the given host (without port) resolves // to a loopback address. HTTP (non-TLS) is only acceptable for local hosts. func IsLocalHost(hostname string) bool { if hostname == "localhost" { return true } ip := net.ParseIP(hostname) return ip != nil && ip.IsLoopback() } // ValidateAppURL parses appURLString and enforces that non-local hosts use // HTTPS. It returns the parsed URL or an error. func ValidateAppURL(appURLString string) (*url.URL, error) { appURL, err := url.Parse(appURLString) if err != nil { return nil, fmt.Errorf("invalid app URL %q: %w", appURLString, err) } if !IsLocalHost(appURL.Hostname()) && appURL.Scheme != "https" { return nil, fmt.Errorf( "HTTPS is required for non-local hosts (got %s://%s); "+ "use https:// or target localhost for development", appURL.Scheme, appURL.Host, ) } return appURL, nil } ================================================ FILE: go/sdp-go/host_trust_test.go ================================================ package sdp import "testing" func TestIsTrustedHost(t *testing.T) { tests := []struct { host string trusted bool }{ // Trusted Overmind domains (callers must pass hostname without port) {"app.overmind.tech", true}, {"api.overmind.tech", true}, {"overmind.tech", true}, {"df.overmind-demo.com", true}, {"staging.overmind-demo.com", true}, {"overmind-demo.com", true}, // Case insensitive {"APP.OVERMIND.TECH", true}, {"DF.Overmind-Demo.Com", true}, // Localhost variants {"localhost", true}, {"127.0.0.1", true}, {"127.0.0.2", true}, {"127.255.255.254", true}, {"::1", true}, // Untrusted domains {"evil.com", false}, {"attacker.io", false}, {"overmind.tech.evil.com", false}, {"notovermind.tech", false}, {"fakeovermind-demo.com", false}, {"overmind-demo.com.evil.com", false}, // Sneaky substrings that should not match {"xovermind.tech", false}, {"xovermind-demo.com", false}, } for _, tt := range tests { t.Run(tt.host, func(t *testing.T) { got := IsTrustedHost(tt.host) if got != tt.trusted { t.Errorf("IsTrustedHost(%q) = %v, want %v", tt.host, got, tt.trusted) } }) } } func TestIsLocalHost(t *testing.T) { tests := []struct { host string local bool }{ {"localhost", true}, {"127.0.0.1", true}, {"127.0.0.2", true}, {"127.255.255.254", true}, {"::1", true}, {"app.overmind.tech", false}, {"evil.com", false}, } for _, tt := range tests { t.Run(tt.host, func(t *testing.T) { got := IsLocalHost(tt.host) if got != tt.local { t.Errorf("IsLocalHost(%q) = %v, want %v", tt.host, got, tt.local) } }) } } func TestValidateAppURL(t *testing.T) { tests := []struct { name string url string wantErr bool }{ {"https production", "https://app.overmind.tech", false}, {"https dogfood", "https://df.overmind-demo.com", false}, {"http localhost", "http://localhost:3000", false}, {"http 127.0.0.1", "http://127.0.0.1:8080", false}, {"http ipv6 loopback", "http://[::1]", false}, {"http ipv6 loopback with port", "http://[::1]:8080", false}, // HTTP to non-local is rejected {"http remote", "http://app.overmind.tech", true}, {"http evil", "http://evil.com", true}, // Invalid URL {"invalid", "://bad", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := ValidateAppURL(tt.url) if (err != nil) != tt.wantErr { t.Errorf("ValidateAppURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr) } }) } } ================================================ FILE: go/sdp-go/instance_detect.go ================================================ package sdp import ( "context" "encoding/json" "fmt" "net/http" "net/url" "github.com/overmindtech/cli/go/tracing" ) // Information about a particular instance of Overmind. This is used to // determine where to send requests, how to authenticate etc. type OvermindInstance struct { FrontendUrl *url.URL ApiUrl *url.URL NatsUrl *url.URL Audience string Auth0Domain string CLIClientID string } // GatewayUrl returns the URL for the gateway for this instance. func (oi OvermindInstance) GatewayUrl() string { return fmt.Sprintf("%v/api/gateway", oi.ApiUrl.String()) } func (oi OvermindInstance) String() string { return fmt.Sprintf("Frontend: %v, API: %v, Nats: %v, Audience: %v", oi.FrontendUrl, oi.ApiUrl, oi.NatsUrl, oi.Audience) } type instanceData struct { Api string `json:"api_url"` Nats string `json:"nats_url"` Aud string `json:"aud"` Auth0Domain string `json:"auth0_domain"` CLIClientID string `json:"auth0_cli_client_id"` } // NewOvermindInstance creates a new OvermindInstance from the given app URL // with all URLs filled in, or an error. The app URL should be the URL of the // frontend of the Overmind instance. e.g. https://app.overmind.tech // // HTTPS is enforced for all non-localhost hosts. Callers should use // [IsTrustedHost] before calling this function and prompt for user // confirmation when the host is not a known Overmind domain. func NewOvermindInstance(ctx context.Context, app string) (OvermindInstance, error) { var instance OvermindInstance var err error instance.FrontendUrl, err = ValidateAppURL(app) if err != nil { return instance, err } // Get the instance data instanceDataUrl := fmt.Sprintf("%v/api/public/instance-data", instance.FrontendUrl) req, err := http.NewRequestWithContext(ctx, http.MethodGet, instanceDataUrl, nil) if err != nil { return OvermindInstance{}, fmt.Errorf("could not initialize instance-data fetch: %w", err) } req = req.WithContext(ctx) res, err := tracing.HTTPClient().Do(req) if err != nil { return OvermindInstance{}, fmt.Errorf("could not fetch instance-data: %w", err) } if res.StatusCode != http.StatusOK { return OvermindInstance{}, fmt.Errorf("instance-data fetch returned non-200 status: %v", res.StatusCode) } defer res.Body.Close() data := instanceData{} err = json.NewDecoder(res.Body).Decode(&data) if err != nil { return OvermindInstance{}, fmt.Errorf("could not parse instance-data: %w", err) } instance.ApiUrl, err = url.Parse(data.Api) if err != nil { return OvermindInstance{}, fmt.Errorf("invalid api_url value '%v' in instance-data, error: %w", data.Api, err) } instance.NatsUrl, err = url.Parse(data.Nats) if err != nil { return OvermindInstance{}, fmt.Errorf("invalid nats_url value '%v' in instance-data, error: %w", data.Nats, err) } instance.Audience = data.Aud instance.CLIClientID = data.CLIClientID instance.Auth0Domain = data.Auth0Domain return instance, nil } ================================================ FILE: go/sdp-go/invites.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: invites.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" _ "google.golang.org/protobuf/types/known/structpb" _ "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Invite_InviteStatus int32 const ( Invite_INVITE_STATUS_UNSPECIFIED Invite_InviteStatus = 0 // The user has been invited but has not yet accepted Invite_INVITE_STATUS_INVITED Invite_InviteStatus = 1 // The user has accepted the invitation Invite_INVITE_STATUS_ACCEPTED Invite_InviteStatus = 2 ) // Enum value maps for Invite_InviteStatus. var ( Invite_InviteStatus_name = map[int32]string{ 0: "INVITE_STATUS_UNSPECIFIED", 1: "INVITE_STATUS_INVITED", 2: "INVITE_STATUS_ACCEPTED", } Invite_InviteStatus_value = map[string]int32{ "INVITE_STATUS_UNSPECIFIED": 0, "INVITE_STATUS_INVITED": 1, "INVITE_STATUS_ACCEPTED": 2, } ) func (x Invite_InviteStatus) Enum() *Invite_InviteStatus { p := new(Invite_InviteStatus) *p = x return p } func (x Invite_InviteStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (Invite_InviteStatus) Descriptor() protoreflect.EnumDescriptor { return file_invites_proto_enumTypes[0].Descriptor() } func (Invite_InviteStatus) Type() protoreflect.EnumType { return &file_invites_proto_enumTypes[0] } func (x Invite_InviteStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use Invite_InviteStatus.Descriptor instead. func (Invite_InviteStatus) EnumDescriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{2, 0} } type CreateInviteRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Emails []string `protobuf:"bytes,1,rep,name=emails,proto3" json:"emails,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateInviteRequest) Reset() { *x = CreateInviteRequest{} mi := &file_invites_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateInviteRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateInviteRequest) ProtoMessage() {} func (x *CreateInviteRequest) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateInviteRequest.ProtoReflect.Descriptor instead. func (*CreateInviteRequest) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{0} } func (x *CreateInviteRequest) GetEmails() []string { if x != nil { return x.Emails } return nil } type CreateInviteResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateInviteResponse) Reset() { *x = CreateInviteResponse{} mi := &file_invites_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateInviteResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateInviteResponse) ProtoMessage() {} func (x *CreateInviteResponse) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateInviteResponse.ProtoReflect.Descriptor instead. func (*CreateInviteResponse) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{1} } type Invite struct { state protoimpl.MessageState `protogen:"open.v1"` Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` Status Invite_InviteStatus `protobuf:"varint,2,opt,name=status,proto3,enum=invites.Invite_InviteStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Invite) Reset() { *x = Invite{} mi := &file_invites_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Invite) String() string { return protoimpl.X.MessageStringOf(x) } func (*Invite) ProtoMessage() {} func (x *Invite) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Invite.ProtoReflect.Descriptor instead. func (*Invite) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{2} } func (x *Invite) GetEmail() string { if x != nil { return x.Email } return "" } func (x *Invite) GetStatus() Invite_InviteStatus { if x != nil { return x.Status } return Invite_INVITE_STATUS_UNSPECIFIED } type ListInvitesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListInvitesRequest) Reset() { *x = ListInvitesRequest{} mi := &file_invites_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListInvitesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListInvitesRequest) ProtoMessage() {} func (x *ListInvitesRequest) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListInvitesRequest.ProtoReflect.Descriptor instead. func (*ListInvitesRequest) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{3} } type ListInvitesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Invites []*Invite `protobuf:"bytes,1,rep,name=invites,proto3" json:"invites,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListInvitesResponse) Reset() { *x = ListInvitesResponse{} mi := &file_invites_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListInvitesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListInvitesResponse) ProtoMessage() {} func (x *ListInvitesResponse) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListInvitesResponse.ProtoReflect.Descriptor instead. func (*ListInvitesResponse) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{4} } func (x *ListInvitesResponse) GetInvites() []*Invite { if x != nil { return x.Invites } return nil } type RevokeInviteRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RevokeInviteRequest) Reset() { *x = RevokeInviteRequest{} mi := &file_invites_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RevokeInviteRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*RevokeInviteRequest) ProtoMessage() {} func (x *RevokeInviteRequest) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RevokeInviteRequest.ProtoReflect.Descriptor instead. func (*RevokeInviteRequest) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{5} } func (x *RevokeInviteRequest) GetEmail() string { if x != nil { return x.Email } return "" } type RevokeInviteResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RevokeInviteResponse) Reset() { *x = RevokeInviteResponse{} mi := &file_invites_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RevokeInviteResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*RevokeInviteResponse) ProtoMessage() {} func (x *RevokeInviteResponse) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RevokeInviteResponse.ProtoReflect.Descriptor instead. func (*RevokeInviteResponse) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{6} } type ResendInviteRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ResendInviteRequest) Reset() { *x = ResendInviteRequest{} mi := &file_invites_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ResendInviteRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ResendInviteRequest) ProtoMessage() {} func (x *ResendInviteRequest) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ResendInviteRequest.ProtoReflect.Descriptor instead. func (*ResendInviteRequest) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{7} } func (x *ResendInviteRequest) GetEmail() string { if x != nil { return x.Email } return "" } type ResendInviteResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ResendInviteResponse) Reset() { *x = ResendInviteResponse{} mi := &file_invites_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ResendInviteResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ResendInviteResponse) ProtoMessage() {} func (x *ResendInviteResponse) ProtoReflect() protoreflect.Message { mi := &file_invites_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ResendInviteResponse.ProtoReflect.Descriptor instead. func (*ResendInviteResponse) Descriptor() ([]byte, []int) { return file_invites_proto_rawDescGZIP(), []int{8} } var File_invites_proto protoreflect.FileDescriptor const file_invites_proto_rawDesc = "" + "\n" + "\rinvites.proto\x12\ainvites\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"-\n" + "\x13CreateInviteRequest\x12\x16\n" + "\x06emails\x18\x01 \x03(\tR\x06emails\"\x16\n" + "\x14CreateInviteResponse\"\xba\x01\n" + "\x06Invite\x12\x14\n" + "\x05email\x18\x01 \x01(\tR\x05email\x124\n" + "\x06status\x18\x02 \x01(\x0e2\x1c.invites.Invite.InviteStatusR\x06status\"d\n" + "\fInviteStatus\x12\x1d\n" + "\x19INVITE_STATUS_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15INVITE_STATUS_INVITED\x10\x01\x12\x1a\n" + "\x16INVITE_STATUS_ACCEPTED\x10\x02\"\x14\n" + "\x12ListInvitesRequest\"@\n" + "\x13ListInvitesResponse\x12)\n" + "\ainvites\x18\x01 \x03(\v2\x0f.invites.InviteR\ainvites\"+\n" + "\x13RevokeInviteRequest\x12\x14\n" + "\x05email\x18\x01 \x01(\tR\x05email\"\x16\n" + "\x14RevokeInviteResponse\"+\n" + "\x13ResendInviteRequest\x12\x14\n" + "\x05email\x18\x01 \x01(\tR\x05email\"\x16\n" + "\x14ResendInviteResponse2\xc0\x02\n" + "\rInviteService\x12K\n" + "\fCreateInvite\x12\x1c.invites.CreateInviteRequest\x1a\x1d.invites.CreateInviteResponse\x12H\n" + "\vListInvites\x12\x1b.invites.ListInvitesRequest\x1a\x1c.invites.ListInvitesResponse\x12K\n" + "\fRevokeInvite\x12\x1c.invites.RevokeInviteRequest\x1a\x1d.invites.RevokeInviteResponse\x12K\n" + "\fResendInvite\x12\x1c.invites.ResendInviteRequest\x1a\x1d.invites.ResendInviteResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_invites_proto_rawDescOnce sync.Once file_invites_proto_rawDescData []byte ) func file_invites_proto_rawDescGZIP() []byte { file_invites_proto_rawDescOnce.Do(func() { file_invites_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_invites_proto_rawDesc), len(file_invites_proto_rawDesc))) }) return file_invites_proto_rawDescData } var file_invites_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_invites_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_invites_proto_goTypes = []any{ (Invite_InviteStatus)(0), // 0: invites.Invite.InviteStatus (*CreateInviteRequest)(nil), // 1: invites.CreateInviteRequest (*CreateInviteResponse)(nil), // 2: invites.CreateInviteResponse (*Invite)(nil), // 3: invites.Invite (*ListInvitesRequest)(nil), // 4: invites.ListInvitesRequest (*ListInvitesResponse)(nil), // 5: invites.ListInvitesResponse (*RevokeInviteRequest)(nil), // 6: invites.RevokeInviteRequest (*RevokeInviteResponse)(nil), // 7: invites.RevokeInviteResponse (*ResendInviteRequest)(nil), // 8: invites.ResendInviteRequest (*ResendInviteResponse)(nil), // 9: invites.ResendInviteResponse } var file_invites_proto_depIdxs = []int32{ 0, // 0: invites.Invite.status:type_name -> invites.Invite.InviteStatus 3, // 1: invites.ListInvitesResponse.invites:type_name -> invites.Invite 1, // 2: invites.InviteService.CreateInvite:input_type -> invites.CreateInviteRequest 4, // 3: invites.InviteService.ListInvites:input_type -> invites.ListInvitesRequest 6, // 4: invites.InviteService.RevokeInvite:input_type -> invites.RevokeInviteRequest 8, // 5: invites.InviteService.ResendInvite:input_type -> invites.ResendInviteRequest 2, // 6: invites.InviteService.CreateInvite:output_type -> invites.CreateInviteResponse 5, // 7: invites.InviteService.ListInvites:output_type -> invites.ListInvitesResponse 7, // 8: invites.InviteService.RevokeInvite:output_type -> invites.RevokeInviteResponse 9, // 9: invites.InviteService.ResendInvite:output_type -> invites.ResendInviteResponse 6, // [6:10] is the sub-list for method output_type 2, // [2:6] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name } func init() { file_invites_proto_init() } func file_invites_proto_init() { if File_invites_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_invites_proto_rawDesc), len(file_invites_proto_rawDesc)), NumEnums: 1, NumMessages: 9, NumExtensions: 0, NumServices: 1, }, GoTypes: file_invites_proto_goTypes, DependencyIndexes: file_invites_proto_depIdxs, EnumInfos: file_invites_proto_enumTypes, MessageInfos: file_invites_proto_msgTypes, }.Build() File_invites_proto = out.File file_invites_proto_goTypes = nil file_invites_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/items.go ================================================ package sdp import ( "context" "crypto/sha256" "encoding/base32" "encoding/json" "errors" "fmt" "reflect" "sort" "strings" "time" "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/types/known/structpb" ) const WILDCARD = "*" // UniqueAttributeValue returns the value of whatever the Unique Attribute is // for this item. This will then be converted to a string and returned func (i *Item) UniqueAttributeValue() string { var value any var err error value, err = i.GetAttributes().Get(i.GetUniqueAttribute()) if err == nil { return fmt.Sprint(value) } return "" } // Reference returns an SDP reference for the item func (i *Item) Reference() *Reference { return &Reference{ Scope: i.GetScope(), Type: i.GetType(), UniqueAttributeValue: i.UniqueAttributeValue(), } } // GloballyUniqueName Returns a string that defines the Item globally. This a // combination of the following values: // // - scope // - type // - uniqueAttributeValue // // They are concatenated with dots (.) func (i *Item) GloballyUniqueName() string { return strings.Join([]string{ i.GetScope(), i.GetType(), i.UniqueAttributeValue(), }, ".", ) } // Hash Returns a 12 character hash for the item. This is likely but not // guaranteed to be unique. The hash is calculated using the GloballyUniqueName func (i *Item) Hash() string { return HashSum((fmt.Append(nil, i.GloballyUniqueName()))) } // IsEqual compares two Edges for equality by checking the From reference // and To reference. func (e *Edge) IsEqual(other *Edge) bool { return e.GetFrom().IsEqual(other.GetFrom()) && e.GetTo().IsEqual(other.GetTo()) } // Hash Returns a 12 character hash for the item. This is likely but not // guaranteed to be unique. The hash is calculated using the GloballyUniqueName func (r *Reference) Hash() string { return HashSum((fmt.Append(nil, r.GloballyUniqueName()))) } // GloballyUniqueName Returns a string that defines the Item globally. This a // combination of the following values: // // - scope // - type // - uniqueAttributeValue // // They are concatenated with dots (.) func (r *Reference) GloballyUniqueName() string { if r == nil { // in the llm templates nil references are processed, and after spending // half an hour on trying to figure out what was happening in the // reflect code, I decided to just return an empty string here. DS, // 2025-02-26 return "" } if r.GetIsQuery() { if r.GetMethod() == QueryMethod_GET { // GET queries are single items return fmt.Sprintf("%v.%v.%v", r.GetScope(), r.GetType(), r.GetQuery()) } panic(fmt.Sprintf("cannot get globally unique name for query reference: %v", r)) } return fmt.Sprintf("%v.%v.%v", r.GetScope(), r.GetType(), r.GetUniqueAttributeValue()) } // Key returns a globally unique string for this reference, even if it is a GET query func (r *Reference) Key() string { if r == nil { panic("cannot get key for nil reference") } if r.GetIsQuery() { if r.IsSingle() { // GET queries without wildcards are single items return fmt.Sprintf("%v.%v.%v", r.GetScope(), r.GetType(), r.GetQuery()) } return fmt.Sprintf("%v: %v.%v.%v", r.GetMethod(), r.GetScope(), r.GetType(), r.GetQuery()) } return r.GloballyUniqueName() } // IsSingle returns true if this references a single item, false if it is a LIST // or SEARCH query, or a GET query with scope and/or type wildcards. func (r *Reference) IsSingle() bool { // nil reference is never good if r == nil { return false } // if it is a query, then it is only a single item if it is a GET query with no wildcards if r.GetIsQuery() { return r.GetMethod() == QueryMethod_GET && r.GetScope() != "*" && r.GetType() != "*" } // if it is not a query, then it is always single item return true } // IsEqual compares two References for equality by checking all fields: // Scope, Type, UniqueAttributeValue, IsQuery, Method, and Query. func (r *Reference) IsEqual(other *Reference) bool { return r.GetScope() == other.GetScope() && r.GetType() == other.GetType() && r.GetUniqueAttributeValue() == other.GetUniqueAttributeValue() && r.GetIsQuery() == other.GetIsQuery() && r.GetMethod() == other.GetMethod() && r.GetQuery() == other.GetQuery() } // ToQuery converts a Reference to a Query object. If the Reference is not // already a query (IsQuery=false), it creates a GET query using the // UniqueAttributeValue. Otherwise, it preserves the existing query parameters. func (r *Reference) ToQuery() *Query { if !r.GetIsQuery() { return &Query{ Scope: r.GetScope(), Type: r.GetType(), Method: QueryMethod_GET, Query: r.GetUniqueAttributeValue(), } } return &Query{ Scope: r.GetScope(), Type: r.GetType(), Method: r.GetMethod(), Query: r.GetQuery(), } } // Get Returns the value of a given attribute by name. If the attribute is // a nested hash, nested values can be referenced using dot notation e.g. // location.country func (a *ItemAttributes) Get(name string) (any, error) { var result any // Start at the beginning of the map, we will then traverse down as required result = a.GetAttrStruct().AsMap() for section := range strings.SplitSeq(name, ".") { // Check that the data we're using is in the supported format var m map[string]any m, isMap := result.(map[string]any) if !isMap { return nil, fmt.Errorf("attribute %v not found", name) } v, keyExists := m[section] if keyExists { result = v } else { return nil, fmt.Errorf("attribute %v not found", name) } } return result, nil } // Set sets an attribute. Values are converted to structpb versions and an error // will be returned if this fails. Note that this does *not* yet support // dot notation e.g. location.country func (a *ItemAttributes) Set(name string, value any) error { // Check to make sure that the pointer is not nil if a == nil { return errors.New("Set called on nil pointer") } // Ensure that this interface will be able to be converted to a struct value sanitizedValue := sanitizeInterface(value, false, DefaultTransforms) structValue, err := structpb.NewValue(sanitizedValue) if err != nil { return err } fields := a.GetAttrStruct().GetFields() fields[name] = structValue return nil } // IsSingle returns true if this query can only return a single item. func (q *Query) IsSingle() bool { return q.GetMethod() == QueryMethod_GET && q.GetScope() != "*" && q.GetType() != "*" } // Reference returns an SDP reference equivalent to this Query func (q *Query) Reference() *Reference { if q.IsSingle() { return &Reference{ Scope: q.GetScope(), Type: q.GetType(), UniqueAttributeValue: q.GetQuery(), } } return &Reference{ Scope: q.GetScope(), Type: q.GetType(), IsQuery: true, Query: q.GetQuery(), Method: q.GetMethod(), } } // Subject returns a NATS subject for all traffic relating to this query func (q *Query) Subject() string { return fmt.Sprintf("query.%v", q.GetUUIDParsed()) } // TimeoutContext returns a context and cancel function representing the timeout // for this request func (q *Query) TimeoutContext(ctx context.Context) (context.Context, context.CancelFunc) { // If there is no deadline, treat that as infinite if q == nil || !q.GetDeadline().IsValid() { return context.WithCancel(ctx) } return context.WithDeadline(ctx, q.GetDeadline().AsTime()) } // GetUUIDParsed returns this request's UUID. If there's an error parsing it, // generates and stores a fresh one func (r *Query) GetUUIDParsed() uuid.UUID { if r == nil { return uuid.UUID{} } // Extract and parse the UUID reqUUID, uuidErr := uuid.FromBytes(r.GetUUID()) if uuidErr != nil { reqUUID = uuid.New() r.UUID = reqUUID[:] } return reqUUID } // SetSpanAttributes sets OpenTelemetry span attributes for the query, // including method, type, scope, query string, UUID, deadline, and cache settings. // All attributes are prefixed with "ovm.sdp." for namespacing. func (q *Query) SetSpanAttributes(span trace.Span) { span.SetAttributes( attribute.String("ovm.sdp.method", q.GetMethod().String()), attribute.String("ovm.sdp.type", q.GetType()), attribute.String("ovm.sdp.scope", q.GetScope()), attribute.String("ovm.sdp.query", q.GetQuery()), attribute.String("ovm.sdp.uuid", q.GetUUIDParsed().String()), attribute.String("ovm.sdp.deadline", q.GetDeadline().AsTime().String()), attribute.Bool("ovm.sdp.queryIgnoreCache", q.GetIgnoreCache()), ) } // NewQueryResponseFromItem creates a QueryResponse wrapping a discovered Item. func NewQueryResponseFromItem(item *Item) *QueryResponse { return &QueryResponse{ ResponseType: &QueryResponse_NewItem{ NewItem: item, }, } } // NewQueryResponseFromEdge creates a QueryResponse wrapping a discovered Edge. func NewQueryResponseFromEdge(edge *Edge) *QueryResponse { return &QueryResponse{ ResponseType: &QueryResponse_Edge{ Edge: edge, }, } } // NewQueryResponseFromError creates a QueryResponse wrapping a QueryError. func NewQueryResponseFromError(qe *QueryError) *QueryResponse { return &QueryResponse{ ResponseType: &QueryResponse_Error{ Error: qe, }, } } // NewQueryResponseFromResponse creates a QueryResponse wrapping a Response status update. func NewQueryResponseFromResponse(r *Response) *QueryResponse { return &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: r, }, } } // ToGatewayResponse converts a QueryResponse to a GatewayResponse for sending // to clients. Handles Item, Edge, Error, and Response status types. func (qr *QueryResponse) ToGatewayResponse() *GatewayResponse { switch qr.GetResponseType().(type) { case *QueryResponse_NewItem: return &GatewayResponse{ ResponseType: &GatewayResponse_NewItem{ NewItem: qr.GetNewItem(), }, } case *QueryResponse_Edge: return &GatewayResponse{ ResponseType: &GatewayResponse_NewEdge{ NewEdge: qr.GetEdge(), }, } case *QueryResponse_Error: return &GatewayResponse{ ResponseType: &GatewayResponse_QueryError{ QueryError: qr.GetError(), }, } case *QueryResponse_Response: return &GatewayResponse{ ResponseType: &GatewayResponse_QueryStatus{ QueryStatus: qr.GetResponse().ToQueryStatus(), }, } default: panic(fmt.Sprintf("encountered unknown QueryResponse type: %T", qr)) } } // GetUUIDParsed returns the parsed UUID from the CancelQuery, or nil if invalid. func (x *CancelQuery) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetUUID()) if err != nil { return nil } return &u } // GetUUIDParsed returns the parsed UUID from the Expand request, or nil if invalid. func (x *Expand) GetUUIDParsed() *uuid.UUID { u, err := uuid.FromBytes(x.GetUUID()) if err != nil { return nil } return &u } // AddDefaultTransforms adds the default transforms to a TransformMap func AddDefaultTransforms(customTransforms TransformMap) TransformMap { for k, v := range DefaultTransforms { if _, ok := customTransforms[k]; !ok { customTransforms[k] = v } } return customTransforms } // Converts to attributes using an additional set of custom transformers. These // can be used to change the transform behaviour of known types to do things // like redaction of sensitive data or simplification of complex types. // // For example this could be used to completely remove anything of type // `Secret`: // // ```go // // TransformMap{ // reflect.TypeOf(Secret{}): func(i interface{}) interface{} { // // Remove it // return "REDACTED" // }, // } // // ``` // // Note that you need to use `AddDefaultTransforms(TransformMap) TransformMap` // to get sensible default transformations. func ToAttributesCustom(m map[string]any, sort bool, customTransforms TransformMap) (*ItemAttributes, error) { return toAttributes(m, sort, customTransforms) } // Converts a map[string]interface{} to an ItemAttributes object, sorting all // slices alphabetically.This should be used when the item doesn't contain array // attributes that are explicitly sorted, especially if these are sometimes // returned in a different order func ToAttributesSorted(m map[string]any) (*ItemAttributes, error) { return toAttributes(m, true, DefaultTransforms) } // ToAttributes Converts a map[string]interface{} to an ItemAttributes object func ToAttributes(m map[string]any) (*ItemAttributes, error) { return toAttributes(m, false, DefaultTransforms) } func toAttributes(m map[string]any, sort bool, customTransforms TransformMap) (*ItemAttributes, error) { if m == nil { return nil, nil } var s map[string]*structpb.Value var err error s = make(map[string]*structpb.Value) // Loop over the map for k, v := range m { sanitizedValue := sanitizeInterface(v, sort, customTransforms) structValue, err := structpb.NewValue(sanitizedValue) if err != nil { return nil, err } s[k] = structValue } return &ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: s, }, }, err } // ToAttributesViaJson Converts any struct to a set of attributes by marshalling // to JSON and then back again. This is less performant than ToAttributes() but // does save work when copying large structs to attributes in their entirety func ToAttributesViaJson(v any) (*ItemAttributes, error) { b, err := json.Marshal(v) if err != nil { return nil, err } var m map[string]any err = json.Unmarshal(b, &m) if err != nil { return nil, err } return ToAttributes(m) } // A function that transforms one data type into another that is compatible with // protobuf. This is used to convert things like time.Time into a string type TransformFunc func(any) any // A map of types to transform functions type TransformMap map[reflect.Type]TransformFunc // The default transforms that are used when converting to attributes var DefaultTransforms = TransformMap{ // Time should be in RFC3339Nano format i.e. 2006-01-02T15:04:05.999999999Z07:00 reflect.TypeFor[time.Time](): func(i any) any { return i.(time.Time).Format(time.RFC3339Nano) }, // Duration should be in string format reflect.TypeFor[time.Duration](): func(i any) any { return i.(time.Duration).String() }, } // sanitizeInterface Ensures that en interface is in a format that can be // converted to a protobuf value. The structpb.ToValue() function expects things // to be in one of the following formats: // // ╔════════════════════════╤════════════════════════════════════════════╗ // ║ Go type │ Conversion ║ // ╠════════════════════════╪════════════════════════════════════════════╣ // ║ nil │ stored as NullValue ║ // ║ bool │ stored as BoolValue ║ // ║ int, int32, int64 │ stored as NumberValue ║ // ║ uint, uint32, uint64 │ stored as NumberValue ║ // ║ float32, float64 │ stored as NumberValue ║ // ║ string │ stored as StringValue; must be valid UTF-8 ║ // ║ []byte │ stored as StringValue; base64-encoded ║ // ║ map[string]interface{} │ stored as StructValue ║ // ║ []interface{} │ stored as ListValue ║ // ╚════════════════════════╧════════════════════════════════════════════╝ // // However this means that a data type like []string won't work, despite the // function being perfectly able to represent it in a protobuf struct. This // function does its best to example the available data type to ensure that as // long as the data can in theory be represented by a protobuf struct, the // conversion will work. func sanitizeInterface(i any, sortArrays bool, customTransforms TransformMap) any { if i == nil { return nil } v := reflect.ValueOf(i) t := v.Type() // Use the transform for this specific type if it exists if tFunc, ok := customTransforms[t]; ok { // Reset the value and type to the transformed value. This means that // even if the function returns something bad, we will then transform it i = tFunc(i) if i == nil { return nil } v = reflect.ValueOf(i) t = v.Type() } switch v.Kind() { //nolint:exhaustive // we fall through to the default case case reflect.Bool: return v.Bool() case reflect.Int: return v.Int() case reflect.Int8: return v.Int() case reflect.Int16: return v.Int() case reflect.Int32: return v.Int() case reflect.Int64: return v.Int() case reflect.Uint: return v.Uint() case reflect.Uint8: return v.Uint() case reflect.Uint16: return v.Uint() case reflect.Uint32: return v.Uint() case reflect.Uint64: return v.Uint() case reflect.Float32: return v.Float() case reflect.Float64: return v.Float() case reflect.String: return fmt.Sprint(v) case reflect.Array, reflect.Slice: // We need to check the type of each element in the array and do // conversion on that // returnSlice Returns the array in the format that protobuf can deal with var returnSlice []any returnSlice = make([]any, v.Len()) for i := range v.Len() { returnSlice[i] = sanitizeInterface(v.Index(i).Interface(), sortArrays, customTransforms) } if sortArrays { sortInterfaceArray(returnSlice) } return returnSlice case reflect.Map: var returnMap map[string]any returnMap = make(map[string]any) for _, mapKey := range v.MapKeys() { // Convert the key to a string stringKey := fmt.Sprint(mapKey.Interface()) // Convert the value to a compatible interface value := sanitizeInterface(v.MapIndex(mapKey).Interface(), sortArrays, customTransforms) returnMap[stringKey] = value } return returnMap case reflect.Struct: // In the case of a struct we basically want to turn it into a // map[string]interface{} var returnMap map[string]any returnMap = make(map[string]any) // Range over fields n := t.NumField() for i := range n { field := t.Field(i) if field.PkgPath != "" { // If this has a PkgPath then it is an un-exported fiend and // should be ignored continue } // Get the zero value for this field zeroValue := reflect.Zero(field.Type).Interface() fieldValue := v.Field(i).Interface() // Check if the field is it's nil value // Check if there actually was a field with that name if !reflect.DeepEqual(fieldValue, zeroValue) { returnMap[field.Name] = fieldValue } } return sanitizeInterface(returnMap, sortArrays, customTransforms) case reflect.Pointer: // Get the zero value for this field zero := reflect.Zero(t) // Check if the field is it's nil value if reflect.DeepEqual(v, zero) { return nil } return sanitizeInterface(v.Elem().Interface(), sortArrays, customTransforms) default: // If we don't recognize the type then we need to see what the // underlying type is and see if we can convert that return i } } // Sorts an interface slice by converting each item to a string and sorting // these strings func sortInterfaceArray(input []any) { sort.Slice(input, func(i, j int) bool { return fmt.Sprint(input[i]) < fmt.Sprint(input[j]) }) } // HashSum is a function that takes a byte array and returns a 12 character hash for use in neo4j func HashSum(b []byte) string { var paddedEncoding *base32.Encoding var unpaddedEncoding *base32.Encoding shaSum := sha256.Sum256(b) // We need to specify a custom encoding here since dGraph has fairly strict // requirements about what name a variable can have paddedEncoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyzABCDEF") // We also can't have padding since "=" is not allowed in variable names unpaddedEncoding = paddedEncoding.WithPadding(base32.NoPadding) return unpaddedEncoding.EncodeToString(shaSum[:11]) } ================================================ FILE: go/sdp-go/items.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: items.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" structpb "google.golang.org/protobuf/types/known/structpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // Represents the health of something, the meaning of each state may depend on // the context in which it is used but should be reasonably obvious type Health int32 const ( Health_HEALTH_UNKNOWN Health = 0 // The health could not be determined Health_HEALTH_OK Health = 1 // Functioning normally Health_HEALTH_WARNING Health = 2 // Functioning, but degraded Health_HEALTH_ERROR Health = 3 // Not functioning Health_HEALTH_PENDING Health = 4 // Health state is transitioning, such as when something is first provisioned ) // Enum value maps for Health. var ( Health_name = map[int32]string{ 0: "HEALTH_UNKNOWN", 1: "HEALTH_OK", 2: "HEALTH_WARNING", 3: "HEALTH_ERROR", 4: "HEALTH_PENDING", } Health_value = map[string]int32{ "HEALTH_UNKNOWN": 0, "HEALTH_OK": 1, "HEALTH_WARNING": 2, "HEALTH_ERROR": 3, "HEALTH_PENDING": 4, } ) func (x Health) Enum() *Health { p := new(Health) *p = x return p } func (x Health) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (Health) Descriptor() protoreflect.EnumDescriptor { return file_items_proto_enumTypes[0].Descriptor() } func (Health) Type() protoreflect.EnumType { return &file_items_proto_enumTypes[0] } func (x Health) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use Health.Descriptor instead. func (Health) EnumDescriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{0} } // QueryMethod represents the available query methods. The details of these // methods are: // // GET: This takes a single unique query and should only return a single item. // // If an item matching the parameter passed doesn't exist the server should // fail // // LIST: This takes no query (or ignores it) and should return all items that it // // can find // // SEARCH: This takes a non-unique query which is designed to be used as a // // search term. It should return some number of items (or zero) which // match the query type QueryMethod int32 const ( QueryMethod_GET QueryMethod = 0 QueryMethod_LIST QueryMethod = 1 QueryMethod_SEARCH QueryMethod = 2 ) // Enum value maps for QueryMethod. var ( QueryMethod_name = map[int32]string{ 0: "GET", 1: "LIST", 2: "SEARCH", } QueryMethod_value = map[string]int32{ "GET": 0, "LIST": 1, "SEARCH": 2, } ) func (x QueryMethod) Enum() *QueryMethod { p := new(QueryMethod) *p = x return p } func (x QueryMethod) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (QueryMethod) Descriptor() protoreflect.EnumDescriptor { return file_items_proto_enumTypes[1].Descriptor() } func (QueryMethod) Type() protoreflect.EnumType { return &file_items_proto_enumTypes[1] } func (x QueryMethod) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use QueryMethod.Descriptor instead. func (QueryMethod) EnumDescriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{1} } // The error type. Any types in here will be gracefully handled unless the // type os "OTHER" type QueryStatus_Status int32 const ( // the status has not been specified QueryStatus_UNSPECIFIED QueryStatus_Status = 0 // the query has been started QueryStatus_STARTED QueryStatus_Status = 1 // the query has been cancelled. // This is a final state. QueryStatus_CANCELLED QueryStatus_Status = 3 // the query has finished with an error status. expect a separate QueryError describing that. // This is a final state. // TODO: fold the error details into this message QueryStatus_ERRORED QueryStatus_Status = 4 // The query has finished and all results have been sent over the wire // This is a final state. QueryStatus_FINISHED QueryStatus_Status = 5 ) // Enum value maps for QueryStatus_Status. var ( QueryStatus_Status_name = map[int32]string{ 0: "UNSPECIFIED", 1: "STARTED", 3: "CANCELLED", 4: "ERRORED", 5: "FINISHED", } QueryStatus_Status_value = map[string]int32{ "UNSPECIFIED": 0, "STARTED": 1, "CANCELLED": 3, "ERRORED": 4, "FINISHED": 5, } ) func (x QueryStatus_Status) Enum() *QueryStatus_Status { p := new(QueryStatus_Status) *p = x return p } func (x QueryStatus_Status) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (QueryStatus_Status) Descriptor() protoreflect.EnumDescriptor { return file_items_proto_enumTypes[2].Descriptor() } func (QueryStatus_Status) Type() protoreflect.EnumType { return &file_items_proto_enumTypes[2] } func (x QueryStatus_Status) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use QueryStatus_Status.Descriptor instead. func (QueryStatus_Status) EnumDescriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{10, 0} } // The error type. Any types in here will be gracefully handled unless the // type os "OTHER" type QueryError_ErrorType int32 const ( // This should be used of all other failure modes, such as timeouts, // unexpected failures when querying state, permissions errors etc. Errors // that return this type should not be cached as the error may be transient. QueryError_OTHER QueryError_ErrorType = 0 // NOTFOUND means that the item was not found. This is only returned as the // result of a GET query since all other queries would return an empty // list instead QueryError_NOTFOUND QueryError_ErrorType = 1 // NOSCOPE means that the item was not found because we don't have // access to the requested scope. This should not be interpreted as "The // item doesn't exist" (as with a NOTFOUND error) but rather as "We can't // tell you whether or not the item exists" QueryError_NOSCOPE QueryError_ErrorType = 2 // TIMEOUT means that the source times out when trying to query the item. // The timeout is provided in the original query QueryError_TIMEOUT QueryError_ErrorType = 3 ) // Enum value maps for QueryError_ErrorType. var ( QueryError_ErrorType_name = map[int32]string{ 0: "OTHER", 1: "NOTFOUND", 2: "NOSCOPE", 3: "TIMEOUT", } QueryError_ErrorType_value = map[string]int32{ "OTHER": 0, "NOTFOUND": 1, "NOSCOPE": 2, "TIMEOUT": 3, } ) func (x QueryError_ErrorType) Enum() *QueryError_ErrorType { p := new(QueryError_ErrorType) *p = x return p } func (x QueryError_ErrorType) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (QueryError_ErrorType) Descriptor() protoreflect.EnumDescriptor { return file_items_proto_enumTypes[3].Descriptor() } func (QueryError_ErrorType) Type() protoreflect.EnumType { return &file_items_proto_enumTypes[3] } func (x QueryError_ErrorType) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use QueryError_ErrorType.Descriptor instead. func (QueryError_ErrorType) EnumDescriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{11, 0} } // DEPRECATED: BlastPropagation was previously used to determine how configuration // changes propagate over links. It has been replaced with an AI-driven approach // for blast radius calculation and is no longer used. // // Reserved to prevent field number reuse and maintain wire-format compatibility. type BlastPropagation struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BlastPropagation) Reset() { *x = BlastPropagation{} mi := &file_items_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BlastPropagation) String() string { return protoimpl.X.MessageStringOf(x) } func (*BlastPropagation) ProtoMessage() {} func (x *BlastPropagation) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BlastPropagation.ProtoReflect.Descriptor instead. func (*BlastPropagation) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{0} } // An annotated query to indicate potential linked items. type LinkedItemQuery struct { state protoimpl.MessageState `protogen:"open.v1"` // the query that would find linked items Query *Query `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LinkedItemQuery) Reset() { *x = LinkedItemQuery{} mi := &file_items_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LinkedItemQuery) String() string { return protoimpl.X.MessageStringOf(x) } func (*LinkedItemQuery) ProtoMessage() {} func (x *LinkedItemQuery) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LinkedItemQuery.ProtoReflect.Descriptor instead. func (*LinkedItemQuery) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{1} } func (x *LinkedItemQuery) GetQuery() *Query { if x != nil { return x.Query } return nil } // An annotated reference to list linked items. type LinkedItem struct { state protoimpl.MessageState `protogen:"open.v1"` // the linked item Item *Reference `protobuf:"bytes,1,opt,name=item,proto3" json:"item,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LinkedItem) Reset() { *x = LinkedItem{} mi := &file_items_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LinkedItem) String() string { return protoimpl.X.MessageStringOf(x) } func (*LinkedItem) ProtoMessage() {} func (x *LinkedItem) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LinkedItem.ProtoReflect.Descriptor instead. func (*LinkedItem) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{2} } func (x *LinkedItem) GetItem() *Reference { if x != nil { return x.Item } return nil } // This is the same as Item within the package with a couple of exceptions, no // real reason why this whole thing couldn't be modelled in protobuf though if // required. Just need to decide what if anything should remain private type Item struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` UniqueAttribute string `protobuf:"bytes,2,opt,name=uniqueAttribute,proto3" json:"uniqueAttribute,omitempty"` Attributes *ItemAttributes `protobuf:"bytes,3,opt,name=attributes,proto3" json:"attributes,omitempty"` Metadata *Metadata `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"` // The scope within which the item is unique. Item uniqueness is determined // by the combination of type and uniqueAttribute value. However it is // possible for the same item to exist in many scopes. There is not formal // definition for what a scope should be other than the fact that it should // be somewhat descriptive and should ensure item uniqueness Scope string `protobuf:"bytes,5,opt,name=scope,proto3" json:"scope,omitempty"` // Not all items will have relatedItems we are are using a two byte // integer to save one byte integers for more common things LinkedItemQueries []*LinkedItemQuery `protobuf:"bytes,16,rep,name=linkedItemQueries,proto3" json:"linkedItemQueries,omitempty"` // Linked items LinkedItems []*LinkedItem `protobuf:"bytes,17,rep,name=linkedItems,proto3" json:"linkedItems,omitempty"` // (optional) Represents the health of the item. Only items that have a // clearly relevant health attribute should return a value for health Health *Health `protobuf:"varint,18,opt,name=health,proto3,enum=Health,oneof" json:"health,omitempty"` // Arbitrary key-value pairs that can be used to store additional information. // These tags are retrieved from the source and map to the target's definition // of a tag (e.g. AWS tags, Kubernetes labels, etc.) Tags map[string]string `protobuf:"bytes,19,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // The available log streams for this item, if any. Use the Logs service to // access the actual contents. LogStreams []*LogStreamDetails `protobuf:"bytes,20,rep,name=logStreams,proto3" json:"logStreams,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Item) Reset() { *x = Item{} mi := &file_items_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Item) String() string { return protoimpl.X.MessageStringOf(x) } func (*Item) ProtoMessage() {} func (x *Item) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Item.ProtoReflect.Descriptor instead. func (*Item) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{3} } func (x *Item) GetType() string { if x != nil { return x.Type } return "" } func (x *Item) GetUniqueAttribute() string { if x != nil { return x.UniqueAttribute } return "" } func (x *Item) GetAttributes() *ItemAttributes { if x != nil { return x.Attributes } return nil } func (x *Item) GetMetadata() *Metadata { if x != nil { return x.Metadata } return nil } func (x *Item) GetScope() string { if x != nil { return x.Scope } return "" } func (x *Item) GetLinkedItemQueries() []*LinkedItemQuery { if x != nil { return x.LinkedItemQueries } return nil } func (x *Item) GetLinkedItems() []*LinkedItem { if x != nil { return x.LinkedItems } return nil } func (x *Item) GetHealth() Health { if x != nil && x.Health != nil { return *x.Health } return Health_HEALTH_UNKNOWN } func (x *Item) GetTags() map[string]string { if x != nil { return x.Tags } return nil } func (x *Item) GetLogStreams() []*LogStreamDetails { if x != nil { return x.LogStreams } return nil } // ItemAttributes represents the known attributes for an item. These are likely // to be common to a given type, but even this is not guaranteed. All items must // have at least one attribute however as it needs something to uniquely // identify it type ItemAttributes struct { state protoimpl.MessageState `protogen:"open.v1"` AttrStruct *structpb.Struct `protobuf:"bytes,1,opt,name=attrStruct,proto3" json:"attrStruct,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ItemAttributes) Reset() { *x = ItemAttributes{} mi := &file_items_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ItemAttributes) String() string { return protoimpl.X.MessageStringOf(x) } func (*ItemAttributes) ProtoMessage() {} func (x *ItemAttributes) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ItemAttributes.ProtoReflect.Descriptor instead. func (*ItemAttributes) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{4} } func (x *ItemAttributes) GetAttrStruct() *structpb.Struct { if x != nil { return x.AttrStruct } return nil } // Metadata about the item. Where it came from, how long it took, etc. type Metadata struct { state protoimpl.MessageState `protogen:"open.v1"` // This is the name of the source that was used to find the item. SourceName string `protobuf:"bytes,2,opt,name=sourceName,proto3" json:"sourceName,omitempty"` // The query that caused this item to be found. This is for gateway-internal use and will not be exposed to the frontend. SourceQuery *Query `protobuf:"bytes,3,opt,name=sourceQuery,proto3" json:"sourceQuery,omitempty"` // The time that the item was found Timestamp *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // How long the source took to execute in total when processing the Query. // // (deprecated) This is no longer sent as streaming responses make this metric // impossible to calculate on a per-item basis // // Deprecated: Marked as deprecated in items.proto. SourceDuration *durationpb.Duration `protobuf:"bytes,5,opt,name=sourceDuration,proto3" json:"sourceDuration,omitempty"` // How long the source took to execute per item when processing the // Query // // (deprecated) This is no longer sent // // Deprecated: Marked as deprecated in items.proto. SourceDurationPerItem *durationpb.Duration `protobuf:"bytes,6,opt,name=sourceDurationPerItem,proto3" json:"sourceDurationPerItem,omitempty"` // Whether the item should be hidden/ignored by user-facing things such as // GUIs and databases. // // Some types of items are only relevant in calculating higher-layer // abstractions and are therefore always hidden. A good example of this would // be the output of a command. This could be used by a remote source to gather // information, but we don't actually want to show the user all the commands // that were run, just the final item returned by the source Hidden bool `protobuf:"varint,7,opt,name=hidden,proto3" json:"hidden,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Metadata) Reset() { *x = Metadata{} mi := &file_items_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Metadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*Metadata) ProtoMessage() {} func (x *Metadata) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Metadata.ProtoReflect.Descriptor instead. func (*Metadata) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{5} } func (x *Metadata) GetSourceName() string { if x != nil { return x.SourceName } return "" } func (x *Metadata) GetSourceQuery() *Query { if x != nil { return x.SourceQuery } return nil } func (x *Metadata) GetTimestamp() *timestamppb.Timestamp { if x != nil { return x.Timestamp } return nil } // Deprecated: Marked as deprecated in items.proto. func (x *Metadata) GetSourceDuration() *durationpb.Duration { if x != nil { return x.SourceDuration } return nil } // Deprecated: Marked as deprecated in items.proto. func (x *Metadata) GetSourceDurationPerItem() *durationpb.Duration { if x != nil { return x.SourceDurationPerItem } return nil } func (x *Metadata) GetHidden() bool { if x != nil { return x.Hidden } return false } // This is a list of items, like a List() would return type Items struct { state protoimpl.MessageState `protogen:"open.v1"` Items []*Item `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Items) Reset() { *x = Items{} mi := &file_items_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Items) String() string { return protoimpl.X.MessageStringOf(x) } func (*Items) ProtoMessage() {} func (x *Items) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Items.ProtoReflect.Descriptor instead. func (*Items) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{6} } func (x *Items) GetItems() []*Item { if x != nil { return x.Items } return nil } // describes the details of a Log Stream for an item type LogStreamDetails struct { state protoimpl.MessageState `protogen:"open.v1"` // The descriptive name for display purposes Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // the source scope for this log stream. Has to be a specific scope, not // wildcarded. Scope string `protobuf:"bytes,2,opt,name=scope,proto3" json:"scope,omitempty"` // The query that should pe passed back to the upstream // API to get log lines from this stream Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LogStreamDetails) Reset() { *x = LogStreamDetails{} mi := &file_items_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LogStreamDetails) String() string { return protoimpl.X.MessageStringOf(x) } func (*LogStreamDetails) ProtoMessage() {} func (x *LogStreamDetails) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LogStreamDetails.ProtoReflect.Descriptor instead. func (*LogStreamDetails) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{7} } func (x *LogStreamDetails) GetName() string { if x != nil { return x.Name } return "" } func (x *LogStreamDetails) GetScope() string { if x != nil { return x.Scope } return "" } func (x *LogStreamDetails) GetQuery() string { if x != nil { return x.Query } return "" } // Query represents a query for an item or a list of items. type Query struct { state protoimpl.MessageState `protogen:"open.v1"` // The type of item to search for. "*" means all types Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` // Which method to use when looking for it Method QueryMethod `protobuf:"varint,2,opt,name=method,proto3,enum=QueryMethod" json:"method,omitempty"` // What query should be passed to that method Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` // Defines how this query should behave when finding new items RecursionBehaviour *Query_RecursionBehaviour `protobuf:"bytes,4,opt,name=recursionBehaviour,proto3" json:"recursionBehaviour,omitempty"` // The scope for which we are requesting. To query all scopes use the the // wildcard '*' Scope string `protobuf:"bytes,5,opt,name=scope,proto3" json:"scope,omitempty"` // Whether to ignore the cache and execute the query regardless. // // By default sources will implement some level of caching, this is // particularly important for linked items as a single query with a large link // depth may result in the same item being queried many times as links are // resolved and more and more items link to each other. However if required // this caching can be turned off using this parameter IgnoreCache bool `protobuf:"varint,6,opt,name=ignoreCache,proto3" json:"ignoreCache,omitempty"` // A UUID to uniquely identify the query. This should be stored by the // requester as it will be needed later if the requester wants to cancel a // query. It should be stored as 128 bytes, as opposed to the textual // representation UUID []byte `protobuf:"bytes,7,opt,name=UUID,proto3" json:"UUID,omitempty"` // The deadline for this query. When the deadline elapses, results become // irrelevant for the sender and any processing can stop. The deadline gets // propagated to all related queries (e.g. for linked items) and processes. // Note: there is currently a migration going on from timeouts to durations, // so depending on which service is hit, either one is evaluated. Deadline *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=deadline,proto3" json:"deadline,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Query) Reset() { *x = Query{} mi := &file_items_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Query) String() string { return protoimpl.X.MessageStringOf(x) } func (*Query) ProtoMessage() {} func (x *Query) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Query.ProtoReflect.Descriptor instead. func (*Query) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{8} } func (x *Query) GetType() string { if x != nil { return x.Type } return "" } func (x *Query) GetMethod() QueryMethod { if x != nil { return x.Method } return QueryMethod_GET } func (x *Query) GetQuery() string { if x != nil { return x.Query } return "" } func (x *Query) GetRecursionBehaviour() *Query_RecursionBehaviour { if x != nil { return x.RecursionBehaviour } return nil } func (x *Query) GetScope() string { if x != nil { return x.Scope } return "" } func (x *Query) GetIgnoreCache() bool { if x != nil { return x.IgnoreCache } return false } func (x *Query) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *Query) GetDeadline() *timestamppb.Timestamp { if x != nil { return x.Deadline } return nil } type QueryResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to ResponseType: // // *QueryResponse_NewItem // *QueryResponse_Response // *QueryResponse_Error // *QueryResponse_Edge ResponseType isQueryResponse_ResponseType `protobuf_oneof:"response_type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *QueryResponse) Reset() { *x = QueryResponse{} mi := &file_items_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *QueryResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*QueryResponse) ProtoMessage() {} func (x *QueryResponse) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use QueryResponse.ProtoReflect.Descriptor instead. func (*QueryResponse) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{9} } func (x *QueryResponse) GetResponseType() isQueryResponse_ResponseType { if x != nil { return x.ResponseType } return nil } func (x *QueryResponse) GetNewItem() *Item { if x != nil { if x, ok := x.ResponseType.(*QueryResponse_NewItem); ok { return x.NewItem } } return nil } func (x *QueryResponse) GetResponse() *Response { if x != nil { if x, ok := x.ResponseType.(*QueryResponse_Response); ok { return x.Response } } return nil } func (x *QueryResponse) GetError() *QueryError { if x != nil { if x, ok := x.ResponseType.(*QueryResponse_Error); ok { return x.Error } } return nil } func (x *QueryResponse) GetEdge() *Edge { if x != nil { if x, ok := x.ResponseType.(*QueryResponse_Edge); ok { return x.Edge } } return nil } type isQueryResponse_ResponseType interface { isQueryResponse_ResponseType() } type QueryResponse_NewItem struct { NewItem *Item `protobuf:"bytes,2,opt,name=newItem,proto3,oneof"` // A new item that has been discovered } type QueryResponse_Response struct { Response *Response `protobuf:"bytes,3,opt,name=response,proto3,oneof"` // Status update } type QueryResponse_Error struct { Error *QueryError `protobuf:"bytes,4,opt,name=error,proto3,oneof"` // An error has been encountered } type QueryResponse_Edge struct { Edge *Edge `protobuf:"bytes,5,opt,name=edge,proto3,oneof"` // a link between items/queries } func (*QueryResponse_NewItem) isQueryResponse_ResponseType() {} func (*QueryResponse_Response) isQueryResponse_ResponseType() {} func (*QueryResponse_Error) isQueryResponse_ResponseType() {} func (*QueryResponse_Edge) isQueryResponse_ResponseType() {} // QueryStatus informs the client of status updates of all queries running in this session. type QueryStatus struct { state protoimpl.MessageState `protogen:"open.v1"` // UUID of the query UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` Status QueryStatus_Status `protobuf:"varint,2,opt,name=status,proto3,enum=QueryStatus_Status" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *QueryStatus) Reset() { *x = QueryStatus{} mi := &file_items_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *QueryStatus) String() string { return protoimpl.X.MessageStringOf(x) } func (*QueryStatus) ProtoMessage() {} func (x *QueryStatus) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use QueryStatus.ProtoReflect.Descriptor instead. func (*QueryStatus) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{10} } func (x *QueryStatus) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *QueryStatus) GetStatus() QueryStatus_Status { if x != nil { return x.Status } return QueryStatus_UNSPECIFIED } // QueryError is sent back when an item query fails type QueryError struct { state protoimpl.MessageState `protogen:"open.v1"` // UUID of the item query that this response is in relation to (in binary // format) UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` ErrorType QueryError_ErrorType `protobuf:"varint,2,opt,name=errorType,proto3,enum=QueryError_ErrorType" json:"errorType,omitempty"` // The string contents of the error ErrorString string `protobuf:"bytes,3,opt,name=errorString,proto3" json:"errorString,omitempty"` // The scope from which the error was raised Scope string `protobuf:"bytes,4,opt,name=scope,proto3" json:"scope,omitempty"` // The name of the source which raised the error (if relevant) SourceName string `protobuf:"bytes,5,opt,name=sourceName,proto3" json:"sourceName,omitempty"` // The type of item that we were looking for at the time of the error ItemType string `protobuf:"bytes,6,opt,name=itemType,proto3" json:"itemType,omitempty"` // The name of the responder that this error was raised from ResponderName string `protobuf:"bytes,7,opt,name=responderName,proto3" json:"responderName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *QueryError) Reset() { *x = QueryError{} mi := &file_items_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *QueryError) String() string { return protoimpl.X.MessageStringOf(x) } func (*QueryError) ProtoMessage() {} func (x *QueryError) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use QueryError.ProtoReflect.Descriptor instead. func (*QueryError) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{11} } func (x *QueryError) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *QueryError) GetErrorType() QueryError_ErrorType { if x != nil { return x.ErrorType } return QueryError_OTHER } func (x *QueryError) GetErrorString() string { if x != nil { return x.ErrorString } return "" } func (x *QueryError) GetScope() string { if x != nil { return x.Scope } return "" } func (x *QueryError) GetSourceName() string { if x != nil { return x.SourceName } return "" } func (x *QueryError) GetItemType() string { if x != nil { return x.ItemType } return "" } func (x *QueryError) GetResponderName() string { if x != nil { return x.ResponderName } return "" } // The message signals that the Query with the corresponding UUID should // be cancelled. Work should stop immediately, and a final response should be // sent with a state of CANCELLED to acknowledge that the query has ended due // to a cancellation type CancelQuery struct { state protoimpl.MessageState `protogen:"open.v1"` // UUID of the Query to cancel UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CancelQuery) Reset() { *x = CancelQuery{} mi := &file_items_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CancelQuery) String() string { return protoimpl.X.MessageStringOf(x) } func (*CancelQuery) ProtoMessage() {} func (x *CancelQuery) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CancelQuery.ProtoReflect.Descriptor instead. func (*CancelQuery) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{12} } func (x *CancelQuery) GetUUID() []byte { if x != nil { return x.UUID } return nil } // This requests that the gateway "expands" an item. This involves executing all // linked item queries within the session and sending the results to the // client. It is recommended that this be used rather than simply sending each // linked item request. Using this request type allows the Gateway to save the // session more intelligently so that it can be bookmarked and used later. // "Expanding" an item will mean an item always acts the same, even if its // linked item queries have changed type Expand struct { state protoimpl.MessageState `protogen:"open.v1"` // The item that should be expanded ItemRef *Reference `protobuf:"bytes,1,opt,name=itemRef,proto3" json:"itemRef,omitempty"` // How many levels of expansion should be run LinkDepth uint32 `protobuf:"varint,2,opt,name=linkDepth,proto3" json:"linkDepth,omitempty"` // A UUID to uniquely identify the request. This should be stored by the // requester as it will be needed later if the requester wants to cancel a // request. It should be stored as 128 bytes, as opposed to the textual // representation UUID []byte `protobuf:"bytes,3,opt,name=UUID,proto3" json:"UUID,omitempty"` // The time at which the gateway should stop processing the queries spawned by this request Deadline *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=deadline,proto3" json:"deadline,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Expand) Reset() { *x = Expand{} mi := &file_items_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Expand) String() string { return protoimpl.X.MessageStringOf(x) } func (*Expand) ProtoMessage() {} func (x *Expand) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Expand.ProtoReflect.Descriptor instead. func (*Expand) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{13} } func (x *Expand) GetItemRef() *Reference { if x != nil { return x.ItemRef } return nil } func (x *Expand) GetLinkDepth() uint32 { if x != nil { return x.LinkDepth } return 0 } func (x *Expand) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *Expand) GetDeadline() *timestamppb.Timestamp { if x != nil { return x.Deadline } return nil } // Reference to an item // // The uniqueness of an item is determined by the combination of: // // - Type // - UniqueAttributeValue // - Scope type Reference struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` UniqueAttributeValue string `protobuf:"bytes,2,opt,name=uniqueAttributeValue,proto3" json:"uniqueAttributeValue,omitempty"` Scope string `protobuf:"bytes,3,opt,name=scope,proto3" json:"scope,omitempty"` IsQuery bool `protobuf:"varint,4,opt,name=isQuery,proto3" json:"isQuery,omitempty"` Query string `protobuf:"bytes,5,opt,name=query,proto3" json:"query,omitempty"` Method QueryMethod `protobuf:"varint,6,opt,name=method,proto3,enum=QueryMethod" json:"method,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Reference) Reset() { *x = Reference{} mi := &file_items_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Reference) String() string { return protoimpl.X.MessageStringOf(x) } func (*Reference) ProtoMessage() {} func (x *Reference) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Reference.ProtoReflect.Descriptor instead. func (*Reference) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{14} } func (x *Reference) GetType() string { if x != nil { return x.Type } return "" } func (x *Reference) GetUniqueAttributeValue() string { if x != nil { return x.UniqueAttributeValue } return "" } func (x *Reference) GetScope() string { if x != nil { return x.Scope } return "" } func (x *Reference) GetIsQuery() bool { if x != nil { return x.IsQuery } return false } func (x *Reference) GetQuery() string { if x != nil { return x.Query } return "" } func (x *Reference) GetMethod() QueryMethod { if x != nil { return x.Method } return QueryMethod_GET } // Edge represents a link between two items. The `to` Reference can be a query // that will be unrolled by the gateway during query processing. Clients are // guaranteed that edges are only sent after the referenced items. type Edge struct { state protoimpl.MessageState `protogen:"open.v1"` From *Reference `protobuf:"bytes,1,opt,name=from,proto3" json:"from,omitempty"` To *Reference `protobuf:"bytes,2,opt,name=to,proto3" json:"to,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Edge) Reset() { *x = Edge{} mi := &file_items_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Edge) String() string { return protoimpl.X.MessageStringOf(x) } func (*Edge) ProtoMessage() {} func (x *Edge) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Edge.ProtoReflect.Descriptor instead. func (*Edge) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{15} } func (x *Edge) GetFrom() *Reference { if x != nil { return x.From } return nil } func (x *Edge) GetTo() *Reference { if x != nil { return x.To } return nil } // Defines how this query should behave when finding new items type Query_RecursionBehaviour struct { state protoimpl.MessageState `protogen:"open.v1"` // How deeply to link items. A value of 0 will mean that items are not linked. // To resolve linked items "infinitely" simply set this to a high number, with // the highest being 4,294,967,295. While this isn't truly *infinite*, chances // are that it is effectively the same, think six degrees of separation etc. LinkDepth uint32 `protobuf:"varint,1,opt,name=linkDepth,proto3" json:"linkDepth,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Query_RecursionBehaviour) Reset() { *x = Query_RecursionBehaviour{} mi := &file_items_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Query_RecursionBehaviour) String() string { return protoimpl.X.MessageStringOf(x) } func (*Query_RecursionBehaviour) ProtoMessage() {} func (x *Query_RecursionBehaviour) ProtoReflect() protoreflect.Message { mi := &file_items_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Query_RecursionBehaviour.ProtoReflect.Descriptor instead. func (*Query_RecursionBehaviour) Descriptor() ([]byte, []int) { return file_items_proto_rawDescGZIP(), []int{8, 0} } func (x *Query_RecursionBehaviour) GetLinkDepth() uint32 { if x != nil { return x.LinkDepth } return 0 } var File_items_proto protoreflect.FileDescriptor const file_items_proto_rawDesc = "" + "\n" + "\vitems.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x0fresponses.proto\"'\n" + "\x10BlastPropagationJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x02inR\x03out\"G\n" + "\x0fLinkedItemQuery\x12\x1c\n" + "\x05query\x18\x01 \x01(\v2\x06.QueryR\x05queryJ\x04\b\x02\x10\x03R\x10blastPropagation\"D\n" + "\n" + "LinkedItem\x12\x1e\n" + "\x04item\x18\x01 \x01(\v2\n" + ".ReferenceR\x04itemJ\x04\b\x02\x10\x03R\x10blastPropagation\"\xe3\x03\n" + "\x04Item\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12(\n" + "\x0funiqueAttribute\x18\x02 \x01(\tR\x0funiqueAttribute\x12/\n" + "\n" + "attributes\x18\x03 \x01(\v2\x0f.ItemAttributesR\n" + "attributes\x12%\n" + "\bmetadata\x18\x04 \x01(\v2\t.MetadataR\bmetadata\x12\x14\n" + "\x05scope\x18\x05 \x01(\tR\x05scope\x12>\n" + "\x11linkedItemQueries\x18\x10 \x03(\v2\x10.LinkedItemQueryR\x11linkedItemQueries\x12-\n" + "\vlinkedItems\x18\x11 \x03(\v2\v.LinkedItemR\vlinkedItems\x12$\n" + "\x06health\x18\x12 \x01(\x0e2\a.HealthH\x00R\x06health\x88\x01\x01\x12#\n" + "\x04tags\x18\x13 \x03(\v2\x0f.Item.TagsEntryR\x04tags\x121\n" + "\n" + "logStreams\x18\x14 \x03(\v2\x11.LogStreamDetailsR\n" + "logStreams\x1a7\n" + "\tTagsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\t\n" + "\a_health\"I\n" + "\x0eItemAttributes\x127\n" + "\n" + "attrStruct\x18\x01 \x01(\v2\x17.google.protobuf.StructR\n" + "attrStruct\"\xc2\x02\n" + "\bMetadata\x12\x1e\n" + "\n" + "sourceName\x18\x02 \x01(\tR\n" + "sourceName\x12(\n" + "\vsourceQuery\x18\x03 \x01(\v2\x06.QueryR\vsourceQuery\x128\n" + "\ttimestamp\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12E\n" + "\x0esourceDuration\x18\x05 \x01(\v2\x19.google.protobuf.DurationB\x02\x18\x01R\x0esourceDuration\x12S\n" + "\x15sourceDurationPerItem\x18\x06 \x01(\v2\x19.google.protobuf.DurationB\x02\x18\x01R\x15sourceDurationPerItem\x12\x16\n" + "\x06hidden\x18\a \x01(\bR\x06hidden\"$\n" + "\x05Items\x12\x1b\n" + "\x05items\x18\x01 \x03(\v2\x05.ItemR\x05items\"R\n" + "\x10LogStreamDetails\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05scope\x18\x02 \x01(\tR\x05scope\x12\x14\n" + "\x05query\x18\x03 \x01(\tR\x05query\"\x82\x03\n" + "\x05Query\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12$\n" + "\x06method\x18\x02 \x01(\x0e2\f.QueryMethodR\x06method\x12\x14\n" + "\x05query\x18\x03 \x01(\tR\x05query\x12I\n" + "\x12recursionBehaviour\x18\x04 \x01(\v2\x19.Query.RecursionBehaviourR\x12recursionBehaviour\x12\x14\n" + "\x05scope\x18\x05 \x01(\tR\x05scope\x12 \n" + "\vignoreCache\x18\x06 \x01(\bR\vignoreCache\x12\x12\n" + "\x04UUID\x18\a \x01(\fR\x04UUID\x126\n" + "\bdeadline\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\bdeadline\x1aT\n" + "\x12RecursionBehaviour\x12\x1c\n" + "\tlinkDepth\x18\x01 \x01(\rR\tlinkDepthJ\x04\b\x02\x10\x03R\x1afollowOnlyBlastPropagationJ\x04\b\b\x10\t\"\xae\x01\n" + "\rQueryResponse\x12!\n" + "\anewItem\x18\x02 \x01(\v2\x05.ItemH\x00R\anewItem\x12'\n" + "\bresponse\x18\x03 \x01(\v2\t.ResponseH\x00R\bresponse\x12#\n" + "\x05error\x18\x04 \x01(\v2\v.QueryErrorH\x00R\x05error\x12\x1b\n" + "\x04edge\x18\x05 \x01(\v2\x05.EdgeH\x00R\x04edgeB\x0f\n" + "\rresponse_type\"\xa6\x01\n" + "\vQueryStatus\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12+\n" + "\x06status\x18\x02 \x01(\x0e2\x13.QueryStatus.StatusR\x06status\"V\n" + "\x06Status\x12\x0f\n" + "\vUNSPECIFIED\x10\x00\x12\v\n" + "\aSTARTED\x10\x01\x12\r\n" + "\tCANCELLED\x10\x03\x12\v\n" + "\aERRORED\x10\x04\x12\f\n" + "\bFINISHED\x10\x05\"\x04\b\x02\x10\x02\"\xaf\x02\n" + "\n" + "QueryError\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x123\n" + "\terrorType\x18\x02 \x01(\x0e2\x15.QueryError.ErrorTypeR\terrorType\x12 \n" + "\verrorString\x18\x03 \x01(\tR\verrorString\x12\x14\n" + "\x05scope\x18\x04 \x01(\tR\x05scope\x12\x1e\n" + "\n" + "sourceName\x18\x05 \x01(\tR\n" + "sourceName\x12\x1a\n" + "\bitemType\x18\x06 \x01(\tR\bitemType\x12$\n" + "\rresponderName\x18\a \x01(\tR\rresponderName\">\n" + "\tErrorType\x12\t\n" + "\x05OTHER\x10\x00\x12\f\n" + "\bNOTFOUND\x10\x01\x12\v\n" + "\aNOSCOPE\x10\x02\x12\v\n" + "\aTIMEOUT\x10\x03\"!\n" + "\vCancelQuery\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"\x98\x01\n" + "\x06Expand\x12$\n" + "\aitemRef\x18\x01 \x01(\v2\n" + ".ReferenceR\aitemRef\x12\x1c\n" + "\tlinkDepth\x18\x02 \x01(\rR\tlinkDepth\x12\x12\n" + "\x04UUID\x18\x03 \x01(\fR\x04UUID\x126\n" + "\bdeadline\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\bdeadline\"\xbf\x01\n" + "\tReference\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x122\n" + "\x14uniqueAttributeValue\x18\x02 \x01(\tR\x14uniqueAttributeValue\x12\x14\n" + "\x05scope\x18\x03 \x01(\tR\x05scope\x12\x18\n" + "\aisQuery\x18\x04 \x01(\bR\aisQuery\x12\x14\n" + "\x05query\x18\x05 \x01(\tR\x05query\x12$\n" + "\x06method\x18\x06 \x01(\x0e2\f.QueryMethodR\x06method\"Z\n" + "\x04Edge\x12\x1e\n" + "\x04from\x18\x01 \x01(\v2\n" + ".ReferenceR\x04from\x12\x1a\n" + "\x02to\x18\x02 \x01(\v2\n" + ".ReferenceR\x02toJ\x04\b\x03\x10\x04R\x10blastPropagation*e\n" + "\x06Health\x12\x12\n" + "\x0eHEALTH_UNKNOWN\x10\x00\x12\r\n" + "\tHEALTH_OK\x10\x01\x12\x12\n" + "\x0eHEALTH_WARNING\x10\x02\x12\x10\n" + "\fHEALTH_ERROR\x10\x03\x12\x12\n" + "\x0eHEALTH_PENDING\x10\x04*,\n" + "\vQueryMethod\x12\a\n" + "\x03GET\x10\x00\x12\b\n" + "\x04LIST\x10\x01\x12\n" + "\n" + "\x06SEARCH\x10\x02B1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_items_proto_rawDescOnce sync.Once file_items_proto_rawDescData []byte ) func file_items_proto_rawDescGZIP() []byte { file_items_proto_rawDescOnce.Do(func() { file_items_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_items_proto_rawDesc), len(file_items_proto_rawDesc))) }) return file_items_proto_rawDescData } var file_items_proto_enumTypes = make([]protoimpl.EnumInfo, 4) var file_items_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_items_proto_goTypes = []any{ (Health)(0), // 0: Health (QueryMethod)(0), // 1: QueryMethod (QueryStatus_Status)(0), // 2: QueryStatus.Status (QueryError_ErrorType)(0), // 3: QueryError.ErrorType (*BlastPropagation)(nil), // 4: BlastPropagation (*LinkedItemQuery)(nil), // 5: LinkedItemQuery (*LinkedItem)(nil), // 6: LinkedItem (*Item)(nil), // 7: Item (*ItemAttributes)(nil), // 8: ItemAttributes (*Metadata)(nil), // 9: Metadata (*Items)(nil), // 10: Items (*LogStreamDetails)(nil), // 11: LogStreamDetails (*Query)(nil), // 12: Query (*QueryResponse)(nil), // 13: QueryResponse (*QueryStatus)(nil), // 14: QueryStatus (*QueryError)(nil), // 15: QueryError (*CancelQuery)(nil), // 16: CancelQuery (*Expand)(nil), // 17: Expand (*Reference)(nil), // 18: Reference (*Edge)(nil), // 19: Edge nil, // 20: Item.TagsEntry (*Query_RecursionBehaviour)(nil), // 21: Query.RecursionBehaviour (*structpb.Struct)(nil), // 22: google.protobuf.Struct (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp (*durationpb.Duration)(nil), // 24: google.protobuf.Duration (*Response)(nil), // 25: Response } var file_items_proto_depIdxs = []int32{ 12, // 0: LinkedItemQuery.query:type_name -> Query 18, // 1: LinkedItem.item:type_name -> Reference 8, // 2: Item.attributes:type_name -> ItemAttributes 9, // 3: Item.metadata:type_name -> Metadata 5, // 4: Item.linkedItemQueries:type_name -> LinkedItemQuery 6, // 5: Item.linkedItems:type_name -> LinkedItem 0, // 6: Item.health:type_name -> Health 20, // 7: Item.tags:type_name -> Item.TagsEntry 11, // 8: Item.logStreams:type_name -> LogStreamDetails 22, // 9: ItemAttributes.attrStruct:type_name -> google.protobuf.Struct 12, // 10: Metadata.sourceQuery:type_name -> Query 23, // 11: Metadata.timestamp:type_name -> google.protobuf.Timestamp 24, // 12: Metadata.sourceDuration:type_name -> google.protobuf.Duration 24, // 13: Metadata.sourceDurationPerItem:type_name -> google.protobuf.Duration 7, // 14: Items.items:type_name -> Item 1, // 15: Query.method:type_name -> QueryMethod 21, // 16: Query.recursionBehaviour:type_name -> Query.RecursionBehaviour 23, // 17: Query.deadline:type_name -> google.protobuf.Timestamp 7, // 18: QueryResponse.newItem:type_name -> Item 25, // 19: QueryResponse.response:type_name -> Response 15, // 20: QueryResponse.error:type_name -> QueryError 19, // 21: QueryResponse.edge:type_name -> Edge 2, // 22: QueryStatus.status:type_name -> QueryStatus.Status 3, // 23: QueryError.errorType:type_name -> QueryError.ErrorType 18, // 24: Expand.itemRef:type_name -> Reference 23, // 25: Expand.deadline:type_name -> google.protobuf.Timestamp 1, // 26: Reference.method:type_name -> QueryMethod 18, // 27: Edge.from:type_name -> Reference 18, // 28: Edge.to:type_name -> Reference 29, // [29:29] is the sub-list for method output_type 29, // [29:29] is the sub-list for method input_type 29, // [29:29] is the sub-list for extension type_name 29, // [29:29] is the sub-list for extension extendee 0, // [0:29] is the sub-list for field type_name } func init() { file_items_proto_init() } func file_items_proto_init() { if File_items_proto != nil { return } file_responses_proto_init() file_items_proto_msgTypes[3].OneofWrappers = []any{} file_items_proto_msgTypes[9].OneofWrappers = []any{ (*QueryResponse_NewItem)(nil), (*QueryResponse_Response)(nil), (*QueryResponse_Error)(nil), (*QueryResponse_Edge)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_items_proto_rawDesc), len(file_items_proto_rawDesc)), NumEnums: 4, NumMessages: 18, NumExtensions: 0, NumServices: 0, }, GoTypes: file_items_proto_goTypes, DependencyIndexes: file_items_proto_depIdxs, EnumInfos: file_items_proto_enumTypes, MessageInfos: file_items_proto_msgTypes, }.Build() File_items_proto = out.File file_items_proto_goTypes = nil file_items_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/items_test.go ================================================ package sdp import ( "bytes" "context" "encoding/json" "reflect" "testing" "time" "github.com/google/uuid" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" ) type ToAttributesTest struct { Name string Input map[string]any } type CustomString string var Dylan CustomString = "Dylan" type CustomBool bool var Bool1 CustomBool = false var NilPointerBool *bool type CustomStruct struct { Foo string `json:",omitempty"` Bar string `json:",omitempty"` Baz string `json:",omitempty"` Time time.Time Duration time.Duration `json:",omitempty"` } var ToAttributesTests = []ToAttributesTest{ { Name: "Basic strings map", Input: map[string]any{ "firstName": "Dylan", "lastName": "Ratcliffe", }, }, { Name: "Arrays map", Input: map[string]any{ "empty": []string{}, "single-level": []string{ "one", "two", }, "multi-level": [][]string{ { "one-one", "one-two", }, { "two-one", "two-two", }, }, }, }, { Name: "Nested strings maps", Input: map[string]any{ "strings map": map[string]string{ "foo": "bar", }, }, }, { Name: "Nested integer map", Input: map[string]any{ "numbers map": map[string]int{ "one": 1, "two": 2, }, }, }, { Name: "Nested string-array map", Input: map[string]any{ "arrays map": map[string][]string{ "dogs": { "pug", "also pug", }, }, }, }, { Name: "Nested non-string keys map", Input: map[string]any{ "non-string keys": map[int]string{ 1: "one", 2: "two", 3: "three", }, }, }, { Name: "Composite types", Input: map[string]any{ "underlying string": Dylan, "underlying bool": Bool1, }, }, { Name: "Pointers", Input: map[string]any{ "pointer bool": &Bool1, "pointer string": &Dylan, }, }, { Name: "structs", Input: map[string]any{ "named struct": CustomStruct{ Foo: "foo", Bar: "bar", Baz: "baz", Time: time.Now(), }, "anon struct": struct { Yes bool }{ Yes: true, }, }, }, { Name: "Zero-value structs", Input: map[string]any{ "something": CustomStruct{ Foo: "yes", Time: time.Now(), }, }, }, } func TestToAttributes(t *testing.T) { for _, tat := range ToAttributesTests { t.Run(tat.Name, func(t *testing.T) { var inputBytes []byte var attributesBytes []byte var inputJSON string var attributesJSON string var attributes *ItemAttributes var err error // Convert the input to Attributes attributes, err = ToAttributes(tat.Input) if err != nil { t.Fatal(err) } // In order to compare these reliably I'm going to do the following: // // 1. Convert to JSON // 2. Convert back again // 3. Compare with reflect.DeepEqual() // Convert the input to JSON inputBytes, err = json.MarshalIndent(tat.Input, "", " ") if err != nil { t.Fatal(err) } // Convert the attributes to JSON attributesBytes, err = json.MarshalIndent(attributes.GetAttrStruct().AsMap(), "", " ") if err != nil { t.Fatal(err) } var input map[string]any var output map[string]any err = json.Unmarshal(inputBytes, &input) if err != nil { t.Fatal(err) } err = json.Unmarshal(attributesBytes, &output) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(input, output) { // Convert to strings for printing inputJSON = string(inputBytes) attributesJSON = string(attributesBytes) t.Errorf("JSON did not match (note that order of map keys doesn't matter)\nInput: %v\nAttributes: %v", inputJSON, attributesJSON) } }) } } func TestDefaultTransformMap(t *testing.T) { input := map[string]any{ // Use a duration "hour": 1 * time.Hour, } attributes, err := ToAttributes(input) if err != nil { t.Fatal(err) } hour, err := attributes.Get("hour") if err != nil { t.Fatal(err) } if hour != "1h0m0s" { t.Errorf("Expected hour to be 1h0m0s, got %v", hour) } } func TestCustomTransforms(t *testing.T) { t.Run("redaction", func(t *testing.T) { type Secret struct { Value string } data := map[string]any{ "user": map[string]any{ "name": "Hunter", "password": Secret{ Value: "hunter2", }, }, } attributes, err := ToAttributesCustom(data, true, TransformMap{ reflect.TypeFor[Secret](): func(i any) any { // Remove it return "REDACTED" }, }) if err != nil { t.Fatal(err) } user, err := attributes.Get("user") if err != nil { t.Fatal(err) } userMap, ok := user.(map[string]any) if !ok { t.Fatalf("Expected user to be a map, got %T", user) } pass := userMap["password"] if pass != "REDACTED" { t.Errorf("Expected password to be REDACTED, got %v", pass) } }) t.Run("map response", func(t *testing.T) { type Something struct { Foo string Bar string } data := map[string]any{ "something": Something{ Foo: "foo", Bar: "bar", }, } attributes, err := ToAttributesCustom(data, true, TransformMap{ reflect.TypeFor[Something](): func(i any) any { something := i.(Something) return map[string]string{ "foo": something.Foo, "bar": something.Bar, } }, }) if err != nil { t.Fatal(err) } something, err := attributes.Get("something") if err != nil { t.Fatal(err) } somethingMap, ok := something.(map[string]any) if !ok { t.Fatalf("Expected something to be a map, got %T", something) } if somethingMap["foo"] != "foo" { t.Errorf("Expected foo to be foo, got %v", somethingMap["foo"]) } if somethingMap["bar"] != "bar" { t.Errorf("Expected bar to be bar, got %v", somethingMap["bar"]) } }) t.Run("returns nil", func(t *testing.T) { type Something struct { Foo string Bar string } data := map[string]any{ "something": Something{ Foo: "foo", Bar: "bar", }, "else": nil, } _, err := ToAttributesCustom(data, true, TransformMap{ reflect.TypeFor[Something](): func(i any) any { return nil }, }) if err != nil { t.Fatal(err) } }) } func TestCopy(t *testing.T) { exampleAttributes, err := ToAttributes(map[string]any{ "name": "Dylan", "friend": "Mike", "age": 27, }) if err != nil { t.Fatalf("Could not convert to attributes: %v", err) } t.Run("With a complete item", func(t *testing.T) { u := uuid.New() itemA := Item{ Type: "user", UniqueAttribute: "name", Scope: "test", Attributes: exampleAttributes, // TODO(LIQs): delete this; it's not part of `(*sdp.Item).Copy()` anymore LinkedItemQueries: []*LinkedItemQuery{ { Query: &Query{ Type: "user", Method: QueryMethod_GET, Query: "Mike", }, }, }, // TODO(LIQs): delete this; it's not part of `(*sdp.Item).Copy()` anymore LinkedItems: []*LinkedItem{}, Metadata: &Metadata{ SourceName: "test", SourceQuery: &Query{ Type: "user", Method: QueryMethod_GET, Query: "Dylan", Scope: "testScope", UUID: u[:], }, Timestamp: timestamppb.Now(), SourceDuration: durationpb.New(100 * time.Millisecond), SourceDurationPerItem: durationpb.New(10 * time.Millisecond), }, Health: Health_HEALTH_ERROR.Enum(), Tags: map[string]string{ "foo": "bar", }, } t.Run("Copying an item", func(t *testing.T) { itemB := proto.Clone(&itemA).(*Item) AssertItemsEqual(&itemA, itemB, t) }) }) t.Run("With a party-filled item", func(t *testing.T) { itemA := Item{ Type: "user", UniqueAttribute: "name", Scope: "test", Attributes: exampleAttributes, // TODO(LIQs): delete this; it's not part of `(*sdp.Item).Copy()` anymore LinkedItemQueries: []*LinkedItemQuery{ { Query: &Query{ Type: "user", Method: QueryMethod_GET, Query: "Mike", }, }, }, // TODO(LIQs): delete this; it's not part of `(*sdp.Item).Copy()` anymore LinkedItems: []*LinkedItem{}, Metadata: &Metadata{ Hidden: true, SourceName: "test", Timestamp: timestamppb.Now(), SourceDuration: durationpb.New(100 * time.Millisecond), SourceDurationPerItem: durationpb.New(10 * time.Millisecond), }, } t.Run("Copying an item", func(t *testing.T) { itemB := proto.Clone(&itemA).(*Item) AssertItemsEqual(&itemA, itemB, t) }) }) t.Run("With a minimal item", func(t *testing.T) { itemA := Item{ Type: "user", UniqueAttribute: "name", Scope: "test", Attributes: exampleAttributes, // TODO(LIQs): delete this; it's not part of `(*sdp.Item).Copy()` anymore LinkedItemQueries: []*LinkedItemQuery{}, LinkedItems: []*LinkedItem{}, } t.Run("Copying an item", func(t *testing.T) { itemB := proto.Clone(&itemA).(*Item) AssertItemsEqual(&itemA, itemB, t) }) }) } func AssertItemsEqual(itemA *Item, itemB *Item, t *testing.T) { if itemA.GetScope() != itemB.GetScope() { t.Error("Scope did not match") } if itemA.GetType() != itemB.GetType() { t.Error("Type did not match") } if itemA.GetUniqueAttribute() != itemB.GetUniqueAttribute() { t.Error("UniqueAttribute did not match") } var nameA any var nameB any var err error nameA, err = itemA.GetAttributes().Get("name") if err != nil { t.Error(err) } nameB, err = itemB.GetAttributes().Get("name") if err != nil { t.Error(err) } if nameA != nameB { t.Error("Attributes.nam did not match") } // TODO(LIQs): delete this; it's not part of `(*sdp.Item).Copy()` anymore if len(itemA.GetLinkedItemQueries()) != len(itemB.GetLinkedItemQueries()) { t.Error("LinkedItemQueries length did not match") } if len(itemA.GetLinkedItemQueries()) > 0 { if itemA.GetLinkedItemQueries()[0].GetQuery().GetType() != itemB.GetLinkedItemQueries()[0].GetQuery().GetType() { t.Error("LinkedItemQueries[0].Type did not match") } } // TODO(LIQs): delete this; it's not part of `(*sdp.Item).Copy()` anymore if len(itemA.GetLinkedItems()) != len(itemB.GetLinkedItems()) { t.Error("LinkedItems length did not match") } if len(itemA.GetLinkedItems()) > 0 { if itemA.GetLinkedItems()[0].GetItem().GetType() != itemB.GetLinkedItems()[0].GetItem().GetType() { t.Error("LinkedItemQueries[0].Type did not match") } } for k, v := range itemA.GetTags() { if itemB.GetTags()[k] != v { t.Errorf("Tags[%v] did not match", k) } } if itemA.Health == nil { if itemB.Health != nil { t.Errorf("mismatched health nil and %v", itemB.GetHealth()) } } else { if itemB.Health == nil { t.Errorf("mismatched health %v and nil", itemA.GetHealth()) } else { if itemA.GetHealth() != itemB.GetHealth() { t.Errorf("mismatched health %v and %v", itemA.GetHealth(), itemB.GetHealth()) } } } if itemA.GetMetadata() != nil { if itemA.GetMetadata().GetSourceDuration().String() != itemB.GetMetadata().GetSourceDuration().String() { t.Error("SourceDuration did not match") } if itemA.GetMetadata().GetSourceDurationPerItem().String() != itemB.GetMetadata().GetSourceDurationPerItem().String() { t.Error("SourceDurationPerItem did not match") } if itemA.GetMetadata().GetSourceName() != itemB.GetMetadata().GetSourceName() { t.Error("SourceName did not match") } if itemA.GetMetadata().GetTimestamp().String() != itemB.GetMetadata().GetTimestamp().String() { t.Error("Timestamp did not match") } if itemA.GetMetadata().GetHidden() != itemB.GetMetadata().GetHidden() { t.Error("Metadata.Hidden does not match") } if itemA.GetMetadata().GetSourceQuery() != nil { if itemA.GetMetadata().GetSourceQuery().GetScope() != itemB.GetMetadata().GetSourceQuery().GetScope() { t.Error("Metadata.SourceQuery.Scope does not match") } if itemA.GetMetadata().GetSourceQuery().GetMethod() != itemB.GetMetadata().GetSourceQuery().GetMethod() { t.Error("Metadata.SourceQuery.Method does not match") } if itemA.GetMetadata().GetSourceQuery().GetQuery() != itemB.GetMetadata().GetSourceQuery().GetQuery() { t.Error("Metadata.SourceQuery.Query does not match") } if itemA.GetMetadata().GetSourceQuery().GetType() != itemB.GetMetadata().GetSourceQuery().GetType() { t.Error("Metadata.SourceQuery.Type does not match") } if !bytes.Equal(itemA.GetMetadata().GetSourceQuery().GetUUID(), itemB.GetMetadata().GetSourceQuery().GetUUID()) { t.Error("Metadata.SourceQuery.UUID does not match") } } } } func TestTimeoutContext(t *testing.T) { q := Query{ Type: "person", Method: QueryMethod_GET, Query: "foo", RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 2, }, IgnoreCache: false, Deadline: timestamppb.New(time.Now().Add(10 * time.Millisecond)), } ctx, cancel := q.TimeoutContext(context.Background()) defer cancel() select { case <-time.After(20 * time.Millisecond): t.Error("Context did not time out after 10ms") case <-ctx.Done(): // This is good } } func TestToAttributesViaJson(t *testing.T) { // Create a random struct test1 := struct { Foo string Bar bool Blip []string Baz struct { Zap string Bam int } }{ Foo: "foo", Bar: false, Blip: []string{ "yes", "I", "blip", }, Baz: struct { Zap string Bam int }{ Zap: "negative", Bam: 42, }, } attributes, err := ToAttributesViaJson(test1) if err != nil { t.Fatal(err) } if foo, err := attributes.Get("Foo"); err != nil || foo != "foo" { t.Errorf("Expected Foo to be 'foo', got %v, err: %v", foo, err) } } func TestAttributesGet(t *testing.T) { mapData := map[string]any{ "foo": "bar", "nest": map[string]any{ "nest2": map[string]string{ "nest3": "nestValue", }, }, } attr, err := ToAttributes(mapData) if err != nil { t.Fatal(err) } if v, err := attr.Get("foo"); err != nil || v != "bar" { t.Errorf("expected Get(\"foo\") to be bar, got %v", v) } if v, err := attr.Get("nest.nest2.nest3"); err != nil || v != "nestValue" { t.Errorf("expected Get(\"nest.nest2.nest3\") to be nestValue, got %v", v) } } func TestAttributesSet(t *testing.T) { mapData := map[string]any{ "foo": "bar", "nest": map[string]any{ "nest2": map[string]string{ "nest3": "nestValue", }, }, } attr, err := ToAttributes(mapData) if err != nil { t.Fatal(err) } err = attr.Set("foo", "baz") if err != nil { t.Error(err) } if v, err := attr.Get("foo"); err != nil || v != "baz" { t.Errorf("expected Get(\"foo\") to be baz, got %v", v) } } ================================================ FILE: go/sdp-go/link_extract.go ================================================ package sdp import ( "net" "net/url" "regexp" "strings" "google.golang.org/protobuf/types/known/structpb" ) // This function tries to extract linked item queries from the attributes of an // item. It should be on items that we know are likely to contain references // that we can discover, but are in an unstructured format which we can't // construct the linked item queries from directly. A good example of this would // be the env vars for a kubernetes pod, or a config map // // This supports extracting the following formats: // // - IP addresses // - HTTP/HTTPS URLs // - DNS names func ExtractLinksFromAttributes(attributes *ItemAttributes) []*LinkedItemQuery { return extractLinksFromStructValue(attributes.GetAttrStruct()) } // The same as `ExtractLinksFromAttributes`, but takes any input format and // converts it to a set of ItemAttributes via the `ToAttributes` function. This // uses reflection. `ExtractLinksFromAttributes` is more efficient if you have // the attributes already in the correct format. func ExtractLinksFrom(anything any) ([]*LinkedItemQuery, error) { attributes, err := ToAttributes(map[string]any{ "": anything, }) if err != nil { return nil, err } return ExtractLinksFromAttributes(attributes), nil } func extractLinksFromValue(value *structpb.Value) []*LinkedItemQuery { switch value.GetKind().(type) { case *structpb.Value_NullValue: return nil case *structpb.Value_NumberValue: return nil case *structpb.Value_StringValue: return extractLinksFromStringValue(value.GetStringValue()) case *structpb.Value_BoolValue: return nil case *structpb.Value_StructValue: return extractLinksFromStructValue(value.GetStructValue()) case *structpb.Value_ListValue: return extractLinksFromListValue(value.GetListValue()) } return nil } func extractLinksFromStructValue(structValue *structpb.Struct) []*LinkedItemQuery { queries := make([]*LinkedItemQuery, 0) for _, value := range structValue.GetFields() { queries = append(queries, extractLinksFromValue(value)...) } return queries } func extractLinksFromListValue(list *structpb.ListValue) []*LinkedItemQuery { queries := make([]*LinkedItemQuery, 0) for _, value := range list.GetValues() { queries = append(queries, extractLinksFromValue(value)...) } return queries } // A regex that matches the ARN format and extracts the service, region, account // id and resource. Uses a capture group for the full resource portion after // the account-id (which may include slashes for resource types). var awsARNRegex = regexp.MustCompile(`^arn:[\w-]+:([\w-]+):([\w-]*):([\w-]*):(.+)`) // This function does all the heavy lifting for extracting linked item queries // from strings. It will be called once for every string value in the item so // needs to be very performant func extractLinksFromStringValue(val string) []*LinkedItemQuery { if ip := net.ParseIP(val); ip != nil { return []*LinkedItemQuery{ { Query: &Query{ Type: "ip", Method: QueryMethod_GET, Query: ip.String(), Scope: "global", }, }, } } // This is pretty overzealous when it comes to what it considers a URL, so // we need ot do out own validation to make sure that it has actually found // what we expected if parsed, err := url.Parse(val); err == nil && parsed.Scheme != "" && parsed.Host != "" { // If it's a HTTP/HTTPS URL, we can use a HTTP query if parsed.Scheme == "http" || parsed.Scheme == "https" { return []*LinkedItemQuery{ { Query: &Query{ Type: "http", Method: QueryMethod_SEARCH, Query: val, Scope: "global", }, }, } } // If it's not a HTTP/HTTPS URL, it'll be an IP or DNS name, so pass // back to the main function return extractLinksFromStringValue(parsed.Hostname()) } if isLikelyDNSName(val) { return []*LinkedItemQuery{ { Query: &Query{ Type: "dns", Method: QueryMethod_SEARCH, Query: val, Scope: "global", }, }, } } // ARNs can't be shorter than 12 characters if len(val) >= 12 { if matches := awsARNRegex.FindStringSubmatch(val); matches != nil { // If it looks like an ARN then we can construct a SEARCH query to try // and find it. We can rely on the conventions in the AWS source here // Basic validation if len(matches) != 5 || matches[1] == "" { return nil } // Parsed ARN parts service := matches[1] // e.g. "ec2", "iam", "s3" region := matches[2] // may be empty for global services (iam, cloudfront) accountID := matches[3] // may be empty (e.g. s3, route53) resource := matches[4] // full resource segment (may contain ":" or "/") // Extract resource type from the resource field (everything before first "/" or ":" if present) resourceType := resource if idx := strings.IndexAny(resource, "/:"); idx != -1 { resourceType = resource[:idx] } // Determine scope using a simple rule: // - No account → wildcard scope // - Account, no region → account-only // - Account and region → account.region var scope string if accountID == "" { scope = WILDCARD } else if region == "" { scope = accountID } else { scope = accountID + "." + region } // Determine type using a consistent rule. Default to service-resourceType if available. queryType := service if resourceType != "" { queryType = service + "-" + resourceType } // Special-case S3 ARNs that omit account and region → treat as bucket references if service == "s3" && accountID == "" && region == "" { queryType = "s3-bucket" // If this is an S3 object ARN (contains /), extract just the bucket if strings.Contains(resource, "/") { bucketName := strings.SplitN(resource, "/", 2)[0] // Construct a bucket-only ARN for the query val = "arn:aws:s3:::" + bucketName } } return []*LinkedItemQuery{ { Query: &Query{ Type: queryType, Method: QueryMethod_SEARCH, Query: val, Scope: scope, }, }, } } } return nil } // Compile a regex pattern to match the general structure of a DNS name. Limits // each label to 1-63 characters and matches only allowed characters and ensure // that the name has at least three sections i.e. two dots. var dnsNameRegex = regexp.MustCompile(`^(?i)([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.){2,}[a-z]{2,}$`) // This function returns true if the given string is a valid DNS name with at // least three labels (sections) func isLikelyDNSName(name string) bool { // Quick length check before the regex. The less than 6 is because we're // only matching names that have three sections or more, and the shortest // three section name is a.b.cd (6 characters, there are no single letter // top-level domains) if len(name) < 6 || len(name) > 253 { return false } // Check if the name matches the regex pattern. return dnsNameRegex.MatchString(name) } ================================================ FILE: go/sdp-go/link_extract_test.go ================================================ package sdp import ( "testing" "go.yaml.in/yaml/v3" ) // Create a very large set of attributes for the benchmark func createTestData() (*ItemAttributes, any) { yamlString := `--- creationTimestamp: 2024-07-09T11:16:31Z data: AUTH0_AUDIENCE: https://api.example.com AUTH0_DOMAIN: domain.eu.auth0.com AUTH_COOKIE_NAME: overmind_app_access_token GATEWAY_CLIENT_ID: 1234567890 GATEWAY_CORS_ALLOW_ORIGINS: https://app.example.com https://*.app.example.com GATEWAY_OVERMIND_AUTH_URL: https://domain.eu.auth0.com/oauth/token GATEWAY_OVERMIND_TOKEN_API: http://service:8080/api GATEWAY_PGDBNAME: user GATEWAY_PGHOST: name.cluster-id.eu-west-2.rds.amazonaws.com GATEWAY_PGPORT: "5432" GATEWAY_PGUSER: user GATEWAY_RUN_MODE: release GATEWAY_SERVICE_PORT: "8080" LOG: info immutable: false name: foo-config namespace: default resourceVersion: "167230088" uid: c1c1be5e-e11e-46da-8ef4-ce243fe7056e generateName: 49731160-e407-4148-bd4d-e00b8eb56cd2-5b76f5987b- labels: app: test config-hash: 2be88ca42 pod-template-hash: 5b76f5987b source: 49731160-e407-4148-bd4d-e00b8eb56cd2 spec: containers: - env: - name: NATS_SERVICE_HOST value: fdb4:5627:96ee::bfa3 - name: NATS_SERVICE_PORT value: "4222" - name: NATS_NAME_PREFIX value: source.default - name: SERVICE_PORT value: "8080" - name: NATS_JWT valueFrom: secretKeyRef: key: jwt name: 49731160-e407-4148-bd4d-e00b8eb56cd2-nats-auth - name: NATS_NKEY_SEED valueFrom: secretKeyRef: key: nkeySeed name: 49731160-e407-4148-bd4d-e00b8eb56cd2-nats-auth - name: NATS_CA_FILE value: /etc/srcman/certs/ca.pem - name: S3_BUCKET_ARN value: arn:aws:s3:::example-bucket-name - name: S3_OBJECT_ARN value: arn:aws:s3:::my-test-bucket/data/key - name: IAM_ROLE_ARN value: arn:aws:iam::123456789012:role/example-role - name: CLOUDFRONT_ARN value: arn:aws:cloudfront::123456789012:distribution/EDFDVBDEXAMPLE envFrom: - secretRef: name: prod-tracing-secrets image: ghcr.io/example/example:main imagePullPolicy: Always name: 49731160-e407-4148-bd4d-e00b8eb56cd2 readinessProbe: failureThreshold: 3 httpGet: path: healthz port: 8080 scheme: HTTP periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 resources: {} terminationMessagePath: /dev/termination-log terminationMessagePolicy: File volumeMounts: - mountPath: /etc/srcman/config name: source-config readOnly: true - mountPath: /etc/srcman/certs name: nats-certs readOnly: true - mountPath: /var/run/secrets/kubernetes.io/serviceaccount name: kube-api-access-vjgp7 readOnly: true dnsPolicy: ClusterFirst enableServiceLinks: true nodeName: ip-10-0-4-118.eu-west-2.compute.internal preemptionPolicy: PreemptLowerPriority priority: 0 restartPolicy: Always schedulerName: default-scheduler securityContext: {} serviceAccount: default serviceAccountName: default terminationGracePeriodSeconds: 30 tolerations: - effect: NoExecute key: node.kubernetes.io/not-ready operator: Exists tolerationSeconds: 300 - effect: NoExecute key: node.kubernetes.io/unreachable operator: Exists tolerationSeconds: 300 volumes: - configMap: defaultMode: 420 name: 49731160-e407-4148-bd4d-e00b8eb56cd2 name: source-config - configMap: defaultMode: 420 name: prod-ca name: nats-certs - name: kube-api-access-vjgp7 projected: defaultMode: 420 sources: - serviceAccountToken: expirationSeconds: 3607 path: token - configMap: items: - key: ca.crt path: ca.crt name: kube-root-ca.crt - downwardAPI: items: - fieldRef: apiVersion: v1 fieldPath: metadata.namespace path: namespace status: conditions: - lastTransitionTime: 2024-08-22T13:42:26Z status: "True" type: Initialized - lastTransitionTime: 2024-08-22T13:43:17Z status: "True" type: Ready - lastTransitionTime: 2024-08-22T13:43:17Z status: "True" type: ContainersReady - lastTransitionTime: 2024-08-22T13:42:26Z status: "True" type: PodScheduled containerStatuses: - containerID: containerd://6274579a84ea3bee8cb9bd68092f4ccd6fff13852c1e5c09672c8b3489f3c082 image: ghcr.io/example/example:main imageID: ghcr.io/example/example@sha256:c3fd0767e82105e9127267bda3bdb77f51a9e6fbeb79d20c4d25ae0a71876719 lastState: {} name: 49731160-e407-4148-bd4d-e00b8eb56cd2 ready: true restartCount: 0 started: true state: running: startedAt: 2024-08-22T13:42:32Z hostIP: 2a05:d01c:40:7600::6c81 phase: Running podIP: 2a05:d01c:40:7600:fbac::4 podIPs: - ip: 2a05:d01c:40:7600:fbac::3 qosClass: BestEffort startTime: 2024-08-22T13:42:26Z code: location: https://awslambda-eu-west-2-tasks.s3.eu-west-2.amazonaws.com/snapshots/123456789/ingress_log-dd9f17f1-0694-4e79-9855-300a7f9b7300?versionId=sxmueRdM4S.HXwlebzlb7rOpD2ZHmS3Z&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEIn%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCWV1LXdlc3QtMiJGMEQCIBWNIrhNoAqNUG%2BoZLmKNSxY9ncDogcyFTGeJFef0zVMAiBhkAW9JWxVna%2FoCXe4u9S3364dCavXEvZP%2FXcD6iwfISq7BQiR%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F8BEAUaDDQ3MjAzODg2NDE4OC repositoryType: S3 configuration: architectures: - x86_64 codeSha256: JxWQc4FaGuW8503fcWt5S2Ua+HHpIX2z2SMhyo/gzBU= codeSize: 7586073 description: Parses LB access logs from S3, sending them to Honeycomb as structured events environment: variables: APIHOST: https://api.honeycomb.io DATASET: ingress ENVIRONMENT: "" FILTERFIELDS: "" FORCEGUNZIP: "true" HONEYCOMBWRITEKEY: foobar KMSKEYID: "" PARSERTYPE: alb RENAMEFIELDS: "" SAMPLERATE: "1" SAMPLERATERULES: "[]" ephemeralStorage: size: 512 functionArn: arn:aws:lambda:eu-west-2:123456789:function:ingress_log functionName: ingress_log handler: s3-handler lastModified: 2024-05-10T14:33:45.279+0000 lastUpdateStatus: Successful lastUpdateStatusReasonCode: "" loggingConfig: applicationLogLevel: "" logFormat: Text logGroup: /aws/lambda/ingress_log systemLogLevel: "" memorySize: 192 packageType: Zip revisionId: 876d6948-2e4c-41e0-9a62-d9be8a6a59f5 role: arn:aws:iam::123456789:role/ingress_log runtime: provided.al2 runtimeVersionConfig: runtimeVersionArn: arn:aws:lambda:eu-west-2::runtime:f4d7a18770044f40f09a49471782a2a42431d746fcfb30bf1cadeda985858aa0 snapStart: applyOn: None optimizationStatus: Off state: Active stateReasonCode: "" timeout: 600 tracingConfig: mode: PassThrough version: $LATEST tags: honeycombAgentless: "true" terraform: "true" capacityProviderStrategy: - base: 0 capacityProvider: FARGATE weight: 100 clusterArn: arn:aws:ecs:eu-west-2:123456789:cluster/example-tfc createdAt: 2024-08-01T16:06:18.906Z createdBy: arn:aws:iam::123456789:role/terraform-example deploymentConfiguration: deploymentCircuitBreaker: enable: false rollback: false maximumPercent: 200 minimumHealthyPercent: 100 deploymentController: type: ECS deployments: - capacityProviderStrategy: - base: 0 capacityProvider: FARGATE weight: 100 createdAt: 2024-08-01T16:42:08.6Z desiredCount: 1 failedTasks: 0 id: ecs-svc/5699741454300708027 launchType: "" networkConfiguration: awsvpcConfiguration: assignPublicIp: DISABLED securityGroups: - sg-0826c8494b61cac1f subnets: - subnet-0a393cf4c844bf32d - subnet-0fafe900a3dc4ba78 pendingCount: 0 platformFamily: Linux platformVersion: 1.4.0 rolloutState: COMPLETED rolloutStateReason: ECS deployment ecs-svc/5699741454300708027 completed. runningCount: 1 status: PRIMARY taskDefinition: arn:aws:ecs:eu-west-2:123456789:task-definition/facial-recognition-tfc:1 updatedAt: 2024-08-01T17:20:11.853Z desiredCount: 1 enableECSManagedTags: false enableExecuteCommand: false events: - createdAt: 2024-08-01T16:37:45.222Z id: f8240f68-73d0-497f-bf8e-4cb5185bd76c message: "(service facial-recognition) has started 1 tasks: (task d0fd4b687ebf4c968482a9814e1de455)." - createdAt: 2024-08-22T15:50:56.905Z id: 769e21aa-7a70-4270-88b9-55f902ddb727 message: (service facial-recognition) has reached a steady state. healthCheckGracePeriodSeconds: 0 launchType: "" loadBalancers: - containerName: facial-recognition containerPort: 1234 targetGroupArn: arn:aws:elasticloadbalancing:eu-west-2:123456789:targetgroup/facerec-tfc/0b6a17c7de07be40 networkConfiguration: awsvpcConfiguration: assignPublicIp: DISABLED securityGroups: - sg-0826c8494b61cac1f subnets: - subnet-0a393cf4c844bf32d - subnet-0fafe900a3dc4ba78 pendingCount: 0 placementConstraints: [] placementStrategy: [] platformFamily: Linux platformVersion: LATEST propagateTags: NONE roleArn: arn:aws:iam::123456789:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS runningCount: 1 schedulingStrategy: REPLICA serviceArn: arn:aws:ecs:eu-west-2:123456789:service/example-tfc/facial-recognition serviceFullName: service/example-tfc/facial-recognition serviceName: facial-recognition serviceRegistries: [] taskSets: [] compatibilities: - EC2 - FARGATE containerDefinitions: - cpu: 1024 environment: [] essential: true healthCheck: command: - CMD-SHELL - wget -q --spider localhost:1234 interval: 30 retries: 3 timeout: 5 image: harshmanvar/face-detection-tensorjs:slim-amd memory: 2048 mountPoints: [] name: facial-recognition portMappings: - appProtocol: http containerPort: 1234 hostPort: 1234 protocol: tcp systemControls: [] volumesFrom: [] cpu: "1024" family: facial-recognition-tfc ipcMode: "" memory: "2048" networkMode: awsvpc pidMode: "" registeredAt: 2024-08-01T15:27:30.781Z registeredBy: arn:aws:sts::123456789:assumed-role/terraform-example/terraform-run-nKsGeEsVUcxfxEuY requiresAttributes: - name: com.amazonaws.ecs.capability.docker-remote-api.1.18 targetType: "" - name: com.amazonaws.ecs.capability.docker-remote-api.1.24 targetType: "" - name: ecs.capability.container-health-check targetType: "" - name: ecs.capability.task-eni targetType: "" requiresCompatibilities: - FARGATE revision: 1 volumes: [] attachments: - details: - name: macAddress value: 0a:98:2a:a1:8c:cd - name: networkInterfaceId value: eni-0c99da7dff9025194 - name: privateDnsName value: ip-10-0-2-101.eu-west-2.compute.internal - name: privateIPv4Address value: 10.0.2.101 - name: subnetId value: subnet-0fafe900a3dc4ba78 id: f2dc881c-d3c5-49ca-904e-30358f1675d8 status: ATTACHED type: ElasticNetworkInterface attributes: - name: ecs.cpu-architecture targetType: "" value: x86_64 availabilityZone: eu-west-2b capacityProviderName: FARGATE connectivity: CONNECTED connectivityAt: 2024-08-01T17:16:34.995Z containers: - containerArn: arn:aws:ecs:eu-west-2:123456789:container/example-tfc/ded4f8eebe4144ddb9a93a27b5661008/778778dd-3a31-44f0-a84f-42a2e75403d9 cpu: "1024" healthStatus: HEALTHY image: harshmanvar/face-detection-tensorjs:slim-amd imageDigest: sha256:a12d885a6d05efa01735e5dd60b2580eece2f21d962e38b9bbdf8cfeb81c6894 lastStatus: RUNNING memory: "2048" name: facial-recognition networkBindings: [] networkInterfaces: - attachmentId: f2dc881c-d3c5-49ca-904e-30358f1675d8 runtimeId: ded4f8eebe4144ddb9a93a27b5661008-4091029319 desiredStatus: RUNNING ephemeralStorage: sizeInGiB: 20 fargateEphemeralStorage: sizeInGiB: 20 group: service:facial-recognition healthStatus: HEALTHY id: example-tfc/ded4f8eebe4144ddb9a93a27b5661008 lastStatus: RUNNING overrides: containerOverrides: - name: facial-recognition inferenceAcceleratorOverrides: [] pullStartedAt: 2024-08-01T17:18:22.901Z pullStoppedAt: 2024-08-01T17:18:34.827Z startedAt: 2024-08-01T17:18:48.139Z startedBy: ecs-svc/5699741454300708027 stopCode: "" taskArn: arn:aws:ecs:eu-west-2:123456789:task/example-tfc/ded4f8eebe4144ddb9a93a27b5661008 version: 5 ` mapData := make(map[string]any) _ = yaml.Unmarshal([]byte(yamlString), &mapData) attrs, _ := ToAttributes(mapData) return attrs, mapData } // Current performance: // BenchmarkExtractLinksFromAttributes-10 5676 193114 ns/op 58868 B/op 721 allocs/op func BenchmarkExtractLinksFromAttributes(b *testing.B) { attrs, _ := createTestData() for range b.N { _ = ExtractLinksFromAttributes(attrs) } } // Current performance: // BenchmarkExtractLinksFrom-10 2671 451209 ns/op 231509 B/op 4241 allocs/op func BenchmarkExtractLinksFrom(b *testing.B) { _, data := createTestData() for range b.N { _, _ = ExtractLinksFrom(data) } } func TestExtractLinksFromAttributes(t *testing.T) { attrs, _ := createTestData() queries := ExtractLinksFromAttributes(attrs) tests := []struct { ExpectedType string ExpectedQuery string ExpectedScope string }{ // ARN edge cases - these should work after the fix { ExpectedType: "s3-bucket", ExpectedQuery: "arn:aws:s3:::example-bucket-name", ExpectedScope: "*", // S3 buckets don't have region/account in ARN, use wildcard }, { ExpectedType: "s3-bucket", ExpectedQuery: "arn:aws:s3:::my-test-bucket", ExpectedScope: "*", }, { ExpectedType: "iam-role", ExpectedQuery: "arn:aws:iam::123456789012:role/example-role", ExpectedScope: "123456789012", // IAM is account-scoped, no region }, { ExpectedType: "cloudfront-distribution", ExpectedQuery: "arn:aws:cloudfront::123456789012:distribution/EDFDVBDEXAMPLE", ExpectedScope: "123456789012", // CloudFront is account-scoped, no region }, { ExpectedType: "ip", ExpectedQuery: "2a05:d01c:40:7600::6c81", }, { ExpectedType: "ip", ExpectedQuery: "2a05:d01c:40:7600:fbac::3", }, { ExpectedType: "ip", ExpectedQuery: "2a05:d01c:40:7600:fbac::4", }, { ExpectedType: "ip", ExpectedQuery: "10.0.2.101", }, { ExpectedType: "ip", ExpectedQuery: "fdb4:5627:96ee::bfa3", }, { ExpectedType: "http", ExpectedQuery: "https://api.example.com", }, { ExpectedType: "http", ExpectedQuery: "https://domain.eu.auth0.com/oauth/token", }, { ExpectedType: "dns", ExpectedQuery: "domain.eu.auth0.com", }, { ExpectedType: "http", ExpectedQuery: "http://service:8080/api", }, { ExpectedType: "http", ExpectedQuery: "https://awslambda-eu-west-2-tasks.s3.eu-west-2.amazonaws.com/snapshots/123456789/ingress_log-dd9f17f1-0694-4e79-9855-300a7f9b7300?versionId=sxmueRdM4S.HXwlebzlb7rOpD2ZHmS3Z&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEIn%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCWV1LXdlc3QtMiJGMEQCIBWNIrhNoAqNUG%2BoZLmKNSxY9ncDogcyFTGeJFef0zVMAiBhkAW9JWxVna%2FoCXe4u9S3364dCavXEvZP%2FXcD6iwfISq7BQiR%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F8BEAUaDDQ3MjAzODg2NDE4OC", }, { ExpectedType: "http", ExpectedQuery: "https://api.honeycomb.io", }, { ExpectedType: "dns", ExpectedQuery: "ip-10-0-2-101.eu-west-2.compute.internal", }, { ExpectedType: "dns", ExpectedQuery: "ip-10-0-4-118.eu-west-2.compute.internal", }, { ExpectedType: "dns", ExpectedQuery: "name.cluster-id.eu-west-2.rds.amazonaws.com", }, { ExpectedType: "lambda-function", ExpectedQuery: "arn:aws:lambda:eu-west-2:123456789:function:ingress_log", ExpectedScope: "123456789.eu-west-2", }, { ExpectedType: "ecs-cluster", ExpectedQuery: "arn:aws:ecs:eu-west-2:123456789:cluster/example-tfc", ExpectedScope: "123456789.eu-west-2", }, { ExpectedType: "ecs-container", ExpectedQuery: "arn:aws:ecs:eu-west-2:123456789:container/example-tfc/ded4f8eebe4144ddb9a93a27b5661008/778778dd-3a31-44f0-a84f-42a2e75403d9", ExpectedScope: "123456789.eu-west-2", }, { ExpectedType: "ecs-service", ExpectedQuery: "arn:aws:ecs:eu-west-2:123456789:service/example-tfc/facial-recognition", ExpectedScope: "123456789.eu-west-2", }, { ExpectedType: "ecs-task-definition", ExpectedQuery: "arn:aws:ecs:eu-west-2:123456789:task-definition/facial-recognition-tfc:1", ExpectedScope: "123456789.eu-west-2", }, { ExpectedType: "ecs-task", ExpectedQuery: "arn:aws:ecs:eu-west-2:123456789:task/example-tfc/ded4f8eebe4144ddb9a93a27b5661008", ExpectedScope: "123456789.eu-west-2", }, { ExpectedType: "elasticloadbalancing-targetgroup", ExpectedQuery: "arn:aws:elasticloadbalancing:eu-west-2:123456789:targetgroup/facerec-tfc/0b6a17c7de07be40", ExpectedScope: "123456789.eu-west-2", }, { ExpectedType: "iam-role", ExpectedQuery: "arn:aws:iam::123456789:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS", ExpectedScope: "123456789", }, { ExpectedType: "iam-role", ExpectedQuery: "arn:aws:iam::123456789:role/ingress_log", ExpectedScope: "123456789", }, { ExpectedType: "iam-role", ExpectedQuery: "arn:aws:iam::123456789:role/terraform-example", ExpectedScope: "123456789", }, { ExpectedType: "sts-assumed-role", ExpectedQuery: "arn:aws:sts::123456789:assumed-role/terraform-example/terraform-run-nKsGeEsVUcxfxEuY", ExpectedScope: "123456789", }, } // Note: We don't check length anymore since we added new test cases // that may result in more extracted queries than we have tests for for _, test := range tests { found := false for _, query := range queries { if query.GetQuery().GetQuery() == test.ExpectedQuery && query.GetQuery().GetType() == test.ExpectedType { if test.ExpectedScope == "" { // If we don't care about the scope then it's a match found = true break } else { // If we do care about the scope then check that it matches if query.GetQuery().GetScope() == test.ExpectedScope { found = true break } } } } if !found { t.Errorf("expected query not found: %s %s", test.ExpectedType, test.ExpectedQuery) } } } func TestExtractLinksFrom(t *testing.T) { tests := []struct { Name string Object any ExpectedQueries []string }{ { Name: "Env var structure array", Object: []struct { Name string Value string }{ { Name: "example", Value: "https://example.com", }, }, ExpectedQueries: []string{"https://example.com"}, }, { Name: "Just a raw string", Object: "https://example.com", ExpectedQueries: []string{"https://example.com"}, }, { Name: "Nil", Object: nil, ExpectedQueries: []string{}, }, { Name: "Struct", Object: struct { Name string Value string }{ Name: "example", Value: "https://example.com", }, ExpectedQueries: []string{"https://example.com"}, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { queries, err := ExtractLinksFrom(test.Object) if err != nil { t.Fatal(err) } if len(queries) != len(test.ExpectedQueries) { t.Errorf("expected %d queries, got %d", len(test.ExpectedQueries), len(queries)) } for i, query := range queries { if query.GetQuery().GetQuery() != test.ExpectedQueries[i] { t.Errorf("expected query %s, got %s", test.ExpectedQueries[i], query.GetQuery().GetQuery()) } } }) } } func TestExtractLinksFromConfigMapData(t *testing.T) { // Test ConfigMap data with S3 bucket ARN configMapData := map[string]any{ "data": map[string]any{ "S3_BUCKET_ARN": "arn:aws:s3:::example-bucket-name", "S3_BUCKET_NAME": "example-bucket-name", }, } queries, err := ExtractLinksFrom(configMapData) if err != nil { t.Fatal(err) } // Find the S3 bucket query found := false for _, query := range queries { if query.GetQuery().GetType() == "s3-bucket" && query.GetQuery().GetQuery() == "arn:aws:s3:::example-bucket-name" { found = true if query.GetQuery().GetScope() != WILDCARD { t.Errorf("expected scope to be WILDCARD (%s), got %s", WILDCARD, query.GetQuery().GetScope()) } if query.GetQuery().GetMethod() != QueryMethod_SEARCH { t.Errorf("expected method to be SEARCH, got %v", query.GetQuery().GetMethod()) } break } } if !found { t.Errorf("expected to find s3-bucket query for ARN arn:aws:s3:::example-bucket-name") t.Logf("Found %d queries:", len(queries)) for _, q := range queries { t.Logf(" Type: %s, Query: %s, Scope: %s", q.GetQuery().GetType(), q.GetQuery().GetQuery(), q.GetQuery().GetScope()) } } } func TestS3BucketARNTypeDetection(t *testing.T) { tests := []struct { name string arn string expectedType string expectedQuery string expectedScope string }{ { name: "S3 bucket ARN without account/region", arn: "arn:aws:s3:::example-bucket-name", expectedType: "s3-bucket", expectedQuery: "arn:aws:s3:::example-bucket-name", expectedScope: WILDCARD, }, { name: "S3 bucket ARN with short name", arn: "arn:aws:s3:::my-bucket", expectedType: "s3-bucket", expectedQuery: "arn:aws:s3:::my-bucket", expectedScope: WILDCARD, }, { name: "S3 object ARN (should extract bucket)", arn: "arn:aws:s3:::my-bucket/path/to/object", expectedType: "s3-bucket", expectedQuery: "arn:aws:s3:::my-bucket", expectedScope: WILDCARD, }, { name: "S3 object ARN with nested path", arn: "arn:aws:s3:::my-bucket/folder/subfolder/file.txt", expectedType: "s3-bucket", expectedQuery: "arn:aws:s3:::my-bucket", expectedScope: WILDCARD, }, { name: "S3 bucket ARN with hyphens in name", arn: "arn:aws:s3:::my-test-bucket-name", expectedType: "s3-bucket", expectedQuery: "arn:aws:s3:::my-test-bucket-name", expectedScope: WILDCARD, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { queries, err := ExtractLinksFrom(map[string]any{ "arn": tt.arn, }) if err != nil { t.Fatal(err) } found := false for _, query := range queries { if query.GetQuery().GetType() == tt.expectedType && query.GetQuery().GetQuery() == tt.expectedQuery { found = true if query.GetQuery().GetScope() != tt.expectedScope { t.Errorf("expected scope %s, got %s", tt.expectedScope, query.GetQuery().GetScope()) } if query.GetQuery().GetMethod() != QueryMethod_SEARCH { t.Errorf("expected method SEARCH, got %v", query.GetQuery().GetMethod()) } break } } if !found { t.Errorf("expected to find query with type %s and query %s", tt.expectedType, tt.expectedQuery) t.Logf("Found %d queries:", len(queries)) for _, q := range queries { t.Logf(" Type: %s, Query: %s, Scope: %s", q.GetQuery().GetType(), q.GetQuery().GetQuery(), q.GetQuery().GetScope()) } } }) } } ================================================ FILE: go/sdp-go/logs.go ================================================ package sdp import ( "errors" "fmt" "strings" "connectrpc.com/connect" ) // Validate ensures that GetLogRecordsRequest is valid func (req *GetLogRecordsRequest) Validate() error { if req == nil { return errors.New("GetLogRecordsRequest is nil") } // scope has to be non-nil, non-empty string if strings.TrimSpace(req.GetScope()) == "" { return errors.New("scope has to be non-empty") } // query has to be non-nil, non-empty string if strings.TrimSpace(req.GetQuery()) == "" { return errors.New("query has to be non-empty") } // from and to have to be valid timestamps if req.GetFrom() == nil { return errors.New("from timestamp is required") } if req.GetTo() == nil { return errors.New("to timestamp is required") } // from has to be before or equal to to fromTime := req.GetFrom().AsTime() toTime := req.GetTo().AsTime() if fromTime.After(toTime) { return fmt.Errorf("from timestamp (%v) must be before or equal to to timestamp (%v)", fromTime, toTime) } if req.GetMaxRecords() < 0 { return errors.New("maxRecords must be greater than or equal to zero") } return nil } // NewUpstreamSourceError creates a new SourceError with the given message and error func NewUpstreamSourceError(code connect.Code, message string) *SourceError { return &SourceError{ Code: SourceError_Code(code), //nolint:gosec Message: message, Upstream: true, } } // NewLocalSourceError creates a new SourceError with the given message and error, indicating a local (non-upstream) error func NewLocalSourceError(code connect.Code, message string) *SourceError { return &SourceError{ Code: SourceError_Code(code), //nolint:gosec Message: message, Upstream: false, } } // assert interface implementation var _ error = (*SourceError)(nil) // Error implements the error interface for SourceError func (e *SourceError) Error() string { if e.GetUpstream() { return fmt.Sprintf("Upstream Error: %s", e.GetMessage()) } return fmt.Sprintf("Source Error: %s", e.GetMessage()) } ================================================ FILE: go/sdp-go/logs.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: logs.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" structpb "google.golang.org/protobuf/types/known/structpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // These mirror the OpenTelemetry log severity levels // https://opentelemetry.io/docs/specs/otel/logs/data-model/#displaying-severity // Refer to the OpenTelemetry documentation for information on how these should // be mapped type LogSeverity int32 const ( LogSeverity_UNSPECIFIED LogSeverity = 0 LogSeverity_TRACE LogSeverity = 1 LogSeverity_TRACE2 LogSeverity = 2 LogSeverity_TRACE3 LogSeverity = 3 LogSeverity_TRACE4 LogSeverity = 4 LogSeverity_DEBUG LogSeverity = 5 LogSeverity_DEBUG2 LogSeverity = 6 LogSeverity_DEBUG3 LogSeverity = 7 LogSeverity_DEBUG4 LogSeverity = 8 LogSeverity_INFO LogSeverity = 9 LogSeverity_INFO2 LogSeverity = 10 LogSeverity_INFO3 LogSeverity = 11 LogSeverity_INFO4 LogSeverity = 12 LogSeverity_WARN LogSeverity = 13 LogSeverity_WARN2 LogSeverity = 14 LogSeverity_WARN3 LogSeverity = 15 LogSeverity_WARN4 LogSeverity = 16 LogSeverity_ERROR LogSeverity = 17 LogSeverity_ERROR2 LogSeverity = 18 LogSeverity_ERROR3 LogSeverity = 19 LogSeverity_ERROR4 LogSeverity = 20 LogSeverity_FATAL LogSeverity = 21 LogSeverity_FATAL2 LogSeverity = 22 LogSeverity_FATAL3 LogSeverity = 23 LogSeverity_FATAL4 LogSeverity = 24 ) // Enum value maps for LogSeverity. var ( LogSeverity_name = map[int32]string{ 0: "UNSPECIFIED", 1: "TRACE", 2: "TRACE2", 3: "TRACE3", 4: "TRACE4", 5: "DEBUG", 6: "DEBUG2", 7: "DEBUG3", 8: "DEBUG4", 9: "INFO", 10: "INFO2", 11: "INFO3", 12: "INFO4", 13: "WARN", 14: "WARN2", 15: "WARN3", 16: "WARN4", 17: "ERROR", 18: "ERROR2", 19: "ERROR3", 20: "ERROR4", 21: "FATAL", 22: "FATAL2", 23: "FATAL3", 24: "FATAL4", } LogSeverity_value = map[string]int32{ "UNSPECIFIED": 0, "TRACE": 1, "TRACE2": 2, "TRACE3": 3, "TRACE4": 4, "DEBUG": 5, "DEBUG2": 6, "DEBUG3": 7, "DEBUG4": 8, "INFO": 9, "INFO2": 10, "INFO3": 11, "INFO4": 12, "WARN": 13, "WARN2": 14, "WARN3": 15, "WARN4": 16, "ERROR": 17, "ERROR2": 18, "ERROR3": 19, "ERROR4": 20, "FATAL": 21, "FATAL2": 22, "FATAL3": 23, "FATAL4": 24, } ) func (x LogSeverity) Enum() *LogSeverity { p := new(LogSeverity) *p = x return p } func (x LogSeverity) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (LogSeverity) Descriptor() protoreflect.EnumDescriptor { return file_logs_proto_enumTypes[0].Descriptor() } func (LogSeverity) Type() protoreflect.EnumType { return &file_logs_proto_enumTypes[0] } func (x LogSeverity) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use LogSeverity.Descriptor instead. func (LogSeverity) EnumDescriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{0} } type NATSGetLogRecordsResponseStatus_Status int32 const ( NATSGetLogRecordsResponseStatus_UNSPECIFIED NATSGetLogRecordsResponseStatus_Status = 0 // The source has started processing the request. NATSGetLogRecordsResponseStatus_STARTED NATSGetLogRecordsResponseStatus_Status = 1 // The source has finished processing the request. No further messages will // be sent after this. NATSGetLogRecordsResponseStatus_FINISHED NATSGetLogRecordsResponseStatus_Status = 2 // The source encountered an error while processing the request. No further // messages will be sent after this. NATSGetLogRecordsResponseStatus_ERRORED NATSGetLogRecordsResponseStatus_Status = 3 ) // Enum value maps for NATSGetLogRecordsResponseStatus_Status. var ( NATSGetLogRecordsResponseStatus_Status_name = map[int32]string{ 0: "UNSPECIFIED", 1: "STARTED", 2: "FINISHED", 3: "ERRORED", } NATSGetLogRecordsResponseStatus_Status_value = map[string]int32{ "UNSPECIFIED": 0, "STARTED": 1, "FINISHED": 2, "ERRORED": 3, } ) func (x NATSGetLogRecordsResponseStatus_Status) Enum() *NATSGetLogRecordsResponseStatus_Status { p := new(NATSGetLogRecordsResponseStatus_Status) *p = x return p } func (x NATSGetLogRecordsResponseStatus_Status) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (NATSGetLogRecordsResponseStatus_Status) Descriptor() protoreflect.EnumDescriptor { return file_logs_proto_enumTypes[1].Descriptor() } func (NATSGetLogRecordsResponseStatus_Status) Type() protoreflect.EnumType { return &file_logs_proto_enumTypes[1] } func (x NATSGetLogRecordsResponseStatus_Status) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use NATSGetLogRecordsResponseStatus_Status.Descriptor instead. func (NATSGetLogRecordsResponseStatus_Status) EnumDescriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{5, 0} } // This directly mirrors the Connect RPC codes that can be found here: // https://connectrpc.com/docs/protocol/#error-codes type SourceError_Code int32 const ( // This is the default value and should not be used. In connectrpc, this // is "OK", but is not actually written out because _go_. SourceError_UNSPECIFIED SourceError_Code = 0 // CodeCanceled indicates that the operation was canceled, typically by the // caller. // // HTTP Code: 499 Client Closed Request SourceError_CANCELED SourceError_Code = 1 // CodeUnknown indicates that the operation failed for an unknown reason. // // HTTP Code: 500 Internal Server Error SourceError_UNKNOWN SourceError_Code = 2 // CodeInvalidArgument indicates that client supplied an invalid argument. // // HTTP Code: 400 Bad Request SourceError_INVALID_ARGUMENT SourceError_Code = 3 // CodeDeadlineExceeded indicates that deadline expired before the operation // could complete. // // HTTP Code: 504 Gateway Timeout SourceError_DEADLINE_EXCEEDED SourceError_Code = 4 // CodeNotFound indicates that some requested entity (for example, a file or // directory) was not found. // // HTTP Code: 404 Not Found SourceError_NOT_FOUND SourceError_Code = 5 // CodeAlreadyExists indicates that client attempted to create an entity (for // example, a file or directory) that already exists. // // HTTP Code: 409 Conflict SourceError_ALREADY_EXISTS SourceError_Code = 6 // CodePermissionDenied indicates that the caller doesn't have permission to // execute the specified operation. // // HTTP Code: 403 Forbidden SourceError_PERMISSION_DENIED SourceError_Code = 7 // CodeResourceExhausted indicates that some resource has been exhausted. For // example, a per-user quota may be exhausted or the entire file system may // be full. // // HTTP Code: 429 Too Many Requests SourceError_RESOURCE_EXHAUSTED SourceError_Code = 8 // CodeFailedPrecondition indicates that the system is not in a state // required for the operation's execution. // // HTTP Code: 400 Bad Request SourceError_FAILED_PRECONDITION SourceError_Code = 9 // CodeAborted indicates that operation was aborted by the system, usually // because of a concurrency issue such as a sequencer check failure or // transaction abort. // // HTTP Code: 409 Conflict SourceError_ABORTED SourceError_Code = 10 // CodeOutOfRange indicates that the operation was attempted past the valid // range (for example, seeking past end-of-file). // // HTTP Code: 400 Bad Request SourceError_OUT_OF_RANGE SourceError_Code = 11 // CodeUnimplemented indicates that the operation isn't implemented, // supported, or enabled in this service. // // HTTP Code: 501 Not Implemented SourceError_UNIMPLEMENTED SourceError_Code = 12 // CodeInternal indicates that some invariants expected by the underlying // system have been broken. This code is reserved for serious errors. // // HTTP Code: 500 Internal Server Error SourceError_INTERNAL SourceError_Code = 13 // CodeUnavailable indicates that the service is currently unavailable. This // is usually temporary, so clients can back off and retry idempotent // operations. // // HTTP Code: 503 Service Unavailable SourceError_UNAVAILABLE SourceError_Code = 14 // CodeDataLoss indicates that the operation has resulted in unrecoverable // data loss or corruption. // // HTTP Code: 500 Internal Server Error SourceError_DATA_LOSS SourceError_Code = 15 // CodeUnauthenticated indicates that the request does not have valid // authentication credentials for the operation. // // HTTP Code: 401 Unauthorized SourceError_UNAUTHENTICATED SourceError_Code = 16 ) // Enum value maps for SourceError_Code. var ( SourceError_Code_name = map[int32]string{ 0: "UNSPECIFIED", 1: "CANCELED", 2: "UNKNOWN", 3: "INVALID_ARGUMENT", 4: "DEADLINE_EXCEEDED", 5: "NOT_FOUND", 6: "ALREADY_EXISTS", 7: "PERMISSION_DENIED", 8: "RESOURCE_EXHAUSTED", 9: "FAILED_PRECONDITION", 10: "ABORTED", 11: "OUT_OF_RANGE", 12: "UNIMPLEMENTED", 13: "INTERNAL", 14: "UNAVAILABLE", 15: "DATA_LOSS", 16: "UNAUTHENTICATED", } SourceError_Code_value = map[string]int32{ "UNSPECIFIED": 0, "CANCELED": 1, "UNKNOWN": 2, "INVALID_ARGUMENT": 3, "DEADLINE_EXCEEDED": 4, "NOT_FOUND": 5, "ALREADY_EXISTS": 6, "PERMISSION_DENIED": 7, "RESOURCE_EXHAUSTED": 8, "FAILED_PRECONDITION": 9, "ABORTED": 10, "OUT_OF_RANGE": 11, "UNIMPLEMENTED": 12, "INTERNAL": 13, "UNAVAILABLE": 14, "DATA_LOSS": 15, "UNAUTHENTICATED": 16, } ) func (x SourceError_Code) Enum() *SourceError_Code { p := new(SourceError_Code) *p = x return p } func (x SourceError_Code) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (SourceError_Code) Descriptor() protoreflect.EnumDescriptor { return file_logs_proto_enumTypes[2].Descriptor() } func (SourceError_Code) Type() protoreflect.EnumType { return &file_logs_proto_enumTypes[2] } func (x SourceError_Code) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use SourceError_Code.Descriptor instead. func (SourceError_Code) EnumDescriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{6, 0} } // The request to get log records from the upstream API. type GetLogRecordsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The scope of the logs to get. This comes from the item that the LogStream // was attached to and ensures that the `NATSGetLogRecordsRequest` is // received by the same source that sent the item in the first place Scope string `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` // The query that was provided in the `LogStreamDetails` . The format of this // is determined by the source, and will contain enough information for the // source to successfully query the upstream API that contains the logs Query string `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` // The start point for the logs From *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=from,proto3" json:"from,omitempty"` // The end point for the logs To *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=to,proto3" json:"to,omitempty"` // The maximum number of records to return. Set to zero (`0`) to return all. MaxRecords int32 `protobuf:"varint,5,opt,name=maxRecords,proto3" json:"maxRecords,omitempty"` // If the value is true, the earliest log events are returned first. If the // value is false, the latest log events are returned first. The default // value is false. StartFromOldest bool `protobuf:"varint,6,opt,name=startFromOldest,proto3" json:"startFromOldest,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetLogRecordsRequest) Reset() { *x = GetLogRecordsRequest{} mi := &file_logs_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetLogRecordsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetLogRecordsRequest) ProtoMessage() {} func (x *GetLogRecordsRequest) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetLogRecordsRequest.ProtoReflect.Descriptor instead. func (*GetLogRecordsRequest) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{0} } func (x *GetLogRecordsRequest) GetScope() string { if x != nil { return x.Scope } return "" } func (x *GetLogRecordsRequest) GetQuery() string { if x != nil { return x.Query } return "" } func (x *GetLogRecordsRequest) GetFrom() *timestamppb.Timestamp { if x != nil { return x.From } return nil } func (x *GetLogRecordsRequest) GetTo() *timestamppb.Timestamp { if x != nil { return x.To } return nil } func (x *GetLogRecordsRequest) GetMaxRecords() int32 { if x != nil { return x.MaxRecords } return 0 } func (x *GetLogRecordsRequest) GetStartFromOldest() bool { if x != nil { return x.StartFromOldest } return false } // Each chunk is gonna be a page of the underlying APIs pagination. // The source is expected to use sane defaults within the limits of the // underlying API and SDP capabilities (message size, etc). // // This chunking can also be re-used for live streaming in the future. // // Note that the results are expected to be returned in ascending (oldest // to newest) order. type GetLogRecordsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Records []*LogRecord `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetLogRecordsResponse) Reset() { *x = GetLogRecordsResponse{} mi := &file_logs_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetLogRecordsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetLogRecordsResponse) ProtoMessage() {} func (x *GetLogRecordsResponse) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetLogRecordsResponse.ProtoReflect.Descriptor instead. func (*GetLogRecordsResponse) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{1} } func (x *GetLogRecordsResponse) GetRecords() []*LogRecord { if x != nil { return x.Records } return nil } // Represents a single entry in a LogStream. Roughly a "line" in traditional // terms, but nowadays often with more details, additional structure, etc. // // This is chiefly modelled on the OpenTelemetry log data model: // https://opentelemetry.io/docs/specs/otel/logs/data-model/ type LogRecord struct { state protoimpl.MessageState `protogen:"open.v1"` // "Time when the event occurred measured by the origin clock, i.e. the time // at the source." // // See https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-timestamp CreatedAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=createdAt,proto3,oneof" json:"createdAt,omitempty"` // "Time when the event was observed by the collection system." // This can be used if no `createdAt` timestamp is available. // Client libraries are encouraged to provide a singular getter that returns // our best guess for ease of use: createdAt if available, else observedAt. // // See https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-observedtimestamp ObservedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=observedAt,proto3,oneof" json:"observedAt,omitempty"` // See the definitions in // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber // Each source should document its mapping to this standard, e.g. following // the examples in // https://opentelemetry.io/docs/specs/otel/logs/data-model-appendix/#appendix-b-severitynumber-example-mappings // // See https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber Severity LogSeverity `protobuf:"varint,3,opt,name=severity,proto3,enum=logs.LogSeverity" json:"severity,omitempty"` // the string form of the `body`. Can be empty if the upstream API only // provides structured records. A Source can decide in this case to render a // default field here if available. // // See https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-body Body string `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` // "Describes the source of the log", as provided by the upstream API. // This is arbitrary metadata from the upstream API as interpreted by the // source. May be empty. Should use standard OTel attribute names where // applicable. // // See https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-resource Resource *structpb.Struct `protobuf:"bytes,5,opt,name=resource,proto3,oneof" json:"resource,omitempty"` // "Additional information about the specific event occurrence." This is // arbitrary metadata from the upstream API as interpreted by the source. // May be empty, may contain error and exception attributes. // // See https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-attributes Attributes *structpb.Struct `protobuf:"bytes,6,opt,name=attributes,proto3,oneof" json:"attributes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LogRecord) Reset() { *x = LogRecord{} mi := &file_logs_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LogRecord) String() string { return protoimpl.X.MessageStringOf(x) } func (*LogRecord) ProtoMessage() {} func (x *LogRecord) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LogRecord.ProtoReflect.Descriptor instead. func (*LogRecord) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{2} } func (x *LogRecord) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt } return nil } func (x *LogRecord) GetObservedAt() *timestamppb.Timestamp { if x != nil { return x.ObservedAt } return nil } func (x *LogRecord) GetSeverity() LogSeverity { if x != nil { return x.Severity } return LogSeverity_UNSPECIFIED } func (x *LogRecord) GetBody() string { if x != nil { return x.Body } return "" } func (x *LogRecord) GetResource() *structpb.Struct { if x != nil { return x.Resource } return nil } func (x *LogRecord) GetAttributes() *structpb.Struct { if x != nil { return x.Attributes } return nil } // A quick passthrough to keep the NATS message format consistent. type NATSGetLogRecordsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Request *GetLogRecordsRequest `protobuf:"bytes,1,opt,name=request,proto3" json:"request,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *NATSGetLogRecordsRequest) Reset() { *x = NATSGetLogRecordsRequest{} mi := &file_logs_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *NATSGetLogRecordsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*NATSGetLogRecordsRequest) ProtoMessage() {} func (x *NATSGetLogRecordsRequest) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use NATSGetLogRecordsRequest.ProtoReflect.Descriptor instead. func (*NATSGetLogRecordsRequest) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{3} } func (x *NATSGetLogRecordsRequest) GetRequest() *GetLogRecordsRequest { if x != nil { return x.Request } return nil } type NATSGetLogRecordsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Content: // // *NATSGetLogRecordsResponse_Status // *NATSGetLogRecordsResponse_Response Content isNATSGetLogRecordsResponse_Content `protobuf_oneof:"content"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *NATSGetLogRecordsResponse) Reset() { *x = NATSGetLogRecordsResponse{} mi := &file_logs_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *NATSGetLogRecordsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*NATSGetLogRecordsResponse) ProtoMessage() {} func (x *NATSGetLogRecordsResponse) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use NATSGetLogRecordsResponse.ProtoReflect.Descriptor instead. func (*NATSGetLogRecordsResponse) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{4} } func (x *NATSGetLogRecordsResponse) GetContent() isNATSGetLogRecordsResponse_Content { if x != nil { return x.Content } return nil } func (x *NATSGetLogRecordsResponse) GetStatus() *NATSGetLogRecordsResponseStatus { if x != nil { if x, ok := x.Content.(*NATSGetLogRecordsResponse_Status); ok { return x.Status } } return nil } func (x *NATSGetLogRecordsResponse) GetResponse() *GetLogRecordsResponse { if x != nil { if x, ok := x.Content.(*NATSGetLogRecordsResponse_Response); ok { return x.Response } } return nil } type isNATSGetLogRecordsResponse_Content interface { isNATSGetLogRecordsResponse_Content() } type NATSGetLogRecordsResponse_Status struct { // The status of the request. This is sent before any log records are // sent, and then if an error occurs, or the request is finished. This // provides signalling of the "method call" over NATS. Status *NATSGetLogRecordsResponseStatus `protobuf:"bytes,1,opt,name=status,proto3,oneof"` } type NATSGetLogRecordsResponse_Response struct { // A set of log records (lines). These should be batched in whatever way // that the upstream provider batches them. For example if the API that // you are pulling the logs from returns them in pages of 50, then you // should return 50 log records in each response, and send the response // on before requesting the next page from the API. Response *GetLogRecordsResponse `protobuf:"bytes,2,opt,name=response,proto3,oneof"` } func (*NATSGetLogRecordsResponse_Status) isNATSGetLogRecordsResponse_Content() {} func (*NATSGetLogRecordsResponse_Response) isNATSGetLogRecordsResponse_Content() {} type NATSGetLogRecordsResponseStatus struct { state protoimpl.MessageState `protogen:"open.v1"` Status NATSGetLogRecordsResponseStatus_Status `protobuf:"varint,1,opt,name=status,proto3,enum=logs.NATSGetLogRecordsResponseStatus_Status" json:"status,omitempty"` // Only populated when the status is ERRORED Error *SourceError `protobuf:"bytes,2,opt,name=error,proto3,oneof" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *NATSGetLogRecordsResponseStatus) Reset() { *x = NATSGetLogRecordsResponseStatus{} mi := &file_logs_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *NATSGetLogRecordsResponseStatus) String() string { return protoimpl.X.MessageStringOf(x) } func (*NATSGetLogRecordsResponseStatus) ProtoMessage() {} func (x *NATSGetLogRecordsResponseStatus) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use NATSGetLogRecordsResponseStatus.ProtoReflect.Descriptor instead. func (*NATSGetLogRecordsResponseStatus) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{5} } func (x *NATSGetLogRecordsResponseStatus) GetStatus() NATSGetLogRecordsResponseStatus_Status { if x != nil { return x.Status } return NATSGetLogRecordsResponseStatus_UNSPECIFIED } func (x *NATSGetLogRecordsResponseStatus) GetError() *SourceError { if x != nil { return x.Error } return nil } type SourceError struct { state protoimpl.MessageState `protogen:"open.v1"` // The error code Code SourceError_Code `protobuf:"varint,1,opt,name=code,proto3,enum=logs.SourceError_Code" json:"code,omitempty"` // The error message Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // Whether this error comes from the upstream API or not. Errors that come // from the upstream API will result in the user-facing RPC returning a // `code.Aborted` error, with the `NatsError` embedded in the `Detail` // field. This differentiates between errors that were part of Overmind // (like the source panicking) and errors that come from the upstream (like // Datadog having an outage) Upstream bool `protobuf:"varint,3,opt,name=upstream,proto3" json:"upstream,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SourceError) Reset() { *x = SourceError{} mi := &file_logs_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SourceError) String() string { return protoimpl.X.MessageStringOf(x) } func (*SourceError) ProtoMessage() {} func (x *SourceError) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SourceError.ProtoReflect.Descriptor instead. func (*SourceError) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{6} } func (x *SourceError) GetCode() SourceError_Code { if x != nil { return x.Code } return SourceError_UNSPECIFIED } func (x *SourceError) GetMessage() string { if x != nil { return x.Message } return "" } func (x *SourceError) GetUpstream() bool { if x != nil { return x.Upstream } return false } var File_logs_proto protoreflect.FileDescriptor const file_logs_proto_rawDesc = "" + "\n" + "\n" + "logs.proto\x12\x04logs\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe8\x01\n" + "\x14GetLogRecordsRequest\x12\x14\n" + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x14\n" + "\x05query\x18\x02 \x01(\tR\x05query\x12.\n" + "\x04from\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x04from\x12*\n" + "\x02to\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02to\x12\x1e\n" + "\n" + "maxRecords\x18\x05 \x01(\x05R\n" + "maxRecords\x12(\n" + "\x0fstartFromOldest\x18\x06 \x01(\bR\x0fstartFromOldest\"B\n" + "\x15GetLogRecordsResponse\x12)\n" + "\arecords\x18\x01 \x03(\v2\x0f.logs.LogRecordR\arecords\"\xff\x02\n" + "\tLogRecord\x12=\n" + "\tcreatedAt\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampH\x00R\tcreatedAt\x88\x01\x01\x12?\n" + "\n" + "observedAt\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\n" + "observedAt\x88\x01\x01\x12-\n" + "\bseverity\x18\x03 \x01(\x0e2\x11.logs.LogSeverityR\bseverity\x12\x12\n" + "\x04body\x18\x04 \x01(\tR\x04body\x128\n" + "\bresource\x18\x05 \x01(\v2\x17.google.protobuf.StructH\x02R\bresource\x88\x01\x01\x12<\n" + "\n" + "attributes\x18\x06 \x01(\v2\x17.google.protobuf.StructH\x03R\n" + "attributes\x88\x01\x01B\f\n" + "\n" + "_createdAtB\r\n" + "\v_observedAtB\v\n" + "\t_resourceB\r\n" + "\v_attributes\"P\n" + "\x18NATSGetLogRecordsRequest\x124\n" + "\arequest\x18\x01 \x01(\v2\x1a.logs.GetLogRecordsRequestR\arequest\"\xa2\x01\n" + "\x19NATSGetLogRecordsResponse\x12?\n" + "\x06status\x18\x01 \x01(\v2%.logs.NATSGetLogRecordsResponseStatusH\x00R\x06status\x129\n" + "\bresponse\x18\x02 \x01(\v2\x1b.logs.GetLogRecordsResponseH\x00R\bresponseB\t\n" + "\acontent\"\xe2\x01\n" + "\x1fNATSGetLogRecordsResponseStatus\x12D\n" + "\x06status\x18\x01 \x01(\x0e2,.logs.NATSGetLogRecordsResponseStatus.StatusR\x06status\x12,\n" + "\x05error\x18\x02 \x01(\v2\x11.logs.SourceErrorH\x00R\x05error\x88\x01\x01\"A\n" + "\x06Status\x12\x0f\n" + "\vUNSPECIFIED\x10\x00\x12\v\n" + "\aSTARTED\x10\x01\x12\f\n" + "\bFINISHED\x10\x02\x12\v\n" + "\aERRORED\x10\x03B\b\n" + "\x06_error\"\xb1\x03\n" + "\vSourceError\x12*\n" + "\x04code\x18\x01 \x01(\x0e2\x16.logs.SourceError.CodeR\x04code\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1a\n" + "\bupstream\x18\x03 \x01(\bR\bupstream\"\xbf\x02\n" + "\x04Code\x12\x0f\n" + "\vUNSPECIFIED\x10\x00\x12\f\n" + "\bCANCELED\x10\x01\x12\v\n" + "\aUNKNOWN\x10\x02\x12\x14\n" + "\x10INVALID_ARGUMENT\x10\x03\x12\x15\n" + "\x11DEADLINE_EXCEEDED\x10\x04\x12\r\n" + "\tNOT_FOUND\x10\x05\x12\x12\n" + "\x0eALREADY_EXISTS\x10\x06\x12\x15\n" + "\x11PERMISSION_DENIED\x10\a\x12\x16\n" + "\x12RESOURCE_EXHAUSTED\x10\b\x12\x17\n" + "\x13FAILED_PRECONDITION\x10\t\x12\v\n" + "\aABORTED\x10\n" + "\x12\x10\n" + "\fOUT_OF_RANGE\x10\v\x12\x11\n" + "\rUNIMPLEMENTED\x10\f\x12\f\n" + "\bINTERNAL\x10\r\x12\x0f\n" + "\vUNAVAILABLE\x10\x0e\x12\r\n" + "\tDATA_LOSS\x10\x0f\x12\x13\n" + "\x0fUNAUTHENTICATED\x10\x10*\xb0\x02\n" + "\vLogSeverity\x12\x0f\n" + "\vUNSPECIFIED\x10\x00\x12\t\n" + "\x05TRACE\x10\x01\x12\n" + "\n" + "\x06TRACE2\x10\x02\x12\n" + "\n" + "\x06TRACE3\x10\x03\x12\n" + "\n" + "\x06TRACE4\x10\x04\x12\t\n" + "\x05DEBUG\x10\x05\x12\n" + "\n" + "\x06DEBUG2\x10\x06\x12\n" + "\n" + "\x06DEBUG3\x10\a\x12\n" + "\n" + "\x06DEBUG4\x10\b\x12\b\n" + "\x04INFO\x10\t\x12\t\n" + "\x05INFO2\x10\n" + "\x12\t\n" + "\x05INFO3\x10\v\x12\t\n" + "\x05INFO4\x10\f\x12\b\n" + "\x04WARN\x10\r\x12\t\n" + "\x05WARN2\x10\x0e\x12\t\n" + "\x05WARN3\x10\x0f\x12\t\n" + "\x05WARN4\x10\x10\x12\t\n" + "\x05ERROR\x10\x11\x12\n" + "\n" + "\x06ERROR2\x10\x12\x12\n" + "\n" + "\x06ERROR3\x10\x13\x12\n" + "\n" + "\x06ERROR4\x10\x14\x12\t\n" + "\x05FATAL\x10\x15\x12\n" + "\n" + "\x06FATAL2\x10\x16\x12\n" + "\n" + "\x06FATAL3\x10\x17\x12\n" + "\n" + "\x06FATAL4\x10\x182Y\n" + "\vLogsService\x12J\n" + "\rGetLogRecords\x12\x1a.logs.GetLogRecordsRequest\x1a\x1b.logs.GetLogRecordsResponse0\x01B1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_logs_proto_rawDescOnce sync.Once file_logs_proto_rawDescData []byte ) func file_logs_proto_rawDescGZIP() []byte { file_logs_proto_rawDescOnce.Do(func() { file_logs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_logs_proto_rawDesc), len(file_logs_proto_rawDesc))) }) return file_logs_proto_rawDescData } var file_logs_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_logs_proto_goTypes = []any{ (LogSeverity)(0), // 0: logs.LogSeverity (NATSGetLogRecordsResponseStatus_Status)(0), // 1: logs.NATSGetLogRecordsResponseStatus.Status (SourceError_Code)(0), // 2: logs.SourceError.Code (*GetLogRecordsRequest)(nil), // 3: logs.GetLogRecordsRequest (*GetLogRecordsResponse)(nil), // 4: logs.GetLogRecordsResponse (*LogRecord)(nil), // 5: logs.LogRecord (*NATSGetLogRecordsRequest)(nil), // 6: logs.NATSGetLogRecordsRequest (*NATSGetLogRecordsResponse)(nil), // 7: logs.NATSGetLogRecordsResponse (*NATSGetLogRecordsResponseStatus)(nil), // 8: logs.NATSGetLogRecordsResponseStatus (*SourceError)(nil), // 9: logs.SourceError (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp (*structpb.Struct)(nil), // 11: google.protobuf.Struct } var file_logs_proto_depIdxs = []int32{ 10, // 0: logs.GetLogRecordsRequest.from:type_name -> google.protobuf.Timestamp 10, // 1: logs.GetLogRecordsRequest.to:type_name -> google.protobuf.Timestamp 5, // 2: logs.GetLogRecordsResponse.records:type_name -> logs.LogRecord 10, // 3: logs.LogRecord.createdAt:type_name -> google.protobuf.Timestamp 10, // 4: logs.LogRecord.observedAt:type_name -> google.protobuf.Timestamp 0, // 5: logs.LogRecord.severity:type_name -> logs.LogSeverity 11, // 6: logs.LogRecord.resource:type_name -> google.protobuf.Struct 11, // 7: logs.LogRecord.attributes:type_name -> google.protobuf.Struct 3, // 8: logs.NATSGetLogRecordsRequest.request:type_name -> logs.GetLogRecordsRequest 8, // 9: logs.NATSGetLogRecordsResponse.status:type_name -> logs.NATSGetLogRecordsResponseStatus 4, // 10: logs.NATSGetLogRecordsResponse.response:type_name -> logs.GetLogRecordsResponse 1, // 11: logs.NATSGetLogRecordsResponseStatus.status:type_name -> logs.NATSGetLogRecordsResponseStatus.Status 9, // 12: logs.NATSGetLogRecordsResponseStatus.error:type_name -> logs.SourceError 2, // 13: logs.SourceError.code:type_name -> logs.SourceError.Code 3, // 14: logs.LogsService.GetLogRecords:input_type -> logs.GetLogRecordsRequest 4, // 15: logs.LogsService.GetLogRecords:output_type -> logs.GetLogRecordsResponse 15, // [15:16] is the sub-list for method output_type 14, // [14:15] is the sub-list for method input_type 14, // [14:14] is the sub-list for extension type_name 14, // [14:14] is the sub-list for extension extendee 0, // [0:14] is the sub-list for field type_name } func init() { file_logs_proto_init() } func file_logs_proto_init() { if File_logs_proto != nil { return } file_logs_proto_msgTypes[2].OneofWrappers = []any{} file_logs_proto_msgTypes[4].OneofWrappers = []any{ (*NATSGetLogRecordsResponse_Status)(nil), (*NATSGetLogRecordsResponse_Response)(nil), } file_logs_proto_msgTypes[5].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_logs_proto_rawDesc), len(file_logs_proto_rawDesc)), NumEnums: 3, NumMessages: 7, NumExtensions: 0, NumServices: 1, }, GoTypes: file_logs_proto_goTypes, DependencyIndexes: file_logs_proto_depIdxs, EnumInfos: file_logs_proto_enumTypes, MessageInfos: file_logs_proto_msgTypes, }.Build() File_logs_proto = out.File file_logs_proto_goTypes = nil file_logs_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/logs_test.go ================================================ package sdp import ( "testing" "time" "google.golang.org/protobuf/types/known/timestamppb" ) func TestGetLogRecordsRequest_Validate(t *testing.T) { now := time.Now() pastTime := now.Add(-1 * time.Hour) futureTime := now.Add(1 * time.Hour) tests := []struct { name string req *GetLogRecordsRequest wantErr bool }{ { name: "Nil request", req: nil, wantErr: true, }, { name: "Empty scope", req: &GetLogRecordsRequest{ Scope: "", Query: "valid-query", From: timestamppb.New(pastTime), To: timestamppb.New(now), MaxRecords: 100, StartFromOldest: false, }, wantErr: true, }, { name: "Empty query", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "", From: timestamppb.New(pastTime), To: timestamppb.New(now), MaxRecords: 100, StartFromOldest: false, }, wantErr: true, }, { name: "Missing from timestamp", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: nil, To: timestamppb.New(now), MaxRecords: 100, StartFromOldest: false, }, wantErr: true, }, { name: "Missing to timestamp", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: timestamppb.New(pastTime), To: nil, MaxRecords: 100, StartFromOldest: false, }, wantErr: true, }, { name: "From after to", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: timestamppb.New(futureTime), To: timestamppb.New(pastTime), MaxRecords: 100, StartFromOldest: false, }, wantErr: true, }, { name: "MaxRecords zero", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: timestamppb.New(pastTime), To: timestamppb.New(now), MaxRecords: 0, StartFromOldest: false, }, wantErr: false, }, { name: "MaxRecords negative", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: timestamppb.New(pastTime), To: timestamppb.New(now), MaxRecords: -10, StartFromOldest: false, }, wantErr: true, }, { name: "Valid request with MaxRecords", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: timestamppb.New(pastTime), To: timestamppb.New(now), MaxRecords: 100, StartFromOldest: false, }, wantErr: false, }, { name: "Valid request without MaxRecords", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: timestamppb.New(pastTime), To: timestamppb.New(now), MaxRecords: 0, StartFromOldest: false, }, wantErr: false, }, { name: "Valid request with equal timestamps", req: &GetLogRecordsRequest{ Scope: "valid-scope", Query: "valid-query", From: timestamppb.New(now), To: timestamppb.New(now), MaxRecords: 100, StartFromOldest: true, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.req.Validate() if (err != nil) != tt.wantErr { t.Errorf("GetLogRecordsRequest.Validate() error = %v, wantErr %v\nrequest = %v", err, tt.wantErr, tt.req) } }) } } ================================================ FILE: go/sdp-go/progress.go ================================================ package sdp import ( "context" "errors" "fmt" "math/rand/v2" "sync" "sync/atomic" "time" "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/nats-io/nats.go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/types/known/durationpb" ) // DefaultResponseInterval is the default period of time within which responses // are sent (30 seconds). Jitter of +/-10% is applied per tick to prevent a // thundering herd when many concurrent queries start simultaneously. const DefaultResponseInterval = (30 * time.Second) // DefaultStartTimeout is the default period of time to wait for the first // response on a query. If no response is received in this time, the query will // be marked as complete. const DefaultStartTimeout = 2000 * time.Millisecond // ResponseSender is a struct responsible for sending responses out on behalf of // agents that are working on that request. Think of it as the agent side // component of Responder type ResponseSender struct { // How often to send responses. The expected next update will be 230% of // this value, allowing for one-and-a-bit missed responses before it is // marked as stalled ResponseInterval time.Duration ResponseSubject string monitorRunning sync.WaitGroup monitorKill chan *Response // Sending to this channel will kill the response sender goroutine and publish the sent message as last msg on the subject responderName string responderId uuid.UUID connection EncodedConnection responseCtx context.Context } // Start sends the first response on the given subject and connection to say // that the request is being worked on. It also starts a go routine to continue // sending responses. // // The user should make sure to call Done(), Error() or Cancel() once the query // has finished to make sure this process stops sending responses. The sender // will also be stopped if the context is cancelled func (rs *ResponseSender) Start(ctx context.Context, ec EncodedConnection, responderName string, responderId uuid.UUID) { rs.monitorKill = make(chan *Response, 1) rs.responseCtx = ctx // Set the default if it's not set if rs.ResponseInterval == 0 { rs.ResponseInterval = DefaultResponseInterval } // Tell it to expect the next update in 230% of the expected time. This // allows for a response getting lost, plus some delay nextUpdateIn := durationpb.New(time.Duration((float64(rs.ResponseInterval) * 2.3))) // Set struct values rs.responderName = responderName rs.responderId = responderId rs.connection = ec // Create the response before starting the goroutine since it only needs to // be done once resp := Response{ Responder: rs.responderName, ResponderUUID: rs.responderId[:], State: ResponderState_WORKING, NextUpdateIn: nextUpdateIn, } if rs.connection != nil { // Send the initial response err := rs.connection.Publish( ctx, rs.ResponseSubject, &QueryResponse{ResponseType: &QueryResponse_Response{Response: &resp}}, ) if err != nil { log.WithContext(ctx).WithError(err).Error("Error publishing initial response") } } rs.monitorRunning.Add(1) // Start a goroutine to send further responses go func() { defer tracing.LogRecoverToReturn(ctx, "ResponseSender ticker") // confirm closure on exit defer rs.monitorRunning.Done() if ec == nil { return } // Apply +/-10% uniform random jitter per tick to prevent a thundering // herd when many ResponseSenders start near-simultaneously. tenth := rs.ResponseInterval / 10 base := rs.ResponseInterval - tenth jitterRange := 2 * tenth for { jitter := time.Duration(rand.Int64N(int64(jitterRange))) //nolint:gosec // jitter does not need cryptographic randomness delay := base + jitter select { case <-rs.monitorKill: return case <-ctx.Done(): return case <-time.After(delay): err := rs.connection.Publish( ctx, rs.ResponseSubject, &QueryResponse{ResponseType: &QueryResponse_Response{Response: &resp}}, ) if err != nil { log.WithContext(ctx).WithError(err).Error("Error publishing response") } } } }() } // Kill Kills the response sender immediately. This should be used if something // has failed and you don't want to send a completed response // // Deprecated: Use KillWithContext(ctx) instead func (rs *ResponseSender) Kill() { rs.killWithResponse(context.Background(), nil) } // KillWithContext Kills the response sender immediately. This should be used if something // has failed and you don't want to send a completed response func (rs *ResponseSender) KillWithContext(ctx context.Context) { rs.killWithResponse(ctx, nil) } func (rs *ResponseSender) killWithResponse(ctx context.Context, r *Response) { // close the channel to kill the sender close(rs.monitorKill) // wait for the sender to be actually done rs.monitorRunning.Wait() if rs.connection != nil { if r != nil { // Send the final response err := rs.connection.Publish(ctx, rs.ResponseSubject, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: r, }, }) if err != nil { log.WithContext(ctx).WithError(err).Error("Error publishing final response") } } } } // Done kills the responder but sends a final completion message // // Deprecated: Use DoneWithContext(ctx) instead func (rs *ResponseSender) Done() { rs.DoneWithContext(context.Background()) } // DoneWithContext kills the responder but sends a final completion message func (rs *ResponseSender) DoneWithContext(ctx context.Context) { resp := Response{ Responder: rs.responderName, ResponderUUID: rs.responderId[:], State: ResponderState_COMPLETE, } rs.killWithResponse(ctx, &resp) } // Error marks the request and completed with error, and sends the final error // response // // Deprecated: Use ErrorWithContext(ctx) instead func (rs *ResponseSender) Error() { rs.ErrorWithContext(context.Background()) } // ErrorWithContext marks the request and completed with error, and sends the final error // response func (rs *ResponseSender) ErrorWithContext(ctx context.Context) { resp := Response{ Responder: rs.responderName, ResponderUUID: rs.responderId[:], State: ResponderState_ERROR, } rs.killWithResponse(ctx, &resp) } // Cancel Marks the request as CANCELLED and sends the final response // // Deprecated: Use CancelWithContext(ctx) instead func (rs *ResponseSender) Cancel() { rs.CancelWithContext(context.Background()) } // CancelWithContext Marks the request as CANCELLED and sends the final response func (rs *ResponseSender) CancelWithContext(ctx context.Context) { resp := Response{ Responder: rs.responderName, ResponderUUID: rs.responderId[:], State: ResponderState_CANCELLED, } rs.killWithResponse(ctx, &resp) } type lastResponse struct { Response *Response Timestamp time.Time } // Checks to see if this responder is stalled. If it is, it will update the // responder state to ResponderState_STALLED. Only runs if the responder is in // the WORKING state, doesn't do anything otherwise. func (l *lastResponse) checkStalled() { if l.Response == nil || l.Response.GetState() != ResponderState_WORKING { return } // Calculate if it's stalled, but only if it has a `NextUpdateIn` value. // Responders that do not provided a `NextUpdateIn` value are not considered // for stalling timeSinceLastUpdate := time.Since(l.Timestamp) timeToNextUpdate := l.Response.GetNextUpdateIn().AsDuration() if timeToNextUpdate > 0 && timeSinceLastUpdate > timeToNextUpdate { l.Response.State = ResponderState_STALLED } } // SourceQuery tracks the progress of a query across multiple responders (Sources). // It manages a state machine for each responder with the following states: // // WORKING → COMPLETE (normal completion) // WORKING → ERROR (responder failed) // WORKING → CANCELLED (query was cancelled) // WORKING → STALLED (responder stopped sending updates) // // A query is considered finished when the start timeout has elapsed AND all // responders are in a terminal state (COMPLETE, ERROR, CANCELLED, or STALLED). type SourceQuery struct { // A map of ResponderUUIDs to the last response we got from them responders map[uuid.UUID]*lastResponse respondersMu sync.Mutex // Channel storage for sending back to the user responseChan chan<- *QueryResponse // Use to make sure a user doesn't try to start a request twice. This is an // atomic to allow tests to directly inject messages using // `handleQueryResponse` startTimeoutElapsed atomic.Bool querySub *nats.Subscription cancel context.CancelFunc } // SourceQueryProgress represents the current progress of a tracked query, // aggregating the state of all responders. type SourceQueryProgress struct { // How many responders are currently working on this query. This means they // are active sending updates Working int // Stalled responders are ones that have sent updates in the past, but the // latest update is overdue. This likely indicates a problem with the // responder Stalled int // Responders that are complete Complete int // Responders that failed Error int // Responders that were cancelled. When cancelling the SourceQueryProgress // does not wait for responders to acknowledge the cancellation, it simply // sends the message and marks all responders that are currently "working" // as "cancelled". It is possible that a responder will self-report // cancellation, but given the timings this is unlikely as it would need to // be very fast Cancelled int // The total number of tracked responders Responders int } // RunSourceQuery returns a pointer to a SourceQuery object with the various // internal members initialized. A startTimeout must also be provided, feel free // to use `DefaultStartTimeout` if you don't have a specific value in mind. func RunSourceQuery(ctx context.Context, query *Query, startTimeout time.Duration, ec EncodedConnection, responseChan chan<- *QueryResponse) (*SourceQuery, error) { if startTimeout == 0 { return nil, errors.New("startTimeout must be greater than 0") } if ec.Underlying() == nil { return nil, errors.New("nil NATS connection") } if responseChan == nil { return nil, errors.New("nil response channel") } if query.GetScope() == "" { return nil, errors.New("cannot execute request with blank scope") } // Generate a UUID if required if len(query.GetUUID()) == 0 { u := uuid.New() query.UUID = u[:] } // Calculate the correct subject to send the message on var requestSubject string if query.GetScope() == WILDCARD { requestSubject = "request.all" } else { requestSubject = fmt.Sprintf("request.scope.%v", query.GetScope()) } // Create the channel that NATS responses will come through natsResponses := make(chan *QueryResponse) // Create a timer for the start timeout startTimeoutTimer := time.NewTimer(startTimeout) // Subscribe to the query subject and wait for responses querySub, err := ec.Subscribe(query.Subject(), NewQueryResponseHandler("", func(ctx context.Context, qr *QueryResponse) { //nolint:contextcheck // we pass the context in the func natsResponses <- qr })) if err != nil { return nil, err } ctx, cancel := context.WithCancel(ctx) sq := &SourceQuery{ responders: make(map[uuid.UUID]*lastResponse), startTimeoutElapsed: atomic.Bool{}, querySub: querySub, cancel: cancel, responseChan: responseChan, } // Main processing loop. This runs is the main goroutine that tracks this // request go func() { // Initialise the stall check ticker stallCheck := time.NewTicker(500 * time.Millisecond) defer stallCheck.Stop() ctx, span := tracing.Tracer().Start(ctx, "QueryProgress") defer span.End() query.SetSpanAttributes(span) for { select { case <-ctx.Done(): // If the connection is closed, we do not need to send a cancel message if u := ec.Underlying(); u == nil || u.IsClosed() { sq.markWorkingRespondersCancelled() sq.cleanup(ctx) return } // Since this context is done, we need a new context just to // send the cancellation message cancelCtx, cancelCtxCancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) defer cancelCtxCancel() // Send a cancel message to all responders cancelRequest := CancelQuery{ UUID: query.GetUUID(), } var cancelSubject string if query.GetScope() == WILDCARD { cancelSubject = "cancel.all" } else { cancelSubject = fmt.Sprintf("cancel.scope.%v", query.GetScope()) } err := ec.Publish(cancelCtx, cancelSubject, &cancelRequest) if err != nil { log.WithContext(ctx).WithError(err).Error("Error sending cancel message") span.RecordError(err) } sq.markWorkingRespondersCancelled() sq.cleanup(ctx) return case <-startTimeoutTimer.C: sq.startTimeoutElapsed.Store(true) if sq.finished() { sq.cleanup(ctx) return } case response := <-natsResponses: // Handle the response if sq.handleQueryResponse(ctx, response) { // This means we are done return } case <-stallCheck.C: // If we get here, it means that we haven't had a response // in a while, so we should check to see if things have // stalled if sq.finished() { sq.cleanup(ctx) return } } } }() // Send the message to start the query err = ec.Publish(ctx, requestSubject, query) if err != nil { return nil, err } return sq, nil } // Execute a given request and wait for it to finish, returns the items that // were found and any errors. The third return error value will only be returned // only if there is a problem making the request. Details of which responders // have failed etc. should be determined using the typical methods like // `NumError()`. func RunSourceQuerySync(ctx context.Context, query *Query, startTimeout time.Duration, ec EncodedConnection) ([]*Item, []*Edge, []*QueryError, error) { items := make([]*Item, 0) edges := make([]*Edge, 0) errs := make([]*QueryError, 0) r := make(chan *QueryResponse, 128) if ec == nil { return items, edges, errs, errors.New("nil NATS connection") } _, err := RunSourceQuery(ctx, query, startTimeout, ec, r) if err != nil { return items, edges, errs, err } // Read items and errors for response := range r { item := response.GetNewItem() if item != nil { items = append(items, item) } edge := response.GetEdge() if edge != nil { edges = append(edges, edge) } qErr := response.GetError() if qErr != nil { errs = append(errs, qErr) } // ignore status responses for now // status := response.GetResponse() // if status != nil { // panic("qp: status not implemented yet") // } } // when the channel closes, we're done return items, edges, errs, nil } // Cancel cancels the query by sending a cancel message to all responders and // closing the response channel. Alternatively, the query can be cancelled by // cancelling the context passed to RunSourceQuery. func (sq *SourceQuery) Cancel() { sq.cancel() } // This is split out into its own function so that it can be tested more easily // with out having to worry about race conditions. This returns a boolean which // indicates if the request is complete or not func (sq *SourceQuery) handleQueryResponse(ctx context.Context, response *QueryResponse) bool { switch r := response.GetResponseType().(type) { case *QueryResponse_NewItem: sq.handleItem(r.NewItem) case *QueryResponse_Edge: sq.handleEdge(r.Edge) case *QueryResponse_Error: sq.handleError(r.Error) case *QueryResponse_Response: sq.handleResponse(ctx, r.Response) if sq.finished() { sq.cleanup(ctx) return true } } return false } // markWorkingRespondersCancelled marks all working responders as cancelled // internally, there is no need to wait for them to confirm the cancellation, as // we're not going to wait for any further responses. func (sq *SourceQuery) markWorkingRespondersCancelled() { sq.respondersMu.Lock() defer sq.respondersMu.Unlock() for _, lastResponse := range sq.responders { if lastResponse.Response.GetState() == ResponderState_WORKING { lastResponse.Response.State = ResponderState_CANCELLED } } } // Whether the query should be considered finished or not. This is based on // whether the start timeout has elapsed and all responders are done func (sq *SourceQuery) finished() bool { return sq.startTimeoutElapsed.Load() && sq.allDone() } // Cleans up the query, unsubscribing from the query subject and closing the // response channel func (sq *SourceQuery) cleanup(ctx context.Context) { span := trace.SpanFromContext(ctx) if sq.querySub != nil && sq.querySub.IsValid() { err := sq.querySub.Unsubscribe() if err != nil { log.WithField("error", err).Error("Error unsubscribing from query subject") span.RecordError(err) } } close(sq.responseChan) sq.cancel() } // Sends the item back to the response channel, also extracts and synthesises // edges from `LinkedItems` and `LinkedItemQueries` and sends them back too func (sq *SourceQuery) handleItem(item *Item) { if item == nil { return } // Send the item back over the channel // TODO(LIQs): translation is not necessary anymore; update code and method comment item, edges := TranslateLinksToEdges(item) sq.responseChan <- &QueryResponse{ ResponseType: &QueryResponse_NewItem{NewItem: item}, } for _, e := range edges { sq.responseChan <- &QueryResponse{ ResponseType: &QueryResponse_Edge{Edge: e}, } } } // Sends the edge back to the response channel func (sq *SourceQuery) handleEdge(edge *Edge) { if edge == nil { return } sq.responseChan <- &QueryResponse{ ResponseType: &QueryResponse_Edge{Edge: edge}, } } // Send the error back to the response channel func (sq *SourceQuery) handleError(err *QueryError) { if err == nil { return } sq.responseChan <- &QueryResponse{ ResponseType: &QueryResponse_Error{ Error: err, }, } } // Update the internal state with the response func (sq *SourceQuery) handleResponse(ctx context.Context, response *Response) { span := trace.SpanFromContext(ctx) // do not deal with responses that do not have a responder UUID ru, err := uuid.FromBytes(response.GetResponderUUID()) if err != nil { span.RecordError(fmt.Errorf("error parsing responder UUID: %w", err)) return } sq.respondersMu.Lock() defer sq.respondersMu.Unlock() // Protect against out-of order responses. Do not mark a responder as // working if it has already finished. this should never happen, but we want // to know if it does as it will indicate a bug in the responder itself last, exists := sq.responders[ru] if exists { if last.Response != nil { switch last.Response.GetState() { case ResponderState_COMPLETE, ResponderState_ERROR, ResponderState_CANCELLED: err = fmt.Errorf("out-of-order response. Responder was already in the state %v, skipping update to %v", last.Response.String(), response.GetState().String()) span.RecordError(err) sentry.CaptureException(err) return case ResponderState_WORKING, ResponderState_STALLED: // This is fine, we can update the state } } } // Update the stored data sq.responders[ru] = &lastResponse{ Response: response, Timestamp: time.Now(), } } // Checks whether all responders are done or not. A "Done" responder is one that // is either: Complete, Error, Cancelled or Stalled // // Note that this doesn't perform locking if the mutex, this needs to be done by // the caller func (sq *SourceQuery) allDone() bool { sq.respondersMu.Lock() defer sq.respondersMu.Unlock() for _, lastResponse := range sq.responders { // Recalculate the stall status lastResponse.checkStalled() if lastResponse.Response.GetState() == ResponderState_WORKING { return false } } return true } // TranslateLinksToEdges Translates linked items and queries into edges. This is // a temporary stop gap measure to allow parallel processing of items and edges // in the gateway while allowing other parts of the system to be updated // independently. See https://github.com/overmindtech/workspace/issues/753 func TranslateLinksToEdges(item *Item) (*Item, []*Edge) { // TODO(LIQs): translation is not necessary anymore; delete this method and all callsites lis := item.GetLinkedItems() item.LinkedItems = nil liqs := item.GetLinkedItemQueries() item.LinkedItemQueries = nil edges := []*Edge{} for _, li := range lis { edges = append(edges, &Edge{ From: item.Reference(), To: li.GetItem(), }) } for _, liq := range liqs { edges = append(edges, &Edge{ From: item.Reference(), To: liq.GetQuery().Reference(), }) } return item, edges } // Progress returns the current progress statistics for the query, including // counts of responders in each state (Working, Stalled, Complete, Error, Cancelled). func (sq *SourceQuery) Progress() SourceQueryProgress { sq.respondersMu.Lock() defer sq.respondersMu.Unlock() var numWorking, numStalled, numComplete, numError, numCancelled int // Loop over all responders once and calculate the progress for _, lastResponse := range sq.responders { // Recalculate the stall status lastResponse.checkStalled() switch lastResponse.Response.GetState() { case ResponderState_WORKING: numWorking++ case ResponderState_STALLED: numStalled++ case ResponderState_COMPLETE: numComplete++ case ResponderState_ERROR: numError++ case ResponderState_CANCELLED: numCancelled++ } } return SourceQueryProgress{ Working: numWorking, Stalled: numStalled, Complete: numComplete, Error: numError, Cancelled: numCancelled, Responders: len(sq.responders), } } // String returns a human-readable summary of the query progress. func (sq *SourceQuery) String() string { progress := sq.Progress() return fmt.Sprintf( "Working: %v\nStalled: %v\nComplete: %v\nError: %v\nCancelled: %v\nResponders: %v\n", progress.Working, progress.Stalled, progress.Complete, progress.Error, progress.Cancelled, progress.Responders, ) } ================================================ FILE: go/sdp-go/progress_test.go ================================================ package sdp import ( "context" "errors" "fmt" "math/rand" "os" "sync" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/nats-io/nats.go" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" ) func TestRunSourceQueryParams(t *testing.T) { u := uuid.New() q := Query{ Type: "person", Method: QueryMethod_GET, Query: "dylan", RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "test", IgnoreCache: false, UUID: u[:], Deadline: timestamppb.New(time.Now().Add(20 * time.Second)), } t.Run("with no start timeout", func(t *testing.T) { _, err := RunSourceQuery(t.Context(), &q, 0, nil, nil) if err == nil { t.Error("expected an error when there is not startTimeout") } }) } func TestResponseNilPublisher(t *testing.T) { ctx := context.Background() rs := ResponseSender{ ResponseInterval: (10 * time.Millisecond), ResponseSubject: "responses", } // Start sending responses with a nil connection, should not panic rs.Start(ctx, nil, "test", uuid.New()) // Give it enough time for ~10 responses time.Sleep(100 * time.Millisecond) // Stop rs.DoneWithContext(ctx) } func TestResponseSenderDone(t *testing.T) { ctx := context.Background() rs := ResponseSender{ ResponseInterval: (10 * time.Millisecond), ResponseSubject: "responses", } tc := TestConnection{IgnoreNoResponders: true} // Start sending responses rs.Start(ctx, &tc, "test", uuid.New()) // Give it enough time for ~10 responses time.Sleep(100 * time.Millisecond) // Stop rs.DoneWithContext(ctx) // Let it drain down time.Sleep(100 * time.Millisecond) // Inspect what was sent tc.MessagesMu.Lock() if len(tc.Messages) <= 10 { t.Errorf("Expected <= 10 responses to be sent, found %v", len(tc.Messages)) } // Make sure that the final message was a completion one finalMessage := tc.Messages[len(tc.Messages)-1] tc.MessagesMu.Unlock() if queryResponse, ok := finalMessage.V.(*QueryResponse); ok { if finalResponse, ok := queryResponse.GetResponseType().(*QueryResponse_Response); ok { if finalResponse.Response.GetState() != ResponderState_COMPLETE { t.Errorf("Expected final message state to be COMPLETE (1), found: %v", finalResponse.Response.GetState()) } } else { t.Errorf("Final QueryResponse did not contain a valid Response object. Message content type %T", queryResponse.GetResponseType()) } } else { t.Errorf("Final message did not contain a valid response object. Message content type %T", finalMessage.V) } } func TestResponseSenderError(t *testing.T) { ctx := context.Background() rs := ResponseSender{ ResponseInterval: (10 * time.Millisecond), ResponseSubject: "responses", } tc := TestConnection{IgnoreNoResponders: true} // Start sending responses rs.Start(ctx, &tc, "test", uuid.New()) // Give it enough time for >10 responses time.Sleep(120 * time.Millisecond) // Stop rs.ErrorWithContext(ctx) // Let it drain down time.Sleep(100 * time.Millisecond) // Inspect what was sent tc.MessagesMu.Lock() if len(tc.Messages) <= 10 { t.Errorf("Expected <= 10 responses to be sent, found %v", len(tc.Messages)) } // Make sure that the final message was a completion one finalMessage := tc.Messages[len(tc.Messages)-1] tc.MessagesMu.Unlock() if queryResponse, ok := finalMessage.V.(*QueryResponse); ok { if finalResponse, ok := queryResponse.GetResponseType().(*QueryResponse_Response); ok { if finalResponse.Response.GetState() != ResponderState_ERROR { t.Errorf("Expected final message state to be ERROR, found: %v", finalResponse.Response.GetState()) } } else { t.Errorf("Final QueryResponse did not contain a valid Response object. Message content type %T", queryResponse.GetResponseType()) } } else { t.Errorf("Final message did not contain a valid response object. Message content type %T", finalMessage.V) } } func TestResponseSenderCancel(t *testing.T) { ctx := context.Background() rs := ResponseSender{ ResponseInterval: (10 * time.Millisecond), ResponseSubject: "responses", } tc := TestConnection{IgnoreNoResponders: true} // Start sending responses rs.Start(ctx, &tc, "test", uuid.New()) // Give it enough time for >10 responses time.Sleep(120 * time.Millisecond) // Stop rs.CancelWithContext(ctx) // Let it drain down time.Sleep(100 * time.Millisecond) // Inspect what was sent tc.MessagesMu.Lock() if len(tc.Messages) <= 10 { t.Errorf("Expected <= 10 responses to be sent, found %v", len(tc.Messages)) } // Make sure that the final message was a completion one finalMessage := tc.Messages[len(tc.Messages)-1] tc.MessagesMu.Unlock() if queryResponse, ok := finalMessage.V.(*QueryResponse); ok { if finalResponse, ok := queryResponse.GetResponseType().(*QueryResponse_Response); ok { if finalResponse.Response.GetState() != ResponderState_CANCELLED { t.Errorf("Expected final message state to be CANCELLED, found: %v", finalResponse.Response.GetState()) } } else { t.Errorf("Final QueryResponse did not contain a valid Response object. Message content type %T", queryResponse.GetResponseType()) } } else { t.Errorf("Final message did not contain a valid response object. Message content type %T", finalMessage.V) } } func TestDefaultResponseInterval(t *testing.T) { ctx := context.Background() rs := ResponseSender{} rs.Start(ctx, &TestConnection{}, "", uuid.New()) rs.KillWithContext(ctx) if rs.ResponseInterval != DefaultResponseInterval { t.Fatal("Response sender interval failed to default") } } // ExpectToMatch Checks that metrics are as expected and returns an error if not func (expected SourceQueryProgress) ExpectToMatch(qp *SourceQuery) error { actual := qp.Progress() var err error if expected.Working != actual.Working { err = errors.Join(err, fmt.Errorf("Expected Working to be %v, got %v", expected.Working, actual.Working)) } if expected.Stalled != actual.Stalled { err = errors.Join(err, fmt.Errorf("Expected Stalled to be %v, got %v", expected.Stalled, actual.Stalled)) } if expected.Complete != actual.Complete { err = errors.Join(err, fmt.Errorf("Expected Complete to be %v, got %v", expected.Complete, actual.Complete)) } if expected.Error != actual.Error { err = errors.Join(err, fmt.Errorf("Expected Error to be %v, got %v", expected.Error, actual.Error)) } if expected.Responders != actual.Responders { err = errors.Join(err, fmt.Errorf("Expected Responders to be %v, got %v", expected.Responders, actual.Responders)) } if expected.Cancelled != actual.Cancelled { err = errors.Join(err, fmt.Errorf("Expected Cancelled to be %v, got %v", expected.Cancelled, actual.Cancelled)) } return err } // Create a channel that discards everything func devNull() chan<- *QueryResponse { c := make(chan *QueryResponse, 128) go func() { for range c { } }() return c } func TestQueryProgressNormal(t *testing.T) { t.Parallel() ctx := t.Context() tc := TestConnection{IgnoreNoResponders: true} sq, err := RunSourceQuery(ctx, &query, DefaultStartTimeout, &tc, devNull()) if err != nil { t.Fatal(err) } ru1 := uuid.New() ru2 := uuid.New() ru3 := uuid.New() t.Logf("UUIDs: %v %v %v", ru1, ru2, ru3) // Make sure that the details are correct initially var expected SourceQueryProgress expected = SourceQueryProgress{ Working: 0, Stalled: 0, Complete: 0, Error: 0, Responders: 0, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } t.Run("Processing initial response", func(t *testing.T) { // Test the initial response sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) expected = SourceQueryProgress{ Working: 1, Stalled: 0, Complete: 0, Error: 0, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) t.Run("Processing when other scopes also responding", func(t *testing.T) { // Then another scope starts working sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru2[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru3[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) expected = SourceQueryProgress{ Working: 3, Stalled: 0, Complete: 0, Error: 0, Responders: 3, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) t.Run("When some are complete and some are not", func(t *testing.T) { time.Sleep(5 * time.Millisecond) // test 1 still working sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) // Test 2 finishes sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru2[:], State: ResponderState_COMPLETE, }, }, }) // Test 3 still working sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru3[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) expected = SourceQueryProgress{ Working: 2, Stalled: 0, Complete: 1, Error: 0, Responders: 3, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) t.Run("When one is cancelled", func(t *testing.T) { time.Sleep(5 * time.Millisecond) // test 1 still working sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) // Test 3 cancelled sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru3[:], State: ResponderState_CANCELLED, }, }, }) expected = SourceQueryProgress{ Working: 1, Stalled: 0, Complete: 1, Error: 0, Cancelled: 1, Responders: 3, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) t.Run("When the final responder finishes", func(t *testing.T) { time.Sleep(5 * time.Millisecond) // Test 1 finishes sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_COMPLETE, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) expected = SourceQueryProgress{ Working: 0, Stalled: 0, Complete: 2, Error: 0, Cancelled: 1, Responders: 3, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) if sq.allDone() == false { t.Error("expected allDone() to be true") } } func TestQueryProgressParallel(t *testing.T) { t.Parallel() ctx := t.Context() tc := TestConnection{IgnoreNoResponders: true} sq, err := RunSourceQuery(ctx, &query, DefaultStartTimeout, &tc, devNull()) if err != nil { t.Fatal(err) } ru1 := uuid.New() // Make sure that the details are correct initially var expected SourceQueryProgress expected = SourceQueryProgress{ Working: 0, Stalled: 0, Complete: 0, Error: 0, Responders: 0, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } t.Run("Processing many bunched responses", func(t *testing.T) { var wg sync.WaitGroup for i := 0; i != 10; i++ { wg.Go(func() { // Test the initial response sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) }) } wg.Wait() expected = SourceQueryProgress{ Working: 1, Stalled: 0, Complete: 0, Error: 0, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) } func TestQueryProgressStalled(t *testing.T) { t.Parallel() ctx := t.Context() tc := TestConnection{IgnoreNoResponders: true} sq, err := RunSourceQuery(ctx, &query, DefaultStartTimeout, &tc, devNull()) if err != nil { t.Fatal(err) } ru1 := uuid.New() // Make sure that the details are correct initially var expected SourceQueryProgress t.Run("Processing the initial response", func(t *testing.T) { // Test the initial response sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) expected = SourceQueryProgress{ Working: 1, Stalled: 0, Complete: 0, Error: 0, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) t.Run("After a responder has stalled", func(t *testing.T) { // Wait long enough for the thing to be marked as stalled time.Sleep(20 * time.Millisecond) expected = SourceQueryProgress{ Working: 0, Stalled: 1, Complete: 0, Error: 0, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } sq.respondersMu.Lock() defer sq.respondersMu.Unlock() if _, ok := sq.responders[ru1]; !ok { t.Error("Could not get responder for scope test1") } }) t.Run("After a responder recovers from a stall", func(t *testing.T) { // See if it will un-stall itself sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_COMPLETE, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) expected = SourceQueryProgress{ Working: 0, Stalled: 0, Complete: 1, Error: 0, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) if sq.allDone() == false { t.Error("expected allDone() to be true") } } func TestRogueResponder(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) tc := TestConnection{IgnoreNoResponders: true} sq, err := RunSourceQuery(ctx, &query, 100*time.Millisecond, &tc, devNull()) if err != nil { t.Fatal(err) } rur := uuid.New() // Create our rogue responder that doesn't cancel when it should ticker := time.NewTicker(5 * time.Second) tickerCtx := t.Context() defer ticker.Stop() go func() { // Send an initial response sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: rur[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(5 * time.Second), }, }, }) // Now start ticking for { select { case <-ticker.C: sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: rur[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(5 * time.Second), }, }, }) case <-tickerCtx.Done(): return } } }() time.Sleep(300 * time.Millisecond) // Check that we've noticed the testRogue responder if sq.allDone() == true { t.Error("expected allDone() to be false") } cancel() time.Sleep(100 * time.Millisecond) // We expect that it has been marked as cancelled, regardless of what the // responder actually did expected := SourceQueryProgress{ Working: 0, Stalled: 0, Complete: 0, Error: 0, Responders: 1, Cancelled: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } } func TestQueryProgressError(t *testing.T) { t.Parallel() ctx := t.Context() tc := TestConnection{IgnoreNoResponders: true} sq, err := RunSourceQuery(ctx, &query, DefaultStartTimeout, &tc, devNull()) if err != nil { t.Fatal(err) } ru1 := uuid.New() // Make sure that the details are correct initially var expected SourceQueryProgress t.Run("Processing the initial response", func(t *testing.T) { // Test the initial response sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(10 * time.Millisecond), }, }, }) expected = SourceQueryProgress{ Working: 1, Stalled: 0, Complete: 0, Error: 0, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) t.Run("After a responder has failed", func(t *testing.T) { sq.handleQueryResponse(ctx, &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_ERROR, }, }, }) expected = SourceQueryProgress{ Working: 0, Stalled: 0, Complete: 0, Error: 1, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) t.Run("Ensuring that a failed responder does not get marked as stalled", func(t *testing.T) { time.Sleep(12 * time.Millisecond) expected = SourceQueryProgress{ Working: 0, Stalled: 0, Complete: 0, Error: 1, Responders: 1, } if err := expected.ExpectToMatch(sq); err != nil { t.Error(err) } }) if sq.allDone() == false { t.Error("expected allDone() to be true") } } func TestStart(t *testing.T) { t.Parallel() ctx := t.Context() tc := TestConnection{IgnoreNoResponders: true} responses := make(chan *QueryResponse, 128) // this emulates a source sourceHit := atomic.Bool{} _, err := tc.Subscribe(fmt.Sprintf("request.scope.%v", query.GetScope()), func(msg *nats.Msg) { sourceHit.Store(true) response := QueryResponse{ ResponseType: &QueryResponse_NewItem{ NewItem: &item, }, } // Test that the handlers work err := tc.Publish(ctx, query.Subject(), &response) if err != nil { t.Fatal(err) } }) if err != nil { t.Fatal(err) } _, err = RunSourceQuery(ctx, &query, DefaultStartTimeout, &tc, responses) if err != nil { t.Fatal(err) } response := <-responses tc.MessagesMu.Lock() if len(tc.Messages) != 2 { t.Errorf("expected 2 messages to be sent, got %v", len(tc.Messages)) } tc.MessagesMu.Unlock() returnedItem := response.GetNewItem() if returnedItem == nil { t.Fatal("expected item to be returned") } if returnedItem.Hash() != item.Hash() { t.Error("item hash mismatch") } if !sourceHit.Load() { t.Error("source was not hit") } } func TestExecute(t *testing.T) { t.Parallel() t.Run("with no responders", func(t *testing.T) { conn := TestConnection{} _, err := conn.Subscribe("request.scope.global", func(msg *nats.Msg) {}) if err != nil { t.Fatal(err) } u := uuid.New() q := Query{ Type: "user", Method: QueryMethod_GET, Query: "Dylan", RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "global", IgnoreCache: false, UUID: u[:], Deadline: timestamppb.New(time.Now().Add(10 * time.Second)), } _, _, _, err = RunSourceQuerySync(t.Context(), &q, 100*time.Millisecond, &conn) if err != nil { t.Fatal(err) } }) t.Run("with a full response set", func(t *testing.T) { conn := TestConnection{} _, err := conn.Subscribe("request.scope.global", func(msg *nats.Msg) {}) if err != nil { t.Fatal(err) } u := uuid.New() q := Query{ Type: "user", Method: QueryMethod_GET, Query: "Dylan", RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 0, }, Scope: "global", IgnoreCache: false, UUID: u[:], Deadline: timestamppb.New(time.Now().Add(10 * time.Second)), } querySent := make(chan struct{}) done := make(chan struct{}) go func() { defer close(done) // wait for the query to be sent <-querySent ru1 := uuid.New() err := conn.Publish(context.Background(), q.Subject(), &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, UUID: q.GetUUID(), NextUpdateIn: &durationpb.Duration{ Seconds: 10, Nanos: 0, }, }, }, }) if err != nil { t.Error(err) } err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ ResponseType: &QueryResponse_NewItem{ NewItem: &item, }, }) if err != nil { t.Error(err) } err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ ResponseType: &QueryResponse_NewItem{ NewItem: &item, }, }) if err != nil { t.Error(err) } err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ ResponseType: &QueryResponse_Response{ Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_COMPLETE, UUID: q.GetUUID(), }, }, }) if err != nil { t.Error(err) } }() responseChan := make(chan *QueryResponse) // items, _, errs, err := RunSourceQuerySync(t.Context(), &q, DefaultStartTimeout, &conn) _, err = RunSourceQuery(t.Context(), &q, DefaultStartTimeout, &conn, responseChan) if err != nil { t.Fatal(err) } close(querySent) items := []*Item{} errs := []*QueryError{} for r := range responseChan { if r == nil { t.Fatal("expected a response") } switch r.GetResponseType().(type) { case *QueryResponse_NewItem: items = append(items, r.GetNewItem()) case *QueryResponse_Error: errs = append(errs, r.GetError()) default: t.Errorf("unexpected response type: %T", r.GetResponseType()) } } <-done if len(items) != 2 { t.Errorf("expected 2 items got %v: %v", len(items), items) } if len(errs) != 0 { t.Errorf("expected 0 errors got %v: %v", len(errs), errs) } }) } func TestRealNats(t *testing.T) { nc, err := nats.Connect("nats://localhost,nats://nats") if err != nil { t.Skip("No NATS connection") } enc := EncodedConnectionImpl{Conn: nc} u := uuid.New() q := Query{ Type: "person", Method: QueryMethod_GET, Query: "dylan", Scope: "global", UUID: u[:], } ru1 := uuid.New() ready := make(chan bool) go func() { _, err := enc.Subscribe("request.scope.global", NewQueryHandler("test", func(ctx context.Context, handledQuery *Query) { delay := 100 * time.Millisecond time.Sleep(delay) err := enc.Publish(ctx, q.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_WORKING, UUID: q.GetUUID(), NextUpdateIn: &durationpb.Duration{ Seconds: 10, Nanos: 0, }, }}}) if err != nil { t.Error(err) } time.Sleep(delay) err = enc.Publish(ctx, q.Subject(), &QueryResponse{ResponseType: &QueryResponse_NewItem{NewItem: &item}}) if err != nil { t.Error(err) } err = enc.Publish(ctx, q.Subject(), &QueryResponse{ResponseType: &QueryResponse_NewItem{NewItem: &item}}) if err != nil { t.Error(err) } err = enc.Publish(ctx, q.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: "test", ResponderUUID: ru1[:], State: ResponderState_COMPLETE, UUID: q.GetUUID(), }}}) if err != nil { t.Error(err) } })) if err != nil { t.Error(err) } ready <- true }() <-ready slowChan := make(chan *QueryResponse) _, err = RunSourceQuery(t.Context(), &q, DefaultStartTimeout, &enc, slowChan) if err != nil { t.Fatal(err) } for i := range slowChan { time.Sleep(100 * time.Millisecond) t.Log(i) } } func TestFastFinisher(t *testing.T) { t.Parallel() // Test for a situation where there is one responder that finishes really // quickly and results in the other responders not getting a chance to start conn := TestConnection{} fast := uuid.New() slow := uuid.New() // Set up the fast responder, it should respond immediately and take only // 100ms to complete its work _, err := conn.Subscribe("request.scope.global", func(msg *nats.Msg) { // Make sure this is the request var q Query err := proto.Unmarshal(msg.Data, &q) if err != nil { t.Error(err) } // Respond immediately saying we're started err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: "test", ResponderUUID: fast[:], State: ResponderState_WORKING, UUID: q.GetUUID(), NextUpdateIn: &durationpb.Duration{ Seconds: 1, Nanos: 0, }, }}}) if err != nil { t.Fatal(err) } time.Sleep(100 * time.Millisecond) // Send an item err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ResponseType: &QueryResponse_NewItem{NewItem: newItem()}}) if err != nil { t.Fatal(err) } // Send a complete message err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: "test", ResponderUUID: fast[:], State: ResponderState_COMPLETE, UUID: q.GetUUID(), }}}) if err != nil { t.Fatal(err) } }) if err != nil { t.Fatal(err) } // Set up another responder that takes 250ms to start _, err = conn.Subscribe("request.scope.global", func(msg *nats.Msg) { // Unmarshal the query var q Query err := proto.Unmarshal(msg.Data, &q) if err != nil { t.Error(err) } // Wait 250ms before starting time.Sleep(250 * time.Millisecond) err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: "test", ResponderUUID: slow[:], State: ResponderState_WORKING, UUID: q.GetUUID(), NextUpdateIn: &durationpb.Duration{ Seconds: 1, Nanos: 0, }, }}}) if err != nil { t.Fatal(err) } // Send an item item := newItem() err = item.GetAttributes().Set("name", "baz") if err != nil { t.Fatal(err) } err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ResponseType: &QueryResponse_NewItem{NewItem: item}}) if err != nil { t.Fatal(err) } // Send a complete message err = conn.Publish(context.Background(), q.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: "test", ResponderUUID: slow[:], State: ResponderState_COMPLETE, UUID: q.GetUUID(), }}}) if err != nil { t.Fatal(err) } }) if err != nil { t.Fatal(err) } items, _, errs, err := RunSourceQuerySync(t.Context(), newQuery(), 500*time.Millisecond, &conn) if err != nil { t.Fatal(err) } if len(items) != 2 { t.Errorf("Expected 2 items, got %d: %v", len(items), items) } if len(errs) != 0 { t.Errorf("Expected 0 errors, got %d: %v", len(errs), errs) } } // This source will simply respond to any query that it sent with a configured // number of items, and configurable delays. This is designed to replicate a // real system at scale type SimpleSource struct { // How many items to return from the query NumItemsReturn int // How long to wait before starting work on the query StartDelay time.Duration // How long to wait before sending each item PerItemDelay time.Duration // How long to wait before sending the completion message CompletionDelay time.Duration // The connection to use Conn *TestConnection // The probability of stalling where 0 is no stall and 1 is always stall StallProbability float64 // The probability of failing where 0 is no fail and 1 is always fail FailProbability float64 // The responder name to use ResponderName string } func (s *SimpleSource) Start(ctx context.Context, t *testing.T) { // ignore errors from test connection _, _ = s.Conn.Subscribe("request.>", func(msg *nats.Msg) { // Run these in parallel go func(msg *nats.Msg) { query := &Query{} err := Unmarshal(ctx, msg.Data, query) if err != nil { panic(fmt.Errorf("Unmarshal(%v): %w", query, err)) } // Create the number of items that were requested items := make([]*Item, s.NumItemsReturn) for i := range s.NumItemsReturn { items[i] = newItem() } // Make a UUID for yourself responderUUID := uuid.New() // Wait for the start delay time.Sleep(s.StartDelay) // Calculate the expected duration of the query expectedQueryDuration := (s.PerItemDelay * time.Duration(s.NumItemsReturn)) + s.CompletionDelay + 500*time.Millisecond err = s.Conn.Publish(ctx, query.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: s.ResponderName, ResponderUUID: responderUUID[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(expectedQueryDuration), UUID: query.GetUUID(), }}}) if err != nil { t.Errorf("error publishing response: %v", err) } for _, item := range items { time.Sleep(s.PerItemDelay) err = s.Conn.Publish(ctx, query.Subject(), &QueryResponse{ResponseType: &QueryResponse_NewItem{NewItem: item}}) if err != nil { t.Errorf("error publishing item: %v", err) } } // Stall with a certain probability if rand.Float64() < s.StallProbability { return } // Fail with a certain probability if rand.Float64() < s.FailProbability { err = s.Conn.Publish(ctx, query.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: s.ResponderName, ResponderUUID: responderUUID[:], State: ResponderState_ERROR, UUID: query.GetUUID(), }}}) if err != nil { t.Errorf("error publishing response: %v", err) } return } time.Sleep(s.CompletionDelay) err = s.Conn.Publish(ctx, query.Subject(), &QueryResponse{ResponseType: &QueryResponse_Response{Response: &Response{ Responder: s.ResponderName, ResponderUUID: responderUUID[:], State: ResponderState_COMPLETE, UUID: query.GetUUID(), }}}) if err != nil { t.Errorf("error publishing response: %v", err) } }(msg) }) } func TestMassiveScale(t *testing.T) { t.Parallel() if _, exists := os.LookupEnv("GITHUB_ACTIONS"); exists { // Note that in these tests we can push things even further, to 10,000 // sources for example. The problem is that once the CPU is context // switching too heavily you end up in a position where the sources // start getting marked as stalled as they don't have enough CPU to send // their messages quickly enough and they blow through their expected // timeout. // // They can also fail locally when using -race as this puts a lot more // load on the CPU than there would normally be t.Skip("These tests are too flaky due to reliance on wall clock time and fast timings") } tests := []struct { // The number of sources to create NumSources int // The maximum time to wait before starting MaxStartDelayMilliseconds int // The maximum time to wait between items MaxPerItemDelayMilliseconds int // The maximum time to wait before completion MaxCompletionDelayMilliseconds int // The maximum number of items to return MaxItemsToReturn int // The probability of a source stalling where 0 is no stall and 1 is // always stall StallProbability float64 // The probability of a source failing where 0 is no fail and 1 is // always fail FailProbability float64 // How long to give sources to start responding, over and above the // maxStartDelayMilliseconds StartDelayGracePeriodMilliseconds int }{ { NumSources: 100, MaxStartDelayMilliseconds: 100, MaxPerItemDelayMilliseconds: 10, MaxCompletionDelayMilliseconds: 100, MaxItemsToReturn: 100, StallProbability: 0.0, FailProbability: 0.0, StartDelayGracePeriodMilliseconds: 100, }, { NumSources: 1_000, MaxStartDelayMilliseconds: 100, MaxPerItemDelayMilliseconds: 10, MaxCompletionDelayMilliseconds: 100, MaxItemsToReturn: 100, StallProbability: 0.0, FailProbability: 0.0, StartDelayGracePeriodMilliseconds: 100, }, { NumSources: 100, MaxStartDelayMilliseconds: 100, MaxPerItemDelayMilliseconds: 10, MaxCompletionDelayMilliseconds: 100, MaxItemsToReturn: 100, StallProbability: 0.3, FailProbability: 0.0, StartDelayGracePeriodMilliseconds: 100, }, { NumSources: 1_000, MaxStartDelayMilliseconds: 100, MaxPerItemDelayMilliseconds: 10, MaxCompletionDelayMilliseconds: 100, MaxItemsToReturn: 100, StallProbability: 0.3, FailProbability: 0.0, StartDelayGracePeriodMilliseconds: 100, }, { NumSources: 100, MaxStartDelayMilliseconds: 100, MaxPerItemDelayMilliseconds: 10, MaxCompletionDelayMilliseconds: 100, MaxItemsToReturn: 100, StallProbability: 0.3, FailProbability: 0.3, StartDelayGracePeriodMilliseconds: 100, }, { NumSources: 1_000, MaxStartDelayMilliseconds: 100, MaxPerItemDelayMilliseconds: 10, MaxCompletionDelayMilliseconds: 100, MaxItemsToReturn: 100, StallProbability: 0.3, FailProbability: 0.3, StartDelayGracePeriodMilliseconds: 100, }, } for _, test := range tests { t.Run(fmt.Sprintf("NumSources %v, MaxStartDelay %v, MaxPerItemDelay %v, MaxCompletionDelay %v, MaxItemsToReturn %v, StallProbability %v, FailProbability %v, StartDelayGracePeriod %v", test.NumSources, test.MaxStartDelayMilliseconds, test.MaxPerItemDelayMilliseconds, test.MaxCompletionDelayMilliseconds, test.MaxItemsToReturn, test.StallProbability, test.FailProbability, test.StartDelayGracePeriodMilliseconds, ), func(t *testing.T) { tConn := TestConnection{} // Generate a random duration between 0 and maxDuration randomDuration := func(maxDuration int) time.Duration { return time.Duration(rand.Intn(maxDuration)) * time.Millisecond } expectedItems := 0 // Start all the sources sources := make([]*SimpleSource, test.NumSources) for i := range sources { numItemsReturn := rand.Intn(test.MaxItemsToReturn) expectedItems += numItemsReturn // Count how many items we expect to receive startDelay := randomDuration(test.MaxStartDelayMilliseconds) perItemDelay := randomDuration(test.MaxPerItemDelayMilliseconds) completionDelay := randomDuration(test.MaxCompletionDelayMilliseconds) sources[i] = &SimpleSource{ NumItemsReturn: numItemsReturn, StartDelay: startDelay, PerItemDelay: perItemDelay, CompletionDelay: completionDelay, StallProbability: test.StallProbability, FailProbability: test.FailProbability, Conn: &tConn, ResponderName: fmt.Sprintf("NumItems %v, StartDelay %v, PerItemDelay %v CompletionDelay %v", numItemsReturn, startDelay.String(), perItemDelay.String(), completionDelay.String(), ), } sources[i].Start(context.Background(), t) } // Create the query u := uuid.New() q := Query{ Type: "massive-scale-test", Method: QueryMethod_GET, Query: "GO!!!!!", Scope: "test", UUID: u[:], Deadline: timestamppb.New(time.Now().Add(60 * time.Second)), } responseChan := make(chan *QueryResponse) doneChan := make(chan struct{}) // Begin handling the responses actualItems := 0 go func() { for { select { case <-t.Context().Done(): return case response, ok := <-responseChan: if !ok { // Channel closed close(doneChan) return } switch response.GetResponseType().(type) { case *QueryResponse_NewItem: actualItems++ } } } }() // Start the query startTimeout := time.Duration(test.MaxStartDelayMilliseconds+test.StartDelayGracePeriodMilliseconds) * time.Millisecond qp, err := RunSourceQuery(t.Context(), &q, startTimeout, &tConn, responseChan) if err != nil { t.Fatal(err) } // Wait for the query to finish <-doneChan if actualItems != expectedItems { t.Errorf("Expected %v items, got %v", expectedItems, actualItems) } progress := qp.Progress() if progress.Responders != test.NumSources { t.Errorf("Expected %v responders, got %v", test.NumSources, progress.Responders) } fmt.Printf("Num Complete: %v\n", progress.Complete) fmt.Printf("Num Working: %v\n", progress.Working) fmt.Printf("Num Stalled: %v\n", progress.Stalled) fmt.Printf("Num Error: %v\n", progress.Error) fmt.Printf("Num Cancelled: %v\n", progress.Cancelled) fmt.Printf("Num Responders: %v\n", progress.Responders) fmt.Printf("Num Items: %v\n", actualItems) }) } } ================================================ FILE: go/sdp-go/proto_clone_test.go ================================================ package sdp import ( "testing" "github.com/google/uuid" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) // TestProtoCloneReplacesCustomCopy validates that proto.Clone works correctly // for all SDP types and can replace the custom Copy methods func TestProtoCloneReplacesCustomCopy(t *testing.T) { t.Run("Reference with all fields", func(t *testing.T) { original := &Reference{ Type: "test", UniqueAttributeValue: "value", Scope: "scope", IsQuery: true, Method: QueryMethod_SEARCH, Query: "search-term", } cloned := proto.Clone(original).(*Reference) if !proto.Equal(original, cloned) { t.Errorf("proto.Clone failed for Reference: %+v != %+v", original, cloned) } // Specifically check the fields that Copy() was missing if cloned.GetIsQuery() != original.GetIsQuery() { t.Errorf("IsQuery field not cloned correctly: got %v, want %v", cloned.GetIsQuery(), original.GetIsQuery()) } if cloned.GetMethod() != original.GetMethod() { t.Errorf("Method field not cloned correctly: got %v, want %v", cloned.GetMethod(), original.GetMethod()) } if cloned.GetQuery() != original.GetQuery() { t.Errorf("Query field not cloned correctly: got %v, want %v", cloned.GetQuery(), original.GetQuery()) } }) t.Run("Query with all fields", func(t *testing.T) { u := uuid.New() original := &Query{ Type: "test", Method: QueryMethod_GET, Query: "value", Scope: "scope", UUID: u[:], RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 5, }, IgnoreCache: true, Deadline: timestamppb.Now(), } cloned := proto.Clone(original).(*Query) if !proto.Equal(original, cloned) { t.Errorf("proto.Clone failed for Query: %+v != %+v", original, cloned) } }) t.Run("Item with all fields", func(t *testing.T) { original := &Item{ Type: "test", UniqueAttribute: "id", Scope: "scope", Metadata: &Metadata{ SourceName: "test-source", Hidden: true, Timestamp: timestamppb.Now(), }, Health: Health_HEALTH_OK.Enum(), Tags: map[string]string{ "key1": "value1", "key2": "value2", }, } // Add attributes attrs, err := ToAttributes(map[string]any{ "name": "test-item", "port": 8080, }) if err != nil { t.Fatal(err) } original.Attributes = attrs cloned := proto.Clone(original).(*Item) if !proto.Equal(original, cloned) { t.Errorf("proto.Clone failed for Item: %+v != %+v", original, cloned) } }) t.Run("All other SDP types", func(t *testing.T) { // LinkedItemQuery liq := &LinkedItemQuery{ Query: &Query{Type: "test", Method: QueryMethod_LIST}, } liqClone := proto.Clone(liq).(*LinkedItemQuery) if !proto.Equal(liq, liqClone) { t.Errorf("proto.Clone failed for LinkedItemQuery") } // LinkedItem li := &LinkedItem{ Item: &Reference{Type: "test", Scope: "scope"}, } liClone := proto.Clone(li).(*LinkedItem) if !proto.Equal(li, liClone) { t.Errorf("proto.Clone failed for LinkedItem") } // Metadata metadata := &Metadata{ SourceName: "test-source", Hidden: true, Timestamp: timestamppb.Now(), } metadataClone := proto.Clone(metadata).(*Metadata) if !proto.Equal(metadata, metadataClone) { t.Errorf("proto.Clone failed for Metadata") } // CancelQuery u := uuid.New() cancelQuery := &CancelQuery{UUID: u[:]} cancelQueryClone := proto.Clone(cancelQuery).(*CancelQuery) if !proto.Equal(cancelQuery, cancelQueryClone) { t.Errorf("proto.Clone failed for CancelQuery") } }) } ================================================ FILE: go/sdp-go/responses.go ================================================ package sdp // TODO: instead of translating, unify this func (r *Response) ToQueryStatus() *QueryStatus { return &QueryStatus{ UUID: r.GetUUID(), Status: r.GetState().ToQueryStatus(), } } // TODO: instead of translating, unify this func (r ResponderState) ToQueryStatus() QueryStatus_Status { switch r { case ResponderState_WORKING: return QueryStatus_STARTED case ResponderState_COMPLETE: return QueryStatus_FINISHED case ResponderState_ERROR: return QueryStatus_ERRORED case ResponderState_CANCELLED: return QueryStatus_CANCELLED case ResponderState_STALLED: return QueryStatus_ERRORED default: return QueryStatus_UNSPECIFIED } } ================================================ FILE: go/sdp-go/responses.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: responses.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // ResponderState represents the state of the responder, note that both // COMPLETE and ERROR are completion states i.e. do not expect any more items // to be returned from the query type ResponderState int32 const ( // The responder is still gathering data ResponderState_WORKING ResponderState = 0 // The query is complete ResponderState_COMPLETE ResponderState = 1 // All sources have returned errors ResponderState_ERROR ResponderState = 2 // Work has been cancelled while in progress ResponderState_CANCELLED ResponderState = 3 // The responder has not set a response in the expected interval ResponderState_STALLED ResponderState = 4 ) // Enum value maps for ResponderState. var ( ResponderState_name = map[int32]string{ 0: "WORKING", 1: "COMPLETE", 2: "ERROR", 3: "CANCELLED", 4: "STALLED", } ResponderState_value = map[string]int32{ "WORKING": 0, "COMPLETE": 1, "ERROR": 2, "CANCELLED": 3, "STALLED": 4, } ) func (x ResponderState) Enum() *ResponderState { p := new(ResponderState) *p = x return p } func (x ResponderState) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (ResponderState) Descriptor() protoreflect.EnumDescriptor { return file_responses_proto_enumTypes[0].Descriptor() } func (ResponderState) Type() protoreflect.EnumType { return &file_responses_proto_enumTypes[0] } func (x ResponderState) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use ResponderState.Descriptor instead. func (ResponderState) EnumDescriptor() ([]byte, []int) { return file_responses_proto_rawDescGZIP(), []int{0} } // Response is returned when a query is made type Response struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the responder that is working on a response. This is purely // informational Responder string `protobuf:"bytes,1,opt,name=responder,proto3" json:"responder,omitempty"` // The state of the responder State ResponderState `protobuf:"varint,2,opt,name=state,proto3,enum=ResponderState" json:"state,omitempty"` // The timespan within which to expect the next update. (e.g. 10s) If no // further interim responses are received within this time the connection // can be considered stale and the requester may give up NextUpdateIn *durationpb.Duration `protobuf:"bytes,3,opt,name=nextUpdateIn,proto3" json:"nextUpdateIn,omitempty"` // UUID of the item query that this response is in relation to (in binary // format) UUID []byte `protobuf:"bytes,4,opt,name=UUID,proto3" json:"UUID,omitempty"` // The ID of the responder that is working on a response. This is used for // internal bookkeeping and should remain constant for the duration of a // request, preferably over the lifetime of the source process. ResponderUUID []byte `protobuf:"bytes,5,opt,name=responderUUID,proto3" json:"responderUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Response) Reset() { *x = Response{} mi := &file_responses_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Response) String() string { return protoimpl.X.MessageStringOf(x) } func (*Response) ProtoMessage() {} func (x *Response) ProtoReflect() protoreflect.Message { mi := &file_responses_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Response.ProtoReflect.Descriptor instead. func (*Response) Descriptor() ([]byte, []int) { return file_responses_proto_rawDescGZIP(), []int{0} } func (x *Response) GetResponder() string { if x != nil { return x.Responder } return "" } func (x *Response) GetState() ResponderState { if x != nil { return x.State } return ResponderState_WORKING } func (x *Response) GetNextUpdateIn() *durationpb.Duration { if x != nil { return x.NextUpdateIn } return nil } func (x *Response) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *Response) GetResponderUUID() []byte { if x != nil { return x.ResponderUUID } return nil } var File_responses_proto protoreflect.FileDescriptor const file_responses_proto_rawDesc = "" + "\n" + "\x0fresponses.proto\x1a\x1egoogle/protobuf/duration.proto\"\xc8\x01\n" + "\bResponse\x12\x1c\n" + "\tresponder\x18\x01 \x01(\tR\tresponder\x12%\n" + "\x05state\x18\x02 \x01(\x0e2\x0f.ResponderStateR\x05state\x12=\n" + "\fnextUpdateIn\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\fnextUpdateIn\x12\x12\n" + "\x04UUID\x18\x04 \x01(\fR\x04UUID\x12$\n" + "\rresponderUUID\x18\x05 \x01(\fR\rresponderUUID*R\n" + "\x0eResponderState\x12\v\n" + "\aWORKING\x10\x00\x12\f\n" + "\bCOMPLETE\x10\x01\x12\t\n" + "\x05ERROR\x10\x02\x12\r\n" + "\tCANCELLED\x10\x03\x12\v\n" + "\aSTALLED\x10\x04B1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_responses_proto_rawDescOnce sync.Once file_responses_proto_rawDescData []byte ) func file_responses_proto_rawDescGZIP() []byte { file_responses_proto_rawDescOnce.Do(func() { file_responses_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_responses_proto_rawDesc), len(file_responses_proto_rawDesc))) }) return file_responses_proto_rawDescData } var file_responses_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_responses_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_responses_proto_goTypes = []any{ (ResponderState)(0), // 0: ResponderState (*Response)(nil), // 1: Response (*durationpb.Duration)(nil), // 2: google.protobuf.Duration } var file_responses_proto_depIdxs = []int32{ 0, // 0: Response.state:type_name -> ResponderState 2, // 1: Response.nextUpdateIn:type_name -> google.protobuf.Duration 2, // [2:2] is the sub-list for method output_type 2, // [2:2] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name } func init() { file_responses_proto_init() } func file_responses_proto_init() { if File_responses_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_responses_proto_rawDesc), len(file_responses_proto_rawDesc)), NumEnums: 1, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_responses_proto_goTypes, DependencyIndexes: file_responses_proto_depIdxs, EnumInfos: file_responses_proto_enumTypes, MessageInfos: file_responses_proto_msgTypes, }.Build() File_responses_proto = out.File file_responses_proto_goTypes = nil file_responses_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/revlink.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: revlink.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" _ "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type GetReverseEdgesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The account that the item belongs to Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` // The item that you would like to find reverse edges for ItemRef *Reference `protobuf:"bytes,2,opt,name=itemRef,proto3" json:"itemRef,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetReverseEdgesRequest) Reset() { *x = GetReverseEdgesRequest{} mi := &file_revlink_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetReverseEdgesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetReverseEdgesRequest) ProtoMessage() {} func (x *GetReverseEdgesRequest) ProtoReflect() protoreflect.Message { mi := &file_revlink_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetReverseEdgesRequest.ProtoReflect.Descriptor instead. func (*GetReverseEdgesRequest) Descriptor() ([]byte, []int) { return file_revlink_proto_rawDescGZIP(), []int{0} } func (x *GetReverseEdgesRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *GetReverseEdgesRequest) GetItemRef() *Reference { if x != nil { return x.ItemRef } return nil } type GetReverseEdgesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The edges to the requested item Edges []*Edge `protobuf:"bytes,1,rep,name=edges,proto3" json:"edges,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetReverseEdgesResponse) Reset() { *x = GetReverseEdgesResponse{} mi := &file_revlink_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetReverseEdgesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetReverseEdgesResponse) ProtoMessage() {} func (x *GetReverseEdgesResponse) ProtoReflect() protoreflect.Message { mi := &file_revlink_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetReverseEdgesResponse.ProtoReflect.Descriptor instead. func (*GetReverseEdgesResponse) Descriptor() ([]byte, []int) { return file_revlink_proto_rawDescGZIP(), []int{1} } func (x *GetReverseEdgesResponse) GetEdges() []*Edge { if x != nil { return x.Edges } return nil } type IngestGatewayResponseRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The account that the response belongs to Account string `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` // The response type to ingest // // Types that are valid to be assigned to ResponseType: // // *IngestGatewayResponseRequest_NewItem // *IngestGatewayResponseRequest_NewEdge ResponseType isIngestGatewayResponseRequest_ResponseType `protobuf_oneof:"response_type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestGatewayResponseRequest) Reset() { *x = IngestGatewayResponseRequest{} mi := &file_revlink_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestGatewayResponseRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestGatewayResponseRequest) ProtoMessage() {} func (x *IngestGatewayResponseRequest) ProtoReflect() protoreflect.Message { mi := &file_revlink_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestGatewayResponseRequest.ProtoReflect.Descriptor instead. func (*IngestGatewayResponseRequest) Descriptor() ([]byte, []int) { return file_revlink_proto_rawDescGZIP(), []int{2} } func (x *IngestGatewayResponseRequest) GetAccount() string { if x != nil { return x.Account } return "" } func (x *IngestGatewayResponseRequest) GetResponseType() isIngestGatewayResponseRequest_ResponseType { if x != nil { return x.ResponseType } return nil } func (x *IngestGatewayResponseRequest) GetNewItem() *Item { if x != nil { if x, ok := x.ResponseType.(*IngestGatewayResponseRequest_NewItem); ok { return x.NewItem } } return nil } func (x *IngestGatewayResponseRequest) GetNewEdge() *Edge { if x != nil { if x, ok := x.ResponseType.(*IngestGatewayResponseRequest_NewEdge); ok { return x.NewEdge } } return nil } type isIngestGatewayResponseRequest_ResponseType interface { isIngestGatewayResponseRequest_ResponseType() } type IngestGatewayResponseRequest_NewItem struct { NewItem *Item `protobuf:"bytes,2,opt,name=newItem,proto3,oneof"` // A new item that has been discovered } type IngestGatewayResponseRequest_NewEdge struct { NewEdge *Edge `protobuf:"bytes,3,opt,name=newEdge,proto3,oneof"` // A new edge between two items } func (*IngestGatewayResponseRequest_NewItem) isIngestGatewayResponseRequest_ResponseType() {} func (*IngestGatewayResponseRequest_NewEdge) isIngestGatewayResponseRequest_ResponseType() {} type IngestGatewayResponsesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NumItemsReceived int32 `protobuf:"varint,1,opt,name=numItemsReceived,proto3" json:"numItemsReceived,omitempty"` NumEdgesReceived int32 `protobuf:"varint,2,opt,name=numEdgesReceived,proto3" json:"numEdgesReceived,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestGatewayResponsesResponse) Reset() { *x = IngestGatewayResponsesResponse{} mi := &file_revlink_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestGatewayResponsesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestGatewayResponsesResponse) ProtoMessage() {} func (x *IngestGatewayResponsesResponse) ProtoReflect() protoreflect.Message { mi := &file_revlink_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestGatewayResponsesResponse.ProtoReflect.Descriptor instead. func (*IngestGatewayResponsesResponse) Descriptor() ([]byte, []int) { return file_revlink_proto_rawDescGZIP(), []int{3} } func (x *IngestGatewayResponsesResponse) GetNumItemsReceived() int32 { if x != nil { return x.NumItemsReceived } return 0 } func (x *IngestGatewayResponsesResponse) GetNumEdgesReceived() int32 { if x != nil { return x.NumEdgesReceived } return 0 } type CheckpointRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} mi := &file_revlink_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CheckpointRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { mi := &file_revlink_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { return file_revlink_proto_rawDescGZIP(), []int{4} } type CheckpointResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} mi := &file_revlink_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CheckpointResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { mi := &file_revlink_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { return file_revlink_proto_rawDescGZIP(), []int{5} } var File_revlink_proto protoreflect.FileDescriptor const file_revlink_proto_rawDesc = "" + "\n" + "\rrevlink.proto\x12\arevlink\x1a\vitems.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"z\n" + "\x16GetReverseEdgesRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x12$\n" + "\aitemRef\x18\x02 \x01(\v2\n" + ".ReferenceR\aitemRefJ\x04\b\x03\x10\x04R\x1afollowOnlyBlastPropagation\"6\n" + "\x17GetReverseEdgesResponse\x12\x1b\n" + "\x05edges\x18\x01 \x03(\v2\x05.EdgeR\x05edges\"\x8f\x01\n" + "\x1cIngestGatewayResponseRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x12!\n" + "\anewItem\x18\x02 \x01(\v2\x05.ItemH\x00R\anewItem\x12!\n" + "\anewEdge\x18\x03 \x01(\v2\x05.EdgeH\x00R\anewEdgeB\x0f\n" + "\rresponse_type\"x\n" + "\x1eIngestGatewayResponsesResponse\x12*\n" + "\x10numItemsReceived\x18\x01 \x01(\x05R\x10numItemsReceived\x12*\n" + "\x10numEdgesReceived\x18\x02 \x01(\x05R\x10numEdgesReceived\"\x13\n" + "\x11CheckpointRequest\"\x14\n" + "\x12CheckpointResponse2\x99\x02\n" + "\x0eRevlinkService\x12T\n" + "\x0fGetReverseEdges\x12\x1f.revlink.GetReverseEdgesRequest\x1a .revlink.GetReverseEdgesResponse\x12j\n" + "\x16IngestGatewayResponses\x12%.revlink.IngestGatewayResponseRequest\x1a'.revlink.IngestGatewayResponsesResponse(\x01\x12E\n" + "\n" + "Checkpoint\x12\x1a.revlink.CheckpointRequest\x1a\x1b.revlink.CheckpointResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_revlink_proto_rawDescOnce sync.Once file_revlink_proto_rawDescData []byte ) func file_revlink_proto_rawDescGZIP() []byte { file_revlink_proto_rawDescOnce.Do(func() { file_revlink_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_revlink_proto_rawDesc), len(file_revlink_proto_rawDesc))) }) return file_revlink_proto_rawDescData } var file_revlink_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_revlink_proto_goTypes = []any{ (*GetReverseEdgesRequest)(nil), // 0: revlink.GetReverseEdgesRequest (*GetReverseEdgesResponse)(nil), // 1: revlink.GetReverseEdgesResponse (*IngestGatewayResponseRequest)(nil), // 2: revlink.IngestGatewayResponseRequest (*IngestGatewayResponsesResponse)(nil), // 3: revlink.IngestGatewayResponsesResponse (*CheckpointRequest)(nil), // 4: revlink.CheckpointRequest (*CheckpointResponse)(nil), // 5: revlink.CheckpointResponse (*Reference)(nil), // 6: Reference (*Edge)(nil), // 7: Edge (*Item)(nil), // 8: Item } var file_revlink_proto_depIdxs = []int32{ 6, // 0: revlink.GetReverseEdgesRequest.itemRef:type_name -> Reference 7, // 1: revlink.GetReverseEdgesResponse.edges:type_name -> Edge 8, // 2: revlink.IngestGatewayResponseRequest.newItem:type_name -> Item 7, // 3: revlink.IngestGatewayResponseRequest.newEdge:type_name -> Edge 0, // 4: revlink.RevlinkService.GetReverseEdges:input_type -> revlink.GetReverseEdgesRequest 2, // 5: revlink.RevlinkService.IngestGatewayResponses:input_type -> revlink.IngestGatewayResponseRequest 4, // 6: revlink.RevlinkService.Checkpoint:input_type -> revlink.CheckpointRequest 1, // 7: revlink.RevlinkService.GetReverseEdges:output_type -> revlink.GetReverseEdgesResponse 3, // 8: revlink.RevlinkService.IngestGatewayResponses:output_type -> revlink.IngestGatewayResponsesResponse 5, // 9: revlink.RevlinkService.Checkpoint:output_type -> revlink.CheckpointResponse 7, // [7:10] is the sub-list for method output_type 4, // [4:7] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name } func init() { file_revlink_proto_init() } func file_revlink_proto_init() { if File_revlink_proto != nil { return } file_items_proto_init() file_revlink_proto_msgTypes[2].OneofWrappers = []any{ (*IngestGatewayResponseRequest_NewItem)(nil), (*IngestGatewayResponseRequest_NewEdge)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_revlink_proto_rawDesc), len(file_revlink_proto_rawDesc)), NumEnums: 0, NumMessages: 6, NumExtensions: 0, NumServices: 1, }, GoTypes: file_revlink_proto_goTypes, DependencyIndexes: file_revlink_proto_depIdxs, MessageInfos: file_revlink_proto_msgTypes, }.Build() File_revlink_proto = out.File file_revlink_proto_goTypes = nil file_revlink_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/sdpconnect/account.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: account.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // AdminServiceName is the fully-qualified name of the AdminService service. AdminServiceName = "account.AdminService" // ManagementServiceName is the fully-qualified name of the ManagementService service. ManagementServiceName = "account.ManagementService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // AdminServiceListAccountsProcedure is the fully-qualified name of the AdminService's ListAccounts // RPC. AdminServiceListAccountsProcedure = "/account.AdminService/ListAccounts" // AdminServiceCreateAccountProcedure is the fully-qualified name of the AdminService's // CreateAccount RPC. AdminServiceCreateAccountProcedure = "/account.AdminService/CreateAccount" // AdminServiceUpdateAccountProcedure is the fully-qualified name of the AdminService's // UpdateAccount RPC. AdminServiceUpdateAccountProcedure = "/account.AdminService/UpdateAccount" // AdminServiceGetAccountProcedure is the fully-qualified name of the AdminService's GetAccount RPC. AdminServiceGetAccountProcedure = "/account.AdminService/GetAccount" // AdminServiceDeleteAccountProcedure is the fully-qualified name of the AdminService's // DeleteAccount RPC. AdminServiceDeleteAccountProcedure = "/account.AdminService/DeleteAccount" // AdminServiceListSourcesProcedure is the fully-qualified name of the AdminService's ListSources // RPC. AdminServiceListSourcesProcedure = "/account.AdminService/ListSources" // AdminServiceCreateSourceProcedure is the fully-qualified name of the AdminService's CreateSource // RPC. AdminServiceCreateSourceProcedure = "/account.AdminService/CreateSource" // AdminServiceGetSourceProcedure is the fully-qualified name of the AdminService's GetSource RPC. AdminServiceGetSourceProcedure = "/account.AdminService/GetSource" // AdminServiceUpdateSourceProcedure is the fully-qualified name of the AdminService's UpdateSource // RPC. AdminServiceUpdateSourceProcedure = "/account.AdminService/UpdateSource" // AdminServiceDeleteSourceProcedure is the fully-qualified name of the AdminService's DeleteSource // RPC. AdminServiceDeleteSourceProcedure = "/account.AdminService/DeleteSource" // AdminServiceKeepaliveSourcesProcedure is the fully-qualified name of the AdminService's // KeepaliveSources RPC. AdminServiceKeepaliveSourcesProcedure = "/account.AdminService/KeepaliveSources" // AdminServiceCreateTokenProcedure is the fully-qualified name of the AdminService's CreateToken // RPC. AdminServiceCreateTokenProcedure = "/account.AdminService/CreateToken" // ManagementServiceGetAccountProcedure is the fully-qualified name of the ManagementService's // GetAccount RPC. ManagementServiceGetAccountProcedure = "/account.ManagementService/GetAccount" // ManagementServiceDeleteAccountProcedure is the fully-qualified name of the ManagementService's // DeleteAccount RPC. ManagementServiceDeleteAccountProcedure = "/account.ManagementService/DeleteAccount" // ManagementServiceListSourcesProcedure is the fully-qualified name of the ManagementService's // ListSources RPC. ManagementServiceListSourcesProcedure = "/account.ManagementService/ListSources" // ManagementServiceCreateSourceProcedure is the fully-qualified name of the ManagementService's // CreateSource RPC. ManagementServiceCreateSourceProcedure = "/account.ManagementService/CreateSource" // ManagementServiceGetSourceProcedure is the fully-qualified name of the ManagementService's // GetSource RPC. ManagementServiceGetSourceProcedure = "/account.ManagementService/GetSource" // ManagementServiceUpdateSourceProcedure is the fully-qualified name of the ManagementService's // UpdateSource RPC. ManagementServiceUpdateSourceProcedure = "/account.ManagementService/UpdateSource" // ManagementServiceDeleteSourceProcedure is the fully-qualified name of the ManagementService's // DeleteSource RPC. ManagementServiceDeleteSourceProcedure = "/account.ManagementService/DeleteSource" // ManagementServiceListAllSourcesStatusProcedure is the fully-qualified name of the // ManagementService's ListAllSourcesStatus RPC. ManagementServiceListAllSourcesStatusProcedure = "/account.ManagementService/ListAllSourcesStatus" // ManagementServiceListActiveSourcesStatusProcedure is the fully-qualified name of the // ManagementService's ListActiveSourcesStatus RPC. ManagementServiceListActiveSourcesStatusProcedure = "/account.ManagementService/ListActiveSourcesStatus" // ManagementServiceSubmitSourceHeartbeatProcedure is the fully-qualified name of the // ManagementService's SubmitSourceHeartbeat RPC. ManagementServiceSubmitSourceHeartbeatProcedure = "/account.ManagementService/SubmitSourceHeartbeat" // ManagementServiceKeepaliveSourcesProcedure is the fully-qualified name of the ManagementService's // KeepaliveSources RPC. ManagementServiceKeepaliveSourcesProcedure = "/account.ManagementService/KeepaliveSources" // ManagementServiceCreateTokenProcedure is the fully-qualified name of the ManagementService's // CreateToken RPC. ManagementServiceCreateTokenProcedure = "/account.ManagementService/CreateToken" // ManagementServiceRevlinkWarmupProcedure is the fully-qualified name of the ManagementService's // RevlinkWarmup RPC. ManagementServiceRevlinkWarmupProcedure = "/account.ManagementService/RevlinkWarmup" // ManagementServiceListAvailableItemTypesProcedure is the fully-qualified name of the // ManagementService's ListAvailableItemTypes RPC. ManagementServiceListAvailableItemTypesProcedure = "/account.ManagementService/ListAvailableItemTypes" // ManagementServiceGetSourceStatusProcedure is the fully-qualified name of the ManagementService's // GetSourceStatus RPC. ManagementServiceGetSourceStatusProcedure = "/account.ManagementService/GetSourceStatus" // ManagementServiceGetUserOnboardingStatusProcedure is the fully-qualified name of the // ManagementService's GetUserOnboardingStatus RPC. ManagementServiceGetUserOnboardingStatusProcedure = "/account.ManagementService/GetUserOnboardingStatus" // ManagementServiceSetUserOnboardingStatusProcedure is the fully-qualified name of the // ManagementService's SetUserOnboardingStatus RPC. ManagementServiceSetUserOnboardingStatusProcedure = "/account.ManagementService/SetUserOnboardingStatus" // ManagementServiceListTeamMembersProcedure is the fully-qualified name of the ManagementService's // ListTeamMembers RPC. ManagementServiceListTeamMembersProcedure = "/account.ManagementService/ListTeamMembers" // ManagementServiceGetWelcomeScreenInformationProcedure is the fully-qualified name of the // ManagementService's GetWelcomeScreenInformation RPC. ManagementServiceGetWelcomeScreenInformationProcedure = "/account.ManagementService/GetWelcomeScreenInformation" // ManagementServiceSetGithubInstallationIDProcedure is the fully-qualified name of the // ManagementService's SetGithubInstallationID RPC. ManagementServiceSetGithubInstallationIDProcedure = "/account.ManagementService/SetGithubInstallationID" // ManagementServiceUnsetGithubInstallationIDProcedure is the fully-qualified name of the // ManagementService's UnsetGithubInstallationID RPC. ManagementServiceUnsetGithubInstallationIDProcedure = "/account.ManagementService/UnsetGithubInstallationID" // ManagementServiceGetOrCreateAWSExternalIdProcedure is the fully-qualified name of the // ManagementService's GetOrCreateAWSExternalId RPC. ManagementServiceGetOrCreateAWSExternalIdProcedure = "/account.ManagementService/GetOrCreateAWSExternalId" ) // AdminServiceClient is a client for the account.AdminService service. type AdminServiceClient interface { // Lists the details of all NATS Accounts ListAccounts(context.Context, *connect.Request[sdp_go.ListAccountsRequest]) (*connect.Response[sdp_go.ListAccountsResponse], error) // Creates a new account, public_nkey will be autogenerated CreateAccount(context.Context, *connect.Request[sdp_go.CreateAccountRequest]) (*connect.Response[sdp_go.CreateAccountResponse], error) // Updates account details, returns the account UpdateAccount(context.Context, *connect.Request[sdp_go.AdminUpdateAccountRequest]) (*connect.Response[sdp_go.UpdateAccountResponse], error) // Get the details of a given account GetAccount(context.Context, *connect.Request[sdp_go.AdminGetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) // Completely deletes an account. This includes all of the data in that // account, bookmarks, changes etc. It also deletes all users from Auth0 // that are associated with this account DeleteAccount(context.Context, *connect.Request[sdp_go.AdminDeleteAccountRequest]) (*connect.Response[sdp_go.AdminDeleteAccountResponse], error) // Lists all sources within the chosen account ListSources(context.Context, *connect.Request[sdp_go.AdminListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) // Creates a new source within the chosen account CreateSource(context.Context, *connect.Request[sdp_go.AdminCreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) // Get the details of a source within the chosen account GetSource(context.Context, *connect.Request[sdp_go.AdminGetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) // Update the details of a source within the chosen account UpdateSource(context.Context, *connect.Request[sdp_go.AdminUpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) // Deletes a source from a chosen account DeleteSource(context.Context, *connect.Request[sdp_go.AdminDeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) // Updates sources to keep them running in the background. This can be used // to add explicit action, when the built-in keepalives are not sufficient. KeepaliveSources(context.Context, *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) // Create a new NATS token for a given public NKey. The user requesting must // control the associated private key also in order to connect to NATS as // the token is not enough on its own CreateToken(context.Context, *connect.Request[sdp_go.AdminCreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) } // NewAdminServiceClient constructs a client for the account.AdminService service. By default, it // uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewAdminServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AdminServiceClient { baseURL = strings.TrimRight(baseURL, "/") adminServiceMethods := sdp_go.File_account_proto.Services().ByName("AdminService").Methods() return &adminServiceClient{ listAccounts: connect.NewClient[sdp_go.ListAccountsRequest, sdp_go.ListAccountsResponse]( httpClient, baseURL+AdminServiceListAccountsProcedure, connect.WithSchema(adminServiceMethods.ByName("ListAccounts")), connect.WithClientOptions(opts...), ), createAccount: connect.NewClient[sdp_go.CreateAccountRequest, sdp_go.CreateAccountResponse]( httpClient, baseURL+AdminServiceCreateAccountProcedure, connect.WithSchema(adminServiceMethods.ByName("CreateAccount")), connect.WithClientOptions(opts...), ), updateAccount: connect.NewClient[sdp_go.AdminUpdateAccountRequest, sdp_go.UpdateAccountResponse]( httpClient, baseURL+AdminServiceUpdateAccountProcedure, connect.WithSchema(adminServiceMethods.ByName("UpdateAccount")), connect.WithClientOptions(opts...), ), getAccount: connect.NewClient[sdp_go.AdminGetAccountRequest, sdp_go.GetAccountResponse]( httpClient, baseURL+AdminServiceGetAccountProcedure, connect.WithSchema(adminServiceMethods.ByName("GetAccount")), connect.WithClientOptions(opts...), ), deleteAccount: connect.NewClient[sdp_go.AdminDeleteAccountRequest, sdp_go.AdminDeleteAccountResponse]( httpClient, baseURL+AdminServiceDeleteAccountProcedure, connect.WithSchema(adminServiceMethods.ByName("DeleteAccount")), connect.WithClientOptions(opts...), ), listSources: connect.NewClient[sdp_go.AdminListSourcesRequest, sdp_go.ListSourcesResponse]( httpClient, baseURL+AdminServiceListSourcesProcedure, connect.WithSchema(adminServiceMethods.ByName("ListSources")), connect.WithClientOptions(opts...), ), createSource: connect.NewClient[sdp_go.AdminCreateSourceRequest, sdp_go.CreateSourceResponse]( httpClient, baseURL+AdminServiceCreateSourceProcedure, connect.WithSchema(adminServiceMethods.ByName("CreateSource")), connect.WithClientOptions(opts...), ), getSource: connect.NewClient[sdp_go.AdminGetSourceRequest, sdp_go.GetSourceResponse]( httpClient, baseURL+AdminServiceGetSourceProcedure, connect.WithSchema(adminServiceMethods.ByName("GetSource")), connect.WithClientOptions(opts...), ), updateSource: connect.NewClient[sdp_go.AdminUpdateSourceRequest, sdp_go.UpdateSourceResponse]( httpClient, baseURL+AdminServiceUpdateSourceProcedure, connect.WithSchema(adminServiceMethods.ByName("UpdateSource")), connect.WithClientOptions(opts...), ), deleteSource: connect.NewClient[sdp_go.AdminDeleteSourceRequest, sdp_go.DeleteSourceResponse]( httpClient, baseURL+AdminServiceDeleteSourceProcedure, connect.WithSchema(adminServiceMethods.ByName("DeleteSource")), connect.WithClientOptions(opts...), ), keepaliveSources: connect.NewClient[sdp_go.AdminKeepaliveSourcesRequest, sdp_go.KeepaliveSourcesResponse]( httpClient, baseURL+AdminServiceKeepaliveSourcesProcedure, connect.WithSchema(adminServiceMethods.ByName("KeepaliveSources")), connect.WithClientOptions(opts...), ), createToken: connect.NewClient[sdp_go.AdminCreateTokenRequest, sdp_go.CreateTokenResponse]( httpClient, baseURL+AdminServiceCreateTokenProcedure, connect.WithSchema(adminServiceMethods.ByName("CreateToken")), connect.WithClientOptions(opts...), ), } } // adminServiceClient implements AdminServiceClient. type adminServiceClient struct { listAccounts *connect.Client[sdp_go.ListAccountsRequest, sdp_go.ListAccountsResponse] createAccount *connect.Client[sdp_go.CreateAccountRequest, sdp_go.CreateAccountResponse] updateAccount *connect.Client[sdp_go.AdminUpdateAccountRequest, sdp_go.UpdateAccountResponse] getAccount *connect.Client[sdp_go.AdminGetAccountRequest, sdp_go.GetAccountResponse] deleteAccount *connect.Client[sdp_go.AdminDeleteAccountRequest, sdp_go.AdminDeleteAccountResponse] listSources *connect.Client[sdp_go.AdminListSourcesRequest, sdp_go.ListSourcesResponse] createSource *connect.Client[sdp_go.AdminCreateSourceRequest, sdp_go.CreateSourceResponse] getSource *connect.Client[sdp_go.AdminGetSourceRequest, sdp_go.GetSourceResponse] updateSource *connect.Client[sdp_go.AdminUpdateSourceRequest, sdp_go.UpdateSourceResponse] deleteSource *connect.Client[sdp_go.AdminDeleteSourceRequest, sdp_go.DeleteSourceResponse] keepaliveSources *connect.Client[sdp_go.AdminKeepaliveSourcesRequest, sdp_go.KeepaliveSourcesResponse] createToken *connect.Client[sdp_go.AdminCreateTokenRequest, sdp_go.CreateTokenResponse] } // ListAccounts calls account.AdminService.ListAccounts. func (c *adminServiceClient) ListAccounts(ctx context.Context, req *connect.Request[sdp_go.ListAccountsRequest]) (*connect.Response[sdp_go.ListAccountsResponse], error) { return c.listAccounts.CallUnary(ctx, req) } // CreateAccount calls account.AdminService.CreateAccount. func (c *adminServiceClient) CreateAccount(ctx context.Context, req *connect.Request[sdp_go.CreateAccountRequest]) (*connect.Response[sdp_go.CreateAccountResponse], error) { return c.createAccount.CallUnary(ctx, req) } // UpdateAccount calls account.AdminService.UpdateAccount. func (c *adminServiceClient) UpdateAccount(ctx context.Context, req *connect.Request[sdp_go.AdminUpdateAccountRequest]) (*connect.Response[sdp_go.UpdateAccountResponse], error) { return c.updateAccount.CallUnary(ctx, req) } // GetAccount calls account.AdminService.GetAccount. func (c *adminServiceClient) GetAccount(ctx context.Context, req *connect.Request[sdp_go.AdminGetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) { return c.getAccount.CallUnary(ctx, req) } // DeleteAccount calls account.AdminService.DeleteAccount. func (c *adminServiceClient) DeleteAccount(ctx context.Context, req *connect.Request[sdp_go.AdminDeleteAccountRequest]) (*connect.Response[sdp_go.AdminDeleteAccountResponse], error) { return c.deleteAccount.CallUnary(ctx, req) } // ListSources calls account.AdminService.ListSources. func (c *adminServiceClient) ListSources(ctx context.Context, req *connect.Request[sdp_go.AdminListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) { return c.listSources.CallUnary(ctx, req) } // CreateSource calls account.AdminService.CreateSource. func (c *adminServiceClient) CreateSource(ctx context.Context, req *connect.Request[sdp_go.AdminCreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) { return c.createSource.CallUnary(ctx, req) } // GetSource calls account.AdminService.GetSource. func (c *adminServiceClient) GetSource(ctx context.Context, req *connect.Request[sdp_go.AdminGetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) { return c.getSource.CallUnary(ctx, req) } // UpdateSource calls account.AdminService.UpdateSource. func (c *adminServiceClient) UpdateSource(ctx context.Context, req *connect.Request[sdp_go.AdminUpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) { return c.updateSource.CallUnary(ctx, req) } // DeleteSource calls account.AdminService.DeleteSource. func (c *adminServiceClient) DeleteSource(ctx context.Context, req *connect.Request[sdp_go.AdminDeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) { return c.deleteSource.CallUnary(ctx, req) } // KeepaliveSources calls account.AdminService.KeepaliveSources. func (c *adminServiceClient) KeepaliveSources(ctx context.Context, req *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) { return c.keepaliveSources.CallUnary(ctx, req) } // CreateToken calls account.AdminService.CreateToken. func (c *adminServiceClient) CreateToken(ctx context.Context, req *connect.Request[sdp_go.AdminCreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) { return c.createToken.CallUnary(ctx, req) } // AdminServiceHandler is an implementation of the account.AdminService service. type AdminServiceHandler interface { // Lists the details of all NATS Accounts ListAccounts(context.Context, *connect.Request[sdp_go.ListAccountsRequest]) (*connect.Response[sdp_go.ListAccountsResponse], error) // Creates a new account, public_nkey will be autogenerated CreateAccount(context.Context, *connect.Request[sdp_go.CreateAccountRequest]) (*connect.Response[sdp_go.CreateAccountResponse], error) // Updates account details, returns the account UpdateAccount(context.Context, *connect.Request[sdp_go.AdminUpdateAccountRequest]) (*connect.Response[sdp_go.UpdateAccountResponse], error) // Get the details of a given account GetAccount(context.Context, *connect.Request[sdp_go.AdminGetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) // Completely deletes an account. This includes all of the data in that // account, bookmarks, changes etc. It also deletes all users from Auth0 // that are associated with this account DeleteAccount(context.Context, *connect.Request[sdp_go.AdminDeleteAccountRequest]) (*connect.Response[sdp_go.AdminDeleteAccountResponse], error) // Lists all sources within the chosen account ListSources(context.Context, *connect.Request[sdp_go.AdminListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) // Creates a new source within the chosen account CreateSource(context.Context, *connect.Request[sdp_go.AdminCreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) // Get the details of a source within the chosen account GetSource(context.Context, *connect.Request[sdp_go.AdminGetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) // Update the details of a source within the chosen account UpdateSource(context.Context, *connect.Request[sdp_go.AdminUpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) // Deletes a source from a chosen account DeleteSource(context.Context, *connect.Request[sdp_go.AdminDeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) // Updates sources to keep them running in the background. This can be used // to add explicit action, when the built-in keepalives are not sufficient. KeepaliveSources(context.Context, *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) // Create a new NATS token for a given public NKey. The user requesting must // control the associated private key also in order to connect to NATS as // the token is not enough on its own CreateToken(context.Context, *connect.Request[sdp_go.AdminCreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) } // NewAdminServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewAdminServiceHandler(svc AdminServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { adminServiceMethods := sdp_go.File_account_proto.Services().ByName("AdminService").Methods() adminServiceListAccountsHandler := connect.NewUnaryHandler( AdminServiceListAccountsProcedure, svc.ListAccounts, connect.WithSchema(adminServiceMethods.ByName("ListAccounts")), connect.WithHandlerOptions(opts...), ) adminServiceCreateAccountHandler := connect.NewUnaryHandler( AdminServiceCreateAccountProcedure, svc.CreateAccount, connect.WithSchema(adminServiceMethods.ByName("CreateAccount")), connect.WithHandlerOptions(opts...), ) adminServiceUpdateAccountHandler := connect.NewUnaryHandler( AdminServiceUpdateAccountProcedure, svc.UpdateAccount, connect.WithSchema(adminServiceMethods.ByName("UpdateAccount")), connect.WithHandlerOptions(opts...), ) adminServiceGetAccountHandler := connect.NewUnaryHandler( AdminServiceGetAccountProcedure, svc.GetAccount, connect.WithSchema(adminServiceMethods.ByName("GetAccount")), connect.WithHandlerOptions(opts...), ) adminServiceDeleteAccountHandler := connect.NewUnaryHandler( AdminServiceDeleteAccountProcedure, svc.DeleteAccount, connect.WithSchema(adminServiceMethods.ByName("DeleteAccount")), connect.WithHandlerOptions(opts...), ) adminServiceListSourcesHandler := connect.NewUnaryHandler( AdminServiceListSourcesProcedure, svc.ListSources, connect.WithSchema(adminServiceMethods.ByName("ListSources")), connect.WithHandlerOptions(opts...), ) adminServiceCreateSourceHandler := connect.NewUnaryHandler( AdminServiceCreateSourceProcedure, svc.CreateSource, connect.WithSchema(adminServiceMethods.ByName("CreateSource")), connect.WithHandlerOptions(opts...), ) adminServiceGetSourceHandler := connect.NewUnaryHandler( AdminServiceGetSourceProcedure, svc.GetSource, connect.WithSchema(adminServiceMethods.ByName("GetSource")), connect.WithHandlerOptions(opts...), ) adminServiceUpdateSourceHandler := connect.NewUnaryHandler( AdminServiceUpdateSourceProcedure, svc.UpdateSource, connect.WithSchema(adminServiceMethods.ByName("UpdateSource")), connect.WithHandlerOptions(opts...), ) adminServiceDeleteSourceHandler := connect.NewUnaryHandler( AdminServiceDeleteSourceProcedure, svc.DeleteSource, connect.WithSchema(adminServiceMethods.ByName("DeleteSource")), connect.WithHandlerOptions(opts...), ) adminServiceKeepaliveSourcesHandler := connect.NewUnaryHandler( AdminServiceKeepaliveSourcesProcedure, svc.KeepaliveSources, connect.WithSchema(adminServiceMethods.ByName("KeepaliveSources")), connect.WithHandlerOptions(opts...), ) adminServiceCreateTokenHandler := connect.NewUnaryHandler( AdminServiceCreateTokenProcedure, svc.CreateToken, connect.WithSchema(adminServiceMethods.ByName("CreateToken")), connect.WithHandlerOptions(opts...), ) return "/account.AdminService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case AdminServiceListAccountsProcedure: adminServiceListAccountsHandler.ServeHTTP(w, r) case AdminServiceCreateAccountProcedure: adminServiceCreateAccountHandler.ServeHTTP(w, r) case AdminServiceUpdateAccountProcedure: adminServiceUpdateAccountHandler.ServeHTTP(w, r) case AdminServiceGetAccountProcedure: adminServiceGetAccountHandler.ServeHTTP(w, r) case AdminServiceDeleteAccountProcedure: adminServiceDeleteAccountHandler.ServeHTTP(w, r) case AdminServiceListSourcesProcedure: adminServiceListSourcesHandler.ServeHTTP(w, r) case AdminServiceCreateSourceProcedure: adminServiceCreateSourceHandler.ServeHTTP(w, r) case AdminServiceGetSourceProcedure: adminServiceGetSourceHandler.ServeHTTP(w, r) case AdminServiceUpdateSourceProcedure: adminServiceUpdateSourceHandler.ServeHTTP(w, r) case AdminServiceDeleteSourceProcedure: adminServiceDeleteSourceHandler.ServeHTTP(w, r) case AdminServiceKeepaliveSourcesProcedure: adminServiceKeepaliveSourcesHandler.ServeHTTP(w, r) case AdminServiceCreateTokenProcedure: adminServiceCreateTokenHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedAdminServiceHandler returns CodeUnimplemented from all methods. type UnimplementedAdminServiceHandler struct{} func (UnimplementedAdminServiceHandler) ListAccounts(context.Context, *connect.Request[sdp_go.ListAccountsRequest]) (*connect.Response[sdp_go.ListAccountsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.ListAccounts is not implemented")) } func (UnimplementedAdminServiceHandler) CreateAccount(context.Context, *connect.Request[sdp_go.CreateAccountRequest]) (*connect.Response[sdp_go.CreateAccountResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.CreateAccount is not implemented")) } func (UnimplementedAdminServiceHandler) UpdateAccount(context.Context, *connect.Request[sdp_go.AdminUpdateAccountRequest]) (*connect.Response[sdp_go.UpdateAccountResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.UpdateAccount is not implemented")) } func (UnimplementedAdminServiceHandler) GetAccount(context.Context, *connect.Request[sdp_go.AdminGetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.GetAccount is not implemented")) } func (UnimplementedAdminServiceHandler) DeleteAccount(context.Context, *connect.Request[sdp_go.AdminDeleteAccountRequest]) (*connect.Response[sdp_go.AdminDeleteAccountResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.DeleteAccount is not implemented")) } func (UnimplementedAdminServiceHandler) ListSources(context.Context, *connect.Request[sdp_go.AdminListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.ListSources is not implemented")) } func (UnimplementedAdminServiceHandler) CreateSource(context.Context, *connect.Request[sdp_go.AdminCreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.CreateSource is not implemented")) } func (UnimplementedAdminServiceHandler) GetSource(context.Context, *connect.Request[sdp_go.AdminGetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.GetSource is not implemented")) } func (UnimplementedAdminServiceHandler) UpdateSource(context.Context, *connect.Request[sdp_go.AdminUpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.UpdateSource is not implemented")) } func (UnimplementedAdminServiceHandler) DeleteSource(context.Context, *connect.Request[sdp_go.AdminDeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.DeleteSource is not implemented")) } func (UnimplementedAdminServiceHandler) KeepaliveSources(context.Context, *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.KeepaliveSources is not implemented")) } func (UnimplementedAdminServiceHandler) CreateToken(context.Context, *connect.Request[sdp_go.AdminCreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.AdminService.CreateToken is not implemented")) } // ManagementServiceClient is a client for the account.ManagementService service. type ManagementServiceClient interface { // Get the details of the account that this user belongs to GetAccount(context.Context, *connect.Request[sdp_go.GetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) // Completely deletes the user's account. This includes all of the data in // that account, bookmarks, changes etc. It also deletes the current user, // and all other users in that account from Auth0 DeleteAccount(context.Context, *connect.Request[sdp_go.DeleteAccountRequest]) (*connect.Response[sdp_go.DeleteAccountResponse], error) // Lists all sources within the user's account ListSources(context.Context, *connect.Request[sdp_go.ListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) // Creates a new source within the user's account CreateSource(context.Context, *connect.Request[sdp_go.CreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) // Get the details of a source GetSource(context.Context, *connect.Request[sdp_go.GetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) // Update the details of a source UpdateSource(context.Context, *connect.Request[sdp_go.UpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) // Deletes a source from a user's account DeleteSource(context.Context, *connect.Request[sdp_go.DeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) // Sources heartbeat and health // List of all recently active sources and their health, includes information from srcman // meaning that it can show the status of managed sources that have not started and // connected yet ListAllSourcesStatus(context.Context, *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) // Lists all active sources and their health. This should be used to determine // what types, scopes etc are available rather than `ListAllSourcesStatus` since // this endpoint only include running, available sources ListActiveSourcesStatus(context.Context, *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) // Heartbeat from a source to keep it registered and healthy SubmitSourceHeartbeat(context.Context, *connect.Request[sdp_go.SubmitSourceHeartbeatRequest]) (*connect.Response[sdp_go.SubmitSourceHeartbeatResponse], error) // Updates sources to keep them running in the background. This can be used // to add explicit action, when the built-in keepalives are not sufficient. // A user can specify how long they are willing to wait and will get a // response either when all sources start, or when the timeout is reached. // If the timeout is reached the response will contain the current state of // all sources at that moment KeepaliveSources(context.Context, *connect.Request[sdp_go.KeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) // Create a new NATS token for a given public NKey. The user requesting must // control the associated private key also in order to connect to NATS as // the token is not enough on its own CreateToken(context.Context, *connect.Request[sdp_go.CreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) // Ensure that all reverse links are populated. This does internal debouncing // so the actual logic does only run when required. RevlinkWarmup(context.Context, *connect.Request[sdp_go.RevlinkWarmupRequest]) (*connect.ServerStreamForClient[sdp_go.RevlinkWarmupResponse], error) // Lists all the available item types that can be discovered by sources that are running and healthy ListAvailableItemTypes(context.Context, *connect.Request[sdp_go.ListAvailableItemTypesRequest]) (*connect.Response[sdp_go.ListAvailableItemTypesResponse], error) // Get status of a single source by UUID GetSourceStatus(context.Context, *connect.Request[sdp_go.GetSourceStatusRequest]) (*connect.Response[sdp_go.GetSourceStatusResponse], error) // Get and set onboarding status for users GetUserOnboardingStatus(context.Context, *connect.Request[sdp_go.GetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.GetUserOnboardingStatusResponse], error) SetUserOnboardingStatus(context.Context, *connect.Request[sdp_go.SetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.SetUserOnboardingStatusResponse], error) // List team members in the current user's account (excludes the active user) ListTeamMembers(context.Context, *connect.Request[sdp_go.ListTeamMembersRequest]) (*connect.Response[sdp_go.ListTeamMembersResponse], error) // Get welcome information for the current user GetWelcomeScreenInformation(context.Context, *connect.Request[sdp_go.GetWelcomeScreenInformationRequest]) (*connect.Response[sdp_go.GetWelcomeScreenInformationResponse], error) // Set github installation ID for the account SetGithubInstallationID(context.Context, *connect.Request[sdp_go.SetGithubInstallationIDRequest]) (*connect.Response[sdp_go.SetGithubInstallationIDResponse], error) // this will unset the github installation ID for the account, allowing the user to install the github app again // it will also remove the organisation profile, so we no longer generate signals for that org UnsetGithubInstallationID(context.Context, *connect.Request[sdp_go.UnsetGithubInstallationIDRequest]) (*connect.Response[sdp_go.UnsetGithubInstallationIDResponse], error) // Returns a stable, per-account external ID for AWS IAM trust policies. // Generates a UUID on first call; returns the same UUID on subsequent calls. GetOrCreateAWSExternalId(context.Context, *connect.Request[sdp_go.GetOrCreateAWSExternalIdRequest]) (*connect.Response[sdp_go.GetOrCreateAWSExternalIdResponse], error) } // NewManagementServiceClient constructs a client for the account.ManagementService service. By // default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, // and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the // connect.WithGRPC() or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewManagementServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ManagementServiceClient { baseURL = strings.TrimRight(baseURL, "/") managementServiceMethods := sdp_go.File_account_proto.Services().ByName("ManagementService").Methods() return &managementServiceClient{ getAccount: connect.NewClient[sdp_go.GetAccountRequest, sdp_go.GetAccountResponse]( httpClient, baseURL+ManagementServiceGetAccountProcedure, connect.WithSchema(managementServiceMethods.ByName("GetAccount")), connect.WithClientOptions(opts...), ), deleteAccount: connect.NewClient[sdp_go.DeleteAccountRequest, sdp_go.DeleteAccountResponse]( httpClient, baseURL+ManagementServiceDeleteAccountProcedure, connect.WithSchema(managementServiceMethods.ByName("DeleteAccount")), connect.WithClientOptions(opts...), ), listSources: connect.NewClient[sdp_go.ListSourcesRequest, sdp_go.ListSourcesResponse]( httpClient, baseURL+ManagementServiceListSourcesProcedure, connect.WithSchema(managementServiceMethods.ByName("ListSources")), connect.WithClientOptions(opts...), ), createSource: connect.NewClient[sdp_go.CreateSourceRequest, sdp_go.CreateSourceResponse]( httpClient, baseURL+ManagementServiceCreateSourceProcedure, connect.WithSchema(managementServiceMethods.ByName("CreateSource")), connect.WithClientOptions(opts...), ), getSource: connect.NewClient[sdp_go.GetSourceRequest, sdp_go.GetSourceResponse]( httpClient, baseURL+ManagementServiceGetSourceProcedure, connect.WithSchema(managementServiceMethods.ByName("GetSource")), connect.WithClientOptions(opts...), ), updateSource: connect.NewClient[sdp_go.UpdateSourceRequest, sdp_go.UpdateSourceResponse]( httpClient, baseURL+ManagementServiceUpdateSourceProcedure, connect.WithSchema(managementServiceMethods.ByName("UpdateSource")), connect.WithClientOptions(opts...), ), deleteSource: connect.NewClient[sdp_go.DeleteSourceRequest, sdp_go.DeleteSourceResponse]( httpClient, baseURL+ManagementServiceDeleteSourceProcedure, connect.WithSchema(managementServiceMethods.ByName("DeleteSource")), connect.WithClientOptions(opts...), ), listAllSourcesStatus: connect.NewClient[sdp_go.ListAllSourcesStatusRequest, sdp_go.ListAllSourcesStatusResponse]( httpClient, baseURL+ManagementServiceListAllSourcesStatusProcedure, connect.WithSchema(managementServiceMethods.ByName("ListAllSourcesStatus")), connect.WithClientOptions(opts...), ), listActiveSourcesStatus: connect.NewClient[sdp_go.ListAllSourcesStatusRequest, sdp_go.ListAllSourcesStatusResponse]( httpClient, baseURL+ManagementServiceListActiveSourcesStatusProcedure, connect.WithSchema(managementServiceMethods.ByName("ListActiveSourcesStatus")), connect.WithClientOptions(opts...), ), submitSourceHeartbeat: connect.NewClient[sdp_go.SubmitSourceHeartbeatRequest, sdp_go.SubmitSourceHeartbeatResponse]( httpClient, baseURL+ManagementServiceSubmitSourceHeartbeatProcedure, connect.WithSchema(managementServiceMethods.ByName("SubmitSourceHeartbeat")), connect.WithClientOptions(opts...), ), keepaliveSources: connect.NewClient[sdp_go.KeepaliveSourcesRequest, sdp_go.KeepaliveSourcesResponse]( httpClient, baseURL+ManagementServiceKeepaliveSourcesProcedure, connect.WithSchema(managementServiceMethods.ByName("KeepaliveSources")), connect.WithClientOptions(opts...), ), createToken: connect.NewClient[sdp_go.CreateTokenRequest, sdp_go.CreateTokenResponse]( httpClient, baseURL+ManagementServiceCreateTokenProcedure, connect.WithSchema(managementServiceMethods.ByName("CreateToken")), connect.WithClientOptions(opts...), ), revlinkWarmup: connect.NewClient[sdp_go.RevlinkWarmupRequest, sdp_go.RevlinkWarmupResponse]( httpClient, baseURL+ManagementServiceRevlinkWarmupProcedure, connect.WithSchema(managementServiceMethods.ByName("RevlinkWarmup")), connect.WithClientOptions(opts...), ), listAvailableItemTypes: connect.NewClient[sdp_go.ListAvailableItemTypesRequest, sdp_go.ListAvailableItemTypesResponse]( httpClient, baseURL+ManagementServiceListAvailableItemTypesProcedure, connect.WithSchema(managementServiceMethods.ByName("ListAvailableItemTypes")), connect.WithClientOptions(opts...), ), getSourceStatus: connect.NewClient[sdp_go.GetSourceStatusRequest, sdp_go.GetSourceStatusResponse]( httpClient, baseURL+ManagementServiceGetSourceStatusProcedure, connect.WithSchema(managementServiceMethods.ByName("GetSourceStatus")), connect.WithClientOptions(opts...), ), getUserOnboardingStatus: connect.NewClient[sdp_go.GetUserOnboardingStatusRequest, sdp_go.GetUserOnboardingStatusResponse]( httpClient, baseURL+ManagementServiceGetUserOnboardingStatusProcedure, connect.WithSchema(managementServiceMethods.ByName("GetUserOnboardingStatus")), connect.WithClientOptions(opts...), ), setUserOnboardingStatus: connect.NewClient[sdp_go.SetUserOnboardingStatusRequest, sdp_go.SetUserOnboardingStatusResponse]( httpClient, baseURL+ManagementServiceSetUserOnboardingStatusProcedure, connect.WithSchema(managementServiceMethods.ByName("SetUserOnboardingStatus")), connect.WithClientOptions(opts...), ), listTeamMembers: connect.NewClient[sdp_go.ListTeamMembersRequest, sdp_go.ListTeamMembersResponse]( httpClient, baseURL+ManagementServiceListTeamMembersProcedure, connect.WithSchema(managementServiceMethods.ByName("ListTeamMembers")), connect.WithClientOptions(opts...), ), getWelcomeScreenInformation: connect.NewClient[sdp_go.GetWelcomeScreenInformationRequest, sdp_go.GetWelcomeScreenInformationResponse]( httpClient, baseURL+ManagementServiceGetWelcomeScreenInformationProcedure, connect.WithSchema(managementServiceMethods.ByName("GetWelcomeScreenInformation")), connect.WithClientOptions(opts...), ), setGithubInstallationID: connect.NewClient[sdp_go.SetGithubInstallationIDRequest, sdp_go.SetGithubInstallationIDResponse]( httpClient, baseURL+ManagementServiceSetGithubInstallationIDProcedure, connect.WithSchema(managementServiceMethods.ByName("SetGithubInstallationID")), connect.WithClientOptions(opts...), ), unsetGithubInstallationID: connect.NewClient[sdp_go.UnsetGithubInstallationIDRequest, sdp_go.UnsetGithubInstallationIDResponse]( httpClient, baseURL+ManagementServiceUnsetGithubInstallationIDProcedure, connect.WithSchema(managementServiceMethods.ByName("UnsetGithubInstallationID")), connect.WithClientOptions(opts...), ), getOrCreateAWSExternalId: connect.NewClient[sdp_go.GetOrCreateAWSExternalIdRequest, sdp_go.GetOrCreateAWSExternalIdResponse]( httpClient, baseURL+ManagementServiceGetOrCreateAWSExternalIdProcedure, connect.WithSchema(managementServiceMethods.ByName("GetOrCreateAWSExternalId")), connect.WithClientOptions(opts...), ), } } // managementServiceClient implements ManagementServiceClient. type managementServiceClient struct { getAccount *connect.Client[sdp_go.GetAccountRequest, sdp_go.GetAccountResponse] deleteAccount *connect.Client[sdp_go.DeleteAccountRequest, sdp_go.DeleteAccountResponse] listSources *connect.Client[sdp_go.ListSourcesRequest, sdp_go.ListSourcesResponse] createSource *connect.Client[sdp_go.CreateSourceRequest, sdp_go.CreateSourceResponse] getSource *connect.Client[sdp_go.GetSourceRequest, sdp_go.GetSourceResponse] updateSource *connect.Client[sdp_go.UpdateSourceRequest, sdp_go.UpdateSourceResponse] deleteSource *connect.Client[sdp_go.DeleteSourceRequest, sdp_go.DeleteSourceResponse] listAllSourcesStatus *connect.Client[sdp_go.ListAllSourcesStatusRequest, sdp_go.ListAllSourcesStatusResponse] listActiveSourcesStatus *connect.Client[sdp_go.ListAllSourcesStatusRequest, sdp_go.ListAllSourcesStatusResponse] submitSourceHeartbeat *connect.Client[sdp_go.SubmitSourceHeartbeatRequest, sdp_go.SubmitSourceHeartbeatResponse] keepaliveSources *connect.Client[sdp_go.KeepaliveSourcesRequest, sdp_go.KeepaliveSourcesResponse] createToken *connect.Client[sdp_go.CreateTokenRequest, sdp_go.CreateTokenResponse] revlinkWarmup *connect.Client[sdp_go.RevlinkWarmupRequest, sdp_go.RevlinkWarmupResponse] listAvailableItemTypes *connect.Client[sdp_go.ListAvailableItemTypesRequest, sdp_go.ListAvailableItemTypesResponse] getSourceStatus *connect.Client[sdp_go.GetSourceStatusRequest, sdp_go.GetSourceStatusResponse] getUserOnboardingStatus *connect.Client[sdp_go.GetUserOnboardingStatusRequest, sdp_go.GetUserOnboardingStatusResponse] setUserOnboardingStatus *connect.Client[sdp_go.SetUserOnboardingStatusRequest, sdp_go.SetUserOnboardingStatusResponse] listTeamMembers *connect.Client[sdp_go.ListTeamMembersRequest, sdp_go.ListTeamMembersResponse] getWelcomeScreenInformation *connect.Client[sdp_go.GetWelcomeScreenInformationRequest, sdp_go.GetWelcomeScreenInformationResponse] setGithubInstallationID *connect.Client[sdp_go.SetGithubInstallationIDRequest, sdp_go.SetGithubInstallationIDResponse] unsetGithubInstallationID *connect.Client[sdp_go.UnsetGithubInstallationIDRequest, sdp_go.UnsetGithubInstallationIDResponse] getOrCreateAWSExternalId *connect.Client[sdp_go.GetOrCreateAWSExternalIdRequest, sdp_go.GetOrCreateAWSExternalIdResponse] } // GetAccount calls account.ManagementService.GetAccount. func (c *managementServiceClient) GetAccount(ctx context.Context, req *connect.Request[sdp_go.GetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) { return c.getAccount.CallUnary(ctx, req) } // DeleteAccount calls account.ManagementService.DeleteAccount. func (c *managementServiceClient) DeleteAccount(ctx context.Context, req *connect.Request[sdp_go.DeleteAccountRequest]) (*connect.Response[sdp_go.DeleteAccountResponse], error) { return c.deleteAccount.CallUnary(ctx, req) } // ListSources calls account.ManagementService.ListSources. func (c *managementServiceClient) ListSources(ctx context.Context, req *connect.Request[sdp_go.ListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) { return c.listSources.CallUnary(ctx, req) } // CreateSource calls account.ManagementService.CreateSource. func (c *managementServiceClient) CreateSource(ctx context.Context, req *connect.Request[sdp_go.CreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) { return c.createSource.CallUnary(ctx, req) } // GetSource calls account.ManagementService.GetSource. func (c *managementServiceClient) GetSource(ctx context.Context, req *connect.Request[sdp_go.GetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) { return c.getSource.CallUnary(ctx, req) } // UpdateSource calls account.ManagementService.UpdateSource. func (c *managementServiceClient) UpdateSource(ctx context.Context, req *connect.Request[sdp_go.UpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) { return c.updateSource.CallUnary(ctx, req) } // DeleteSource calls account.ManagementService.DeleteSource. func (c *managementServiceClient) DeleteSource(ctx context.Context, req *connect.Request[sdp_go.DeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) { return c.deleteSource.CallUnary(ctx, req) } // ListAllSourcesStatus calls account.ManagementService.ListAllSourcesStatus. func (c *managementServiceClient) ListAllSourcesStatus(ctx context.Context, req *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) { return c.listAllSourcesStatus.CallUnary(ctx, req) } // ListActiveSourcesStatus calls account.ManagementService.ListActiveSourcesStatus. func (c *managementServiceClient) ListActiveSourcesStatus(ctx context.Context, req *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) { return c.listActiveSourcesStatus.CallUnary(ctx, req) } // SubmitSourceHeartbeat calls account.ManagementService.SubmitSourceHeartbeat. func (c *managementServiceClient) SubmitSourceHeartbeat(ctx context.Context, req *connect.Request[sdp_go.SubmitSourceHeartbeatRequest]) (*connect.Response[sdp_go.SubmitSourceHeartbeatResponse], error) { return c.submitSourceHeartbeat.CallUnary(ctx, req) } // KeepaliveSources calls account.ManagementService.KeepaliveSources. func (c *managementServiceClient) KeepaliveSources(ctx context.Context, req *connect.Request[sdp_go.KeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) { return c.keepaliveSources.CallUnary(ctx, req) } // CreateToken calls account.ManagementService.CreateToken. func (c *managementServiceClient) CreateToken(ctx context.Context, req *connect.Request[sdp_go.CreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) { return c.createToken.CallUnary(ctx, req) } // RevlinkWarmup calls account.ManagementService.RevlinkWarmup. func (c *managementServiceClient) RevlinkWarmup(ctx context.Context, req *connect.Request[sdp_go.RevlinkWarmupRequest]) (*connect.ServerStreamForClient[sdp_go.RevlinkWarmupResponse], error) { return c.revlinkWarmup.CallServerStream(ctx, req) } // ListAvailableItemTypes calls account.ManagementService.ListAvailableItemTypes. func (c *managementServiceClient) ListAvailableItemTypes(ctx context.Context, req *connect.Request[sdp_go.ListAvailableItemTypesRequest]) (*connect.Response[sdp_go.ListAvailableItemTypesResponse], error) { return c.listAvailableItemTypes.CallUnary(ctx, req) } // GetSourceStatus calls account.ManagementService.GetSourceStatus. func (c *managementServiceClient) GetSourceStatus(ctx context.Context, req *connect.Request[sdp_go.GetSourceStatusRequest]) (*connect.Response[sdp_go.GetSourceStatusResponse], error) { return c.getSourceStatus.CallUnary(ctx, req) } // GetUserOnboardingStatus calls account.ManagementService.GetUserOnboardingStatus. func (c *managementServiceClient) GetUserOnboardingStatus(ctx context.Context, req *connect.Request[sdp_go.GetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.GetUserOnboardingStatusResponse], error) { return c.getUserOnboardingStatus.CallUnary(ctx, req) } // SetUserOnboardingStatus calls account.ManagementService.SetUserOnboardingStatus. func (c *managementServiceClient) SetUserOnboardingStatus(ctx context.Context, req *connect.Request[sdp_go.SetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.SetUserOnboardingStatusResponse], error) { return c.setUserOnboardingStatus.CallUnary(ctx, req) } // ListTeamMembers calls account.ManagementService.ListTeamMembers. func (c *managementServiceClient) ListTeamMembers(ctx context.Context, req *connect.Request[sdp_go.ListTeamMembersRequest]) (*connect.Response[sdp_go.ListTeamMembersResponse], error) { return c.listTeamMembers.CallUnary(ctx, req) } // GetWelcomeScreenInformation calls account.ManagementService.GetWelcomeScreenInformation. func (c *managementServiceClient) GetWelcomeScreenInformation(ctx context.Context, req *connect.Request[sdp_go.GetWelcomeScreenInformationRequest]) (*connect.Response[sdp_go.GetWelcomeScreenInformationResponse], error) { return c.getWelcomeScreenInformation.CallUnary(ctx, req) } // SetGithubInstallationID calls account.ManagementService.SetGithubInstallationID. func (c *managementServiceClient) SetGithubInstallationID(ctx context.Context, req *connect.Request[sdp_go.SetGithubInstallationIDRequest]) (*connect.Response[sdp_go.SetGithubInstallationIDResponse], error) { return c.setGithubInstallationID.CallUnary(ctx, req) } // UnsetGithubInstallationID calls account.ManagementService.UnsetGithubInstallationID. func (c *managementServiceClient) UnsetGithubInstallationID(ctx context.Context, req *connect.Request[sdp_go.UnsetGithubInstallationIDRequest]) (*connect.Response[sdp_go.UnsetGithubInstallationIDResponse], error) { return c.unsetGithubInstallationID.CallUnary(ctx, req) } // GetOrCreateAWSExternalId calls account.ManagementService.GetOrCreateAWSExternalId. func (c *managementServiceClient) GetOrCreateAWSExternalId(ctx context.Context, req *connect.Request[sdp_go.GetOrCreateAWSExternalIdRequest]) (*connect.Response[sdp_go.GetOrCreateAWSExternalIdResponse], error) { return c.getOrCreateAWSExternalId.CallUnary(ctx, req) } // ManagementServiceHandler is an implementation of the account.ManagementService service. type ManagementServiceHandler interface { // Get the details of the account that this user belongs to GetAccount(context.Context, *connect.Request[sdp_go.GetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) // Completely deletes the user's account. This includes all of the data in // that account, bookmarks, changes etc. It also deletes the current user, // and all other users in that account from Auth0 DeleteAccount(context.Context, *connect.Request[sdp_go.DeleteAccountRequest]) (*connect.Response[sdp_go.DeleteAccountResponse], error) // Lists all sources within the user's account ListSources(context.Context, *connect.Request[sdp_go.ListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) // Creates a new source within the user's account CreateSource(context.Context, *connect.Request[sdp_go.CreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) // Get the details of a source GetSource(context.Context, *connect.Request[sdp_go.GetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) // Update the details of a source UpdateSource(context.Context, *connect.Request[sdp_go.UpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) // Deletes a source from a user's account DeleteSource(context.Context, *connect.Request[sdp_go.DeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) // Sources heartbeat and health // List of all recently active sources and their health, includes information from srcman // meaning that it can show the status of managed sources that have not started and // connected yet ListAllSourcesStatus(context.Context, *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) // Lists all active sources and their health. This should be used to determine // what types, scopes etc are available rather than `ListAllSourcesStatus` since // this endpoint only include running, available sources ListActiveSourcesStatus(context.Context, *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) // Heartbeat from a source to keep it registered and healthy SubmitSourceHeartbeat(context.Context, *connect.Request[sdp_go.SubmitSourceHeartbeatRequest]) (*connect.Response[sdp_go.SubmitSourceHeartbeatResponse], error) // Updates sources to keep them running in the background. This can be used // to add explicit action, when the built-in keepalives are not sufficient. // A user can specify how long they are willing to wait and will get a // response either when all sources start, or when the timeout is reached. // If the timeout is reached the response will contain the current state of // all sources at that moment KeepaliveSources(context.Context, *connect.Request[sdp_go.KeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) // Create a new NATS token for a given public NKey. The user requesting must // control the associated private key also in order to connect to NATS as // the token is not enough on its own CreateToken(context.Context, *connect.Request[sdp_go.CreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) // Ensure that all reverse links are populated. This does internal debouncing // so the actual logic does only run when required. RevlinkWarmup(context.Context, *connect.Request[sdp_go.RevlinkWarmupRequest], *connect.ServerStream[sdp_go.RevlinkWarmupResponse]) error // Lists all the available item types that can be discovered by sources that are running and healthy ListAvailableItemTypes(context.Context, *connect.Request[sdp_go.ListAvailableItemTypesRequest]) (*connect.Response[sdp_go.ListAvailableItemTypesResponse], error) // Get status of a single source by UUID GetSourceStatus(context.Context, *connect.Request[sdp_go.GetSourceStatusRequest]) (*connect.Response[sdp_go.GetSourceStatusResponse], error) // Get and set onboarding status for users GetUserOnboardingStatus(context.Context, *connect.Request[sdp_go.GetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.GetUserOnboardingStatusResponse], error) SetUserOnboardingStatus(context.Context, *connect.Request[sdp_go.SetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.SetUserOnboardingStatusResponse], error) // List team members in the current user's account (excludes the active user) ListTeamMembers(context.Context, *connect.Request[sdp_go.ListTeamMembersRequest]) (*connect.Response[sdp_go.ListTeamMembersResponse], error) // Get welcome information for the current user GetWelcomeScreenInformation(context.Context, *connect.Request[sdp_go.GetWelcomeScreenInformationRequest]) (*connect.Response[sdp_go.GetWelcomeScreenInformationResponse], error) // Set github installation ID for the account SetGithubInstallationID(context.Context, *connect.Request[sdp_go.SetGithubInstallationIDRequest]) (*connect.Response[sdp_go.SetGithubInstallationIDResponse], error) // this will unset the github installation ID for the account, allowing the user to install the github app again // it will also remove the organisation profile, so we no longer generate signals for that org UnsetGithubInstallationID(context.Context, *connect.Request[sdp_go.UnsetGithubInstallationIDRequest]) (*connect.Response[sdp_go.UnsetGithubInstallationIDResponse], error) // Returns a stable, per-account external ID for AWS IAM trust policies. // Generates a UUID on first call; returns the same UUID on subsequent calls. GetOrCreateAWSExternalId(context.Context, *connect.Request[sdp_go.GetOrCreateAWSExternalIdRequest]) (*connect.Response[sdp_go.GetOrCreateAWSExternalIdResponse], error) } // NewManagementServiceHandler builds an HTTP handler from the service implementation. It returns // the path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewManagementServiceHandler(svc ManagementServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { managementServiceMethods := sdp_go.File_account_proto.Services().ByName("ManagementService").Methods() managementServiceGetAccountHandler := connect.NewUnaryHandler( ManagementServiceGetAccountProcedure, svc.GetAccount, connect.WithSchema(managementServiceMethods.ByName("GetAccount")), connect.WithHandlerOptions(opts...), ) managementServiceDeleteAccountHandler := connect.NewUnaryHandler( ManagementServiceDeleteAccountProcedure, svc.DeleteAccount, connect.WithSchema(managementServiceMethods.ByName("DeleteAccount")), connect.WithHandlerOptions(opts...), ) managementServiceListSourcesHandler := connect.NewUnaryHandler( ManagementServiceListSourcesProcedure, svc.ListSources, connect.WithSchema(managementServiceMethods.ByName("ListSources")), connect.WithHandlerOptions(opts...), ) managementServiceCreateSourceHandler := connect.NewUnaryHandler( ManagementServiceCreateSourceProcedure, svc.CreateSource, connect.WithSchema(managementServiceMethods.ByName("CreateSource")), connect.WithHandlerOptions(opts...), ) managementServiceGetSourceHandler := connect.NewUnaryHandler( ManagementServiceGetSourceProcedure, svc.GetSource, connect.WithSchema(managementServiceMethods.ByName("GetSource")), connect.WithHandlerOptions(opts...), ) managementServiceUpdateSourceHandler := connect.NewUnaryHandler( ManagementServiceUpdateSourceProcedure, svc.UpdateSource, connect.WithSchema(managementServiceMethods.ByName("UpdateSource")), connect.WithHandlerOptions(opts...), ) managementServiceDeleteSourceHandler := connect.NewUnaryHandler( ManagementServiceDeleteSourceProcedure, svc.DeleteSource, connect.WithSchema(managementServiceMethods.ByName("DeleteSource")), connect.WithHandlerOptions(opts...), ) managementServiceListAllSourcesStatusHandler := connect.NewUnaryHandler( ManagementServiceListAllSourcesStatusProcedure, svc.ListAllSourcesStatus, connect.WithSchema(managementServiceMethods.ByName("ListAllSourcesStatus")), connect.WithHandlerOptions(opts...), ) managementServiceListActiveSourcesStatusHandler := connect.NewUnaryHandler( ManagementServiceListActiveSourcesStatusProcedure, svc.ListActiveSourcesStatus, connect.WithSchema(managementServiceMethods.ByName("ListActiveSourcesStatus")), connect.WithHandlerOptions(opts...), ) managementServiceSubmitSourceHeartbeatHandler := connect.NewUnaryHandler( ManagementServiceSubmitSourceHeartbeatProcedure, svc.SubmitSourceHeartbeat, connect.WithSchema(managementServiceMethods.ByName("SubmitSourceHeartbeat")), connect.WithHandlerOptions(opts...), ) managementServiceKeepaliveSourcesHandler := connect.NewUnaryHandler( ManagementServiceKeepaliveSourcesProcedure, svc.KeepaliveSources, connect.WithSchema(managementServiceMethods.ByName("KeepaliveSources")), connect.WithHandlerOptions(opts...), ) managementServiceCreateTokenHandler := connect.NewUnaryHandler( ManagementServiceCreateTokenProcedure, svc.CreateToken, connect.WithSchema(managementServiceMethods.ByName("CreateToken")), connect.WithHandlerOptions(opts...), ) managementServiceRevlinkWarmupHandler := connect.NewServerStreamHandler( ManagementServiceRevlinkWarmupProcedure, svc.RevlinkWarmup, connect.WithSchema(managementServiceMethods.ByName("RevlinkWarmup")), connect.WithHandlerOptions(opts...), ) managementServiceListAvailableItemTypesHandler := connect.NewUnaryHandler( ManagementServiceListAvailableItemTypesProcedure, svc.ListAvailableItemTypes, connect.WithSchema(managementServiceMethods.ByName("ListAvailableItemTypes")), connect.WithHandlerOptions(opts...), ) managementServiceGetSourceStatusHandler := connect.NewUnaryHandler( ManagementServiceGetSourceStatusProcedure, svc.GetSourceStatus, connect.WithSchema(managementServiceMethods.ByName("GetSourceStatus")), connect.WithHandlerOptions(opts...), ) managementServiceGetUserOnboardingStatusHandler := connect.NewUnaryHandler( ManagementServiceGetUserOnboardingStatusProcedure, svc.GetUserOnboardingStatus, connect.WithSchema(managementServiceMethods.ByName("GetUserOnboardingStatus")), connect.WithHandlerOptions(opts...), ) managementServiceSetUserOnboardingStatusHandler := connect.NewUnaryHandler( ManagementServiceSetUserOnboardingStatusProcedure, svc.SetUserOnboardingStatus, connect.WithSchema(managementServiceMethods.ByName("SetUserOnboardingStatus")), connect.WithHandlerOptions(opts...), ) managementServiceListTeamMembersHandler := connect.NewUnaryHandler( ManagementServiceListTeamMembersProcedure, svc.ListTeamMembers, connect.WithSchema(managementServiceMethods.ByName("ListTeamMembers")), connect.WithHandlerOptions(opts...), ) managementServiceGetWelcomeScreenInformationHandler := connect.NewUnaryHandler( ManagementServiceGetWelcomeScreenInformationProcedure, svc.GetWelcomeScreenInformation, connect.WithSchema(managementServiceMethods.ByName("GetWelcomeScreenInformation")), connect.WithHandlerOptions(opts...), ) managementServiceSetGithubInstallationIDHandler := connect.NewUnaryHandler( ManagementServiceSetGithubInstallationIDProcedure, svc.SetGithubInstallationID, connect.WithSchema(managementServiceMethods.ByName("SetGithubInstallationID")), connect.WithHandlerOptions(opts...), ) managementServiceUnsetGithubInstallationIDHandler := connect.NewUnaryHandler( ManagementServiceUnsetGithubInstallationIDProcedure, svc.UnsetGithubInstallationID, connect.WithSchema(managementServiceMethods.ByName("UnsetGithubInstallationID")), connect.WithHandlerOptions(opts...), ) managementServiceGetOrCreateAWSExternalIdHandler := connect.NewUnaryHandler( ManagementServiceGetOrCreateAWSExternalIdProcedure, svc.GetOrCreateAWSExternalId, connect.WithSchema(managementServiceMethods.ByName("GetOrCreateAWSExternalId")), connect.WithHandlerOptions(opts...), ) return "/account.ManagementService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ManagementServiceGetAccountProcedure: managementServiceGetAccountHandler.ServeHTTP(w, r) case ManagementServiceDeleteAccountProcedure: managementServiceDeleteAccountHandler.ServeHTTP(w, r) case ManagementServiceListSourcesProcedure: managementServiceListSourcesHandler.ServeHTTP(w, r) case ManagementServiceCreateSourceProcedure: managementServiceCreateSourceHandler.ServeHTTP(w, r) case ManagementServiceGetSourceProcedure: managementServiceGetSourceHandler.ServeHTTP(w, r) case ManagementServiceUpdateSourceProcedure: managementServiceUpdateSourceHandler.ServeHTTP(w, r) case ManagementServiceDeleteSourceProcedure: managementServiceDeleteSourceHandler.ServeHTTP(w, r) case ManagementServiceListAllSourcesStatusProcedure: managementServiceListAllSourcesStatusHandler.ServeHTTP(w, r) case ManagementServiceListActiveSourcesStatusProcedure: managementServiceListActiveSourcesStatusHandler.ServeHTTP(w, r) case ManagementServiceSubmitSourceHeartbeatProcedure: managementServiceSubmitSourceHeartbeatHandler.ServeHTTP(w, r) case ManagementServiceKeepaliveSourcesProcedure: managementServiceKeepaliveSourcesHandler.ServeHTTP(w, r) case ManagementServiceCreateTokenProcedure: managementServiceCreateTokenHandler.ServeHTTP(w, r) case ManagementServiceRevlinkWarmupProcedure: managementServiceRevlinkWarmupHandler.ServeHTTP(w, r) case ManagementServiceListAvailableItemTypesProcedure: managementServiceListAvailableItemTypesHandler.ServeHTTP(w, r) case ManagementServiceGetSourceStatusProcedure: managementServiceGetSourceStatusHandler.ServeHTTP(w, r) case ManagementServiceGetUserOnboardingStatusProcedure: managementServiceGetUserOnboardingStatusHandler.ServeHTTP(w, r) case ManagementServiceSetUserOnboardingStatusProcedure: managementServiceSetUserOnboardingStatusHandler.ServeHTTP(w, r) case ManagementServiceListTeamMembersProcedure: managementServiceListTeamMembersHandler.ServeHTTP(w, r) case ManagementServiceGetWelcomeScreenInformationProcedure: managementServiceGetWelcomeScreenInformationHandler.ServeHTTP(w, r) case ManagementServiceSetGithubInstallationIDProcedure: managementServiceSetGithubInstallationIDHandler.ServeHTTP(w, r) case ManagementServiceUnsetGithubInstallationIDProcedure: managementServiceUnsetGithubInstallationIDHandler.ServeHTTP(w, r) case ManagementServiceGetOrCreateAWSExternalIdProcedure: managementServiceGetOrCreateAWSExternalIdHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedManagementServiceHandler returns CodeUnimplemented from all methods. type UnimplementedManagementServiceHandler struct{} func (UnimplementedManagementServiceHandler) GetAccount(context.Context, *connect.Request[sdp_go.GetAccountRequest]) (*connect.Response[sdp_go.GetAccountResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.GetAccount is not implemented")) } func (UnimplementedManagementServiceHandler) DeleteAccount(context.Context, *connect.Request[sdp_go.DeleteAccountRequest]) (*connect.Response[sdp_go.DeleteAccountResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.DeleteAccount is not implemented")) } func (UnimplementedManagementServiceHandler) ListSources(context.Context, *connect.Request[sdp_go.ListSourcesRequest]) (*connect.Response[sdp_go.ListSourcesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.ListSources is not implemented")) } func (UnimplementedManagementServiceHandler) CreateSource(context.Context, *connect.Request[sdp_go.CreateSourceRequest]) (*connect.Response[sdp_go.CreateSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.CreateSource is not implemented")) } func (UnimplementedManagementServiceHandler) GetSource(context.Context, *connect.Request[sdp_go.GetSourceRequest]) (*connect.Response[sdp_go.GetSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.GetSource is not implemented")) } func (UnimplementedManagementServiceHandler) UpdateSource(context.Context, *connect.Request[sdp_go.UpdateSourceRequest]) (*connect.Response[sdp_go.UpdateSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.UpdateSource is not implemented")) } func (UnimplementedManagementServiceHandler) DeleteSource(context.Context, *connect.Request[sdp_go.DeleteSourceRequest]) (*connect.Response[sdp_go.DeleteSourceResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.DeleteSource is not implemented")) } func (UnimplementedManagementServiceHandler) ListAllSourcesStatus(context.Context, *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.ListAllSourcesStatus is not implemented")) } func (UnimplementedManagementServiceHandler) ListActiveSourcesStatus(context.Context, *connect.Request[sdp_go.ListAllSourcesStatusRequest]) (*connect.Response[sdp_go.ListAllSourcesStatusResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.ListActiveSourcesStatus is not implemented")) } func (UnimplementedManagementServiceHandler) SubmitSourceHeartbeat(context.Context, *connect.Request[sdp_go.SubmitSourceHeartbeatRequest]) (*connect.Response[sdp_go.SubmitSourceHeartbeatResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.SubmitSourceHeartbeat is not implemented")) } func (UnimplementedManagementServiceHandler) KeepaliveSources(context.Context, *connect.Request[sdp_go.KeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.KeepaliveSources is not implemented")) } func (UnimplementedManagementServiceHandler) CreateToken(context.Context, *connect.Request[sdp_go.CreateTokenRequest]) (*connect.Response[sdp_go.CreateTokenResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.CreateToken is not implemented")) } func (UnimplementedManagementServiceHandler) RevlinkWarmup(context.Context, *connect.Request[sdp_go.RevlinkWarmupRequest], *connect.ServerStream[sdp_go.RevlinkWarmupResponse]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.RevlinkWarmup is not implemented")) } func (UnimplementedManagementServiceHandler) ListAvailableItemTypes(context.Context, *connect.Request[sdp_go.ListAvailableItemTypesRequest]) (*connect.Response[sdp_go.ListAvailableItemTypesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.ListAvailableItemTypes is not implemented")) } func (UnimplementedManagementServiceHandler) GetSourceStatus(context.Context, *connect.Request[sdp_go.GetSourceStatusRequest]) (*connect.Response[sdp_go.GetSourceStatusResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.GetSourceStatus is not implemented")) } func (UnimplementedManagementServiceHandler) GetUserOnboardingStatus(context.Context, *connect.Request[sdp_go.GetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.GetUserOnboardingStatusResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.GetUserOnboardingStatus is not implemented")) } func (UnimplementedManagementServiceHandler) SetUserOnboardingStatus(context.Context, *connect.Request[sdp_go.SetUserOnboardingStatusRequest]) (*connect.Response[sdp_go.SetUserOnboardingStatusResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.SetUserOnboardingStatus is not implemented")) } func (UnimplementedManagementServiceHandler) ListTeamMembers(context.Context, *connect.Request[sdp_go.ListTeamMembersRequest]) (*connect.Response[sdp_go.ListTeamMembersResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.ListTeamMembers is not implemented")) } func (UnimplementedManagementServiceHandler) GetWelcomeScreenInformation(context.Context, *connect.Request[sdp_go.GetWelcomeScreenInformationRequest]) (*connect.Response[sdp_go.GetWelcomeScreenInformationResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.GetWelcomeScreenInformation is not implemented")) } func (UnimplementedManagementServiceHandler) SetGithubInstallationID(context.Context, *connect.Request[sdp_go.SetGithubInstallationIDRequest]) (*connect.Response[sdp_go.SetGithubInstallationIDResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.SetGithubInstallationID is not implemented")) } func (UnimplementedManagementServiceHandler) UnsetGithubInstallationID(context.Context, *connect.Request[sdp_go.UnsetGithubInstallationIDRequest]) (*connect.Response[sdp_go.UnsetGithubInstallationIDResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.UnsetGithubInstallationID is not implemented")) } func (UnimplementedManagementServiceHandler) GetOrCreateAWSExternalId(context.Context, *connect.Request[sdp_go.GetOrCreateAWSExternalIdRequest]) (*connect.Response[sdp_go.GetOrCreateAWSExternalIdResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("account.ManagementService.GetOrCreateAWSExternalId is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/apikeys.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: apikeys.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // ApiKeyServiceName is the fully-qualified name of the ApiKeyService service. ApiKeyServiceName = "apikeys.ApiKeyService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // ApiKeyServiceCreateAPIKeyProcedure is the fully-qualified name of the ApiKeyService's // CreateAPIKey RPC. ApiKeyServiceCreateAPIKeyProcedure = "/apikeys.ApiKeyService/CreateAPIKey" // ApiKeyServiceRefreshAPIKeyProcedure is the fully-qualified name of the ApiKeyService's // RefreshAPIKey RPC. ApiKeyServiceRefreshAPIKeyProcedure = "/apikeys.ApiKeyService/RefreshAPIKey" // ApiKeyServiceGetAPIKeyProcedure is the fully-qualified name of the ApiKeyService's GetAPIKey RPC. ApiKeyServiceGetAPIKeyProcedure = "/apikeys.ApiKeyService/GetAPIKey" // ApiKeyServiceUpdateAPIKeyProcedure is the fully-qualified name of the ApiKeyService's // UpdateAPIKey RPC. ApiKeyServiceUpdateAPIKeyProcedure = "/apikeys.ApiKeyService/UpdateAPIKey" // ApiKeyServiceListAPIKeysProcedure is the fully-qualified name of the ApiKeyService's ListAPIKeys // RPC. ApiKeyServiceListAPIKeysProcedure = "/apikeys.ApiKeyService/ListAPIKeys" // ApiKeyServiceDeleteAPIKeyProcedure is the fully-qualified name of the ApiKeyService's // DeleteAPIKey RPC. ApiKeyServiceDeleteAPIKeyProcedure = "/apikeys.ApiKeyService/DeleteAPIKey" // ApiKeyServiceExchangeKeyForTokenProcedure is the fully-qualified name of the ApiKeyService's // ExchangeKeyForToken RPC. ApiKeyServiceExchangeKeyForTokenProcedure = "/apikeys.ApiKeyService/ExchangeKeyForToken" ) // ApiKeyServiceClient is a client for the apikeys.ApiKeyService service. type ApiKeyServiceClient interface { // Creates an API key, pending access token generation from Auth0. The key // cannot be used until the user has been redirected to the given URL which // allows Auth0 to actually generate an access token CreateAPIKey(context.Context, *connect.Request[sdp_go.CreateAPIKeyRequest]) (*connect.Response[sdp_go.CreateAPIKeyResponse], error) // Refreshes an API key, returning a new one with the same metadata and // properties. The response will be the same as CreateAPIKey, and requires // the same redirect handling to authenticate the new key. RefreshAPIKey(context.Context, *connect.Request[sdp_go.RefreshAPIKeyRequest]) (*connect.Response[sdp_go.RefreshAPIKeyResponse], error) GetAPIKey(context.Context, *connect.Request[sdp_go.GetAPIKeyRequest]) (*connect.Response[sdp_go.GetAPIKeyResponse], error) UpdateAPIKey(context.Context, *connect.Request[sdp_go.UpdateAPIKeyRequest]) (*connect.Response[sdp_go.UpdateAPIKeyResponse], error) ListAPIKeys(context.Context, *connect.Request[sdp_go.ListAPIKeysRequest]) (*connect.Response[sdp_go.ListAPIKeysResponse], error) DeleteAPIKey(context.Context, *connect.Request[sdp_go.DeleteAPIKeyRequest]) (*connect.Response[sdp_go.DeleteAPIKeyResponse], error) // Exchanges an Overmind API key for an Oauth access token. That token can // then be used to access all other Overmind APIs ExchangeKeyForToken(context.Context, *connect.Request[sdp_go.ExchangeKeyForTokenRequest]) (*connect.Response[sdp_go.ExchangeKeyForTokenResponse], error) } // NewApiKeyServiceClient constructs a client for the apikeys.ApiKeyService service. By default, it // uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewApiKeyServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ApiKeyServiceClient { baseURL = strings.TrimRight(baseURL, "/") apiKeyServiceMethods := sdp_go.File_apikeys_proto.Services().ByName("ApiKeyService").Methods() return &apiKeyServiceClient{ createAPIKey: connect.NewClient[sdp_go.CreateAPIKeyRequest, sdp_go.CreateAPIKeyResponse]( httpClient, baseURL+ApiKeyServiceCreateAPIKeyProcedure, connect.WithSchema(apiKeyServiceMethods.ByName("CreateAPIKey")), connect.WithClientOptions(opts...), ), refreshAPIKey: connect.NewClient[sdp_go.RefreshAPIKeyRequest, sdp_go.RefreshAPIKeyResponse]( httpClient, baseURL+ApiKeyServiceRefreshAPIKeyProcedure, connect.WithSchema(apiKeyServiceMethods.ByName("RefreshAPIKey")), connect.WithClientOptions(opts...), ), getAPIKey: connect.NewClient[sdp_go.GetAPIKeyRequest, sdp_go.GetAPIKeyResponse]( httpClient, baseURL+ApiKeyServiceGetAPIKeyProcedure, connect.WithSchema(apiKeyServiceMethods.ByName("GetAPIKey")), connect.WithClientOptions(opts...), ), updateAPIKey: connect.NewClient[sdp_go.UpdateAPIKeyRequest, sdp_go.UpdateAPIKeyResponse]( httpClient, baseURL+ApiKeyServiceUpdateAPIKeyProcedure, connect.WithSchema(apiKeyServiceMethods.ByName("UpdateAPIKey")), connect.WithClientOptions(opts...), ), listAPIKeys: connect.NewClient[sdp_go.ListAPIKeysRequest, sdp_go.ListAPIKeysResponse]( httpClient, baseURL+ApiKeyServiceListAPIKeysProcedure, connect.WithSchema(apiKeyServiceMethods.ByName("ListAPIKeys")), connect.WithClientOptions(opts...), ), deleteAPIKey: connect.NewClient[sdp_go.DeleteAPIKeyRequest, sdp_go.DeleteAPIKeyResponse]( httpClient, baseURL+ApiKeyServiceDeleteAPIKeyProcedure, connect.WithSchema(apiKeyServiceMethods.ByName("DeleteAPIKey")), connect.WithClientOptions(opts...), ), exchangeKeyForToken: connect.NewClient[sdp_go.ExchangeKeyForTokenRequest, sdp_go.ExchangeKeyForTokenResponse]( httpClient, baseURL+ApiKeyServiceExchangeKeyForTokenProcedure, connect.WithSchema(apiKeyServiceMethods.ByName("ExchangeKeyForToken")), connect.WithClientOptions(opts...), ), } } // apiKeyServiceClient implements ApiKeyServiceClient. type apiKeyServiceClient struct { createAPIKey *connect.Client[sdp_go.CreateAPIKeyRequest, sdp_go.CreateAPIKeyResponse] refreshAPIKey *connect.Client[sdp_go.RefreshAPIKeyRequest, sdp_go.RefreshAPIKeyResponse] getAPIKey *connect.Client[sdp_go.GetAPIKeyRequest, sdp_go.GetAPIKeyResponse] updateAPIKey *connect.Client[sdp_go.UpdateAPIKeyRequest, sdp_go.UpdateAPIKeyResponse] listAPIKeys *connect.Client[sdp_go.ListAPIKeysRequest, sdp_go.ListAPIKeysResponse] deleteAPIKey *connect.Client[sdp_go.DeleteAPIKeyRequest, sdp_go.DeleteAPIKeyResponse] exchangeKeyForToken *connect.Client[sdp_go.ExchangeKeyForTokenRequest, sdp_go.ExchangeKeyForTokenResponse] } // CreateAPIKey calls apikeys.ApiKeyService.CreateAPIKey. func (c *apiKeyServiceClient) CreateAPIKey(ctx context.Context, req *connect.Request[sdp_go.CreateAPIKeyRequest]) (*connect.Response[sdp_go.CreateAPIKeyResponse], error) { return c.createAPIKey.CallUnary(ctx, req) } // RefreshAPIKey calls apikeys.ApiKeyService.RefreshAPIKey. func (c *apiKeyServiceClient) RefreshAPIKey(ctx context.Context, req *connect.Request[sdp_go.RefreshAPIKeyRequest]) (*connect.Response[sdp_go.RefreshAPIKeyResponse], error) { return c.refreshAPIKey.CallUnary(ctx, req) } // GetAPIKey calls apikeys.ApiKeyService.GetAPIKey. func (c *apiKeyServiceClient) GetAPIKey(ctx context.Context, req *connect.Request[sdp_go.GetAPIKeyRequest]) (*connect.Response[sdp_go.GetAPIKeyResponse], error) { return c.getAPIKey.CallUnary(ctx, req) } // UpdateAPIKey calls apikeys.ApiKeyService.UpdateAPIKey. func (c *apiKeyServiceClient) UpdateAPIKey(ctx context.Context, req *connect.Request[sdp_go.UpdateAPIKeyRequest]) (*connect.Response[sdp_go.UpdateAPIKeyResponse], error) { return c.updateAPIKey.CallUnary(ctx, req) } // ListAPIKeys calls apikeys.ApiKeyService.ListAPIKeys. func (c *apiKeyServiceClient) ListAPIKeys(ctx context.Context, req *connect.Request[sdp_go.ListAPIKeysRequest]) (*connect.Response[sdp_go.ListAPIKeysResponse], error) { return c.listAPIKeys.CallUnary(ctx, req) } // DeleteAPIKey calls apikeys.ApiKeyService.DeleteAPIKey. func (c *apiKeyServiceClient) DeleteAPIKey(ctx context.Context, req *connect.Request[sdp_go.DeleteAPIKeyRequest]) (*connect.Response[sdp_go.DeleteAPIKeyResponse], error) { return c.deleteAPIKey.CallUnary(ctx, req) } // ExchangeKeyForToken calls apikeys.ApiKeyService.ExchangeKeyForToken. func (c *apiKeyServiceClient) ExchangeKeyForToken(ctx context.Context, req *connect.Request[sdp_go.ExchangeKeyForTokenRequest]) (*connect.Response[sdp_go.ExchangeKeyForTokenResponse], error) { return c.exchangeKeyForToken.CallUnary(ctx, req) } // ApiKeyServiceHandler is an implementation of the apikeys.ApiKeyService service. type ApiKeyServiceHandler interface { // Creates an API key, pending access token generation from Auth0. The key // cannot be used until the user has been redirected to the given URL which // allows Auth0 to actually generate an access token CreateAPIKey(context.Context, *connect.Request[sdp_go.CreateAPIKeyRequest]) (*connect.Response[sdp_go.CreateAPIKeyResponse], error) // Refreshes an API key, returning a new one with the same metadata and // properties. The response will be the same as CreateAPIKey, and requires // the same redirect handling to authenticate the new key. RefreshAPIKey(context.Context, *connect.Request[sdp_go.RefreshAPIKeyRequest]) (*connect.Response[sdp_go.RefreshAPIKeyResponse], error) GetAPIKey(context.Context, *connect.Request[sdp_go.GetAPIKeyRequest]) (*connect.Response[sdp_go.GetAPIKeyResponse], error) UpdateAPIKey(context.Context, *connect.Request[sdp_go.UpdateAPIKeyRequest]) (*connect.Response[sdp_go.UpdateAPIKeyResponse], error) ListAPIKeys(context.Context, *connect.Request[sdp_go.ListAPIKeysRequest]) (*connect.Response[sdp_go.ListAPIKeysResponse], error) DeleteAPIKey(context.Context, *connect.Request[sdp_go.DeleteAPIKeyRequest]) (*connect.Response[sdp_go.DeleteAPIKeyResponse], error) // Exchanges an Overmind API key for an Oauth access token. That token can // then be used to access all other Overmind APIs ExchangeKeyForToken(context.Context, *connect.Request[sdp_go.ExchangeKeyForTokenRequest]) (*connect.Response[sdp_go.ExchangeKeyForTokenResponse], error) } // NewApiKeyServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewApiKeyServiceHandler(svc ApiKeyServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { apiKeyServiceMethods := sdp_go.File_apikeys_proto.Services().ByName("ApiKeyService").Methods() apiKeyServiceCreateAPIKeyHandler := connect.NewUnaryHandler( ApiKeyServiceCreateAPIKeyProcedure, svc.CreateAPIKey, connect.WithSchema(apiKeyServiceMethods.ByName("CreateAPIKey")), connect.WithHandlerOptions(opts...), ) apiKeyServiceRefreshAPIKeyHandler := connect.NewUnaryHandler( ApiKeyServiceRefreshAPIKeyProcedure, svc.RefreshAPIKey, connect.WithSchema(apiKeyServiceMethods.ByName("RefreshAPIKey")), connect.WithHandlerOptions(opts...), ) apiKeyServiceGetAPIKeyHandler := connect.NewUnaryHandler( ApiKeyServiceGetAPIKeyProcedure, svc.GetAPIKey, connect.WithSchema(apiKeyServiceMethods.ByName("GetAPIKey")), connect.WithHandlerOptions(opts...), ) apiKeyServiceUpdateAPIKeyHandler := connect.NewUnaryHandler( ApiKeyServiceUpdateAPIKeyProcedure, svc.UpdateAPIKey, connect.WithSchema(apiKeyServiceMethods.ByName("UpdateAPIKey")), connect.WithHandlerOptions(opts...), ) apiKeyServiceListAPIKeysHandler := connect.NewUnaryHandler( ApiKeyServiceListAPIKeysProcedure, svc.ListAPIKeys, connect.WithSchema(apiKeyServiceMethods.ByName("ListAPIKeys")), connect.WithHandlerOptions(opts...), ) apiKeyServiceDeleteAPIKeyHandler := connect.NewUnaryHandler( ApiKeyServiceDeleteAPIKeyProcedure, svc.DeleteAPIKey, connect.WithSchema(apiKeyServiceMethods.ByName("DeleteAPIKey")), connect.WithHandlerOptions(opts...), ) apiKeyServiceExchangeKeyForTokenHandler := connect.NewUnaryHandler( ApiKeyServiceExchangeKeyForTokenProcedure, svc.ExchangeKeyForToken, connect.WithSchema(apiKeyServiceMethods.ByName("ExchangeKeyForToken")), connect.WithHandlerOptions(opts...), ) return "/apikeys.ApiKeyService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ApiKeyServiceCreateAPIKeyProcedure: apiKeyServiceCreateAPIKeyHandler.ServeHTTP(w, r) case ApiKeyServiceRefreshAPIKeyProcedure: apiKeyServiceRefreshAPIKeyHandler.ServeHTTP(w, r) case ApiKeyServiceGetAPIKeyProcedure: apiKeyServiceGetAPIKeyHandler.ServeHTTP(w, r) case ApiKeyServiceUpdateAPIKeyProcedure: apiKeyServiceUpdateAPIKeyHandler.ServeHTTP(w, r) case ApiKeyServiceListAPIKeysProcedure: apiKeyServiceListAPIKeysHandler.ServeHTTP(w, r) case ApiKeyServiceDeleteAPIKeyProcedure: apiKeyServiceDeleteAPIKeyHandler.ServeHTTP(w, r) case ApiKeyServiceExchangeKeyForTokenProcedure: apiKeyServiceExchangeKeyForTokenHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedApiKeyServiceHandler returns CodeUnimplemented from all methods. type UnimplementedApiKeyServiceHandler struct{} func (UnimplementedApiKeyServiceHandler) CreateAPIKey(context.Context, *connect.Request[sdp_go.CreateAPIKeyRequest]) (*connect.Response[sdp_go.CreateAPIKeyResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("apikeys.ApiKeyService.CreateAPIKey is not implemented")) } func (UnimplementedApiKeyServiceHandler) RefreshAPIKey(context.Context, *connect.Request[sdp_go.RefreshAPIKeyRequest]) (*connect.Response[sdp_go.RefreshAPIKeyResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("apikeys.ApiKeyService.RefreshAPIKey is not implemented")) } func (UnimplementedApiKeyServiceHandler) GetAPIKey(context.Context, *connect.Request[sdp_go.GetAPIKeyRequest]) (*connect.Response[sdp_go.GetAPIKeyResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("apikeys.ApiKeyService.GetAPIKey is not implemented")) } func (UnimplementedApiKeyServiceHandler) UpdateAPIKey(context.Context, *connect.Request[sdp_go.UpdateAPIKeyRequest]) (*connect.Response[sdp_go.UpdateAPIKeyResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("apikeys.ApiKeyService.UpdateAPIKey is not implemented")) } func (UnimplementedApiKeyServiceHandler) ListAPIKeys(context.Context, *connect.Request[sdp_go.ListAPIKeysRequest]) (*connect.Response[sdp_go.ListAPIKeysResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("apikeys.ApiKeyService.ListAPIKeys is not implemented")) } func (UnimplementedApiKeyServiceHandler) DeleteAPIKey(context.Context, *connect.Request[sdp_go.DeleteAPIKeyRequest]) (*connect.Response[sdp_go.DeleteAPIKeyResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("apikeys.ApiKeyService.DeleteAPIKey is not implemented")) } func (UnimplementedApiKeyServiceHandler) ExchangeKeyForToken(context.Context, *connect.Request[sdp_go.ExchangeKeyForTokenRequest]) (*connect.Response[sdp_go.ExchangeKeyForTokenResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("apikeys.ApiKeyService.ExchangeKeyForToken is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/area51.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: area51.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // Area51ServiceName is the fully-qualified name of the Area51Service service. Area51ServiceName = "area51.Area51Service" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // Area51ServiceGetChangeArchiveProcedure is the fully-qualified name of the Area51Service's // GetChangeArchive RPC. Area51ServiceGetChangeArchiveProcedure = "/area51.Area51Service/GetChangeArchive" ) // Area51ServiceClient is a client for the area51.Area51Service service. type Area51ServiceClient interface { // This is not implemented at all, it prevents javascript generation errors // we manually use the generated sdp objects in area51 service GetChangeArchive(context.Context, *connect.Request[sdp_go.GetChangeArchiveRequest]) (*connect.Response[sdp_go.GetChangeArchiveResponse], error) } // NewArea51ServiceClient constructs a client for the area51.Area51Service service. By default, it // uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewArea51ServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) Area51ServiceClient { baseURL = strings.TrimRight(baseURL, "/") area51ServiceMethods := sdp_go.File_area51_proto.Services().ByName("Area51Service").Methods() return &area51ServiceClient{ getChangeArchive: connect.NewClient[sdp_go.GetChangeArchiveRequest, sdp_go.GetChangeArchiveResponse]( httpClient, baseURL+Area51ServiceGetChangeArchiveProcedure, connect.WithSchema(area51ServiceMethods.ByName("GetChangeArchive")), connect.WithClientOptions(opts...), ), } } // area51ServiceClient implements Area51ServiceClient. type area51ServiceClient struct { getChangeArchive *connect.Client[sdp_go.GetChangeArchiveRequest, sdp_go.GetChangeArchiveResponse] } // GetChangeArchive calls area51.Area51Service.GetChangeArchive. func (c *area51ServiceClient) GetChangeArchive(ctx context.Context, req *connect.Request[sdp_go.GetChangeArchiveRequest]) (*connect.Response[sdp_go.GetChangeArchiveResponse], error) { return c.getChangeArchive.CallUnary(ctx, req) } // Area51ServiceHandler is an implementation of the area51.Area51Service service. type Area51ServiceHandler interface { // This is not implemented at all, it prevents javascript generation errors // we manually use the generated sdp objects in area51 service GetChangeArchive(context.Context, *connect.Request[sdp_go.GetChangeArchiveRequest]) (*connect.Response[sdp_go.GetChangeArchiveResponse], error) } // NewArea51ServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewArea51ServiceHandler(svc Area51ServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { area51ServiceMethods := sdp_go.File_area51_proto.Services().ByName("Area51Service").Methods() area51ServiceGetChangeArchiveHandler := connect.NewUnaryHandler( Area51ServiceGetChangeArchiveProcedure, svc.GetChangeArchive, connect.WithSchema(area51ServiceMethods.ByName("GetChangeArchive")), connect.WithHandlerOptions(opts...), ) return "/area51.Area51Service/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case Area51ServiceGetChangeArchiveProcedure: area51ServiceGetChangeArchiveHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedArea51ServiceHandler returns CodeUnimplemented from all methods. type UnimplementedArea51ServiceHandler struct{} func (UnimplementedArea51ServiceHandler) GetChangeArchive(context.Context, *connect.Request[sdp_go.GetChangeArchiveRequest]) (*connect.Response[sdp_go.GetChangeArchiveResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("area51.Area51Service.GetChangeArchive is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/auth0support.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: auth0support.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // Auth0SupportName is the fully-qualified name of the Auth0Support service. Auth0SupportName = "auth0support.Auth0Support" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // Auth0SupportCreateUserProcedure is the fully-qualified name of the Auth0Support's CreateUser RPC. Auth0SupportCreateUserProcedure = "/auth0support.Auth0Support/CreateUser" // Auth0SupportKeepaliveSourcesProcedure is the fully-qualified name of the Auth0Support's // KeepaliveSources RPC. Auth0SupportKeepaliveSourcesProcedure = "/auth0support.Auth0Support/KeepaliveSources" ) // Auth0SupportClient is a client for the auth0support.Auth0Support service. type Auth0SupportClient interface { // create a new user on first login CreateUser(context.Context, *connect.Request[sdp_go.Auth0CreateUserRequest]) (*connect.Response[sdp_go.Auth0CreateUserResponse], error) // Updates sources to keep them running in the background. This is called on // login by auth0 to give us a chance to boot up sources while the app is // loading. KeepaliveSources(context.Context, *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) } // NewAuth0SupportClient constructs a client for the auth0support.Auth0Support service. By default, // it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and // sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() // or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewAuth0SupportClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) Auth0SupportClient { baseURL = strings.TrimRight(baseURL, "/") auth0SupportMethods := sdp_go.File_auth0support_proto.Services().ByName("Auth0Support").Methods() return &auth0SupportClient{ createUser: connect.NewClient[sdp_go.Auth0CreateUserRequest, sdp_go.Auth0CreateUserResponse]( httpClient, baseURL+Auth0SupportCreateUserProcedure, connect.WithSchema(auth0SupportMethods.ByName("CreateUser")), connect.WithClientOptions(opts...), ), keepaliveSources: connect.NewClient[sdp_go.AdminKeepaliveSourcesRequest, sdp_go.KeepaliveSourcesResponse]( httpClient, baseURL+Auth0SupportKeepaliveSourcesProcedure, connect.WithSchema(auth0SupportMethods.ByName("KeepaliveSources")), connect.WithClientOptions(opts...), ), } } // auth0SupportClient implements Auth0SupportClient. type auth0SupportClient struct { createUser *connect.Client[sdp_go.Auth0CreateUserRequest, sdp_go.Auth0CreateUserResponse] keepaliveSources *connect.Client[sdp_go.AdminKeepaliveSourcesRequest, sdp_go.KeepaliveSourcesResponse] } // CreateUser calls auth0support.Auth0Support.CreateUser. func (c *auth0SupportClient) CreateUser(ctx context.Context, req *connect.Request[sdp_go.Auth0CreateUserRequest]) (*connect.Response[sdp_go.Auth0CreateUserResponse], error) { return c.createUser.CallUnary(ctx, req) } // KeepaliveSources calls auth0support.Auth0Support.KeepaliveSources. func (c *auth0SupportClient) KeepaliveSources(ctx context.Context, req *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) { return c.keepaliveSources.CallUnary(ctx, req) } // Auth0SupportHandler is an implementation of the auth0support.Auth0Support service. type Auth0SupportHandler interface { // create a new user on first login CreateUser(context.Context, *connect.Request[sdp_go.Auth0CreateUserRequest]) (*connect.Response[sdp_go.Auth0CreateUserResponse], error) // Updates sources to keep them running in the background. This is called on // login by auth0 to give us a chance to boot up sources while the app is // loading. KeepaliveSources(context.Context, *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) } // NewAuth0SupportHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewAuth0SupportHandler(svc Auth0SupportHandler, opts ...connect.HandlerOption) (string, http.Handler) { auth0SupportMethods := sdp_go.File_auth0support_proto.Services().ByName("Auth0Support").Methods() auth0SupportCreateUserHandler := connect.NewUnaryHandler( Auth0SupportCreateUserProcedure, svc.CreateUser, connect.WithSchema(auth0SupportMethods.ByName("CreateUser")), connect.WithHandlerOptions(opts...), ) auth0SupportKeepaliveSourcesHandler := connect.NewUnaryHandler( Auth0SupportKeepaliveSourcesProcedure, svc.KeepaliveSources, connect.WithSchema(auth0SupportMethods.ByName("KeepaliveSources")), connect.WithHandlerOptions(opts...), ) return "/auth0support.Auth0Support/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case Auth0SupportCreateUserProcedure: auth0SupportCreateUserHandler.ServeHTTP(w, r) case Auth0SupportKeepaliveSourcesProcedure: auth0SupportKeepaliveSourcesHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedAuth0SupportHandler returns CodeUnimplemented from all methods. type UnimplementedAuth0SupportHandler struct{} func (UnimplementedAuth0SupportHandler) CreateUser(context.Context, *connect.Request[sdp_go.Auth0CreateUserRequest]) (*connect.Response[sdp_go.Auth0CreateUserResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth0support.Auth0Support.CreateUser is not implemented")) } func (UnimplementedAuth0SupportHandler) KeepaliveSources(context.Context, *connect.Request[sdp_go.AdminKeepaliveSourcesRequest]) (*connect.Response[sdp_go.KeepaliveSourcesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth0support.Auth0Support.KeepaliveSources is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/bookmarks.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: bookmarks.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // BookmarksServiceName is the fully-qualified name of the BookmarksService service. BookmarksServiceName = "bookmarks.BookmarksService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // BookmarksServiceListBookmarksProcedure is the fully-qualified name of the BookmarksService's // ListBookmarks RPC. BookmarksServiceListBookmarksProcedure = "/bookmarks.BookmarksService/ListBookmarks" // BookmarksServiceCreateBookmarkProcedure is the fully-qualified name of the BookmarksService's // CreateBookmark RPC. BookmarksServiceCreateBookmarkProcedure = "/bookmarks.BookmarksService/CreateBookmark" // BookmarksServiceGetBookmarkProcedure is the fully-qualified name of the BookmarksService's // GetBookmark RPC. BookmarksServiceGetBookmarkProcedure = "/bookmarks.BookmarksService/GetBookmark" // BookmarksServiceUpdateBookmarkProcedure is the fully-qualified name of the BookmarksService's // UpdateBookmark RPC. BookmarksServiceUpdateBookmarkProcedure = "/bookmarks.BookmarksService/UpdateBookmark" // BookmarksServiceDeleteBookmarkProcedure is the fully-qualified name of the BookmarksService's // DeleteBookmark RPC. BookmarksServiceDeleteBookmarkProcedure = "/bookmarks.BookmarksService/DeleteBookmark" // BookmarksServiceGetAffectedBookmarksProcedure is the fully-qualified name of the // BookmarksService's GetAffectedBookmarks RPC. BookmarksServiceGetAffectedBookmarksProcedure = "/bookmarks.BookmarksService/GetAffectedBookmarks" ) // BookmarksServiceClient is a client for the bookmarks.BookmarksService service. type BookmarksServiceClient interface { // ListBookmarks returns all bookmarks of the current user. note that this does not include the actual bookmark data, use GetBookmark for that ListBookmarks(context.Context, *connect.Request[sdp_go.ListBookmarksRequest]) (*connect.Response[sdp_go.ListBookmarkResponse], error) // CreateBookmark creates a new bookmark CreateBookmark(context.Context, *connect.Request[sdp_go.CreateBookmarkRequest]) (*connect.Response[sdp_go.CreateBookmarkResponse], error) // GetBookmark returns the bookmark with the given UUID. This can also return snapshots as bookmarks and will strip the stored items from the response. GetBookmark(context.Context, *connect.Request[sdp_go.GetBookmarkRequest]) (*connect.Response[sdp_go.GetBookmarkResponse], error) UpdateBookmark(context.Context, *connect.Request[sdp_go.UpdateBookmarkRequest]) (*connect.Response[sdp_go.UpdateBookmarkResponse], error) DeleteBookmark(context.Context, *connect.Request[sdp_go.DeleteBookmarkRequest]) (*connect.Response[sdp_go.DeleteBookmarkResponse], error) // a helper method to find all affected apps for a given blast radius snapshot GetAffectedBookmarks(context.Context, *connect.Request[sdp_go.GetAffectedBookmarksRequest]) (*connect.Response[sdp_go.GetAffectedBookmarksResponse], error) } // NewBookmarksServiceClient constructs a client for the bookmarks.BookmarksService service. By // default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, // and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the // connect.WithGRPC() or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewBookmarksServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) BookmarksServiceClient { baseURL = strings.TrimRight(baseURL, "/") bookmarksServiceMethods := sdp_go.File_bookmarks_proto.Services().ByName("BookmarksService").Methods() return &bookmarksServiceClient{ listBookmarks: connect.NewClient[sdp_go.ListBookmarksRequest, sdp_go.ListBookmarkResponse]( httpClient, baseURL+BookmarksServiceListBookmarksProcedure, connect.WithSchema(bookmarksServiceMethods.ByName("ListBookmarks")), connect.WithClientOptions(opts...), ), createBookmark: connect.NewClient[sdp_go.CreateBookmarkRequest, sdp_go.CreateBookmarkResponse]( httpClient, baseURL+BookmarksServiceCreateBookmarkProcedure, connect.WithSchema(bookmarksServiceMethods.ByName("CreateBookmark")), connect.WithClientOptions(opts...), ), getBookmark: connect.NewClient[sdp_go.GetBookmarkRequest, sdp_go.GetBookmarkResponse]( httpClient, baseURL+BookmarksServiceGetBookmarkProcedure, connect.WithSchema(bookmarksServiceMethods.ByName("GetBookmark")), connect.WithClientOptions(opts...), ), updateBookmark: connect.NewClient[sdp_go.UpdateBookmarkRequest, sdp_go.UpdateBookmarkResponse]( httpClient, baseURL+BookmarksServiceUpdateBookmarkProcedure, connect.WithSchema(bookmarksServiceMethods.ByName("UpdateBookmark")), connect.WithClientOptions(opts...), ), deleteBookmark: connect.NewClient[sdp_go.DeleteBookmarkRequest, sdp_go.DeleteBookmarkResponse]( httpClient, baseURL+BookmarksServiceDeleteBookmarkProcedure, connect.WithSchema(bookmarksServiceMethods.ByName("DeleteBookmark")), connect.WithClientOptions(opts...), ), getAffectedBookmarks: connect.NewClient[sdp_go.GetAffectedBookmarksRequest, sdp_go.GetAffectedBookmarksResponse]( httpClient, baseURL+BookmarksServiceGetAffectedBookmarksProcedure, connect.WithSchema(bookmarksServiceMethods.ByName("GetAffectedBookmarks")), connect.WithClientOptions(opts...), ), } } // bookmarksServiceClient implements BookmarksServiceClient. type bookmarksServiceClient struct { listBookmarks *connect.Client[sdp_go.ListBookmarksRequest, sdp_go.ListBookmarkResponse] createBookmark *connect.Client[sdp_go.CreateBookmarkRequest, sdp_go.CreateBookmarkResponse] getBookmark *connect.Client[sdp_go.GetBookmarkRequest, sdp_go.GetBookmarkResponse] updateBookmark *connect.Client[sdp_go.UpdateBookmarkRequest, sdp_go.UpdateBookmarkResponse] deleteBookmark *connect.Client[sdp_go.DeleteBookmarkRequest, sdp_go.DeleteBookmarkResponse] getAffectedBookmarks *connect.Client[sdp_go.GetAffectedBookmarksRequest, sdp_go.GetAffectedBookmarksResponse] } // ListBookmarks calls bookmarks.BookmarksService.ListBookmarks. func (c *bookmarksServiceClient) ListBookmarks(ctx context.Context, req *connect.Request[sdp_go.ListBookmarksRequest]) (*connect.Response[sdp_go.ListBookmarkResponse], error) { return c.listBookmarks.CallUnary(ctx, req) } // CreateBookmark calls bookmarks.BookmarksService.CreateBookmark. func (c *bookmarksServiceClient) CreateBookmark(ctx context.Context, req *connect.Request[sdp_go.CreateBookmarkRequest]) (*connect.Response[sdp_go.CreateBookmarkResponse], error) { return c.createBookmark.CallUnary(ctx, req) } // GetBookmark calls bookmarks.BookmarksService.GetBookmark. func (c *bookmarksServiceClient) GetBookmark(ctx context.Context, req *connect.Request[sdp_go.GetBookmarkRequest]) (*connect.Response[sdp_go.GetBookmarkResponse], error) { return c.getBookmark.CallUnary(ctx, req) } // UpdateBookmark calls bookmarks.BookmarksService.UpdateBookmark. func (c *bookmarksServiceClient) UpdateBookmark(ctx context.Context, req *connect.Request[sdp_go.UpdateBookmarkRequest]) (*connect.Response[sdp_go.UpdateBookmarkResponse], error) { return c.updateBookmark.CallUnary(ctx, req) } // DeleteBookmark calls bookmarks.BookmarksService.DeleteBookmark. func (c *bookmarksServiceClient) DeleteBookmark(ctx context.Context, req *connect.Request[sdp_go.DeleteBookmarkRequest]) (*connect.Response[sdp_go.DeleteBookmarkResponse], error) { return c.deleteBookmark.CallUnary(ctx, req) } // GetAffectedBookmarks calls bookmarks.BookmarksService.GetAffectedBookmarks. func (c *bookmarksServiceClient) GetAffectedBookmarks(ctx context.Context, req *connect.Request[sdp_go.GetAffectedBookmarksRequest]) (*connect.Response[sdp_go.GetAffectedBookmarksResponse], error) { return c.getAffectedBookmarks.CallUnary(ctx, req) } // BookmarksServiceHandler is an implementation of the bookmarks.BookmarksService service. type BookmarksServiceHandler interface { // ListBookmarks returns all bookmarks of the current user. note that this does not include the actual bookmark data, use GetBookmark for that ListBookmarks(context.Context, *connect.Request[sdp_go.ListBookmarksRequest]) (*connect.Response[sdp_go.ListBookmarkResponse], error) // CreateBookmark creates a new bookmark CreateBookmark(context.Context, *connect.Request[sdp_go.CreateBookmarkRequest]) (*connect.Response[sdp_go.CreateBookmarkResponse], error) // GetBookmark returns the bookmark with the given UUID. This can also return snapshots as bookmarks and will strip the stored items from the response. GetBookmark(context.Context, *connect.Request[sdp_go.GetBookmarkRequest]) (*connect.Response[sdp_go.GetBookmarkResponse], error) UpdateBookmark(context.Context, *connect.Request[sdp_go.UpdateBookmarkRequest]) (*connect.Response[sdp_go.UpdateBookmarkResponse], error) DeleteBookmark(context.Context, *connect.Request[sdp_go.DeleteBookmarkRequest]) (*connect.Response[sdp_go.DeleteBookmarkResponse], error) // a helper method to find all affected apps for a given blast radius snapshot GetAffectedBookmarks(context.Context, *connect.Request[sdp_go.GetAffectedBookmarksRequest]) (*connect.Response[sdp_go.GetAffectedBookmarksResponse], error) } // NewBookmarksServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewBookmarksServiceHandler(svc BookmarksServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { bookmarksServiceMethods := sdp_go.File_bookmarks_proto.Services().ByName("BookmarksService").Methods() bookmarksServiceListBookmarksHandler := connect.NewUnaryHandler( BookmarksServiceListBookmarksProcedure, svc.ListBookmarks, connect.WithSchema(bookmarksServiceMethods.ByName("ListBookmarks")), connect.WithHandlerOptions(opts...), ) bookmarksServiceCreateBookmarkHandler := connect.NewUnaryHandler( BookmarksServiceCreateBookmarkProcedure, svc.CreateBookmark, connect.WithSchema(bookmarksServiceMethods.ByName("CreateBookmark")), connect.WithHandlerOptions(opts...), ) bookmarksServiceGetBookmarkHandler := connect.NewUnaryHandler( BookmarksServiceGetBookmarkProcedure, svc.GetBookmark, connect.WithSchema(bookmarksServiceMethods.ByName("GetBookmark")), connect.WithHandlerOptions(opts...), ) bookmarksServiceUpdateBookmarkHandler := connect.NewUnaryHandler( BookmarksServiceUpdateBookmarkProcedure, svc.UpdateBookmark, connect.WithSchema(bookmarksServiceMethods.ByName("UpdateBookmark")), connect.WithHandlerOptions(opts...), ) bookmarksServiceDeleteBookmarkHandler := connect.NewUnaryHandler( BookmarksServiceDeleteBookmarkProcedure, svc.DeleteBookmark, connect.WithSchema(bookmarksServiceMethods.ByName("DeleteBookmark")), connect.WithHandlerOptions(opts...), ) bookmarksServiceGetAffectedBookmarksHandler := connect.NewUnaryHandler( BookmarksServiceGetAffectedBookmarksProcedure, svc.GetAffectedBookmarks, connect.WithSchema(bookmarksServiceMethods.ByName("GetAffectedBookmarks")), connect.WithHandlerOptions(opts...), ) return "/bookmarks.BookmarksService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case BookmarksServiceListBookmarksProcedure: bookmarksServiceListBookmarksHandler.ServeHTTP(w, r) case BookmarksServiceCreateBookmarkProcedure: bookmarksServiceCreateBookmarkHandler.ServeHTTP(w, r) case BookmarksServiceGetBookmarkProcedure: bookmarksServiceGetBookmarkHandler.ServeHTTP(w, r) case BookmarksServiceUpdateBookmarkProcedure: bookmarksServiceUpdateBookmarkHandler.ServeHTTP(w, r) case BookmarksServiceDeleteBookmarkProcedure: bookmarksServiceDeleteBookmarkHandler.ServeHTTP(w, r) case BookmarksServiceGetAffectedBookmarksProcedure: bookmarksServiceGetAffectedBookmarksHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedBookmarksServiceHandler returns CodeUnimplemented from all methods. type UnimplementedBookmarksServiceHandler struct{} func (UnimplementedBookmarksServiceHandler) ListBookmarks(context.Context, *connect.Request[sdp_go.ListBookmarksRequest]) (*connect.Response[sdp_go.ListBookmarkResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("bookmarks.BookmarksService.ListBookmarks is not implemented")) } func (UnimplementedBookmarksServiceHandler) CreateBookmark(context.Context, *connect.Request[sdp_go.CreateBookmarkRequest]) (*connect.Response[sdp_go.CreateBookmarkResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("bookmarks.BookmarksService.CreateBookmark is not implemented")) } func (UnimplementedBookmarksServiceHandler) GetBookmark(context.Context, *connect.Request[sdp_go.GetBookmarkRequest]) (*connect.Response[sdp_go.GetBookmarkResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("bookmarks.BookmarksService.GetBookmark is not implemented")) } func (UnimplementedBookmarksServiceHandler) UpdateBookmark(context.Context, *connect.Request[sdp_go.UpdateBookmarkRequest]) (*connect.Response[sdp_go.UpdateBookmarkResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("bookmarks.BookmarksService.UpdateBookmark is not implemented")) } func (UnimplementedBookmarksServiceHandler) DeleteBookmark(context.Context, *connect.Request[sdp_go.DeleteBookmarkRequest]) (*connect.Response[sdp_go.DeleteBookmarkResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("bookmarks.BookmarksService.DeleteBookmark is not implemented")) } func (UnimplementedBookmarksServiceHandler) GetAffectedBookmarks(context.Context, *connect.Request[sdp_go.GetAffectedBookmarksRequest]) (*connect.Response[sdp_go.GetAffectedBookmarksResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("bookmarks.BookmarksService.GetAffectedBookmarks is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/changes.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: changes.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // ChangesServiceName is the fully-qualified name of the ChangesService service. ChangesServiceName = "changes.ChangesService" // LabelServiceName is the fully-qualified name of the LabelService service. LabelServiceName = "changes.LabelService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // ChangesServiceListChangesProcedure is the fully-qualified name of the ChangesService's // ListChanges RPC. ChangesServiceListChangesProcedure = "/changes.ChangesService/ListChanges" // ChangesServiceListChangesByStatusProcedure is the fully-qualified name of the ChangesService's // ListChangesByStatus RPC. ChangesServiceListChangesByStatusProcedure = "/changes.ChangesService/ListChangesByStatus" // ChangesServiceCreateChangeProcedure is the fully-qualified name of the ChangesService's // CreateChange RPC. ChangesServiceCreateChangeProcedure = "/changes.ChangesService/CreateChange" // ChangesServiceGetChangeProcedure is the fully-qualified name of the ChangesService's GetChange // RPC. ChangesServiceGetChangeProcedure = "/changes.ChangesService/GetChange" // ChangesServiceGetChangeByTicketLinkProcedure is the fully-qualified name of the ChangesService's // GetChangeByTicketLink RPC. ChangesServiceGetChangeByTicketLinkProcedure = "/changes.ChangesService/GetChangeByTicketLink" // ChangesServiceGetChangeSummaryProcedure is the fully-qualified name of the ChangesService's // GetChangeSummary RPC. ChangesServiceGetChangeSummaryProcedure = "/changes.ChangesService/GetChangeSummary" // ChangesServiceGetChangeTimelineV2Procedure is the fully-qualified name of the ChangesService's // GetChangeTimelineV2 RPC. ChangesServiceGetChangeTimelineV2Procedure = "/changes.ChangesService/GetChangeTimelineV2" // ChangesServiceGetChangeRisksProcedure is the fully-qualified name of the ChangesService's // GetChangeRisks RPC. ChangesServiceGetChangeRisksProcedure = "/changes.ChangesService/GetChangeRisks" // ChangesServiceUpdateChangeProcedure is the fully-qualified name of the ChangesService's // UpdateChange RPC. ChangesServiceUpdateChangeProcedure = "/changes.ChangesService/UpdateChange" // ChangesServiceDeleteChangeProcedure is the fully-qualified name of the ChangesService's // DeleteChange RPC. ChangesServiceDeleteChangeProcedure = "/changes.ChangesService/DeleteChange" // ChangesServiceListChangesBySnapshotUUIDProcedure is the fully-qualified name of the // ChangesService's ListChangesBySnapshotUUID RPC. ChangesServiceListChangesBySnapshotUUIDProcedure = "/changes.ChangesService/ListChangesBySnapshotUUID" // ChangesServiceRefreshStateProcedure is the fully-qualified name of the ChangesService's // RefreshState RPC. ChangesServiceRefreshStateProcedure = "/changes.ChangesService/RefreshState" // ChangesServiceStartChangeProcedure is the fully-qualified name of the ChangesService's // StartChange RPC. ChangesServiceStartChangeProcedure = "/changes.ChangesService/StartChange" // ChangesServiceEndChangeProcedure is the fully-qualified name of the ChangesService's EndChange // RPC. ChangesServiceEndChangeProcedure = "/changes.ChangesService/EndChange" // ChangesServiceStartChangeSimpleProcedure is the fully-qualified name of the ChangesService's // StartChangeSimple RPC. ChangesServiceStartChangeSimpleProcedure = "/changes.ChangesService/StartChangeSimple" // ChangesServiceEndChangeSimpleProcedure is the fully-qualified name of the ChangesService's // EndChangeSimple RPC. ChangesServiceEndChangeSimpleProcedure = "/changes.ChangesService/EndChangeSimple" // ChangesServiceListHomeChangesProcedure is the fully-qualified name of the ChangesService's // ListHomeChanges RPC. ChangesServiceListHomeChangesProcedure = "/changes.ChangesService/ListHomeChanges" // ChangesServiceStartChangeAnalysisProcedure is the fully-qualified name of the ChangesService's // StartChangeAnalysis RPC. ChangesServiceStartChangeAnalysisProcedure = "/changes.ChangesService/StartChangeAnalysis" // ChangesServiceListChangingItemsSummaryProcedure is the fully-qualified name of the // ChangesService's ListChangingItemsSummary RPC. ChangesServiceListChangingItemsSummaryProcedure = "/changes.ChangesService/ListChangingItemsSummary" // ChangesServiceGetDiffProcedure is the fully-qualified name of the ChangesService's GetDiff RPC. ChangesServiceGetDiffProcedure = "/changes.ChangesService/GetDiff" // ChangesServicePopulateChangeFiltersProcedure is the fully-qualified name of the ChangesService's // PopulateChangeFilters RPC. ChangesServicePopulateChangeFiltersProcedure = "/changes.ChangesService/PopulateChangeFilters" // ChangesServiceGenerateRiskFixProcedure is the fully-qualified name of the ChangesService's // GenerateRiskFix RPC. ChangesServiceGenerateRiskFixProcedure = "/changes.ChangesService/GenerateRiskFix" // ChangesServiceSubmitRiskFeedbackProcedure is the fully-qualified name of the ChangesService's // SubmitRiskFeedback RPC. ChangesServiceSubmitRiskFeedbackProcedure = "/changes.ChangesService/SubmitRiskFeedback" // ChangesServiceGetHypothesesDetailsProcedure is the fully-qualified name of the ChangesService's // GetHypothesesDetails RPC. ChangesServiceGetHypothesesDetailsProcedure = "/changes.ChangesService/GetHypothesesDetails" // ChangesServiceGetChangeSignalsProcedure is the fully-qualified name of the ChangesService's // GetChangeSignals RPC. ChangesServiceGetChangeSignalsProcedure = "/changes.ChangesService/GetChangeSignals" // ChangesServiceAddPlannedChangesProcedure is the fully-qualified name of the ChangesService's // AddPlannedChanges RPC. ChangesServiceAddPlannedChangesProcedure = "/changes.ChangesService/AddPlannedChanges" // LabelServiceListLabelRulesProcedure is the fully-qualified name of the LabelService's // ListLabelRules RPC. LabelServiceListLabelRulesProcedure = "/changes.LabelService/ListLabelRules" // LabelServiceCreateLabelRuleProcedure is the fully-qualified name of the LabelService's // CreateLabelRule RPC. LabelServiceCreateLabelRuleProcedure = "/changes.LabelService/CreateLabelRule" // LabelServiceGetLabelRuleProcedure is the fully-qualified name of the LabelService's GetLabelRule // RPC. LabelServiceGetLabelRuleProcedure = "/changes.LabelService/GetLabelRule" // LabelServiceUpdateLabelRuleProcedure is the fully-qualified name of the LabelService's // UpdateLabelRule RPC. LabelServiceUpdateLabelRuleProcedure = "/changes.LabelService/UpdateLabelRule" // LabelServiceDeleteLabelRuleProcedure is the fully-qualified name of the LabelService's // DeleteLabelRule RPC. LabelServiceDeleteLabelRuleProcedure = "/changes.LabelService/DeleteLabelRule" // LabelServiceTestLabelRuleProcedure is the fully-qualified name of the LabelService's // TestLabelRule RPC. LabelServiceTestLabelRuleProcedure = "/changes.LabelService/TestLabelRule" // LabelServiceReapplyLabelRuleInTimeRangeProcedure is the fully-qualified name of the // LabelService's ReapplyLabelRuleInTimeRange RPC. LabelServiceReapplyLabelRuleInTimeRangeProcedure = "/changes.LabelService/ReapplyLabelRuleInTimeRange" ) // ChangesServiceClient is a client for the changes.ChangesService service. type ChangesServiceClient interface { // Lists all changes ListChanges(context.Context, *connect.Request[sdp_go.ListChangesRequest]) (*connect.Response[sdp_go.ListChangesResponse], error) // list all changes in a specific status ListChangesByStatus(context.Context, *connect.Request[sdp_go.ListChangesByStatusRequest]) (*connect.Response[sdp_go.ListChangesByStatusResponse], error) // Creates a new change CreateChange(context.Context, *connect.Request[sdp_go.CreateChangeRequest]) (*connect.Response[sdp_go.CreateChangeResponse], error) // Gets the details of an existing change GetChange(context.Context, *connect.Request[sdp_go.GetChangeRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) // Get a change by the ticket link GetChangeByTicketLink(context.Context, *connect.Request[sdp_go.GetChangeByTicketLinkRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) // Gets the details of an existing change in markdown format GetChangeSummary(context.Context, *connect.Request[sdp_go.GetChangeSummaryRequest]) (*connect.Response[sdp_go.GetChangeSummaryResponse], error) // Gets the full timeline for this change, this will send one response // immediately and then hold the connection open, and send the entire // timeline again if there are any changes GetChangeTimelineV2(context.Context, *connect.Request[sdp_go.GetChangeTimelineV2Request]) (*connect.Response[sdp_go.GetChangeTimelineV2Response], error) // This is used on the blast radius page to get the risks and status for a change. GetChangeRisks(context.Context, *connect.Request[sdp_go.GetChangeRisksRequest]) (*connect.Response[sdp_go.GetChangeRisksResponse], error) // Updates an existing change UpdateChange(context.Context, *connect.Request[sdp_go.UpdateChangeRequest]) (*connect.Response[sdp_go.UpdateChangeResponse], error) // Deletes a change DeleteChange(context.Context, *connect.Request[sdp_go.DeleteChangeRequest]) (*connect.Response[sdp_go.DeleteChangeResponse], error) // Lists all changes for a snapshot UUID ListChangesBySnapshotUUID(context.Context, *connect.Request[sdp_go.ListChangesBySnapshotUUIDRequest]) (*connect.Response[sdp_go.ListChangesBySnapshotUUIDResponse], error) // Ask the gateway to refresh all internal caches and status slots // The RPC will return immediately doing all processing in the background RefreshState(context.Context, *connect.Request[sdp_go.RefreshStateRequest]) (*connect.Response[sdp_go.RefreshStateResponse], error) // Executing this RPC take a snapshot of the current blast radius and store it // in `systemBeforeSnapshotUUID` and then advance the status to // `STATUS_HAPPENING`. It can only be called once per change. StartChange(context.Context, *connect.Request[sdp_go.StartChangeRequest]) (*connect.ServerStreamForClient[sdp_go.StartChangeResponse], error) // Takes the "after" snapshot, stores it in `systemAfterSnapshotUUID`, calculates // the change diff and stores it as a list of DiffedItems and // advances the change status to `STATUS_DONE` EndChange(context.Context, *connect.Request[sdp_go.EndChangeRequest]) (*connect.ServerStreamForClient[sdp_go.EndChangeResponse], error) // Simple version of StartChange that returns immediately after enqueuing the job. // Use this instead of StartChange for non-streaming clients. StartChangeSimple(context.Context, *connect.Request[sdp_go.StartChangeRequest]) (*connect.Response[sdp_go.StartChangeSimpleResponse], error) // Simple version of EndChange that returns immediately after enqueuing the job. // Use this instead of EndChange for non-streaming clients. EndChangeSimple(context.Context, *connect.Request[sdp_go.EndChangeRequest]) (*connect.Response[sdp_go.EndChangeSimpleResponse], error) // Lists all changes, designed for use in the changes home page ListHomeChanges(context.Context, *connect.Request[sdp_go.ListHomeChangesRequest]) (*connect.Response[sdp_go.ListHomeChangesResponse], error) // Start the change analysis process. This will calculate various things // blast radius, risks etc. This will return immediately and // the results can be fetched using the other RPCs StartChangeAnalysis(context.Context, *connect.Request[sdp_go.StartChangeAnalysisRequest]) (*connect.Response[sdp_go.StartChangeAnalysisResponse], error) // Gets the diff summary for all items that were planned to change as part of // this change. This includes the high level details of the item, and the // status (e.g. changed, deleted) but not the diff itself ListChangingItemsSummary(context.Context, *connect.Request[sdp_go.ListChangingItemsSummaryRequest]) (*connect.Response[sdp_go.ListChangingItemsSummaryResponse], error) // Gets the full diff of everything that changed as part of this "change". // This includes all items and also edges between them GetDiff(context.Context, *connect.Request[sdp_go.GetDiffRequest]) (*connect.Response[sdp_go.GetDiffResponse], error) // List all the available repos, authors and statuses that can be used to populate the dropdown filters PopulateChangeFilters(context.Context, *connect.Request[sdp_go.PopulateChangeFiltersRequest]) (*connect.Response[sdp_go.PopulateChangeFiltersResponse], error) // Generates an AI-powered fix suggestion for a specific risk GenerateRiskFix(context.Context, *connect.Request[sdp_go.GenerateRiskFixRequest]) (*connect.Response[sdp_go.GenerateRiskFixResponse], error) // Submit user feedback on a risk SubmitRiskFeedback(context.Context, *connect.Request[sdp_go.SubmitRiskFeedbackRequest]) (*connect.Response[sdp_go.SubmitRiskFeedbackResponse], error) // The full details of all of the hypotheses that were considered or are being // considered as part of this change. GetHypothesesDetails(context.Context, *connect.Request[sdp_go.GetHypothesesDetailsRequest]) (*connect.Response[sdp_go.GetHypothesesDetailsResponse], error) // Gets all signals for a change, including: // - Overall signal for the change // - Top level signals for each category // - Routineness signals per item // - Individual custom signals // This is similar to GetChangeSummary but focused on signals data GetChangeSignals(context.Context, *connect.Request[sdp_go.GetChangeSignalsRequest]) (*connect.Response[sdp_go.GetChangeSignalsResponse], error) // Appends planned changes to an existing change without starting analysis. // The change must be in CHANGE_STATUS_DEFINING. Each call inserts a new batch // of items; call StartChangeAnalysis (with empty changingItems) to trigger // analysis on all accumulated items. AddPlannedChanges(context.Context, *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) } // NewChangesServiceClient constructs a client for the changes.ChangesService service. By default, // it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and // sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() // or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewChangesServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ChangesServiceClient { baseURL = strings.TrimRight(baseURL, "/") changesServiceMethods := sdp_go.File_changes_proto.Services().ByName("ChangesService").Methods() return &changesServiceClient{ listChanges: connect.NewClient[sdp_go.ListChangesRequest, sdp_go.ListChangesResponse]( httpClient, baseURL+ChangesServiceListChangesProcedure, connect.WithSchema(changesServiceMethods.ByName("ListChanges")), connect.WithClientOptions(opts...), ), listChangesByStatus: connect.NewClient[sdp_go.ListChangesByStatusRequest, sdp_go.ListChangesByStatusResponse]( httpClient, baseURL+ChangesServiceListChangesByStatusProcedure, connect.WithSchema(changesServiceMethods.ByName("ListChangesByStatus")), connect.WithClientOptions(opts...), ), createChange: connect.NewClient[sdp_go.CreateChangeRequest, sdp_go.CreateChangeResponse]( httpClient, baseURL+ChangesServiceCreateChangeProcedure, connect.WithSchema(changesServiceMethods.ByName("CreateChange")), connect.WithClientOptions(opts...), ), getChange: connect.NewClient[sdp_go.GetChangeRequest, sdp_go.GetChangeResponse]( httpClient, baseURL+ChangesServiceGetChangeProcedure, connect.WithSchema(changesServiceMethods.ByName("GetChange")), connect.WithClientOptions(opts...), ), getChangeByTicketLink: connect.NewClient[sdp_go.GetChangeByTicketLinkRequest, sdp_go.GetChangeResponse]( httpClient, baseURL+ChangesServiceGetChangeByTicketLinkProcedure, connect.WithSchema(changesServiceMethods.ByName("GetChangeByTicketLink")), connect.WithClientOptions(opts...), ), getChangeSummary: connect.NewClient[sdp_go.GetChangeSummaryRequest, sdp_go.GetChangeSummaryResponse]( httpClient, baseURL+ChangesServiceGetChangeSummaryProcedure, connect.WithSchema(changesServiceMethods.ByName("GetChangeSummary")), connect.WithClientOptions(opts...), ), getChangeTimelineV2: connect.NewClient[sdp_go.GetChangeTimelineV2Request, sdp_go.GetChangeTimelineV2Response]( httpClient, baseURL+ChangesServiceGetChangeTimelineV2Procedure, connect.WithSchema(changesServiceMethods.ByName("GetChangeTimelineV2")), connect.WithClientOptions(opts...), ), getChangeRisks: connect.NewClient[sdp_go.GetChangeRisksRequest, sdp_go.GetChangeRisksResponse]( httpClient, baseURL+ChangesServiceGetChangeRisksProcedure, connect.WithSchema(changesServiceMethods.ByName("GetChangeRisks")), connect.WithClientOptions(opts...), ), updateChange: connect.NewClient[sdp_go.UpdateChangeRequest, sdp_go.UpdateChangeResponse]( httpClient, baseURL+ChangesServiceUpdateChangeProcedure, connect.WithSchema(changesServiceMethods.ByName("UpdateChange")), connect.WithClientOptions(opts...), ), deleteChange: connect.NewClient[sdp_go.DeleteChangeRequest, sdp_go.DeleteChangeResponse]( httpClient, baseURL+ChangesServiceDeleteChangeProcedure, connect.WithSchema(changesServiceMethods.ByName("DeleteChange")), connect.WithClientOptions(opts...), ), listChangesBySnapshotUUID: connect.NewClient[sdp_go.ListChangesBySnapshotUUIDRequest, sdp_go.ListChangesBySnapshotUUIDResponse]( httpClient, baseURL+ChangesServiceListChangesBySnapshotUUIDProcedure, connect.WithSchema(changesServiceMethods.ByName("ListChangesBySnapshotUUID")), connect.WithClientOptions(opts...), ), refreshState: connect.NewClient[sdp_go.RefreshStateRequest, sdp_go.RefreshStateResponse]( httpClient, baseURL+ChangesServiceRefreshStateProcedure, connect.WithSchema(changesServiceMethods.ByName("RefreshState")), connect.WithClientOptions(opts...), ), startChange: connect.NewClient[sdp_go.StartChangeRequest, sdp_go.StartChangeResponse]( httpClient, baseURL+ChangesServiceStartChangeProcedure, connect.WithSchema(changesServiceMethods.ByName("StartChange")), connect.WithClientOptions(opts...), ), endChange: connect.NewClient[sdp_go.EndChangeRequest, sdp_go.EndChangeResponse]( httpClient, baseURL+ChangesServiceEndChangeProcedure, connect.WithSchema(changesServiceMethods.ByName("EndChange")), connect.WithClientOptions(opts...), ), startChangeSimple: connect.NewClient[sdp_go.StartChangeRequest, sdp_go.StartChangeSimpleResponse]( httpClient, baseURL+ChangesServiceStartChangeSimpleProcedure, connect.WithSchema(changesServiceMethods.ByName("StartChangeSimple")), connect.WithClientOptions(opts...), ), endChangeSimple: connect.NewClient[sdp_go.EndChangeRequest, sdp_go.EndChangeSimpleResponse]( httpClient, baseURL+ChangesServiceEndChangeSimpleProcedure, connect.WithSchema(changesServiceMethods.ByName("EndChangeSimple")), connect.WithClientOptions(opts...), ), listHomeChanges: connect.NewClient[sdp_go.ListHomeChangesRequest, sdp_go.ListHomeChangesResponse]( httpClient, baseURL+ChangesServiceListHomeChangesProcedure, connect.WithSchema(changesServiceMethods.ByName("ListHomeChanges")), connect.WithClientOptions(opts...), ), startChangeAnalysis: connect.NewClient[sdp_go.StartChangeAnalysisRequest, sdp_go.StartChangeAnalysisResponse]( httpClient, baseURL+ChangesServiceStartChangeAnalysisProcedure, connect.WithSchema(changesServiceMethods.ByName("StartChangeAnalysis")), connect.WithClientOptions(opts...), ), listChangingItemsSummary: connect.NewClient[sdp_go.ListChangingItemsSummaryRequest, sdp_go.ListChangingItemsSummaryResponse]( httpClient, baseURL+ChangesServiceListChangingItemsSummaryProcedure, connect.WithSchema(changesServiceMethods.ByName("ListChangingItemsSummary")), connect.WithClientOptions(opts...), ), getDiff: connect.NewClient[sdp_go.GetDiffRequest, sdp_go.GetDiffResponse]( httpClient, baseURL+ChangesServiceGetDiffProcedure, connect.WithSchema(changesServiceMethods.ByName("GetDiff")), connect.WithClientOptions(opts...), ), populateChangeFilters: connect.NewClient[sdp_go.PopulateChangeFiltersRequest, sdp_go.PopulateChangeFiltersResponse]( httpClient, baseURL+ChangesServicePopulateChangeFiltersProcedure, connect.WithSchema(changesServiceMethods.ByName("PopulateChangeFilters")), connect.WithClientOptions(opts...), ), generateRiskFix: connect.NewClient[sdp_go.GenerateRiskFixRequest, sdp_go.GenerateRiskFixResponse]( httpClient, baseURL+ChangesServiceGenerateRiskFixProcedure, connect.WithSchema(changesServiceMethods.ByName("GenerateRiskFix")), connect.WithClientOptions(opts...), ), submitRiskFeedback: connect.NewClient[sdp_go.SubmitRiskFeedbackRequest, sdp_go.SubmitRiskFeedbackResponse]( httpClient, baseURL+ChangesServiceSubmitRiskFeedbackProcedure, connect.WithSchema(changesServiceMethods.ByName("SubmitRiskFeedback")), connect.WithClientOptions(opts...), ), getHypothesesDetails: connect.NewClient[sdp_go.GetHypothesesDetailsRequest, sdp_go.GetHypothesesDetailsResponse]( httpClient, baseURL+ChangesServiceGetHypothesesDetailsProcedure, connect.WithSchema(changesServiceMethods.ByName("GetHypothesesDetails")), connect.WithClientOptions(opts...), ), getChangeSignals: connect.NewClient[sdp_go.GetChangeSignalsRequest, sdp_go.GetChangeSignalsResponse]( httpClient, baseURL+ChangesServiceGetChangeSignalsProcedure, connect.WithSchema(changesServiceMethods.ByName("GetChangeSignals")), connect.WithClientOptions(opts...), ), addPlannedChanges: connect.NewClient[sdp_go.AddPlannedChangesRequest, sdp_go.AddPlannedChangesResponse]( httpClient, baseURL+ChangesServiceAddPlannedChangesProcedure, connect.WithSchema(changesServiceMethods.ByName("AddPlannedChanges")), connect.WithClientOptions(opts...), ), } } // changesServiceClient implements ChangesServiceClient. type changesServiceClient struct { listChanges *connect.Client[sdp_go.ListChangesRequest, sdp_go.ListChangesResponse] listChangesByStatus *connect.Client[sdp_go.ListChangesByStatusRequest, sdp_go.ListChangesByStatusResponse] createChange *connect.Client[sdp_go.CreateChangeRequest, sdp_go.CreateChangeResponse] getChange *connect.Client[sdp_go.GetChangeRequest, sdp_go.GetChangeResponse] getChangeByTicketLink *connect.Client[sdp_go.GetChangeByTicketLinkRequest, sdp_go.GetChangeResponse] getChangeSummary *connect.Client[sdp_go.GetChangeSummaryRequest, sdp_go.GetChangeSummaryResponse] getChangeTimelineV2 *connect.Client[sdp_go.GetChangeTimelineV2Request, sdp_go.GetChangeTimelineV2Response] getChangeRisks *connect.Client[sdp_go.GetChangeRisksRequest, sdp_go.GetChangeRisksResponse] updateChange *connect.Client[sdp_go.UpdateChangeRequest, sdp_go.UpdateChangeResponse] deleteChange *connect.Client[sdp_go.DeleteChangeRequest, sdp_go.DeleteChangeResponse] listChangesBySnapshotUUID *connect.Client[sdp_go.ListChangesBySnapshotUUIDRequest, sdp_go.ListChangesBySnapshotUUIDResponse] refreshState *connect.Client[sdp_go.RefreshStateRequest, sdp_go.RefreshStateResponse] startChange *connect.Client[sdp_go.StartChangeRequest, sdp_go.StartChangeResponse] endChange *connect.Client[sdp_go.EndChangeRequest, sdp_go.EndChangeResponse] startChangeSimple *connect.Client[sdp_go.StartChangeRequest, sdp_go.StartChangeSimpleResponse] endChangeSimple *connect.Client[sdp_go.EndChangeRequest, sdp_go.EndChangeSimpleResponse] listHomeChanges *connect.Client[sdp_go.ListHomeChangesRequest, sdp_go.ListHomeChangesResponse] startChangeAnalysis *connect.Client[sdp_go.StartChangeAnalysisRequest, sdp_go.StartChangeAnalysisResponse] listChangingItemsSummary *connect.Client[sdp_go.ListChangingItemsSummaryRequest, sdp_go.ListChangingItemsSummaryResponse] getDiff *connect.Client[sdp_go.GetDiffRequest, sdp_go.GetDiffResponse] populateChangeFilters *connect.Client[sdp_go.PopulateChangeFiltersRequest, sdp_go.PopulateChangeFiltersResponse] generateRiskFix *connect.Client[sdp_go.GenerateRiskFixRequest, sdp_go.GenerateRiskFixResponse] submitRiskFeedback *connect.Client[sdp_go.SubmitRiskFeedbackRequest, sdp_go.SubmitRiskFeedbackResponse] getHypothesesDetails *connect.Client[sdp_go.GetHypothesesDetailsRequest, sdp_go.GetHypothesesDetailsResponse] getChangeSignals *connect.Client[sdp_go.GetChangeSignalsRequest, sdp_go.GetChangeSignalsResponse] addPlannedChanges *connect.Client[sdp_go.AddPlannedChangesRequest, sdp_go.AddPlannedChangesResponse] } // ListChanges calls changes.ChangesService.ListChanges. func (c *changesServiceClient) ListChanges(ctx context.Context, req *connect.Request[sdp_go.ListChangesRequest]) (*connect.Response[sdp_go.ListChangesResponse], error) { return c.listChanges.CallUnary(ctx, req) } // ListChangesByStatus calls changes.ChangesService.ListChangesByStatus. func (c *changesServiceClient) ListChangesByStatus(ctx context.Context, req *connect.Request[sdp_go.ListChangesByStatusRequest]) (*connect.Response[sdp_go.ListChangesByStatusResponse], error) { return c.listChangesByStatus.CallUnary(ctx, req) } // CreateChange calls changes.ChangesService.CreateChange. func (c *changesServiceClient) CreateChange(ctx context.Context, req *connect.Request[sdp_go.CreateChangeRequest]) (*connect.Response[sdp_go.CreateChangeResponse], error) { return c.createChange.CallUnary(ctx, req) } // GetChange calls changes.ChangesService.GetChange. func (c *changesServiceClient) GetChange(ctx context.Context, req *connect.Request[sdp_go.GetChangeRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) { return c.getChange.CallUnary(ctx, req) } // GetChangeByTicketLink calls changes.ChangesService.GetChangeByTicketLink. func (c *changesServiceClient) GetChangeByTicketLink(ctx context.Context, req *connect.Request[sdp_go.GetChangeByTicketLinkRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) { return c.getChangeByTicketLink.CallUnary(ctx, req) } // GetChangeSummary calls changes.ChangesService.GetChangeSummary. func (c *changesServiceClient) GetChangeSummary(ctx context.Context, req *connect.Request[sdp_go.GetChangeSummaryRequest]) (*connect.Response[sdp_go.GetChangeSummaryResponse], error) { return c.getChangeSummary.CallUnary(ctx, req) } // GetChangeTimelineV2 calls changes.ChangesService.GetChangeTimelineV2. func (c *changesServiceClient) GetChangeTimelineV2(ctx context.Context, req *connect.Request[sdp_go.GetChangeTimelineV2Request]) (*connect.Response[sdp_go.GetChangeTimelineV2Response], error) { return c.getChangeTimelineV2.CallUnary(ctx, req) } // GetChangeRisks calls changes.ChangesService.GetChangeRisks. func (c *changesServiceClient) GetChangeRisks(ctx context.Context, req *connect.Request[sdp_go.GetChangeRisksRequest]) (*connect.Response[sdp_go.GetChangeRisksResponse], error) { return c.getChangeRisks.CallUnary(ctx, req) } // UpdateChange calls changes.ChangesService.UpdateChange. func (c *changesServiceClient) UpdateChange(ctx context.Context, req *connect.Request[sdp_go.UpdateChangeRequest]) (*connect.Response[sdp_go.UpdateChangeResponse], error) { return c.updateChange.CallUnary(ctx, req) } // DeleteChange calls changes.ChangesService.DeleteChange. func (c *changesServiceClient) DeleteChange(ctx context.Context, req *connect.Request[sdp_go.DeleteChangeRequest]) (*connect.Response[sdp_go.DeleteChangeResponse], error) { return c.deleteChange.CallUnary(ctx, req) } // ListChangesBySnapshotUUID calls changes.ChangesService.ListChangesBySnapshotUUID. func (c *changesServiceClient) ListChangesBySnapshotUUID(ctx context.Context, req *connect.Request[sdp_go.ListChangesBySnapshotUUIDRequest]) (*connect.Response[sdp_go.ListChangesBySnapshotUUIDResponse], error) { return c.listChangesBySnapshotUUID.CallUnary(ctx, req) } // RefreshState calls changes.ChangesService.RefreshState. func (c *changesServiceClient) RefreshState(ctx context.Context, req *connect.Request[sdp_go.RefreshStateRequest]) (*connect.Response[sdp_go.RefreshStateResponse], error) { return c.refreshState.CallUnary(ctx, req) } // StartChange calls changes.ChangesService.StartChange. func (c *changesServiceClient) StartChange(ctx context.Context, req *connect.Request[sdp_go.StartChangeRequest]) (*connect.ServerStreamForClient[sdp_go.StartChangeResponse], error) { return c.startChange.CallServerStream(ctx, req) } // EndChange calls changes.ChangesService.EndChange. func (c *changesServiceClient) EndChange(ctx context.Context, req *connect.Request[sdp_go.EndChangeRequest]) (*connect.ServerStreamForClient[sdp_go.EndChangeResponse], error) { return c.endChange.CallServerStream(ctx, req) } // StartChangeSimple calls changes.ChangesService.StartChangeSimple. func (c *changesServiceClient) StartChangeSimple(ctx context.Context, req *connect.Request[sdp_go.StartChangeRequest]) (*connect.Response[sdp_go.StartChangeSimpleResponse], error) { return c.startChangeSimple.CallUnary(ctx, req) } // EndChangeSimple calls changes.ChangesService.EndChangeSimple. func (c *changesServiceClient) EndChangeSimple(ctx context.Context, req *connect.Request[sdp_go.EndChangeRequest]) (*connect.Response[sdp_go.EndChangeSimpleResponse], error) { return c.endChangeSimple.CallUnary(ctx, req) } // ListHomeChanges calls changes.ChangesService.ListHomeChanges. func (c *changesServiceClient) ListHomeChanges(ctx context.Context, req *connect.Request[sdp_go.ListHomeChangesRequest]) (*connect.Response[sdp_go.ListHomeChangesResponse], error) { return c.listHomeChanges.CallUnary(ctx, req) } // StartChangeAnalysis calls changes.ChangesService.StartChangeAnalysis. func (c *changesServiceClient) StartChangeAnalysis(ctx context.Context, req *connect.Request[sdp_go.StartChangeAnalysisRequest]) (*connect.Response[sdp_go.StartChangeAnalysisResponse], error) { return c.startChangeAnalysis.CallUnary(ctx, req) } // ListChangingItemsSummary calls changes.ChangesService.ListChangingItemsSummary. func (c *changesServiceClient) ListChangingItemsSummary(ctx context.Context, req *connect.Request[sdp_go.ListChangingItemsSummaryRequest]) (*connect.Response[sdp_go.ListChangingItemsSummaryResponse], error) { return c.listChangingItemsSummary.CallUnary(ctx, req) } // GetDiff calls changes.ChangesService.GetDiff. func (c *changesServiceClient) GetDiff(ctx context.Context, req *connect.Request[sdp_go.GetDiffRequest]) (*connect.Response[sdp_go.GetDiffResponse], error) { return c.getDiff.CallUnary(ctx, req) } // PopulateChangeFilters calls changes.ChangesService.PopulateChangeFilters. func (c *changesServiceClient) PopulateChangeFilters(ctx context.Context, req *connect.Request[sdp_go.PopulateChangeFiltersRequest]) (*connect.Response[sdp_go.PopulateChangeFiltersResponse], error) { return c.populateChangeFilters.CallUnary(ctx, req) } // GenerateRiskFix calls changes.ChangesService.GenerateRiskFix. func (c *changesServiceClient) GenerateRiskFix(ctx context.Context, req *connect.Request[sdp_go.GenerateRiskFixRequest]) (*connect.Response[sdp_go.GenerateRiskFixResponse], error) { return c.generateRiskFix.CallUnary(ctx, req) } // SubmitRiskFeedback calls changes.ChangesService.SubmitRiskFeedback. func (c *changesServiceClient) SubmitRiskFeedback(ctx context.Context, req *connect.Request[sdp_go.SubmitRiskFeedbackRequest]) (*connect.Response[sdp_go.SubmitRiskFeedbackResponse], error) { return c.submitRiskFeedback.CallUnary(ctx, req) } // GetHypothesesDetails calls changes.ChangesService.GetHypothesesDetails. func (c *changesServiceClient) GetHypothesesDetails(ctx context.Context, req *connect.Request[sdp_go.GetHypothesesDetailsRequest]) (*connect.Response[sdp_go.GetHypothesesDetailsResponse], error) { return c.getHypothesesDetails.CallUnary(ctx, req) } // GetChangeSignals calls changes.ChangesService.GetChangeSignals. func (c *changesServiceClient) GetChangeSignals(ctx context.Context, req *connect.Request[sdp_go.GetChangeSignalsRequest]) (*connect.Response[sdp_go.GetChangeSignalsResponse], error) { return c.getChangeSignals.CallUnary(ctx, req) } // AddPlannedChanges calls changes.ChangesService.AddPlannedChanges. func (c *changesServiceClient) AddPlannedChanges(ctx context.Context, req *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) { return c.addPlannedChanges.CallUnary(ctx, req) } // ChangesServiceHandler is an implementation of the changes.ChangesService service. type ChangesServiceHandler interface { // Lists all changes ListChanges(context.Context, *connect.Request[sdp_go.ListChangesRequest]) (*connect.Response[sdp_go.ListChangesResponse], error) // list all changes in a specific status ListChangesByStatus(context.Context, *connect.Request[sdp_go.ListChangesByStatusRequest]) (*connect.Response[sdp_go.ListChangesByStatusResponse], error) // Creates a new change CreateChange(context.Context, *connect.Request[sdp_go.CreateChangeRequest]) (*connect.Response[sdp_go.CreateChangeResponse], error) // Gets the details of an existing change GetChange(context.Context, *connect.Request[sdp_go.GetChangeRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) // Get a change by the ticket link GetChangeByTicketLink(context.Context, *connect.Request[sdp_go.GetChangeByTicketLinkRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) // Gets the details of an existing change in markdown format GetChangeSummary(context.Context, *connect.Request[sdp_go.GetChangeSummaryRequest]) (*connect.Response[sdp_go.GetChangeSummaryResponse], error) // Gets the full timeline for this change, this will send one response // immediately and then hold the connection open, and send the entire // timeline again if there are any changes GetChangeTimelineV2(context.Context, *connect.Request[sdp_go.GetChangeTimelineV2Request]) (*connect.Response[sdp_go.GetChangeTimelineV2Response], error) // This is used on the blast radius page to get the risks and status for a change. GetChangeRisks(context.Context, *connect.Request[sdp_go.GetChangeRisksRequest]) (*connect.Response[sdp_go.GetChangeRisksResponse], error) // Updates an existing change UpdateChange(context.Context, *connect.Request[sdp_go.UpdateChangeRequest]) (*connect.Response[sdp_go.UpdateChangeResponse], error) // Deletes a change DeleteChange(context.Context, *connect.Request[sdp_go.DeleteChangeRequest]) (*connect.Response[sdp_go.DeleteChangeResponse], error) // Lists all changes for a snapshot UUID ListChangesBySnapshotUUID(context.Context, *connect.Request[sdp_go.ListChangesBySnapshotUUIDRequest]) (*connect.Response[sdp_go.ListChangesBySnapshotUUIDResponse], error) // Ask the gateway to refresh all internal caches and status slots // The RPC will return immediately doing all processing in the background RefreshState(context.Context, *connect.Request[sdp_go.RefreshStateRequest]) (*connect.Response[sdp_go.RefreshStateResponse], error) // Executing this RPC take a snapshot of the current blast radius and store it // in `systemBeforeSnapshotUUID` and then advance the status to // `STATUS_HAPPENING`. It can only be called once per change. StartChange(context.Context, *connect.Request[sdp_go.StartChangeRequest], *connect.ServerStream[sdp_go.StartChangeResponse]) error // Takes the "after" snapshot, stores it in `systemAfterSnapshotUUID`, calculates // the change diff and stores it as a list of DiffedItems and // advances the change status to `STATUS_DONE` EndChange(context.Context, *connect.Request[sdp_go.EndChangeRequest], *connect.ServerStream[sdp_go.EndChangeResponse]) error // Simple version of StartChange that returns immediately after enqueuing the job. // Use this instead of StartChange for non-streaming clients. StartChangeSimple(context.Context, *connect.Request[sdp_go.StartChangeRequest]) (*connect.Response[sdp_go.StartChangeSimpleResponse], error) // Simple version of EndChange that returns immediately after enqueuing the job. // Use this instead of EndChange for non-streaming clients. EndChangeSimple(context.Context, *connect.Request[sdp_go.EndChangeRequest]) (*connect.Response[sdp_go.EndChangeSimpleResponse], error) // Lists all changes, designed for use in the changes home page ListHomeChanges(context.Context, *connect.Request[sdp_go.ListHomeChangesRequest]) (*connect.Response[sdp_go.ListHomeChangesResponse], error) // Start the change analysis process. This will calculate various things // blast radius, risks etc. This will return immediately and // the results can be fetched using the other RPCs StartChangeAnalysis(context.Context, *connect.Request[sdp_go.StartChangeAnalysisRequest]) (*connect.Response[sdp_go.StartChangeAnalysisResponse], error) // Gets the diff summary for all items that were planned to change as part of // this change. This includes the high level details of the item, and the // status (e.g. changed, deleted) but not the diff itself ListChangingItemsSummary(context.Context, *connect.Request[sdp_go.ListChangingItemsSummaryRequest]) (*connect.Response[sdp_go.ListChangingItemsSummaryResponse], error) // Gets the full diff of everything that changed as part of this "change". // This includes all items and also edges between them GetDiff(context.Context, *connect.Request[sdp_go.GetDiffRequest]) (*connect.Response[sdp_go.GetDiffResponse], error) // List all the available repos, authors and statuses that can be used to populate the dropdown filters PopulateChangeFilters(context.Context, *connect.Request[sdp_go.PopulateChangeFiltersRequest]) (*connect.Response[sdp_go.PopulateChangeFiltersResponse], error) // Generates an AI-powered fix suggestion for a specific risk GenerateRiskFix(context.Context, *connect.Request[sdp_go.GenerateRiskFixRequest]) (*connect.Response[sdp_go.GenerateRiskFixResponse], error) // Submit user feedback on a risk SubmitRiskFeedback(context.Context, *connect.Request[sdp_go.SubmitRiskFeedbackRequest]) (*connect.Response[sdp_go.SubmitRiskFeedbackResponse], error) // The full details of all of the hypotheses that were considered or are being // considered as part of this change. GetHypothesesDetails(context.Context, *connect.Request[sdp_go.GetHypothesesDetailsRequest]) (*connect.Response[sdp_go.GetHypothesesDetailsResponse], error) // Gets all signals for a change, including: // - Overall signal for the change // - Top level signals for each category // - Routineness signals per item // - Individual custom signals // This is similar to GetChangeSummary but focused on signals data GetChangeSignals(context.Context, *connect.Request[sdp_go.GetChangeSignalsRequest]) (*connect.Response[sdp_go.GetChangeSignalsResponse], error) // Appends planned changes to an existing change without starting analysis. // The change must be in CHANGE_STATUS_DEFINING. Each call inserts a new batch // of items; call StartChangeAnalysis (with empty changingItems) to trigger // analysis on all accumulated items. AddPlannedChanges(context.Context, *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) } // NewChangesServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewChangesServiceHandler(svc ChangesServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { changesServiceMethods := sdp_go.File_changes_proto.Services().ByName("ChangesService").Methods() changesServiceListChangesHandler := connect.NewUnaryHandler( ChangesServiceListChangesProcedure, svc.ListChanges, connect.WithSchema(changesServiceMethods.ByName("ListChanges")), connect.WithHandlerOptions(opts...), ) changesServiceListChangesByStatusHandler := connect.NewUnaryHandler( ChangesServiceListChangesByStatusProcedure, svc.ListChangesByStatus, connect.WithSchema(changesServiceMethods.ByName("ListChangesByStatus")), connect.WithHandlerOptions(opts...), ) changesServiceCreateChangeHandler := connect.NewUnaryHandler( ChangesServiceCreateChangeProcedure, svc.CreateChange, connect.WithSchema(changesServiceMethods.ByName("CreateChange")), connect.WithHandlerOptions(opts...), ) changesServiceGetChangeHandler := connect.NewUnaryHandler( ChangesServiceGetChangeProcedure, svc.GetChange, connect.WithSchema(changesServiceMethods.ByName("GetChange")), connect.WithHandlerOptions(opts...), ) changesServiceGetChangeByTicketLinkHandler := connect.NewUnaryHandler( ChangesServiceGetChangeByTicketLinkProcedure, svc.GetChangeByTicketLink, connect.WithSchema(changesServiceMethods.ByName("GetChangeByTicketLink")), connect.WithHandlerOptions(opts...), ) changesServiceGetChangeSummaryHandler := connect.NewUnaryHandler( ChangesServiceGetChangeSummaryProcedure, svc.GetChangeSummary, connect.WithSchema(changesServiceMethods.ByName("GetChangeSummary")), connect.WithHandlerOptions(opts...), ) changesServiceGetChangeTimelineV2Handler := connect.NewUnaryHandler( ChangesServiceGetChangeTimelineV2Procedure, svc.GetChangeTimelineV2, connect.WithSchema(changesServiceMethods.ByName("GetChangeTimelineV2")), connect.WithHandlerOptions(opts...), ) changesServiceGetChangeRisksHandler := connect.NewUnaryHandler( ChangesServiceGetChangeRisksProcedure, svc.GetChangeRisks, connect.WithSchema(changesServiceMethods.ByName("GetChangeRisks")), connect.WithHandlerOptions(opts...), ) changesServiceUpdateChangeHandler := connect.NewUnaryHandler( ChangesServiceUpdateChangeProcedure, svc.UpdateChange, connect.WithSchema(changesServiceMethods.ByName("UpdateChange")), connect.WithHandlerOptions(opts...), ) changesServiceDeleteChangeHandler := connect.NewUnaryHandler( ChangesServiceDeleteChangeProcedure, svc.DeleteChange, connect.WithSchema(changesServiceMethods.ByName("DeleteChange")), connect.WithHandlerOptions(opts...), ) changesServiceListChangesBySnapshotUUIDHandler := connect.NewUnaryHandler( ChangesServiceListChangesBySnapshotUUIDProcedure, svc.ListChangesBySnapshotUUID, connect.WithSchema(changesServiceMethods.ByName("ListChangesBySnapshotUUID")), connect.WithHandlerOptions(opts...), ) changesServiceRefreshStateHandler := connect.NewUnaryHandler( ChangesServiceRefreshStateProcedure, svc.RefreshState, connect.WithSchema(changesServiceMethods.ByName("RefreshState")), connect.WithHandlerOptions(opts...), ) changesServiceStartChangeHandler := connect.NewServerStreamHandler( ChangesServiceStartChangeProcedure, svc.StartChange, connect.WithSchema(changesServiceMethods.ByName("StartChange")), connect.WithHandlerOptions(opts...), ) changesServiceEndChangeHandler := connect.NewServerStreamHandler( ChangesServiceEndChangeProcedure, svc.EndChange, connect.WithSchema(changesServiceMethods.ByName("EndChange")), connect.WithHandlerOptions(opts...), ) changesServiceStartChangeSimpleHandler := connect.NewUnaryHandler( ChangesServiceStartChangeSimpleProcedure, svc.StartChangeSimple, connect.WithSchema(changesServiceMethods.ByName("StartChangeSimple")), connect.WithHandlerOptions(opts...), ) changesServiceEndChangeSimpleHandler := connect.NewUnaryHandler( ChangesServiceEndChangeSimpleProcedure, svc.EndChangeSimple, connect.WithSchema(changesServiceMethods.ByName("EndChangeSimple")), connect.WithHandlerOptions(opts...), ) changesServiceListHomeChangesHandler := connect.NewUnaryHandler( ChangesServiceListHomeChangesProcedure, svc.ListHomeChanges, connect.WithSchema(changesServiceMethods.ByName("ListHomeChanges")), connect.WithHandlerOptions(opts...), ) changesServiceStartChangeAnalysisHandler := connect.NewUnaryHandler( ChangesServiceStartChangeAnalysisProcedure, svc.StartChangeAnalysis, connect.WithSchema(changesServiceMethods.ByName("StartChangeAnalysis")), connect.WithHandlerOptions(opts...), ) changesServiceListChangingItemsSummaryHandler := connect.NewUnaryHandler( ChangesServiceListChangingItemsSummaryProcedure, svc.ListChangingItemsSummary, connect.WithSchema(changesServiceMethods.ByName("ListChangingItemsSummary")), connect.WithHandlerOptions(opts...), ) changesServiceGetDiffHandler := connect.NewUnaryHandler( ChangesServiceGetDiffProcedure, svc.GetDiff, connect.WithSchema(changesServiceMethods.ByName("GetDiff")), connect.WithHandlerOptions(opts...), ) changesServicePopulateChangeFiltersHandler := connect.NewUnaryHandler( ChangesServicePopulateChangeFiltersProcedure, svc.PopulateChangeFilters, connect.WithSchema(changesServiceMethods.ByName("PopulateChangeFilters")), connect.WithHandlerOptions(opts...), ) changesServiceGenerateRiskFixHandler := connect.NewUnaryHandler( ChangesServiceGenerateRiskFixProcedure, svc.GenerateRiskFix, connect.WithSchema(changesServiceMethods.ByName("GenerateRiskFix")), connect.WithHandlerOptions(opts...), ) changesServiceSubmitRiskFeedbackHandler := connect.NewUnaryHandler( ChangesServiceSubmitRiskFeedbackProcedure, svc.SubmitRiskFeedback, connect.WithSchema(changesServiceMethods.ByName("SubmitRiskFeedback")), connect.WithHandlerOptions(opts...), ) changesServiceGetHypothesesDetailsHandler := connect.NewUnaryHandler( ChangesServiceGetHypothesesDetailsProcedure, svc.GetHypothesesDetails, connect.WithSchema(changesServiceMethods.ByName("GetHypothesesDetails")), connect.WithHandlerOptions(opts...), ) changesServiceGetChangeSignalsHandler := connect.NewUnaryHandler( ChangesServiceGetChangeSignalsProcedure, svc.GetChangeSignals, connect.WithSchema(changesServiceMethods.ByName("GetChangeSignals")), connect.WithHandlerOptions(opts...), ) changesServiceAddPlannedChangesHandler := connect.NewUnaryHandler( ChangesServiceAddPlannedChangesProcedure, svc.AddPlannedChanges, connect.WithSchema(changesServiceMethods.ByName("AddPlannedChanges")), connect.WithHandlerOptions(opts...), ) return "/changes.ChangesService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ChangesServiceListChangesProcedure: changesServiceListChangesHandler.ServeHTTP(w, r) case ChangesServiceListChangesByStatusProcedure: changesServiceListChangesByStatusHandler.ServeHTTP(w, r) case ChangesServiceCreateChangeProcedure: changesServiceCreateChangeHandler.ServeHTTP(w, r) case ChangesServiceGetChangeProcedure: changesServiceGetChangeHandler.ServeHTTP(w, r) case ChangesServiceGetChangeByTicketLinkProcedure: changesServiceGetChangeByTicketLinkHandler.ServeHTTP(w, r) case ChangesServiceGetChangeSummaryProcedure: changesServiceGetChangeSummaryHandler.ServeHTTP(w, r) case ChangesServiceGetChangeTimelineV2Procedure: changesServiceGetChangeTimelineV2Handler.ServeHTTP(w, r) case ChangesServiceGetChangeRisksProcedure: changesServiceGetChangeRisksHandler.ServeHTTP(w, r) case ChangesServiceUpdateChangeProcedure: changesServiceUpdateChangeHandler.ServeHTTP(w, r) case ChangesServiceDeleteChangeProcedure: changesServiceDeleteChangeHandler.ServeHTTP(w, r) case ChangesServiceListChangesBySnapshotUUIDProcedure: changesServiceListChangesBySnapshotUUIDHandler.ServeHTTP(w, r) case ChangesServiceRefreshStateProcedure: changesServiceRefreshStateHandler.ServeHTTP(w, r) case ChangesServiceStartChangeProcedure: changesServiceStartChangeHandler.ServeHTTP(w, r) case ChangesServiceEndChangeProcedure: changesServiceEndChangeHandler.ServeHTTP(w, r) case ChangesServiceStartChangeSimpleProcedure: changesServiceStartChangeSimpleHandler.ServeHTTP(w, r) case ChangesServiceEndChangeSimpleProcedure: changesServiceEndChangeSimpleHandler.ServeHTTP(w, r) case ChangesServiceListHomeChangesProcedure: changesServiceListHomeChangesHandler.ServeHTTP(w, r) case ChangesServiceStartChangeAnalysisProcedure: changesServiceStartChangeAnalysisHandler.ServeHTTP(w, r) case ChangesServiceListChangingItemsSummaryProcedure: changesServiceListChangingItemsSummaryHandler.ServeHTTP(w, r) case ChangesServiceGetDiffProcedure: changesServiceGetDiffHandler.ServeHTTP(w, r) case ChangesServicePopulateChangeFiltersProcedure: changesServicePopulateChangeFiltersHandler.ServeHTTP(w, r) case ChangesServiceGenerateRiskFixProcedure: changesServiceGenerateRiskFixHandler.ServeHTTP(w, r) case ChangesServiceSubmitRiskFeedbackProcedure: changesServiceSubmitRiskFeedbackHandler.ServeHTTP(w, r) case ChangesServiceGetHypothesesDetailsProcedure: changesServiceGetHypothesesDetailsHandler.ServeHTTP(w, r) case ChangesServiceGetChangeSignalsProcedure: changesServiceGetChangeSignalsHandler.ServeHTTP(w, r) case ChangesServiceAddPlannedChangesProcedure: changesServiceAddPlannedChangesHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedChangesServiceHandler returns CodeUnimplemented from all methods. type UnimplementedChangesServiceHandler struct{} func (UnimplementedChangesServiceHandler) ListChanges(context.Context, *connect.Request[sdp_go.ListChangesRequest]) (*connect.Response[sdp_go.ListChangesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.ListChanges is not implemented")) } func (UnimplementedChangesServiceHandler) ListChangesByStatus(context.Context, *connect.Request[sdp_go.ListChangesByStatusRequest]) (*connect.Response[sdp_go.ListChangesByStatusResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.ListChangesByStatus is not implemented")) } func (UnimplementedChangesServiceHandler) CreateChange(context.Context, *connect.Request[sdp_go.CreateChangeRequest]) (*connect.Response[sdp_go.CreateChangeResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.CreateChange is not implemented")) } func (UnimplementedChangesServiceHandler) GetChange(context.Context, *connect.Request[sdp_go.GetChangeRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetChange is not implemented")) } func (UnimplementedChangesServiceHandler) GetChangeByTicketLink(context.Context, *connect.Request[sdp_go.GetChangeByTicketLinkRequest]) (*connect.Response[sdp_go.GetChangeResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetChangeByTicketLink is not implemented")) } func (UnimplementedChangesServiceHandler) GetChangeSummary(context.Context, *connect.Request[sdp_go.GetChangeSummaryRequest]) (*connect.Response[sdp_go.GetChangeSummaryResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetChangeSummary is not implemented")) } func (UnimplementedChangesServiceHandler) GetChangeTimelineV2(context.Context, *connect.Request[sdp_go.GetChangeTimelineV2Request]) (*connect.Response[sdp_go.GetChangeTimelineV2Response], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetChangeTimelineV2 is not implemented")) } func (UnimplementedChangesServiceHandler) GetChangeRisks(context.Context, *connect.Request[sdp_go.GetChangeRisksRequest]) (*connect.Response[sdp_go.GetChangeRisksResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetChangeRisks is not implemented")) } func (UnimplementedChangesServiceHandler) UpdateChange(context.Context, *connect.Request[sdp_go.UpdateChangeRequest]) (*connect.Response[sdp_go.UpdateChangeResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.UpdateChange is not implemented")) } func (UnimplementedChangesServiceHandler) DeleteChange(context.Context, *connect.Request[sdp_go.DeleteChangeRequest]) (*connect.Response[sdp_go.DeleteChangeResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.DeleteChange is not implemented")) } func (UnimplementedChangesServiceHandler) ListChangesBySnapshotUUID(context.Context, *connect.Request[sdp_go.ListChangesBySnapshotUUIDRequest]) (*connect.Response[sdp_go.ListChangesBySnapshotUUIDResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.ListChangesBySnapshotUUID is not implemented")) } func (UnimplementedChangesServiceHandler) RefreshState(context.Context, *connect.Request[sdp_go.RefreshStateRequest]) (*connect.Response[sdp_go.RefreshStateResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.RefreshState is not implemented")) } func (UnimplementedChangesServiceHandler) StartChange(context.Context, *connect.Request[sdp_go.StartChangeRequest], *connect.ServerStream[sdp_go.StartChangeResponse]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.StartChange is not implemented")) } func (UnimplementedChangesServiceHandler) EndChange(context.Context, *connect.Request[sdp_go.EndChangeRequest], *connect.ServerStream[sdp_go.EndChangeResponse]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.EndChange is not implemented")) } func (UnimplementedChangesServiceHandler) StartChangeSimple(context.Context, *connect.Request[sdp_go.StartChangeRequest]) (*connect.Response[sdp_go.StartChangeSimpleResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.StartChangeSimple is not implemented")) } func (UnimplementedChangesServiceHandler) EndChangeSimple(context.Context, *connect.Request[sdp_go.EndChangeRequest]) (*connect.Response[sdp_go.EndChangeSimpleResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.EndChangeSimple is not implemented")) } func (UnimplementedChangesServiceHandler) ListHomeChanges(context.Context, *connect.Request[sdp_go.ListHomeChangesRequest]) (*connect.Response[sdp_go.ListHomeChangesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.ListHomeChanges is not implemented")) } func (UnimplementedChangesServiceHandler) StartChangeAnalysis(context.Context, *connect.Request[sdp_go.StartChangeAnalysisRequest]) (*connect.Response[sdp_go.StartChangeAnalysisResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.StartChangeAnalysis is not implemented")) } func (UnimplementedChangesServiceHandler) ListChangingItemsSummary(context.Context, *connect.Request[sdp_go.ListChangingItemsSummaryRequest]) (*connect.Response[sdp_go.ListChangingItemsSummaryResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.ListChangingItemsSummary is not implemented")) } func (UnimplementedChangesServiceHandler) GetDiff(context.Context, *connect.Request[sdp_go.GetDiffRequest]) (*connect.Response[sdp_go.GetDiffResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetDiff is not implemented")) } func (UnimplementedChangesServiceHandler) PopulateChangeFilters(context.Context, *connect.Request[sdp_go.PopulateChangeFiltersRequest]) (*connect.Response[sdp_go.PopulateChangeFiltersResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.PopulateChangeFilters is not implemented")) } func (UnimplementedChangesServiceHandler) GenerateRiskFix(context.Context, *connect.Request[sdp_go.GenerateRiskFixRequest]) (*connect.Response[sdp_go.GenerateRiskFixResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GenerateRiskFix is not implemented")) } func (UnimplementedChangesServiceHandler) SubmitRiskFeedback(context.Context, *connect.Request[sdp_go.SubmitRiskFeedbackRequest]) (*connect.Response[sdp_go.SubmitRiskFeedbackResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.SubmitRiskFeedback is not implemented")) } func (UnimplementedChangesServiceHandler) GetHypothesesDetails(context.Context, *connect.Request[sdp_go.GetHypothesesDetailsRequest]) (*connect.Response[sdp_go.GetHypothesesDetailsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetHypothesesDetails is not implemented")) } func (UnimplementedChangesServiceHandler) GetChangeSignals(context.Context, *connect.Request[sdp_go.GetChangeSignalsRequest]) (*connect.Response[sdp_go.GetChangeSignalsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetChangeSignals is not implemented")) } func (UnimplementedChangesServiceHandler) AddPlannedChanges(context.Context, *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.AddPlannedChanges is not implemented")) } // LabelServiceClient is a client for the changes.LabelService service. type LabelServiceClient interface { // Lists all label rules for an account ListLabelRules(context.Context, *connect.Request[sdp_go.ListLabelRulesRequest]) (*connect.Response[sdp_go.ListLabelRulesResponse], error) // Creates a new label rule CreateLabelRule(context.Context, *connect.Request[sdp_go.CreateLabelRuleRequest]) (*connect.Response[sdp_go.CreateLabelRuleResponse], error) // Gets the details of a label rule GetLabelRule(context.Context, *connect.Request[sdp_go.GetLabelRuleRequest]) (*connect.Response[sdp_go.GetLabelRuleResponse], error) // Updates a label rule UpdateLabelRule(context.Context, *connect.Request[sdp_go.UpdateLabelRuleRequest]) (*connect.Response[sdp_go.UpdateLabelRuleResponse], error) // Deletes a label rule // this also removes the label from all changes that are currently labelled with this rule DeleteLabelRule(context.Context, *connect.Request[sdp_go.DeleteLabelRuleRequest]) (*connect.Response[sdp_go.DeleteLabelRuleResponse], error) // Test a label rule against a list of changes, this does not apply the label to the changes, it only tests if the label should be applied TestLabelRule(context.Context, *connect.Request[sdp_go.TestLabelRuleRequest]) (*connect.ServerStreamForClient[sdp_go.TestLabelRuleResponse], error) // Re-apply a label rule across all changes within a specified time window: // 1. Removes the label from all relevant changes in the period that match this rule // 2. Applies (or re-applies) the label to eligible changes in the period ReapplyLabelRuleInTimeRange(context.Context, *connect.Request[sdp_go.ReapplyLabelRuleInTimeRangeRequest]) (*connect.Response[sdp_go.ReapplyLabelRuleInTimeRangeResponse], error) } // NewLabelServiceClient constructs a client for the changes.LabelService service. By default, it // uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewLabelServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) LabelServiceClient { baseURL = strings.TrimRight(baseURL, "/") labelServiceMethods := sdp_go.File_changes_proto.Services().ByName("LabelService").Methods() return &labelServiceClient{ listLabelRules: connect.NewClient[sdp_go.ListLabelRulesRequest, sdp_go.ListLabelRulesResponse]( httpClient, baseURL+LabelServiceListLabelRulesProcedure, connect.WithSchema(labelServiceMethods.ByName("ListLabelRules")), connect.WithClientOptions(opts...), ), createLabelRule: connect.NewClient[sdp_go.CreateLabelRuleRequest, sdp_go.CreateLabelRuleResponse]( httpClient, baseURL+LabelServiceCreateLabelRuleProcedure, connect.WithSchema(labelServiceMethods.ByName("CreateLabelRule")), connect.WithClientOptions(opts...), ), getLabelRule: connect.NewClient[sdp_go.GetLabelRuleRequest, sdp_go.GetLabelRuleResponse]( httpClient, baseURL+LabelServiceGetLabelRuleProcedure, connect.WithSchema(labelServiceMethods.ByName("GetLabelRule")), connect.WithClientOptions(opts...), ), updateLabelRule: connect.NewClient[sdp_go.UpdateLabelRuleRequest, sdp_go.UpdateLabelRuleResponse]( httpClient, baseURL+LabelServiceUpdateLabelRuleProcedure, connect.WithSchema(labelServiceMethods.ByName("UpdateLabelRule")), connect.WithClientOptions(opts...), ), deleteLabelRule: connect.NewClient[sdp_go.DeleteLabelRuleRequest, sdp_go.DeleteLabelRuleResponse]( httpClient, baseURL+LabelServiceDeleteLabelRuleProcedure, connect.WithSchema(labelServiceMethods.ByName("DeleteLabelRule")), connect.WithClientOptions(opts...), ), testLabelRule: connect.NewClient[sdp_go.TestLabelRuleRequest, sdp_go.TestLabelRuleResponse]( httpClient, baseURL+LabelServiceTestLabelRuleProcedure, connect.WithSchema(labelServiceMethods.ByName("TestLabelRule")), connect.WithClientOptions(opts...), ), reapplyLabelRuleInTimeRange: connect.NewClient[sdp_go.ReapplyLabelRuleInTimeRangeRequest, sdp_go.ReapplyLabelRuleInTimeRangeResponse]( httpClient, baseURL+LabelServiceReapplyLabelRuleInTimeRangeProcedure, connect.WithSchema(labelServiceMethods.ByName("ReapplyLabelRuleInTimeRange")), connect.WithClientOptions(opts...), ), } } // labelServiceClient implements LabelServiceClient. type labelServiceClient struct { listLabelRules *connect.Client[sdp_go.ListLabelRulesRequest, sdp_go.ListLabelRulesResponse] createLabelRule *connect.Client[sdp_go.CreateLabelRuleRequest, sdp_go.CreateLabelRuleResponse] getLabelRule *connect.Client[sdp_go.GetLabelRuleRequest, sdp_go.GetLabelRuleResponse] updateLabelRule *connect.Client[sdp_go.UpdateLabelRuleRequest, sdp_go.UpdateLabelRuleResponse] deleteLabelRule *connect.Client[sdp_go.DeleteLabelRuleRequest, sdp_go.DeleteLabelRuleResponse] testLabelRule *connect.Client[sdp_go.TestLabelRuleRequest, sdp_go.TestLabelRuleResponse] reapplyLabelRuleInTimeRange *connect.Client[sdp_go.ReapplyLabelRuleInTimeRangeRequest, sdp_go.ReapplyLabelRuleInTimeRangeResponse] } // ListLabelRules calls changes.LabelService.ListLabelRules. func (c *labelServiceClient) ListLabelRules(ctx context.Context, req *connect.Request[sdp_go.ListLabelRulesRequest]) (*connect.Response[sdp_go.ListLabelRulesResponse], error) { return c.listLabelRules.CallUnary(ctx, req) } // CreateLabelRule calls changes.LabelService.CreateLabelRule. func (c *labelServiceClient) CreateLabelRule(ctx context.Context, req *connect.Request[sdp_go.CreateLabelRuleRequest]) (*connect.Response[sdp_go.CreateLabelRuleResponse], error) { return c.createLabelRule.CallUnary(ctx, req) } // GetLabelRule calls changes.LabelService.GetLabelRule. func (c *labelServiceClient) GetLabelRule(ctx context.Context, req *connect.Request[sdp_go.GetLabelRuleRequest]) (*connect.Response[sdp_go.GetLabelRuleResponse], error) { return c.getLabelRule.CallUnary(ctx, req) } // UpdateLabelRule calls changes.LabelService.UpdateLabelRule. func (c *labelServiceClient) UpdateLabelRule(ctx context.Context, req *connect.Request[sdp_go.UpdateLabelRuleRequest]) (*connect.Response[sdp_go.UpdateLabelRuleResponse], error) { return c.updateLabelRule.CallUnary(ctx, req) } // DeleteLabelRule calls changes.LabelService.DeleteLabelRule. func (c *labelServiceClient) DeleteLabelRule(ctx context.Context, req *connect.Request[sdp_go.DeleteLabelRuleRequest]) (*connect.Response[sdp_go.DeleteLabelRuleResponse], error) { return c.deleteLabelRule.CallUnary(ctx, req) } // TestLabelRule calls changes.LabelService.TestLabelRule. func (c *labelServiceClient) TestLabelRule(ctx context.Context, req *connect.Request[sdp_go.TestLabelRuleRequest]) (*connect.ServerStreamForClient[sdp_go.TestLabelRuleResponse], error) { return c.testLabelRule.CallServerStream(ctx, req) } // ReapplyLabelRuleInTimeRange calls changes.LabelService.ReapplyLabelRuleInTimeRange. func (c *labelServiceClient) ReapplyLabelRuleInTimeRange(ctx context.Context, req *connect.Request[sdp_go.ReapplyLabelRuleInTimeRangeRequest]) (*connect.Response[sdp_go.ReapplyLabelRuleInTimeRangeResponse], error) { return c.reapplyLabelRuleInTimeRange.CallUnary(ctx, req) } // LabelServiceHandler is an implementation of the changes.LabelService service. type LabelServiceHandler interface { // Lists all label rules for an account ListLabelRules(context.Context, *connect.Request[sdp_go.ListLabelRulesRequest]) (*connect.Response[sdp_go.ListLabelRulesResponse], error) // Creates a new label rule CreateLabelRule(context.Context, *connect.Request[sdp_go.CreateLabelRuleRequest]) (*connect.Response[sdp_go.CreateLabelRuleResponse], error) // Gets the details of a label rule GetLabelRule(context.Context, *connect.Request[sdp_go.GetLabelRuleRequest]) (*connect.Response[sdp_go.GetLabelRuleResponse], error) // Updates a label rule UpdateLabelRule(context.Context, *connect.Request[sdp_go.UpdateLabelRuleRequest]) (*connect.Response[sdp_go.UpdateLabelRuleResponse], error) // Deletes a label rule // this also removes the label from all changes that are currently labelled with this rule DeleteLabelRule(context.Context, *connect.Request[sdp_go.DeleteLabelRuleRequest]) (*connect.Response[sdp_go.DeleteLabelRuleResponse], error) // Test a label rule against a list of changes, this does not apply the label to the changes, it only tests if the label should be applied TestLabelRule(context.Context, *connect.Request[sdp_go.TestLabelRuleRequest], *connect.ServerStream[sdp_go.TestLabelRuleResponse]) error // Re-apply a label rule across all changes within a specified time window: // 1. Removes the label from all relevant changes in the period that match this rule // 2. Applies (or re-applies) the label to eligible changes in the period ReapplyLabelRuleInTimeRange(context.Context, *connect.Request[sdp_go.ReapplyLabelRuleInTimeRangeRequest]) (*connect.Response[sdp_go.ReapplyLabelRuleInTimeRangeResponse], error) } // NewLabelServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewLabelServiceHandler(svc LabelServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { labelServiceMethods := sdp_go.File_changes_proto.Services().ByName("LabelService").Methods() labelServiceListLabelRulesHandler := connect.NewUnaryHandler( LabelServiceListLabelRulesProcedure, svc.ListLabelRules, connect.WithSchema(labelServiceMethods.ByName("ListLabelRules")), connect.WithHandlerOptions(opts...), ) labelServiceCreateLabelRuleHandler := connect.NewUnaryHandler( LabelServiceCreateLabelRuleProcedure, svc.CreateLabelRule, connect.WithSchema(labelServiceMethods.ByName("CreateLabelRule")), connect.WithHandlerOptions(opts...), ) labelServiceGetLabelRuleHandler := connect.NewUnaryHandler( LabelServiceGetLabelRuleProcedure, svc.GetLabelRule, connect.WithSchema(labelServiceMethods.ByName("GetLabelRule")), connect.WithHandlerOptions(opts...), ) labelServiceUpdateLabelRuleHandler := connect.NewUnaryHandler( LabelServiceUpdateLabelRuleProcedure, svc.UpdateLabelRule, connect.WithSchema(labelServiceMethods.ByName("UpdateLabelRule")), connect.WithHandlerOptions(opts...), ) labelServiceDeleteLabelRuleHandler := connect.NewUnaryHandler( LabelServiceDeleteLabelRuleProcedure, svc.DeleteLabelRule, connect.WithSchema(labelServiceMethods.ByName("DeleteLabelRule")), connect.WithHandlerOptions(opts...), ) labelServiceTestLabelRuleHandler := connect.NewServerStreamHandler( LabelServiceTestLabelRuleProcedure, svc.TestLabelRule, connect.WithSchema(labelServiceMethods.ByName("TestLabelRule")), connect.WithHandlerOptions(opts...), ) labelServiceReapplyLabelRuleInTimeRangeHandler := connect.NewUnaryHandler( LabelServiceReapplyLabelRuleInTimeRangeProcedure, svc.ReapplyLabelRuleInTimeRange, connect.WithSchema(labelServiceMethods.ByName("ReapplyLabelRuleInTimeRange")), connect.WithHandlerOptions(opts...), ) return "/changes.LabelService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case LabelServiceListLabelRulesProcedure: labelServiceListLabelRulesHandler.ServeHTTP(w, r) case LabelServiceCreateLabelRuleProcedure: labelServiceCreateLabelRuleHandler.ServeHTTP(w, r) case LabelServiceGetLabelRuleProcedure: labelServiceGetLabelRuleHandler.ServeHTTP(w, r) case LabelServiceUpdateLabelRuleProcedure: labelServiceUpdateLabelRuleHandler.ServeHTTP(w, r) case LabelServiceDeleteLabelRuleProcedure: labelServiceDeleteLabelRuleHandler.ServeHTTP(w, r) case LabelServiceTestLabelRuleProcedure: labelServiceTestLabelRuleHandler.ServeHTTP(w, r) case LabelServiceReapplyLabelRuleInTimeRangeProcedure: labelServiceReapplyLabelRuleInTimeRangeHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedLabelServiceHandler returns CodeUnimplemented from all methods. type UnimplementedLabelServiceHandler struct{} func (UnimplementedLabelServiceHandler) ListLabelRules(context.Context, *connect.Request[sdp_go.ListLabelRulesRequest]) (*connect.Response[sdp_go.ListLabelRulesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.LabelService.ListLabelRules is not implemented")) } func (UnimplementedLabelServiceHandler) CreateLabelRule(context.Context, *connect.Request[sdp_go.CreateLabelRuleRequest]) (*connect.Response[sdp_go.CreateLabelRuleResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.LabelService.CreateLabelRule is not implemented")) } func (UnimplementedLabelServiceHandler) GetLabelRule(context.Context, *connect.Request[sdp_go.GetLabelRuleRequest]) (*connect.Response[sdp_go.GetLabelRuleResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.LabelService.GetLabelRule is not implemented")) } func (UnimplementedLabelServiceHandler) UpdateLabelRule(context.Context, *connect.Request[sdp_go.UpdateLabelRuleRequest]) (*connect.Response[sdp_go.UpdateLabelRuleResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.LabelService.UpdateLabelRule is not implemented")) } func (UnimplementedLabelServiceHandler) DeleteLabelRule(context.Context, *connect.Request[sdp_go.DeleteLabelRuleRequest]) (*connect.Response[sdp_go.DeleteLabelRuleResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.LabelService.DeleteLabelRule is not implemented")) } func (UnimplementedLabelServiceHandler) TestLabelRule(context.Context, *connect.Request[sdp_go.TestLabelRuleRequest], *connect.ServerStream[sdp_go.TestLabelRuleResponse]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("changes.LabelService.TestLabelRule is not implemented")) } func (UnimplementedLabelServiceHandler) ReapplyLabelRuleInTimeRange(context.Context, *connect.Request[sdp_go.ReapplyLabelRuleInTimeRangeRequest]) (*connect.Response[sdp_go.ReapplyLabelRuleInTimeRangeResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.LabelService.ReapplyLabelRuleInTimeRange is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/cli.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: cli.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // ConfigServiceName is the fully-qualified name of the ConfigService service. ConfigServiceName = "cli.ConfigService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // ConfigServiceGetConfigProcedure is the fully-qualified name of the ConfigService's GetConfig RPC. ConfigServiceGetConfigProcedure = "/cli.ConfigService/GetConfig" // ConfigServiceSetConfigProcedure is the fully-qualified name of the ConfigService's SetConfig RPC. ConfigServiceSetConfigProcedure = "/cli.ConfigService/SetConfig" ) // ConfigServiceClient is a client for the cli.ConfigService service. type ConfigServiceClient interface { GetConfig(context.Context, *connect.Request[sdp_go.GetConfigRequest]) (*connect.Response[sdp_go.GetConfigResponse], error) SetConfig(context.Context, *connect.Request[sdp_go.SetConfigRequest]) (*connect.Response[sdp_go.SetConfigResponse], error) } // NewConfigServiceClient constructs a client for the cli.ConfigService service. By default, it uses // the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewConfigServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ConfigServiceClient { baseURL = strings.TrimRight(baseURL, "/") configServiceMethods := sdp_go.File_cli_proto.Services().ByName("ConfigService").Methods() return &configServiceClient{ getConfig: connect.NewClient[sdp_go.GetConfigRequest, sdp_go.GetConfigResponse]( httpClient, baseURL+ConfigServiceGetConfigProcedure, connect.WithSchema(configServiceMethods.ByName("GetConfig")), connect.WithClientOptions(opts...), ), setConfig: connect.NewClient[sdp_go.SetConfigRequest, sdp_go.SetConfigResponse]( httpClient, baseURL+ConfigServiceSetConfigProcedure, connect.WithSchema(configServiceMethods.ByName("SetConfig")), connect.WithClientOptions(opts...), ), } } // configServiceClient implements ConfigServiceClient. type configServiceClient struct { getConfig *connect.Client[sdp_go.GetConfigRequest, sdp_go.GetConfigResponse] setConfig *connect.Client[sdp_go.SetConfigRequest, sdp_go.SetConfigResponse] } // GetConfig calls cli.ConfigService.GetConfig. func (c *configServiceClient) GetConfig(ctx context.Context, req *connect.Request[sdp_go.GetConfigRequest]) (*connect.Response[sdp_go.GetConfigResponse], error) { return c.getConfig.CallUnary(ctx, req) } // SetConfig calls cli.ConfigService.SetConfig. func (c *configServiceClient) SetConfig(ctx context.Context, req *connect.Request[sdp_go.SetConfigRequest]) (*connect.Response[sdp_go.SetConfigResponse], error) { return c.setConfig.CallUnary(ctx, req) } // ConfigServiceHandler is an implementation of the cli.ConfigService service. type ConfigServiceHandler interface { GetConfig(context.Context, *connect.Request[sdp_go.GetConfigRequest]) (*connect.Response[sdp_go.GetConfigResponse], error) SetConfig(context.Context, *connect.Request[sdp_go.SetConfigRequest]) (*connect.Response[sdp_go.SetConfigResponse], error) } // NewConfigServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewConfigServiceHandler(svc ConfigServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { configServiceMethods := sdp_go.File_cli_proto.Services().ByName("ConfigService").Methods() configServiceGetConfigHandler := connect.NewUnaryHandler( ConfigServiceGetConfigProcedure, svc.GetConfig, connect.WithSchema(configServiceMethods.ByName("GetConfig")), connect.WithHandlerOptions(opts...), ) configServiceSetConfigHandler := connect.NewUnaryHandler( ConfigServiceSetConfigProcedure, svc.SetConfig, connect.WithSchema(configServiceMethods.ByName("SetConfig")), connect.WithHandlerOptions(opts...), ) return "/cli.ConfigService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ConfigServiceGetConfigProcedure: configServiceGetConfigHandler.ServeHTTP(w, r) case ConfigServiceSetConfigProcedure: configServiceSetConfigHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedConfigServiceHandler returns CodeUnimplemented from all methods. type UnimplementedConfigServiceHandler struct{} func (UnimplementedConfigServiceHandler) GetConfig(context.Context, *connect.Request[sdp_go.GetConfigRequest]) (*connect.Response[sdp_go.GetConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("cli.ConfigService.GetConfig is not implemented")) } func (UnimplementedConfigServiceHandler) SetConfig(context.Context, *connect.Request[sdp_go.SetConfigRequest]) (*connect.Response[sdp_go.SetConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("cli.ConfigService.SetConfig is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/config.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: config.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // ConfigurationServiceName is the fully-qualified name of the ConfigurationService service. ConfigurationServiceName = "config.ConfigurationService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // ConfigurationServiceGetAccountConfigProcedure is the fully-qualified name of the // ConfigurationService's GetAccountConfig RPC. ConfigurationServiceGetAccountConfigProcedure = "/config.ConfigurationService/GetAccountConfig" // ConfigurationServiceUpdateAccountConfigProcedure is the fully-qualified name of the // ConfigurationService's UpdateAccountConfig RPC. ConfigurationServiceUpdateAccountConfigProcedure = "/config.ConfigurationService/UpdateAccountConfig" // ConfigurationServiceCreateHcpConfigProcedure is the fully-qualified name of the // ConfigurationService's CreateHcpConfig RPC. ConfigurationServiceCreateHcpConfigProcedure = "/config.ConfigurationService/CreateHcpConfig" // ConfigurationServiceGetHcpConfigProcedure is the fully-qualified name of the // ConfigurationService's GetHcpConfig RPC. ConfigurationServiceGetHcpConfigProcedure = "/config.ConfigurationService/GetHcpConfig" // ConfigurationServiceDeleteHcpConfigProcedure is the fully-qualified name of the // ConfigurationService's DeleteHcpConfig RPC. ConfigurationServiceDeleteHcpConfigProcedure = "/config.ConfigurationService/DeleteHcpConfig" // ConfigurationServiceReplaceHcpApiKeyProcedure is the fully-qualified name of the // ConfigurationService's ReplaceHcpApiKey RPC. ConfigurationServiceReplaceHcpApiKeyProcedure = "/config.ConfigurationService/ReplaceHcpApiKey" // ConfigurationServiceGetSignalConfigProcedure is the fully-qualified name of the // ConfigurationService's GetSignalConfig RPC. ConfigurationServiceGetSignalConfigProcedure = "/config.ConfigurationService/GetSignalConfig" // ConfigurationServiceUpdateSignalConfigProcedure is the fully-qualified name of the // ConfigurationService's UpdateSignalConfig RPC. ConfigurationServiceUpdateSignalConfigProcedure = "/config.ConfigurationService/UpdateSignalConfig" // ConfigurationServiceGetGithubAppInformationProcedure is the fully-qualified name of the // ConfigurationService's GetGithubAppInformation RPC. ConfigurationServiceGetGithubAppInformationProcedure = "/config.ConfigurationService/GetGithubAppInformation" // ConfigurationServiceRegenerateGithubAppProfileProcedure is the fully-qualified name of the // ConfigurationService's RegenerateGithubAppProfile RPC. ConfigurationServiceRegenerateGithubAppProfileProcedure = "/config.ConfigurationService/RegenerateGithubAppProfile" // ConfigurationServiceCreateGithubInstallURLProcedure is the fully-qualified name of the // ConfigurationService's CreateGithubInstallURL RPC. ConfigurationServiceCreateGithubInstallURLProcedure = "/config.ConfigurationService/CreateGithubInstallURL" ) // ConfigurationServiceClient is a client for the config.ConfigurationService service. type ConfigurationServiceClient interface { // Get the account config for the user's account GetAccountConfig(context.Context, *connect.Request[sdp_go.GetAccountConfigRequest]) (*connect.Response[sdp_go.GetAccountConfigResponse], error) // Update the account config for the user's account UpdateAccountConfig(context.Context, *connect.Request[sdp_go.UpdateAccountConfigRequest]) (*connect.Response[sdp_go.UpdateAccountConfigResponse], error) // Create a new HCP Terraform config for the user's account. This follows // the same flow as CreateAPIKey, to create a new API key that is then used // for the HCP Terraform endpoint URL. CreateHcpConfig(context.Context, *connect.Request[sdp_go.CreateHcpConfigRequest]) (*connect.Response[sdp_go.CreateHcpConfigResponse], error) // Get the existing HCP Terraform config for the user's account. GetHcpConfig(context.Context, *connect.Request[sdp_go.GetHcpConfigRequest]) (*connect.Response[sdp_go.GetHcpConfigResponse], error) // Remove the existing HCP Terraform config from the user's account. DeleteHcpConfig(context.Context, *connect.Request[sdp_go.DeleteHcpConfigRequest]) (*connect.Response[sdp_go.DeleteHcpConfigResponse], error) // Replace the API key backing the HCP Terraform integration with a fresh // one. The old API key is revoked. The endpoint URL and HMAC secret are // preserved. Follows the same OAuth flow as CreateHcpConfig. ReplaceHcpApiKey(context.Context, *connect.Request[sdp_go.ReplaceHcpApiKeyRequest]) (*connect.Response[sdp_go.ReplaceHcpApiKeyResponse], error) // Get the signal config for the account GetSignalConfig(context.Context, *connect.Request[sdp_go.GetSignalConfigRequest]) (*connect.Response[sdp_go.GetSignalConfigResponse], error) // Update the signal config for the account UpdateSignalConfig(context.Context, *connect.Request[sdp_go.UpdateSignalConfigRequest]) (*connect.Response[sdp_go.UpdateSignalConfigResponse], error) // Github app // we will be displaying app installation information for this account on the github integrations page GetGithubAppInformation(context.Context, *connect.Request[sdp_go.GetGithubAppInformationRequest]) (*connect.Response[sdp_go.GetGithubAppInformationResponse], error) // regenerate the github app profile, this information is used for signal processing RegenerateGithubAppProfile(context.Context, *connect.Request[sdp_go.RegenerateGithubAppProfileRequest]) (*connect.Response[sdp_go.RegenerateGithubAppProfileResponse], error) // Create a GitHub App install URL with a DB-backed state parameter for CSRF // protection. The frontend calls this RPC, then redirects the user to the // returned URL. GitHub will redirect back with the state UUID, which the // callback handler consumes to identify the Overmind account. CreateGithubInstallURL(context.Context, *connect.Request[sdp_go.CreateGithubInstallURLRequest]) (*connect.Response[sdp_go.CreateGithubInstallURLResponse], error) } // NewConfigurationServiceClient constructs a client for the config.ConfigurationService service. By // default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, // and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the // connect.WithGRPC() or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewConfigurationServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ConfigurationServiceClient { baseURL = strings.TrimRight(baseURL, "/") configurationServiceMethods := sdp_go.File_config_proto.Services().ByName("ConfigurationService").Methods() return &configurationServiceClient{ getAccountConfig: connect.NewClient[sdp_go.GetAccountConfigRequest, sdp_go.GetAccountConfigResponse]( httpClient, baseURL+ConfigurationServiceGetAccountConfigProcedure, connect.WithSchema(configurationServiceMethods.ByName("GetAccountConfig")), connect.WithClientOptions(opts...), ), updateAccountConfig: connect.NewClient[sdp_go.UpdateAccountConfigRequest, sdp_go.UpdateAccountConfigResponse]( httpClient, baseURL+ConfigurationServiceUpdateAccountConfigProcedure, connect.WithSchema(configurationServiceMethods.ByName("UpdateAccountConfig")), connect.WithClientOptions(opts...), ), createHcpConfig: connect.NewClient[sdp_go.CreateHcpConfigRequest, sdp_go.CreateHcpConfigResponse]( httpClient, baseURL+ConfigurationServiceCreateHcpConfigProcedure, connect.WithSchema(configurationServiceMethods.ByName("CreateHcpConfig")), connect.WithClientOptions(opts...), ), getHcpConfig: connect.NewClient[sdp_go.GetHcpConfigRequest, sdp_go.GetHcpConfigResponse]( httpClient, baseURL+ConfigurationServiceGetHcpConfigProcedure, connect.WithSchema(configurationServiceMethods.ByName("GetHcpConfig")), connect.WithClientOptions(opts...), ), deleteHcpConfig: connect.NewClient[sdp_go.DeleteHcpConfigRequest, sdp_go.DeleteHcpConfigResponse]( httpClient, baseURL+ConfigurationServiceDeleteHcpConfigProcedure, connect.WithSchema(configurationServiceMethods.ByName("DeleteHcpConfig")), connect.WithClientOptions(opts...), ), replaceHcpApiKey: connect.NewClient[sdp_go.ReplaceHcpApiKeyRequest, sdp_go.ReplaceHcpApiKeyResponse]( httpClient, baseURL+ConfigurationServiceReplaceHcpApiKeyProcedure, connect.WithSchema(configurationServiceMethods.ByName("ReplaceHcpApiKey")), connect.WithClientOptions(opts...), ), getSignalConfig: connect.NewClient[sdp_go.GetSignalConfigRequest, sdp_go.GetSignalConfigResponse]( httpClient, baseURL+ConfigurationServiceGetSignalConfigProcedure, connect.WithSchema(configurationServiceMethods.ByName("GetSignalConfig")), connect.WithClientOptions(opts...), ), updateSignalConfig: connect.NewClient[sdp_go.UpdateSignalConfigRequest, sdp_go.UpdateSignalConfigResponse]( httpClient, baseURL+ConfigurationServiceUpdateSignalConfigProcedure, connect.WithSchema(configurationServiceMethods.ByName("UpdateSignalConfig")), connect.WithClientOptions(opts...), ), getGithubAppInformation: connect.NewClient[sdp_go.GetGithubAppInformationRequest, sdp_go.GetGithubAppInformationResponse]( httpClient, baseURL+ConfigurationServiceGetGithubAppInformationProcedure, connect.WithSchema(configurationServiceMethods.ByName("GetGithubAppInformation")), connect.WithClientOptions(opts...), ), regenerateGithubAppProfile: connect.NewClient[sdp_go.RegenerateGithubAppProfileRequest, sdp_go.RegenerateGithubAppProfileResponse]( httpClient, baseURL+ConfigurationServiceRegenerateGithubAppProfileProcedure, connect.WithSchema(configurationServiceMethods.ByName("RegenerateGithubAppProfile")), connect.WithClientOptions(opts...), ), createGithubInstallURL: connect.NewClient[sdp_go.CreateGithubInstallURLRequest, sdp_go.CreateGithubInstallURLResponse]( httpClient, baseURL+ConfigurationServiceCreateGithubInstallURLProcedure, connect.WithSchema(configurationServiceMethods.ByName("CreateGithubInstallURL")), connect.WithClientOptions(opts...), ), } } // configurationServiceClient implements ConfigurationServiceClient. type configurationServiceClient struct { getAccountConfig *connect.Client[sdp_go.GetAccountConfigRequest, sdp_go.GetAccountConfigResponse] updateAccountConfig *connect.Client[sdp_go.UpdateAccountConfigRequest, sdp_go.UpdateAccountConfigResponse] createHcpConfig *connect.Client[sdp_go.CreateHcpConfigRequest, sdp_go.CreateHcpConfigResponse] getHcpConfig *connect.Client[sdp_go.GetHcpConfigRequest, sdp_go.GetHcpConfigResponse] deleteHcpConfig *connect.Client[sdp_go.DeleteHcpConfigRequest, sdp_go.DeleteHcpConfigResponse] replaceHcpApiKey *connect.Client[sdp_go.ReplaceHcpApiKeyRequest, sdp_go.ReplaceHcpApiKeyResponse] getSignalConfig *connect.Client[sdp_go.GetSignalConfigRequest, sdp_go.GetSignalConfigResponse] updateSignalConfig *connect.Client[sdp_go.UpdateSignalConfigRequest, sdp_go.UpdateSignalConfigResponse] getGithubAppInformation *connect.Client[sdp_go.GetGithubAppInformationRequest, sdp_go.GetGithubAppInformationResponse] regenerateGithubAppProfile *connect.Client[sdp_go.RegenerateGithubAppProfileRequest, sdp_go.RegenerateGithubAppProfileResponse] createGithubInstallURL *connect.Client[sdp_go.CreateGithubInstallURLRequest, sdp_go.CreateGithubInstallURLResponse] } // GetAccountConfig calls config.ConfigurationService.GetAccountConfig. func (c *configurationServiceClient) GetAccountConfig(ctx context.Context, req *connect.Request[sdp_go.GetAccountConfigRequest]) (*connect.Response[sdp_go.GetAccountConfigResponse], error) { return c.getAccountConfig.CallUnary(ctx, req) } // UpdateAccountConfig calls config.ConfigurationService.UpdateAccountConfig. func (c *configurationServiceClient) UpdateAccountConfig(ctx context.Context, req *connect.Request[sdp_go.UpdateAccountConfigRequest]) (*connect.Response[sdp_go.UpdateAccountConfigResponse], error) { return c.updateAccountConfig.CallUnary(ctx, req) } // CreateHcpConfig calls config.ConfigurationService.CreateHcpConfig. func (c *configurationServiceClient) CreateHcpConfig(ctx context.Context, req *connect.Request[sdp_go.CreateHcpConfigRequest]) (*connect.Response[sdp_go.CreateHcpConfigResponse], error) { return c.createHcpConfig.CallUnary(ctx, req) } // GetHcpConfig calls config.ConfigurationService.GetHcpConfig. func (c *configurationServiceClient) GetHcpConfig(ctx context.Context, req *connect.Request[sdp_go.GetHcpConfigRequest]) (*connect.Response[sdp_go.GetHcpConfigResponse], error) { return c.getHcpConfig.CallUnary(ctx, req) } // DeleteHcpConfig calls config.ConfigurationService.DeleteHcpConfig. func (c *configurationServiceClient) DeleteHcpConfig(ctx context.Context, req *connect.Request[sdp_go.DeleteHcpConfigRequest]) (*connect.Response[sdp_go.DeleteHcpConfigResponse], error) { return c.deleteHcpConfig.CallUnary(ctx, req) } // ReplaceHcpApiKey calls config.ConfigurationService.ReplaceHcpApiKey. func (c *configurationServiceClient) ReplaceHcpApiKey(ctx context.Context, req *connect.Request[sdp_go.ReplaceHcpApiKeyRequest]) (*connect.Response[sdp_go.ReplaceHcpApiKeyResponse], error) { return c.replaceHcpApiKey.CallUnary(ctx, req) } // GetSignalConfig calls config.ConfigurationService.GetSignalConfig. func (c *configurationServiceClient) GetSignalConfig(ctx context.Context, req *connect.Request[sdp_go.GetSignalConfigRequest]) (*connect.Response[sdp_go.GetSignalConfigResponse], error) { return c.getSignalConfig.CallUnary(ctx, req) } // UpdateSignalConfig calls config.ConfigurationService.UpdateSignalConfig. func (c *configurationServiceClient) UpdateSignalConfig(ctx context.Context, req *connect.Request[sdp_go.UpdateSignalConfigRequest]) (*connect.Response[sdp_go.UpdateSignalConfigResponse], error) { return c.updateSignalConfig.CallUnary(ctx, req) } // GetGithubAppInformation calls config.ConfigurationService.GetGithubAppInformation. func (c *configurationServiceClient) GetGithubAppInformation(ctx context.Context, req *connect.Request[sdp_go.GetGithubAppInformationRequest]) (*connect.Response[sdp_go.GetGithubAppInformationResponse], error) { return c.getGithubAppInformation.CallUnary(ctx, req) } // RegenerateGithubAppProfile calls config.ConfigurationService.RegenerateGithubAppProfile. func (c *configurationServiceClient) RegenerateGithubAppProfile(ctx context.Context, req *connect.Request[sdp_go.RegenerateGithubAppProfileRequest]) (*connect.Response[sdp_go.RegenerateGithubAppProfileResponse], error) { return c.regenerateGithubAppProfile.CallUnary(ctx, req) } // CreateGithubInstallURL calls config.ConfigurationService.CreateGithubInstallURL. func (c *configurationServiceClient) CreateGithubInstallURL(ctx context.Context, req *connect.Request[sdp_go.CreateGithubInstallURLRequest]) (*connect.Response[sdp_go.CreateGithubInstallURLResponse], error) { return c.createGithubInstallURL.CallUnary(ctx, req) } // ConfigurationServiceHandler is an implementation of the config.ConfigurationService service. type ConfigurationServiceHandler interface { // Get the account config for the user's account GetAccountConfig(context.Context, *connect.Request[sdp_go.GetAccountConfigRequest]) (*connect.Response[sdp_go.GetAccountConfigResponse], error) // Update the account config for the user's account UpdateAccountConfig(context.Context, *connect.Request[sdp_go.UpdateAccountConfigRequest]) (*connect.Response[sdp_go.UpdateAccountConfigResponse], error) // Create a new HCP Terraform config for the user's account. This follows // the same flow as CreateAPIKey, to create a new API key that is then used // for the HCP Terraform endpoint URL. CreateHcpConfig(context.Context, *connect.Request[sdp_go.CreateHcpConfigRequest]) (*connect.Response[sdp_go.CreateHcpConfigResponse], error) // Get the existing HCP Terraform config for the user's account. GetHcpConfig(context.Context, *connect.Request[sdp_go.GetHcpConfigRequest]) (*connect.Response[sdp_go.GetHcpConfigResponse], error) // Remove the existing HCP Terraform config from the user's account. DeleteHcpConfig(context.Context, *connect.Request[sdp_go.DeleteHcpConfigRequest]) (*connect.Response[sdp_go.DeleteHcpConfigResponse], error) // Replace the API key backing the HCP Terraform integration with a fresh // one. The old API key is revoked. The endpoint URL and HMAC secret are // preserved. Follows the same OAuth flow as CreateHcpConfig. ReplaceHcpApiKey(context.Context, *connect.Request[sdp_go.ReplaceHcpApiKeyRequest]) (*connect.Response[sdp_go.ReplaceHcpApiKeyResponse], error) // Get the signal config for the account GetSignalConfig(context.Context, *connect.Request[sdp_go.GetSignalConfigRequest]) (*connect.Response[sdp_go.GetSignalConfigResponse], error) // Update the signal config for the account UpdateSignalConfig(context.Context, *connect.Request[sdp_go.UpdateSignalConfigRequest]) (*connect.Response[sdp_go.UpdateSignalConfigResponse], error) // Github app // we will be displaying app installation information for this account on the github integrations page GetGithubAppInformation(context.Context, *connect.Request[sdp_go.GetGithubAppInformationRequest]) (*connect.Response[sdp_go.GetGithubAppInformationResponse], error) // regenerate the github app profile, this information is used for signal processing RegenerateGithubAppProfile(context.Context, *connect.Request[sdp_go.RegenerateGithubAppProfileRequest]) (*connect.Response[sdp_go.RegenerateGithubAppProfileResponse], error) // Create a GitHub App install URL with a DB-backed state parameter for CSRF // protection. The frontend calls this RPC, then redirects the user to the // returned URL. GitHub will redirect back with the state UUID, which the // callback handler consumes to identify the Overmind account. CreateGithubInstallURL(context.Context, *connect.Request[sdp_go.CreateGithubInstallURLRequest]) (*connect.Response[sdp_go.CreateGithubInstallURLResponse], error) } // NewConfigurationServiceHandler builds an HTTP handler from the service implementation. It returns // the path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewConfigurationServiceHandler(svc ConfigurationServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { configurationServiceMethods := sdp_go.File_config_proto.Services().ByName("ConfigurationService").Methods() configurationServiceGetAccountConfigHandler := connect.NewUnaryHandler( ConfigurationServiceGetAccountConfigProcedure, svc.GetAccountConfig, connect.WithSchema(configurationServiceMethods.ByName("GetAccountConfig")), connect.WithHandlerOptions(opts...), ) configurationServiceUpdateAccountConfigHandler := connect.NewUnaryHandler( ConfigurationServiceUpdateAccountConfigProcedure, svc.UpdateAccountConfig, connect.WithSchema(configurationServiceMethods.ByName("UpdateAccountConfig")), connect.WithHandlerOptions(opts...), ) configurationServiceCreateHcpConfigHandler := connect.NewUnaryHandler( ConfigurationServiceCreateHcpConfigProcedure, svc.CreateHcpConfig, connect.WithSchema(configurationServiceMethods.ByName("CreateHcpConfig")), connect.WithHandlerOptions(opts...), ) configurationServiceGetHcpConfigHandler := connect.NewUnaryHandler( ConfigurationServiceGetHcpConfigProcedure, svc.GetHcpConfig, connect.WithSchema(configurationServiceMethods.ByName("GetHcpConfig")), connect.WithHandlerOptions(opts...), ) configurationServiceDeleteHcpConfigHandler := connect.NewUnaryHandler( ConfigurationServiceDeleteHcpConfigProcedure, svc.DeleteHcpConfig, connect.WithSchema(configurationServiceMethods.ByName("DeleteHcpConfig")), connect.WithHandlerOptions(opts...), ) configurationServiceReplaceHcpApiKeyHandler := connect.NewUnaryHandler( ConfigurationServiceReplaceHcpApiKeyProcedure, svc.ReplaceHcpApiKey, connect.WithSchema(configurationServiceMethods.ByName("ReplaceHcpApiKey")), connect.WithHandlerOptions(opts...), ) configurationServiceGetSignalConfigHandler := connect.NewUnaryHandler( ConfigurationServiceGetSignalConfigProcedure, svc.GetSignalConfig, connect.WithSchema(configurationServiceMethods.ByName("GetSignalConfig")), connect.WithHandlerOptions(opts...), ) configurationServiceUpdateSignalConfigHandler := connect.NewUnaryHandler( ConfigurationServiceUpdateSignalConfigProcedure, svc.UpdateSignalConfig, connect.WithSchema(configurationServiceMethods.ByName("UpdateSignalConfig")), connect.WithHandlerOptions(opts...), ) configurationServiceGetGithubAppInformationHandler := connect.NewUnaryHandler( ConfigurationServiceGetGithubAppInformationProcedure, svc.GetGithubAppInformation, connect.WithSchema(configurationServiceMethods.ByName("GetGithubAppInformation")), connect.WithHandlerOptions(opts...), ) configurationServiceRegenerateGithubAppProfileHandler := connect.NewUnaryHandler( ConfigurationServiceRegenerateGithubAppProfileProcedure, svc.RegenerateGithubAppProfile, connect.WithSchema(configurationServiceMethods.ByName("RegenerateGithubAppProfile")), connect.WithHandlerOptions(opts...), ) configurationServiceCreateGithubInstallURLHandler := connect.NewUnaryHandler( ConfigurationServiceCreateGithubInstallURLProcedure, svc.CreateGithubInstallURL, connect.WithSchema(configurationServiceMethods.ByName("CreateGithubInstallURL")), connect.WithHandlerOptions(opts...), ) return "/config.ConfigurationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ConfigurationServiceGetAccountConfigProcedure: configurationServiceGetAccountConfigHandler.ServeHTTP(w, r) case ConfigurationServiceUpdateAccountConfigProcedure: configurationServiceUpdateAccountConfigHandler.ServeHTTP(w, r) case ConfigurationServiceCreateHcpConfigProcedure: configurationServiceCreateHcpConfigHandler.ServeHTTP(w, r) case ConfigurationServiceGetHcpConfigProcedure: configurationServiceGetHcpConfigHandler.ServeHTTP(w, r) case ConfigurationServiceDeleteHcpConfigProcedure: configurationServiceDeleteHcpConfigHandler.ServeHTTP(w, r) case ConfigurationServiceReplaceHcpApiKeyProcedure: configurationServiceReplaceHcpApiKeyHandler.ServeHTTP(w, r) case ConfigurationServiceGetSignalConfigProcedure: configurationServiceGetSignalConfigHandler.ServeHTTP(w, r) case ConfigurationServiceUpdateSignalConfigProcedure: configurationServiceUpdateSignalConfigHandler.ServeHTTP(w, r) case ConfigurationServiceGetGithubAppInformationProcedure: configurationServiceGetGithubAppInformationHandler.ServeHTTP(w, r) case ConfigurationServiceRegenerateGithubAppProfileProcedure: configurationServiceRegenerateGithubAppProfileHandler.ServeHTTP(w, r) case ConfigurationServiceCreateGithubInstallURLProcedure: configurationServiceCreateGithubInstallURLHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedConfigurationServiceHandler returns CodeUnimplemented from all methods. type UnimplementedConfigurationServiceHandler struct{} func (UnimplementedConfigurationServiceHandler) GetAccountConfig(context.Context, *connect.Request[sdp_go.GetAccountConfigRequest]) (*connect.Response[sdp_go.GetAccountConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.GetAccountConfig is not implemented")) } func (UnimplementedConfigurationServiceHandler) UpdateAccountConfig(context.Context, *connect.Request[sdp_go.UpdateAccountConfigRequest]) (*connect.Response[sdp_go.UpdateAccountConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.UpdateAccountConfig is not implemented")) } func (UnimplementedConfigurationServiceHandler) CreateHcpConfig(context.Context, *connect.Request[sdp_go.CreateHcpConfigRequest]) (*connect.Response[sdp_go.CreateHcpConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.CreateHcpConfig is not implemented")) } func (UnimplementedConfigurationServiceHandler) GetHcpConfig(context.Context, *connect.Request[sdp_go.GetHcpConfigRequest]) (*connect.Response[sdp_go.GetHcpConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.GetHcpConfig is not implemented")) } func (UnimplementedConfigurationServiceHandler) DeleteHcpConfig(context.Context, *connect.Request[sdp_go.DeleteHcpConfigRequest]) (*connect.Response[sdp_go.DeleteHcpConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.DeleteHcpConfig is not implemented")) } func (UnimplementedConfigurationServiceHandler) ReplaceHcpApiKey(context.Context, *connect.Request[sdp_go.ReplaceHcpApiKeyRequest]) (*connect.Response[sdp_go.ReplaceHcpApiKeyResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.ReplaceHcpApiKey is not implemented")) } func (UnimplementedConfigurationServiceHandler) GetSignalConfig(context.Context, *connect.Request[sdp_go.GetSignalConfigRequest]) (*connect.Response[sdp_go.GetSignalConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.GetSignalConfig is not implemented")) } func (UnimplementedConfigurationServiceHandler) UpdateSignalConfig(context.Context, *connect.Request[sdp_go.UpdateSignalConfigRequest]) (*connect.Response[sdp_go.UpdateSignalConfigResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.UpdateSignalConfig is not implemented")) } func (UnimplementedConfigurationServiceHandler) GetGithubAppInformation(context.Context, *connect.Request[sdp_go.GetGithubAppInformationRequest]) (*connect.Response[sdp_go.GetGithubAppInformationResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.GetGithubAppInformation is not implemented")) } func (UnimplementedConfigurationServiceHandler) RegenerateGithubAppProfile(context.Context, *connect.Request[sdp_go.RegenerateGithubAppProfileRequest]) (*connect.Response[sdp_go.RegenerateGithubAppProfileResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.RegenerateGithubAppProfile is not implemented")) } func (UnimplementedConfigurationServiceHandler) CreateGithubInstallURL(context.Context, *connect.Request[sdp_go.CreateGithubInstallURLRequest]) (*connect.Response[sdp_go.CreateGithubInstallURLResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("config.ConfigurationService.CreateGithubInstallURL is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/invites.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: invites.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // InviteServiceName is the fully-qualified name of the InviteService service. InviteServiceName = "invites.InviteService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // InviteServiceCreateInviteProcedure is the fully-qualified name of the InviteService's // CreateInvite RPC. InviteServiceCreateInviteProcedure = "/invites.InviteService/CreateInvite" // InviteServiceListInvitesProcedure is the fully-qualified name of the InviteService's ListInvites // RPC. InviteServiceListInvitesProcedure = "/invites.InviteService/ListInvites" // InviteServiceRevokeInviteProcedure is the fully-qualified name of the InviteService's // RevokeInvite RPC. InviteServiceRevokeInviteProcedure = "/invites.InviteService/RevokeInvite" // InviteServiceResendInviteProcedure is the fully-qualified name of the InviteService's // ResendInvite RPC. InviteServiceResendInviteProcedure = "/invites.InviteService/ResendInvite" ) // InviteServiceClient is a client for the invites.InviteService service. type InviteServiceClient interface { CreateInvite(context.Context, *connect.Request[sdp_go.CreateInviteRequest]) (*connect.Response[sdp_go.CreateInviteResponse], error) ListInvites(context.Context, *connect.Request[sdp_go.ListInvitesRequest]) (*connect.Response[sdp_go.ListInvitesResponse], error) RevokeInvite(context.Context, *connect.Request[sdp_go.RevokeInviteRequest]) (*connect.Response[sdp_go.RevokeInviteResponse], error) ResendInvite(context.Context, *connect.Request[sdp_go.ResendInviteRequest]) (*connect.Response[sdp_go.ResendInviteResponse], error) } // NewInviteServiceClient constructs a client for the invites.InviteService service. By default, it // uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewInviteServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) InviteServiceClient { baseURL = strings.TrimRight(baseURL, "/") inviteServiceMethods := sdp_go.File_invites_proto.Services().ByName("InviteService").Methods() return &inviteServiceClient{ createInvite: connect.NewClient[sdp_go.CreateInviteRequest, sdp_go.CreateInviteResponse]( httpClient, baseURL+InviteServiceCreateInviteProcedure, connect.WithSchema(inviteServiceMethods.ByName("CreateInvite")), connect.WithClientOptions(opts...), ), listInvites: connect.NewClient[sdp_go.ListInvitesRequest, sdp_go.ListInvitesResponse]( httpClient, baseURL+InviteServiceListInvitesProcedure, connect.WithSchema(inviteServiceMethods.ByName("ListInvites")), connect.WithClientOptions(opts...), ), revokeInvite: connect.NewClient[sdp_go.RevokeInviteRequest, sdp_go.RevokeInviteResponse]( httpClient, baseURL+InviteServiceRevokeInviteProcedure, connect.WithSchema(inviteServiceMethods.ByName("RevokeInvite")), connect.WithClientOptions(opts...), ), resendInvite: connect.NewClient[sdp_go.ResendInviteRequest, sdp_go.ResendInviteResponse]( httpClient, baseURL+InviteServiceResendInviteProcedure, connect.WithSchema(inviteServiceMethods.ByName("ResendInvite")), connect.WithClientOptions(opts...), ), } } // inviteServiceClient implements InviteServiceClient. type inviteServiceClient struct { createInvite *connect.Client[sdp_go.CreateInviteRequest, sdp_go.CreateInviteResponse] listInvites *connect.Client[sdp_go.ListInvitesRequest, sdp_go.ListInvitesResponse] revokeInvite *connect.Client[sdp_go.RevokeInviteRequest, sdp_go.RevokeInviteResponse] resendInvite *connect.Client[sdp_go.ResendInviteRequest, sdp_go.ResendInviteResponse] } // CreateInvite calls invites.InviteService.CreateInvite. func (c *inviteServiceClient) CreateInvite(ctx context.Context, req *connect.Request[sdp_go.CreateInviteRequest]) (*connect.Response[sdp_go.CreateInviteResponse], error) { return c.createInvite.CallUnary(ctx, req) } // ListInvites calls invites.InviteService.ListInvites. func (c *inviteServiceClient) ListInvites(ctx context.Context, req *connect.Request[sdp_go.ListInvitesRequest]) (*connect.Response[sdp_go.ListInvitesResponse], error) { return c.listInvites.CallUnary(ctx, req) } // RevokeInvite calls invites.InviteService.RevokeInvite. func (c *inviteServiceClient) RevokeInvite(ctx context.Context, req *connect.Request[sdp_go.RevokeInviteRequest]) (*connect.Response[sdp_go.RevokeInviteResponse], error) { return c.revokeInvite.CallUnary(ctx, req) } // ResendInvite calls invites.InviteService.ResendInvite. func (c *inviteServiceClient) ResendInvite(ctx context.Context, req *connect.Request[sdp_go.ResendInviteRequest]) (*connect.Response[sdp_go.ResendInviteResponse], error) { return c.resendInvite.CallUnary(ctx, req) } // InviteServiceHandler is an implementation of the invites.InviteService service. type InviteServiceHandler interface { CreateInvite(context.Context, *connect.Request[sdp_go.CreateInviteRequest]) (*connect.Response[sdp_go.CreateInviteResponse], error) ListInvites(context.Context, *connect.Request[sdp_go.ListInvitesRequest]) (*connect.Response[sdp_go.ListInvitesResponse], error) RevokeInvite(context.Context, *connect.Request[sdp_go.RevokeInviteRequest]) (*connect.Response[sdp_go.RevokeInviteResponse], error) ResendInvite(context.Context, *connect.Request[sdp_go.ResendInviteRequest]) (*connect.Response[sdp_go.ResendInviteResponse], error) } // NewInviteServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewInviteServiceHandler(svc InviteServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { inviteServiceMethods := sdp_go.File_invites_proto.Services().ByName("InviteService").Methods() inviteServiceCreateInviteHandler := connect.NewUnaryHandler( InviteServiceCreateInviteProcedure, svc.CreateInvite, connect.WithSchema(inviteServiceMethods.ByName("CreateInvite")), connect.WithHandlerOptions(opts...), ) inviteServiceListInvitesHandler := connect.NewUnaryHandler( InviteServiceListInvitesProcedure, svc.ListInvites, connect.WithSchema(inviteServiceMethods.ByName("ListInvites")), connect.WithHandlerOptions(opts...), ) inviteServiceRevokeInviteHandler := connect.NewUnaryHandler( InviteServiceRevokeInviteProcedure, svc.RevokeInvite, connect.WithSchema(inviteServiceMethods.ByName("RevokeInvite")), connect.WithHandlerOptions(opts...), ) inviteServiceResendInviteHandler := connect.NewUnaryHandler( InviteServiceResendInviteProcedure, svc.ResendInvite, connect.WithSchema(inviteServiceMethods.ByName("ResendInvite")), connect.WithHandlerOptions(opts...), ) return "/invites.InviteService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case InviteServiceCreateInviteProcedure: inviteServiceCreateInviteHandler.ServeHTTP(w, r) case InviteServiceListInvitesProcedure: inviteServiceListInvitesHandler.ServeHTTP(w, r) case InviteServiceRevokeInviteProcedure: inviteServiceRevokeInviteHandler.ServeHTTP(w, r) case InviteServiceResendInviteProcedure: inviteServiceResendInviteHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedInviteServiceHandler returns CodeUnimplemented from all methods. type UnimplementedInviteServiceHandler struct{} func (UnimplementedInviteServiceHandler) CreateInvite(context.Context, *connect.Request[sdp_go.CreateInviteRequest]) (*connect.Response[sdp_go.CreateInviteResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("invites.InviteService.CreateInvite is not implemented")) } func (UnimplementedInviteServiceHandler) ListInvites(context.Context, *connect.Request[sdp_go.ListInvitesRequest]) (*connect.Response[sdp_go.ListInvitesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("invites.InviteService.ListInvites is not implemented")) } func (UnimplementedInviteServiceHandler) RevokeInvite(context.Context, *connect.Request[sdp_go.RevokeInviteRequest]) (*connect.Response[sdp_go.RevokeInviteResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("invites.InviteService.RevokeInvite is not implemented")) } func (UnimplementedInviteServiceHandler) ResendInvite(context.Context, *connect.Request[sdp_go.ResendInviteRequest]) (*connect.Response[sdp_go.ResendInviteResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("invites.InviteService.ResendInvite is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/logs.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: logs.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // LogsServiceName is the fully-qualified name of the LogsService service. LogsServiceName = "logs.LogsService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // LogsServiceGetLogRecordsProcedure is the fully-qualified name of the LogsService's GetLogRecords // RPC. LogsServiceGetLogRecordsProcedure = "/logs.LogsService/GetLogRecords" ) // LogsServiceClient is a client for the logs.LogsService service. type LogsServiceClient interface { // GetLogRecords returns a stream of log records from the upstream API. The // source is expected to use sane defaults within the limits of the // underlying API and SDP capabilities (message size, etc). Each chunk is // roughly a page of the upstream APIs pagination. GetLogRecords(context.Context, *connect.Request[sdp_go.GetLogRecordsRequest]) (*connect.ServerStreamForClient[sdp_go.GetLogRecordsResponse], error) } // NewLogsServiceClient constructs a client for the logs.LogsService service. By default, it uses // the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewLogsServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) LogsServiceClient { baseURL = strings.TrimRight(baseURL, "/") logsServiceMethods := sdp_go.File_logs_proto.Services().ByName("LogsService").Methods() return &logsServiceClient{ getLogRecords: connect.NewClient[sdp_go.GetLogRecordsRequest, sdp_go.GetLogRecordsResponse]( httpClient, baseURL+LogsServiceGetLogRecordsProcedure, connect.WithSchema(logsServiceMethods.ByName("GetLogRecords")), connect.WithClientOptions(opts...), ), } } // logsServiceClient implements LogsServiceClient. type logsServiceClient struct { getLogRecords *connect.Client[sdp_go.GetLogRecordsRequest, sdp_go.GetLogRecordsResponse] } // GetLogRecords calls logs.LogsService.GetLogRecords. func (c *logsServiceClient) GetLogRecords(ctx context.Context, req *connect.Request[sdp_go.GetLogRecordsRequest]) (*connect.ServerStreamForClient[sdp_go.GetLogRecordsResponse], error) { return c.getLogRecords.CallServerStream(ctx, req) } // LogsServiceHandler is an implementation of the logs.LogsService service. type LogsServiceHandler interface { // GetLogRecords returns a stream of log records from the upstream API. The // source is expected to use sane defaults within the limits of the // underlying API and SDP capabilities (message size, etc). Each chunk is // roughly a page of the upstream APIs pagination. GetLogRecords(context.Context, *connect.Request[sdp_go.GetLogRecordsRequest], *connect.ServerStream[sdp_go.GetLogRecordsResponse]) error } // NewLogsServiceHandler builds an HTTP handler from the service implementation. It returns the path // on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewLogsServiceHandler(svc LogsServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { logsServiceMethods := sdp_go.File_logs_proto.Services().ByName("LogsService").Methods() logsServiceGetLogRecordsHandler := connect.NewServerStreamHandler( LogsServiceGetLogRecordsProcedure, svc.GetLogRecords, connect.WithSchema(logsServiceMethods.ByName("GetLogRecords")), connect.WithHandlerOptions(opts...), ) return "/logs.LogsService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case LogsServiceGetLogRecordsProcedure: logsServiceGetLogRecordsHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedLogsServiceHandler returns CodeUnimplemented from all methods. type UnimplementedLogsServiceHandler struct{} func (UnimplementedLogsServiceHandler) GetLogRecords(context.Context, *connect.Request[sdp_go.GetLogRecordsRequest], *connect.ServerStream[sdp_go.GetLogRecordsResponse]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("logs.LogsService.GetLogRecords is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/revlink.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: revlink.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // RevlinkServiceName is the fully-qualified name of the RevlinkService service. RevlinkServiceName = "revlink.RevlinkService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // RevlinkServiceGetReverseEdgesProcedure is the fully-qualified name of the RevlinkService's // GetReverseEdges RPC. RevlinkServiceGetReverseEdgesProcedure = "/revlink.RevlinkService/GetReverseEdges" // RevlinkServiceIngestGatewayResponsesProcedure is the fully-qualified name of the RevlinkService's // IngestGatewayResponses RPC. RevlinkServiceIngestGatewayResponsesProcedure = "/revlink.RevlinkService/IngestGatewayResponses" // RevlinkServiceCheckpointProcedure is the fully-qualified name of the RevlinkService's Checkpoint // RPC. RevlinkServiceCheckpointProcedure = "/revlink.RevlinkService/Checkpoint" ) // RevlinkServiceClient is a client for the revlink.RevlinkService service. type RevlinkServiceClient interface { // Gets reverse edges for a given item GetReverseEdges(context.Context, *connect.Request[sdp_go.GetReverseEdgesRequest]) (*connect.Response[sdp_go.GetReverseEdgesResponse], error) // Ingests a stream of gateway responses IngestGatewayResponses(context.Context) *connect.ClientStreamForClient[sdp_go.IngestGatewayResponseRequest, sdp_go.IngestGatewayResponsesResponse] // Waits until all currently submitted gateway responses are committed to // the database. This is primarily intended for tests to ensure that setup // was completed. // // Note that this does only count the first try of each insertion; retries // are not considered. // // Note2 that this is implemented in memory, so there is no guarantee // that this will work in a distributed environment. Checkpoint(context.Context, *connect.Request[sdp_go.CheckpointRequest]) (*connect.Response[sdp_go.CheckpointResponse], error) } // NewRevlinkServiceClient constructs a client for the revlink.RevlinkService service. By default, // it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and // sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() // or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewRevlinkServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) RevlinkServiceClient { baseURL = strings.TrimRight(baseURL, "/") revlinkServiceMethods := sdp_go.File_revlink_proto.Services().ByName("RevlinkService").Methods() return &revlinkServiceClient{ getReverseEdges: connect.NewClient[sdp_go.GetReverseEdgesRequest, sdp_go.GetReverseEdgesResponse]( httpClient, baseURL+RevlinkServiceGetReverseEdgesProcedure, connect.WithSchema(revlinkServiceMethods.ByName("GetReverseEdges")), connect.WithClientOptions(opts...), ), ingestGatewayResponses: connect.NewClient[sdp_go.IngestGatewayResponseRequest, sdp_go.IngestGatewayResponsesResponse]( httpClient, baseURL+RevlinkServiceIngestGatewayResponsesProcedure, connect.WithSchema(revlinkServiceMethods.ByName("IngestGatewayResponses")), connect.WithClientOptions(opts...), ), checkpoint: connect.NewClient[sdp_go.CheckpointRequest, sdp_go.CheckpointResponse]( httpClient, baseURL+RevlinkServiceCheckpointProcedure, connect.WithSchema(revlinkServiceMethods.ByName("Checkpoint")), connect.WithClientOptions(opts...), ), } } // revlinkServiceClient implements RevlinkServiceClient. type revlinkServiceClient struct { getReverseEdges *connect.Client[sdp_go.GetReverseEdgesRequest, sdp_go.GetReverseEdgesResponse] ingestGatewayResponses *connect.Client[sdp_go.IngestGatewayResponseRequest, sdp_go.IngestGatewayResponsesResponse] checkpoint *connect.Client[sdp_go.CheckpointRequest, sdp_go.CheckpointResponse] } // GetReverseEdges calls revlink.RevlinkService.GetReverseEdges. func (c *revlinkServiceClient) GetReverseEdges(ctx context.Context, req *connect.Request[sdp_go.GetReverseEdgesRequest]) (*connect.Response[sdp_go.GetReverseEdgesResponse], error) { return c.getReverseEdges.CallUnary(ctx, req) } // IngestGatewayResponses calls revlink.RevlinkService.IngestGatewayResponses. func (c *revlinkServiceClient) IngestGatewayResponses(ctx context.Context) *connect.ClientStreamForClient[sdp_go.IngestGatewayResponseRequest, sdp_go.IngestGatewayResponsesResponse] { return c.ingestGatewayResponses.CallClientStream(ctx) } // Checkpoint calls revlink.RevlinkService.Checkpoint. func (c *revlinkServiceClient) Checkpoint(ctx context.Context, req *connect.Request[sdp_go.CheckpointRequest]) (*connect.Response[sdp_go.CheckpointResponse], error) { return c.checkpoint.CallUnary(ctx, req) } // RevlinkServiceHandler is an implementation of the revlink.RevlinkService service. type RevlinkServiceHandler interface { // Gets reverse edges for a given item GetReverseEdges(context.Context, *connect.Request[sdp_go.GetReverseEdgesRequest]) (*connect.Response[sdp_go.GetReverseEdgesResponse], error) // Ingests a stream of gateway responses IngestGatewayResponses(context.Context, *connect.ClientStream[sdp_go.IngestGatewayResponseRequest]) (*connect.Response[sdp_go.IngestGatewayResponsesResponse], error) // Waits until all currently submitted gateway responses are committed to // the database. This is primarily intended for tests to ensure that setup // was completed. // // Note that this does only count the first try of each insertion; retries // are not considered. // // Note2 that this is implemented in memory, so there is no guarantee // that this will work in a distributed environment. Checkpoint(context.Context, *connect.Request[sdp_go.CheckpointRequest]) (*connect.Response[sdp_go.CheckpointResponse], error) } // NewRevlinkServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewRevlinkServiceHandler(svc RevlinkServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { revlinkServiceMethods := sdp_go.File_revlink_proto.Services().ByName("RevlinkService").Methods() revlinkServiceGetReverseEdgesHandler := connect.NewUnaryHandler( RevlinkServiceGetReverseEdgesProcedure, svc.GetReverseEdges, connect.WithSchema(revlinkServiceMethods.ByName("GetReverseEdges")), connect.WithHandlerOptions(opts...), ) revlinkServiceIngestGatewayResponsesHandler := connect.NewClientStreamHandler( RevlinkServiceIngestGatewayResponsesProcedure, svc.IngestGatewayResponses, connect.WithSchema(revlinkServiceMethods.ByName("IngestGatewayResponses")), connect.WithHandlerOptions(opts...), ) revlinkServiceCheckpointHandler := connect.NewUnaryHandler( RevlinkServiceCheckpointProcedure, svc.Checkpoint, connect.WithSchema(revlinkServiceMethods.ByName("Checkpoint")), connect.WithHandlerOptions(opts...), ) return "/revlink.RevlinkService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case RevlinkServiceGetReverseEdgesProcedure: revlinkServiceGetReverseEdgesHandler.ServeHTTP(w, r) case RevlinkServiceIngestGatewayResponsesProcedure: revlinkServiceIngestGatewayResponsesHandler.ServeHTTP(w, r) case RevlinkServiceCheckpointProcedure: revlinkServiceCheckpointHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedRevlinkServiceHandler returns CodeUnimplemented from all methods. type UnimplementedRevlinkServiceHandler struct{} func (UnimplementedRevlinkServiceHandler) GetReverseEdges(context.Context, *connect.Request[sdp_go.GetReverseEdgesRequest]) (*connect.Response[sdp_go.GetReverseEdgesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("revlink.RevlinkService.GetReverseEdges is not implemented")) } func (UnimplementedRevlinkServiceHandler) IngestGatewayResponses(context.Context, *connect.ClientStream[sdp_go.IngestGatewayResponseRequest]) (*connect.Response[sdp_go.IngestGatewayResponsesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("revlink.RevlinkService.IngestGatewayResponses is not implemented")) } func (UnimplementedRevlinkServiceHandler) Checkpoint(context.Context, *connect.Request[sdp_go.CheckpointRequest]) (*connect.Response[sdp_go.CheckpointResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("revlink.RevlinkService.Checkpoint is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/signal.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: signal.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // SignalServiceName is the fully-qualified name of the SignalService service. SignalServiceName = "signal.SignalService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // SignalServiceAddSignalProcedure is the fully-qualified name of the SignalService's AddSignal RPC. SignalServiceAddSignalProcedure = "/signal.SignalService/AddSignal" // SignalServiceGetSignalsByChangeExternalIDProcedure is the fully-qualified name of the // SignalService's GetSignalsByChangeExternalID RPC. SignalServiceGetSignalsByChangeExternalIDProcedure = "/signal.SignalService/GetSignalsByChangeExternalID" // SignalServiceGetChangeOverviewSignalsProcedure is the fully-qualified name of the SignalService's // GetChangeOverviewSignals RPC. SignalServiceGetChangeOverviewSignalsProcedure = "/signal.SignalService/GetChangeOverviewSignals" // SignalServiceGetItemSignalsProcedure is the fully-qualified name of the SignalService's // GetItemSignals RPC. SignalServiceGetItemSignalsProcedure = "/signal.SignalService/GetItemSignals" // SignalServiceGetItemSignalsV2Procedure is the fully-qualified name of the SignalService's // GetItemSignalsV2 RPC. SignalServiceGetItemSignalsV2Procedure = "/signal.SignalService/GetItemSignalsV2" // SignalServiceGetCustomSignalsByCategoryProcedure is the fully-qualified name of the // SignalService's GetCustomSignalsByCategory RPC. SignalServiceGetCustomSignalsByCategoryProcedure = "/signal.SignalService/GetCustomSignalsByCategory" // SignalServiceGetItemSignalDetailsProcedure is the fully-qualified name of the SignalService's // GetItemSignalDetails RPC. SignalServiceGetItemSignalDetailsProcedure = "/signal.SignalService/GetItemSignalDetails" ) // SignalServiceClient is a client for the signal.SignalService service. type SignalServiceClient interface { // This is an external API to add a signals to a change. // It will be used by the CLI, the web UI, and other clients. // It expects the user to provide the properties of the signal, such as name, value, description, and category. // And it returns the signal that was added, including the machine-generated metadata such as UUID and aggregation ID. // DOES THIS NEED TO BE UPDATED TO SPECIFY THE LEVEL OF THE SIGNAL? AddSignal(context.Context, *connect.Request[sdp_go.AddSignalRequest]) (*connect.Response[sdp_go.AddSignalResponse], error) // This is an API to get all signals associated with a change by its external ID. It is not used by the frontend. // It returns a slice/array of Signals. Which includes both the user-facing properties of the signal, and the machine-generated metadata. // Look at the Signal message for more details. GetSignalsByChangeExternalID(context.Context, *connect.Request[sdp_go.GetSignalsByChangeExternalIDRequest]) (*connect.Response[sdp_go.GetSignalsByChangeExternalIDResponse], error) // NB for the following we do not need to specify the icons for the Items, as they are derived by the Frontend separately. // Get all top-level signals for a change. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetChangeOverviewSignals(context.Context, *connect.Request[sdp_go.GetChangeOverviewSignalsRequest]) (*connect.Response[sdp_go.GetChangeOverviewSignalsResponse], error) // Get item-level signals for all items in a change. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetItemSignals(context.Context, *connect.Request[sdp_go.GetItemSignalsRequest]) (*connect.Response[sdp_go.GetItemSignalsResponse], error) // Get a slice of items, sorted by their aggregate signal value, ascending. // for each item include // - an aggregated value for the item, calculated by AggregateSignalScores. // - a friendly item ref, also before and after // - a slice of signals for the item, sorted by the signal value, ascending. // - the status of the item, e.g. "added", "modified", "deleted". GetItemSignalsV2(context.Context, *connect.Request[sdp_go.GetItemSignalsRequestV2]) (*connect.Response[sdp_go.GetItemSignalsResponseV2], error) // Get all custom signals for a change by its external ID and category. They are NOT associated with any item. // There is no score aggregation or description aggregation at this level. They are aggregated at the GetChangeOverviewSignals level. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetCustomSignalsByCategory(context.Context, *connect.Request[sdp_go.GetCustomSignalsByCategoryRequest]) (*connect.Response[sdp_go.GetCustomSignalsByCategoryResponse], error) // Get all signals for attributes/modifications of an item. This will only be used for routineness to start with. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetItemSignalDetails(context.Context, *connect.Request[sdp_go.GetItemSignalDetailsRequest]) (*connect.Response[sdp_go.GetItemSignalDetailsResponse], error) } // NewSignalServiceClient constructs a client for the signal.SignalService service. By default, it // uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends // uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or // connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewSignalServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SignalServiceClient { baseURL = strings.TrimRight(baseURL, "/") signalServiceMethods := sdp_go.File_signal_proto.Services().ByName("SignalService").Methods() return &signalServiceClient{ addSignal: connect.NewClient[sdp_go.AddSignalRequest, sdp_go.AddSignalResponse]( httpClient, baseURL+SignalServiceAddSignalProcedure, connect.WithSchema(signalServiceMethods.ByName("AddSignal")), connect.WithClientOptions(opts...), ), getSignalsByChangeExternalID: connect.NewClient[sdp_go.GetSignalsByChangeExternalIDRequest, sdp_go.GetSignalsByChangeExternalIDResponse]( httpClient, baseURL+SignalServiceGetSignalsByChangeExternalIDProcedure, connect.WithSchema(signalServiceMethods.ByName("GetSignalsByChangeExternalID")), connect.WithClientOptions(opts...), ), getChangeOverviewSignals: connect.NewClient[sdp_go.GetChangeOverviewSignalsRequest, sdp_go.GetChangeOverviewSignalsResponse]( httpClient, baseURL+SignalServiceGetChangeOverviewSignalsProcedure, connect.WithSchema(signalServiceMethods.ByName("GetChangeOverviewSignals")), connect.WithClientOptions(opts...), ), getItemSignals: connect.NewClient[sdp_go.GetItemSignalsRequest, sdp_go.GetItemSignalsResponse]( httpClient, baseURL+SignalServiceGetItemSignalsProcedure, connect.WithSchema(signalServiceMethods.ByName("GetItemSignals")), connect.WithClientOptions(opts...), ), getItemSignalsV2: connect.NewClient[sdp_go.GetItemSignalsRequestV2, sdp_go.GetItemSignalsResponseV2]( httpClient, baseURL+SignalServiceGetItemSignalsV2Procedure, connect.WithSchema(signalServiceMethods.ByName("GetItemSignalsV2")), connect.WithClientOptions(opts...), ), getCustomSignalsByCategory: connect.NewClient[sdp_go.GetCustomSignalsByCategoryRequest, sdp_go.GetCustomSignalsByCategoryResponse]( httpClient, baseURL+SignalServiceGetCustomSignalsByCategoryProcedure, connect.WithSchema(signalServiceMethods.ByName("GetCustomSignalsByCategory")), connect.WithClientOptions(opts...), ), getItemSignalDetails: connect.NewClient[sdp_go.GetItemSignalDetailsRequest, sdp_go.GetItemSignalDetailsResponse]( httpClient, baseURL+SignalServiceGetItemSignalDetailsProcedure, connect.WithSchema(signalServiceMethods.ByName("GetItemSignalDetails")), connect.WithClientOptions(opts...), ), } } // signalServiceClient implements SignalServiceClient. type signalServiceClient struct { addSignal *connect.Client[sdp_go.AddSignalRequest, sdp_go.AddSignalResponse] getSignalsByChangeExternalID *connect.Client[sdp_go.GetSignalsByChangeExternalIDRequest, sdp_go.GetSignalsByChangeExternalIDResponse] getChangeOverviewSignals *connect.Client[sdp_go.GetChangeOverviewSignalsRequest, sdp_go.GetChangeOverviewSignalsResponse] getItemSignals *connect.Client[sdp_go.GetItemSignalsRequest, sdp_go.GetItemSignalsResponse] getItemSignalsV2 *connect.Client[sdp_go.GetItemSignalsRequestV2, sdp_go.GetItemSignalsResponseV2] getCustomSignalsByCategory *connect.Client[sdp_go.GetCustomSignalsByCategoryRequest, sdp_go.GetCustomSignalsByCategoryResponse] getItemSignalDetails *connect.Client[sdp_go.GetItemSignalDetailsRequest, sdp_go.GetItemSignalDetailsResponse] } // AddSignal calls signal.SignalService.AddSignal. func (c *signalServiceClient) AddSignal(ctx context.Context, req *connect.Request[sdp_go.AddSignalRequest]) (*connect.Response[sdp_go.AddSignalResponse], error) { return c.addSignal.CallUnary(ctx, req) } // GetSignalsByChangeExternalID calls signal.SignalService.GetSignalsByChangeExternalID. func (c *signalServiceClient) GetSignalsByChangeExternalID(ctx context.Context, req *connect.Request[sdp_go.GetSignalsByChangeExternalIDRequest]) (*connect.Response[sdp_go.GetSignalsByChangeExternalIDResponse], error) { return c.getSignalsByChangeExternalID.CallUnary(ctx, req) } // GetChangeOverviewSignals calls signal.SignalService.GetChangeOverviewSignals. func (c *signalServiceClient) GetChangeOverviewSignals(ctx context.Context, req *connect.Request[sdp_go.GetChangeOverviewSignalsRequest]) (*connect.Response[sdp_go.GetChangeOverviewSignalsResponse], error) { return c.getChangeOverviewSignals.CallUnary(ctx, req) } // GetItemSignals calls signal.SignalService.GetItemSignals. func (c *signalServiceClient) GetItemSignals(ctx context.Context, req *connect.Request[sdp_go.GetItemSignalsRequest]) (*connect.Response[sdp_go.GetItemSignalsResponse], error) { return c.getItemSignals.CallUnary(ctx, req) } // GetItemSignalsV2 calls signal.SignalService.GetItemSignalsV2. func (c *signalServiceClient) GetItemSignalsV2(ctx context.Context, req *connect.Request[sdp_go.GetItemSignalsRequestV2]) (*connect.Response[sdp_go.GetItemSignalsResponseV2], error) { return c.getItemSignalsV2.CallUnary(ctx, req) } // GetCustomSignalsByCategory calls signal.SignalService.GetCustomSignalsByCategory. func (c *signalServiceClient) GetCustomSignalsByCategory(ctx context.Context, req *connect.Request[sdp_go.GetCustomSignalsByCategoryRequest]) (*connect.Response[sdp_go.GetCustomSignalsByCategoryResponse], error) { return c.getCustomSignalsByCategory.CallUnary(ctx, req) } // GetItemSignalDetails calls signal.SignalService.GetItemSignalDetails. func (c *signalServiceClient) GetItemSignalDetails(ctx context.Context, req *connect.Request[sdp_go.GetItemSignalDetailsRequest]) (*connect.Response[sdp_go.GetItemSignalDetailsResponse], error) { return c.getItemSignalDetails.CallUnary(ctx, req) } // SignalServiceHandler is an implementation of the signal.SignalService service. type SignalServiceHandler interface { // This is an external API to add a signals to a change. // It will be used by the CLI, the web UI, and other clients. // It expects the user to provide the properties of the signal, such as name, value, description, and category. // And it returns the signal that was added, including the machine-generated metadata such as UUID and aggregation ID. // DOES THIS NEED TO BE UPDATED TO SPECIFY THE LEVEL OF THE SIGNAL? AddSignal(context.Context, *connect.Request[sdp_go.AddSignalRequest]) (*connect.Response[sdp_go.AddSignalResponse], error) // This is an API to get all signals associated with a change by its external ID. It is not used by the frontend. // It returns a slice/array of Signals. Which includes both the user-facing properties of the signal, and the machine-generated metadata. // Look at the Signal message for more details. GetSignalsByChangeExternalID(context.Context, *connect.Request[sdp_go.GetSignalsByChangeExternalIDRequest]) (*connect.Response[sdp_go.GetSignalsByChangeExternalIDResponse], error) // NB for the following we do not need to specify the icons for the Items, as they are derived by the Frontend separately. // Get all top-level signals for a change. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetChangeOverviewSignals(context.Context, *connect.Request[sdp_go.GetChangeOverviewSignalsRequest]) (*connect.Response[sdp_go.GetChangeOverviewSignalsResponse], error) // Get item-level signals for all items in a change. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetItemSignals(context.Context, *connect.Request[sdp_go.GetItemSignalsRequest]) (*connect.Response[sdp_go.GetItemSignalsResponse], error) // Get a slice of items, sorted by their aggregate signal value, ascending. // for each item include // - an aggregated value for the item, calculated by AggregateSignalScores. // - a friendly item ref, also before and after // - a slice of signals for the item, sorted by the signal value, ascending. // - the status of the item, e.g. "added", "modified", "deleted". GetItemSignalsV2(context.Context, *connect.Request[sdp_go.GetItemSignalsRequestV2]) (*connect.Response[sdp_go.GetItemSignalsResponseV2], error) // Get all custom signals for a change by its external ID and category. They are NOT associated with any item. // There is no score aggregation or description aggregation at this level. They are aggregated at the GetChangeOverviewSignals level. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetCustomSignalsByCategory(context.Context, *connect.Request[sdp_go.GetCustomSignalsByCategoryRequest]) (*connect.Response[sdp_go.GetCustomSignalsByCategoryResponse], error) // Get all signals for attributes/modifications of an item. This will only be used for routineness to start with. // They are sorted by the signal value, ascending. From minus 5 to plus 5. GetItemSignalDetails(context.Context, *connect.Request[sdp_go.GetItemSignalDetailsRequest]) (*connect.Response[sdp_go.GetItemSignalDetailsResponse], error) } // NewSignalServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewSignalServiceHandler(svc SignalServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { signalServiceMethods := sdp_go.File_signal_proto.Services().ByName("SignalService").Methods() signalServiceAddSignalHandler := connect.NewUnaryHandler( SignalServiceAddSignalProcedure, svc.AddSignal, connect.WithSchema(signalServiceMethods.ByName("AddSignal")), connect.WithHandlerOptions(opts...), ) signalServiceGetSignalsByChangeExternalIDHandler := connect.NewUnaryHandler( SignalServiceGetSignalsByChangeExternalIDProcedure, svc.GetSignalsByChangeExternalID, connect.WithSchema(signalServiceMethods.ByName("GetSignalsByChangeExternalID")), connect.WithHandlerOptions(opts...), ) signalServiceGetChangeOverviewSignalsHandler := connect.NewUnaryHandler( SignalServiceGetChangeOverviewSignalsProcedure, svc.GetChangeOverviewSignals, connect.WithSchema(signalServiceMethods.ByName("GetChangeOverviewSignals")), connect.WithHandlerOptions(opts...), ) signalServiceGetItemSignalsHandler := connect.NewUnaryHandler( SignalServiceGetItemSignalsProcedure, svc.GetItemSignals, connect.WithSchema(signalServiceMethods.ByName("GetItemSignals")), connect.WithHandlerOptions(opts...), ) signalServiceGetItemSignalsV2Handler := connect.NewUnaryHandler( SignalServiceGetItemSignalsV2Procedure, svc.GetItemSignalsV2, connect.WithSchema(signalServiceMethods.ByName("GetItemSignalsV2")), connect.WithHandlerOptions(opts...), ) signalServiceGetCustomSignalsByCategoryHandler := connect.NewUnaryHandler( SignalServiceGetCustomSignalsByCategoryProcedure, svc.GetCustomSignalsByCategory, connect.WithSchema(signalServiceMethods.ByName("GetCustomSignalsByCategory")), connect.WithHandlerOptions(opts...), ) signalServiceGetItemSignalDetailsHandler := connect.NewUnaryHandler( SignalServiceGetItemSignalDetailsProcedure, svc.GetItemSignalDetails, connect.WithSchema(signalServiceMethods.ByName("GetItemSignalDetails")), connect.WithHandlerOptions(opts...), ) return "/signal.SignalService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case SignalServiceAddSignalProcedure: signalServiceAddSignalHandler.ServeHTTP(w, r) case SignalServiceGetSignalsByChangeExternalIDProcedure: signalServiceGetSignalsByChangeExternalIDHandler.ServeHTTP(w, r) case SignalServiceGetChangeOverviewSignalsProcedure: signalServiceGetChangeOverviewSignalsHandler.ServeHTTP(w, r) case SignalServiceGetItemSignalsProcedure: signalServiceGetItemSignalsHandler.ServeHTTP(w, r) case SignalServiceGetItemSignalsV2Procedure: signalServiceGetItemSignalsV2Handler.ServeHTTP(w, r) case SignalServiceGetCustomSignalsByCategoryProcedure: signalServiceGetCustomSignalsByCategoryHandler.ServeHTTP(w, r) case SignalServiceGetItemSignalDetailsProcedure: signalServiceGetItemSignalDetailsHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedSignalServiceHandler returns CodeUnimplemented from all methods. type UnimplementedSignalServiceHandler struct{} func (UnimplementedSignalServiceHandler) AddSignal(context.Context, *connect.Request[sdp_go.AddSignalRequest]) (*connect.Response[sdp_go.AddSignalResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("signal.SignalService.AddSignal is not implemented")) } func (UnimplementedSignalServiceHandler) GetSignalsByChangeExternalID(context.Context, *connect.Request[sdp_go.GetSignalsByChangeExternalIDRequest]) (*connect.Response[sdp_go.GetSignalsByChangeExternalIDResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("signal.SignalService.GetSignalsByChangeExternalID is not implemented")) } func (UnimplementedSignalServiceHandler) GetChangeOverviewSignals(context.Context, *connect.Request[sdp_go.GetChangeOverviewSignalsRequest]) (*connect.Response[sdp_go.GetChangeOverviewSignalsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("signal.SignalService.GetChangeOverviewSignals is not implemented")) } func (UnimplementedSignalServiceHandler) GetItemSignals(context.Context, *connect.Request[sdp_go.GetItemSignalsRequest]) (*connect.Response[sdp_go.GetItemSignalsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("signal.SignalService.GetItemSignals is not implemented")) } func (UnimplementedSignalServiceHandler) GetItemSignalsV2(context.Context, *connect.Request[sdp_go.GetItemSignalsRequestV2]) (*connect.Response[sdp_go.GetItemSignalsResponseV2], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("signal.SignalService.GetItemSignalsV2 is not implemented")) } func (UnimplementedSignalServiceHandler) GetCustomSignalsByCategory(context.Context, *connect.Request[sdp_go.GetCustomSignalsByCategoryRequest]) (*connect.Response[sdp_go.GetCustomSignalsByCategoryResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("signal.SignalService.GetCustomSignalsByCategory is not implemented")) } func (UnimplementedSignalServiceHandler) GetItemSignalDetails(context.Context, *connect.Request[sdp_go.GetItemSignalDetailsRequest]) (*connect.Response[sdp_go.GetItemSignalDetailsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("signal.SignalService.GetItemSignalDetails is not implemented")) } ================================================ FILE: go/sdp-go/sdpconnect/snapshots.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: snapshots.proto package sdpconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" sdp_go "github.com/overmindtech/cli/go/sdp-go" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // SnapshotsServiceName is the fully-qualified name of the SnapshotsService service. SnapshotsServiceName = "snapshots.SnapshotsService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // SnapshotsServiceListSnapshotsProcedure is the fully-qualified name of the SnapshotsService's // ListSnapshots RPC. SnapshotsServiceListSnapshotsProcedure = "/snapshots.SnapshotsService/ListSnapshots" // SnapshotsServiceCreateSnapshotProcedure is the fully-qualified name of the SnapshotsService's // CreateSnapshot RPC. SnapshotsServiceCreateSnapshotProcedure = "/snapshots.SnapshotsService/CreateSnapshot" // SnapshotsServiceGetSnapshotProcedure is the fully-qualified name of the SnapshotsService's // GetSnapshot RPC. SnapshotsServiceGetSnapshotProcedure = "/snapshots.SnapshotsService/GetSnapshot" // SnapshotsServiceUpdateSnapshotProcedure is the fully-qualified name of the SnapshotsService's // UpdateSnapshot RPC. SnapshotsServiceUpdateSnapshotProcedure = "/snapshots.SnapshotsService/UpdateSnapshot" // SnapshotsServiceDeleteSnapshotProcedure is the fully-qualified name of the SnapshotsService's // DeleteSnapshot RPC. SnapshotsServiceDeleteSnapshotProcedure = "/snapshots.SnapshotsService/DeleteSnapshot" // SnapshotsServiceListSnapshotByGUNProcedure is the fully-qualified name of the SnapshotsService's // ListSnapshotByGUN RPC. SnapshotsServiceListSnapshotByGUNProcedure = "/snapshots.SnapshotsService/ListSnapshotByGUN" ) // SnapshotsServiceClient is a client for the snapshots.SnapshotsService service. type SnapshotsServiceClient interface { ListSnapshots(context.Context, *connect.Request[sdp_go.ListSnapshotsRequest]) (*connect.Response[sdp_go.ListSnapshotResponse], error) CreateSnapshot(context.Context, *connect.Request[sdp_go.CreateSnapshotRequest]) (*connect.Response[sdp_go.CreateSnapshotResponse], error) GetSnapshot(context.Context, *connect.Request[sdp_go.GetSnapshotRequest]) (*connect.Response[sdp_go.GetSnapshotResponse], error) UpdateSnapshot(context.Context, *connect.Request[sdp_go.UpdateSnapshotRequest]) (*connect.Response[sdp_go.UpdateSnapshotResponse], error) DeleteSnapshot(context.Context, *connect.Request[sdp_go.DeleteSnapshotRequest]) (*connect.Response[sdp_go.DeleteSnapshotResponse], error) ListSnapshotByGUN(context.Context, *connect.Request[sdp_go.ListSnapshotsByGUNRequest]) (*connect.Response[sdp_go.ListSnapshotsByGUNResponse], error) } // NewSnapshotsServiceClient constructs a client for the snapshots.SnapshotsService service. By // default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, // and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the // connect.WithGRPC() or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewSnapshotsServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SnapshotsServiceClient { baseURL = strings.TrimRight(baseURL, "/") snapshotsServiceMethods := sdp_go.File_snapshots_proto.Services().ByName("SnapshotsService").Methods() return &snapshotsServiceClient{ listSnapshots: connect.NewClient[sdp_go.ListSnapshotsRequest, sdp_go.ListSnapshotResponse]( httpClient, baseURL+SnapshotsServiceListSnapshotsProcedure, connect.WithSchema(snapshotsServiceMethods.ByName("ListSnapshots")), connect.WithClientOptions(opts...), ), createSnapshot: connect.NewClient[sdp_go.CreateSnapshotRequest, sdp_go.CreateSnapshotResponse]( httpClient, baseURL+SnapshotsServiceCreateSnapshotProcedure, connect.WithSchema(snapshotsServiceMethods.ByName("CreateSnapshot")), connect.WithClientOptions(opts...), ), getSnapshot: connect.NewClient[sdp_go.GetSnapshotRequest, sdp_go.GetSnapshotResponse]( httpClient, baseURL+SnapshotsServiceGetSnapshotProcedure, connect.WithSchema(snapshotsServiceMethods.ByName("GetSnapshot")), connect.WithClientOptions(opts...), ), updateSnapshot: connect.NewClient[sdp_go.UpdateSnapshotRequest, sdp_go.UpdateSnapshotResponse]( httpClient, baseURL+SnapshotsServiceUpdateSnapshotProcedure, connect.WithSchema(snapshotsServiceMethods.ByName("UpdateSnapshot")), connect.WithClientOptions(opts...), ), deleteSnapshot: connect.NewClient[sdp_go.DeleteSnapshotRequest, sdp_go.DeleteSnapshotResponse]( httpClient, baseURL+SnapshotsServiceDeleteSnapshotProcedure, connect.WithSchema(snapshotsServiceMethods.ByName("DeleteSnapshot")), connect.WithClientOptions(opts...), ), listSnapshotByGUN: connect.NewClient[sdp_go.ListSnapshotsByGUNRequest, sdp_go.ListSnapshotsByGUNResponse]( httpClient, baseURL+SnapshotsServiceListSnapshotByGUNProcedure, connect.WithSchema(snapshotsServiceMethods.ByName("ListSnapshotByGUN")), connect.WithClientOptions(opts...), ), } } // snapshotsServiceClient implements SnapshotsServiceClient. type snapshotsServiceClient struct { listSnapshots *connect.Client[sdp_go.ListSnapshotsRequest, sdp_go.ListSnapshotResponse] createSnapshot *connect.Client[sdp_go.CreateSnapshotRequest, sdp_go.CreateSnapshotResponse] getSnapshot *connect.Client[sdp_go.GetSnapshotRequest, sdp_go.GetSnapshotResponse] updateSnapshot *connect.Client[sdp_go.UpdateSnapshotRequest, sdp_go.UpdateSnapshotResponse] deleteSnapshot *connect.Client[sdp_go.DeleteSnapshotRequest, sdp_go.DeleteSnapshotResponse] listSnapshotByGUN *connect.Client[sdp_go.ListSnapshotsByGUNRequest, sdp_go.ListSnapshotsByGUNResponse] } // ListSnapshots calls snapshots.SnapshotsService.ListSnapshots. func (c *snapshotsServiceClient) ListSnapshots(ctx context.Context, req *connect.Request[sdp_go.ListSnapshotsRequest]) (*connect.Response[sdp_go.ListSnapshotResponse], error) { return c.listSnapshots.CallUnary(ctx, req) } // CreateSnapshot calls snapshots.SnapshotsService.CreateSnapshot. func (c *snapshotsServiceClient) CreateSnapshot(ctx context.Context, req *connect.Request[sdp_go.CreateSnapshotRequest]) (*connect.Response[sdp_go.CreateSnapshotResponse], error) { return c.createSnapshot.CallUnary(ctx, req) } // GetSnapshot calls snapshots.SnapshotsService.GetSnapshot. func (c *snapshotsServiceClient) GetSnapshot(ctx context.Context, req *connect.Request[sdp_go.GetSnapshotRequest]) (*connect.Response[sdp_go.GetSnapshotResponse], error) { return c.getSnapshot.CallUnary(ctx, req) } // UpdateSnapshot calls snapshots.SnapshotsService.UpdateSnapshot. func (c *snapshotsServiceClient) UpdateSnapshot(ctx context.Context, req *connect.Request[sdp_go.UpdateSnapshotRequest]) (*connect.Response[sdp_go.UpdateSnapshotResponse], error) { return c.updateSnapshot.CallUnary(ctx, req) } // DeleteSnapshot calls snapshots.SnapshotsService.DeleteSnapshot. func (c *snapshotsServiceClient) DeleteSnapshot(ctx context.Context, req *connect.Request[sdp_go.DeleteSnapshotRequest]) (*connect.Response[sdp_go.DeleteSnapshotResponse], error) { return c.deleteSnapshot.CallUnary(ctx, req) } // ListSnapshotByGUN calls snapshots.SnapshotsService.ListSnapshotByGUN. func (c *snapshotsServiceClient) ListSnapshotByGUN(ctx context.Context, req *connect.Request[sdp_go.ListSnapshotsByGUNRequest]) (*connect.Response[sdp_go.ListSnapshotsByGUNResponse], error) { return c.listSnapshotByGUN.CallUnary(ctx, req) } // SnapshotsServiceHandler is an implementation of the snapshots.SnapshotsService service. type SnapshotsServiceHandler interface { ListSnapshots(context.Context, *connect.Request[sdp_go.ListSnapshotsRequest]) (*connect.Response[sdp_go.ListSnapshotResponse], error) CreateSnapshot(context.Context, *connect.Request[sdp_go.CreateSnapshotRequest]) (*connect.Response[sdp_go.CreateSnapshotResponse], error) GetSnapshot(context.Context, *connect.Request[sdp_go.GetSnapshotRequest]) (*connect.Response[sdp_go.GetSnapshotResponse], error) UpdateSnapshot(context.Context, *connect.Request[sdp_go.UpdateSnapshotRequest]) (*connect.Response[sdp_go.UpdateSnapshotResponse], error) DeleteSnapshot(context.Context, *connect.Request[sdp_go.DeleteSnapshotRequest]) (*connect.Response[sdp_go.DeleteSnapshotResponse], error) ListSnapshotByGUN(context.Context, *connect.Request[sdp_go.ListSnapshotsByGUNRequest]) (*connect.Response[sdp_go.ListSnapshotsByGUNResponse], error) } // NewSnapshotsServiceHandler builds an HTTP handler from the service implementation. It returns the // path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewSnapshotsServiceHandler(svc SnapshotsServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { snapshotsServiceMethods := sdp_go.File_snapshots_proto.Services().ByName("SnapshotsService").Methods() snapshotsServiceListSnapshotsHandler := connect.NewUnaryHandler( SnapshotsServiceListSnapshotsProcedure, svc.ListSnapshots, connect.WithSchema(snapshotsServiceMethods.ByName("ListSnapshots")), connect.WithHandlerOptions(opts...), ) snapshotsServiceCreateSnapshotHandler := connect.NewUnaryHandler( SnapshotsServiceCreateSnapshotProcedure, svc.CreateSnapshot, connect.WithSchema(snapshotsServiceMethods.ByName("CreateSnapshot")), connect.WithHandlerOptions(opts...), ) snapshotsServiceGetSnapshotHandler := connect.NewUnaryHandler( SnapshotsServiceGetSnapshotProcedure, svc.GetSnapshot, connect.WithSchema(snapshotsServiceMethods.ByName("GetSnapshot")), connect.WithHandlerOptions(opts...), ) snapshotsServiceUpdateSnapshotHandler := connect.NewUnaryHandler( SnapshotsServiceUpdateSnapshotProcedure, svc.UpdateSnapshot, connect.WithSchema(snapshotsServiceMethods.ByName("UpdateSnapshot")), connect.WithHandlerOptions(opts...), ) snapshotsServiceDeleteSnapshotHandler := connect.NewUnaryHandler( SnapshotsServiceDeleteSnapshotProcedure, svc.DeleteSnapshot, connect.WithSchema(snapshotsServiceMethods.ByName("DeleteSnapshot")), connect.WithHandlerOptions(opts...), ) snapshotsServiceListSnapshotByGUNHandler := connect.NewUnaryHandler( SnapshotsServiceListSnapshotByGUNProcedure, svc.ListSnapshotByGUN, connect.WithSchema(snapshotsServiceMethods.ByName("ListSnapshotByGUN")), connect.WithHandlerOptions(opts...), ) return "/snapshots.SnapshotsService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case SnapshotsServiceListSnapshotsProcedure: snapshotsServiceListSnapshotsHandler.ServeHTTP(w, r) case SnapshotsServiceCreateSnapshotProcedure: snapshotsServiceCreateSnapshotHandler.ServeHTTP(w, r) case SnapshotsServiceGetSnapshotProcedure: snapshotsServiceGetSnapshotHandler.ServeHTTP(w, r) case SnapshotsServiceUpdateSnapshotProcedure: snapshotsServiceUpdateSnapshotHandler.ServeHTTP(w, r) case SnapshotsServiceDeleteSnapshotProcedure: snapshotsServiceDeleteSnapshotHandler.ServeHTTP(w, r) case SnapshotsServiceListSnapshotByGUNProcedure: snapshotsServiceListSnapshotByGUNHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedSnapshotsServiceHandler returns CodeUnimplemented from all methods. type UnimplementedSnapshotsServiceHandler struct{} func (UnimplementedSnapshotsServiceHandler) ListSnapshots(context.Context, *connect.Request[sdp_go.ListSnapshotsRequest]) (*connect.Response[sdp_go.ListSnapshotResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("snapshots.SnapshotsService.ListSnapshots is not implemented")) } func (UnimplementedSnapshotsServiceHandler) CreateSnapshot(context.Context, *connect.Request[sdp_go.CreateSnapshotRequest]) (*connect.Response[sdp_go.CreateSnapshotResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("snapshots.SnapshotsService.CreateSnapshot is not implemented")) } func (UnimplementedSnapshotsServiceHandler) GetSnapshot(context.Context, *connect.Request[sdp_go.GetSnapshotRequest]) (*connect.Response[sdp_go.GetSnapshotResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("snapshots.SnapshotsService.GetSnapshot is not implemented")) } func (UnimplementedSnapshotsServiceHandler) UpdateSnapshot(context.Context, *connect.Request[sdp_go.UpdateSnapshotRequest]) (*connect.Response[sdp_go.UpdateSnapshotResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("snapshots.SnapshotsService.UpdateSnapshot is not implemented")) } func (UnimplementedSnapshotsServiceHandler) DeleteSnapshot(context.Context, *connect.Request[sdp_go.DeleteSnapshotRequest]) (*connect.Response[sdp_go.DeleteSnapshotResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("snapshots.SnapshotsService.DeleteSnapshot is not implemented")) } func (UnimplementedSnapshotsServiceHandler) ListSnapshotByGUN(context.Context, *connect.Request[sdp_go.ListSnapshotsByGUNRequest]) (*connect.Response[sdp_go.ListSnapshotsByGUNResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("snapshots.SnapshotsService.ListSnapshotByGUN is not implemented")) } ================================================ FILE: go/sdp-go/sdpws/client.go ================================================ package sdpws import ( "bytes" "context" "errors" "fmt" "net/http" "slices" "sync" "github.com/coder/websocket" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/proto" ) // Client is the main driver for all interactions with a SDP/Gateway websocket. // // Internally it holds a map of all active requests, which are identified by a // UUID, to multiplex incoming responses to the correct caller. Note that the // request methods block until the response is received, so to send multiple // requests in parallel, call requestor methods in goroutines, e.g. using a conc // Pool: // // ``` // // pool := pool.New().WithContext(ctx).WithCancelOnError().WithFirstError() // pool.Go(func() error { // items, err := client.Query(ctx, q) // if err != nil { // return err // } // // do something with items // } // // ... // pool.Wait() // // ``` // // Alternatively, pass in a GatewayMessageHandler to receive all messages as // they come in and send messages directly using `Send()` and then `Wait()` for // all request IDs. type Client struct { conn *websocket.Conn handler GatewayMessageHandler requestMap map[uuid.UUID]chan *sdp.GatewayResponse requestMapMu sync.RWMutex finishedRequestMap map[uuid.UUID]bool finishedRequestMapCond *sync.Cond finishedRequestMapMu sync.Mutex err error errMu sync.Mutex closed bool closedMu sync.Mutex // receiveCtx is the context for the receive goroutine // receiveCancel cancels the receive context // receiveDone signals when receive has finished receiveCtx context.Context receiveCancel context.CancelFunc receiveDone sync.WaitGroup } // Dial connects to the given URL and returns a new Client. Pass nil as handler // if you do not need per-message callbacks. // // To stop the client, cancel the provided context: // // ``` // ctx, cancel := context.WithCancel(context.Background()) // defer cancel() // client, err := sdpws.Dial(ctx, gatewayUrl, NewAuthenticatedClient(ctx, otelhttp.DefaultClient), nil) // ``` func Dial(ctx context.Context, u string, httpClient *http.Client, handler GatewayMessageHandler) (*Client, error) { return dialImpl(ctx, u, httpClient, handler, true) } // DialBatch connects to the given URL and returns a new Client. Pass nil as // handler if you do not need per-message callbacks. This method is intended for // batch processing and sets up opentelemetry propagation. Otherwise this // equivalent to `Dial()` func DialBatch(ctx context.Context, u string, httpClient *http.Client, handler GatewayMessageHandler) (*Client, error) { return dialImpl(ctx, u, httpClient, handler, false) } func dialImpl(ctx context.Context, u string, httpClient *http.Client, handler GatewayMessageHandler, interactive bool) (*Client, error) { if httpClient == nil { httpClient = tracing.HTTPClient() } options := &websocket.DialOptions{ HTTPClient: httpClient, } if !interactive { options.HTTPHeader = http.Header{ "X-overmind-interactive": []string{"false"}, } } //nolint: bodyclose // github.com/coder/websocket reads the body internally conn, _, err := websocket.Dial(ctx, u, options) if err != nil { return nil, err } // the default, 32kB is too small for cert bundles and rds-db-cluster-parameter-groups conn.SetReadLimit(2 * 1024 * 1024) c := &Client{ conn: conn, handler: handler, requestMap: make(map[uuid.UUID]chan *sdp.GatewayResponse), finishedRequestMap: make(map[uuid.UUID]bool), } c.finishedRequestMapCond = sync.NewCond(&c.finishedRequestMapMu) // Create a dedicated context for receive() that we can cancel independently c.receiveCtx, c.receiveCancel = context.WithCancel(ctx) c.receiveDone.Go(func() { c.receive(c.receiveCtx) }) return c, nil } func (c *Client) receive(ctx context.Context) { defer tracing.LogRecoverToReturn(ctx, "sdpws.Client.receive") for { // Check if context is cancelled before attempting to read // This prevents lock acquisition failures when context is cancelled during Close() if ctx.Err() != nil { // Context is cancelled - exit gracefully without calling abort // This prevents "failed to acquire lock" errors when Close() is called // with a cancelled context. The abort() will be called by Close() itself. return } // Check if client is already closed before attempting to read // This prevents errors when Close() is called from another goroutine if c.Closed() { return } msg := &sdp.GatewayResponse{} typ, r, err := c.conn.Reader(ctx) if err != nil { // If context is cancelled, this is expected during Close() and we should // exit gracefully without calling abort to avoid lock contention. // The abort() will be called by Close() itself. if ctx.Err() != nil { // Context cancelled - exit gracefully // Don't call abort() here as it may already be closing, which could // cause "failed to acquire lock" errors return } // Check if this is a normal closure from the remote side var ce websocket.CloseError if errors.As(err, &ce) && ce.Code == websocket.StatusNormalClosure { // Normal closure from remote - exit gracefully // Call abort() with nil to properly set the closed state // abort() will handle normal closure gracefully (no error logged) c.abort(ctx, nil) return } // For other errors, abort normally c.abort(ctx, fmt.Errorf("failed to initialise websocket reader: %w", err)) return } if typ != websocket.MessageBinary { c.conn.Close(websocket.StatusUnsupportedData, "expected binary message") c.abort(ctx, fmt.Errorf("expected binary message for protobuf but got: %v", typ)) return } b := new(bytes.Buffer) _, err = b.ReadFrom(r) if err != nil { c.abort(ctx, fmt.Errorf("failed to read from websocket: %w", err)) return } err = proto.Unmarshal(b.Bytes(), msg) if err != nil { c.abort(ctx, fmt.Errorf("error unmarshalling message: %w", err)) return } switch msg.GetResponseType().(type) { case *sdp.GatewayResponse_NewItem: item := msg.GetNewItem() if c.handler != nil { c.handler.NewItem(ctx, item) } u, err := uuid.FromBytes(item.GetMetadata().GetSourceQuery().GetUUID()) if err == nil { c.postRequestChan(u, msg) } case *sdp.GatewayResponse_NewEdge: edge := msg.GetNewEdge() if c.handler != nil { c.handler.NewEdge(ctx, edge) } // TODO: edges are not attached to a specific query, so we can't send them to a request channel // maybe that's not a problem anyways? // c, ok := c.getRequestChan(uuid.UUID(edge.Metadata.SourceQuery.UUID)) // if ok { // c <- msg // } case *sdp.GatewayResponse_Status: status := msg.GetStatus() if c.handler != nil { c.handler.Status(ctx, status) } case *sdp.GatewayResponse_QueryError: qe := msg.GetQueryError() if c.handler != nil { c.handler.QueryError(ctx, qe) } u, err := uuid.FromBytes(qe.GetUUID()) if err == nil { c.postRequestChan(u, msg) } case *sdp.GatewayResponse_DeleteItemRef: item := msg.GetDeleteItemRef() if c.handler != nil { c.handler.DeleteItem(ctx, item) } case *sdp.GatewayResponse_DeleteEdge: edge := msg.GetDeleteEdge() if c.handler != nil { c.handler.DeleteEdge(ctx, edge) } case *sdp.GatewayResponse_UpdateItem: item := msg.GetUpdateItem() if c.handler != nil { c.handler.UpdateItem(ctx, item) } case *sdp.GatewayResponse_SnapshotStoreResult: result := msg.GetSnapshotStoreResult() if c.handler != nil { c.handler.SnapshotStoreResult(ctx, result) } u, err := uuid.FromBytes(result.GetMsgID()) if err == nil { c.postRequestChan(u, msg) } case *sdp.GatewayResponse_SnapshotLoadResult: result := msg.GetSnapshotLoadResult() if c.handler != nil { c.handler.SnapshotLoadResult(ctx, result) } u, err := uuid.FromBytes(result.GetMsgID()) if err == nil { c.postRequestChan(u, msg) } case *sdp.GatewayResponse_BookmarkStoreResult: result := msg.GetBookmarkStoreResult() if c.handler != nil { c.handler.BookmarkStoreResult(ctx, result) } u, err := uuid.FromBytes(result.GetMsgID()) if err == nil { c.postRequestChan(u, msg) } case *sdp.GatewayResponse_BookmarkLoadResult: result := msg.GetBookmarkLoadResult() if c.handler != nil { c.handler.BookmarkLoadResult(ctx, result) } u, err := uuid.FromBytes(result.GetMsgID()) if err == nil { c.postRequestChan(u, msg) } case *sdp.GatewayResponse_QueryStatus: qs := msg.GetQueryStatus() if c.handler != nil { c.handler.QueryStatus(ctx, qs) } u, err := uuid.FromBytes(qs.GetUUID()) if err == nil { c.postRequestChan(u, msg) } switch qs.GetStatus() { //nolint: exhaustive // ignore sdp.QueryStatus_UNSPECIFIED, sdp.QueryStatus_STARTED case sdp.QueryStatus_FINISHED, sdp.QueryStatus_CANCELLED, sdp.QueryStatus_ERRORED: c.finishRequestChan(u) } case *sdp.GatewayResponse_ChatResponse: chatResponse := msg.GetChatResponse() if c.handler != nil { c.handler.ChatResponse(ctx, chatResponse) } c.postRequestChan(uuid.Nil, msg) case *sdp.GatewayResponse_ToolStart: toolStart := msg.GetToolStart() if c.handler != nil { c.handler.ToolStart(ctx, toolStart) } c.postRequestChan(uuid.Nil, msg) case *sdp.GatewayResponse_ToolFinish: toolFinish := msg.GetToolFinish() if c.handler != nil { c.handler.ToolFinish(ctx, toolFinish) } c.postRequestChan(uuid.Nil, msg) default: log.WithContext(ctx).WithField("response", msg).WithField("responseType", fmt.Sprintf("%T", msg.GetResponseType())).Warn("unexpected response") } } } func (c *Client) send(ctx context.Context, msg *sdp.GatewayRequest) error { buf, err := proto.Marshal(msg) if err != nil { log.WithContext(ctx).WithError(err).WithField("request", msg).Trace("error marshaling request") c.abort(ctx, err) return err } err = c.conn.Write(ctx, websocket.MessageBinary, buf) if err != nil { log.WithContext(ctx).WithError(err).WithField("request", msg).Trace("error writing request to websocket") c.abort(ctx, err) return err } return nil } // Wait blocks until all specified requests have been finished. Waiting on a // closed client returns immediately with no error. func (c *Client) Wait(ctx context.Context, reqIDs uuid.UUIDs) error { for { if c.Closed() { return nil } // check for context cancellation if ctx.Err() != nil { return ctx.Err() } // wrap this in a function so defers can be called (otherwise the lock is held for all loop iterations) finished := func() bool { c.finishedRequestMapMu.Lock() defer c.finishedRequestMapMu.Unlock() // remove all finished requests from the list of requests to wait for reqIDs = slices.DeleteFunc(reqIDs, func(reqID uuid.UUID) bool { _, ok := c.finishedRequestMap[reqID] return ok }) if len(reqIDs) == 0 { return true } c.finishedRequestMapCond.Wait() return false }() if finished { return nil } } } // abort stores the specified error and closes the connection. func (c *Client) abort(ctx context.Context, err error) { c.closedMu.Lock() if c.closed { c.closedMu.Unlock() return } c.closed = true c.closedMu.Unlock() isNormalClosure := false var ce websocket.CloseError if errors.As(err, &ce) { // tear down the connection without a new error if this is a regular close isNormalClosure = ce.Code == websocket.StatusNormalClosure } if err != nil && !isNormalClosure { log.WithContext(ctx).WithError(err).Error("aborting client") } c.errMu.Lock() c.err = errors.Join(c.err, err) c.errMu.Unlock() // Cancel the receive context to stop receive() from reading more messages. if c.receiveCancel != nil { c.receiveCancel() } // call this outside of the lock to avoid deadlock should other parts of the // code try to call abort() when crashing out of a read or write // Only close the connection if it exists (may be nil in test scenarios) var closeErr error if c.conn != nil { closeErr = c.conn.Close(websocket.StatusNormalClosure, "normal closure") } c.errMu.Lock() c.err = errors.Join(c.err, closeErr) c.errMu.Unlock() c.closeAllRequestChans() } // Close closes the connection and returns any errors from the underlying connection. func (c *Client) Close(ctx context.Context) error { // Cancel the receive context first to stop receive() from reading more messages if c.receiveCancel != nil { c.receiveCancel() } // Wait for receive() to finish reading/sending its last message before closing channels. // This prevents race conditions where we close channels while receive() is still trying // to send to them. We do this before calling abort() to ensure receive() has finished. if c.receiveCancel != nil { c.receiveDone.Wait() } c.abort(ctx, nil) c.errMu.Lock() defer c.errMu.Unlock() return c.err } func (c *Client) Closed() bool { c.closedMu.Lock() defer c.closedMu.Unlock() return c.closed } func (c *Client) createRequestChan(u uuid.UUID) chan *sdp.GatewayResponse { r := make(chan *sdp.GatewayResponse, 1) c.requestMapMu.Lock() defer c.requestMapMu.Unlock() c.requestMap[u] = r return r } func (c *Client) postRequestChan(u uuid.UUID, msg *sdp.GatewayResponse) { c.requestMapMu.RLock() r, ok := c.requestMap[u] c.requestMapMu.RUnlock() if !ok { return } // Check if client is closed before sending. If closed, channels may be closed, // so we should not attempt to send. With proper context handling, receive() will // have finished before channels are closed, but we check here as a safety measure. if c.Closed() { return } // Use select with receive context to avoid blocking when context is cancelled. // This prevents deadlock where receive() is blocked on send while Close() is waiting for it. // During normal operation (context not cancelled), the send case will be selected, // ensuring no messages are dropped. When context is cancelled, we use non-blocking send. select { case <-c.receiveCtx.Done(): return case r <- msg: // Successfully sent (normal operation - blocking until receiver reads) return } } func (c *Client) finishRequestChan(u uuid.UUID) { c.requestMapMu.Lock() defer c.requestMapMu.Unlock() c.finishedRequestMapMu.Lock() defer c.finishedRequestMapMu.Unlock() delete(c.requestMap, u) c.finishedRequestMap[u] = true c.finishedRequestMapCond.Broadcast() } func (c *Client) closeAllRequestChans() { c.requestMapMu.Lock() defer c.requestMapMu.Unlock() c.finishedRequestMapMu.Lock() defer c.finishedRequestMapMu.Unlock() for k, v := range c.requestMap { close(v) c.finishedRequestMap[k] = true } // clear the map to free up memory c.requestMap = map[uuid.UUID]chan *sdp.GatewayResponse{} c.finishedRequestMapCond.Broadcast() } ================================================ FILE: go/sdp-go/sdpws/client_test.go ================================================ package sdpws import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "os" "slices" "sync" "testing" "time" "github.com/coder/websocket" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "go.uber.org/goleak" "google.golang.org/protobuf/proto" ) // Helper function to check if a slice contains a string func contains(slice []string, item string) bool { return slices.Contains(slice, item) } // TestServer is a test server for the websocket client. Note that this can only // handle a single connection at a time. type testServer struct { url string conn *websocket.Conn connMu sync.Mutex requests []*sdp.GatewayRequest requestsMu sync.Mutex } func newTestServer(_ context.Context, t *testing.T) (*testServer, func()) { ts := &testServer{ requests: make([]*sdp.GatewayRequest, 0), } serveMux := http.NewServeMux() serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, nil) if err != nil { return } defer func() { _ = c.Close(websocket.StatusNormalClosure, "") }() ts.connMu.Lock() ts.conn = c ts.connMu.Unlock() // ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) // defer cancel() for { msg := &sdp.GatewayRequest{} typ, reader, err := c.Reader(r.Context()) if err != nil { c.Close(websocket.StatusAbnormalClosure, fmt.Sprintf("failed to initialise websocket reader: %v", err)) return } if typ != websocket.MessageBinary { c.Close(websocket.StatusAbnormalClosure, fmt.Sprintf("expected binary message for protobuf but got: %v", typ)) t.Fatalf("expected binary message for protobuf but got: %v", typ) return } b := new(bytes.Buffer) _, err = b.ReadFrom(reader) if err != nil { c.Close(websocket.StatusAbnormalClosure, fmt.Sprintf("failed to read from websocket: %v", err)) t.Fatalf("failed to read from websocket: %v", err) return } err = proto.Unmarshal(b.Bytes(), msg) if err != nil { c.Close(websocket.StatusAbnormalClosure, fmt.Sprintf("error un marshaling message: %v", err)) t.Fatalf("error un marshaling message: %v", err) return } ts.requestsMu.Lock() ts.requests = append(ts.requests, msg) ts.requestsMu.Unlock() } }) s := httptest.NewServer(serveMux) ts.url = s.URL return ts, func() { s.Close() } } func (ts *testServer) inject(ctx context.Context, msg *sdp.GatewayResponse) { ts.connMu.Lock() c := ts.conn ts.connMu.Unlock() buf, err := proto.Marshal(msg) if err != nil { c.Close(websocket.StatusAbnormalClosure, fmt.Sprintf("error marshaling message: %v", err)) return } err = c.Write(ctx, websocket.MessageBinary, buf) if err != nil { c.Close(websocket.StatusAbnormalClosure, fmt.Sprintf("error writing message: %v", err)) return } } func TestClient(t *testing.T) { defer goleak.VerifyNone(t) t.Run("Query", func(t *testing.T) { defer goleak.VerifyNone(t) ctx := context.Background() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: "", Method: 0, Query: "", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "", IgnoreCache: false, } go func() { time.Sleep(100 * time.Millisecond) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: u[:], Status: sdp.QueryStatus_FINISHED, }, }, }) }() // this will block until the above goroutine has injected the response _, err = c.QueryOne(ctx, q) if err != nil { t.Fatal(err) } err = c.Wait(ctx, uuid.UUIDs{u}) if err != nil { t.Fatal(err) } ts.requestsMu.Lock() defer ts.requestsMu.Unlock() if len(ts.requests) != 1 { t.Fatalf("expected 1 request, got %v: %v", len(ts.requests), ts.requests) } recvQ, ok := ts.requests[0].GetRequestType().(*sdp.GatewayRequest_Query) if !ok || uuid.UUID(recvQ.Query.GetUUID()) != u { t.Fatalf("expected query, got %v", ts.requests[0]) } }) t.Run("QueryNotFound", func(t *testing.T) { defer goleak.VerifyNone(t) ctx := context.Background() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: "", Method: 0, Query: "", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "", IgnoreCache: false, } go func() { time.Sleep(100 * time.Millisecond) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: u[:], Status: sdp.QueryStatus_STARTED, }, }, }) time.Sleep(100 * time.Millisecond) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryError{ QueryError: &sdp.QueryError{ UUID: u[:], ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not found", Scope: "scope", SourceName: "src name", ItemType: "item type", ResponderName: "responder name", }, }, }) time.Sleep(100 * time.Millisecond) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: u[:], Status: sdp.QueryStatus_ERRORED, }, }, }) }() // this will block until the above goroutine has injected the response _, err = c.QueryOne(ctx, q) if err != nil { t.Fatal(err) } err = c.Wait(ctx, uuid.UUIDs{u}) if err != nil { t.Fatal(err) } ts.requestsMu.Lock() defer ts.requestsMu.Unlock() if len(ts.requests) != 1 { t.Fatalf("expected 1 request, got %v: %v", len(ts.requests), ts.requests) } recvQ, ok := ts.requests[0].GetRequestType().(*sdp.GatewayRequest_Query) if !ok || uuid.UUID(recvQ.Query.GetUUID()) != u { t.Fatalf("expected query, got %v", ts.requests[0]) } }) t.Run("StoreSnapshot", func(t *testing.T) { defer goleak.VerifyNone(t) ctx := context.Background() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() u := uuid.New() go func() { time.Sleep(100 * time.Millisecond) ts.requestsMu.Lock() msgID := ts.requests[0].GetStoreSnapshot().GetMsgID() ts.requestsMu.Unlock() ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_SnapshotStoreResult{ SnapshotStoreResult: &sdp.SnapshotStoreResult{ Success: true, ErrorMessage: "", MsgID: msgID, SnapshotID: u[:], }, }, }) }() // this will block until the above goroutine has injected the response snapu, err := c.StoreSnapshot(ctx, "name", "description") if err != nil { t.Fatal(err) } if snapu != u { t.Errorf("expected snapshot id %v, got %v", u, snapu) } ts.requestsMu.Lock() defer ts.requestsMu.Unlock() if len(ts.requests) != 1 { t.Fatalf("expected 1 request, got %v: %v", len(ts.requests), ts.requests) } }) t.Run("StoreBookmark", func(t *testing.T) { defer goleak.VerifyNone(t) ctx := context.Background() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() u := uuid.New() go func() { time.Sleep(100 * time.Millisecond) ts.requestsMu.Lock() msgID := ts.requests[0].GetStoreBookmark().GetMsgID() ts.requestsMu.Unlock() ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_BookmarkStoreResult{ BookmarkStoreResult: &sdp.BookmarkStoreResult{ Success: true, ErrorMessage: "", MsgID: msgID, BookmarkID: u[:], }, }, }) }() // this will block until the above goroutine has injected the response snapu, err := c.StoreBookmark(ctx, "name", "description", true) if err != nil { t.Fatal(err) } if snapu != u { t.Errorf("expected bookmark id %v, got %v", u, snapu) } ts.requestsMu.Lock() defer ts.requestsMu.Unlock() if len(ts.requests) != 1 { t.Fatalf("expected 1 request, got %v: %v", len(ts.requests), ts.requests) } }) t.Run("ConcurrentQueries", func(t *testing.T) { defer goleak.VerifyNone(t) ctx := context.Background() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() // Create multiple queries with different UUIDs numQueries := 5 queries := make([]*sdp.Query, numQueries) expectedItems := make(map[string]*sdp.Item) for i := range numQueries { u := uuid.New() queries[i] = &sdp.Query{ UUID: u[:], Type: "test", Method: sdp.QueryMethod_GET, Query: fmt.Sprintf("query-%d", i), RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "test", IgnoreCache: false, } // Create expected items that should be returned for each query expectedItems[u.String()] = &sdp.Item{ Type: "test", UniqueAttribute: fmt.Sprintf("item-%d", i), Scope: "test", Metadata: &sdp.Metadata{ SourceQuery: queries[i], }, } } // Inject responses in a different order than queries to test proper routing go func() { time.Sleep(50 * time.Millisecond) // Send responses in reverse order to test UUID-based routing for i := numQueries - 1; i >= 0; i-- { u := uuid.UUID(queries[i].GetUUID()) // Send an item response first ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: expectedItems[u.String()], }, }) // Then send the completion status ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: u[:], Status: sdp.QueryStatus_FINISHED, }, }, }) // Add a small delay between responses to make race conditions more likely time.Sleep(10 * time.Millisecond) } }() // Execute all queries concurrently type queryResult struct { index int items []*sdp.Item err error } results := make([]queryResult, numQueries) var wg sync.WaitGroup for i := range numQueries { wg.Add(1) go func(index int) { defer wg.Done() items, err := c.QueryOne(ctx, queries[index]) results[index] = queryResult{ index: index, items: items, err: err, } }(i) } wg.Wait() // Verify that each query got the correct response for i, result := range results { if result.err != nil { t.Errorf("Query %d failed: %v", i, result.err) continue } if len(result.items) != 1 { t.Errorf("Query %d: expected 1 item, got %d", i, len(result.items)) continue } receivedItem := result.items[0] expectedUniqueAttr := fmt.Sprintf("item-%d", i) if receivedItem.GetUniqueAttribute() != expectedUniqueAttr { t.Errorf("Query %d: expected item with unique attribute %s, got %s", i, expectedUniqueAttr, receivedItem.GetUniqueAttribute()) } // Verify the item's metadata contains the correct source query if receivedItem.GetMetadata() == nil || receivedItem.GetMetadata().GetSourceQuery() == nil { t.Errorf("Query %d: item missing metadata or source query", i) continue } sourceQueryUUID := uuid.UUID(receivedItem.GetMetadata().GetSourceQuery().GetUUID()) expectedUUID := uuid.UUID(queries[i].GetUUID()) if sourceQueryUUID != expectedUUID { t.Errorf("Query %d: expected source query UUID %s, got %s", i, expectedUUID, sourceQueryUUID) } } // Verify that the server received all queries ts.requestsMu.Lock() defer ts.requestsMu.Unlock() if len(ts.requests) != numQueries { t.Fatalf("expected %d requests, got %d", numQueries, len(ts.requests)) } }) t.Run("ResponseMixupPrevention", func(t *testing.T) { defer goleak.VerifyNone(t) ctx := context.Background() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() // Create two queries with different UUIDs query1UUID := uuid.New() query2UUID := uuid.New() query1 := &sdp.Query{ UUID: query1UUID[:], Type: "test", Method: sdp.QueryMethod_GET, Query: "query-1", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "test", IgnoreCache: false, } query2 := &sdp.Query{ UUID: query2UUID[:], Type: "test", Method: sdp.QueryMethod_GET, Query: "query-2", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "test", IgnoreCache: false, } // Items that should be returned for each query item1 := &sdp.Item{ Type: "test", UniqueAttribute: "item-for-query-1", Scope: "test", Metadata: &sdp.Metadata{ SourceQuery: query1, }, } item2 := &sdp.Item{ Type: "test", UniqueAttribute: "item-for-query-2", Scope: "test", Metadata: &sdp.Metadata{ SourceQuery: query2, }, } // Inject responses in a way that could cause mixup if UUIDs aren't handled correctly go func() { time.Sleep(50 * time.Millisecond) // Send responses for query2 first, then query1 // If the client doesn't properly route by UUID, responses could get mixed up // Send multiple items for query2 ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: item2, }, }) // Send an item for query1 ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: item1, }, }) // Send another item for query2 to test multiple items per query item2_duplicate := &sdp.Item{ Type: "test", UniqueAttribute: "item-for-query-2-duplicate", Scope: "test", Metadata: &sdp.Metadata{ SourceQuery: query2, }, } ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: item2_duplicate, }, }) // Complete query1 first (even though we sent its response second) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: query1UUID[:], Status: sdp.QueryStatus_FINISHED, }, }, }) // Complete query2 after query1 ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: query2UUID[:], Status: sdp.QueryStatus_FINISHED, }, }, }) }() // Execute both queries concurrently type result struct { items []*sdp.Item err error } var wg sync.WaitGroup results := make([]result, 2) wg.Go(func() { items, err := c.QueryOne(ctx, query1) results[0] = result{items: items, err: err} }) wg.Go(func() { items, err := c.QueryOne(ctx, query2) results[1] = result{items: items, err: err} }) wg.Wait() // Verify query1 got the correct response if results[0].err != nil { t.Errorf("Query 1 failed: %v", results[0].err) } else { if len(results[0].items) != 1 { t.Errorf("Query 1: expected 1 item, got %d", len(results[0].items)) } else if results[0].items[0].GetUniqueAttribute() != "item-for-query-1" { t.Errorf("Query 1: got wrong item: %s", results[0].items[0].GetUniqueAttribute()) } } // Verify query2 got the correct responses if results[1].err != nil { t.Errorf("Query 2 failed: %v", results[1].err) } else { if len(results[1].items) != 2 { t.Errorf("Query 2: expected 2 items, got %d", len(results[1].items)) } else { // Check that both items are for query2 for i, item := range results[1].items { if !contains([]string{"item-for-query-2", "item-for-query-2-duplicate"}, item.GetUniqueAttribute()) { t.Errorf("Query 2, item %d: got wrong item: %s", i, item.GetUniqueAttribute()) } } } } }) t.Run("UUIDRoutingValidation", func(t *testing.T) { defer goleak.VerifyNone(t) ctx := context.Background() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() // This test validates that responses are properly routed by UUID // If the client were reading responses in order (FIFO) instead of by UUID, // this test would fail because we send responses out of order queryA_UUID := uuid.New() queryB_UUID := uuid.New() queryA := &sdp.Query{ UUID: queryA_UUID[:], Type: "test", Method: sdp.QueryMethod_GET, Query: "query-A", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "test", IgnoreCache: false, } queryB := &sdp.Query{ UUID: queryB_UUID[:], Type: "test", Method: sdp.QueryMethod_GET, Query: "query-B", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "test", IgnoreCache: false, } // Items that should be returned for each query itemA := &sdp.Item{ Type: "test", UniqueAttribute: "item-A", Scope: "test", Metadata: &sdp.Metadata{ SourceQuery: queryA, }, } itemB := &sdp.Item{ Type: "test", UniqueAttribute: "item-B", Scope: "test", Metadata: &sdp.Metadata{ SourceQuery: queryB, }, } // Inject responses deliberately out of order go func() { time.Sleep(50 * time.Millisecond) // Send itemB first (for queryB), then itemA (for queryA) // If the client doesn't route by UUID, queryA might get itemB ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: itemB, }, }) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: itemA, }, }) // Complete queryA first (even though itemA was sent second) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: queryA_UUID[:], Status: sdp.QueryStatus_FINISHED, }, }, }) // Complete queryB second ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: queryB_UUID[:], Status: sdp.QueryStatus_FINISHED, }, }, }) }() // Execute queryA - it should get itemA despite itemB being sent first var wg sync.WaitGroup type result struct { items []*sdp.Item err error } resultsA := make([]result, 1) resultsB := make([]result, 1) wg.Go(func() { items, err := c.QueryOne(ctx, queryA) resultsA[0] = result{items: items, err: err} }) wg.Go(func() { items, err := c.QueryOne(ctx, queryB) resultsB[0] = result{items: items, err: err} }) wg.Wait() // Verify queryA got the correct item if resultsA[0].err != nil { t.Fatalf("Query A failed: %v", resultsA[0].err) } if len(resultsA[0].items) != 1 { t.Fatalf("Query A: expected 1 item, got %d", len(resultsA[0].items)) } if resultsA[0].items[0].GetUniqueAttribute() != "item-A" { t.Errorf("Query A got wrong item: expected 'item-A', got '%s'", resultsA[0].items[0].GetUniqueAttribute()) } // Verify queryB got the correct item if resultsB[0].err != nil { t.Fatalf("Query B failed: %v", resultsB[0].err) } if len(resultsB[0].items) != 1 { t.Fatalf("Query B: expected 1 item, got %d", len(resultsB[0].items)) } if resultsB[0].items[0].GetUniqueAttribute() != "item-B" { t.Errorf("Query B got wrong item: expected 'item-B', got '%s'", resultsB[0].items[0].GetUniqueAttribute()) } }) } // TestRaceConditionOnClose stresses the shutdown path around the historical // "send on closed channel" bug. Originally, there was a race where: // 1. postRequestChan is called and acquires the read lock // 2. postRequestChan looks up a channel and prepares to send // 3. Another goroutine calls closeAllRequestChans(), which closes all channels // 4. postRequestChan tries to send on the now-closed channel and panics // // The implementation has since been fixed by eliminating this race via proper // synchronization (e.g. context cancellation followed by waiting for the // relevant goroutines to finish) instead of relying on recover(). This test // verifies that no "send on closed channel" panics escape to the caller under // concurrent activity and that the shutdown logic behaves correctly. // // This test is expected to pass cleanly when run with the Go race detector // enabled (go test -race). It may be run multiple times to increase stress: // go test -run TestRaceConditionOnClose -race -v -count=100 func TestRaceConditionOnClose(t *testing.T) { // Skip on CI to avoid flaky tests in automated environments if os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" { t.Skip("Skipping race condition test in CI environment") } // Skip goleak for this stress test as we're intentionally testing race conditions ctx := t.Context() // Run many iterations to increase probability of hitting the race condition // The race happens when postRequestChan and closeAllRequestChans run concurrently iterations := 1000 panics := 0 for iteration := range iterations { func() { // Create a minimal client with just the necessary fields c := &Client{ requestMap: make(map[uuid.UUID]chan *sdp.GatewayResponse), finishedRequestMap: make(map[uuid.UUID]bool), } c.finishedRequestMapCond = sync.NewCond(&c.finishedRequestMapMu) // Initialize context handling to properly test the mechanism // This simulates what Dial() does c.receiveCtx, c.receiveCancel = context.WithCancel(ctx) // Create multiple request channels to increase race probability numChannels := 10 uuids := make([]uuid.UUID, numChannels) for i := range numChannels { uuids[i] = uuid.New() ch := make(chan *sdp.GatewayResponse, 1) c.requestMapMu.Lock() c.requestMap[uuids[i]] = ch c.requestMapMu.Unlock() } // Create a message to send msg := &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: &sdp.Item{ Type: "test", UniqueAttribute: fmt.Sprintf("item-%d", iteration), Scope: "test", }, }, } // Use a wait group to coordinate concurrent operations var wg sync.WaitGroup panicChan := make(chan bool, numChannels*2) // Start a simulated receive() goroutine that will call postRequestChan // This simulates the real receive() behavior where it processes messages // and calls postRequestChan() until the context is cancelled c.receiveDone.Go(func() { // Simulate receive() processing messages and calling postRequestChan // It will be cancelled by Close() and should stop before channels are closed for i := range 1000 { // Check context before processing each message (like real receive() does) if c.receiveCtx.Err() != nil { // Context cancelled - exit gracefully (simulating receive() behavior) return } // Simulate receive() processing a message and calling postRequestChan // Use a random UUID to simulate different queries // Wrap in recover() to catch any panics (shouldn't happen with proper context handling) func() { defer func() { if r := recover(); r != nil { panicChan <- true } }() u := uuids[i%numChannels] c.postRequestChan(u, msg) }() time.Sleep(time.Nanosecond) } }) // Start a goroutine that calls Close() concurrently // Close() will cancel the receive context, wait for receive() to finish, // and then close channels. This ensures receive() stops before channels are closed. wg.Go(func() { // Wait a tiny bit to let some postRequestChan calls start time.Sleep(time.Microsecond * 10) // Use Close() which properly cancels receive context and waits before closing channels _ = c.Close(ctx) }) // Wait for all goroutines to complete wg.Wait() close(panicChan) // Count panics for range panicChan { panics++ } }() } t.Logf("Successfully completed all %d iterations", iterations) if panics > 0 { t.Errorf("Detected %d panics - with proper context handling, receive() should stop before channels are closed, preventing panics. Panics indicate the context handling is not working correctly.", panics) } else { t.Logf("No panics detected - context handling is working correctly. receive() stops before channels are closed, preventing 'send on closed channel' panics") } } // TestNoMessageDroppingDuringNormalOperation verifies that messages are not // dropped during normal operation when items arrive faster than they can be read. // This test sends many items rapidly and verifies that all items are received. func TestNoMessageDroppingDuringNormalOperation(t *testing.T) { defer goleak.VerifyNone(t) ctx := t.Context() ts, closeFn := newTestServer(ctx, t) defer closeFn() c, err := Dial(ctx, ts.url, nil, nil) if err != nil { t.Fatal(err) } defer func() { _ = c.Close(ctx) }() u := uuid.New() q := &sdp.Query{ UUID: u[:], Type: "test", Method: sdp.QueryMethod_GET, Query: "query", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, Scope: "test", IgnoreCache: false, } // Send many items rapidly - more than the channel buffer size (1) // to test that blocking send works correctly and no messages are dropped numItems := 100 expectedItems := make([]*sdp.Item, numItems) for i := range numItems { expectedItems[i] = &sdp.Item{ Type: "test", UniqueAttribute: fmt.Sprintf("item-%d", i), Scope: "test", Metadata: &sdp.Metadata{ SourceQuery: q, }, } } // Inject all items rapidly, then the completion status go func() { time.Sleep(50 * time.Millisecond) // Send all items as fast as possible for i := range numItems { ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_NewItem{ NewItem: expectedItems[i], }, }) } // Then send the completion status time.Sleep(10 * time.Millisecond) ts.inject(ctx, &sdp.GatewayResponse{ ResponseType: &sdp.GatewayResponse_QueryStatus{ QueryStatus: &sdp.QueryStatus{ UUID: u[:], Status: sdp.QueryStatus_FINISHED, }, }, }) }() // QueryOne should receive all items, not drop any items, err := c.QueryOne(ctx, q) if err != nil { t.Fatalf("QueryOne failed: %v", err) } if len(items) != numItems { t.Errorf("Expected %d items, got %d. Messages were dropped!", numItems, len(items)) } // Verify we got all the expected items receivedAttrs := make(map[string]bool) for _, item := range items { receivedAttrs[item.GetUniqueAttribute()] = true } for i := range numItems { expectedAttr := fmt.Sprintf("item-%d", i) if !receivedAttrs[expectedAttr] { t.Errorf("Missing expected item: %s", expectedAttr) } } } ================================================ FILE: go/sdp-go/sdpws/messagehandler.go ================================================ package sdpws import ( "context" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" ) // GatewayMessageHandler is an interface that can be implemented to handle // messages from the gateway. The individual methods are called when the sdpws // client receives a message from the gateway. Methods are called in the same // order as the messages are received from the gateway. The sdpws client // guarantees that the methods are called in a single thread, so no locking is // needed. type GatewayMessageHandler interface { NewItem(context.Context, *sdp.Item) NewEdge(context.Context, *sdp.Edge) Status(context.Context, *sdp.GatewayRequestStatus) Error(context.Context, string) QueryError(context.Context, *sdp.QueryError) DeleteItem(context.Context, *sdp.Reference) DeleteEdge(context.Context, *sdp.Edge) UpdateItem(context.Context, *sdp.Item) SnapshotStoreResult(context.Context, *sdp.SnapshotStoreResult) SnapshotLoadResult(context.Context, *sdp.SnapshotLoadResult) BookmarkStoreResult(context.Context, *sdp.BookmarkStoreResult) BookmarkLoadResult(context.Context, *sdp.BookmarkLoadResult) QueryStatus(context.Context, *sdp.QueryStatus) ChatResponse(context.Context, *sdp.ChatResponse) ToolStart(context.Context, *sdp.ToolStart) ToolFinish(context.Context, *sdp.ToolFinish) } type LoggingGatewayMessageHandler struct { Level log.Level } // assert that LoggingGatewayMessageHandler implements GatewayMessageHandler var _ GatewayMessageHandler = (*LoggingGatewayMessageHandler)(nil) func (l *LoggingGatewayMessageHandler) NewItem(ctx context.Context, item *sdp.Item) { entry := log.WithContext(ctx) if item != nil { entry = entry.WithField("item.globallyUniqueName", item.GloballyUniqueName()) } if l.Level >= log.DebugLevel { entry = entry.WithField("item", item) } entry.Log(l.Level, "received new item") } func (l *LoggingGatewayMessageHandler) NewEdge(ctx context.Context, edge *sdp.Edge) { log.WithContext(ctx).WithField("edge", edge).Log(l.Level, "received new edge") } func (l *LoggingGatewayMessageHandler) Status(ctx context.Context, status *sdp.GatewayRequestStatus) { log.WithContext(ctx).WithField("status", status.GetSummary()).Log(l.Level, "received status") } func (l *LoggingGatewayMessageHandler) Error(ctx context.Context, errorMessage string) { log.WithContext(ctx).WithField("errorMessage", errorMessage).Log(l.Level, "received error") } func (l *LoggingGatewayMessageHandler) QueryError(ctx context.Context, queryError *sdp.QueryError) { log.WithContext(ctx).WithField("queryError", queryError).Log(l.Level, "received query error") } func (l *LoggingGatewayMessageHandler) DeleteItem(ctx context.Context, reference *sdp.Reference) { log.WithContext(ctx).WithField("reference", reference).Log(l.Level, "received delete item") } func (l *LoggingGatewayMessageHandler) DeleteEdge(ctx context.Context, edge *sdp.Edge) { log.WithContext(ctx).WithField("edge", edge).Log(l.Level, "received delete edge") } func (l *LoggingGatewayMessageHandler) UpdateItem(ctx context.Context, item *sdp.Item) { entry := log.WithContext(ctx) if item != nil { entry = entry.WithField("item.globallyUniqueName", item.GloballyUniqueName()) } if l.Level >= log.DebugLevel { entry = entry.WithField("item", item) } entry.Log(l.Level, "received updated item") } func (l *LoggingGatewayMessageHandler) SnapshotStoreResult(ctx context.Context, result *sdp.SnapshotStoreResult) { log.WithContext(ctx).WithField("result", result).Log(l.Level, "received snapshot store result") } func (l *LoggingGatewayMessageHandler) SnapshotLoadResult(ctx context.Context, result *sdp.SnapshotLoadResult) { log.WithContext(ctx).WithField("result", result).Log(l.Level, "received snapshot load result") } func (l *LoggingGatewayMessageHandler) BookmarkStoreResult(ctx context.Context, result *sdp.BookmarkStoreResult) { log.WithContext(ctx).WithField("result", result).Log(l.Level, "received bookmark store result") } func (l *LoggingGatewayMessageHandler) BookmarkLoadResult(ctx context.Context, result *sdp.BookmarkLoadResult) { log.WithContext(ctx).WithField("result", result).Log(l.Level, "received bookmark load result") } func (l *LoggingGatewayMessageHandler) QueryStatus(ctx context.Context, status *sdp.QueryStatus) { log.WithContext(ctx).WithField("status", status).WithField("uuid", status.GetUUIDParsed()).Log(l.Level, "received query status") } func (l *LoggingGatewayMessageHandler) ChatResponse(ctx context.Context, chatResponse *sdp.ChatResponse) { log.WithContext(ctx).WithField("chatResponse", chatResponse).Log(l.Level, "received chat response") } func (l *LoggingGatewayMessageHandler) ToolStart(ctx context.Context, toolStart *sdp.ToolStart) { log.WithContext(ctx).WithField("toolStart", toolStart).Log(l.Level, "received tool start") } func (l *LoggingGatewayMessageHandler) ToolFinish(ctx context.Context, toolFinish *sdp.ToolFinish) { log.WithContext(ctx).WithField("toolFinish", toolFinish).Log(l.Level, "received tool finish") } type NoopGatewayMessageHandler struct{} // assert that NoopGatewayMessageHandler implements GatewayMessageHandler var _ GatewayMessageHandler = (*NoopGatewayMessageHandler)(nil) func (l *NoopGatewayMessageHandler) NewItem(ctx context.Context, item *sdp.Item) { } func (l *NoopGatewayMessageHandler) NewEdge(ctx context.Context, edge *sdp.Edge) { } func (l *NoopGatewayMessageHandler) Status(ctx context.Context, status *sdp.GatewayRequestStatus) { } func (l *NoopGatewayMessageHandler) Error(ctx context.Context, errorMessage string) { } func (l *NoopGatewayMessageHandler) QueryError(ctx context.Context, queryError *sdp.QueryError) { } func (l *NoopGatewayMessageHandler) DeleteItem(ctx context.Context, reference *sdp.Reference) { } func (l *NoopGatewayMessageHandler) DeleteEdge(ctx context.Context, edge *sdp.Edge) { } func (l *NoopGatewayMessageHandler) UpdateItem(ctx context.Context, item *sdp.Item) { } func (l *NoopGatewayMessageHandler) SnapshotStoreResult(ctx context.Context, result *sdp.SnapshotStoreResult) { } func (l *NoopGatewayMessageHandler) SnapshotLoadResult(ctx context.Context, result *sdp.SnapshotLoadResult) { } func (l *NoopGatewayMessageHandler) BookmarkStoreResult(ctx context.Context, result *sdp.BookmarkStoreResult) { } func (l *NoopGatewayMessageHandler) BookmarkLoadResult(ctx context.Context, result *sdp.BookmarkLoadResult) { } func (l *NoopGatewayMessageHandler) QueryStatus(ctx context.Context, status *sdp.QueryStatus) { } func (l *NoopGatewayMessageHandler) ChatResponse(ctx context.Context, chatMessageResult *sdp.ChatResponse) { } func (l *NoopGatewayMessageHandler) ToolStart(ctx context.Context, toolStart *sdp.ToolStart) { } func (l *NoopGatewayMessageHandler) ToolFinish(ctx context.Context, toolFinish *sdp.ToolFinish) { } var _ GatewayMessageHandler = (*StoreEverythingHandler)(nil) // A handler that stores all the items and edges it receives type StoreEverythingHandler struct { Items []*sdp.Item Edges []*sdp.Edge NoopGatewayMessageHandler } func (s *StoreEverythingHandler) NewItem(ctx context.Context, item *sdp.Item) { s.Items = append(s.Items, item) } func (s *StoreEverythingHandler) NewEdge(ctx context.Context, edge *sdp.Edge) { s.Edges = append(s.Edges, edge) } var _ GatewayMessageHandler = (*WaitForAllQueriesHandler)(nil) // A Handler that waits for all queries to be done then calls a callback type WaitForAllQueriesHandler struct { // A callback that will be called when all queries are done DoneCallback func() StoreEverythingHandler } func (w *WaitForAllQueriesHandler) Status(ctx context.Context, status *sdp.GatewayRequestStatus) { if status.Done() { w.DoneCallback() } } ================================================ FILE: go/sdp-go/sdpws/utils.go ================================================ package sdpws import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/types/known/durationpb" ) // Sends a query on the websocket connection without waiting for responses. Use // the `Wait()` method to wait for completion of requests based on their UUID func (c *Client) SendQuery(ctx context.Context, q *sdp.Query) error { if c.Closed() { return errors.New("client closed") } log.WithContext(ctx).WithField("query", q).Trace("writing query to websocket") err := c.send(ctx, &sdp.GatewayRequest{ RequestType: &sdp.GatewayRequest_Query{ Query: q, }, MinStatusInterval: durationpb.New(time.Second), }) if err != nil { // c.send already aborts // c.abort(ctx, err) return fmt.Errorf("error sending query: %w", err) } return nil } // QueryOne runs a query and waits for it to complete, returning only the items // that were found as direct results to the top-level query. func (c *Client) QueryOne(ctx context.Context, q *sdp.Query) ([]*sdp.Item, error) { if c.Closed() { return nil, errors.New("client closed") } u := uuid.UUID(q.GetUUID()) r := c.createRequestChan(u) defer c.finishRequestChan(u) err := c.SendQuery(ctx, q) if err != nil { // c.SendQuery already aborts // c.abort(ctx, err) return nil, err } items := make([]*sdp.Item, 0) var otherErr *sdp.QueryError readLoop: for { select { case <-ctx.Done(): return nil, fmt.Errorf("context canceled: %w", ctx.Err()) case resp, more := <-r: if !more { break readLoop } switch resp.GetResponseType().(type) { case *sdp.GatewayResponse_NewItem: item := resp.GetNewItem() log.WithContext(ctx).WithField("query", q).WithField("item", item).Trace("received item") items = append(items, item) case *sdp.GatewayResponse_QueryError: qe := resp.GetQueryError() log.WithContext(ctx).WithField("query", q).WithField("queryError", qe).Trace("received query error") switch qe.GetErrorType() { case sdp.QueryError_OTHER, sdp.QueryError_TIMEOUT, sdp.QueryError_NOSCOPE: // record that we received an error, but continue reading // if we receive any item, mapping was still successful otherErr = qe continue readLoop case sdp.QueryError_NOTFOUND: // never record not found as an error continue readLoop } case *sdp.GatewayResponse_QueryStatus: qs := resp.GetQueryStatus() span := trace.SpanFromContext(ctx) span.SetAttributes(attribute.String("ovm.sdp.lastQueryStatus", qs.String())) log.WithContext(ctx).WithField("query", q).WithField("queryStatus", qs).Trace("received query status") switch qs.GetStatus() { //nolint:exhaustive // we dont care about sdp.QueryStatus_UNSPECIFIED, sdp.QueryStatus_STARTED case sdp.QueryStatus_FINISHED: break readLoop case sdp.QueryStatus_CANCELLED: return nil, errors.New("query cancelled") case sdp.QueryStatus_ERRORED: // if we already received items, we can ignore the error if len(items) == 0 && otherErr != nil { err = fmt.Errorf("query errored: %w", otherErr) // query errors should not abort the connection // c.abort(ctx, err) return nil, err } break readLoop } default: log.WithContext(ctx).WithField("response", resp).WithField("responseType", fmt.Sprintf("%T", resp.GetResponseType())).Warn("unexpected response") } } } return items, nil } // TODO: CancelQuery // TODO: Expand // Sends a LoadSnapshot request on the websocket connection without waiting for // a response. func (c *Client) SendLoadSnapshot(ctx context.Context, s *sdp.LoadSnapshot) error { if c.Closed() { return errors.New("client closed") } log.WithContext(ctx).WithField("snapshot", s).Trace("loading snapshot via websocket") err := c.send(ctx, &sdp.GatewayRequest{ RequestType: &sdp.GatewayRequest_LoadSnapshot{ LoadSnapshot: s, }, }) if err != nil { return fmt.Errorf("error sending load snapshot: %w", err) } return nil } // Load a snapshot and wait for it to complete. This will return the // SnapshotLoadResult from the gateway. A separate error is only returned when // there is a communication error. Logic errors from the gateway are reported // through the returned SnapshotLoadResult. func (c *Client) LoadSnapshot(ctx context.Context, id uuid.UUID) (*sdp.SnapshotLoadResult, error) { if c.Closed() { return nil, errors.New("client closed") } u := uuid.New() s := &sdp.LoadSnapshot{ UUID: id[:], MsgID: u[:], } r := c.createRequestChan(u) err := c.SendLoadSnapshot(ctx, s) if err != nil { return nil, err } for { select { case <-ctx.Done(): return nil, fmt.Errorf("context canceled: %w", ctx.Err()) case resp, more := <-r: if !more { return nil, errors.New("request channel closed") } switch resp.GetResponseType().(type) { case *sdp.GatewayResponse_SnapshotLoadResult: slr := resp.GetSnapshotLoadResult() log.WithContext(ctx).WithField("snapshot", s).WithField("snapshotLoadResult", slr).Trace("received snapshot load result") return slr, nil default: log.WithContext(ctx).WithField("response", resp).WithField("responseType", fmt.Sprintf("%T", resp.GetResponseType())).Warn("unexpected response") return nil, errors.New("unexpected response") } } } } // Sends a StoreSnapshot request on the websocket connection without waiting for // a response. func (c *Client) SendStoreSnapshot(ctx context.Context, s *sdp.StoreSnapshot) error { if c.Closed() { return errors.New("client closed") } log.WithContext(ctx).WithField("snapshot", s).Trace("storing snapshot via websocket") err := c.send(ctx, &sdp.GatewayRequest{ RequestType: &sdp.GatewayRequest_StoreSnapshot{ StoreSnapshot: s, }, }) if err != nil { return fmt.Errorf("error sending store snapshot: %w", err) } return nil } // Store a snapshot and wait for it to complete, returning the UUID of the // snapshot that was created. func (c *Client) StoreSnapshot(ctx context.Context, name, description string) (uuid.UUID, error) { if c.Closed() { return uuid.UUID{}, errors.New("client closed") } u := uuid.New() s := &sdp.StoreSnapshot{ Name: name, Description: description, MsgID: u[:], } r := c.createRequestChan(u) err := c.SendStoreSnapshot(ctx, s) if err != nil { return uuid.UUID{}, err } for { select { case <-ctx.Done(): return uuid.UUID{}, fmt.Errorf("context canceled: %w", ctx.Err()) case resp, more := <-r: if !more { return uuid.UUID{}, errors.New("request channel closed") } switch resp.GetResponseType().(type) { case *sdp.GatewayResponse_SnapshotStoreResult: ssr := resp.GetSnapshotStoreResult() log.WithContext(ctx).WithField("Snapshot", s).WithField("snapshotStoreResult", ssr).Trace("received snapshot store result") if ssr.GetSuccess() { return uuid.UUID(ssr.GetSnapshotID()), nil } return uuid.UUID{}, fmt.Errorf("snapshot store failed: %v", ssr.GetErrorMessage()) default: log.WithContext(ctx).WithField("response", resp).WithField("responseType", fmt.Sprintf("%T", resp.GetResponseType())).Warn("unexpected response") return uuid.UUID{}, errors.New("unexpected response") } } } } func (c *Client) SendLoadBookmark(ctx context.Context, b *sdp.LoadBookmark) error { if c.Closed() { return errors.New("client closed") } log.WithContext(ctx).WithField("bookmark", b).Trace("loading bookmark via websocket") err := c.send(ctx, &sdp.GatewayRequest{ RequestType: &sdp.GatewayRequest_LoadBookmark{ LoadBookmark: b, }, }) if err != nil { return fmt.Errorf("error sending load bookmark: %w", err) } return nil } // Sends a StoreBookmark request on the websocket connection without waiting for // a response. func (c *Client) SendStoreBookmark(ctx context.Context, b *sdp.StoreBookmark) error { if c.Closed() { return errors.New("client closed") } log.WithContext(ctx).WithField("bookmark", b).Trace("storing bookmark via websocket") err := c.send(ctx, &sdp.GatewayRequest{ RequestType: &sdp.GatewayRequest_StoreBookmark{ StoreBookmark: b, }, }) if err != nil { return fmt.Errorf("error sending store bookmark: %w", err) } return nil } // Store a bookmark and wait for it to complete, returning the UUID of the // bookmark that was created. func (c *Client) StoreBookmark(ctx context.Context, name, description string, isSystem bool) (uuid.UUID, error) { if c.Closed() { return uuid.UUID{}, errors.New("client closed") } u := uuid.New() b := &sdp.StoreBookmark{ Name: name, Description: description, MsgID: u[:], IsSystem: true, } r := c.createRequestChan(u) err := c.SendStoreBookmark(ctx, b) if err != nil { return uuid.UUID{}, err } for { select { case <-ctx.Done(): return uuid.UUID{}, fmt.Errorf("context canceled: %w", ctx.Err()) case resp, more := <-r: if !more { return uuid.UUID{}, errors.New("request channel closed") } switch resp.GetResponseType().(type) { case *sdp.GatewayResponse_BookmarkStoreResult: bsr := resp.GetBookmarkStoreResult() log.WithContext(ctx).WithField("bookmark", b).WithField("bookmarkStoreResult", bsr).Trace("received bookmark store result") if bsr.GetSuccess() { return uuid.UUID(bsr.GetBookmarkID()), nil } return uuid.UUID{}, fmt.Errorf("bookmark store failed: %v", bsr.GetErrorMessage()) default: log.WithContext(ctx).WithField("response", resp).WithField("responseType", fmt.Sprintf("%T", resp.GetResponseType())).Warn("unexpected response") return uuid.UUID{}, errors.New("unexpected response") } } } } // TODO: LoadBookmark // send chatMessage to the assistant func (c *Client) SendChatMessage(ctx context.Context, m *sdp.ChatMessage) error { if c.Closed() { return errors.New("client closed") } log.WithContext(ctx).WithField("message", m).Trace("sending chat message via websocket") err := c.send(ctx, &sdp.GatewayRequest{ RequestType: &sdp.GatewayRequest_ChatMessage{ ChatMessage: m, }, }) if err != nil { return fmt.Errorf("error sending chat message: %w", err) } return nil } ================================================ FILE: go/sdp-go/signal.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: signal.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type AddSignalRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The user facing properties of the signal Properties *SignalProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` // UUID of the change this signal is associated with ChangeUUID []byte `protobuf:"bytes,2,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AddSignalRequest) Reset() { *x = AddSignalRequest{} mi := &file_signal_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AddSignalRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AddSignalRequest) ProtoMessage() {} func (x *AddSignalRequest) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AddSignalRequest.ProtoReflect.Descriptor instead. func (*AddSignalRequest) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{0} } func (x *AddSignalRequest) GetProperties() *SignalProperties { if x != nil { return x.Properties } return nil } func (x *AddSignalRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type AddSignalResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Signal *Signal `protobuf:"bytes,1,opt,name=signal,proto3" json:"signal,omitempty"` // The signal that was added unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AddSignalResponse) Reset() { *x = AddSignalResponse{} mi := &file_signal_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AddSignalResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*AddSignalResponse) ProtoMessage() {} func (x *AddSignalResponse) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AddSignalResponse.ProtoReflect.Descriptor instead. func (*AddSignalResponse) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{1} } func (x *AddSignalResponse) GetSignal() *Signal { if x != nil { return x.Signal } return nil } type GetSignalsByChangeExternalIDRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` // UUID of the change unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSignalsByChangeExternalIDRequest) Reset() { *x = GetSignalsByChangeExternalIDRequest{} mi := &file_signal_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSignalsByChangeExternalIDRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSignalsByChangeExternalIDRequest) ProtoMessage() {} func (x *GetSignalsByChangeExternalIDRequest) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSignalsByChangeExternalIDRequest.ProtoReflect.Descriptor instead. func (*GetSignalsByChangeExternalIDRequest) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{2} } func (x *GetSignalsByChangeExternalIDRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type GetSignalsByChangeExternalIDResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Signals []*Signal `protobuf:"bytes,1,rep,name=signals,proto3" json:"signals,omitempty"` // List of all signals associated with the change unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSignalsByChangeExternalIDResponse) Reset() { *x = GetSignalsByChangeExternalIDResponse{} mi := &file_signal_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSignalsByChangeExternalIDResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSignalsByChangeExternalIDResponse) ProtoMessage() {} func (x *GetSignalsByChangeExternalIDResponse) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSignalsByChangeExternalIDResponse.ProtoReflect.Descriptor instead. func (*GetSignalsByChangeExternalIDResponse) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{3} } func (x *GetSignalsByChangeExternalIDResponse) GetSignals() []*Signal { if x != nil { return x.Signals } return nil } type GetChangeOverviewSignalsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeOverviewSignalsRequest) Reset() { *x = GetChangeOverviewSignalsRequest{} mi := &file_signal_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeOverviewSignalsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeOverviewSignalsRequest) ProtoMessage() {} func (x *GetChangeOverviewSignalsRequest) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeOverviewSignalsRequest.ProtoReflect.Descriptor instead. func (*GetChangeOverviewSignalsRequest) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{4} } func (x *GetChangeOverviewSignalsRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type GetChangeOverviewSignalsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Signals []*Signal `protobuf:"bytes,1,rep,name=signals,proto3" json:"signals,omitempty"` // The aggregated value for all categories in the change, calculated by AggregateSignalScores. Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // Corresponds to float64 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetChangeOverviewSignalsResponse) Reset() { *x = GetChangeOverviewSignalsResponse{} mi := &file_signal_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetChangeOverviewSignalsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetChangeOverviewSignalsResponse) ProtoMessage() {} func (x *GetChangeOverviewSignalsResponse) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetChangeOverviewSignalsResponse.ProtoReflect.Descriptor instead. func (*GetChangeOverviewSignalsResponse) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{5} } func (x *GetChangeOverviewSignalsResponse) GetSignals() []*Signal { if x != nil { return x.Signals } return nil } func (x *GetChangeOverviewSignalsResponse) GetValue() float64 { if x != nil { return x.Value } return 0 } type ItemAggregation struct { state protoimpl.MessageState `protogen:"open.v1"` // A sorted list of signals for this item, by category. Signals []*Signal `protobuf:"bytes,1,rep,name=signals,proto3" json:"signals,omitempty"` // Corresponds to []*sdp.Signal // The aggregated value for this item, calculated by AggregateSignalScores. Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // Corresponds to float64 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ItemAggregation) Reset() { *x = ItemAggregation{} mi := &file_signal_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ItemAggregation) String() string { return protoimpl.X.MessageStringOf(x) } func (*ItemAggregation) ProtoMessage() {} func (x *ItemAggregation) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ItemAggregation.ProtoReflect.Descriptor instead. func (*ItemAggregation) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{6} } func (x *ItemAggregation) GetSignals() []*Signal { if x != nil { return x.Signals } return nil } func (x *ItemAggregation) GetValue() float64 { if x != nil { return x.Value } return 0 } type GetItemSignalsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetItemSignalsRequest) Reset() { *x = GetItemSignalsRequest{} mi := &file_signal_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetItemSignalsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetItemSignalsRequest) ProtoMessage() {} func (x *GetItemSignalsRequest) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetItemSignalsRequest.ProtoReflect.Descriptor instead. func (*GetItemSignalsRequest) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{7} } func (x *GetItemSignalsRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type GetItemSignalsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // A map of Globally Unique Names (GUNs) of items to their aggregation of signals. // These are by category, sorted by the signal value, ascending. // We also include a value for this GUN, which is calculated by AggregateSignalScores ItemAggregations map[string]*ItemAggregation `protobuf:"bytes,1,rep,name=itemAggregations,proto3" json:"itemAggregations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetItemSignalsResponse) Reset() { *x = GetItemSignalsResponse{} mi := &file_signal_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetItemSignalsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetItemSignalsResponse) ProtoMessage() {} func (x *GetItemSignalsResponse) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetItemSignalsResponse.ProtoReflect.Descriptor instead. func (*GetItemSignalsResponse) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{8} } func (x *GetItemSignalsResponse) GetItemAggregations() map[string]*ItemAggregation { if x != nil { return x.ItemAggregations } return nil } type GetItemSignalsRequestV2 struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetItemSignalsRequestV2) Reset() { *x = GetItemSignalsRequestV2{} mi := &file_signal_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetItemSignalsRequestV2) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetItemSignalsRequestV2) ProtoMessage() {} func (x *GetItemSignalsRequestV2) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetItemSignalsRequestV2.ProtoReflect.Descriptor instead. func (*GetItemSignalsRequestV2) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{9} } func (x *GetItemSignalsRequestV2) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } type ItemAggregationV2 struct { state protoimpl.MessageState `protogen:"open.v1"` // A sorted list of signals for this item, by category. Signals []*Signal `protobuf:"bytes,1,rep,name=signals,proto3" json:"signals,omitempty"` // Corresponds to []*sdp.Signal // The aggregated value for this item, calculated by AggregateSignalScores. Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // Corresponds to float64 // It is the friendly item reference, taken from the resolve mapping queries. // for handiness we wall back to the afterRef if the mappedRef is not available. MappedRef *Reference `protobuf:"bytes,3,opt,name=mappedRef,proto3" json:"mappedRef,omitempty"` // This it the item reference from after, it is used to look up the item in GetItemSignalDetailsRequest. AfterRef *Reference `protobuf:"bytes,4,opt,name=afterRef,proto3" json:"afterRef,omitempty"` // status is the status of the item, e.g. "added", "modified", "deleted". Status ItemDiffStatus `protobuf:"varint,5,opt,name=status,proto3,enum=changes.ItemDiffStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ItemAggregationV2) Reset() { *x = ItemAggregationV2{} mi := &file_signal_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ItemAggregationV2) String() string { return protoimpl.X.MessageStringOf(x) } func (*ItemAggregationV2) ProtoMessage() {} func (x *ItemAggregationV2) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ItemAggregationV2.ProtoReflect.Descriptor instead. func (*ItemAggregationV2) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{10} } func (x *ItemAggregationV2) GetSignals() []*Signal { if x != nil { return x.Signals } return nil } func (x *ItemAggregationV2) GetValue() float64 { if x != nil { return x.Value } return 0 } func (x *ItemAggregationV2) GetMappedRef() *Reference { if x != nil { return x.MappedRef } return nil } func (x *ItemAggregationV2) GetAfterRef() *Reference { if x != nil { return x.AfterRef } return nil } func (x *ItemAggregationV2) GetStatus() ItemDiffStatus { if x != nil { return x.Status } return ItemDiffStatus_ITEM_DIFF_STATUS_UNSPECIFIED } type GetItemSignalsResponseV2 struct { state protoimpl.MessageState `protogen:"open.v1"` // A map of Globally Unique Names (GUNs) of items to their aggregation of signals. // These are by category, sorted by the signal value, ascending. // We also include a value for this GUN, which is calculated by AggregateSignalScores ItemAggregations []*ItemAggregationV2 `protobuf:"bytes,1,rep,name=itemAggregations,proto3" json:"itemAggregations,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetItemSignalsResponseV2) Reset() { *x = GetItemSignalsResponseV2{} mi := &file_signal_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetItemSignalsResponseV2) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetItemSignalsResponseV2) ProtoMessage() {} func (x *GetItemSignalsResponseV2) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetItemSignalsResponseV2.ProtoReflect.Descriptor instead. func (*GetItemSignalsResponseV2) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{11} } func (x *GetItemSignalsResponseV2) GetItemAggregations() []*ItemAggregationV2 { if x != nil { return x.ItemAggregations } return nil } // Get all custom signals for a change by its external ID and category. They are NOT associated with any item. type GetCustomSignalsByCategoryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetCustomSignalsByCategoryRequest) Reset() { *x = GetCustomSignalsByCategoryRequest{} mi := &file_signal_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetCustomSignalsByCategoryRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetCustomSignalsByCategoryRequest) ProtoMessage() {} func (x *GetCustomSignalsByCategoryRequest) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetCustomSignalsByCategoryRequest.ProtoReflect.Descriptor instead. func (*GetCustomSignalsByCategoryRequest) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{12} } func (x *GetCustomSignalsByCategoryRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } func (x *GetCustomSignalsByCategoryRequest) GetCategory() string { if x != nil { return x.Category } return "" } // array of signals type GetCustomSignalsByCategoryResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Signals []*Signal `protobuf:"bytes,1,rep,name=signals,proto3" json:"signals,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetCustomSignalsByCategoryResponse) Reset() { *x = GetCustomSignalsByCategoryResponse{} mi := &file_signal_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetCustomSignalsByCategoryResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetCustomSignalsByCategoryResponse) ProtoMessage() {} func (x *GetCustomSignalsByCategoryResponse) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetCustomSignalsByCategoryResponse.ProtoReflect.Descriptor instead. func (*GetCustomSignalsByCategoryResponse) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{13} } func (x *GetCustomSignalsByCategoryResponse) GetSignals() []*Signal { if x != nil { return x.Signals } return nil } type GetItemSignalDetailsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The item for which we want to get the details of the signals. // it is the reference of the terraform item before/after. // NB it is not the lookup item from resolve mapping queries. ItemRef *Reference `protobuf:"bytes,1,opt,name=itemRef,proto3" json:"itemRef,omitempty"` // The UUID of the change this item is associated with. ChangeUUID []byte `protobuf:"bytes,2,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` // The category of the signals we want to get. This is used to filter the signals by category. Category string `protobuf:"bytes,3,opt,name=category,proto3" json:"category,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetItemSignalDetailsRequest) Reset() { *x = GetItemSignalDetailsRequest{} mi := &file_signal_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetItemSignalDetailsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetItemSignalDetailsRequest) ProtoMessage() {} func (x *GetItemSignalDetailsRequest) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetItemSignalDetailsRequest.ProtoReflect.Descriptor instead. func (*GetItemSignalDetailsRequest) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{14} } func (x *GetItemSignalDetailsRequest) GetItemRef() *Reference { if x != nil { return x.ItemRef } return nil } func (x *GetItemSignalDetailsRequest) GetChangeUUID() []byte { if x != nil { return x.ChangeUUID } return nil } func (x *GetItemSignalDetailsRequest) GetCategory() string { if x != nil { return x.Category } return "" } type GetItemSignalDetailsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Signals []*Signal `protobuf:"bytes,1,rep,name=signals,proto3" json:"signals,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetItemSignalDetailsResponse) Reset() { *x = GetItemSignalDetailsResponse{} mi := &file_signal_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetItemSignalDetailsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetItemSignalDetailsResponse) ProtoMessage() {} func (x *GetItemSignalDetailsResponse) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetItemSignalDetailsResponse.ProtoReflect.Descriptor instead. func (*GetItemSignalDetailsResponse) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{15} } func (x *GetItemSignalDetailsResponse) GetSignals() []*Signal { if x != nil { return x.Signals } return nil } type SignalMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SignalMetadata) Reset() { *x = SignalMetadata{} mi := &file_signal_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SignalMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*SignalMetadata) ProtoMessage() {} func (x *SignalMetadata) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SignalMetadata.ProtoReflect.Descriptor instead. func (*SignalMetadata) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{16} } type SignalProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // user-supplied properties of this signal // A short name for the signal Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // This is float64 value, representing the signal's value. -5 to +5. +5 is very high / strong, 0 is neutral, -5 is very low / weak. Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // A one sentence description of the signal, it could be activity in routineness, or another // descriptive text Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` // A category for the signal, e.g. "routineness", "anomaly", etc. How it will be grouped in the UI. Category string `protobuf:"bytes,4,opt,name=category,proto3" json:"category,omitempty"` // This is poorly named, this is the after item reference, equivalent to afterRef in ItemAggregationV2 // in the signals table this is the afterRef column. It is not the mappedRef / friendly item reference. Item *Reference `protobuf:"bytes,5,opt,name=item,proto3,oneof" json:"item,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SignalProperties) Reset() { *x = SignalProperties{} mi := &file_signal_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SignalProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*SignalProperties) ProtoMessage() {} func (x *SignalProperties) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SignalProperties.ProtoReflect.Descriptor instead. func (*SignalProperties) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{17} } func (x *SignalProperties) GetName() string { if x != nil { return x.Name } return "" } func (x *SignalProperties) GetValue() float64 { if x != nil { return x.Value } return 0 } func (x *SignalProperties) GetDescription() string { if x != nil { return x.Description } return "" } func (x *SignalProperties) GetCategory() string { if x != nil { return x.Category } return "" } func (x *SignalProperties) GetItem() *Reference { if x != nil { return x.Item } return nil } // we mimic the layout of the changes object here, because there are 2 parts // to a signal: the machine-generated metadata and the user-supplied properties. type Signal struct { state protoimpl.MessageState `protogen:"open.v1"` // machine-generated metadata of this signal Metadata *SignalMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` // user-supplied properties of this signal Properties *SignalProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Signal) Reset() { *x = Signal{} mi := &file_signal_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Signal) String() string { return protoimpl.X.MessageStringOf(x) } func (*Signal) ProtoMessage() {} func (x *Signal) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Signal.ProtoReflect.Descriptor instead. func (*Signal) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{18} } func (x *Signal) GetMetadata() *SignalMetadata { if x != nil { return x.Metadata } return nil } func (x *Signal) GetProperties() *SignalProperties { if x != nil { return x.Properties } return nil } // Structured output for GetChangeSummary JSON format type ChangeSummaryJSONOutput struct { state protoimpl.MessageState `protogen:"open.v1"` Change *Change `protobuf:"bytes,1,opt,name=change,proto3" json:"change,omitempty"` Risks []*Risk `protobuf:"bytes,2,rep,name=risks,proto3" json:"risks,omitempty"` Signals *GetChangeOverviewSignalsResponse `protobuf:"bytes,3,opt,name=signals,proto3" json:"signals,omitempty"` Hypotheses []*HypothesesDetails `protobuf:"bytes,4,rep,name=hypotheses,proto3" json:"hypotheses,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ChangeSummaryJSONOutput) Reset() { *x = ChangeSummaryJSONOutput{} mi := &file_signal_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ChangeSummaryJSONOutput) String() string { return protoimpl.X.MessageStringOf(x) } func (*ChangeSummaryJSONOutput) ProtoMessage() {} func (x *ChangeSummaryJSONOutput) ProtoReflect() protoreflect.Message { mi := &file_signal_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ChangeSummaryJSONOutput.ProtoReflect.Descriptor instead. func (*ChangeSummaryJSONOutput) Descriptor() ([]byte, []int) { return file_signal_proto_rawDescGZIP(), []int{19} } func (x *ChangeSummaryJSONOutput) GetChange() *Change { if x != nil { return x.Change } return nil } func (x *ChangeSummaryJSONOutput) GetRisks() []*Risk { if x != nil { return x.Risks } return nil } func (x *ChangeSummaryJSONOutput) GetSignals() *GetChangeOverviewSignalsResponse { if x != nil { return x.Signals } return nil } func (x *ChangeSummaryJSONOutput) GetHypotheses() []*HypothesesDetails { if x != nil { return x.Hypotheses } return nil } var File_signal_proto protoreflect.FileDescriptor const file_signal_proto_rawDesc = "" + "\n" + "\fsignal.proto\x12\x06signal\x1a\rchanges.proto\x1a\vitems.proto\"l\n" + "\x10AddSignalRequest\x128\n" + "\n" + "properties\x18\x01 \x01(\v2\x18.signal.SignalPropertiesR\n" + "properties\x12\x1e\n" + "\n" + "changeUUID\x18\x02 \x01(\fR\n" + "changeUUID\";\n" + "\x11AddSignalResponse\x12&\n" + "\x06signal\x18\x01 \x01(\v2\x0e.signal.SignalR\x06signal\"E\n" + "#GetSignalsByChangeExternalIDRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"P\n" + "$GetSignalsByChangeExternalIDResponse\x12(\n" + "\asignals\x18\x01 \x03(\v2\x0e.signal.SignalR\asignals\"A\n" + "\x1fGetChangeOverviewSignalsRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"b\n" + " GetChangeOverviewSignalsResponse\x12(\n" + "\asignals\x18\x01 \x03(\v2\x0e.signal.SignalR\asignals\x12\x14\n" + "\x05value\x18\x02 \x01(\x01R\x05value\"Q\n" + "\x0fItemAggregation\x12(\n" + "\asignals\x18\x01 \x03(\v2\x0e.signal.SignalR\asignals\x12\x14\n" + "\x05value\x18\x02 \x01(\x01R\x05value\"7\n" + "\x15GetItemSignalsRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"\xd8\x01\n" + "\x16GetItemSignalsResponse\x12`\n" + "\x10itemAggregations\x18\x01 \x03(\v24.signal.GetItemSignalsResponse.ItemAggregationsEntryR\x10itemAggregations\x1a\\\n" + "\x15ItemAggregationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12-\n" + "\x05value\x18\x02 \x01(\v2\x17.signal.ItemAggregationR\x05value:\x028\x01\"9\n" + "\x17GetItemSignalsRequestV2\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\"\xd6\x01\n" + "\x11ItemAggregationV2\x12(\n" + "\asignals\x18\x01 \x03(\v2\x0e.signal.SignalR\asignals\x12\x14\n" + "\x05value\x18\x02 \x01(\x01R\x05value\x12(\n" + "\tmappedRef\x18\x03 \x01(\v2\n" + ".ReferenceR\tmappedRef\x12&\n" + "\bafterRef\x18\x04 \x01(\v2\n" + ".ReferenceR\bafterRef\x12/\n" + "\x06status\x18\x05 \x01(\x0e2\x17.changes.ItemDiffStatusR\x06status\"a\n" + "\x18GetItemSignalsResponseV2\x12E\n" + "\x10itemAggregations\x18\x01 \x03(\v2\x19.signal.ItemAggregationV2R\x10itemAggregations\"_\n" + "!GetCustomSignalsByCategoryRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + "changeUUID\x12\x1a\n" + "\bcategory\x18\x02 \x01(\tR\bcategory\"N\n" + "\"GetCustomSignalsByCategoryResponse\x12(\n" + "\asignals\x18\x01 \x03(\v2\x0e.signal.SignalR\asignals\"\x7f\n" + "\x1bGetItemSignalDetailsRequest\x12$\n" + "\aitemRef\x18\x01 \x01(\v2\n" + ".ReferenceR\aitemRef\x12\x1e\n" + "\n" + "changeUUID\x18\x02 \x01(\fR\n" + "changeUUID\x12\x1a\n" + "\bcategory\x18\x03 \x01(\tR\bcategory\"H\n" + "\x1cGetItemSignalDetailsResponse\x12(\n" + "\asignals\x18\x01 \x03(\v2\x0e.signal.SignalR\asignals\"\x10\n" + "\x0eSignalMetadata\"\xa8\x01\n" + "\x10SignalProperties\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05value\x18\x02 \x01(\x01R\x05value\x12 \n" + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x1a\n" + "\bcategory\x18\x04 \x01(\tR\bcategory\x12#\n" + "\x04item\x18\x05 \x01(\v2\n" + ".ReferenceH\x00R\x04item\x88\x01\x01B\a\n" + "\x05_item\"v\n" + "\x06Signal\x122\n" + "\bmetadata\x18\x01 \x01(\v2\x16.signal.SignalMetadataR\bmetadata\x128\n" + "\n" + "properties\x18\x02 \x01(\v2\x18.signal.SignalPropertiesR\n" + "properties\"\xe7\x01\n" + "\x17ChangeSummaryJSONOutput\x12'\n" + "\x06change\x18\x01 \x01(\v2\x0f.changes.ChangeR\x06change\x12#\n" + "\x05risks\x18\x02 \x03(\v2\r.changes.RiskR\x05risks\x12B\n" + "\asignals\x18\x03 \x01(\v2(.signal.GetChangeOverviewSignalsResponseR\asignals\x12:\n" + "\n" + "hypotheses\x18\x04 \x03(\v2\x1a.changes.HypothesesDetailsR\n" + "hypotheses2\xbb\x05\n" + "\rSignalService\x12@\n" + "\tAddSignal\x12\x18.signal.AddSignalRequest\x1a\x19.signal.AddSignalResponse\x12y\n" + "\x1cGetSignalsByChangeExternalID\x12+.signal.GetSignalsByChangeExternalIDRequest\x1a,.signal.GetSignalsByChangeExternalIDResponse\x12m\n" + "\x18GetChangeOverviewSignals\x12'.signal.GetChangeOverviewSignalsRequest\x1a(.signal.GetChangeOverviewSignalsResponse\x12O\n" + "\x0eGetItemSignals\x12\x1d.signal.GetItemSignalsRequest\x1a\x1e.signal.GetItemSignalsResponse\x12U\n" + "\x10GetItemSignalsV2\x12\x1f.signal.GetItemSignalsRequestV2\x1a .signal.GetItemSignalsResponseV2\x12s\n" + "\x1aGetCustomSignalsByCategory\x12).signal.GetCustomSignalsByCategoryRequest\x1a*.signal.GetCustomSignalsByCategoryResponse\x12a\n" + "\x14GetItemSignalDetails\x12#.signal.GetItemSignalDetailsRequest\x1a$.signal.GetItemSignalDetailsResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_signal_proto_rawDescOnce sync.Once file_signal_proto_rawDescData []byte ) func file_signal_proto_rawDescGZIP() []byte { file_signal_proto_rawDescOnce.Do(func() { file_signal_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_signal_proto_rawDesc), len(file_signal_proto_rawDesc))) }) return file_signal_proto_rawDescData } var file_signal_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_signal_proto_goTypes = []any{ (*AddSignalRequest)(nil), // 0: signal.AddSignalRequest (*AddSignalResponse)(nil), // 1: signal.AddSignalResponse (*GetSignalsByChangeExternalIDRequest)(nil), // 2: signal.GetSignalsByChangeExternalIDRequest (*GetSignalsByChangeExternalIDResponse)(nil), // 3: signal.GetSignalsByChangeExternalIDResponse (*GetChangeOverviewSignalsRequest)(nil), // 4: signal.GetChangeOverviewSignalsRequest (*GetChangeOverviewSignalsResponse)(nil), // 5: signal.GetChangeOverviewSignalsResponse (*ItemAggregation)(nil), // 6: signal.ItemAggregation (*GetItemSignalsRequest)(nil), // 7: signal.GetItemSignalsRequest (*GetItemSignalsResponse)(nil), // 8: signal.GetItemSignalsResponse (*GetItemSignalsRequestV2)(nil), // 9: signal.GetItemSignalsRequestV2 (*ItemAggregationV2)(nil), // 10: signal.ItemAggregationV2 (*GetItemSignalsResponseV2)(nil), // 11: signal.GetItemSignalsResponseV2 (*GetCustomSignalsByCategoryRequest)(nil), // 12: signal.GetCustomSignalsByCategoryRequest (*GetCustomSignalsByCategoryResponse)(nil), // 13: signal.GetCustomSignalsByCategoryResponse (*GetItemSignalDetailsRequest)(nil), // 14: signal.GetItemSignalDetailsRequest (*GetItemSignalDetailsResponse)(nil), // 15: signal.GetItemSignalDetailsResponse (*SignalMetadata)(nil), // 16: signal.SignalMetadata (*SignalProperties)(nil), // 17: signal.SignalProperties (*Signal)(nil), // 18: signal.Signal (*ChangeSummaryJSONOutput)(nil), // 19: signal.ChangeSummaryJSONOutput nil, // 20: signal.GetItemSignalsResponse.ItemAggregationsEntry (*Reference)(nil), // 21: Reference (ItemDiffStatus)(0), // 22: changes.ItemDiffStatus (*Change)(nil), // 23: changes.Change (*Risk)(nil), // 24: changes.Risk (*HypothesesDetails)(nil), // 25: changes.HypothesesDetails } var file_signal_proto_depIdxs = []int32{ 17, // 0: signal.AddSignalRequest.properties:type_name -> signal.SignalProperties 18, // 1: signal.AddSignalResponse.signal:type_name -> signal.Signal 18, // 2: signal.GetSignalsByChangeExternalIDResponse.signals:type_name -> signal.Signal 18, // 3: signal.GetChangeOverviewSignalsResponse.signals:type_name -> signal.Signal 18, // 4: signal.ItemAggregation.signals:type_name -> signal.Signal 20, // 5: signal.GetItemSignalsResponse.itemAggregations:type_name -> signal.GetItemSignalsResponse.ItemAggregationsEntry 18, // 6: signal.ItemAggregationV2.signals:type_name -> signal.Signal 21, // 7: signal.ItemAggregationV2.mappedRef:type_name -> Reference 21, // 8: signal.ItemAggregationV2.afterRef:type_name -> Reference 22, // 9: signal.ItemAggregationV2.status:type_name -> changes.ItemDiffStatus 10, // 10: signal.GetItemSignalsResponseV2.itemAggregations:type_name -> signal.ItemAggregationV2 18, // 11: signal.GetCustomSignalsByCategoryResponse.signals:type_name -> signal.Signal 21, // 12: signal.GetItemSignalDetailsRequest.itemRef:type_name -> Reference 18, // 13: signal.GetItemSignalDetailsResponse.signals:type_name -> signal.Signal 21, // 14: signal.SignalProperties.item:type_name -> Reference 16, // 15: signal.Signal.metadata:type_name -> signal.SignalMetadata 17, // 16: signal.Signal.properties:type_name -> signal.SignalProperties 23, // 17: signal.ChangeSummaryJSONOutput.change:type_name -> changes.Change 24, // 18: signal.ChangeSummaryJSONOutput.risks:type_name -> changes.Risk 5, // 19: signal.ChangeSummaryJSONOutput.signals:type_name -> signal.GetChangeOverviewSignalsResponse 25, // 20: signal.ChangeSummaryJSONOutput.hypotheses:type_name -> changes.HypothesesDetails 6, // 21: signal.GetItemSignalsResponse.ItemAggregationsEntry.value:type_name -> signal.ItemAggregation 0, // 22: signal.SignalService.AddSignal:input_type -> signal.AddSignalRequest 2, // 23: signal.SignalService.GetSignalsByChangeExternalID:input_type -> signal.GetSignalsByChangeExternalIDRequest 4, // 24: signal.SignalService.GetChangeOverviewSignals:input_type -> signal.GetChangeOverviewSignalsRequest 7, // 25: signal.SignalService.GetItemSignals:input_type -> signal.GetItemSignalsRequest 9, // 26: signal.SignalService.GetItemSignalsV2:input_type -> signal.GetItemSignalsRequestV2 12, // 27: signal.SignalService.GetCustomSignalsByCategory:input_type -> signal.GetCustomSignalsByCategoryRequest 14, // 28: signal.SignalService.GetItemSignalDetails:input_type -> signal.GetItemSignalDetailsRequest 1, // 29: signal.SignalService.AddSignal:output_type -> signal.AddSignalResponse 3, // 30: signal.SignalService.GetSignalsByChangeExternalID:output_type -> signal.GetSignalsByChangeExternalIDResponse 5, // 31: signal.SignalService.GetChangeOverviewSignals:output_type -> signal.GetChangeOverviewSignalsResponse 8, // 32: signal.SignalService.GetItemSignals:output_type -> signal.GetItemSignalsResponse 11, // 33: signal.SignalService.GetItemSignalsV2:output_type -> signal.GetItemSignalsResponseV2 13, // 34: signal.SignalService.GetCustomSignalsByCategory:output_type -> signal.GetCustomSignalsByCategoryResponse 15, // 35: signal.SignalService.GetItemSignalDetails:output_type -> signal.GetItemSignalDetailsResponse 29, // [29:36] is the sub-list for method output_type 22, // [22:29] is the sub-list for method input_type 22, // [22:22] is the sub-list for extension type_name 22, // [22:22] is the sub-list for extension extendee 0, // [0:22] is the sub-list for field type_name } func init() { file_signal_proto_init() } func file_signal_proto_init() { if File_signal_proto != nil { return } file_changes_proto_init() file_items_proto_init() file_signal_proto_msgTypes[17].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_signal_proto_rawDesc), len(file_signal_proto_rawDesc)), NumEnums: 0, NumMessages: 21, NumExtensions: 0, NumServices: 1, }, GoTypes: file_signal_proto_goTypes, DependencyIndexes: file_signal_proto_depIdxs, MessageInfos: file_signal_proto_msgTypes, }.Build() File_signal_proto = out.File file_signal_proto_goTypes = nil file_signal_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/signals.go ================================================ package sdp type SignalCategoryName string // SignalCategoryName constants represent the predefined categories for signals. // if you add a new category, please also update the cli command "submit-signal" @ cli/cmd/changes_submit_signal.go const ( SignalCategoryNameCustom SignalCategoryName = "Custom" SignalCategoryNameRoutine SignalCategoryName = "Routine" ) ================================================ FILE: go/sdp-go/snapshots.go ================================================ package sdp import "github.com/google/uuid" // ToMap converts a Snapshot to a map for serialization. func (s *Snapshot) ToMap() map[string]any { return map[string]any{ "metadata": s.GetMetadata().ToMap(), "properties": s.GetProperties().ToMap(), } } // ToMap converts SnapshotMetadata to a map for serialization. func (sm *SnapshotMetadata) ToMap() map[string]any { return map[string]any{ "UUID": stringFromUuidBytes(sm.GetUUID()), "created": sm.GetCreated().AsTime(), } } // GetUUIDParsed returns the parsed UUID from the SnapshotMetadata, or nil if invalid. func (sm *SnapshotMetadata) GetUUIDParsed() *uuid.UUID { if sm == nil { return nil } u, err := uuid.FromBytes(sm.GetUUID()) if err != nil { return nil } return &u } // ToMap converts SnapshotProperties to a map for serialization. func (sp *SnapshotProperties) ToMap() map[string]any { return map[string]any{ "name": sp.GetName(), "description": sp.GetDescription(), "queries": sp.GetQueries(), "Items": sp.GetItems(), } } ================================================ FILE: go/sdp-go/snapshots.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: snapshots.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Snapshot struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *SnapshotMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Properties *SnapshotProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Snapshot) Reset() { *x = Snapshot{} mi := &file_snapshots_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Snapshot) String() string { return protoimpl.X.MessageStringOf(x) } func (*Snapshot) ProtoMessage() {} func (x *Snapshot) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Snapshot.ProtoReflect.Descriptor instead. func (*Snapshot) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{0} } func (x *Snapshot) GetMetadata() *SnapshotMetadata { if x != nil { return x.Metadata } return nil } func (x *Snapshot) GetProperties() *SnapshotProperties { if x != nil { return x.Properties } return nil } type SnapshotProperties struct { state protoimpl.MessageState `protogen:"open.v1"` // name of this snapshot Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // description of this snapshot Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` // queries that make up the snapshot Queries []*Query `protobuf:"bytes,3,rep,name=queries,proto3" json:"queries,omitempty"` // items stored in the snapshot Items []*Item `protobuf:"bytes,5,rep,name=items,proto3" json:"items,omitempty"` // edges stored in the snapshot Edges []*Edge `protobuf:"bytes,6,rep,name=edges,proto3" json:"edges,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SnapshotProperties) Reset() { *x = SnapshotProperties{} mi := &file_snapshots_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SnapshotProperties) String() string { return protoimpl.X.MessageStringOf(x) } func (*SnapshotProperties) ProtoMessage() {} func (x *SnapshotProperties) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SnapshotProperties.ProtoReflect.Descriptor instead. func (*SnapshotProperties) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{1} } func (x *SnapshotProperties) GetName() string { if x != nil { return x.Name } return "" } func (x *SnapshotProperties) GetDescription() string { if x != nil { return x.Description } return "" } func (x *SnapshotProperties) GetQueries() []*Query { if x != nil { return x.Queries } return nil } func (x *SnapshotProperties) GetItems() []*Item { if x != nil { return x.Items } return nil } func (x *SnapshotProperties) GetEdges() []*Edge { if x != nil { return x.Edges } return nil } type SnapshotMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // unique id to identify this snapshot UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` // timestamp when this snapshot was created Created *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created,proto3" json:"created,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SnapshotMetadata) Reset() { *x = SnapshotMetadata{} mi := &file_snapshots_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SnapshotMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*SnapshotMetadata) ProtoMessage() {} func (x *SnapshotMetadata) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SnapshotMetadata.ProtoReflect.Descriptor instead. func (*SnapshotMetadata) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{2} } func (x *SnapshotMetadata) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *SnapshotMetadata) GetCreated() *timestamppb.Timestamp { if x != nil { return x.Created } return nil } // lists all snapshots type ListSnapshotsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListSnapshotsRequest) Reset() { *x = ListSnapshotsRequest{} mi := &file_snapshots_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListSnapshotsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListSnapshotsRequest) ProtoMessage() {} func (x *ListSnapshotsRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListSnapshotsRequest.ProtoReflect.Descriptor instead. func (*ListSnapshotsRequest) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{3} } type ListSnapshotResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // the list of all snapshots Snapshots []*Snapshot `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListSnapshotResponse) Reset() { *x = ListSnapshotResponse{} mi := &file_snapshots_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListSnapshotResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListSnapshotResponse) ProtoMessage() {} func (x *ListSnapshotResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListSnapshotResponse.ProtoReflect.Descriptor instead. func (*ListSnapshotResponse) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{4} } func (x *ListSnapshotResponse) GetSnapshots() []*Snapshot { if x != nil { return x.Snapshots } return nil } // creates a new snapshot type CreateSnapshotRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // properties of the new snapshot Properties *SnapshotProperties `protobuf:"bytes,1,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateSnapshotRequest) Reset() { *x = CreateSnapshotRequest{} mi := &file_snapshots_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateSnapshotRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateSnapshotRequest) ProtoMessage() {} func (x *CreateSnapshotRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateSnapshotRequest.ProtoReflect.Descriptor instead. func (*CreateSnapshotRequest) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{5} } func (x *CreateSnapshotRequest) GetProperties() *SnapshotProperties { if x != nil { return x.Properties } return nil } type CreateSnapshotResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // the newly created snapshot Snapshot *Snapshot `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateSnapshotResponse) Reset() { *x = CreateSnapshotResponse{} mi := &file_snapshots_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CreateSnapshotResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*CreateSnapshotResponse) ProtoMessage() {} func (x *CreateSnapshotResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CreateSnapshotResponse.ProtoReflect.Descriptor instead. func (*CreateSnapshotResponse) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{6} } func (x *CreateSnapshotResponse) GetSnapshot() *Snapshot { if x != nil { return x.Snapshot } return nil } // get the details of a specific snapshot type GetSnapshotRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSnapshotRequest) Reset() { *x = GetSnapshotRequest{} mi := &file_snapshots_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSnapshotRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSnapshotRequest) ProtoMessage() {} func (x *GetSnapshotRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSnapshotRequest.ProtoReflect.Descriptor instead. func (*GetSnapshotRequest) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{7} } func (x *GetSnapshotRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type GetSnapshotResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Snapshot *Snapshot `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetSnapshotResponse) Reset() { *x = GetSnapshotResponse{} mi := &file_snapshots_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetSnapshotResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetSnapshotResponse) ProtoMessage() {} func (x *GetSnapshotResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetSnapshotResponse.ProtoReflect.Descriptor instead. func (*GetSnapshotResponse) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{8} } func (x *GetSnapshotResponse) GetSnapshot() *Snapshot { if x != nil { return x.Snapshot } return nil } // updates the properties of an existing snapshot type UpdateSnapshotRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` Properties *SnapshotProperties `protobuf:"bytes,2,opt,name=properties,proto3" json:"properties,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateSnapshotRequest) Reset() { *x = UpdateSnapshotRequest{} mi := &file_snapshots_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateSnapshotRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateSnapshotRequest) ProtoMessage() {} func (x *UpdateSnapshotRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateSnapshotRequest.ProtoReflect.Descriptor instead. func (*UpdateSnapshotRequest) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{9} } func (x *UpdateSnapshotRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } func (x *UpdateSnapshotRequest) GetProperties() *SnapshotProperties { if x != nil { return x.Properties } return nil } type UpdateSnapshotResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // the updated version of the snapshot Snapshot *Snapshot `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateSnapshotResponse) Reset() { *x = UpdateSnapshotResponse{} mi := &file_snapshots_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateSnapshotResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateSnapshotResponse) ProtoMessage() {} func (x *UpdateSnapshotResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateSnapshotResponse.ProtoReflect.Descriptor instead. func (*UpdateSnapshotResponse) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{10} } func (x *UpdateSnapshotResponse) GetSnapshot() *Snapshot { if x != nil { return x.Snapshot } return nil } // deletes a given snapshot type DeleteSnapshotRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteSnapshotRequest) Reset() { *x = DeleteSnapshotRequest{} mi := &file_snapshots_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteSnapshotRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteSnapshotRequest) ProtoMessage() {} func (x *DeleteSnapshotRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteSnapshotRequest.ProtoReflect.Descriptor instead. func (*DeleteSnapshotRequest) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{11} } func (x *DeleteSnapshotRequest) GetUUID() []byte { if x != nil { return x.UUID } return nil } type DeleteSnapshotResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeleteSnapshotResponse) Reset() { *x = DeleteSnapshotResponse{} mi := &file_snapshots_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DeleteSnapshotResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*DeleteSnapshotResponse) ProtoMessage() {} func (x *DeleteSnapshotResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DeleteSnapshotResponse.ProtoReflect.Descriptor instead. func (*DeleteSnapshotResponse) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{12} } // get the initial data type GetInitialDataRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetInitialDataRequest) Reset() { *x = GetInitialDataRequest{} mi := &file_snapshots_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetInitialDataRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetInitialDataRequest) ProtoMessage() {} func (x *GetInitialDataRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetInitialDataRequest.ProtoReflect.Descriptor instead. func (*GetInitialDataRequest) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{13} } type GetInitialDataResponse struct { state protoimpl.MessageState `protogen:"open.v1"` BlastRadiusSnapshot *Snapshot `protobuf:"bytes,1,opt,name=blastRadiusSnapshot,proto3" json:"blastRadiusSnapshot,omitempty"` ChangingItemsBookmark *Bookmark `protobuf:"bytes,2,opt,name=changingItemsBookmark,proto3" json:"changingItemsBookmark,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetInitialDataResponse) Reset() { *x = GetInitialDataResponse{} mi := &file_snapshots_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetInitialDataResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetInitialDataResponse) ProtoMessage() {} func (x *GetInitialDataResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetInitialDataResponse.ProtoReflect.Descriptor instead. func (*GetInitialDataResponse) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{14} } func (x *GetInitialDataResponse) GetBlastRadiusSnapshot() *Snapshot { if x != nil { return x.BlastRadiusSnapshot } return nil } func (x *GetInitialDataResponse) GetChangingItemsBookmark() *Bookmark { if x != nil { return x.ChangingItemsBookmark } return nil } type ListSnapshotsByGUNRequest struct { state protoimpl.MessageState `protogen:"open.v1"` GloballyUniqueName string `protobuf:"bytes,1,opt,name=globallyUniqueName,proto3" json:"globallyUniqueName,omitempty"` Pagination *PaginationRequest `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListSnapshotsByGUNRequest) Reset() { *x = ListSnapshotsByGUNRequest{} mi := &file_snapshots_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListSnapshotsByGUNRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListSnapshotsByGUNRequest) ProtoMessage() {} func (x *ListSnapshotsByGUNRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListSnapshotsByGUNRequest.ProtoReflect.Descriptor instead. func (*ListSnapshotsByGUNRequest) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{15} } func (x *ListSnapshotsByGUNRequest) GetGloballyUniqueName() string { if x != nil { return x.GloballyUniqueName } return "" } func (x *ListSnapshotsByGUNRequest) GetPagination() *PaginationRequest { if x != nil { return x.Pagination } return nil } type ListSnapshotsByGUNResponse struct { state protoimpl.MessageState `protogen:"open.v1"` UUIDs [][]byte `protobuf:"bytes,1,rep,name=UUIDs,proto3" json:"UUIDs,omitempty"` Pagination *PaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ListSnapshotsByGUNResponse) Reset() { *x = ListSnapshotsByGUNResponse{} mi := &file_snapshots_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ListSnapshotsByGUNResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ListSnapshotsByGUNResponse) ProtoMessage() {} func (x *ListSnapshotsByGUNResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshots_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ListSnapshotsByGUNResponse.ProtoReflect.Descriptor instead. func (*ListSnapshotsByGUNResponse) Descriptor() ([]byte, []int) { return file_snapshots_proto_rawDescGZIP(), []int{16} } func (x *ListSnapshotsByGUNResponse) GetUUIDs() [][]byte { if x != nil { return x.UUIDs } return nil } func (x *ListSnapshotsByGUNResponse) GetPagination() *PaginationResponse { if x != nil { return x.Pagination } return nil } var File_snapshots_proto protoreflect.FileDescriptor const file_snapshots_proto_rawDesc = "" + "\n" + "\x0fsnapshots.proto\x12\tsnapshots\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x0fbookmarks.proto\x1a\vitems.proto\x1a\n" + "util.proto\"\x82\x01\n" + "\bSnapshot\x127\n" + "\bmetadata\x18\x01 \x01(\v2\x1b.snapshots.SnapshotMetadataR\bmetadata\x12=\n" + "\n" + "properties\x18\x02 \x01(\v2\x1d.snapshots.SnapshotPropertiesR\n" + "properties\"\xac\x01\n" + "\x12SnapshotProperties\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12 \n" + "\aqueries\x18\x03 \x03(\v2\x06.QueryR\aqueries\x12\x1b\n" + "\x05items\x18\x05 \x03(\v2\x05.ItemR\x05items\x12\x1b\n" + "\x05edges\x18\x06 \x03(\v2\x05.EdgeR\x05edgesJ\x04\b\x04\x10\x05\"\\\n" + "\x10SnapshotMetadata\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x124\n" + "\acreated\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\acreated\"\x16\n" + "\x14ListSnapshotsRequest\"I\n" + "\x14ListSnapshotResponse\x121\n" + "\tsnapshots\x18\x01 \x03(\v2\x13.snapshots.SnapshotR\tsnapshots\"V\n" + "\x15CreateSnapshotRequest\x12=\n" + "\n" + "properties\x18\x01 \x01(\v2\x1d.snapshots.SnapshotPropertiesR\n" + "properties\"I\n" + "\x16CreateSnapshotResponse\x12/\n" + "\bsnapshot\x18\x01 \x01(\v2\x13.snapshots.SnapshotR\bsnapshot\"(\n" + "\x12GetSnapshotRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"F\n" + "\x13GetSnapshotResponse\x12/\n" + "\bsnapshot\x18\x01 \x01(\v2\x13.snapshots.SnapshotR\bsnapshot\"j\n" + "\x15UpdateSnapshotRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12=\n" + "\n" + "properties\x18\x02 \x01(\v2\x1d.snapshots.SnapshotPropertiesR\n" + "properties\"I\n" + "\x16UpdateSnapshotResponse\x12/\n" + "\bsnapshot\x18\x01 \x01(\v2\x13.snapshots.SnapshotR\bsnapshot\"+\n" + "\x15DeleteSnapshotRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\"\x18\n" + "\x16DeleteSnapshotResponse\"\x17\n" + "\x15GetInitialDataRequest\"\xaa\x01\n" + "\x16GetInitialDataResponse\x12E\n" + "\x13blastRadiusSnapshot\x18\x01 \x01(\v2\x13.snapshots.SnapshotR\x13blastRadiusSnapshot\x12I\n" + "\x15changingItemsBookmark\x18\x02 \x01(\v2\x13.bookmarks.BookmarkR\x15changingItemsBookmark\"\x7f\n" + "\x19ListSnapshotsByGUNRequest\x12.\n" + "\x12globallyUniqueName\x18\x01 \x01(\tR\x12globallyUniqueName\x122\n" + "\n" + "pagination\x18\x02 \x01(\v2\x12.PaginationRequestR\n" + "pagination\"g\n" + "\x1aListSnapshotsByGUNResponse\x12\x14\n" + "\x05UUIDs\x18\x01 \x03(\fR\x05UUIDs\x123\n" + "\n" + "pagination\x18\x02 \x01(\v2\x13.PaginationResponseR\n" + "pagination2\x9a\x04\n" + "\x10SnapshotsService\x12Q\n" + "\rListSnapshots\x12\x1f.snapshots.ListSnapshotsRequest\x1a\x1f.snapshots.ListSnapshotResponse\x12U\n" + "\x0eCreateSnapshot\x12 .snapshots.CreateSnapshotRequest\x1a!.snapshots.CreateSnapshotResponse\x12L\n" + "\vGetSnapshot\x12\x1d.snapshots.GetSnapshotRequest\x1a\x1e.snapshots.GetSnapshotResponse\x12U\n" + "\x0eUpdateSnapshot\x12 .snapshots.UpdateSnapshotRequest\x1a!.snapshots.UpdateSnapshotResponse\x12U\n" + "\x0eDeleteSnapshot\x12 .snapshots.DeleteSnapshotRequest\x1a!.snapshots.DeleteSnapshotResponse\x12`\n" + "\x11ListSnapshotByGUN\x12$.snapshots.ListSnapshotsByGUNRequest\x1a%.snapshots.ListSnapshotsByGUNResponseB1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_snapshots_proto_rawDescOnce sync.Once file_snapshots_proto_rawDescData []byte ) func file_snapshots_proto_rawDescGZIP() []byte { file_snapshots_proto_rawDescOnce.Do(func() { file_snapshots_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_snapshots_proto_rawDesc), len(file_snapshots_proto_rawDesc))) }) return file_snapshots_proto_rawDescData } var file_snapshots_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_snapshots_proto_goTypes = []any{ (*Snapshot)(nil), // 0: snapshots.Snapshot (*SnapshotProperties)(nil), // 1: snapshots.SnapshotProperties (*SnapshotMetadata)(nil), // 2: snapshots.SnapshotMetadata (*ListSnapshotsRequest)(nil), // 3: snapshots.ListSnapshotsRequest (*ListSnapshotResponse)(nil), // 4: snapshots.ListSnapshotResponse (*CreateSnapshotRequest)(nil), // 5: snapshots.CreateSnapshotRequest (*CreateSnapshotResponse)(nil), // 6: snapshots.CreateSnapshotResponse (*GetSnapshotRequest)(nil), // 7: snapshots.GetSnapshotRequest (*GetSnapshotResponse)(nil), // 8: snapshots.GetSnapshotResponse (*UpdateSnapshotRequest)(nil), // 9: snapshots.UpdateSnapshotRequest (*UpdateSnapshotResponse)(nil), // 10: snapshots.UpdateSnapshotResponse (*DeleteSnapshotRequest)(nil), // 11: snapshots.DeleteSnapshotRequest (*DeleteSnapshotResponse)(nil), // 12: snapshots.DeleteSnapshotResponse (*GetInitialDataRequest)(nil), // 13: snapshots.GetInitialDataRequest (*GetInitialDataResponse)(nil), // 14: snapshots.GetInitialDataResponse (*ListSnapshotsByGUNRequest)(nil), // 15: snapshots.ListSnapshotsByGUNRequest (*ListSnapshotsByGUNResponse)(nil), // 16: snapshots.ListSnapshotsByGUNResponse (*Query)(nil), // 17: Query (*Item)(nil), // 18: Item (*Edge)(nil), // 19: Edge (*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp (*Bookmark)(nil), // 21: bookmarks.Bookmark (*PaginationRequest)(nil), // 22: PaginationRequest (*PaginationResponse)(nil), // 23: PaginationResponse } var file_snapshots_proto_depIdxs = []int32{ 2, // 0: snapshots.Snapshot.metadata:type_name -> snapshots.SnapshotMetadata 1, // 1: snapshots.Snapshot.properties:type_name -> snapshots.SnapshotProperties 17, // 2: snapshots.SnapshotProperties.queries:type_name -> Query 18, // 3: snapshots.SnapshotProperties.items:type_name -> Item 19, // 4: snapshots.SnapshotProperties.edges:type_name -> Edge 20, // 5: snapshots.SnapshotMetadata.created:type_name -> google.protobuf.Timestamp 0, // 6: snapshots.ListSnapshotResponse.snapshots:type_name -> snapshots.Snapshot 1, // 7: snapshots.CreateSnapshotRequest.properties:type_name -> snapshots.SnapshotProperties 0, // 8: snapshots.CreateSnapshotResponse.snapshot:type_name -> snapshots.Snapshot 0, // 9: snapshots.GetSnapshotResponse.snapshot:type_name -> snapshots.Snapshot 1, // 10: snapshots.UpdateSnapshotRequest.properties:type_name -> snapshots.SnapshotProperties 0, // 11: snapshots.UpdateSnapshotResponse.snapshot:type_name -> snapshots.Snapshot 0, // 12: snapshots.GetInitialDataResponse.blastRadiusSnapshot:type_name -> snapshots.Snapshot 21, // 13: snapshots.GetInitialDataResponse.changingItemsBookmark:type_name -> bookmarks.Bookmark 22, // 14: snapshots.ListSnapshotsByGUNRequest.pagination:type_name -> PaginationRequest 23, // 15: snapshots.ListSnapshotsByGUNResponse.pagination:type_name -> PaginationResponse 3, // 16: snapshots.SnapshotsService.ListSnapshots:input_type -> snapshots.ListSnapshotsRequest 5, // 17: snapshots.SnapshotsService.CreateSnapshot:input_type -> snapshots.CreateSnapshotRequest 7, // 18: snapshots.SnapshotsService.GetSnapshot:input_type -> snapshots.GetSnapshotRequest 9, // 19: snapshots.SnapshotsService.UpdateSnapshot:input_type -> snapshots.UpdateSnapshotRequest 11, // 20: snapshots.SnapshotsService.DeleteSnapshot:input_type -> snapshots.DeleteSnapshotRequest 15, // 21: snapshots.SnapshotsService.ListSnapshotByGUN:input_type -> snapshots.ListSnapshotsByGUNRequest 4, // 22: snapshots.SnapshotsService.ListSnapshots:output_type -> snapshots.ListSnapshotResponse 6, // 23: snapshots.SnapshotsService.CreateSnapshot:output_type -> snapshots.CreateSnapshotResponse 8, // 24: snapshots.SnapshotsService.GetSnapshot:output_type -> snapshots.GetSnapshotResponse 10, // 25: snapshots.SnapshotsService.UpdateSnapshot:output_type -> snapshots.UpdateSnapshotResponse 12, // 26: snapshots.SnapshotsService.DeleteSnapshot:output_type -> snapshots.DeleteSnapshotResponse 16, // 27: snapshots.SnapshotsService.ListSnapshotByGUN:output_type -> snapshots.ListSnapshotsByGUNResponse 22, // [22:28] is the sub-list for method output_type 16, // [16:22] is the sub-list for method input_type 16, // [16:16] is the sub-list for extension type_name 16, // [16:16] is the sub-list for extension extendee 0, // [0:16] is the sub-list for field type_name } func init() { file_snapshots_proto_init() } func file_snapshots_proto_init() { if File_snapshots_proto != nil { return } file_bookmarks_proto_init() file_items_proto_init() file_util_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_snapshots_proto_rawDesc), len(file_snapshots_proto_rawDesc)), NumEnums: 0, NumMessages: 17, NumExtensions: 0, NumServices: 1, }, GoTypes: file_snapshots_proto_goTypes, DependencyIndexes: file_snapshots_proto_depIdxs, MessageInfos: file_snapshots_proto_msgTypes, }.Build() File_snapshots_proto = out.File file_snapshots_proto_goTypes = nil file_snapshots_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/test_utils.go ================================================ package sdp import ( "context" "errors" "fmt" "math/rand" "regexp" "strings" sync "sync" "github.com/nats-io/nats.go" "google.golang.org/protobuf/proto" ) type ResponseMessage struct { Subject string V any } // TestConnection Used to mock a NATS connection for testing type TestConnection struct { Messages []ResponseMessage MessagesMu sync.Mutex // If set, the test connection will not return ErrNoResponders if someone // tries to publish a message to a subject with no responders IgnoreNoResponders bool Subscriptions map[*regexp.Regexp][]nats.MsgHandler subscriptionsMutex sync.RWMutex } // assert interface implementation var _ EncodedConnection = (*TestConnection)(nil) // Publish Test publish method, notes down the subject and the message func (t *TestConnection) Publish(ctx context.Context, subj string, m proto.Message) error { t.MessagesMu.Lock() t.Messages = append(t.Messages, ResponseMessage{ Subject: subj, V: m, }) t.MessagesMu.Unlock() data, err := proto.Marshal(m) if err != nil { return err } msg := nats.Msg{ Subject: subj, Data: data, } return t.runHandlers(&msg) } // PublishRequest Test publish method, notes down the subject and the message func (t *TestConnection) PublishRequest(ctx context.Context, subj, replyTo string, m proto.Message) error { t.MessagesMu.Lock() t.Messages = append(t.Messages, ResponseMessage{ Subject: subj, V: m, }) t.MessagesMu.Unlock() data, err := proto.Marshal(m) if err != nil { return err } msg := nats.Msg{ Subject: subj, Data: data, Header: nats.Header{}, } msg.Header.Add("reply-to", replyTo) return t.runHandlers(&msg) } // PublishMsg Test publish method, notes down the subject and the message func (t *TestConnection) PublishMsg(ctx context.Context, msg *nats.Msg) error { t.MessagesMu.Lock() t.Messages = append(t.Messages, ResponseMessage{ Subject: msg.Subject, V: msg.Data, }) t.MessagesMu.Unlock() err := t.runHandlers(msg) if err != nil { return err } return nil } func (t *TestConnection) Subscribe(subj string, cb nats.MsgHandler) (*nats.Subscription, error) { t.subscriptionsMutex.Lock() defer t.subscriptionsMutex.Unlock() if t.Subscriptions == nil { t.Subscriptions = make(map[*regexp.Regexp][]nats.MsgHandler) } regex := t.subjectToRegexp(subj) t.Subscriptions[regex] = append(t.Subscriptions[regex], cb) return nil, nil } func (t *TestConnection) QueueSubscribe(subj, queue string, cb nats.MsgHandler) (*nats.Subscription, error) { // TODO: implement queue groups here return t.Subscribe(subj, cb) } func (r *TestConnection) subjectToRegexp(subject string) *regexp.Regexp { // If the subject contains a > then handle this if strings.Contains(subject, ">") { // Escape regex to literal quoted := regexp.QuoteMeta(subject) // Replace > with .*$ return regexp.MustCompile(strings.ReplaceAll(quoted, ">", ".*$")) } if strings.Contains(subject, "*") { // Escape regex to literal quoted := regexp.QuoteMeta(subject) // Replace \* with \w+ return regexp.MustCompile(strings.ReplaceAll(quoted, `\*`, `\w+`)) } return regexp.MustCompile(regexp.QuoteMeta(subject)) } // RequestMsg Simulates a request on the given subject, assigns a random // response subject then calls the handler on the given subject, we are // expecting the handler to be in the format: func(msg *nats.Msg) func (t *TestConnection) RequestMsg(ctx context.Context, msg *nats.Msg) (*nats.Msg, error) { replySubject := randSeq(10) msg.Reply = replySubject replies := make(chan any, 128) // Subscribe to the reply subject _, err := t.Subscribe(replySubject, func(msg *nats.Msg) { replies <- msg }) if err != nil { return nil, err } // Run the handlers err = t.runHandlers(msg) if err != nil { return nil, err } // Return the first result select { case reply, ok := <-replies: if ok { if m, ok := reply.(*nats.Msg); ok { return &nats.Msg{ Subject: replySubject, Data: m.Data, }, nil } else { return nil, fmt.Errorf("reply was not a *nats.Msg, but a %T", reply) } } else { return nil, errors.New("no replies") } case <-ctx.Done(): return nil, ctx.Err() } } // Status Always returns nats.CONNECTED func (n *TestConnection) Status() nats.Status { return nats.CONNECTED } // Stats Always returns empty/zero nats.Statistics func (n *TestConnection) Stats() nats.Statistics { return nats.Statistics{} } // LastError Always returns nil func (n *TestConnection) LastError() error { return nil } // Drain Always returns nil func (n *TestConnection) Drain() error { return nil } // Close Does nothing func (n *TestConnection) Close() {} // Underlying Always returns nil func (n *TestConnection) Underlying() *nats.Conn { return &nats.Conn{} } // Drop Does nothing func (n *TestConnection) Drop() {} // runHandlers Runs the handlers for a given subject func (t *TestConnection) runHandlers(msg *nats.Msg) error { t.subscriptionsMutex.RLock() defer t.subscriptionsMutex.RUnlock() var hasResponder bool for subjectRegex, handlers := range t.Subscriptions { if subjectRegex.MatchString(msg.Subject) { for _, handler := range handlers { hasResponder = true handler(msg) } } } if hasResponder || t.IgnoreNoResponders { return nil } else { return nats.ErrNoResponders } } var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func randSeq(n int) string { b := make([]rune, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] //nolint:gosec // This is not for security } return string(b) } ================================================ FILE: go/sdp-go/test_utils_test.go ================================================ package sdp import ( "context" "testing" "github.com/nats-io/nats.go" "google.golang.org/protobuf/proto" ) func TestRequest(t *testing.T) { tc := TestConnection{} t.Run("with a regular subject", func(t *testing.T) { // Create the responder _, err := tc.Subscribe("test", func(msg *nats.Msg) { err2 := tc.Publish(context.Background(), msg.Reply, &GatewayResponse{ ResponseType: &GatewayResponse_Error{ Error: "testing", }, }) if err2 != nil { t.Error(err2) } }) if err != nil { t.Fatal(err) } request := &GatewayRequest{} data, err := proto.Marshal(request) if err != nil { t.Fatal(err) } msg := nats.Msg{ Subject: "test", Data: data, } replyMsg, err := tc.RequestMsg(context.Background(), &msg) if err != nil { t.Fatal(err) } response := &GatewayResponse{} err = proto.Unmarshal(replyMsg.Data, response) if err != nil { t.Error(err) } if response.GetResponseType().(*GatewayResponse_Error).Error != "testing" { t.Errorf("expected error to be 'testing', got '%v'", response) } }) t.Run("with a > wildcard subject", func(t *testing.T) { // Create the responder _, err := tc.Subscribe("test.>", func(msg *nats.Msg) { err2 := tc.Publish(context.Background(), msg.Reply, &GatewayResponse{ ResponseType: &GatewayResponse_Error{ Error: "testing", }, }) if err2 != nil { t.Error(err2) } }) if err != nil { t.Fatal(err) } request := &GatewayRequest{} data, err := proto.Marshal(request) if err != nil { t.Fatal(err) } msg := nats.Msg{ Subject: "test.foo.bar", Data: data, } replyMsg, err := tc.RequestMsg(context.Background(), &msg) if err != nil { t.Fatal(err) } response := &GatewayResponse{} err = proto.Unmarshal(replyMsg.Data, response) if err != nil { t.Error(err) } if response.GetResponseType().(*GatewayResponse_Error).Error != "testing" { t.Errorf("expected error to be 'testing', got '%v'", response) } }) t.Run("with a * wildcard subject", func(t *testing.T) { // Create the responder _, err := tc.Subscribe("test.*.bar", func(msg *nats.Msg) { err2 := tc.Publish(context.Background(), msg.Reply, &GatewayResponse{ ResponseType: &GatewayResponse_Error{ Error: "testing", }, }) if err2 != nil { t.Error(err2) } }) if err != nil { t.Fatal(err) } request := &GatewayRequest{} data, err := proto.Marshal(request) if err != nil { t.Fatal(err) } msg := nats.Msg{ Subject: "test.foo.bar", Data: data, } replyMsg, err := tc.RequestMsg(context.Background(), &msg) if err != nil { t.Fatal(err) } response := &GatewayResponse{} err = proto.Unmarshal(replyMsg.Data, response) if err != nil { t.Error(err) } if response.GetResponseType().(*GatewayResponse_Error).Error != "testing" { t.Errorf("expected error to be 'testing', got '%v'", response) } }) } ================================================ FILE: go/sdp-go/tracing.go ================================================ package sdp import ( "context" "connectrpc.com/connect" "github.com/getsentry/sentry-go" "github.com/nats-io/nats.go" "github.com/overmindtech/cli/go/tracing" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" ) type CtxMsgHandler func(ctx context.Context, msg *nats.Msg) func NewOtelExtractingHandler(spanName string, h CtxMsgHandler, t trace.Tracer, spanOpts ...trace.SpanStartOption) nats.MsgHandler { if h == nil { return nil } return func(msg *nats.Msg) { ctx := context.Background() ctx = otel.GetTextMapPropagator().Extract(ctx, tracing.NewNatsHeaderCarrier(msg.Header)) // don't start a span when we have no spanName if spanName != "" { var span trace.Span ctx, span = t.Start(ctx, spanName, spanOpts...) defer span.End() } h(ctx, msg) } } func NewAsyncOtelExtractingHandler(spanName string, h CtxMsgHandler, t trace.Tracer, spanOpts ...trace.SpanStartOption) nats.MsgHandler { if h == nil { return nil } return func(msg *nats.Msg) { go func() { defer sentry.Recover() ctx := context.Background() ctx = otel.GetTextMapPropagator().Extract(ctx, tracing.NewNatsHeaderCarrier(msg.Header)) // don't start a span when we have no spanName if spanName != "" { var span trace.Span ctx, span = t.Start(ctx, spanName, spanOpts...) defer span.End() } h(ctx, msg) }() } } func InjectOtelTraceContext(ctx context.Context, msg *nats.Msg) { if msg.Header == nil { msg.Header = make(nats.Header) } otel.GetTextMapPropagator().Inject(ctx, tracing.NewNatsHeaderCarrier(msg.Header)) } type sentryInterceptor struct{} // NewSentryInterceptor pass this to connect handlers as `connect.WithInterceptors(NewSentryInterceptor())` to recover from panics in the handler and report them to sentry. Otherwise panics get recovered by connect-go itself and do not get reported to sentry. func NewSentryInterceptor() connect.Interceptor { return &sentryInterceptor{} } func (i *sentryInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { // Same as previous UnaryInterceptorFunc. return connect.UnaryFunc(func( ctx context.Context, req connect.AnyRequest, ) (connect.AnyResponse, error) { defer sentry.Recover() return next(ctx, req) }) } func (*sentryInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { return connect.StreamingClientFunc(func( ctx context.Context, spec connect.Spec, ) connect.StreamingClientConn { defer sentry.Recover() conn := next(ctx, spec) return conn }) } func (i *sentryInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { return connect.StreamingHandlerFunc(func( ctx context.Context, conn connect.StreamingHandlerConn, ) error { defer sentry.Recover() return next(ctx, conn) }) } ================================================ FILE: go/sdp-go/tracing_test.go ================================================ package sdp import ( "context" "testing" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/propagation" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func TestTraceContextPropagation(t *testing.T) { tp := sdktrace.NewTracerProvider() otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) tc := TestConnection{ Messages: make([]ResponseMessage, 0), } outerCtx := context.Background() outerCtx, outerSpan := tp.Tracer("outerTracer").Start(outerCtx, "outer span") defer outerSpan.End() // outerJson, err := outerSpan.SpanContext().MarshalJSON() // if err != nil { // t.Errorf("error marshalling outerSpan: %v", err) // } else { // if !bytes.Equal(outerJson, []byte("{\"TraceID\":\"00000000000000000000000000000000\",\"SpanID\":\"0000000000000000\",\"TraceFlags\":\"00\",\"TraceState\":\"\",\"Remote\":false}")) { // t.Errorf("outer span has unexpected context: %v", string(outerJson)) // } // } handlerCalled := make(chan struct{}) _, err := tc.Subscribe("test.subject", NewOtelExtractingHandler("inner span", func(innerCtx context.Context, msg *nats.Msg) { _, innerSpan := tp.Tracer("innerTracer").Start(innerCtx, "innerSpan") // innerJson, err := innerSpan.SpanContext().MarshalJSON() // if err != nil { // t.Errorf("error marshalling innerSpan: %v", err) // } else { // if !bytes.Equal(innerJson, []byte("{\"TraceID\":\"00000000000000000000000000000000\",\"SpanID\":\"0000000000000000\",\"TraceFlags\":\"00\",\"TraceState\":\"\",\"Remote\":false}")) { // t.Errorf("inner span has unexpected context: %v", string(innerJson)) // } // } if innerSpan.SpanContext().TraceID() != outerSpan.SpanContext().TraceID() { t.Error("inner span did not link up to outer span") } // clean up innerSpan.End() // finish the test handlerCalled <- struct{}{} }, tp.Tracer("providedTracer"))) if err != nil { t.Errorf("error subscribing: %v", err) } m := &nats.Msg{ Subject: "test.subject", Data: make([]byte, 0), } go func() { InjectOtelTraceContext(outerCtx, m) err = tc.PublishMsg(outerCtx, m) if err != nil { t.Errorf("error publishing message: %v", err) } }() // Wait for the handler to be called <-handlerCalled } ================================================ FILE: go/sdp-go/util.go ================================================ package sdp import ( "fmt" "math" "strings" "github.com/google/uuid" ) // CalculatePaginationOffsetLimit Calculates the offset and limit for pagination // in SQL queries, along with the current page and total pages that should be // included in the response // // This also sets sane defaults for the page size if pagination is not provided. // These defaults are page 1 with a page size of 10 // // NOTE: If there are no items, then this will return 0 for all values func CalculatePaginationOffsetLimit(pagination *PaginationRequest, totalItems int32) (offset, limit, page, totalPages int32) { if totalItems == 0 { // If there are no items, there are no pages return 0, 0, 0, 0 } var requestedPageSize int32 var requestedPage int32 if pagination == nil { // Set sane defaults requestedPageSize = 10 requestedPage = 1 } else { requestedPageSize = pagination.GetPageSize() requestedPage = pagination.GetPage() } // pagesize is at least 10, at most 100 limit = min(100, max(10, requestedPageSize)) // calculate the total number of pages totalPages = int32(math.Ceil(float64(totalItems) / float64(limit))) // page has to be at least 1, and at most totalPages page = min(totalPages, requestedPage) page = max(1, page) // calculate the offset if totalPages == 0 { offset = 0 } else { offset = (page * limit) - limit } return offset, limit, page, totalPages } // An object that returns all of the adapter metadata for a given source type AdapterMetadataProvider interface { AllAdapterMetadata() []*AdapterMetadata } // A list of adapter metadata, this is used to store all the adapter metadata // for a given source so that it can be retrieved later for the purposes of // generating documentation and Terraform mappings type AdapterMetadataList struct { // The list of adapter metadata list []*AdapterMetadata } // AllAdapterMetadata returns all the adapter metadata func (a *AdapterMetadataList) AllAdapterMetadata() []*AdapterMetadata { return a.list } // RegisterAdapterMetadata registers a new adapter metadata with the list and // returns a pointer to that same metadata to be used elsewhere func (a *AdapterMetadataList) Register(metadata *AdapterMetadata) *AdapterMetadata { if a == nil { return metadata } a.list = append(a.list, metadata) return metadata } type RoutineRollUp struct { ChangeId uuid.UUID Gun string Attr string Value string } func (rr RoutineRollUp) String() string { val := fmt.Sprintf("%v", rr.Value) if len(val) > 100 { val = val[:100] } val = strings.ReplaceAll(val, "\n", " ") val = strings.ReplaceAll(val, "\t", " ") return fmt.Sprintf("change:%v\tgun:%v\tattr:%v\tval:%v", rr.ChangeId, rr.Gun, rr.Attr, val) } func WalkMapToRoutineRollUp(gun string, key string, data map[string]any) []RoutineRollUp { results := []RoutineRollUp{} for k, v := range data { attr := k if key != "" { attr = fmt.Sprintf("%v.%v", key, k) } switch val := v.(type) { case map[string]any: results = append(results, WalkMapToRoutineRollUp(gun, attr, val)...) default: results = append(results, RoutineRollUp{ Gun: gun, Attr: attr, Value: fmt.Sprintf("%v", val), }) } } return results } // GcpSANameFromAccountName generates a GCP service account name from the given // Service account must be 6-30 characters long, and must comply with the // `^[a-zA-Z][a-zA-Z\d\-]*[a-zA-Z\d]$` regex. // // This regex returned from an error message when trying to create a service account. // Unfortunately, we could not find any documentation on this. // The account name is expected to be in the format of a UUID, which is 36 characters long, // and contains dashes. // The service account name must be 30 characters or less, // and must start with a letter, end with a letter or digit, and can only contain // letters, digits, and dashes. // So we keep the SA name simple: Start with "C-" and take the first 28 characters of the account name. func GcpSANameFromAccountName(accountName string) string { if accountName == "" { return "" } accountName = strings.ReplaceAll(accountName, "-", "") if len(accountName) >= 6 { // Ensure the account name is at most 30 characters long // We will prefix it with "C-" to ensure it starts with a letter // and truncate it to 28 characters after the prefix if len(accountName) > 28 { accountName = accountName[:28] } return "C-" + accountName } return "" } ================================================ FILE: go/sdp-go/util.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: util.proto package sdp import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type SortOrder int32 const ( SortOrder_ALPHABETICAL_ASCENDING SortOrder = 0 // A-Z SortOrder_ALPHABETICAL_DESCENDING SortOrder = 1 // Z-A SortOrder_DATE_ASCENDING SortOrder = 2 // Oldest first SortOrder_DATE_DESCENDING SortOrder = 3 // Newest first ) // Enum value maps for SortOrder. var ( SortOrder_name = map[int32]string{ 0: "ALPHABETICAL_ASCENDING", 1: "ALPHABETICAL_DESCENDING", 2: "DATE_ASCENDING", 3: "DATE_DESCENDING", } SortOrder_value = map[string]int32{ "ALPHABETICAL_ASCENDING": 0, "ALPHABETICAL_DESCENDING": 1, "DATE_ASCENDING": 2, "DATE_DESCENDING": 3, } ) func (x SortOrder) Enum() *SortOrder { p := new(SortOrder) *p = x return p } func (x SortOrder) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (SortOrder) Descriptor() protoreflect.EnumDescriptor { return file_util_proto_enumTypes[0].Descriptor() } func (SortOrder) Type() protoreflect.EnumType { return &file_util_proto_enumTypes[0] } func (x SortOrder) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use SortOrder.Descriptor instead. func (SortOrder) EnumDescriptor() ([]byte, []int) { return file_util_proto_rawDescGZIP(), []int{0} } type PaginationRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of items to return in a single page. The minimum is 10 and the // maximum is 100. PageSize int32 `protobuf:"varint,1,opt,name=pageSize,proto3" json:"pageSize,omitempty"` // The page number to return. the first page is 1. If the page number is // larger than the total number of pages, the last page is returned. If the // page number is negative, the first page 1 is returned. Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PaginationRequest) Reset() { *x = PaginationRequest{} mi := &file_util_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *PaginationRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*PaginationRequest) ProtoMessage() {} func (x *PaginationRequest) ProtoReflect() protoreflect.Message { mi := &file_util_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use PaginationRequest.ProtoReflect.Descriptor instead. func (*PaginationRequest) Descriptor() ([]byte, []int) { return file_util_proto_rawDescGZIP(), []int{0} } func (x *PaginationRequest) GetPageSize() int32 { if x != nil { return x.PageSize } return 0 } func (x *PaginationRequest) GetPage() int32 { if x != nil { return x.Page } return 0 } type PaginationResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of items in the current page PageSize int32 `protobuf:"varint,1,opt,name=pageSize,proto3" json:"pageSize,omitempty"` // The total number of items available. Expensive to calculate // https://www.cybertec-postgresql.com/en/pagination-problem-total-result-count/ // this is done as a separate query TotalItems int32 `protobuf:"varint,2,opt,name=totalItems,proto3" json:"totalItems,omitempty"` // The current page number, NB if the user provided a negative page number, // this will be 1, if the user provided a page number larger than the total // number of pages, this will be the last page. If there are no results at // all, this will be 0. Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"` // The total number of pages available. based on the totalItems and pageSize. // If there are no results this will be zero. TotalPages int32 `protobuf:"varint,4,opt,name=totalPages,proto3" json:"totalPages,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PaginationResponse) Reset() { *x = PaginationResponse{} mi := &file_util_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *PaginationResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*PaginationResponse) ProtoMessage() {} func (x *PaginationResponse) ProtoReflect() protoreflect.Message { mi := &file_util_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use PaginationResponse.ProtoReflect.Descriptor instead. func (*PaginationResponse) Descriptor() ([]byte, []int) { return file_util_proto_rawDescGZIP(), []int{1} } func (x *PaginationResponse) GetPageSize() int32 { if x != nil { return x.PageSize } return 0 } func (x *PaginationResponse) GetTotalItems() int32 { if x != nil { return x.TotalItems } return 0 } func (x *PaginationResponse) GetPage() int32 { if x != nil { return x.Page } return 0 } func (x *PaginationResponse) GetTotalPages() int32 { if x != nil { return x.TotalPages } return 0 } var File_util_proto protoreflect.FileDescriptor const file_util_proto_rawDesc = "" + "\n" + "\n" + "util.proto\"C\n" + "\x11PaginationRequest\x12\x1a\n" + "\bpageSize\x18\x01 \x01(\x05R\bpageSize\x12\x12\n" + "\x04page\x18\x02 \x01(\x05R\x04page\"\x84\x01\n" + "\x12PaginationResponse\x12\x1a\n" + "\bpageSize\x18\x01 \x01(\x05R\bpageSize\x12\x1e\n" + "\n" + "totalItems\x18\x02 \x01(\x05R\n" + "totalItems\x12\x12\n" + "\x04page\x18\x03 \x01(\x05R\x04page\x12\x1e\n" + "\n" + "totalPages\x18\x04 \x01(\x05R\n" + "totalPages*m\n" + "\tSortOrder\x12\x1a\n" + "\x16ALPHABETICAL_ASCENDING\x10\x00\x12\x1b\n" + "\x17ALPHABETICAL_DESCENDING\x10\x01\x12\x12\n" + "\x0eDATE_ASCENDING\x10\x02\x12\x13\n" + "\x0fDATE_DESCENDING\x10\x03B1Z/github.com/overmindtech/workspace/go/sdp-go;sdpb\x06proto3" var ( file_util_proto_rawDescOnce sync.Once file_util_proto_rawDescData []byte ) func file_util_proto_rawDescGZIP() []byte { file_util_proto_rawDescOnce.Do(func() { file_util_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_util_proto_rawDesc), len(file_util_proto_rawDesc))) }) return file_util_proto_rawDescData } var file_util_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_util_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_util_proto_goTypes = []any{ (SortOrder)(0), // 0: SortOrder (*PaginationRequest)(nil), // 1: PaginationRequest (*PaginationResponse)(nil), // 2: PaginationResponse } var file_util_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type 0, // [0:0] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_util_proto_init() } func file_util_proto_init() { if File_util_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_util_proto_rawDesc), len(file_util_proto_rawDesc)), NumEnums: 1, NumMessages: 2, NumExtensions: 0, NumServices: 0, }, GoTypes: file_util_proto_goTypes, DependencyIndexes: file_util_proto_depIdxs, EnumInfos: file_util_proto_enumTypes, MessageInfos: file_util_proto_msgTypes, }.Build() File_util_proto = out.File file_util_proto_goTypes = nil file_util_proto_depIdxs = nil } ================================================ FILE: go/sdp-go/util_test.go ================================================ package sdp import ( "fmt" "regexp" "testing" ) func TestCalculatePaginationOffsetLimit(t *testing.T) { testCases := []struct { page int32 pageSize int32 totalItems int32 expectedOffset int32 expectedLimit int32 expectedPage int32 expectedTotalPages int32 }{ {page: 2, pageSize: 10, totalItems: 20, expectedOffset: 10, expectedPage: 2, expectedLimit: 10, expectedTotalPages: 2}, {page: 3, pageSize: 10, totalItems: 25, expectedOffset: 20, expectedPage: 3, expectedLimit: 10, expectedTotalPages: 3}, {page: 0, pageSize: 5, totalItems: 15, expectedOffset: 0, expectedPage: 1, expectedLimit: 10, expectedTotalPages: 2}, {page: 5, pageSize: 7, totalItems: 23, expectedOffset: 20, expectedPage: 3, expectedLimit: 10, expectedTotalPages: 3}, {page: 1, pageSize: 10, totalItems: 3, expectedOffset: 0, expectedPage: 1, expectedLimit: 10, expectedTotalPages: 1}, {page: -1, pageSize: 10, totalItems: 1, expectedOffset: 0, expectedPage: 1, expectedLimit: 10, expectedTotalPages: 1}, {page: 1, pageSize: 101, totalItems: 1, expectedOffset: 0, expectedPage: 1, expectedLimit: 100, expectedTotalPages: 1}, {page: 1, pageSize: 10, totalItems: 0, expectedOffset: 0, expectedPage: 0, expectedLimit: 0, expectedTotalPages: 0}, } for _, tc := range testCases { t.Run(fmt.Sprintf("page%d pagesize%d totalItems%d", tc.page, tc.pageSize, tc.totalItems), func(t *testing.T) { req := PaginationRequest{ Page: tc.page, PageSize: tc.pageSize, } offset, limit, correctedPage, pages := CalculatePaginationOffsetLimit(&req, tc.totalItems) if offset != tc.expectedOffset { t.Errorf("Expected offset %d, but got %d", tc.expectedOffset, offset) } if correctedPage != tc.expectedPage { t.Errorf("Expected correctedPage %d, but got %d", tc.expectedPage, correctedPage) } if limit != tc.expectedLimit { t.Errorf("Expected limit %d, but got %d", tc.expectedLimit, limit) } if pages != tc.expectedTotalPages { t.Errorf("Expected pages %d, but got %d", tc.expectedTotalPages, pages) } }) } t.Run("Default values", func(t *testing.T) { offset, limit, correctedPage, pages := CalculatePaginationOffsetLimit(nil, 100) if offset != 0 { t.Errorf("Expected offset 0, but got %d", offset) } if correctedPage != 1 { t.Errorf("Expected correctedPage 1, but got %d", correctedPage) } if limit != 10 { t.Errorf("Expected limit 10, but got %d", limit) } if pages != 10 { t.Errorf("Expected pages 10, but got %d", pages) } }) } func TestGcpSANameFromAccountName(t *testing.T) { t.Parallel() tests := []struct { accountName string expected string }{ // BEWARE!! If this test needs changing, all currently existing service // accounts in GCP will need to be updated, which sounds like an unholy // mess. {"test-account", "C-testaccount"}, {"", ""}, {"6351cbb7-cb45-481a-99cd-909d04a58512", "C-6351cbb7cb45481a99cd909d04a5"}, {"d408ea46-f4c9-487f-9bf4-b0bcb6843815", "C-d408ea46f4c9487f9bf4b0bcb684"}, {"63d185c7141237978cfdbaa2", "C-63d185c7141237978cfdbaa2"}, {"b6c1119a-b80b-4a7b-b8df-acb5348525ac", "C-b6c1119ab80b4a7bb8dfacb53485"}, } pattern := `^[a-zA-Z][a-zA-Z\d\-]*[a-zA-Z\d]$` for _, test := range tests { t.Run(test.accountName, func(t *testing.T) { result := GcpSANameFromAccountName(test.accountName) if result != test.expected { t.Errorf("expected %s, got %s", test.expected, result) } if test.expected != "" { matched, err := regexp.MatchString(pattern, result) if err != nil { t.Fatalf("failed to compile regex: %v", err) } if !matched { t.Errorf("result %q does not match regex %q", result, pattern) } if len(result) > 30 { t.Errorf("result %q exceeds 30 characters", result) } if len(result) < 6 { t.Errorf("result %q is less than 6 characters", result) } } }) } } ================================================ FILE: go/sdp-go/validation.go ================================================ package sdp import ( "errors" "fmt" ) // Validate ensures that an Item contains all required fields: // - Type: must be non-empty // - UniqueAttribute: must be non-empty // - Attributes: must not be nil // - Scope: must be non-empty // - UniqueAttributeValue: must be non-empty (derived from Attributes) func (i *Item) Validate() error { if i == nil { return errors.New("Item is nil") } if i.GetType() == "" { return fmt.Errorf("item has empty Type: %v", i.GloballyUniqueName()) } if i.GetUniqueAttribute() == "" { return fmt.Errorf("item has empty UniqueAttribute: %v", i.GloballyUniqueName()) } if i.GetAttributes() == nil { return fmt.Errorf("item has nil Attributes: %v", i.GloballyUniqueName()) } if i.GetScope() == "" { return fmt.Errorf("item has empty Scope: %v", i.GloballyUniqueName()) } if i.UniqueAttributeValue() == "" { return fmt.Errorf("item has empty UniqueAttributeValue: %v", i.GloballyUniqueName()) } return nil } // Validate ensures a Reference contains all required fields: // - Type: must be non-empty // - UniqueAttributeValue: must be non-empty // - Scope: must be non-empty func (r *Reference) Validate() error { if r == nil { return errors.New("reference is nil") } if r.GetType() == "" { return fmt.Errorf("reference has empty Type: %v", r) } if r.GetUniqueAttributeValue() == "" { return fmt.Errorf("reference has empty UniqueAttributeValue: %v", r) } if r.GetScope() == "" { return fmt.Errorf("reference has empty Scope: %v", r) } return nil } // Validate ensures an Edge is valid by validating both the From and To references. func (e *Edge) Validate() error { if e == nil { return errors.New("edge is nil") } var err error err = e.GetFrom().Validate() if err != nil { return err } err = e.GetTo().Validate() return err } // Validate ensures a Response contains all required fields: // - Responder: must be non-empty // - UUID: must be non-empty func (r *Response) Validate() error { if r == nil { return errors.New("response is nil") } if r.GetResponder() == "" { return fmt.Errorf("response has empty Responder: %v", r) } if len(r.GetUUID()) == 0 { return fmt.Errorf("response has empty UUID: %v", r) } return nil } // Validate ensures a QueryError contains all required fields: // - UUID: must be non-empty // - ErrorString: must be non-empty // - Scope: must be non-empty // - SourceName: must be non-empty // - ItemType: must be non-empty // - ResponderName: must be non-empty func (e *QueryError) Validate() error { if e == nil { return errors.New("queryError is nil") } if len(e.GetUUID()) == 0 { return fmt.Errorf("queryError has empty UUID: %w", e) } if e.GetErrorString() == "" { return fmt.Errorf("queryError has empty ErrorString: %w", e) } if e.GetScope() == "" { return fmt.Errorf("queryError has empty Scope: %w", e) } if e.GetSourceName() == "" { return fmt.Errorf("queryError has empty SourceName: %w", e) } if e.GetItemType() == "" { return fmt.Errorf("queryError has empty ItemType: %w", e) } if e.GetResponderName() == "" { return fmt.Errorf("queryError has empty ResponderName: %w", e) } return nil } // Validate ensures a Query contains all required fields: // - Type: must be non-empty // - Scope: must be non-empty // - UUID: must be exactly 16 bytes // - Query: must be non-empty when method is GET func (q *Query) Validate() error { if q == nil { return errors.New("query is nil") } if q.GetType() == "" { return fmt.Errorf("query has empty Type: %v", q) } if q.GetScope() == "" { return fmt.Errorf("query has empty Scope: %v", q) } if len(q.GetUUID()) != 16 { return fmt.Errorf("query has invalid UUID: %v", q) } if q.GetMethod() == QueryMethod_GET { if q.GetQuery() == "" { return fmt.Errorf("query cannot have empty Query when method is Get: %v", q) } } return nil } ================================================ FILE: go/sdp-go/validation_test.go ================================================ package sdp import ( "errors" "testing" "time" "buf.build/go/protovalidate" "github.com/google/uuid" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) func TestValidateItem(t *testing.T) { t.Run("item is fine", func(t *testing.T) { err := newItem().Validate() if err != nil { t.Error(err) } }) t.Run("Item is nil", func(t *testing.T) { var i *Item err := i.Validate() if err == nil { t.Error("expected error") } }) t.Run("item has empty Type", func(t *testing.T) { i := newItem() i.Type = "" err := i.Validate() if err == nil { t.Error("expected error") } }) t.Run("item has empty UniqueAttribute", func(t *testing.T) { i := newItem() i.UniqueAttribute = "" err := i.Validate() if err == nil { t.Error("expected error") } }) t.Run("item has nil Attributes", func(t *testing.T) { i := newItem() i.Attributes = nil err := i.Validate() if err == nil { t.Error("expected error") } }) t.Run("item has empty Scope", func(t *testing.T) { i := newItem() i.Scope = "" err := i.Validate() if err == nil { t.Error("expected error") } }) t.Run("item has empty UniqueAttributeValue", func(t *testing.T) { i := newItem() err := i.GetAttributes().Set(i.GetUniqueAttribute(), "") if err != nil { t.Fatal(err) } err = i.Validate() if err == nil { t.Error("expected error") } }) } func TestValidateReference(t *testing.T) { t.Run("Reference is fine", func(t *testing.T) { r := newReference() err := r.Validate() if err != nil { t.Error(err) } }) t.Run("Reference is nil", func(t *testing.T) { var r *Reference err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("reference has empty Type", func(t *testing.T) { r := newReference() r.Type = "" err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("reference has empty UniqueAttributeValue", func(t *testing.T) { r := newReference() r.UniqueAttributeValue = "" err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("reference has empty Scope", func(t *testing.T) { r := newReference() r.Scope = "" err := r.Validate() if err == nil { t.Error("expected error") } }) } func TestValidateEdge(t *testing.T) { t.Run("Edge is fine", func(t *testing.T) { e := newEdge() err := e.Validate() if err != nil { t.Error(err) } }) t.Run("Edge has nil From", func(t *testing.T) { e := newEdge() e.From = nil err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("Edge has nil To", func(t *testing.T) { e := newEdge() e.To = nil err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("Edge has invalid From", func(t *testing.T) { e := newEdge() e.From.Type = "" err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("Edge has invalid To", func(t *testing.T) { e := newEdge() e.To.Scope = "" err := e.Validate() if err == nil { t.Error("expected error") } }) } func TestValidateResponse(t *testing.T) { t.Run("Response is fine", func(t *testing.T) { r := newResponse() err := r.Validate() if err != nil { t.Error(err) } }) t.Run("Response is nil", func(t *testing.T) { var r *Response err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("Response has empty Responder", func(t *testing.T) { r := newResponse() r.Responder = "" err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("Response has empty UUID", func(t *testing.T) { r := newResponse() r.UUID = nil err := r.Validate() if err == nil { t.Error("expected error") } }) } func TestValidateQueryError(t *testing.T) { t.Run("QueryError is fine", func(t *testing.T) { e := newQueryError() err := e.Validate() if err != nil { t.Error(err) } }) t.Run("QueryError is nil", func(t *testing.T) { }) t.Run("QueryError has empty UUID", func(t *testing.T) { e := newQueryError() e.UUID = nil err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("QueryError has empty ErrorString", func(t *testing.T) { e := newQueryError() e.ErrorString = "" err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("QueryError has empty Scope", func(t *testing.T) { e := newQueryError() e.Scope = "" err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("QueryError has empty SourceName", func(t *testing.T) { e := newQueryError() e.SourceName = "" err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("QueryError has empty ItemType", func(t *testing.T) { e := newQueryError() e.ItemType = "" err := e.Validate() if err == nil { t.Error("expected error") } }) t.Run("QueryError has empty ResponderName", func(t *testing.T) { e := newQueryError() e.ResponderName = "" err := e.Validate() if err == nil { t.Error("expected error") } }) } func TestValidateQuery(t *testing.T) { t.Run("Query is fine", func(t *testing.T) { r := newQuery() err := r.Validate() if err != nil { t.Error(err) } }) t.Run("Query is nil", func(t *testing.T) { }) t.Run("Query has empty Type", func(t *testing.T) { r := newQuery() r.Type = "" err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("Query has empty Scope", func(t *testing.T) { r := newQuery() r.Scope = "" err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("Response has empty UUID", func(t *testing.T) { r := newQuery() r.UUID = nil err := r.Validate() if err == nil { t.Error("expected error") } }) t.Run("Query cannot have empty Query when method is Get", func(t *testing.T) { r := newQuery() r.Method = QueryMethod_GET r.Query = "" err := r.Validate() if err == nil { t.Error("expected error") } }) } func newQuery() *Query { u := uuid.New() return &Query{ Type: "person", Method: QueryMethod_GET, Query: "Dylan", RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 1, }, Scope: "global", UUID: u[:], Deadline: timestamppb.New(time.Now().Add(1 * time.Second)), IgnoreCache: false, } } func newQueryError() *QueryError { u := uuid.New() return &QueryError{ UUID: u[:], ErrorType: QueryError_OTHER, ErrorString: "bad", Scope: "global", SourceName: "test-source", ItemType: "test", ResponderName: "test-responder", } } func newResponse() *Response { u := uuid.New() ru := uuid.New() return &Response{ Responder: "foo", ResponderUUID: ru[:], State: ResponderState_WORKING, NextUpdateIn: durationpb.New(time.Second), UUID: u[:], } } func newEdge() *Edge { return &Edge{ From: newReference(), To: newReference(), } } func newReference() *Reference { return &Reference{ Type: "person", UniqueAttributeValue: "Dylan", Scope: "global", } } func newItem() *Item { return &Item{ Type: "user", UniqueAttribute: "name", Scope: "test", // TODO(LIQs): delete empty data LinkedItemQueries: []*LinkedItemQuery{}, LinkedItems: []*LinkedItem{}, Attributes: &ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": { Kind: &structpb.Value_StringValue{ StringValue: "bar", }, }, }, }, }, Metadata: &Metadata{ SourceName: "users", SourceQuery: &Query{ Type: "user", Method: QueryMethod_LIST, Query: "*", RecursionBehaviour: &Query_RecursionBehaviour{ LinkDepth: 12, }, Scope: "testScope", }, Timestamp: timestamppb.Now(), SourceDuration: &durationpb.Duration{ Seconds: 1, Nanos: 1, }, SourceDurationPerItem: &durationpb.Duration{ Seconds: 0, Nanos: 500, }, }, } } func TestAdapterMetadataValidation(t *testing.T) { t.Run("Valid Metadata", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ { TerraformMethod: QueryMethod_GET, TerraformQueryMap: "aws_test_adapter.test_adapter", }, }, } err := protovalidate.Validate(md) if err != nil { t.Errorf("expected no errors, got %v", err) } }) t.Run("Empty Terraform mappings is OK", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, } err := protovalidate.Validate(md) if err != nil { t.Errorf("expected no errors, got %v", err) } }) t.Run("Empty strings in the potential links", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{""}, TerraformMappings: []*TerraformMapping{ { TerraformMethod: QueryMethod_GET, TerraformQueryMap: "aws_test_adapter.test_adapter", }, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Undefined category", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: 9999, // Undefined category SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ { TerraformMethod: QueryMethod_GET, TerraformQueryMap: "aws_test_adapter.test_adapter", }, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Undefined Terraform query method", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ { TerraformMethod: 9999, // Undefined method TerraformQueryMap: "aws_test_adapter.test_adapter", }, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Malformed Terraform query map - no dots", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ { TerraformMethod: QueryMethod_GET, TerraformQueryMap: "aws_test_adapter_test_adapter", // no dots! }, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Malformed Terraform query map - more than 2 items", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ { TerraformMethod: QueryMethod_GET, TerraformQueryMap: "aws_test_adapter.test_adapter_id.something_else", // expected 2 items, got 3 }, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("With Nil Terraform mapping", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ nil, { TerraformMethod: QueryMethod_GET, TerraformQueryMap: "aws_test_adapter.test_adapter_id", }, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Missing get description", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ {TerraformQueryMap: "aws_test_adapter.test_adapter"}, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Missing search description", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ {TerraformQueryMap: "aws_test_adapter.test_adapter"}, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Missing list description", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ {TerraformQueryMap: "aws_test_adapter.test_adapter"}, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Empty string in the get description", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ {TerraformQueryMap: "aws_test_adapter.test_adapter"}, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Empty string in the search description", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "", List: true, ListDescription: "List test adapters", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ {TerraformQueryMap: "aws_test_adapter.test_adapter"}, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) t.Run("Empty string in the list description", func(t *testing.T) { md := &AdapterMetadata{ Type: "test-adapter", DescriptiveName: "Test Adapter", Category: AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, SupportedQueryMethods: &AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a test adapter", Search: true, SearchDescription: "Search test adapters", List: true, ListDescription: "", }, PotentialLinks: []string{"test-link"}, TerraformMappings: []*TerraformMapping{ {TerraformQueryMap: "aws_test_adapter.test_adapter"}, }, } err := protovalidate.Validate(md) if err == nil { t.Errorf("expected error, got nil") } var validationError *protovalidate.ValidationError if !errors.As(err, &validationError) { t.Errorf("expected validation error, got %T: %v", err, err) } }) } ================================================ FILE: go/sdpcache/bolt.go ================================================ package sdpcache import ( "context" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // BoltCache wraps boltStore and delegates Lookup control-flow to // lookupCoordinator. Purge scheduling lives here; boltStore only handles // storage and purge execution. type BoltCache struct { purger *boltStore pending *pendingWork lookup *lookupCoordinator } // assert interface var _ Cache = (*BoltCache)(nil) // NewBoltCache creates a new BoltCache at the specified path. // If a cache file already exists at the path, it will be opened and used. // The existing file will be automatically handled by the purge process, // which removes expired items. No explicit cleanup is needed on startup. func NewBoltCache(path string, opts ...BoltCacheOption) (*BoltCache, error) { store, err := newBoltCacheStore(path, opts...) if err != nil { return nil, err } pending := newPendingWork() c := &BoltCache{ boltStore: store, pending: pending, lookup: newLookupCoordinator(pending), } c.purgeFunc = c.boltStore.Purge return c, nil } // Lookup performs a cache lookup for the given query parameters. func (c *BoltCache) Lookup(ctx context.Context, srcName string, method sdp.QueryMethod, scope string, typ string, query string, ignoreCache bool) (bool, CacheKey, []*sdp.Item, *sdp.QueryError, func()) { ctx, span := tracing.Tracer().Start(ctx, "BoltCache.Lookup", trace.WithAttributes( attribute.String("ovm.cache.sourceName", srcName), attribute.String("ovm.cache.method", method.String()), attribute.String("ovm.cache.scope", scope), attribute.String("ovm.cache.type", typ), attribute.String("ovm.cache.query", query), attribute.Bool("ovm.cache.ignoreCache", ignoreCache), ), ) defer span.End() ck := CacheKeyFromParts(srcName, method, scope, typ, query) if c == nil || c.boltStore == nil { span.SetAttributes( attribute.String("ovm.cache.result", "cache not initialised"), attribute.Bool("ovm.cache.hit", false), ) return false, ck, nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "cache has not been initialised", Scope: scope, SourceName: srcName, ItemType: typ, }, noopDone } // Set disk usage metrics c.setDiskUsageAttributes(span) if ignoreCache { span.SetAttributes( attribute.String("ovm.cache.result", "ignore cache"), attribute.Bool("ovm.cache.hit", false), ) return false, ck, nil, nil, noopDone } lookup := c.lookup if lookup == nil { lookup = newLookupCoordinator(c.pending) } hit, items, qErr, done := lookup.Lookup( ctx, c, ck, method, ) return hit, ck, items, qErr, done } // StoreItem delegates to boltStore and pokes the purge timer. func (c *BoltCache) StoreItem(ctx context.Context, item *sdp.Item, duration time.Duration, ck CacheKey) { if item == nil { return } c.boltStore.StoreItem(ctx, item, duration, ck) c.setNextPurgeIfEarlier(time.Now().Add(duration)) } // StoreUnavailableItem delegates to boltStore and pokes the purge timer. func (c *BoltCache) StoreUnavailableItem(ctx context.Context, err error, duration time.Duration, ck CacheKey) { if err == nil { return } c.boltStore.StoreUnavailableItem(ctx, err, duration, ck) c.setNextPurgeIfEarlier(time.Now().Add(duration)) } ================================================ FILE: go/sdpcache/boltstore.go ================================================ package sdpcache import ( "bytes" "context" "encoding/binary" "errors" "fmt" "os" "path/filepath" "sync" "syscall" "time" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "go.etcd.io/bbolt" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/proto" ) // Bucket names for bbolt var ( itemsBucketName = []byte("items") expiryBucketName = []byte("expiry") metaBucketName = []byte("meta") deletedBytesKey = []byte("deletedBytes") ) // cacheOpenOptions are the bbolt options used for every Open call in this // package. Since this is a cache layer, crash durability is unnecessary: // - NoSync skips fdatasync per commit, removing the single-writer bottleneck. // - NoFreelistSync skips persisting the freelist, reducing write amplification. var cacheOpenOptions = &bbolt.Options{ Timeout: 5 * time.Second, NoSync: true, NoFreelistSync: true, } // DefaultCompactThreshold is the default threshold for triggering compaction (100MB) const DefaultCompactThreshold = 100 * 1024 * 1024 // isDiskFullError checks if an error is due to disk being full (ENOSPC) func isDiskFullError(err error) bool { if err == nil { return false } // Check if it wraps ENOSPC var errno syscall.Errno if errors.As(err, &errno) && errno == syscall.ENOSPC { return true } // Check using errors.Is for wrapped errors return errors.Is(err, syscall.ENOSPC) } // encodeCachedEntry serializes a CachedEntry to bytes using protobuf func encodeCachedEntry(e *sdp.CachedEntry) ([]byte, error) { return proto.Marshal(e) } // decodeCachedEntry deserializes bytes to a CachedEntry using protobuf func decodeCachedEntry(data []byte) (*sdp.CachedEntry, error) { e := &sdp.CachedEntry{} if err := proto.Unmarshal(data, e); err != nil { return nil, fmt.Errorf("failed to unmarshal cached entry: %w", err) } return e, nil } // toCachedResult converts a CachedEntry to a CachedResult func cachedEntryToCachedResult(e *sdp.CachedEntry) *CachedResult { result := &CachedResult{ Item: e.GetItem(), Expiry: time.Unix(0, e.GetExpiryUnixNano()), IndexValues: IndexValues{ SSTHash: SSTHash(e.GetSstHash()), UniqueAttributeValue: e.GetUniqueAttributeValue(), Method: e.GetMethod(), Query: e.GetQuery(), }, } // Only set Error if it's actually meaningful (not nil and not zero-value) err := e.GetError() if err != nil && (err.GetErrorType() != 0 || err.GetErrorString() != "" || err.GetScope() != "" || err.GetSourceName() != "" || err.GetItemType() != "") { result.Error = err } return result } // fromCachedResult creates a CachedEntry from a CachedResult func fromCachedResult(cr *CachedResult) (*sdp.CachedEntry, error) { e := &sdp.CachedEntry{ Item: cr.Item, ExpiryUnixNano: cr.Expiry.UnixNano(), UniqueAttributeValue: cr.IndexValues.UniqueAttributeValue, Method: cr.IndexValues.Method, Query: cr.IndexValues.Query, SstHash: string(cr.IndexValues.SSTHash), } if cr.Error != nil { // Try to cast to QueryError for protobuf serialization var qErr *sdp.QueryError if errors.As(cr.Error, &qErr) { e.Error = qErr } else { // For non-QueryError errors, wrap in a QueryError e.Error = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: cr.Error.Error(), } } } return e, nil } // makeEntryKey creates a key for storing an entry in the items bucket // Format: {method}|{query}|{uniqueAttributeValue}|{globallyUniqueName} func makeEntryKey(iv IndexValues, item *sdp.Item) []byte { var gun string if item != nil { gun = item.GloballyUniqueName() } key := fmt.Sprintf("%d|%s|%s|%s", iv.Method, iv.Query, iv.UniqueAttributeValue, gun) return []byte(key) } // makeExpiryKey creates a key for the expiry index // Format: {expiryNano}|{sstHash}|{entryKey} func makeExpiryKey(expiry time.Time, sstHash SSTHash, entryKey []byte) []byte { // Use big-endian encoding for expiry so keys sort chronologically buf := make([]byte, 8+1+len(sstHash)+1+len(entryKey)) expiryNano := expiry.UnixNano() var expiryNanoUint uint64 if expiryNano < 0 { expiryNanoUint = 0 } else { expiryNanoUint = uint64(expiryNano) } binary.BigEndian.PutUint64(buf[0:8], expiryNanoUint) buf[8] = '|' copy(buf[9:], []byte(sstHash)) buf[9+len(sstHash)] = '|' copy(buf[10+len(sstHash):], entryKey) return buf } // parseExpiryKey extracts the expiry time, sst hash, and entry key from an expiry key func parseExpiryKey(key []byte) (time.Time, SSTHash, []byte, error) { if len(key) < 10 { return time.Time{}, "", nil, errors.New("expiry key too short") } expiryNanoUint := binary.BigEndian.Uint64(key[0:8]) expiryNano := int64(expiryNanoUint) //nolint:gosec // G115 (overflow): guarded by underflow check that clamps to zero // Check for overflow when converting uint64 to int64 if expiryNano < 0 && expiryNanoUint > 0 { expiryNano = 0 } expiry := time.Unix(0, expiryNano) // Find the separators rest := key[9:] // skip the first separator before, after, ok := bytes.Cut(rest, []byte{'|'}) if !ok { return time.Time{}, "", nil, errors.New("invalid expiry key format") } sstHash := SSTHash(before) entryKey := after return expiry, sstHash, entryKey, nil } // boltStore holds the bbolt-backed storage implementation reused by both // BoltCache and ShardedCache. It handles storage and purge execution only; // purge scheduling (timer, goroutine) is owned by the Cache-level wrapper. type boltStore struct { db *bbolt.DB path string // CompactThreshold is the number of deleted bytes before triggering compaction CompactThreshold int64 // Track deleted bytes for compaction deletedBytes int64 deletedMu sync.Mutex // Ensures that compaction operations aren't running concurrently // Read operations use RLock, write operations and compaction use Lock compactMutex sync.RWMutex } // BoltCacheOption is a functional option for configuring bolt-backed storage. type BoltCacheOption func(*boltStore) // WithCompactThreshold sets the threshold for triggering compaction func WithCompactThreshold(bytes int64) BoltCacheOption { return func(c *boltStore) { c.CompactThreshold = bytes } } func newBoltCacheStore(path string, opts ...BoltCacheOption) (*boltStore, error) { // Ensure the directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("failed to create directory: %w", err) } // bbolt.Open will open an existing file if present, or create a new one db, err := bbolt.Open(path, 0o600, cacheOpenOptions) if err != nil { return nil, fmt.Errorf("failed to open bolt database: %w", err) } c := &boltStore{ db: db, path: path, CompactThreshold: DefaultCompactThreshold, } for _, opt := range opts { opt(c) } // Initialize buckets if err := c.initBuckets(); err != nil { db.Close() return nil, fmt.Errorf("failed to initialize buckets: %w", err) } // Load deleted bytes from meta if err := c.loadDeletedBytes(); err != nil { db.Close() return nil, fmt.Errorf("failed to load deleted bytes: %w", err) } return c, nil } // initBuckets creates the required buckets if they don't exist func (c *boltStore) initBuckets() error { return c.db.Update(func(tx *bbolt.Tx) error { if _, err := tx.CreateBucketIfNotExists(itemsBucketName); err != nil { return fmt.Errorf("failed to create items bucket: %w", err) } if _, err := tx.CreateBucketIfNotExists(expiryBucketName); err != nil { return fmt.Errorf("failed to create expiry bucket: %w", err) } if _, err := tx.CreateBucketIfNotExists(metaBucketName); err != nil { return fmt.Errorf("failed to create meta bucket: %w", err) } return nil }) } // loadDeletedBytes loads the deleted bytes counter from the meta bucket func (c *boltStore) loadDeletedBytes() error { return c.db.View(func(tx *bbolt.Tx) error { meta := tx.Bucket(metaBucketName) if meta == nil { return nil } data := meta.Get(deletedBytesKey) if len(data) == 8 { deletedBytesUint := binary.BigEndian.Uint64(data) deletedBytes := int64(deletedBytesUint) //nolint:gosec // G115 (overflow): guarded by underflow check that clamps to zero // Check for overflow when converting uint64 to int64 if deletedBytes < 0 && deletedBytesUint > 0 { deletedBytes = 0 } c.deletedBytes = deletedBytes } return nil }) } // saveDeletedBytes saves the deleted bytes counter to the meta bucket func (c *boltStore) saveDeletedBytes(tx *bbolt.Tx) error { meta := tx.Bucket(metaBucketName) if meta == nil { return errors.New("meta bucket not found") } buf := make([]byte, 8) deletedBytes := c.deletedBytes var deletedBytesUint uint64 if deletedBytes < 0 { deletedBytesUint = 0 } else { deletedBytesUint = uint64(deletedBytes) } binary.BigEndian.PutUint64(buf, deletedBytesUint) return meta.Put(deletedBytesKey, buf) } // addDeletedBytes adds to the deleted bytes counter (thread-safe) func (c *boltStore) addDeletedBytes(n int64) { c.deletedMu.Lock() c.deletedBytes += n c.deletedMu.Unlock() } // getDeletedBytes returns the current deleted bytes count (thread-safe) func (c *boltStore) getDeletedBytes() int64 { c.deletedMu.Lock() defer c.deletedMu.Unlock() return c.deletedBytes } // resetDeletedBytes resets the deleted bytes counter (thread-safe) func (c *boltStore) resetDeletedBytes() { c.deletedMu.Lock() c.deletedBytes = 0 c.deletedMu.Unlock() } // getFileSize returns the size of the BoltDB file, logging any errors func (c *boltStore) getFileSize() int64 { if c == nil || c.path == "" { return 0 } stat, err := os.Stat(c.path) if err != nil { if os.IsNotExist(err) { log.Warnf("BoltDB cache file does not exist: %s", c.path) } else { log.WithError(err).Warnf("Failed to stat BoltDB cache file: %s", c.path) } return 0 } return stat.Size() } // setDiskUsageAttributes sets disk usage attributes on a span func (c *boltStore) setDiskUsageAttributes(span trace.Span) { if c == nil { return } fileSize := c.getFileSize() deletedBytes := c.getDeletedBytes() span.SetAttributes( attribute.Int64("ovm.boltdb.fileSizeBytes", fileSize), attribute.Int64("ovm.boltdb.deletedBytes", deletedBytes), attribute.Int64("ovm.boltdb.compactThresholdBytes", c.CompactThreshold), ) } // CloseAndDestroy closes the database and deletes the cache file. // This method makes the destructive behavior explicit. func (c *boltStore) CloseAndDestroy() error { if c == nil { return nil } // Acquire write lock to prevent compaction from interfering c.compactMutex.Lock() defer c.compactMutex.Unlock() // Get the file path before closing path := c.db.Path() // Close the database if err := c.db.Close(); err != nil { return err } // Delete the cache file return os.Remove(path) } // deleteCacheFile removes the cache file entirely. This is used as a last resort // when the disk is full and cleanup doesn't help. It closes the database, // removes the file, and resets internal state. func (c *boltStore) deleteCacheFile(ctx context.Context) error { if c == nil { return nil } // Create a span for this operation ctx, span := tracing.Tracer().Start(ctx, "BoltCache.deleteCacheFile", trace.WithAttributes( attribute.String("ovm.cache.path", c.path), )) defer span.End() // Acquire write lock to prevent compaction from interfering c.compactMutex.Lock() defer c.compactMutex.Unlock() return c.deleteCacheFileLocked(ctx, span) } // deleteCacheFileLocked is the internal version that assumes the caller already holds compactMutex.Lock() func (c *boltStore) deleteCacheFileLocked(ctx context.Context, span trace.Span) error { // Close the database if it's open if err := c.db.Close(); err != nil { span.RecordError(err) sentry.CaptureException(err) log.WithContext(ctx).WithError(err).Error("Failed to close database during cache file deletion") } // Remove the cache file if c.path != "" { if err := os.Remove(c.path); err != nil && !os.IsNotExist(err) { span.RecordError(err) sentry.CaptureException(err) log.WithContext(ctx).WithError(err).Error("Failed to remove cache file") return fmt.Errorf("failed to remove cache file: %w", err) } span.SetAttributes(attribute.Bool("ovm.cache.file_deleted", true)) } // Reset internal state c.resetDeletedBytes() // Reopen the database db, err := bbolt.Open(c.path, 0o600, cacheOpenOptions) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to reopen database") return fmt.Errorf("failed to reopen database: %w", err) } c.db = db // Initialize buckets if err := c.initBuckets(); err != nil { _ = db.Close() return fmt.Errorf("failed to initialize buckets after cache file deletion: %w", err) } return nil } // Search performs a lower-level search using a CacheKey, bypassing pendingWork // deduplication. This is used by ShardedCache and lookupCoordinator. // If ctx contains a span, detailed timing metrics will be added as span attributes. func (c *boltStore) Search(ctx context.Context, ck CacheKey) ([]*sdp.Item, error) { if c == nil { return nil, nil } // Get span from context if available span := trace.SpanFromContext(ctx) // Acquire read lock to prevent compaction from closing the database, but do not lock out other bbolt operations lockAcquireStart := time.Now() c.compactMutex.RLock() lockAcquireDuration := time.Since(lockAcquireStart) defer c.compactMutex.RUnlock() results := make([]*CachedResult, 0) var itemsScanned int txStart := time.Now() err := c.db.View(func(tx *bbolt.Tx) error { items := tx.Bucket(itemsBucketName) if items == nil { return nil } sstHash := ck.SST.Hash() sstBucket := items.Bucket([]byte(sstHash)) if sstBucket == nil { return nil } now := time.Now() // Scan through all entries in this SST bucket cursor := sstBucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { itemsScanned++ entry, err := decodeCachedEntry(v) if err != nil { continue // Skip corrupted entries } // Check if expired expiry := time.Unix(0, entry.GetExpiryUnixNano()) if expiry.Before(now) { continue } // Check if matches the cache key entryIV := IndexValues{ SSTHash: SSTHash(entry.GetSstHash()), UniqueAttributeValue: entry.GetUniqueAttributeValue(), Method: entry.GetMethod(), Query: entry.GetQuery(), } if !ck.Matches(entryIV) { continue } result := cachedEntryToCachedResult(entry) results = append(results, result) } return nil }) txDuration := time.Since(txStart) // Add detailed search metrics to span if available if span.IsRecording() { span.SetAttributes( attribute.Int64("ovm.cache.lockAcquireDuration_ms", lockAcquireDuration.Milliseconds()), attribute.Int64("ovm.cache.txDuration_ms", txDuration.Milliseconds()), attribute.Int("ovm.cache.itemsScanned", itemsScanned), attribute.Int("ovm.cache.itemsReturned", len(results)), ) } if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "cache search failed") return nil, fmt.Errorf("search failed: %w", err) } if len(results) == 0 { return nil, ErrCacheNotFound } // Check for errors first items := make([]*sdp.Item, 0, len(results)) for _, res := range results { if res.Error != nil { return nil, res.Error } if res.Item != nil { items = append(items, res.Item) } } return items, nil } // StoreItem stores an item in the cache with the specified duration. func (c *boltStore) StoreItem(ctx context.Context, item *sdp.Item, duration time.Duration, ck CacheKey) { if item == nil || c == nil { return } // Acquire read lock to prevent compaction from closing the database, but do not lock out other bbolt operations c.compactMutex.RLock() defer c.compactMutex.RUnlock() methodStr := "" if ck.Method != nil { methodStr = ck.Method.String() } ctx, span := tracing.Tracer().Start(ctx, "BoltCache.StoreItem", trace.WithAttributes( attribute.String("ovm.cache.method", methodStr), attribute.String("ovm.cache.scope", ck.SST.Scope), attribute.String("ovm.cache.type", ck.SST.Type), attribute.String("ovm.cache.sourceName", ck.SST.SourceName), attribute.String("ovm.cache.itemType", item.GetType()), attribute.String("ovm.cache.itemScope", item.GetScope()), attribute.String("ovm.cache.duration", duration.String()), ), ) defer span.End() // Set disk usage metrics c.setDiskUsageAttributes(span) res := CachedResult{ Item: item, Error: nil, Expiry: time.Now().Add(duration), IndexValues: IndexValues{ UniqueAttributeValue: item.UniqueAttributeValue(), }, } if ck.Method != nil { res.IndexValues.Method = *ck.Method } if ck.Query != nil { res.IndexValues.Query = *ck.Query } res.IndexValues.SSTHash = ck.SST.Hash() c.storeResult(ctx, res) } // StoreUnavailableItem stores an error in the cache with the specified duration. func (c *boltStore) StoreUnavailableItem(ctx context.Context, err error, duration time.Duration, ck CacheKey) { if c == nil || err == nil { return } // Acquire read lock to prevent compaction from closing the database, but do not lock out other bbolt operations c.compactMutex.RLock() defer c.compactMutex.RUnlock() methodStr := "" if ck.Method != nil { methodStr = ck.Method.String() } ctx, span := tracing.Tracer().Start(ctx, "BoltCache.StoreUnavailableItem", trace.WithAttributes( attribute.String("ovm.cache.method", methodStr), attribute.String("ovm.cache.scope", ck.SST.Scope), attribute.String("ovm.cache.type", ck.SST.Type), attribute.String("ovm.cache.sourceName", ck.SST.SourceName), attribute.String("ovm.cache.error", err.Error()), attribute.String("ovm.cache.duration", duration.String()), ), ) defer span.End() // Set disk usage metrics c.setDiskUsageAttributes(span) res := CachedResult{ Item: nil, Error: err, Expiry: time.Now().Add(duration), IndexValues: ck.ToIndexValues(), } c.storeResult(ctx, res) } // storeResult stores a CachedResult in the database func (c *boltStore) storeResult(ctx context.Context, res CachedResult) { span := trace.SpanFromContext(ctx) entry, err := fromCachedResult(&res) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to serialize cache result") return } entryBytes, err := encodeCachedEntry(entry) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to encode cache entry") return } entryKey := makeEntryKey(res.IndexValues, res.Item) expiryKey := makeExpiryKey(res.Expiry, res.IndexValues.SSTHash, entryKey) overwritten := false entrySize := int64(len(entryBytes)) // Helper function to perform the actual database update performUpdate := func() error { return c.db.Update(func(tx *bbolt.Tx) error { items := tx.Bucket(itemsBucketName) if items == nil { return errors.New("items bucket not found") } // Get or create the SST sub-bucket sstBucket, err := items.CreateBucketIfNotExists([]byte(res.IndexValues.SSTHash)) if err != nil { return fmt.Errorf("failed to create sst bucket: %w", err) } // Check if we're overwriting an unexpired entry existingData := sstBucket.Get(entryKey) if existingData != nil { existingEntry, err := decodeCachedEntry(existingData) if err == nil { existingExpiry := time.Unix(0, existingEntry.GetExpiryUnixNano()) now := time.Now() if existingExpiry.After(now) { overwritten = true timeUntilExpiry := existingExpiry.Sub(now) attrs := []attribute.KeyValue{ attribute.Bool("ovm.cache.unexpired_overwrite", true), attribute.String("ovm.cache.time_until_expiry", timeUntilExpiry.String()), attribute.String("ovm.cache.sst_hash", string(res.IndexValues.SSTHash)), attribute.String("ovm.cache.query_method", res.IndexValues.Method.String()), } if res.Item != nil { attrs = append(attrs, attribute.String("ovm.cache.item_type", res.Item.GetType()), attribute.String("ovm.cache.item_scope", res.Item.GetScope()), ) } if res.IndexValues.Query != "" { attrs = append(attrs, attribute.String("ovm.cache.query", res.IndexValues.Query)) } if res.IndexValues.UniqueAttributeValue != "" { attrs = append(attrs, attribute.String("ovm.cache.unique_attribute", res.IndexValues.UniqueAttributeValue)) } span.SetAttributes(attrs...) // Delete old expiry key expiry := tx.Bucket(expiryBucketName) if expiry != nil { oldExpiryKey := makeExpiryKey(existingExpiry, res.IndexValues.SSTHash, entryKey) _ = expiry.Delete(oldExpiryKey) } } } } // Store the entry if err := sstBucket.Put(entryKey, entryBytes); err != nil { return fmt.Errorf("failed to store entry: %w", err) } // Store in expiry index expiry := tx.Bucket(expiryBucketName) if expiry == nil { return errors.New("expiry bucket not found") } if err := expiry.Put(expiryKey, nil); err != nil { return fmt.Errorf("failed to store expiry: %w", err) } return nil }) } err = performUpdate() // Handle disk full errors // Note: storeResult is called from StoreItem/StoreUnavailableItem which already holds compactMutex.RLock() // so we use the locked versions to avoid deadlock if err != nil && isDiskFullError(err) { // Attempt cleanup by purging expired items - needs to happen in a // goroutine to avoid deadlocks and get a fresh write lock go func() { // we need a fresh write lock to block concurrent compaction and // deleteCacheFileLocked operations. Retrying performUpdate under // the write lock will ensure that only one instance of this // goroutine will actually perform the deleteCacheFileLocked. c.compactMutex.Lock() defer c.compactMutex.Unlock() ctx, purgeSpan := tracing.Tracer().Start(ctx, "BoltCache.purgeLocked") defer purgeSpan.End() c.purgeLocked(ctx, time.Now()) // Retry the write operation once err = performUpdate() // If still failing with disk full, delete the cache entirely - use locked version if err != nil && isDiskFullError(err) { deleteCtx, deleteSpan := tracing.Tracer().Start(ctx, "BoltCache.deleteCacheFileLocked", trace.WithAttributes( attribute.String("ovm.cache.path", c.path), )) defer deleteSpan.End() _ = c.deleteCacheFileLocked(deleteCtx, deleteSpan) // After deleting the cache, we can't store the result, so just return return } }() // now return to release the read lock and allow the goroutine above to run return } if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to store result") // Update disk usage metrics even on error c.setDiskUsageAttributes(span) return } if !overwritten { span.SetAttributes(attribute.Bool("ovm.cache.unexpired_overwrite", false)) } // Add entry size and update disk usage metrics span.SetAttributes( attribute.Int64("ovm.boltdb.entrySizeBytes", entrySize), ) c.setDiskUsageAttributes(span) } // Delete removes all entries matching the given cache key. func (c *boltStore) Delete(ck CacheKey) { if c == nil { return } // Acquire read lock to prevent compaction from closing the database, but do not lock out other bbolt operations c.compactMutex.RLock() defer c.compactMutex.RUnlock() var totalDeleted int64 _ = c.db.Update(func(tx *bbolt.Tx) error { items := tx.Bucket(itemsBucketName) if items == nil { return nil } sstHash := ck.SST.Hash() sstBucket := items.Bucket([]byte(sstHash)) if sstBucket == nil { return nil } expiry := tx.Bucket(expiryBucketName) // Collect keys to delete keysToDelete := make([][]byte, 0) cursor := sstBucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { entry, err := decodeCachedEntry(v) if err != nil { continue } entryIV := IndexValues{ SSTHash: SSTHash(entry.GetSstHash()), UniqueAttributeValue: entry.GetUniqueAttributeValue(), Method: entry.GetMethod(), Query: entry.GetQuery(), } if ck.Matches(entryIV) { keysToDelete = append(keysToDelete, append([]byte(nil), k...)) totalDeleted += int64(len(k) + len(v)) // Delete from expiry index if expiry != nil { expiryTime := time.Unix(0, entry.GetExpiryUnixNano()) expiryKey := makeExpiryKey(expiryTime, SSTHash(entry.GetSstHash()), k) _ = expiry.Delete(expiryKey) } } } // Delete the entries for _, k := range keysToDelete { _ = sstBucket.Delete(k) } return nil }) if totalDeleted > 0 { c.addDeletedBytes(totalDeleted) } } // Clear removes all entries from the cache. func (c *boltStore) Clear() { if c == nil { return } // Acquire read lock to prevent compaction from closing the database, but do not lock out other bbolt operations c.compactMutex.RLock() defer c.compactMutex.RUnlock() _ = c.db.Update(func(tx *bbolt.Tx) error { // Delete and recreate buckets _ = tx.DeleteBucket(itemsBucketName) _ = tx.DeleteBucket(expiryBucketName) _, _ = tx.CreateBucketIfNotExists(itemsBucketName) _, _ = tx.CreateBucketIfNotExists(expiryBucketName) // Reset deleted bytes in meta meta := tx.Bucket(metaBucketName) if meta != nil { buf := make([]byte, 8) _ = meta.Put(deletedBytesKey, buf) } return nil }) c.resetDeletedBytes() } // Purge removes all expired items from the cache. func (c *boltStore) Purge(ctx context.Context, before time.Time) PurgeStats { if c == nil { return PurgeStats{} } ctx, span := tracing.Tracer().Start(ctx, "BoltCache.Purge", trace.WithAttributes( attribute.String("ovm.boltdb.purgeBefore", before.Format(time.RFC3339)), ), ) defer span.End() stats := func() PurgeStats { // Acquire read lock to prevent compaction from closing the database, but do not lock out other bbolt operations c.compactMutex.RLock() defer c.compactMutex.RUnlock() return c.purgeLocked(ctx, before) }() // Check if compaction is needed deletedBytesBeforeCompact := c.getDeletedBytes() compactionTriggered := deletedBytesBeforeCompact >= c.CompactThreshold if compactionTriggered { span.SetAttributes( attribute.Bool("ovm.boltdb.compactionTriggered", true), attribute.Int64("ovm.boltdb.deletedBytesBeforeCompact", deletedBytesBeforeCompact), ) if err := c.compact(ctx); err == nil { span.SetAttributes(attribute.Bool("ovm.boltdb.compactionSuccess", true)) } else { span.RecordError(err) span.SetStatus(codes.Error, "compaction failed") span.SetAttributes(attribute.Bool("ovm.boltdb.compactionSuccess", false)) } } else { span.SetAttributes(attribute.Bool("ovm.boltdb.compactionTriggered", false)) } return stats } // purgeLocked is the internal version that assumes the caller already holds compactMutex.Lock() // It performs the actual purging work and returns the stats, but does not handle compaction. func (c *boltStore) purgeLocked(ctx context.Context, before time.Time) PurgeStats { span := trace.SpanFromContext(ctx) // Set initial disk usage metrics c.setDiskUsageAttributes(span) start := time.Now() var nextExpiry *time.Time numPurged := 0 var totalDeleted int64 // Collect expired entries type expiredEntry struct { sstHash SSTHash entryKey []byte size int64 } expired := make([]expiredEntry, 0) if err := c.db.View(func(tx *bbolt.Tx) error { expiry := tx.Bucket(expiryBucketName) if expiry == nil { return nil } items := tx.Bucket(itemsBucketName) cursor := expiry.Cursor() for k, _ := cursor.First(); k != nil; k, _ = cursor.Next() { expiryTime, sstHash, entryKey, err := parseExpiryKey(k) if err != nil { continue } if expiryTime.Before(before) { // Calculate size for deleted bytes tracking var size int64 if items != nil { if sstBucket := items.Bucket([]byte(sstHash)); sstBucket != nil { if v := sstBucket.Get(entryKey); v != nil { size = int64(len(k) + len(entryKey) + len(v)) } } } expired = append(expired, expiredEntry{ sstHash: sstHash, entryKey: append([]byte(nil), entryKey...), size: size, }) } else { // Found first non-expired entry nextExpiry = &expiryTime break } } return nil }); err != nil { span.RecordError(err) span.SetStatus(codes.Error, "purge scan failed") } // Delete expired entries if len(expired) > 0 { if err := c.db.Update(func(tx *bbolt.Tx) error { items := tx.Bucket(itemsBucketName) expiry := tx.Bucket(expiryBucketName) for _, e := range expired { // Delete from items if items != nil { if sstBucket := items.Bucket([]byte(e.sstHash)); sstBucket != nil { _ = sstBucket.Delete(e.entryKey) } } // Delete from expiry index if expiry != nil { // We need to reconstruct the expiry key cursor := expiry.Cursor() for k, _ := cursor.First(); k != nil; k, _ = cursor.Next() { _, sstHash, entryKey, err := parseExpiryKey(k) if err != nil { continue } if sstHash == e.sstHash && bytes.Equal(entryKey, e.entryKey) { _ = expiry.Delete(k) break } } } totalDeleted += e.size numPurged++ } // Save deleted bytes c.addDeletedBytes(totalDeleted) return c.saveDeletedBytes(tx) }); err != nil { span.RecordError(err) span.SetStatus(codes.Error, "purge delete failed") } } // Update final disk usage metrics c.setDiskUsageAttributes(span) span.SetAttributes( attribute.Int("ovm.boltdb.numPurged", numPurged), attribute.Int64("ovm.boltdb.totalDeletedBytes", totalDeleted), ) if nextExpiry != nil { span.SetAttributes(attribute.String("ovm.boltdb.nextExpiry", nextExpiry.Format(time.RFC3339))) } return PurgeStats{ NumPurged: numPurged, TimeTaken: time.Since(start), NextExpiry: nextExpiry, } } // compact performs database compaction to reclaim disk space func (c *boltStore) compact(ctx context.Context) error { // Acquire global lock to prevent any concurrent bbolt operations c.compactMutex.Lock() defer c.compactMutex.Unlock() ctx, span := tracing.Tracer().Start(ctx, "BoltCache.compact") defer span.End() // Set initial disk usage metrics c.setDiskUsageAttributes(span) fileSizeBefore := c.getFileSize() if fileSizeBefore > 0 { span.SetAttributes(attribute.Int64("ovm.boltdb.fileSizeBeforeBytes", fileSizeBefore)) } // Create a temporary file for the compacted database tempPath := c.path + ".compact" // Helper to handle disk full errors during file operations // Note: We already hold compactMutex.Lock(), so we use the locked versions handleDiskFull := func(err error, operation string) error { if isDiskFullError(err) { // Attempt cleanup first - use locked version since we already hold the lock c.purgeLocked(ctx, time.Now()) // If cleanup didn't help, delete the cache - use locked version deleteCtx, deleteSpan := tracing.Tracer().Start(ctx, "BoltCache.deleteCacheFileLocked", trace.WithAttributes( attribute.String("ovm.cache.path", c.path), )) defer deleteSpan.End() _ = c.deleteCacheFileLocked(deleteCtx, deleteSpan) return fmt.Errorf("disk full during %s, cache deleted: %w", operation, err) } return err } // Open the destination database dstDB, err := bbolt.Open(tempPath, 0o600, cacheOpenOptions) if err != nil { if isDiskFullError(err) { // Attempt cleanup first - use locked version since we already hold the lock c.purgeLocked(ctx, time.Now()) // Try again dstDB, err = bbolt.Open(tempPath, 0o600, cacheOpenOptions) if err != nil { return handleDiskFull(err, "temp database creation") } } else { return fmt.Errorf("failed to create temp database: %w", err) } } // Compact from source to destination if err := bbolt.Compact(dstDB, c.db, 0); err != nil { dstDB.Close() os.Remove(tempPath) if isDiskFullError(err) { // Attempt cleanup first - use locked version since we already hold the lock c.purgeLocked(ctx, time.Now()) // Try compaction again dstDB2, retryErr := bbolt.Open(tempPath, 0o600, cacheOpenOptions) if retryErr != nil { return handleDiskFull(retryErr, "temp database creation after cleanup") } if compactErr := bbolt.Compact(dstDB2, c.db, 0); compactErr != nil { dstDB2.Close() os.Remove(tempPath) return handleDiskFull(compactErr, "compaction after cleanup") } // Success on retry, continue with dstDB2 dstDB = dstDB2 } else { return fmt.Errorf("compaction failed: %w", err) } } // Close the destination database if err := dstDB.Close(); err != nil { os.Remove(tempPath) return fmt.Errorf("failed to close temp database: %w", err) } // Close the current database if err := c.db.Close(); err != nil { os.Remove(tempPath) return fmt.Errorf("failed to close database: %w", err) } // Replace the old file with the compacted one if err := os.Rename(tempPath, c.path); err != nil { // Try to reopen the original database c.db, _ = bbolt.Open(c.path, 0o600, cacheOpenOptions) return handleDiskFull(err, "rename") } // Reopen the database db, err := bbolt.Open(c.path, 0o600, cacheOpenOptions) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to reopen database") return fmt.Errorf("failed to reopen database: %w", err) } c.db = db // Set final disk usage metrics and compaction results fileSizeAfter := c.getFileSize() spaceReclaimed := fileSizeBefore - fileSizeAfter span.SetAttributes( attribute.Int64("ovm.boltdb.fileSizeAfterBytes", fileSizeAfter), attribute.Int64("ovm.boltdb.spaceReclaimedBytes", spaceReclaimed), ) c.setDiskUsageAttributes(span) // update deleted bytes after compaction c.resetDeletedBytes() _ = c.db.Update(func(tx *bbolt.Tx) error { return c.saveDeletedBytes(tx) }) return nil } ================================================ FILE: go/sdpcache/boltstore_test.go ================================================ package sdpcache import ( "context" "errors" "os" "path/filepath" "sync" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) // TestBoltStoreCloseAndDestroy verifies that CloseAndDestroy() correctly // closes the database and deletes the cache file. func TestBoltStoreCloseAndDestroy(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") // Create a cache and store some data ctx := t.Context() cache1, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } // Store an item item1 := GenerateRandomItem() ck1 := CacheKeyFromQuery(item1.GetMetadata().GetSourceQuery(), item1.GetMetadata().GetSourceName()) cache1.StoreItem(ctx, item1, 10*time.Second, ck1) // Store another item with a short TTL (will expire) item2 := GenerateRandomItem() ck2 := CacheKeyFromQuery(item2.GetMetadata().GetSourceQuery(), item2.GetMetadata().GetSourceName()) cache1.StoreItem(ctx, item2, 100*time.Millisecond, ck2) // Verify both items are in the cache items, err := testSearch(t.Context(), cache1, ck1) if err != nil { t.Errorf("failed to search for item1: %v", err) } if len(items) != 1 { t.Errorf("expected 1 item for ck1, got %d", len(items)) } // Verify the cache file exists if _, err := os.Stat(cachePath); os.IsNotExist(err) { t.Fatal("cache file should exist before CloseAndDestroy") } // Close and destroy the cache if err := cache1.CloseAndDestroy(); err != nil { t.Fatalf("failed to close and destroy cache1: %v", err) } // Verify the cache file is deleted if _, err := os.Stat(cachePath); !os.IsNotExist(err) { t.Error("cache file should be deleted after CloseAndDestroy") } // Create a new cache at the same path - should create a fresh, empty cache cache2, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create new BoltCache: %v", err) } defer func() { _ = cache2.CloseAndDestroy() }() // Verify the old item is NOT accessible (cache was destroyed) items, err = testSearch(ctx, cache2, ck1) if !errors.Is(err, ErrCacheNotFound) { t.Errorf("expected cache miss for item1 in new cache, got: err=%v, items=%d", err, len(items)) } // Verify we can store new items in the fresh cache item3 := GenerateRandomItem() ck3 := CacheKeyFromQuery(item3.GetMetadata().GetSourceQuery(), item3.GetMetadata().GetSourceName()) cache2.StoreItem(ctx, item3, 10*time.Second, ck3) items, err = testSearch(ctx, cache2, ck3) if err != nil { t.Errorf("failed to search for newly stored item3: %v", err) } if len(items) != 1 { t.Errorf("expected 1 item for ck3, got %d", len(items)) } } // TestBoltStoreOperationsAfterCloseAndDestroy verifies that operations after // CloseAndDestroy() return proper errors instead of panicking. func TestBoltStoreOperationsAfterCloseAndDestroy(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") ctx := t.Context() cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } // Store an item before closing item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) // Close and destroy the cache if err := cache.CloseAndDestroy(); err != nil { t.Fatalf("failed to close and destroy cache: %v", err) } // Now try various operations after the cache is closed and destroyed // These should return errors, not panic t.Run("Search after CloseAndDestroy", func(t *testing.T) { // This should error because the database is closed _, err := testSearch(ctx, cache, ck) if err == nil { t.Error("expected error when searching after CloseAndDestroy, got nil") } t.Logf("Search returned expected error: %v", err) }) t.Run("StoreItem after CloseAndDestroy", func(t *testing.T) { // This should not panic - it might silently fail or error // The key is that it doesn't panic newItem := GenerateRandomItem() newCk := CacheKeyFromQuery(newItem.GetMetadata().GetSourceQuery(), newItem.GetMetadata().GetSourceName()) // This should either complete without panic or handle the closed DB gracefully cache.StoreItem(ctx, newItem, 10*time.Second, newCk) t.Log("StoreItem completed without panic (may have failed internally)") }) t.Run("Delete after CloseAndDestroy", func(t *testing.T) { // This should not panic cache.Delete(ck) t.Log("Delete completed without panic (may have failed internally)") }) t.Run("Purge after CloseAndDestroy", func(t *testing.T) { // This should not panic stats := cache.Purge(ctx, time.Now()) t.Logf("Purge completed without panic, purged %d items", stats.NumPurged) }) } // TestBoltStoreConcurrentCloseAndDestroy verifies that CloseAndDestroy() // properly synchronizes with concurrent operations using the compaction lock. func TestBoltStoreConcurrentCloseAndDestroy(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") ctx := t.Context() cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } // Store some items for range 10 { item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) } // Start some concurrent operations var wg sync.WaitGroup numOperations := 50 // Launch concurrent read/write operations for range numOperations { wg.Go(func() { item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) }) } // Wait a bit to let operations start time.Sleep(10 * time.Millisecond) // Close and destroy while operations are in flight // The compaction lock should serialize this properly wg.Go(func() { err := cache.CloseAndDestroy() if err != nil { t.Logf("CloseAndDestroy returned error: %v", err) } }) // Wait for all operations to complete wg.Wait() // Verify the file is deleted if _, err := os.Stat(cachePath); !os.IsNotExist(err) { t.Error("cache file should be deleted after CloseAndDestroy") } t.Log("Concurrent operations with CloseAndDestroy completed without data races") } // TestIsDiskFullError tests the isDiskFullError helper function. func TestIsDiskFullError(t *testing.T) { // Test that non-disk-full errors are not detected. regularErr := errors.New("some other error") if isDiskFullError(regularErr) { t.Error("isDiskFullError should return false for regular errors") } // Test nil error. if isDiskFullError(nil) { t.Error("isDiskFullError should return false for nil") } } // TestBoltStoreDeleteCacheFile recreates the DB file and clears data by exercising // deleteCacheFile(). This is the behavior relied upon in disk-full recovery paths. func TestBoltStoreDeleteCacheFile(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") // Create a cache and store some data ctx := t.Context() cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } // Store an item item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) // Verify the cache file exists if _, err := os.Stat(cachePath); os.IsNotExist(err) { t.Fatal("cache file should exist") } // Verify item is in cache items, err := testSearch(t.Context(), cache, ck) if err != nil { t.Errorf("failed to search: %v", err) } if len(items) != 1 { t.Errorf("expected 1 item, got %d", len(items)) } // Delete the cache file (cache is already *BoltCache) if err := cache.deleteCacheFile(ctx); err != nil { t.Fatalf("failed to delete cache file: %v", err) } // Verify the cache file is gone if _, err := os.Stat(cachePath); os.IsNotExist(err) { t.Error("cache file should be recreated") } // Verify the database is closed (can't search anymore) _, _ = testSearch(t.Context(), cache, ck) // The search might fail or return empty, but the important thing is the file is gone // and we can't use the cache anymore } // TestBoltStoreCompactThresholdTriggeredByPurge verifies that purge-triggered // compaction keeps the store usable afterwards. func TestBoltStoreCompactThresholdTriggeredByPurge(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") cache, err := NewBoltCache(cachePath, WithCompactThreshold(1024)) // Small threshold to trigger compaction if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() // Store enough items to trigger compaction // We'll store items and then delete them to accumulate deleted bytes for range 10 { item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) } // Manually set deleted bytes to trigger compaction cache.addDeletedBytes(cache.CompactThreshold) // Trigger purge which should trigger compaction stats := cache.Purge(ctx, time.Now().Add(-1*time.Hour)) // Purge items from an hour ago (none should exist) _ = stats // Use stats to avoid unused variable // Verify cache still works after compaction attempt item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) items, err := testSearch(t.Context(), cache, ck) if err != nil { t.Errorf("failed to search after compaction: %v", err) } if len(items) != 1 { t.Errorf("expected 1 item after compaction, got %d", len(items)) } } // TestBoltCacheLookupDeduplicatesConcurrentMisses verifies that multiple concurrent // Lookup calls for the same cache key result in only one caller doing the work. func TestBoltCacheLookupDeduplicatesConcurrentMisses(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() // Create a cache key for the test - use LIST method to avoid UniqueAttributeValue matching issues sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST // Track how many goroutines actually do work (get cache miss as first caller) var workCount int32 var mu sync.Mutex var wg sync.WaitGroup numGoroutines := 10 results := make([]struct { hit bool items []*sdp.Item err *sdp.QueryError }, numGoroutines) // Start barrier to ensure all goroutines start at roughly the same time startBarrier := make(chan struct{}) for i := range numGoroutines { wg.Go(func() { // Wait for the start signal <-startBarrier // Lookup the cache - all should get miss initially hit, ck, items, qErr, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() if !hit { // This goroutine is doing the work mu.Lock() workCount++ mu.Unlock() // Simulate some work time.Sleep(50 * time.Millisecond) // Create and store the item item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item, 10*time.Second, ck) // Re-lookup to get the stored item for our result hit, _, items, qErr, done = cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() } results[i] = struct { hit bool items []*sdp.Item err *sdp.QueryError }{hit, items, qErr} }) } // Release all goroutines at once close(startBarrier) // Wait for all goroutines to complete wg.Wait() // Verify that only one goroutine did the work if workCount != 1 { t.Errorf("expected exactly 1 goroutine to do work, got %d", workCount) } // Verify all goroutines got results for i, r := range results { if !r.hit { t.Errorf("goroutine %d: expected cache hit after dedup, got miss", i) } if len(r.items) != 1 { t.Errorf("goroutine %d: expected 1 item, got %d", i, len(r.items)) } } } // TestBoltCacheLookupDeduplicationRespectsWaiterTimeout verifies that waiter // lookups return when their context deadline is exceeded. func TestBoltCacheLookupDeduplicationRespectsWaiterTimeout(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_GET query := "timeout-test" var wg sync.WaitGroup startBarrier := make(chan struct{}) // First goroutine: does the work but takes a long time wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if hit { t.Error("first goroutine: expected cache miss") return } // Simulate slow work time.Sleep(500 * time.Millisecond) // Store the item item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type cache.StoreItem(ctx, item, 10*time.Second, ck) }) // Second goroutine: should timeout waiting var secondHit bool wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) // Use a short timeout context shortCtx, done := context.WithTimeout(ctx, 50*time.Millisecond) defer done() hit, _, _, _, done := cache.Lookup(shortCtx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() secondHit = hit }) // Release all goroutines close(startBarrier) wg.Wait() // Second goroutine should have timed out and returned miss if secondHit { t.Error("second goroutine should have timed out and returned miss") } } // TestBoltCacheLookupDeduplicationPropagatesStoredError verifies that waiters // receive the error stored by the first caller. func TestBoltCacheLookupDeduplicationPropagatesStoredError(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_GET query := "error-test" var wg sync.WaitGroup startBarrier := make(chan struct{}) expectedError := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "item not found", Scope: sst.Scope, SourceName: sst.SourceName, ItemType: sst.Type, } // Track results from waiters var waiterErrors []*sdp.QueryError var waiterMu sync.Mutex numWaiters := 5 // First goroutine: does the work and stores an error wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if hit { t.Error("first goroutine: expected cache miss") return } // Simulate work that results in an error time.Sleep(50 * time.Millisecond) // Store the error cache.StoreUnavailableItem(ctx, expectedError, 10*time.Second, ck) }) // Waiter goroutines: should receive the error for range numWaiters { wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) hit, _, _, qErr, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() waiterMu.Lock() if hit && qErr != nil { waiterErrors = append(waiterErrors, qErr) } waiterMu.Unlock() }) } // Release all goroutines close(startBarrier) wg.Wait() // All waiters should have received the error if len(waiterErrors) != numWaiters { t.Errorf("expected %d waiters to receive error, got %d", numWaiters, len(waiterErrors)) } // Verify the error content for i, qErr := range waiterErrors { if qErr.GetErrorType() != expectedError.GetErrorType() { t.Errorf("waiter %d: expected error type %v, got %v", i, expectedError.GetErrorType(), qErr.GetErrorType()) } } } // TestBoltCacheLookupDeduplicationReturnsMissAfterCancel verifies that waiters // return misses when the in-flight work is cancelled. func TestBoltCacheLookupDeduplicationReturnsMissAfterCancel(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_GET query := "done-test" var wg sync.WaitGroup startBarrier := make(chan struct{}) // Track results var waiterHits []bool var waiterMu sync.Mutex numWaiters := 3 // First goroutine: starts work but then calls done() without storing anything wg.Go(func() { <-startBarrier hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) if hit { t.Error("first goroutine: expected cache miss") done() return } // Simulate work that fails - done the pending work time.Sleep(50 * time.Millisecond) done() }) // Waiter goroutines for range numWaiters { wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() waiterMu.Lock() waiterHits = append(waiterHits, hit) waiterMu.Unlock() }) } // Release all goroutines close(startBarrier) wg.Wait() // When work is cancelled, waiters receive ok=false from Wait // (because entry.cancelled is true) and return a cache miss without re-checking. // This is the correct behavior - waiters don't hang forever and can retry. if len(waiterHits) != numWaiters { t.Errorf("expected %d waiter results, got %d", numWaiters, len(waiterHits)) } } // TestBoltCacheLookupDeduplicationReturnsMissWhenCompletedWithoutStore verifies // waiter behavior when the first caller completes without storing data. func TestBoltCacheLookupDeduplicationReturnsMissWhenCompletedWithoutStore(t *testing.T) { tempDir := t.TempDir() cachePath := filepath.Join(tempDir, "cache.db") cache, err := NewBoltCache(cachePath) if err != nil { t.Fatalf("failed to create BoltCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST query := "complete-without-store-test" var wg sync.WaitGroup startBarrier := make(chan struct{}) // Track results var waiterHits []bool var waiterMu sync.Mutex numWaiters := 3 // First goroutine: starts work and completes without storing anything // This simulates a LIST query that returns 0 items wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if hit { t.Error("first goroutine: expected cache miss") return } // Simulate work that completes successfully but returns nothing time.Sleep(50 * time.Millisecond) // Complete without storing anything - no items, no error // This triggers the ErrCacheNotFound path in waiters' re-check cache.pending.Complete(ck.String()) }) // Waiter goroutines for range numWaiters { wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() waiterMu.Lock() waiterHits = append(waiterHits, hit) waiterMu.Unlock() }) } // Release all goroutines close(startBarrier) wg.Wait() // When Complete is called without storing anything: // 1. Waiters' Wait returns ok=true (not cancelled) // 2. Waiters re-check the cache and get ErrCacheNotFound // 3. Waiters return hit=false (cache miss) if len(waiterHits) != numWaiters { t.Errorf("expected %d waiter results, got %d", numWaiters, len(waiterHits)) } // All waiters should get a cache miss since nothing was stored for i, hit := range waiterHits { if hit { t.Errorf("waiter %d: expected cache miss (hit=false), got hit=true", i) } } } ================================================ FILE: go/sdpcache/cache.go ================================================ package sdpcache import ( "context" "crypto/sha256" "errors" "fmt" "strings" "time" "github.com/overmindtech/cli/go/sdp-go" ) // noopDone is a reusable no-op done function returned when no cleanup is needed var noopDone = func() {} type IndexValues struct { SSTHash SSTHash UniqueAttributeValue string Method sdp.QueryMethod Query string } type CacheKey struct { SST SST // *required UniqueAttributeValue *string Method *sdp.QueryMethod Query *string } func CacheKeyFromParts(srcName string, method sdp.QueryMethod, scope string, typ string, query string) CacheKey { ck := CacheKey{ SST: SST{ SourceName: srcName, Scope: scope, Type: typ, }, } switch method { case sdp.QueryMethod_GET: // With a Get query we need just the one specific item, so also // filter on uniqueAttributeValue ck.UniqueAttributeValue = &query case sdp.QueryMethod_LIST: // In the case of a find, we just want everything that was found in // the last find, so we only care about the method ck.Method = &method case sdp.QueryMethod_SEARCH: // For a search, we only want to get from the cache items that were // found using search, and with the exact same query ck.Method = &method ck.Query = &query } return ck } func CacheKeyFromQuery(q *sdp.Query, srcName string) CacheKey { return CacheKeyFromParts(srcName, q.GetMethod(), q.GetScope(), q.GetType(), q.GetQuery()) } func (ck CacheKey) String() string { fields := []string{ ("SourceName=" + ck.SST.SourceName), ("Scope=" + ck.SST.Scope), ("Type=" + ck.SST.Type), } if ck.UniqueAttributeValue != nil { fields = append(fields, ("UniqueAttributeValue=" + *ck.UniqueAttributeValue)) } if ck.Method != nil { fields = append(fields, ("Method=" + ck.Method.String())) } if ck.Query != nil { fields = append(fields, ("Query=" + *ck.Query)) } return strings.Join(fields, ", ") } // ToIndexValues Converts a cache query to a set of index values func (ck CacheKey) ToIndexValues() IndexValues { iv := IndexValues{ SSTHash: ck.SST.Hash(), } if ck.Method != nil { iv.Method = *ck.Method } if ck.Query != nil { iv.Query = *ck.Query } if ck.UniqueAttributeValue != nil { iv.UniqueAttributeValue = *ck.UniqueAttributeValue } return iv } // Matches Returns whether or not the supplied index values match the // CacheQuery, excluding the SST since this will have already been validated. // Note that this only checks values that ave actually been set in the // CacheQuery func (ck CacheKey) Matches(i IndexValues) bool { // Check for any mismatches on the values that are set if ck.Method != nil { if *ck.Method != i.Method { return false } } if ck.Query != nil { if *ck.Query != i.Query { return false } } if ck.UniqueAttributeValue != nil { if *ck.UniqueAttributeValue != i.UniqueAttributeValue { return false } } return true } var ErrCacheNotFound = errors.New("not found in cache") // SST A combination of SourceName, Scope and Type, all of which must be // provided type SST struct { SourceName string Scope string Type string } // Hash Creates a new SST hash from a given SST func (s SST) Hash() SSTHash { h := sha256.New() h.Write([]byte(s.SourceName)) h.Write([]byte(s.Scope)) h.Write([]byte(s.Type)) sum := make([]byte, 0) sum = h.Sum(sum) return SSTHash(fmt.Sprintf("%x", sum)) } // CachedResult An item including cache metadata type CachedResult struct { // Item is the actual cached item Item *sdp.Item // Error is the error that we want Error error // The time at which this item expires Expiry time.Time // Values that we use for calculating indexes IndexValues IndexValues } // SSTHash Represents the hash of `SourceName`, `Scope` and `Type` type SSTHash string // Cache provides operations for caching SDP query results (items and errors). // // # Lookup state matrix // // Lookup returns (hit bool, ck CacheKey, items []*sdp.Item, qErr *sdp.QueryError, done func()). // The return values follow one of three states: // // - Miss: hit=false, items=nil, qErr=nil — no cached data // - Item hit: hit=true, len(items)>0, qErr=nil — cached items found // - Error hit: hit=true, items=nil, qErr!=nil — cached error found // // # done() contract // // On a cache miss the returned done function MUST be called after storing // results (or deciding to store nothing). It releases the pending-work slot // so that waiting goroutines can proceed. The done function is idempotent // (safe to call multiple times). On a cache hit or for goroutines that were // waiting, done is a no-op. // // # ignoreCache // // When ignoreCache=true, Lookup always returns a miss without checking the // cache or registering pending work. The returned done is a no-op. // // # GET cardinality // // If a GET lookup finds more than one cached item for the same key, the // cache treats the data as inconsistent, purges the key, and returns a miss. // // # Item ordering // // The order of items returned from Lookup or any fan-out search is // implementation-defined and must not be relied upon by callers. // // # Error precedence // // If both items and an error are cached under the same CacheKey, the error // takes precedence: Lookup returns an error hit with nil items. // // # TTL handling // // There is no minimum TTL floor. A zero or negative duration stores the // entry with an expiry at (or before) the current time. The entry will // not survive a Purge(ctx, time.Now()) call and will be skipped by // subsequent searches once the clock advances past the stored expiry. // // # Copy semantics // // Stored items are copied; mutating an item after StoreItem will not alter // the cached copy. type Cache interface { // Lookup performs a cache lookup for the given query parameters. // See the Cache-level doc for the state matrix, done() obligations, // ignoreCache semantics, and GET cardinality rules. Lookup(ctx context.Context, srcName string, method sdp.QueryMethod, scope string, typ string, query string, ignoreCache bool) (bool, CacheKey, []*sdp.Item, *sdp.QueryError, func()) // StoreItem stores an item in the cache with the specified TTL. // The item is deep-copied before storage; the caller retains ownership // of the original. Storing under the same CacheKey overwrites any // previous entry with matching IndexValues. StoreItem(ctx context.Context, item *sdp.Item, duration time.Duration, ck CacheKey) // StoreUnavailableItem stores an error in the cache with the specified TTL. // A subsequent Lookup for the same key returns an error hit. StoreUnavailableItem(ctx context.Context, err error, duration time.Duration, ck CacheKey) // Delete removes all entries whose IndexValues match the supplied // CacheKey. Because CacheKey fields are optional, omitting Method or // UniqueAttributeValue acts as a wildcard across those dimensions. Delete(ck CacheKey) // Clear removes every entry from the cache. Clear() // Purge removes entries that expired before the given time. // Returns PurgeStats with the count of purged entries and the next // expiry time (nil when the cache is empty after purging). Purge(ctx context.Context, before time.Time) PurgeStats // GetMinWaitTime returns the minimum interval between automatic purge // cycles. Stateful implementations return a positive duration; // NoOpCache returns 0. GetMinWaitTime() time.Duration // StartPurger starts a background goroutine that periodically calls // Purge. The goroutine exits when ctx is cancelled. StartPurger(ctx context.Context) } // NoOpCache is a cache implementation that does nothing. // It can be used in tests or when caching is not desired, avoiding nil checks. type NoOpCache struct{} var _ Cache = (*NoOpCache)(nil) // NewNoOpCache creates a new no-op cache that implements the Cache interface // but performs no operations. Useful for testing or when caching is disabled. func NewNoOpCache() Cache { return &NoOpCache{} } // Lookup always returns a cache miss func (n *NoOpCache) Lookup(ctx context.Context, srcName string, method sdp.QueryMethod, scope string, typ string, query string, ignoreCache bool) (bool, CacheKey, []*sdp.Item, *sdp.QueryError, func()) { ck := CacheKeyFromParts(srcName, method, scope, typ, query) return false, ck, nil, nil, noopDone } // StoreItem does nothing func (n *NoOpCache) StoreItem(ctx context.Context, item *sdp.Item, duration time.Duration, ck CacheKey) { // No-op } // StoreUnavailableItem does nothing func (n *NoOpCache) StoreUnavailableItem(ctx context.Context, err error, duration time.Duration, ck CacheKey) { // No-op } // Delete does nothing func (n *NoOpCache) Delete(ck CacheKey) { // No-op } // Clear does nothing func (n *NoOpCache) Clear() { // No-op } // Purge returns empty stats func (n *NoOpCache) Purge(ctx context.Context, before time.Time) PurgeStats { return PurgeStats{} } // GetMinWaitTime returns 0 func (n *NoOpCache) GetMinWaitTime() time.Duration { return 0 } // StartPurger does nothing func (n *NoOpCache) StartPurger(ctx context.Context) { } // NewCache creates a new cache. This function returns a Cache interface backed // by a ShardedCache (N independent BoltDB files) for write concurrency. // The passed context will be used to start the purger. func NewCache(ctx context.Context) Cache { return newShardedCacheForProduction(ctx) } ================================================ FILE: go/sdpcache/cache_benchmark_test.go ================================================ package sdpcache import ( "context" "fmt" "math/rand" "runtime" "sync" "sync/atomic" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) const CacheDuration = 10 * time.Second // NewPopulatedCache Returns a newly populated cache and the CacheQuery that // matches a randomly selected item in that cache func NewPopulatedCache(ctx context.Context, numberItems int) (Cache, CacheKey) { // Populate the cache c := NewCache(ctx) var item *sdp.Item var exampleCk CacheKey exampleIndex := rand.Intn(numberItems) for i := range numberItems { item = GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) if i == exampleIndex { exampleCk = ck } c.StoreItem(ctx, item, CacheDuration, ck) } return c, exampleCk } // NewPopulatedCacheWithListItems populates a cache with items that share the same // SST (Source, Scope, Type) for LIST query benchmarking. All items will be returned // when searching with a LIST query for the given SST. func NewPopulatedCacheWithListItems(cache Cache, numberItems int, sst SST) CacheKey { listMethod := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &listMethod} for i := range numberItems { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName // Ensure each item has a unique attribute value to prevent overwrites // Format: "item-{index}" to guarantee uniqueness uniqueValue := fmt.Sprintf("item-%d", i) item.GetAttributes().Set("name", uniqueValue) cache.StoreItem(context.Background(), item, CacheDuration, ck) } return ck } // NewPopulatedCacheWithMultipleBuckets creates a cache with multiple SST buckets // to enable realistic concurrent access patterns where different goroutines hit // different buckets. func NewPopulatedCacheWithMultipleBuckets(cache Cache, itemsPerBucket, numBuckets int) []CacheKey { keys := make([]CacheKey, numBuckets) listMethod := sdp.QueryMethod_LIST for bucketIdx := range numBuckets { sst := SST{ SourceName: "test-source", Scope: fmt.Sprintf("scope-%d", bucketIdx), Type: "test-type", } keys[bucketIdx] = CacheKey{SST: sst, Method: &listMethod} for i := range itemsPerBucket { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName uniqueValue := fmt.Sprintf("bucket-%d-item-%d", bucketIdx, i) item.GetAttributes().Set("name", uniqueValue) cache.StoreItem(context.Background(), item, CacheDuration, keys[bucketIdx]) } } return keys } func BenchmarkCache1SingleItem(b *testing.B) { c, query := NewPopulatedCache(b.Context(), 1) var err error b.ResetTimer() for range b.N { // Search for a single item _, err = testSearch(context.Background(), c, query) if err != nil { b.Fatal(err) } } } func BenchmarkCache10SingleItem(b *testing.B) { c, query := NewPopulatedCache(b.Context(), 10) var err error b.ResetTimer() for range b.N { // Search for a single item _, err = testSearch(context.Background(), c, query) if err != nil { b.Fatal(err) } } } func BenchmarkCache100SingleItem(b *testing.B) { c, query := NewPopulatedCache(b.Context(), 100) var err error b.ResetTimer() for range b.N { // Search for a single item _, err = testSearch(context.Background(), c, query) if err != nil { b.Fatal(err) } } } func BenchmarkCache1000SingleItem(b *testing.B) { c, query := NewPopulatedCache(b.Context(), 1000) var err error b.ResetTimer() for range b.N { // Search for a single item _, err = testSearch(context.Background(), c, query) if err != nil { b.Fatal(err) } } } func BenchmarkCache10_000SingleItem(b *testing.B) { c, query := NewPopulatedCache(b.Context(), 10_000) var err error b.ResetTimer() for range b.N { // Search for a single item _, err = testSearch(context.Background(), c, query) if err != nil { b.Fatal(err) } } } // BenchmarkListQueryLookup benchmarks LIST query performance using the Lookup method, // which includes the full production path with pending work deduplication logic. // This provides a more realistic benchmark of end-to-end LIST query performance. func BenchmarkListQueryLookup(b *testing.B) { implementations := cacheImplementations(b) cacheSizes := []int{10, 100, 1_000, 10_000} for _, impl := range implementations { b.Run(impl.name, func(b *testing.B) { for _, size := range cacheSizes { b.Run(fmt.Sprintf("%d_items", size), func(b *testing.B) { // Setup cache := impl.factory() sst := SST{ SourceName: "test-source", Scope: "test-scope", Type: "test-type", } _ = NewPopulatedCacheWithListItems(cache, size, sst) b.ResetTimer() b.ReportAllocs() // Benchmark for range b.N { hit, _, items, qErr, done := cache.Lookup( b.Context(), sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false, // ignoreCache ) done() // Clean up immediately if qErr != nil { b.Fatalf("unexpected query error: %v", qErr) } if !hit { b.Fatal("expected cache hit, got miss") } if len(items) != size { b.Fatalf("expected %d items, got %d", size, len(items)) } } }) } }) } } // BenchmarkListQueryConcurrent benchmarks LIST query performance under high concurrency. // This simulates production scenarios where hundreds of goroutines hit the cache simultaneously. func BenchmarkListQueryConcurrent(b *testing.B) { implementations := cacheImplementations(b) // Test configuration similar to production cacheSize := 5_000 // Similar to production's largest bucket concurrencyLevels := []int{10, 50, 100, 250, 500} for _, impl := range implementations { b.Run(impl.name, func(b *testing.B) { for _, concurrency := range concurrencyLevels { b.Run(fmt.Sprintf("%d_concurrent", concurrency), func(b *testing.B) { // Setup: Create cache with multiple buckets for realistic access patterns cache := impl.factory() numBuckets := 10 // Multiple buckets to spread queries itemsPerBucket := cacheSize / numBuckets cacheKeys := NewPopulatedCacheWithMultipleBuckets(cache, itemsPerBucket, numBuckets) b.ResetTimer() b.ReportAllocs() b.SetParallelism(concurrency / runtime.GOMAXPROCS(0)) // Scale to desired concurrency // Benchmark: Each goroutine randomly queries one of the buckets b.RunParallel(func(pb *testing.PB) { for pb.Next() { // Randomly select a bucket to query bucketIdx := rand.Intn(numBuckets) ck := cacheKeys[bucketIdx] // Use Lookup() to match production behavior hit, _, items, qErr, done := cache.Lookup( b.Context(), ck.SST.SourceName, sdp.QueryMethod_LIST, ck.SST.Scope, ck.SST.Type, "", false, // ignoreCache ) done() // Clean up immediately if qErr != nil { b.Errorf("unexpected query error: %v", qErr) return } if !hit { b.Error("expected cache hit, got miss") return } if len(items) != itemsPerBucket { b.Errorf("expected %d items, got %d", itemsPerBucket, len(items)) return } } }) }) } }) } } // BenchmarkListQueryConcurrentSameKey benchmarks worst-case contention where all // goroutines query the same cache key simultaneously. This tests pending work // deduplication and maximum lock contention. func BenchmarkListQueryConcurrentSameKey(b *testing.B) { implementations := cacheImplementations(b) cacheSize := 5_000 concurrencyLevels := []int{10, 50, 100, 250, 500} for _, impl := range implementations { b.Run(impl.name, func(b *testing.B) { for _, concurrency := range concurrencyLevels { b.Run(fmt.Sprintf("%d_concurrent", concurrency), func(b *testing.B) { // Setup: Single SST bucket that all goroutines will hit cache := impl.factory() sst := SST{ SourceName: "test-source", Scope: "test-scope", Type: "test-type", } _ = NewPopulatedCacheWithListItems(cache, cacheSize, sst) b.ResetTimer() b.ReportAllocs() b.SetParallelism(concurrency / runtime.GOMAXPROCS(0)) // Benchmark: All goroutines hit the same key b.RunParallel(func(pb *testing.PB) { for pb.Next() { // Use Lookup() to match production behavior hit, _, items, qErr, done := cache.Lookup( b.Context(), sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false, // ignoreCache ) done() // Clean up immediately if qErr != nil { b.Errorf("unexpected query error: %v", qErr) return } if !hit { b.Error("expected cache hit, got miss") return } if len(items) != cacheSize { b.Errorf("expected %d items, got %d", cacheSize, len(items)) return } } }) }) } }) } } // BenchmarkPendingWorkContention tests cache behavior when many concurrent goroutines // all call Lookup() for the same cache key simultaneously. This simulates the production // scenario where hundreds of goroutines wait in pending.Wait() for a single slow // aggregatedList operation to complete. func BenchmarkPendingWorkContention(b *testing.B) { // Test parameters matching production scenarios concurrencyLevels := []int{100, 200, 400, 500} fetchDurations := []time.Duration{1 * time.Second, 5 * time.Second, 10 * time.Second} resultSizes := []int{100, 1000, 5000} for _, impl := range cacheImplementations(b) { b.Run(impl.name, func(b *testing.B) { for _, concurrency := range concurrencyLevels { b.Run(fmt.Sprintf("concurrency=%d", concurrency), func(b *testing.B) { for _, fetchDuration := range fetchDurations { b.Run(fmt.Sprintf("fetchDuration=%s", fetchDuration), func(b *testing.B) { for _, resultSize := range resultSizes { b.Run(fmt.Sprintf("resultSize=%d", resultSize), func(b *testing.B) { // Run the actual benchmark benchmarkPendingWorkContentionScenario( b, impl.factory, concurrency, fetchDuration, resultSize, ) }) } }) } }) } }) } } // benchmarkPendingWorkContentionScenario runs a single pending work contention scenario func benchmarkPendingWorkContentionScenario( b *testing.B, cacheFactory func() Cache, concurrency int, fetchDuration time.Duration, resultSize int, ) { b.ReportAllocs() // Create a fresh cache for this test cache := cacheFactory() defer func() { if closer, ok := cache.(interface{ Close() error }); ok { closer.Close() } }() // Define the shared cache key that all goroutines will use sst := SST{ SourceName: "test-source", Scope: "test-scope-*", Type: "test-type", } listMethod := sdp.QueryMethod_LIST sharedCacheKey := CacheKey{SST: sst, Method: &listMethod} // Track timing metrics across all goroutines var ( firstStartTime time.Time firstCompleteTime time.Time lastCompleteTime time.Time timingMutex sync.Mutex ) // Atomic flag to detect the first goroutine (the one that does the work) var firstGoroutine atomic.Bool // Use a start barrier to ensure all goroutines begin simultaneously startBarrier := make(chan struct{}) b.ResetTimer() for range b.N { // Clear cache between iterations cache.Clear() // Reset state firstGoroutine.Store(false) firstStartTime = time.Time{} firstCompleteTime = time.Time{} lastCompleteTime = time.Time{} var wg sync.WaitGroup // Spawn all goroutines for range concurrency { wg.Go(func() { // Wait for start signal to ensure simultaneous execution <-startBarrier startTime := time.Now() // Call Lookup - this is where the contention happens hit, _, items, qErr, done := cache.Lookup( b.Context(), sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false, // ignoreCache ) endTime := time.Now() // Check if this goroutine was the first one (the worker) isFirst := firstGoroutine.CompareAndSwap(false, true) if isFirst { // This goroutine got the cache miss and needs to do the work if hit { b.Errorf("First goroutine should get cache miss, got hit") done() return } // Record when work started timingMutex.Lock() firstStartTime = startTime timingMutex.Unlock() // Simulate slow fetch operation (like aggregatedList) time.Sleep(fetchDuration) // Store items in cache (simulating results from aggregatedList) for itemIdx := range resultSize { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName item.GetAttributes().Set("name", fmt.Sprintf("item-%d", itemIdx)) cache.StoreItem(b.Context(), item, CacheDuration, sharedCacheKey) } // Record when work completed timingMutex.Lock() firstCompleteTime = time.Now() timingMutex.Unlock() // Call done() to complete pending work and release waiting goroutines done() } else { // This goroutine should have waited in pending.Wait() and then got a cache hit // Note: It might get partial results if it wakes up while the first goroutine // is still storing items (released when the first goroutine calls done()) done() // No-op for waiters, but good practice if !hit { b.Errorf("Waiting goroutine should get cache hit after pending work completes, got miss") return } if qErr != nil { b.Errorf("Waiting goroutine got error: %v", qErr) return } if len(items) == 0 { b.Errorf("Waiting goroutine got cache hit but no items") return } // Don't check exact count - waiters may get partial results } // Track when each goroutine completes timingMutex.Lock() if lastCompleteTime.IsZero() || endTime.After(lastCompleteTime) { lastCompleteTime = endTime } timingMutex.Unlock() }) } // Release all goroutines simultaneously close(startBarrier) // Wait for all goroutines to complete wg.Wait() // Calculate and report metrics for this iteration if !firstStartTime.IsZero() && !firstCompleteTime.IsZero() && !lastCompleteTime.IsZero() { workDuration := firstCompleteTime.Sub(firstStartTime) totalDuration := lastCompleteTime.Sub(firstStartTime) maxWaitTime := lastCompleteTime.Sub(firstCompleteTime) // Report metrics b.ReportMetric(workDuration.Seconds(), "work_duration_sec") b.ReportMetric(totalDuration.Seconds(), "total_duration_sec") b.ReportMetric(maxWaitTime.Seconds(), "max_wait_sec") b.ReportMetric(float64(concurrency-1), "waiting_goroutines") // Calculate efficiency: ideally, waiters should return immediately after work completes // A ratio close to 1.0 means waiters waited approximately the work duration waitToWorkRatio := totalDuration.Seconds() / workDuration.Seconds() b.ReportMetric(waitToWorkRatio, "wait_to_work_ratio") } // Recreate start barrier for next iteration startBarrier = make(chan struct{}) } b.StopTimer() } // BenchmarkConcurrentMultiKeyWrites tests cache behavior when many concurrent goroutines // call Lookup() with DIFFERENT cache keys, all get cache misses, and all write results // concurrently to the same BoltDB file. This simulates the production scenario where // a wildcard query is expanded into 620+ separate queries with different scopes. func BenchmarkConcurrentMultiKeyWrites(b *testing.B) { // Test parameters matching production scenarios concurrencyLevels := []int{100, 200, 400, 600} itemsPerGoroutine := []int{10, 100, 500} fetchDurations := []time.Duration{100 * time.Millisecond, 1 * time.Second, 5 * time.Second} for _, impl := range cacheImplementations(b) { b.Run(impl.name, func(b *testing.B) { for _, concurrency := range concurrencyLevels { b.Run(fmt.Sprintf("concurrency=%d", concurrency), func(b *testing.B) { for _, itemsPerGoroutine := range itemsPerGoroutine { b.Run(fmt.Sprintf("itemsPerGoroutine=%d", itemsPerGoroutine), func(b *testing.B) { for _, fetchDuration := range fetchDurations { b.Run(fmt.Sprintf("fetchDuration=%s", fetchDuration), func(b *testing.B) { // Run the actual benchmark benchmarkConcurrentMultiKeyWritesScenario( b, impl.factory, concurrency, itemsPerGoroutine, fetchDuration, ) }) } }) } }) } }) } } // benchmarkConcurrentMultiKeyWritesScenario runs a single concurrent multi-key write scenario func benchmarkConcurrentMultiKeyWritesScenario( b *testing.B, cacheFactory func() Cache, concurrency int, itemsPerGoroutine int, fetchDuration time.Duration, ) { b.ReportAllocs() // Create a fresh cache for this test cache := cacheFactory() defer func() { if closer, ok := cache.(interface{ Close() error }); ok { closer.Close() } }() // Generate unique cache keys for each goroutine (different scopes) cacheKeys := make([]CacheKey, concurrency) listMethod := sdp.QueryMethod_LIST for i := range concurrency { cacheKeys[i] = CacheKey{ SST: SST{ SourceName: "test-source", Scope: fmt.Sprintf("scope-%d", i), // Different scope = different cache key Type: "test-type", }, Method: &listMethod, } } // Track timing metrics var ( goroutineStartTimes []time.Time goroutineEndTimes []time.Time timesMutex sync.Mutex totalStoreItemCalls atomic.Int64 ) // Use a start barrier to ensure all goroutines begin simultaneously startBarrier := make(chan struct{}) b.ResetTimer() for range b.N { // Clear cache between iterations cache.Clear() // Reset metrics goroutineStartTimes = make([]time.Time, 0, concurrency) goroutineEndTimes = make([]time.Time, 0, concurrency) totalStoreItemCalls.Store(0) var wg sync.WaitGroup wg.Add(concurrency) // Spawn all goroutines for g := range concurrency { goroutineIdx := g go func() { defer wg.Done() // Wait for start signal to ensure simultaneous execution <-startBarrier startTime := time.Now() // Track start time timesMutex.Lock() goroutineStartTimes = append(goroutineStartTimes, startTime) timesMutex.Unlock() // Call Lookup with unique cache key - should be a cache miss myCacheKey := cacheKeys[goroutineIdx] hit, _, _, qErr, done := cache.Lookup( b.Context(), myCacheKey.SST.SourceName, sdp.QueryMethod_LIST, myCacheKey.SST.Scope, myCacheKey.SST.Type, "", false, // ignoreCache ) if hit { b.Errorf("Expected cache miss for goroutine %d, got hit", goroutineIdx) done() return } if qErr != nil { b.Errorf("Unexpected error for goroutine %d: %v", goroutineIdx, qErr) done() return } // Simulate slow fetch operation (like aggregatedList API call) time.Sleep(fetchDuration) // Store multiple items (simulating API results) for itemIdx := range itemsPerGoroutine { item := GenerateRandomItem() item.Scope = myCacheKey.SST.Scope item.Type = myCacheKey.SST.Type item.Metadata.SourceName = myCacheKey.SST.SourceName item.GetAttributes().Set("name", fmt.Sprintf("goroutine-%d-item-%d", goroutineIdx, itemIdx)) cache.StoreItem(b.Context(), item, CacheDuration, myCacheKey) totalStoreItemCalls.Add(1) } // Call done() to complete pending work done() endTime := time.Now() // Track end time timesMutex.Lock() goroutineEndTimes = append(goroutineEndTimes, endTime) timesMutex.Unlock() }() } // Release all goroutines simultaneously close(startBarrier) // Wait for all goroutines to complete wg.Wait() // Calculate and report metrics for this iteration if len(goroutineStartTimes) > 0 && len(goroutineEndTimes) > 0 { // Find earliest start and latest end earliestStart := goroutineStartTimes[0] latestEnd := goroutineEndTimes[0] for _, t := range goroutineStartTimes { if t.Before(earliestStart) { earliestStart = t } } for _, t := range goroutineEndTimes { if t.After(latestEnd) { latestEnd = t } } totalDuration := latestEnd.Sub(earliestStart) totalWrites := totalStoreItemCalls.Load() writeThroughput := float64(totalWrites) / totalDuration.Seconds() // Calculate average goroutine duration var totalGoroutineDuration time.Duration for idx := range goroutineStartTimes { if idx < len(goroutineEndTimes) { totalGoroutineDuration += goroutineEndTimes[idx].Sub(goroutineStartTimes[idx]) } } avgGoroutineDuration := totalGoroutineDuration / time.Duration(len(goroutineStartTimes)) // Report metrics b.ReportMetric(totalDuration.Seconds(), "total_duration_sec") b.ReportMetric(avgGoroutineDuration.Seconds(), "avg_goroutine_sec") b.ReportMetric(float64(concurrency), "concurrent_writers") b.ReportMetric(float64(totalWrites), "total_store_calls") b.ReportMetric(writeThroughput, "writes_per_sec") } // Recreate start barrier for next iteration startBarrier = make(chan struct{}) } b.StopTimer() } ================================================ FILE: go/sdpcache/cache_contract_test.go ================================================ package sdpcache import ( "context" "fmt" "sync" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) // ────────────────────────────────────────────────────────────────────── // Contract tests for the Cache interface. // // Every test in this file exercises only the public Cache methods and // asserts guarantees documented on the Cache interface in cache.go. // Implementation internals (Search, pending, shardFor, …) are tested // in the backend-specific test files. // // NoOpCache is intentionally excluded; its dedicated no-op semantics // are validated in noop_cache_test.go. // ────────────────────────────────────────────────────────────────────── // --- Lookup: miss / item-hit / error-hit ------------------------------------ func TestCacheContract_LookupMiss(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() hit, ck, items, qErr, done := cache.Lookup( t.Context(), "src", sdp.QueryMethod_GET, "scope", "type", "query", false, ) defer done() if hit { t.Fatal("expected miss on empty cache") } if len(items) != 0 { t.Fatalf("expected no items, got %d", len(items)) } if qErr != nil { t.Fatalf("expected nil error, got %v", qErr) } if ck.SST.SourceName != "src" || ck.SST.Scope != "scope" || ck.SST.Type != "type" { t.Fatalf("returned CacheKey SST mismatch: %v", ck) } }) } } func TestCacheContract_LookupItemHit(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, items, qErr, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if !hit { t.Fatal("expected item hit") } if qErr != nil { t.Fatalf("expected nil error, got %v", qErr) } if len(items) != 1 { t.Fatalf("expected 1 item, got %d", len(items)) } if items[0].GetType() != item.GetType() { t.Errorf("type mismatch: got %q, want %q", items[0].GetType(), item.GetType()) } }) } } func TestCacheContract_LookupErrorHit(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} ck := CacheKey{SST: sst, Method: new(sdp.QueryMethod_GET), UniqueAttributeValue: new("q")} qErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not found", Scope: sst.Scope, SourceName: sst.SourceName, ItemType: sst.Type, } cache.StoreUnavailableItem(ctx, qErr, 10*time.Second, ck) hit, _, items, retErr, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_GET, sst.Scope, sst.Type, "q", false) defer done() if !hit { t.Fatal("expected error hit") } if items != nil { t.Fatalf("expected nil items on error hit, got %d", len(items)) } if retErr == nil { t.Fatal("expected non-nil QueryError") } if retErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("error type: got %v, want NOTFOUND", retErr.GetErrorType()) } }) } } // --- ignoreCache ----------------------------------------------------------- func TestCacheContract_IgnoreCache(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, items, qErr, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), true, // ignoreCache ) defer done() if hit { t.Fatal("expected miss with ignoreCache=true") } if len(items) != 0 { t.Errorf("expected no items, got %d", len(items)) } if qErr != nil { t.Errorf("expected nil error, got %v", qErr) } }) } } // --- done() idempotency ---------------------------------------------------- func TestCacheContract_DoneIdempotent(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() _, _, _, _, done := cache.Lookup( t.Context(), "src", sdp.QueryMethod_GET, "scope", "type", "q", false, ) done() done() // must not panic }) } } func TestCacheContract_DoneIdempotentOnHit(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) _, _, _, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) done() done() // must not panic }) } } // --- GET cardinality ------------------------------------------------------- func TestCacheContract_GETMultipleItemsPurgesAndMisses(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} listMethod := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &listMethod} // Store two distinct entries (different GUN via scope) that both share // the same unique attribute value used by GET lookup. for i := range 2 { item := GenerateRandomItem() item.Scope = fmt.Sprintf("%s-%d", sst.Scope, i) item.Type = sst.Type item.Metadata.SourceName = sst.SourceName item.GetAttributes().Set("name", "shared-uav") cache.StoreItem(ctx, item, 10*time.Second, ck) } // Precondition: both entries are retrievable. hit, _, items, qErr, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false) defer done() if !hit { t.Fatal("expected LIST hit before GET cardinality purge") } if qErr != nil { t.Fatalf("expected nil error for LIST precondition, got %v", qErr) } if len(items) != 2 { t.Fatalf("expected 2 LIST items before purge, got %d", len(items)) } hit, _, _, _, done2 := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_GET, sst.Scope, sst.Type, "shared-uav", false) defer done2() if hit { t.Fatal("expected miss when GET finds >1 item (cardinality purge)") } // The purge should have removed all entries that matched the GET key. hit, _, _, _, done3 := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false) defer done3() if hit { t.Fatal("expected LIST miss after GET cardinality purge") } }) } } // --- Copy semantics -------------------------------------------------------- func TestCacheContract_StoreItemCopies(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) original := item.GetType() item.Type = "mutated-after-store" hit, _, items, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), original, item.UniqueAttributeValue(), false, ) defer done() if !hit || len(items) == 0 { t.Fatal("expected hit after StoreItem") } if items[0].GetType() == "mutated-after-store" { t.Error("cached item was mutated through original pointer") } }) } } // --- StoreItem + Lookup round-trip for LIST & SEARCH ----------------------- func TestCacheContract_LISTReturnsMultipleItems(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} ck := CacheKey{SST: sst, Method: new(sdp.QueryMethod_LIST)} for i := range 3 { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName item.GetAttributes().Set("name", fmt.Sprintf("item-%d", i)) cache.StoreItem(ctx, item, 10*time.Second, ck) } hit, _, items, qErr, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false) defer done() if qErr != nil { t.Fatalf("unexpected error: %v", qErr) } if !hit { t.Fatal("expected hit") } if len(items) != 3 { t.Errorf("expected 3 items, got %d", len(items)) } }) } } func TestCacheContract_SEARCHIsolatesByQuery(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} ck1 := CacheKey{SST: sst, Method: new(sdp.QueryMethod_SEARCH), Query: new("alpha")} item1 := GenerateRandomItem() item1.Scope = sst.Scope item1.Type = sst.Type item1.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item1, 10*time.Second, ck1) ck2 := CacheKey{SST: sst, Method: new(sdp.QueryMethod_SEARCH), Query: new("beta")} item2 := GenerateRandomItem() item2.Scope = sst.Scope item2.Type = sst.Type item2.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item2, 10*time.Second, ck2) // Lookup alpha hit, _, items, _, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_SEARCH, sst.Scope, sst.Type, "alpha", false) defer done() if !hit || len(items) != 1 { t.Errorf("alpha: hit=%v, items=%d", hit, len(items)) } // Lookup beta hit, _, items, _, done2 := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_SEARCH, sst.Scope, sst.Type, "beta", false) defer done2() if !hit || len(items) != 1 { t.Errorf("beta: hit=%v, items=%d", hit, len(items)) } }) } } // --- SEARCH items retrievable via GET (cross-method hit) ------------------- func TestCacheContract_SEARCHItemRetrievableViaGET(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() item.Metadata.SourceQuery.Method = sdp.QueryMethod_SEARCH ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, items, qErr, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if qErr != nil { t.Fatalf("unexpected error: %v", qErr) } if !hit { t.Fatal("expected GET hit for SEARCH-stored item") } if len(items) != 1 { t.Fatalf("expected 1 item, got %d", len(items)) } }) } } // --- Delete ---------------------------------------------------------------- func TestCacheContract_DeleteRemovesEntry(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) cache.Delete(ck) hit, _, _, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if hit { t.Fatal("expected miss after Delete") } }) } } func TestCacheContract_DeleteWildcard(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} for i := range 3 { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName item.GetAttributes().Set("name", fmt.Sprintf("wc-%d", i)) ck := CacheKey{SST: sst, Method: new(sdp.QueryMethod_LIST)} cache.StoreItem(ctx, item, 10*time.Second, ck) } // Delete with SST-only (wildcard on method/uav) cache.Delete(CacheKey{SST: sst}) hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false) defer done() if hit { t.Fatal("expected miss after wildcard Delete") } }) } } func TestCacheContract_DeleteOnEmptyCacheIsIdempotent(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() cache.Delete(CacheKey{SST: SST{SourceName: "x", Scope: "y", Type: "z"}}) }) } } // --- Clear ----------------------------------------------------------------- func TestCacheContract_Clear(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) cache.Clear() hit, _, _, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if hit { t.Fatal("expected miss after Clear") } }) } } func TestCacheContract_ClearThenStoreWorks(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() cache.Clear() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, items, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if !hit || len(items) != 1 { t.Fatalf("expected hit with 1 item after Clear+Store, got hit=%v items=%d", hit, len(items)) } }) } } func TestCacheContract_ClearOnEmptyCacheIsIdempotent(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() cache.Clear() cache.Clear() }) } } // --- Purge ----------------------------------------------------------------- func TestCacheContract_PurgeRemovesExpired(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 50*time.Millisecond, ck) stats := cache.Purge(ctx, time.Now().Add(100*time.Millisecond)) if stats.NumPurged != 1 { t.Errorf("expected 1 purged, got %d", stats.NumPurged) } hit, _, _, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if hit { t.Fatal("expected miss after purge") } }) } } func TestCacheContract_PurgeStatsNextExpiry(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item1 := GenerateRandomItem() ck1 := CacheKeyFromQuery(item1.GetMetadata().GetSourceQuery(), item1.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item1, 50*time.Millisecond, ck1) item2 := GenerateRandomItem() ck2 := CacheKeyFromQuery(item2.GetMetadata().GetSourceQuery(), item2.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item2, 5*time.Second, ck2) stats := cache.Purge(ctx, time.Now().Add(100*time.Millisecond)) if stats.NumPurged != 1 { t.Errorf("expected 1 purged, got %d", stats.NumPurged) } if stats.NextExpiry == nil { t.Fatal("expected non-nil NextExpiry (second item still cached)") } }) } } func TestCacheContract_PurgeEmptyCache(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() stats := cache.Purge(t.Context(), time.Now()) if stats.NumPurged != 0 { t.Errorf("expected 0 purged on empty cache, got %d", stats.NumPurged) } if stats.NextExpiry != nil { t.Errorf("expected nil NextExpiry on empty cache, got %v", stats.NextExpiry) } }) } } // --- GetMinWaitTime -------------------------------------------------------- func TestCacheContract_GetMinWaitTimePositive(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() if d := cache.GetMinWaitTime(); d <= 0 { t.Errorf("stateful cache should return positive min wait time, got %v", d) } }) } } // --- StartPurger ----------------------------------------------------------- func TestCacheContract_StartPurgerPurgesExpired(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 50*time.Millisecond, ck) cache.StartPurger(ctx) // Wait long enough for at least one purge cycle. time.Sleep(cache.GetMinWaitTime() + 200*time.Millisecond) hit, _, _, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if hit { t.Error("expected miss after purger ran (item expired)") } }) } } // --- Thundering herd / deduplication (documented contract) ---------------- func TestCacheContract_LookupDeduplication(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() ctx := t.Context() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} var workCount int var mu sync.Mutex var wg sync.WaitGroup numGoroutines := 10 results := make([]bool, numGoroutines) startBarrier := make(chan struct{}) for idx := range numGoroutines { wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false) defer done() if !hit { mu.Lock() workCount++ mu.Unlock() time.Sleep(50 * time.Millisecond) item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, _, _, done2 := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "", false) defer done2() results[idx] = hit } else { results[idx] = true } }) } close(startBarrier) wg.Wait() if workCount != 1 { t.Fatalf("expected 1 worker, got %d", workCount) } for i, hit := range results { if !hit { t.Errorf("goroutine %d: expected hit after dedup, got miss", i) } } }) } } func TestCacheContract_WaitersGetMissWhenWorkerStoresNothing(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() ctx := t.Context() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} var wg sync.WaitGroup startBarrier := make(chan struct{}) numWaiters := 3 waiterHits := make([]bool, 0, numWaiters) var waiterMu sync.Mutex // Worker: gets miss, completes without storing. wg.Go(func() { <-startBarrier hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "no-store", false) if hit { t.Error("worker: expected miss") } time.Sleep(50 * time.Millisecond) done() }) for range numWaiters { wg.Go(func() { <-startBarrier time.Sleep(10 * time.Millisecond) hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_LIST, sst.Scope, sst.Type, "no-store", false) defer done() waiterMu.Lock() waiterHits = append(waiterHits, hit) waiterMu.Unlock() }) } close(startBarrier) wg.Wait() for i, hit := range waiterHits { if hit { t.Errorf("waiter %d: expected miss when worker stored nothing", i) } } }) } } // --- Error precedence over items ------------------------------------------- func TestCacheContract_ErrorTakesPrecedenceOverItems(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() sst := SST{SourceName: "src", Scope: "scope", Type: "type"} ck := CacheKey{SST: sst, Method: new(sdp.QueryMethod_GET), UniqueAttributeValue: new("prec")} // Store an item first. item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName item.GetAttributes().Set("name", "prec") cache.StoreItem(ctx, item, 10*time.Second, ck) // Then store an error under the same key. cache.StoreUnavailableItem(ctx, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "gone", Scope: sst.Scope, SourceName: sst.SourceName, ItemType: sst.Type, }, 10*time.Second, ck) hit, _, items, qErr, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_GET, sst.Scope, sst.Type, "prec", false) defer done() if !hit { t.Fatal("expected hit") } if qErr == nil { t.Fatal("expected error hit (error should take precedence over items)") } if items != nil { t.Errorf("expected nil items when error takes precedence, got %d", len(items)) } }) } } // --- Zero/negative TTL ----------------------------------------------------- func TestCacheContract_ZeroTTLPurgedImmediately(t *testing.T) { for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 0, ck) // A zero-TTL item sets expiry to ~time.Now(). It may survive a // Search in the same nanosecond (strict Before check) but must // not survive a Purge with a future cutoff. stats := cache.Purge(ctx, time.Now().Add(time.Second)) if stats.NumPurged != 1 { t.Errorf("expected 1 purged, got %d", stats.NumPurged) } hit, _, _, _, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if hit { t.Error("expected miss after purging zero-TTL item") } }) } } // --- Multiple error types -------------------------------------------------- func TestCacheContract_StoreUnavailableItemTypes(t *testing.T) { errorTypes := []sdp.QueryError_ErrorType{ sdp.QueryError_NOTFOUND, sdp.QueryError_NOSCOPE, sdp.QueryError_TIMEOUT, sdp.QueryError_OTHER, } for _, impl := range cacheImplementations(t) { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() for i, et := range errorTypes { t.Run(et.String(), func(t *testing.T) { sst := SST{ SourceName: fmt.Sprintf("src-%d", i), Scope: "scope", Type: "type", } ck := CacheKey{SST: sst, Method: new(sdp.QueryMethod_GET), UniqueAttributeValue: new("q")} qErr := &sdp.QueryError{ ErrorType: et, ErrorString: fmt.Sprintf("err %s", et), Scope: sst.Scope, SourceName: sst.SourceName, ItemType: sst.Type, } cache.StoreUnavailableItem(ctx, qErr, 10*time.Second, ck) hit, _, items, retErr, done := cache.Lookup(ctx, sst.SourceName, sdp.QueryMethod_GET, sst.Scope, sst.Type, "q", false) defer done() if !hit { t.Fatal("expected hit for cached error") } if items != nil { t.Errorf("expected nil items, got %d", len(items)) } if retErr == nil || retErr.GetErrorType() != et { t.Errorf("error type: got %v, want %v", retErr.GetErrorType(), et) } }) } }) } } ================================================ FILE: go/sdpcache/cache_stuck_test.go ================================================ package sdpcache // ────────────────────────────────────────────────────────────────────── // "Stuck" scenario tests for the done() / pending-work lifecycle. // // These tests verify that proper use of done() and StoreUnavailableItem prevents // goroutines from blocking indefinitely. They exercise the public Cache // API and complement the contract suite with real-world error-recovery // patterns. // ────────────────────────────────────────────────────────────────────── import ( "context" "sync" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) // TestListErrorWithProperCleanup tests the correct behavior where: // 1. A LIST operation is performed and gets a cache miss // 2. The caller starts the work // 3. The query encounters an error // 4. The caller properly calls StoreUnavailableItem to cache the error // 5. Subsequent requests get the cached error immediately (don't block) // // This test documents the fix for the cache timeout bug. func TestListErrorWithProperCleanup(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST query := "" var wg sync.WaitGroup startBarrier := make(chan struct{}) // Track timing var secondCallDuration time.Duration // First goroutine: Gets cache miss, simulates work that errors, // and properly calls StoreUnavailableItem to cache the error wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if hit { t.Error("first goroutine: expected cache miss") return } // Simulate work that takes time and then errors time.Sleep(50 * time.Millisecond) // CORRECT BEHAVIOR: Worker encounters an error and properly caches it err := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "simulated list error", } cache.StoreUnavailableItem(ctx, err, 1*time.Hour, ck) t.Log("First goroutine: properly called StoreUnavailableItem") }) // Second goroutine: Should get cached error immediately wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) // Use a short timeout to detect blocking timeoutCtx, done := context.WithTimeout(ctx, 500*time.Millisecond) defer done() start := time.Now() hit, _, _, qErr, done := cache.Lookup(timeoutCtx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() secondCallDuration = time.Since(start) if !hit { t.Error("second goroutine: expected cache hit (cached error)") } if qErr == nil { t.Error("second goroutine: expected cached error") } t.Logf("Second goroutine: got cached error after %v", secondCallDuration) }) // Release all goroutines close(startBarrier) wg.Wait() // Verify the second call got the result quickly (didn't block) if secondCallDuration > 200*time.Millisecond { t.Fatalf("Second call took too long (%v), possibly blocked waiting for pending work", secondCallDuration) } t.Logf("✓ Second call returned quickly (%v) with cached error - proper cleanup is working", secondCallDuration) }) } } // TestListErrorWithProperCancellation tests the CORRECT behavior where: // 1. A LIST operation is performed and gets a cache miss // 2. The query encounters an error // 3. The caller properly calls the done function // 4. Subsequent requests should get a cache miss immediately (not block) func TestListErrorWithProperDone(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST query := "" var wg sync.WaitGroup startBarrier := make(chan struct{}) // Track timing var secondCallDuration time.Duration // First goroutine: Gets cache miss, simulates work that errors, // and PROPERLY calls the done function wg.Go(func() { <-startBarrier hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) if hit { t.Error("first goroutine: expected cache miss") done() // Clean up even on error return } // Simulate work that takes time and then errors time.Sleep(100 * time.Millisecond) // CORRECT BEHAVIOR: Call done to release resources done() t.Log("First goroutine: properly called done()") }) // Second goroutine: Should receive cache miss quickly (not block) wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) start := time.Now() hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() secondCallDuration = time.Since(start) if hit { t.Error("second goroutine: expected cache miss") } t.Logf("Second goroutine: got cache miss after %v", secondCallDuration) }) // Release all goroutines close(startBarrier) wg.Wait() // The second call should NOT block for long // It should get a cache miss shortly after the first call done() (~100ms) if secondCallDuration > 300*time.Millisecond { t.Errorf("Expected second call to return quickly after cancellation, but it took %v", secondCallDuration) } t.Logf("Test demonstrates correct behavior: second call returned in %v", secondCallDuration) }) } } // TestListErrorWithStoreUnavailableItem tests the CORRECT behavior where: // 1. A LIST operation is performed and gets a cache miss // 2. The query encounters an error // 3. The caller properly calls StoreUnavailableItem // 4. Subsequent requests should get the cached error immediately func TestListErrorWithStoreUnavailableItem(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST query := "" var wg sync.WaitGroup startBarrier := make(chan struct{}) expectedError := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "list returned error", Scope: sst.Scope, SourceName: sst.SourceName, ItemType: sst.Type, } // Track results var secondCallHit bool var secondCallError *sdp.QueryError var secondCallDuration time.Duration // First goroutine: Gets cache miss, simulates work that errors, // and PROPERLY calls StoreUnavailableItem wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if hit { t.Error("first goroutine: expected cache miss") return } // Simulate work that takes time and then errors time.Sleep(100 * time.Millisecond) // CORRECT BEHAVIOR: Store the error so other callers can get it cache.StoreUnavailableItem(ctx, expectedError, 10*time.Second, ck) t.Log("First goroutine: properly called StoreUnavailableItem") }) // Second goroutine: Should receive the cached error wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) start := time.Now() var items []*sdp.Item var done func() secondCallHit, _, items, secondCallError, done = cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() secondCallDuration = time.Since(start) if items != nil { t.Error("second goroutine: expected nil items with error") } t.Logf("Second goroutine: got result after %v", secondCallDuration) }) // Release all goroutines close(startBarrier) wg.Wait() // The second call should get the cached error if !secondCallHit { t.Error("Expected cache hit with error") } if secondCallError == nil { t.Error("Expected error to be returned") } if secondCallError != nil && secondCallError.GetErrorType() != expectedError.GetErrorType() { t.Errorf("Expected error type %v, got %v", expectedError.GetErrorType(), secondCallError.GetErrorType()) } // Should return relatively quickly (~100ms for first goroutine work) if secondCallDuration > 300*time.Millisecond { t.Errorf("Expected second call to return quickly with cached error, but it took %v", secondCallDuration) } t.Logf("Test demonstrates correct behavior: second call got cached error in %v", secondCallDuration) }) } } // TestListReturnsEmptyButNoStore tests the scenario where: // 1. A LIST operation completes successfully but finds no items // 2. The caller calls Complete() but doesn't store anything // 3. Subsequent requests should get cache miss (not error) func TestListReturnsEmptyButNoStore(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST query := "" var wg sync.WaitGroup startBarrier := make(chan struct{}) var secondCallHit bool var secondCallDuration time.Duration // First goroutine: LIST returns 0 items, completes without storing wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if hit { t.Error("first goroutine: expected cache miss") return } // Simulate work that completes and finds no items time.Sleep(100 * time.Millisecond) // Complete without storing anything (LIST found 0 items) // This is handled by the underlying pending work mechanism switch c := cache.(type) { case *MemoryCache: c.pending.Complete(ck.String()) case *BoltCache: c.pending.Complete(ck.String()) } t.Log("First goroutine: completed work but stored nothing") }) // Second goroutine: Should get cache miss wg.Go(func() { <-startBarrier // Small delay to ensure first goroutine starts first time.Sleep(10 * time.Millisecond) start := time.Now() secondCallHit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() secondCallDuration = time.Since(start) t.Logf("Second goroutine: hit=%v, duration=%v", secondCallHit, secondCallDuration) }) // Release all goroutines close(startBarrier) wg.Wait() // Second call should get cache miss (not error) if secondCallHit { t.Error("Expected cache miss when first caller completed without storing") } // Should return relatively quickly (~100ms for first goroutine work) if secondCallDuration > 300*time.Millisecond { t.Errorf("Expected second call to return quickly, but it took %v", secondCallDuration) } }) } } ================================================ FILE: go/sdpcache/cache_test.go ================================================ package sdpcache // ────────────────────────────────────────────────────────────────────── // Implementation-detail tests for stateful cache backends. // // These tests exercise the internal Search method and storage internals // that are NOT part of the public Cache contract. Contract-level tests // (using only the public Cache interface) live in cache_contract_test.go. // NoOpCache-specific tests live in noop_cache_test.go. // ────────────────────────────────────────────────────────────────────── import ( "context" "errors" "fmt" "path/filepath" "sync" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) type searchableCache interface { Search(context.Context, CacheKey) ([]*sdp.Item, error) } // testSearch is a helper function that calls the lower-level Search method on // cache implementations for testing purposes. func testSearch(ctx context.Context, cache Cache, ck CacheKey) ([]*sdp.Item, error) { if c, ok := cache.(searchableCache); ok { return c.Search(ctx, ck) } return nil, fmt.Errorf("unsupported cache type for search: %T", cache) } // cacheImplementations returns stateful cache implementations used by shared // behavior tests. NoOpCache is intentionally excluded and tested separately. // Accepts testing.TB so it can be used by both tests and benchmarks. func cacheImplementations(tb testing.TB) []struct { name string factory func() Cache } { return []struct { name string factory func() Cache }{ {"MemoryCache", func() Cache { return NewMemoryCache() }}, {"BoltCache", func() Cache { c, err := NewBoltCache(filepath.Join(tb.TempDir(), "cache.db")) if err != nil { tb.Fatalf("failed to create BoltCache: %v", err) } tb.Cleanup(func() { _ = c.CloseAndDestroy() }) return c }}, {"ShardedCache", func() Cache { c, err := NewShardedCache( filepath.Join(tb.TempDir(), "shards"), DefaultShardCount, ) if err != nil { tb.Fatalf("failed to create ShardedCache: %v", err) } tb.Cleanup(func() { _ = c.CloseAndDestroy() }) return c }}, } } func TestStoreItem(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(t.Context(), item, 10*time.Second, ck) results, err := testSearch(t.Context(), cache, ck) if err != nil { t.Error(err) } if len(results) != 1 { t.Errorf("expected 1 result, got %v", len(results)) } // Test another match item = GenerateRandomItem() ck = CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(t.Context(), item, 10*time.Second, ck) results, err = testSearch(t.Context(), cache, ck) if err != nil { t.Error(err) } if len(results) != 1 { t.Errorf("expected 1 result, got %v", len(results)) } // Test different scope item = GenerateRandomItem() ck = CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(t.Context(), item, 10*time.Second, ck) ck.SST.Scope = fmt.Sprintf("new scope %v", ck.SST.Scope) results, err = testSearch(t.Context(), cache, ck) if err != nil { if !errors.Is(err, ErrCacheNotFound) { t.Error(err) } else { t.Log("expected cache miss") } } if len(results) != 0 { t.Errorf("expected 0 result, got %v", results) } }) } } func TestStoreUnavailableItem(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() // Test with just an error sst := SST{ SourceName: "foo", Scope: "foo", Type: "foo", } uav := "foo" cache.StoreUnavailableItem(t.Context(), errors.New("arse"), 10*time.Second, CacheKey{ SST: sst, Method: sdp.QueryMethod_GET.Enum(), Query: &uav, }) items, err := testSearch(t.Context(), cache, CacheKey{ SST: sst, Method: sdp.QueryMethod_GET.Enum(), Query: &uav, }) if len(items) > 0 { t.Errorf("expected 0 items, got %v", len(items)) } if err == nil { t.Error("expected error, got nil") } // Test with items and an error for the same query // Add an item with the same details as above item := GenerateRandomItem() item.Metadata.SourceQuery.Method = sdp.QueryMethod_GET item.Metadata.SourceQuery.Query = "foo" item.Metadata.SourceName = "foo" item.Scope = "foo" item.Type = "foo" ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) items, err = testSearch(t.Context(), cache, ck) if len(items) > 0 { t.Errorf("expected 0 items, got %v", len(items)) } if err == nil { t.Error("expected error, got nil") } // Test with multiple errors cache.StoreUnavailableItem(t.Context(), errors.New("nope"), 10*time.Second, CacheKey{ SST: sst, Method: sdp.QueryMethod_GET.Enum(), Query: &uav, }) items, err = testSearch(t.Context(), cache, CacheKey{ SST: sst, Method: sdp.QueryMethod_GET.Enum(), Query: &uav, }) if len(items) > 0 { t.Errorf("expected 0 items, got %v", len(items)) } if err == nil { t.Error("expected error, got nil") } }) } } func TestPurge(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() cachedItems := []struct { Item *sdp.Item Expiry time.Time }{ { Item: GenerateRandomItem(), Expiry: time.Now().Add(50 * time.Millisecond), }, { Item: GenerateRandomItem(), Expiry: time.Now().Add(1 * time.Second), }, { Item: GenerateRandomItem(), Expiry: time.Now().Add(2 * time.Second), }, { Item: GenerateRandomItem(), Expiry: time.Now().Add(3 * time.Second), }, { Item: GenerateRandomItem(), Expiry: time.Now().Add(4 * time.Second), }, { Item: GenerateRandomItem(), Expiry: time.Now().Add(5 * time.Second), }, } for _, i := range cachedItems { ck := CacheKeyFromQuery(i.Item.GetMetadata().GetSourceQuery(), i.Item.GetMetadata().GetSourceName()) cache.StoreItem(t.Context(), i.Item, time.Until(i.Expiry), ck) } // Make sure all the items are in the cache for _, i := range cachedItems { ck := CacheKeyFromQuery(i.Item.GetMetadata().GetSourceQuery(), i.Item.GetMetadata().GetSourceName()) items, err := testSearch(t.Context(), cache, ck) if err != nil { t.Error(err) } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } } // Purge just the first one stats := cache.Purge(t.Context(), cachedItems[0].Expiry.Add(500*time.Millisecond)) if stats.NumPurged != 1 { t.Errorf("expected 1 item purged, got %v", stats.NumPurged) } // The times won't be exactly equal because we're checking it against // time.Now more than once. So I need to check that they are *almost* the // same, but not exactly nextExpiryString := stats.NextExpiry.Format(time.RFC3339) expectedNextExpiryString := cachedItems[1].Expiry.Format(time.RFC3339) if nextExpiryString != expectedNextExpiryString { t.Errorf("expected next expiry to be %v, got %v", expectedNextExpiryString, nextExpiryString) } // Purge all but the last one stats = cache.Purge(t.Context(), cachedItems[4].Expiry.Add(500*time.Millisecond)) if stats.NumPurged != 4 { t.Errorf("expected 4 item purged, got %v", stats.NumPurged) } // Purge the last one stats = cache.Purge(t.Context(), cachedItems[5].Expiry.Add(500*time.Millisecond)) if stats.NumPurged != 1 { t.Errorf("expected 1 item purged, got %v", stats.NumPurged) } if stats.NextExpiry != nil { t.Errorf("expected expiry to be nil, got %v", stats.NextExpiry) } }) } } func TestDelete(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() // Insert an item item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(t.Context(), item, time.Millisecond, ck) sst := SST{ SourceName: item.GetMetadata().GetSourceName(), Scope: item.GetScope(), Type: item.GetType(), } // It should be there items, err := testSearch(t.Context(), cache, CacheKey{ SST: sst, }) if err != nil { t.Error(err) } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } // Delete it cache.Delete(CacheKey{ SST: sst, }) // It should be gone items, err = testSearch(t.Context(), cache, CacheKey{ SST: sst, }) if !errors.Is(err, ErrCacheNotFound) { t.Errorf("expected ErrCacheNotFound, got %v", err) } if len(items) != 0 { t.Errorf("expected 0 item, got %v", len(items)) } }) } } func TestSearchWithListMethod(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() // Store items with LIST method sst := SST{SourceName: "test", Scope: "scope", Type: "type"} listMethod := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &listMethod} item1 := GenerateRandomItem() item1.Scope = sst.Scope item1.Type = sst.Type cache.StoreItem(t.Context(), item1, 10*time.Second, ck) item2 := GenerateRandomItem() item2.Scope = sst.Scope item2.Type = sst.Type cache.StoreItem(t.Context(), item2, 10*time.Second, ck) // Search should return both items items, err := testSearch(t.Context(), cache, ck) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(items) != 2 { t.Errorf("expected 2 items, got %v", len(items)) } }) } } func TestSearchMethodWithDifferentQueries(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} searchMethod := sdp.QueryMethod_SEARCH // Store items with different search queries query1 := "query1" ck1 := CacheKey{SST: sst, Method: &searchMethod, Query: &query1} item1 := GenerateRandomItem() item1.Scope = sst.Scope item1.Type = sst.Type cache.StoreItem(t.Context(), item1, 10*time.Second, ck1) query2 := "query2" ck2 := CacheKey{SST: sst, Method: &searchMethod, Query: &query2} item2 := GenerateRandomItem() item2.Scope = sst.Scope item2.Type = sst.Type cache.StoreItem(t.Context(), item2, 10*time.Second, ck2) // Search with query1 should only return item1 items, err := testSearch(t.Context(), cache, ck1) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(items) != 1 { t.Errorf("expected 1 item for query1, got %v", len(items)) } // Search with query2 should only return item2 items, err = testSearch(t.Context(), cache, ck2) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(items) != 1 { t.Errorf("expected 1 item for query2, got %v", len(items)) } }) } } func TestSearchWithPartialCacheKey(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} // Store items with different methods getMethod := sdp.QueryMethod_GET listMethod := sdp.QueryMethod_LIST item1 := GenerateRandomItem() item1.Scope = sst.Scope item1.Type = sst.Type uav1 := "item1" ck1 := CacheKey{SST: sst, Method: &getMethod, UniqueAttributeValue: &uav1} cache.StoreItem(t.Context(), item1, 10*time.Second, ck1) item2 := GenerateRandomItem() item2.Scope = sst.Scope item2.Type = sst.Type ck2 := CacheKey{SST: sst, Method: &listMethod} cache.StoreItem(t.Context(), item2, 10*time.Second, ck2) // Search with SST only should return both items ckPartial := CacheKey{SST: sst} items, err := testSearch(t.Context(), cache, ckPartial) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(items) != 2 { t.Errorf("expected 2 items with SST-only search, got %v", len(items)) } }) } } func TestDeleteWithPartialCacheKey(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} // Store multiple items with same SST item1 := GenerateRandomItem() item1.Scope = sst.Scope item1.Type = sst.Type ck1 := CacheKeyFromQuery(item1.GetMetadata().GetSourceQuery(), sst.SourceName) cache.StoreItem(t.Context(), item1, 10*time.Second, ck1) item2 := GenerateRandomItem() item2.Scope = sst.Scope item2.Type = sst.Type ck2 := CacheKeyFromQuery(item2.GetMetadata().GetSourceQuery(), sst.SourceName) cache.StoreItem(t.Context(), item2, 10*time.Second, ck2) // Delete with SST only should remove all items cache.Delete(CacheKey{SST: sst}) // Verify all items are gone items, err := testSearch(t.Context(), cache, CacheKey{SST: sst}) if !errors.Is(err, ErrCacheNotFound) { t.Errorf("expected ErrCacheNotFound after delete, got: %v", err) } if len(items) != 0 { t.Errorf("expected 0 items after delete, got %v", len(items)) } }) } } func TestLookupWithCachedError(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() // Test different error types errorTypes := []struct { name string errorType sdp.QueryError_ErrorType }{ {"NOTFOUND", sdp.QueryError_NOTFOUND}, {"NOSCOPE", sdp.QueryError_NOSCOPE}, {"TIMEOUT", sdp.QueryError_TIMEOUT}, {"OTHER", sdp.QueryError_OTHER}, } for i, et := range errorTypes { t.Run(et.name, func(t *testing.T) { sst := SST{ SourceName: fmt.Sprintf("test%d", i), Scope: "scope", Type: "type", } method := sdp.QueryMethod_GET query := "test" ck := CacheKey{SST: sst, Method: &method, UniqueAttributeValue: &query} // Store error qErr := &sdp.QueryError{ ErrorType: et.errorType, ErrorString: fmt.Sprintf("test error %s", et.name), Scope: sst.Scope, SourceName: sst.SourceName, ItemType: sst.Type, } cache.StoreUnavailableItem(ctx, qErr, 10*time.Second, ck) // Lookup should return cached error cacheHit, _, items, returnedErr, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if !cacheHit { t.Error("expected cache hit for cached error") } if items != nil { t.Errorf("expected nil items, got %v", items) } if returnedErr == nil { t.Fatal("expected error to be returned") } if returnedErr.GetErrorType() != et.errorType { t.Errorf("expected error type %v, got %v", et.errorType, returnedErr.GetErrorType()) } }) } }) } } func TestGetMinWaitTime(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() minWaitTime := cache.GetMinWaitTime() // Should return a positive duration if minWaitTime <= 0 { t.Errorf("expected positive duration, got %v", minWaitTime) } // Default should be reasonable (e.g., 5 seconds) if minWaitTime > time.Minute { t.Errorf("expected reasonable default (< 1 minute), got %v", minWaitTime) } }) } } func TestEmptyCacheOperations(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { cache := impl.factory() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} ck := CacheKey{SST: sst} // Search on empty cache items, err := testSearch(t.Context(), cache, ck) if !errors.Is(err, ErrCacheNotFound) { t.Errorf("expected ErrCacheNotFound on empty cache, got: %v", err) } if len(items) != 0 { t.Errorf("expected 0 items on empty cache, got %v", len(items)) } // Delete on empty cache (should be idempotent) cache.Delete(ck) // Purge on empty cache stats := cache.Purge(t.Context(), time.Now()) if stats.NumPurged != 0 { t.Errorf("expected 0 items purged on empty cache, got %v", stats.NumPurged) } if stats.NextExpiry != nil { t.Errorf("expected nil NextExpiry on empty cache, got %v", stats.NextExpiry) } // Clear on empty cache (should not error) cache.Clear() }) } } func TestMultipleItemsSameSST(t *testing.T) { implementations := cacheImplementations(t) for _, impl := range implementations { t.Run(impl.name, func(t *testing.T) { ctx := t.Context() cache := impl.factory() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} method := sdp.QueryMethod_GET // Store multiple items with same SST but different unique attributes items := make([]*sdp.Item, 3) for i := range 3 { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName uav := fmt.Sprintf("item%d", i) // Set the item's unique attribute value to match the CacheKey attrs := make(map[string]any) if item.GetAttributes() != nil && item.GetAttributes().GetAttrStruct() != nil { for k, v := range item.GetAttributes().GetAttrStruct().GetFields() { attrs[k] = v } } attrs["name"] = uav attributes, _ := sdp.ToAttributes(attrs) item.Attributes = attributes ck := CacheKey{SST: sst, Method: &method, UniqueAttributeValue: &uav} cache.StoreItem(ctx, item, 10*time.Second, ck) items[i] = item } // Search with SST only should return all 3 items allItems, err := testSearch(t.Context(), cache, CacheKey{SST: sst}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(allItems) != 3 { t.Errorf("expected 3 items, got %v", len(allItems)) } // Search with specific unique attribute should return only that item for i := range 3 { uav := fmt.Sprintf("item%d", i) ck := CacheKey{SST: sst, Method: &method, UniqueAttributeValue: &uav} foundItems, err := testSearch(t.Context(), cache, ck) if err != nil { t.Errorf("unexpected error for item%d: %v", i, err) } if len(foundItems) != 1 { t.Errorf("expected 1 item for item%d, got %v", i, len(foundItems)) } } }) } } func TestToIndexValues(t *testing.T) { ck := CacheKey{ SST: SST{ SourceName: "foo", Scope: "foo", Type: "foo", }, } t.Run("with just SST", func(t *testing.T) { iv := ck.ToIndexValues() if iv.SSTHash != ck.SST.Hash() { t.Error("hash mismatch") } }) t.Run("with SST & Method", func(t *testing.T) { ck.Method = sdp.QueryMethod_GET.Enum() iv := ck.ToIndexValues() if iv.Method != sdp.QueryMethod_GET { t.Errorf("expected %v, got %v", sdp.QueryMethod_GET, iv.Method) } }) t.Run("with SST & Query", func(t *testing.T) { q := "query" ck.Query = &q iv := ck.ToIndexValues() if iv.Query != "query" { t.Errorf("expected %v, got %v", "query", iv.Query) } }) t.Run("with SST & UniqueAttributeValue", func(t *testing.T) { q := "foo" ck.UniqueAttributeValue = &q iv := ck.ToIndexValues() if iv.UniqueAttributeValue != "foo" { t.Errorf("expected %v, got %v", "foo", iv.UniqueAttributeValue) } }) } func TestUnexpiredOverwriteLogging(t *testing.T) { cache := NewCache(t.Context()) t.Run("overwriting unexpired entry increments counter", func(t *testing.T) { ctx := t.Context() // Create an item and cache key item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) // Store the item with a long TTL (10 seconds) cache.StoreItem(ctx, item, 10*time.Second, ck) // Store the same item again before it expires (overwrite will be tracked via span attributes) cache.StoreItem(ctx, item, 10*time.Second, ck) // Store it again cache.StoreItem(ctx, item, 10*time.Second, ck) }) t.Run("overwriting expired entry does not increment counter", func(t *testing.T) { ctx := t.Context() // Create a new cache for this test cache := NewCache(ctx) item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) // Store the item with a very short TTL cache.StoreItem(ctx, item, 1*time.Millisecond, ck) // Wait for it to expire time.Sleep(10 * time.Millisecond) // Store the same item again after it expired (overwrite tracking via span attributes) cache.StoreItem(ctx, item, 10*time.Second, ck) }) t.Run("overwriting different items does not increment counter", func(t *testing.T) { ctx := t.Context() // Create a new cache for this test cache := NewCache(ctx) item1 := GenerateRandomItem() item2 := GenerateRandomItem() ck1 := CacheKeyFromQuery(item1.GetMetadata().GetSourceQuery(), item1.GetMetadata().GetSourceName()) ck2 := CacheKeyFromQuery(item2.GetMetadata().GetSourceQuery(), item2.GetMetadata().GetSourceName()) // Store two different items (no overwrites, just new items) cache.StoreItem(ctx, item1, 10*time.Second, ck1) cache.StoreItem(ctx, item2, 10*time.Second, ck2) }) t.Run("overwriting error entries increments counter", func(t *testing.T) { ctx := t.Context() // Create a new cache for this test cache := NewCache(ctx) sst := SST{ SourceName: "test-source", Scope: "test-scope", Type: "test-type", } method := sdp.QueryMethod_LIST query := "test-query" ck := CacheKey{ SST: sst, Method: &method, Query: &query, } // Store an error cache.StoreUnavailableItem(ctx, errors.New("test error"), 10*time.Second, ck) // Store the same error again before it expires (overwrite will be tracked via span attributes) cache.StoreUnavailableItem(ctx, errors.New("another error"), 10*time.Second, ck) }) } // TestPendingWorkUnit tests the pendingWork component in isolation. func TestPendingWorkUnit(t *testing.T) { t.Run("StartWork first caller", func(t *testing.T) { pw := newPendingWork() shouldWork, entry := pw.StartWork("key1") if !shouldWork { t.Error("first caller should do work") } if entry == nil { t.Error("entry should not be nil") } }) t.Run("StartWork second caller", func(t *testing.T) { pw := newPendingWork() // First caller shouldWork1, entry1 := pw.StartWork("key1") if !shouldWork1 { t.Error("first caller should do work") } // Second caller for same key shouldWork2, entry2 := pw.StartWork("key1") if shouldWork2 { t.Error("second caller should not do work") } if entry2 != entry1 { t.Error("second caller should get same entry") } }) t.Run("Complete wakes waiters", func(t *testing.T) { pw := newPendingWork() ctx := context.Background() // First caller _, entry := pw.StartWork("key1") // Second caller waits var wg sync.WaitGroup var waitOk bool wg.Go(func() { waitOk = pw.Wait(ctx, entry) }) // Give waiter time to start waiting time.Sleep(10 * time.Millisecond) // Complete the work pw.Complete("key1") wg.Wait() if !waitOk { t.Error("wait should succeed") } }) t.Run("Wait respects context donelation", func(t *testing.T) { pw := newPendingWork() ctx, done := context.WithCancel(context.Background()) // First caller _, entry := pw.StartWork("key1") // Second caller waits with donelable context var wg sync.WaitGroup var waitOk bool wg.Go(func() { waitOk = pw.Wait(ctx, entry) }) // Give waiter time to start waiting time.Sleep(10 * time.Millisecond) // Cancel the context done() wg.Wait() if waitOk { t.Error("wait should fail due to context donelation") } }) } ================================================ FILE: go/sdpcache/item_generator_test.go ================================================ package sdpcache import ( "math/rand" "time" "github.com/google/uuid" "github.com/overmindtech/cli/go/sdp-go" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" ) var Types = []string{ "person", "dog", "kite", "flag", "cat", "leopard", "fish", "bird", "kangaroo", "ostrich", "emu", "hawk", "mole", "badger", "lemur", } const ( MaxAttributes = 30 MaxTags = 10 MaxTagKeyLength = 10 MaxTagValueLength = 10 MaxAttributeKeyLength = 20 MaxAttributeValueLength = 50 ) // TODO(LIQs): rewrite this to `MaxEdges` const MaxLinkedItems = 10 // TODO(LIQs): delete const MaxLinkedItemQueries = 10 // GenerateRandomItem Generates a random item and the tags for this item. The // tags include the name, type and a tag called "all" with a value of "all" func GenerateRandomItem() *sdp.Item { attrs := make(map[string]any) name := randSeq(rand.Intn(MaxAttributeValueLength)) typ := Types[rand.Intn(len(Types))] scope := randSeq(rand.Intn(MaxAttributeKeyLength)) attrs["name"] = name for range rand.Intn(MaxAttributes) { attrs[randSeq(rand.Intn(MaxAttributeKeyLength))] = randSeq(rand.Intn(MaxAttributeValueLength)) } attributes, _ := sdp.ToAttributes(attrs) tags := make(map[string]string) for range rand.Intn(MaxTags) { tags[randSeq(rand.Intn(MaxTagKeyLength))] = randSeq(rand.Intn(MaxTagValueLength)) } // TODO(LIQs): rewrite this to `MaxEdges` and return and additional []*sdp.Edge linkedItems := make([]*sdp.LinkedItem, rand.Intn(MaxLinkedItems)) for i := range linkedItems { linkedItems[i] = &sdp.LinkedItem{Item: &sdp.Reference{ Type: randSeq(rand.Intn(MaxAttributeKeyLength)), UniqueAttributeValue: randSeq(rand.Intn(MaxAttributeValueLength)), Scope: randSeq(rand.Intn(MaxAttributeKeyLength)), }} } linkedItemQueries := make([]*sdp.LinkedItemQuery, rand.Intn(MaxLinkedItemQueries)) for i := range linkedItemQueries { linkedItemQueries[i] = &sdp.LinkedItemQuery{Query: &sdp.Query{ Type: randSeq(rand.Intn(MaxAttributeKeyLength)), Method: sdp.QueryMethod(rand.Intn(3)), Query: randSeq(rand.Intn(MaxAttributeValueLength)), RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: rand.Uint32(), }, Scope: randSeq(rand.Intn(MaxAttributeKeyLength)), }} } // Generate health (which is an int32 between 0 and 4) health := sdp.Health(rand.Intn(int(sdp.Health_HEALTH_PENDING) + 1)) queryUuid := uuid.New() item := sdp.Item{ Type: typ, UniqueAttribute: "name", Attributes: attributes, Scope: scope, LinkedItemQueries: linkedItemQueries, LinkedItems: linkedItems, Metadata: &sdp.Metadata{ SourceName: randSeq(rand.Intn(MaxAttributeKeyLength)), SourceQuery: &sdp.Query{ Type: typ, Method: sdp.QueryMethod_GET, Query: name, RecursionBehaviour: &sdp.Query_RecursionBehaviour{ LinkDepth: 1, }, Scope: scope, UUID: queryUuid[:], }, Timestamp: timestamppb.New(time.Now()), SourceDuration: durationpb.New(time.Millisecond * time.Duration(rand.Int63())), SourceDurationPerItem: durationpb.New(time.Millisecond * time.Duration(rand.Int63())), }, Tags: tags, Health: &health, } return &item } var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func randSeq(n int) string { b := make([]rune, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } ================================================ FILE: go/sdpcache/lookup_coordinator.go ================================================ package sdpcache import ( "context" "errors" "sync" "time" "github.com/overmindtech/cli/go/sdp-go" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // lookupBackend is the storage-facing interface used by lookupCoordinator. // Implementations should focus on cache I/O while lookupCoordinator owns // pending-work deduplication and shared branching behavior. type lookupBackend interface { Search(ctx context.Context, ck CacheKey) ([]*sdp.Item, error) Delete(ck CacheKey) } // lookupCoordinator centralizes shared Lookup control flow: // cache miss deduplication, wait/re-check behavior, error classification, // and GET cardinality validation. type lookupCoordinator struct { pending *pendingWork } func newLookupCoordinator(pending *pendingWork) *lookupCoordinator { if pending == nil { pending = newPendingWork() } return &lookupCoordinator{ pending: pending, } } func (lc *lookupCoordinator) doneForMiss(ck CacheKey) func() { if lc == nil || lc.pending == nil { return noopDone } key := ck.String() var once sync.Once return func() { once.Do(func() { lc.pending.Complete(key) }) } } func (lc *lookupCoordinator) Lookup( ctx context.Context, backend lookupBackend, ck CacheKey, requestedMethod sdp.QueryMethod, ) (bool, []*sdp.Item, *sdp.QueryError, func()) { span := trace.SpanFromContext(ctx) initialSearchStart := time.Now() items, err := backend.Search(ctx, ck) span.SetAttributes(attribute.Float64("ovm.cache.initialSearchDurationMs", float64(time.Since(initialSearchStart).Milliseconds()))) if err != nil { var qErr *sdp.QueryError if errors.Is(err, ErrCacheNotFound) { shouldWork, entry := lc.pending.StartWork(ck.String()) if shouldWork { span.SetAttributes( attribute.String("ovm.cache.result", "cache miss"), attribute.Bool("ovm.cache.hit", false), attribute.Bool("ovm.cache.workPending", false), ) return false, nil, nil, lc.doneForMiss(ck) } pendingWaitStart := time.Now() ok := lc.pending.Wait(ctx, entry) pendingWaitDuration := time.Since(pendingWaitStart) span.SetAttributes( attribute.Float64("ovm.cache.pendingWaitDurationMs", float64(pendingWaitDuration.Milliseconds())), attribute.Bool("ovm.cache.pendingWaitSuccess", ok), ) if !ok { span.SetAttributes( attribute.String("ovm.cache.result", "pending work cancelled or timeout"), attribute.Bool("ovm.cache.hit", false), ) return false, nil, nil, noopDone } recheckStart := time.Now() items, recheckErr := backend.Search(ctx, ck) span.SetAttributes(attribute.Float64("ovm.cache.recheckSearchDurationMs", float64(time.Since(recheckStart).Milliseconds()))) if recheckErr != nil { if errors.Is(recheckErr, ErrCacheNotFound) { span.SetAttributes( attribute.String("ovm.cache.result", "pending work completed but cache still empty"), attribute.Bool("ovm.cache.hit", false), ) return false, nil, nil, noopDone } var recheckQErr *sdp.QueryError if errors.As(recheckErr, &recheckQErr) { span.SetAttributes( attribute.String("ovm.cache.result", "cache hit from pending work: error"), attribute.Bool("ovm.cache.hit", true), ) return true, nil, recheckQErr, noopDone } span.SetAttributes( attribute.String("ovm.cache.result", "unexpected error on re-check"), attribute.Bool("ovm.cache.hit", false), ) return false, nil, nil, noopDone } span.SetAttributes( attribute.String("ovm.cache.result", "cache hit from pending work"), attribute.Int("ovm.cache.numItems", len(items)), attribute.Bool("ovm.cache.hit", true), ) return true, items, nil, noopDone } if errors.As(err, &qErr) { if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { span.SetAttributes(attribute.String("ovm.cache.result", "cache hit: item not found")) } else { span.SetAttributes( attribute.String("ovm.cache.result", "cache hit: QueryError"), attribute.String("ovm.cache.error", err.Error()), ) } span.SetAttributes(attribute.Bool("ovm.cache.hit", true)) return true, nil, qErr, noopDone } qErr = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: ck.SST.Scope, SourceName: ck.SST.SourceName, ItemType: ck.SST.Type, } span.SetAttributes( attribute.String("ovm.cache.error", err.Error()), attribute.String("ovm.cache.result", "cache hit: unknown QueryError"), attribute.Bool("ovm.cache.hit", true), ) return true, nil, qErr, noopDone } if requestedMethod == sdp.QueryMethod_GET { if len(items) < 2 { span.SetAttributes( attribute.String("ovm.cache.result", "cache hit: 1 item"), attribute.Int("ovm.cache.numItems", len(items)), attribute.Bool("ovm.cache.hit", true), ) return true, items, nil, noopDone } span.SetAttributes( attribute.String("ovm.cache.result", "cache returned >1 value, purging and continuing"), attribute.Int("ovm.cache.numItems", len(items)), attribute.Bool("ovm.cache.hit", false), ) backend.Delete(ck) return false, nil, nil, noopDone } span.SetAttributes( attribute.String("ovm.cache.result", "cache hit: multiple items"), attribute.Int("ovm.cache.numItems", len(items)), attribute.Bool("ovm.cache.hit", true), ) return true, items, nil, noopDone } ================================================ FILE: go/sdpcache/memory.go ================================================ package sdpcache import ( "context" "sync" "time" "github.com/google/btree" "github.com/overmindtech/cli/go/sdp-go" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/proto" ) type MemoryCache struct { purger indexes map[SSTHash]*indexSet // This index is used to track item expiries, since items can have different // expiry durations we need to use a btree here rather than just appending // to a slice or something. The purge process uses this to determine what // needs deleting, then calls into each specific index to delete as required expiryIndex *btree.BTreeG[*CachedResult] // Mutex for reading caches indexMutex sync.RWMutex // Tracks in-flight lookups to prevent duplicate work when multiple // goroutines request the same cache key simultaneously pending *pendingWork lookup *lookupCoordinator } var _ Cache = (*MemoryCache)(nil) // NewMemoryCache creates a new in-memory cache implementation. func NewMemoryCache() *MemoryCache { pending := newPendingWork() c := &MemoryCache{ indexes: make(map[SSTHash]*indexSet), expiryIndex: newExpiryIndex(), pending: pending, lookup: newLookupCoordinator(pending), } c.purgeFunc = c.Purge return c } func newExpiryIndex() *btree.BTreeG[*CachedResult] { return btree.NewG(2, func(a, b *CachedResult) bool { return a.Expiry.Before(b.Expiry) }) } type indexSet struct { uniqueAttributeValueIndex *btree.BTreeG[*CachedResult] methodIndex *btree.BTreeG[*CachedResult] queryIndex *btree.BTreeG[*CachedResult] } func newIndexSet() *indexSet { return &indexSet{ uniqueAttributeValueIndex: btree.NewG(2, func(a, b *CachedResult) bool { return sortString(a.IndexValues.UniqueAttributeValue, a.Item) < sortString(b.IndexValues.UniqueAttributeValue, b.Item) }), methodIndex: btree.NewG(2, func(a, b *CachedResult) bool { return sortString(a.IndexValues.Method.String(), a.Item) < sortString(b.IndexValues.Method.String(), b.Item) }), queryIndex: btree.NewG(2, func(a, b *CachedResult) bool { return sortString(a.IndexValues.Query, a.Item) < sortString(b.IndexValues.Query, b.Item) }), } } // Lookup returns true/false whether or not the cache has a result for the given // query. If there are results, they will be returned as slice of `sdp.Item`s or // an `*sdp.QueryError`. // The CacheKey is always returned, even if the lookup otherwise fails or errors. func (c *MemoryCache) Lookup(ctx context.Context, srcName string, method sdp.QueryMethod, scope string, typ string, query string, ignoreCache bool) (bool, CacheKey, []*sdp.Item, *sdp.QueryError, func()) { span := trace.SpanFromContext(ctx) ck := CacheKeyFromParts(srcName, method, scope, typ, query) if c == nil { span.SetAttributes( attribute.String("ovm.cache.result", "cache not initialised"), attribute.Bool("ovm.cache.hit", false), ) return false, ck, nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "cache has not been initialised", Scope: scope, SourceName: srcName, ItemType: typ, }, noopDone } if ignoreCache { span.SetAttributes( attribute.String("ovm.cache.result", "ignore cache"), attribute.Bool("ovm.cache.hit", false), ) return false, ck, nil, nil, noopDone } lookup := c.lookup if lookup == nil { lookup = newLookupCoordinator(c.pending) } hit, items, qErr, done := lookup.Lookup( ctx, c, ck, method, ) return hit, ck, items, qErr, done } // Search performs a lower-level search using a CacheKey. // This bypasses pending-work deduplication and is used by lookupCoordinator. func (c *MemoryCache) Search(ctx context.Context, ck CacheKey) ([]*sdp.Item, error) { return c.search(ctx, ck) } // search performs a lower-level search using a CacheKey. func (c *MemoryCache) search(ctx context.Context, ck CacheKey) ([]*sdp.Item, error) { if c == nil { return nil, nil } items := make([]*sdp.Item, 0) results := c.getResults(ck) if len(results) == 0 { return nil, ErrCacheNotFound } now := time.Now() // If there is an error we want to return that, so we need to range over the // results and separate items and errors. This is computationally less // efficient than extracting errors inside of `getResults()` but logically // it's a lot less complicated since `Delete()` uses the same method but // applies different logic for _, res := range results { // Check if the cached result has expired if res.Expiry.Before(now) { // Skip expired results continue } if res.Error != nil { return nil, res.Error } // Return a copy of the item so the user can do whatever they want with it itemCopy := proto.Clone(res.Item).(*sdp.Item) items = append(items, itemCopy) } // If all results were expired, return cache not found if len(items) == 0 { return nil, ErrCacheNotFound } return items, nil } // Delete deletes anything that matches the given cache query. func (c *MemoryCache) Delete(ck CacheKey) { if c == nil { return } c.deleteResults(c.getResults(ck)) } // getResults searches indexes for cached results, doing no other logic. If // nothing is found an empty slice will be returned. func (c *MemoryCache) getResults(ck CacheKey) []*CachedResult { c.indexMutex.RLock() defer c.indexMutex.RUnlock() results := make([]*CachedResult, 0) // Get the relevant set of indexes based on the SST Hash sstHash := ck.SST.Hash() indexes, exists := c.indexes[sstHash] pivot := CachedResult{ IndexValues: IndexValues{ SSTHash: sstHash, }, } if !exists { // If we don't have a set of indexes then it definitely doesn't exist return results } // Start with the most specific index and fall back to the least specific. // Checking all matching items and returning. These is no need to check all // indexes since they all have the same content if ck.UniqueAttributeValue != nil { pivot.IndexValues.UniqueAttributeValue = *ck.UniqueAttributeValue indexes.uniqueAttributeValueIndex.AscendGreaterOrEqual(&pivot, func(result *CachedResult) bool { if *ck.UniqueAttributeValue == result.IndexValues.UniqueAttributeValue { if ck.Matches(result.IndexValues) { results = append(results, result) } // Always return true so that we continue to iterate return true } return false }) return results } if ck.Query != nil { pivot.IndexValues.Query = *ck.Query indexes.queryIndex.AscendGreaterOrEqual(&pivot, func(result *CachedResult) bool { if *ck.Query == result.IndexValues.Query { if ck.Matches(result.IndexValues) { results = append(results, result) } // Always return true so that we continue to iterate return true } return false }) return results } if ck.Method != nil { pivot.IndexValues.Method = *ck.Method indexes.methodIndex.AscendGreaterOrEqual(&pivot, func(result *CachedResult) bool { if *ck.Method == result.IndexValues.Method { // If the methods match, check the rest if ck.Matches(result.IndexValues) { results = append(results, result) } // Always return true so that we continue to iterate return true } return false }) return results } // If nothing other than SST has been set then return everything indexes.methodIndex.Ascend(func(result *CachedResult) bool { results = append(results, result) return true }) return results } // StoreItem stores an item in the cache. Note that this item must be fully // populated (including metadata) for indexing to work correctly. func (c *MemoryCache) StoreItem(ctx context.Context, item *sdp.Item, duration time.Duration, ck CacheKey) { if item == nil || c == nil { return } itemCopy := proto.Clone(item).(*sdp.Item) res := CachedResult{ Item: itemCopy, Error: nil, Expiry: time.Now().Add(duration), IndexValues: IndexValues{ UniqueAttributeValue: itemCopy.UniqueAttributeValue(), }, } if ck.Method != nil { res.IndexValues.Method = *ck.Method } if ck.Query != nil { res.IndexValues.Query = *ck.Query } res.IndexValues.SSTHash = ck.SST.Hash() c.storeResult(ctx, res) } // StoreUnavailableItem stores an error for the given duration. func (c *MemoryCache) StoreUnavailableItem(ctx context.Context, err error, duration time.Duration, cacheQuery CacheKey) { if c == nil || err == nil { return } res := CachedResult{ Item: nil, Error: err, Expiry: time.Now().Add(duration), IndexValues: cacheQuery.ToIndexValues(), } c.storeResult(ctx, res) } // Clear deletes all data in cache. func (c *MemoryCache) Clear() { if c == nil { return } c.indexMutex.Lock() defer c.indexMutex.Unlock() c.indexes = make(map[SSTHash]*indexSet) c.expiryIndex = newExpiryIndex() } func (c *MemoryCache) storeResult(ctx context.Context, res CachedResult) { c.indexMutex.Lock() defer c.indexMutex.Unlock() // Create the index if it doesn't exist indexes, ok := c.indexes[res.IndexValues.SSTHash] if !ok { indexes = newIndexSet() c.indexes[res.IndexValues.SSTHash] = indexes } // Add the item to the indexes and check if we're overwriting an unexpired entry // We only need to check one index since they all reference the same CachedResult oldResult, replaced := indexes.methodIndex.ReplaceOrInsert(&res) indexes.queryIndex.ReplaceOrInsert(&res) indexes.uniqueAttributeValueIndex.ReplaceOrInsert(&res) // Get the current span to add attributes span := trace.SpanFromContext(ctx) // Check if we overwrote an entry that hasn't expired yet // This indicates potential thundering-herd issues where multiple identical // queries are executed concurrently instead of waiting for the first result overwritten := false if replaced && oldResult != nil { now := time.Now() if oldResult.Expiry.After(now) { overwritten = true timeUntilExpiry := oldResult.Expiry.Sub(now) // Build attributes for the overwrite event attrs := []attribute.KeyValue{ attribute.Bool("ovm.cache.unexpired_overwrite", true), attribute.String("ovm.cache.time_until_expiry", timeUntilExpiry.String()), attribute.String("ovm.cache.sst_hash", string(res.IndexValues.SSTHash)), attribute.String("ovm.cache.query_method", res.IndexValues.Method.String()), } if res.Item != nil { attrs = append(attrs, attribute.String("ovm.cache.item_type", res.Item.GetType()), attribute.String("ovm.cache.item_scope", res.Item.GetScope()), ) } if res.IndexValues.Query != "" { attrs = append(attrs, attribute.String("ovm.cache.query", res.IndexValues.Query)) } if res.IndexValues.UniqueAttributeValue != "" { attrs = append(attrs, attribute.String("ovm.cache.unique_attribute", res.IndexValues.UniqueAttributeValue)) } span.SetAttributes(attrs...) } } // Always set the overwrite attribute, even if false, for consistent tracking if !overwritten { span.SetAttributes(attribute.Bool("ovm.cache.unexpired_overwrite", false)) } // Add the item to the expiry index c.expiryIndex.ReplaceOrInsert(&res) // Update the purge time if required c.setNextPurgeIfEarlier(res.Expiry) } // sortString returns the string that the cached result should be sorted on. // This has a prefix of the index value and suffix of the GloballyUniqueName if // relevant. func sortString(indexValue string, item *sdp.Item) string { if item == nil { return indexValue } return indexValue + item.GloballyUniqueName() } // deleteResults deletes many cached results at once. func (c *MemoryCache) deleteResults(results []*CachedResult) { c.indexMutex.Lock() defer c.indexMutex.Unlock() for _, res := range results { if indexSet, ok := c.indexes[res.IndexValues.SSTHash]; ok { // For each expired item, delete it from all of the indexes that it will be in if indexSet.methodIndex != nil { indexSet.methodIndex.Delete(res) } if indexSet.queryIndex != nil { indexSet.queryIndex.Delete(res) } if indexSet.uniqueAttributeValueIndex != nil { indexSet.uniqueAttributeValueIndex.Delete(res) } } c.expiryIndex.Delete(res) } } // Purge purges all expired items from the cache. The user must pass in the // `before` time. All items that expired before this will be purged. Usually // this would be just `time.Now()` however it could be overridden for testing. func (c *MemoryCache) Purge(ctx context.Context, before time.Time) PurgeStats { if c == nil { return PurgeStats{} } // Store the current time rather than calling it a million times start := time.Now() var nextExpiry *time.Time expired := make([]*CachedResult, 0) // Look through the expiry cache and work out what has expired c.indexMutex.RLock() c.expiryIndex.Ascend(func(res *CachedResult) bool { if res.Expiry.Before(before) { expired = append(expired, res) return true } // Take note of the next expiry so we can schedule the next run nextExpiry = &res.Expiry // As soon as hit this we'll stop ascending return false }) c.indexMutex.RUnlock() c.deleteResults(expired) return PurgeStats{ NumPurged: len(expired), TimeTaken: time.Since(start), NextExpiry: nextExpiry, } } ================================================ FILE: go/sdpcache/memory_test.go ================================================ package sdpcache import ( "context" "errors" "sync" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) // TestMemoryCacheStartPurge tests the memory cache implementation's purger. func TestMemoryCacheStartPurge(t *testing.T) { ctx := t.Context() cache := NewMemoryCache() cache.minWaitTime = 100 * time.Millisecond cachedItems := []struct { Item *sdp.Item Expiry time.Time }{ { Item: GenerateRandomItem(), Expiry: time.Now().Add(0), }, { Item: GenerateRandomItem(), Expiry: time.Now().Add(100 * time.Millisecond), }, } for _, i := range cachedItems { ck := CacheKeyFromQuery(i.Item.GetMetadata().GetSourceQuery(), i.Item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, i.Item, time.Until(i.Expiry), ck) } ctx, done := context.WithCancel(ctx) defer done() cache.StartPurger(ctx) // Wait for everything to be purged time.Sleep(200 * time.Millisecond) // At this point everything should be been cleaned, and the purger should be // sleeping forever items, err := testSearch(t.Context(), cache, CacheKeyFromQuery( cachedItems[1].Item.GetMetadata().GetSourceQuery(), cachedItems[1].Item.GetMetadata().GetSourceName(), )) if !errors.Is(err, ErrCacheNotFound) { t.Errorf("unexpected error: %v", err) t.Errorf("unexpected items: %v", len(items)) } cache.purgeMutex.Lock() if cache.nextPurge.Before(time.Now().Add(time.Hour)) { // If the next purge is within the next hour that's an error, it should // be really, really for in the future t.Errorf("Expected next purge to be in 1000 years, got %v", cache.nextPurge.String()) } cache.purgeMutex.Unlock() // Adding a new item should kick off the purging again for _, i := range cachedItems { ck := CacheKeyFromQuery(i.Item.GetMetadata().GetSourceQuery(), i.Item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, i.Item, 100*time.Millisecond, ck) } time.Sleep(200 * time.Millisecond) // It should be empty again items, err = testSearch(t.Context(), cache, CacheKeyFromQuery( cachedItems[1].Item.GetMetadata().GetSourceQuery(), cachedItems[1].Item.GetMetadata().GetSourceName(), )) if !errors.Is(err, ErrCacheNotFound) { t.Errorf("unexpected error: %v", err) t.Errorf("unexpected items: %v: %v", len(items), items) } } // TestMemoryCacheStopPurge tests the memory cache implementation's purger stop functionality. func TestMemoryCacheStopPurge(t *testing.T) { cache := NewMemoryCache() cache.minWaitTime = 1 * time.Millisecond ctx, done := context.WithCancel(t.Context()) cache.StartPurger(ctx) // Stop the purger done() // Insert an item item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 1*time.Second, ck) sst := SST{ SourceName: item.GetMetadata().GetSourceName(), Scope: item.GetScope(), Type: item.GetType(), } // Make sure it's not purged time.Sleep(100 * time.Millisecond) items, err := testSearch(t.Context(), cache, CacheKey{ SST: sst, }) if err != nil { t.Error(err) } if len(items) != 1 { t.Errorf("Expected 1 item, got %v", len(items)) } } // TestMemoryCacheConcurrent tests the memory cache implementation for data races. // This test is designed to be run with -race to ensure that there aren't any // data races. func TestMemoryCacheConcurrent(t *testing.T) { cache := NewMemoryCache() // Run the purger super fast to generate a worst-case scenario cache.minWaitTime = 1 * time.Millisecond ctx, done := context.WithCancel(t.Context()) defer done() cache.StartPurger(ctx) var wg sync.WaitGroup numParallel := 1_000 for range numParallel { wg.Go(func() { // Store the item item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 100*time.Millisecond, ck) // Create a goroutine to also delete in parallel wg.Go(func() { cache.Delete(ck) }) }) } wg.Wait() } // TestMemoryCacheLookupDeduplication tests that multiple concurrent Lookup calls // for the same cache key in MemoryCache result in only one caller doing work. func TestMemoryCacheLookupDeduplication(t *testing.T) { cache := NewMemoryCache() ctx := t.Context() // Create a cache key for the test - use LIST method to avoid UniqueAttributeValue matching issues sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST // Track how many goroutines actually do work var workCount int32 var mu sync.Mutex var wg sync.WaitGroup numGoroutines := 10 results := make([]struct { hit bool items []*sdp.Item }, numGoroutines) startBarrier := make(chan struct{}) for idx := range numGoroutines { wg.Go(func() { <-startBarrier hit, ck, items, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() if !hit { mu.Lock() workCount++ mu.Unlock() time.Sleep(50 * time.Millisecond) item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, items, _, done = cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() } results[idx] = struct { hit bool items []*sdp.Item }{hit, items} }) } close(startBarrier) wg.Wait() if workCount != 1 { t.Errorf("expected exactly 1 goroutine to do work, got %d", workCount) } for i, r := range results { if !r.hit { t.Errorf("goroutine %d: expected cache hit after dedup, got miss", i) } if len(r.items) != 1 { t.Errorf("goroutine %d: expected 1 item, got %d", i, len(r.items)) } } } // TestMemoryCacheLookupDeduplicationCompleteWithoutStore tests the scenario where // Complete is called but nothing was stored in the cache. This tests the explicit // ErrCacheNotFound check in the re-check logic. func TestMemoryCacheLookupDeduplicationCompleteWithoutStore(t *testing.T) { cache := NewMemoryCache() ctx := t.Context() sst := SST{SourceName: "test-source", Scope: "test-scope", Type: "test-type"} method := sdp.QueryMethod_LIST query := "complete-without-store-test" var wg sync.WaitGroup startBarrier := make(chan struct{}) // Track results var waiterHits []bool var waiterMu sync.Mutex numWaiters := 3 // First goroutine: starts work and completes without storing anything wg.Go(func() { <-startBarrier hit, ck, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() if hit { t.Error("first goroutine: expected cache miss") return } // Simulate work that completes successfully but returns nothing time.Sleep(50 * time.Millisecond) // Complete without storing anything - triggers ErrCacheNotFound on re-check cache.pending.Complete(ck.String()) }) // Waiter goroutines for range numWaiters { wg.Go(func() { <-startBarrier time.Sleep(10 * time.Millisecond) hit, _, _, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, query, false) defer done() waiterMu.Lock() waiterHits = append(waiterHits, hit) waiterMu.Unlock() }) } close(startBarrier) wg.Wait() if len(waiterHits) != numWaiters { t.Errorf("expected %d waiter results, got %d", numWaiters, len(waiterHits)) } // All waiters should get a cache miss since nothing was stored for i, hit := range waiterHits { if hit { t.Errorf("waiter %d: expected cache miss (hit=false), got hit=true", i) } } } ================================================ FILE: go/sdpcache/noop_cache_test.go ================================================ package sdpcache import ( "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) func TestNoOpCacheLookupAlwaysMiss(t *testing.T) { cache := NewNoOpCache() hit, ck, items, qErr, done := cache.Lookup( t.Context(), "test-source", sdp.QueryMethod_GET, "test-scope", "test-type", "test-query", false, ) if hit { t.Fatal("expected miss, got hit") } if qErr != nil { t.Fatalf("expected nil error, got %v", qErr) } if len(items) != 0 { t.Fatalf("expected no items, got %d", len(items)) } expected := CacheKeyFromParts("test-source", sdp.QueryMethod_GET, "test-scope", "test-type", "test-query") if ck.String() != expected.String() { t.Fatalf("expected cache key %q, got %q", expected.String(), ck.String()) } // done() should be a no-op and idempotent. done() done() } func TestNoOpCacheIgnoresAllMutations(t *testing.T) { cache := NewNoOpCache() ctx := t.Context() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, time.Second, ck) cache.StoreUnavailableItem(ctx, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "noop", }, time.Second, ck) cache.Delete(ck) cache.Clear() cache.StartPurger(ctx) hit, _, items, qErr, done := cache.Lookup( ctx, item.GetMetadata().GetSourceName(), item.GetMetadata().GetSourceQuery().GetMethod(), item.GetMetadata().GetSourceQuery().GetScope(), item.GetMetadata().GetSourceQuery().GetType(), item.GetMetadata().GetSourceQuery().GetQuery(), false, ) defer done() if hit { t.Fatal("expected miss after no-op mutations, got hit") } if qErr != nil { t.Fatalf("expected nil error after no-op mutations, got %v", qErr) } if len(items) != 0 { t.Fatalf("expected no items after no-op mutations, got %d", len(items)) } } func TestNoOpCachePurgeAndMinWaitDefaults(t *testing.T) { cache := NewNoOpCache() if got := cache.GetMinWaitTime(); got != 0 { t.Fatalf("expected min wait time 0, got %v", got) } stats := cache.Purge(t.Context(), time.Now()) if stats.NumPurged != 0 { t.Fatalf("expected NumPurged=0, got %d", stats.NumPurged) } if stats.NextExpiry != nil { t.Fatalf("expected NextExpiry=nil, got %v", stats.NextExpiry) } } ================================================ FILE: go/sdpcache/pending.go ================================================ package sdpcache import ( "context" "sync" "time" ) // pendingWork tracks in-flight cache lookups to prevent duplicate work. // When multiple goroutines request the same cache key simultaneously, // only the first one does the actual work while others wait for the result. type pendingWork struct { mu sync.Mutex pending map[string]*workEntry } // maxPendingWorkAge is a safety timeout for pending work const maxPendingWorkAge = 5 * time.Minute // workEntry represents a pending piece of work that one or more goroutines // are waiting on. type workEntry struct { done chan struct{} cancelled bool startTime time.Time } // newPendingWork creates a new pendingWork tracker. func newPendingWork() *pendingWork { return &pendingWork{ pending: make(map[string]*workEntry), } } // StartWork checks if work is already pending for the given key. // If no work is pending, it creates a new entry and returns (true, entry) - // the caller should do the work and call Complete when done. // If work is already pending, it returns (false, entry) - the caller should // call Wait on the entry to get the result. func (p *pendingWork) StartWork(key string) (shouldWork bool, entry *workEntry) { p.mu.Lock() defer p.mu.Unlock() if existing, ok := p.pending[key]; ok { return false, existing } entry = &workEntry{ done: make(chan struct{}), startTime: time.Now(), } p.pending[key] = entry return true, entry } // Wait blocks until the work entry is ready or the context is cancelled. // Returns ok=true if the work completed successfully (caller should re-check cache). // Returns ok=false if the context was cancelled or work was cancelled. func (p *pendingWork) Wait(ctx context.Context, entry *workEntry) (ok bool) { // Calculate safety timeout based on when work started deadline := entry.startTime.Add(maxPendingWorkAge) timeUntilDeadline := time.Until(deadline) // If we're already past the deadline, return immediately if timeUntilDeadline <= 0 { return false } // Create a timer for the safety timeout timer := time.NewTimer(timeUntilDeadline) defer timer.Stop() select { case <-entry.done: // Work completed normally return !entry.cancelled case <-ctx.Done(): // Context was cancelled by caller return false case <-timer.C: // SAFETY: Work has been pending too long (worker likely forgot to call Complete/Cancel) // Return false so caller gets a cache miss and can retry return false } } // Complete marks the work as done and wakes all waiters. // Waiters will receive ok=true and should re-lookup the cache. func (p *pendingWork) Complete(key string) { p.mu.Lock() defer p.mu.Unlock() entry, ok := p.pending[key] if !ok { return } delete(p.pending, key) close(entry.done) } // Cancel removes a pending work entry without storing a result. // Waiters will receive ok=false and should retry or return error. func (p *pendingWork) Cancel(key string) { p.mu.Lock() defer p.mu.Unlock() entry, ok := p.pending[key] if !ok { return } delete(p.pending, key) entry.cancelled = true close(entry.done) } ================================================ FILE: go/sdpcache/purger.go ================================================ package sdpcache import ( "context" "sync" "time" log "github.com/sirupsen/logrus" ) // MinWaitDefault is the default minimum wait time between purge cycles. const MinWaitDefault = 5 * time.Second // PurgeStats holds statistics from a single purge run. type PurgeStats struct { // How many items were timed out of the cache NumPurged int // How long purging took overall TimeTaken time.Duration // The expiry time of the next item to expire. If there are no more items in // the cache, this will be nil NextExpiry *time.Time } // purger manages timer-based scheduling for periodic cache purging. // MemoryCache and boltStore embed this struct to share the scheduling logic; // storage-specific purge work is injected via the purgeFunc callback. type purger struct { purgeFunc func(context.Context, time.Time) PurgeStats minWaitTime time.Duration purgeTimer *time.Timer nextPurge time.Time purgeMutex sync.Mutex } // GetMinWaitTime returns the minimum wait time or the default if not set. func (p *purger) GetMinWaitTime() time.Duration { if p.minWaitTime == 0 { return MinWaitDefault } return p.minWaitTime } // StartPurger starts the purge process in the background, it will be cancelled // when the context is cancelled. The cache will be purged initially, at which // point the process will sleep until the next time an item expires. func (p *purger) StartPurger(ctx context.Context) { p.purgeMutex.Lock() if p.purgeTimer == nil { p.purgeTimer = time.NewTimer(0) p.purgeMutex.Unlock() } else { p.purgeMutex.Unlock() log.WithContext(ctx).Info("Purger already running") return } go func(ctx context.Context) { for { select { case <-p.purgeTimer.C: stats := p.purgeFunc(ctx, time.Now()) p.setNextPurgeFromStats(stats) case <-ctx.Done(): p.purgeMutex.Lock() defer p.purgeMutex.Unlock() p.purgeTimer.Stop() p.purgeTimer = nil return } } }(ctx) } // setNextPurgeFromStats sets when the next purge should run based on the stats // of the previous purge. func (p *purger) setNextPurgeFromStats(stats PurgeStats) { p.purgeMutex.Lock() defer p.purgeMutex.Unlock() if stats.NextExpiry == nil { p.purgeTimer.Reset(1000 * time.Hour) p.nextPurge = time.Now().Add(1000 * time.Hour) } else { if time.Until(*stats.NextExpiry) < p.GetMinWaitTime() { p.purgeTimer.Reset(p.GetMinWaitTime()) p.nextPurge = time.Now().Add(p.GetMinWaitTime()) } else { p.purgeTimer.Reset(time.Until(*stats.NextExpiry)) p.nextPurge = *stats.NextExpiry } } } // setNextPurgeIfEarlier sets the next time the purger will run, if the provided // time is sooner than the current scheduled purge time. While the purger is // active this will be constantly updated, however if the purger is sleeping and // new items are added this method ensures that the purger is woken up. func (p *purger) setNextPurgeIfEarlier(t time.Time) { p.purgeMutex.Lock() defer p.purgeMutex.Unlock() if t.Before(p.nextPurge) { if p.purgeTimer == nil { return } p.purgeTimer.Stop() p.nextPurge = t p.purgeTimer.Reset(time.Until(t)) } } ================================================ FILE: go/sdpcache/sharded.go ================================================ package sdpcache import ( "context" "errors" "fmt" "hash/fnv" "os" "path/filepath" "sync" "time" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // DefaultShardCount is the number of independent BoltDB shards. 17 is prime // (avoids hash collision clustering) and distributes ~345 stdlib goroutines to // ~20 per shard, making BoltDB's single-writer lock no longer a bottleneck. const DefaultShardCount = 17 // ShardedCache implements the Cache interface by distributing entries across N // independent BoltCache instances. Shard selection uses FNV-32a of the item // identity (SSTHash + UniqueAttributeValue), so writes within a single adapter // type (e.g. DNS in stdlib) spread evenly across all shards. // // GET queries route to exactly one shard. LIST/SEARCH queries fan out to all // shards in parallel and merge results. pendingWork deduplication lives at the // ShardedCache level to prevent duplicate API calls across the fan-out. type ShardedCache struct { purger shards []*boltStore dir string // pendingWork lives at the ShardedCache level so that deduplication spans // the entire cache, not individual shards. pending *pendingWork lookup *lookupCoordinator } var _ Cache = (*ShardedCache)(nil) // NewShardedCache creates N BoltCache instances in dir (shard-00.db through // shard-{N-1}.db) using goroutine fan-out to avoid N× startup latency. func NewShardedCache(dir string, shardCount int, opts ...BoltCacheOption) (*ShardedCache, error) { if shardCount <= 0 { return nil, fmt.Errorf("shard count must be positive, got %d", shardCount) } if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("failed to create shard directory: %w", err) } shards := make([]*boltStore, shardCount) errs := make([]error, shardCount) var wg sync.WaitGroup for i := range shardCount { wg.Go(func() { path := filepath.Join(dir, fmt.Sprintf("shard-%02d.db", i)) c, err := newBoltCacheStore(path, opts...) if err != nil { errs[i] = fmt.Errorf("shard %d: %w", i, err) return } shards[i] = c }) } wg.Wait() // If any shard failed, close the ones that succeeded and return the error. for _, err := range errs { if err != nil { for _, s := range shards { if s != nil { _ = s.CloseAndDestroy() } } return nil, err } } pending := newPendingWork() sc := &ShardedCache{ shards: shards, dir: dir, pending: pending, lookup: newLookupCoordinator(pending), } sc.purgeFunc = sc.Purge return sc, nil } // shardFor returns the shard index for a given item identity. func (sc *ShardedCache) shardFor(sstHash SSTHash, uav string) int { h := fnv.New32a() _, _ = h.Write([]byte(sstHash)) _, _ = h.Write([]byte(uav)) return int(h.Sum32()) % len(sc.shards) } // Lookup performs a cache lookup, routing GET queries to a single shard and // LIST/SEARCH queries to all shards via parallel fan-out. func (sc *ShardedCache) Lookup(ctx context.Context, srcName string, method sdp.QueryMethod, scope string, typ string, query string, ignoreCache bool) (bool, CacheKey, []*sdp.Item, *sdp.QueryError, func()) { ctx, span := tracing.Tracer().Start(ctx, "ShardedCache.Lookup", trace.WithAttributes( attribute.String("ovm.cache.sourceName", srcName), attribute.String("ovm.cache.method", method.String()), attribute.String("ovm.cache.scope", scope), attribute.String("ovm.cache.type", typ), attribute.String("ovm.cache.query", query), attribute.Bool("ovm.cache.ignoreCache", ignoreCache), attribute.Int("ovm.cache.shardCount", len(sc.shards)), ), ) defer span.End() ck := CacheKeyFromParts(srcName, method, scope, typ, query) if ignoreCache { span.SetAttributes( attribute.String("ovm.cache.result", "ignore cache"), attribute.Bool("ovm.cache.hit", false), ) return false, ck, nil, nil, noopDone } lookup := sc.lookup if lookup == nil { lookup = newLookupCoordinator(sc.pending) } hit, items, qErr, done := lookup.Lookup( ctx, sc, ck, method, ) return hit, ck, items, qErr, done } // Search performs a lower-level search using a CacheKey. // This bypasses pending-work deduplication and is used by lookupCoordinator. func (sc *ShardedCache) Search(ctx context.Context, ck CacheKey) ([]*sdp.Item, error) { span := trace.SpanFromContext(ctx) if ck.UniqueAttributeValue != nil { idx := sc.shardFor(ck.SST.Hash(), *ck.UniqueAttributeValue) span.SetAttributes( attribute.Int("ovm.cache.shardIndex", idx), attribute.Bool("ovm.cache.fanOut", false), ) return sc.shards[idx].Search(ctx, ck) } return sc.searchAll(ctx, ck) } // searchAll fans out a search to all shards in parallel and merges results. func (sc *ShardedCache) searchAll(ctx context.Context, ck CacheKey) ([]*sdp.Item, error) { span := trace.SpanFromContext(ctx) span.SetAttributes(attribute.Bool("ovm.cache.fanOut", true)) type result struct { items []*sdp.Item err error dur time.Duration } results := make([]result, len(sc.shards)) var wg sync.WaitGroup for i, shard := range sc.shards { wg.Go(func() { start := time.Now() items, err := shard.Search(ctx, ck) results[i] = result{items: items, err: err, dur: time.Since(start)} }) } wg.Wait() var ( allItems []*sdp.Item maxDur time.Duration shardsWithResult int firstErr error allNotFound = true ) for _, r := range results { if r.dur > maxDur { maxDur = r.dur } if r.err != nil { if errors.Is(r.err, ErrCacheNotFound) { continue } allNotFound = false if firstErr == nil { firstErr = r.err } continue } allNotFound = false if len(r.items) > 0 { shardsWithResult++ allItems = append(allItems, r.items...) } } span.SetAttributes( attribute.Float64("ovm.cache.fanOutMaxMs", float64(maxDur.Milliseconds())), attribute.Int("ovm.cache.shardsWithResults", shardsWithResult), ) if firstErr != nil { return nil, firstErr } if allNotFound { return nil, ErrCacheNotFound } return allItems, nil } // StoreItem routes the item to one shard based on its UniqueAttributeValue. func (sc *ShardedCache) StoreItem(ctx context.Context, item *sdp.Item, duration time.Duration, ck CacheKey) { if item == nil { return } sstHash := ck.SST.Hash() uav := item.UniqueAttributeValue() idx := sc.shardFor(sstHash, uav) span := trace.SpanFromContext(ctx) span.SetAttributes(attribute.Int("ovm.cache.shardIndex", idx)) sc.shards[idx].StoreItem(ctx, item, duration, ck) sc.setNextPurgeIfEarlier(time.Now().Add(duration)) } // StoreUnavailableItem routes the error based on the CacheKey: // - GET errors (UniqueAttributeValue set) go to the same shard a GET Lookup would query. // - LIST/SEARCH errors go to shard 0 as a deterministic default; fan-out reads will find them. func (sc *ShardedCache) StoreUnavailableItem(ctx context.Context, err error, duration time.Duration, ck CacheKey) { if err == nil { return } var idx int if ck.UniqueAttributeValue != nil { idx = sc.shardFor(ck.SST.Hash(), *ck.UniqueAttributeValue) } span := trace.SpanFromContext(ctx) span.SetAttributes(attribute.Int("ovm.cache.shardIndex", idx)) sc.shards[idx].StoreUnavailableItem(ctx, err, duration, ck) sc.setNextPurgeIfEarlier(time.Now().Add(duration)) } // Delete fans out to all shards. func (sc *ShardedCache) Delete(ck CacheKey) { var wg sync.WaitGroup for _, s := range sc.shards { wg.Go(func() { s.Delete(ck) }) } wg.Wait() } // Clear fans out to all shards. func (sc *ShardedCache) Clear() { var wg sync.WaitGroup for _, s := range sc.shards { wg.Go(func() { s.Clear() }) } wg.Wait() } // Purge fans out to all shards in parallel and aggregates PurgeStats. // TimeTaken reflects wall-clock time of the parallel fan-out, not the sum of // per-shard durations. func (sc *ShardedCache) Purge(ctx context.Context, before time.Time) PurgeStats { ctx, span := tracing.Tracer().Start(ctx, "ShardedCache.Purge", trace.WithAttributes( attribute.Int("ovm.cache.shardCount", len(sc.shards)), ), ) defer span.End() type result struct { stats PurgeStats } results := make([]result, len(sc.shards)) start := time.Now() var wg sync.WaitGroup for i, s := range sc.shards { wg.Go(func() { results[i] = result{stats: s.Purge(ctx, before)} }) } wg.Wait() combined := PurgeStats{ TimeTaken: time.Since(start), } for _, r := range results { combined.NumPurged += r.stats.NumPurged if r.stats.NextExpiry != nil { if combined.NextExpiry == nil || r.stats.NextExpiry.Before(*combined.NextExpiry) { combined.NextExpiry = r.stats.NextExpiry } } } span.SetAttributes( attribute.Int("ovm.cache.numPurged", combined.NumPurged), attribute.Float64("ovm.cache.purgeDurationMs", float64(combined.TimeTaken.Milliseconds())), ) return combined } // CloseAndDestroy closes and destroys all shard files in parallel, then removes // the shard directory. func (sc *ShardedCache) CloseAndDestroy() error { errs := make([]error, len(sc.shards)) var wg sync.WaitGroup for i, s := range sc.shards { wg.Go(func() { errs[i] = s.CloseAndDestroy() }) } wg.Wait() for _, err := range errs { if err != nil { return err } } return os.RemoveAll(sc.dir) } // newShardedCacheForProduction is used by NewCache to create a production // ShardedCache with appropriate defaults. It logs and falls back to MemoryCache // on failure. func newShardedCacheForProduction(ctx context.Context) Cache { dir, err := os.MkdirTemp("", "sdpcache-shards-*") if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Failed to create temp dir for ShardedCache, using memory cache instead") cache := NewMemoryCache() cache.StartPurger(ctx) return cache } perShardThreshold := int64(1*1024*1024*1024) / int64(DefaultShardCount) cache, err := NewShardedCache( dir, DefaultShardCount, WithCompactThreshold(perShardThreshold), ) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Failed to create ShardedCache, using memory cache instead") _ = os.RemoveAll(dir) memCache := NewMemoryCache() memCache.StartPurger(ctx) return memCache } cache.minWaitTime = 30 * time.Second cache.StartPurger(ctx) return cache } ================================================ FILE: go/sdpcache/sharded_test.go ================================================ package sdpcache import ( "context" "fmt" "math" "os" "path/filepath" "sync" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" ) func TestShardDistributionUniformity(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() numItems := 1000 // Use the same SST for all items so they share the same BoltDB SST bucket. // Different UAVs cause items to distribute across shards via shardFor(). sst := SST{SourceName: "test-source", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &method} for i := range numItems { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName attrs := make(map[string]any) attrs["name"] = fmt.Sprintf("item-%d", i) attributes, _ := sdp.ToAttributes(attrs) item.Attributes = attributes cache.StoreItem(ctx, item, 10*time.Second, ck) } // Count items per shard by searching each shard with the common SST counts := make([]int, DefaultShardCount) for i, shard := range cache.shards { items, searchErr := shard.Search(ctx, ck) if searchErr == nil { counts[i] = len(items) } } totalFound := 0 for _, c := range counts { totalFound += c } if totalFound != numItems { t.Errorf("expected %d total items across shards, got %d", numItems, totalFound) } // Verify distribution is reasonably uniform: no shard should have more than // 3× the expected average (very loose bound to avoid flaky tests). expected := float64(numItems) / float64(DefaultShardCount) for i, c := range counts { if float64(c) > expected*3 { t.Errorf("shard %d has %d items, expected roughly %.0f (3× threshold: %.0f)", i, c, expected, expected*3) } } // Chi-squared test for uniformity (p < 0.001 threshold) var chiSq float64 for _, c := range counts { diff := float64(c) - expected chiSq += (diff * diff) / expected } // Critical value for df=16, p=0.001 is ~39.25 if chiSq > 39.25 { t.Errorf("chi-squared %.2f exceeds critical value 39.25 (df=16, p=0.001), distribution may be non-uniform: %v", chiSq, counts) } } func TestShardedCacheGETRoutesToCorrectShard(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} method := sdp.QueryMethod_GET item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName uav := item.UniqueAttributeValue() ck := CacheKey{SST: sst, Method: &method, UniqueAttributeValue: &uav} cache.StoreItem(ctx, item, 10*time.Second, ck) // Verify the item lands on the expected shard expectedShard := cache.shardFor(sst.Hash(), uav) items, err := cache.shards[expectedShard].Search(ctx, ck) if err != nil { t.Fatalf("expected item on shard %d, got error: %v", expectedShard, err) } if len(items) != 1 { t.Fatalf("expected 1 item on shard %d, got %d", expectedShard, len(items)) } // Verify Lookup returns the item hit, _, cachedItems, qErr, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, uav, false) defer done() if qErr != nil { t.Fatalf("unexpected error: %v", qErr) } if !hit { t.Fatal("expected cache hit") } if len(cachedItems) != 1 { t.Fatalf("expected 1 item, got %d", len(cachedItems)) } } func TestShardedCacheLISTFanOutMerge(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &method} // Store items that should land on different shards numItems := 50 for i := range numItems { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName attrs := make(map[string]any) attrs["name"] = fmt.Sprintf("item-%d", i) attributes, _ := sdp.ToAttributes(attrs) item.Attributes = attributes cache.StoreItem(ctx, item, 10*time.Second, ck) } // LIST should fan out and return all items hit, _, items, qErr, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() if qErr != nil { t.Fatalf("unexpected error: %v", qErr) } if !hit { t.Fatal("expected cache hit") } if len(items) != numItems { t.Errorf("expected %d items from LIST fan-out, got %d", numItems, len(items)) } } func TestShardedCacheCrossShardLIST(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") // Use a small shard count for easier verification cache, err := NewShardedCache(dir, 3) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &method} // Store enough items that at least 2 shards get items storedNames := make(map[string]bool) for i := range 30 { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName name := fmt.Sprintf("cross-shard-%d", i) attrs := make(map[string]any) attrs["name"] = name attributes, _ := sdp.ToAttributes(attrs) item.Attributes = attributes cache.StoreItem(ctx, item, 10*time.Second, ck) storedNames[name] = true } // Count items per shard shardsWithItems := 0 for _, shard := range cache.shards { items, err := shard.Search(ctx, ck) if err == nil && len(items) > 0 { shardsWithItems++ } } if shardsWithItems < 2 { t.Errorf("expected items on at least 2 shards, got %d", shardsWithItems) } // LIST fan-out should return all items regardless of shard items, err := cache.searchAll(ctx, ck) if err != nil { t.Fatalf("searchAll failed: %v", err) } if len(items) != 30 { t.Errorf("expected 30 items from fan-out, got %d", len(items)) } } func TestShardedCachePendingWorkDeduplication(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "dedup-test", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST var workCount int32 var mu sync.Mutex var wg sync.WaitGroup numGoroutines := 10 results := make([]struct { hit bool items []*sdp.Item }, numGoroutines) startBarrier := make(chan struct{}) for idx := range numGoroutines { wg.Go(func() { <-startBarrier hit, ck, items, _, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() if !hit { mu.Lock() workCount++ mu.Unlock() time.Sleep(50 * time.Millisecond) item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, items, _, done = cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() } results[idx] = struct { hit bool items []*sdp.Item }{hit, items} }) } close(startBarrier) wg.Wait() if workCount != 1 { t.Errorf("expected exactly 1 goroutine to do work, got %d", workCount) } for i, r := range results { if !r.hit { t.Errorf("goroutine %d: expected cache hit after dedup, got miss", i) } if len(r.items) != 1 { t.Errorf("goroutine %d: expected 1 item, got %d", i, len(r.items)) } } } func TestShardedCacheCloseAndDestroy(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } ctx := t.Context() item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) // Verify shard files exist entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("failed to read shard directory: %v", err) } if len(entries) != DefaultShardCount { t.Errorf("expected %d shard files, got %d", DefaultShardCount, len(entries)) } // Close and destroy if err := cache.CloseAndDestroy(); err != nil { t.Fatalf("CloseAndDestroy failed: %v", err) } // Verify the directory is removed if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Error("shard directory should be removed after CloseAndDestroy") } } func BenchmarkShardedCacheVsSingleBoltCache(b *testing.B) { implementations := []struct { name string factory func(b *testing.B) Cache }{ {"BoltCache", func(b *testing.B) Cache { c, err := NewBoltCache(filepath.Join(b.TempDir(), "cache.db")) if err != nil { b.Fatalf("failed to create BoltCache: %v", err) } b.Cleanup(func() { _ = c.CloseAndDestroy() }) return c }}, {"ShardedCache", func(b *testing.B) Cache { c, err := NewShardedCache( filepath.Join(b.TempDir(), "shards"), DefaultShardCount, ) if err != nil { b.Fatalf("failed to create ShardedCache: %v", err) } b.Cleanup(func() { _ = c.CloseAndDestroy() }) return c }}, } for _, impl := range implementations { b.Run(impl.name+"/ConcurrentWrite", func(b *testing.B) { cache := impl.factory(b) ctx := context.Background() sst := SST{SourceName: "bench", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &method} b.ResetTimer() b.RunParallel(func(pb *testing.PB) { for pb.Next() { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item, 10*time.Second, ck) } }) }) } } func TestShardedCacheShardForDeterminism(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() sst := SST{SourceName: "test", Scope: "scope", Type: "type"} sstHash := sst.Hash() // Same input should always produce the same shard for range 100 { idx1 := cache.shardFor(sstHash, "my-unique-value") idx2 := cache.shardFor(sstHash, "my-unique-value") if idx1 != idx2 { t.Fatalf("shardFor is not deterministic: got %d and %d", idx1, idx2) } } // Different UAVs should produce different shards (at least some of the time) shardsSeen := make(map[int]bool) for i := range 100 { idx := cache.shardFor(sstHash, fmt.Sprintf("value-%d", i)) shardsSeen[idx] = true } if len(shardsSeen) < 2 { t.Error("expected different UAVs to hash to different shards") } } func TestShardedCacheErrorRouting(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() t.Run("GET error routes to same shard as GET lookup", func(t *testing.T) { sst := SST{SourceName: "err-test", Scope: "scope", Type: "type"} method := sdp.QueryMethod_GET uav := "my-item" ck := CacheKey{SST: sst, Method: &method, UniqueAttributeValue: &uav} qErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not found", } cache.StoreUnavailableItem(ctx, qErr, 10*time.Second, ck) // Lookup should find the error hit, _, _, returnedErr, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, uav, false) defer done() if !hit { t.Fatal("expected cache hit for stored error") } if returnedErr == nil { t.Fatal("expected error to be returned") } if returnedErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected NOTFOUND, got %v", returnedErr.GetErrorType()) } }) t.Run("LIST error routes to shard 0 and is found via fan-out", func(t *testing.T) { sst := SST{SourceName: "list-err-test", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &method} qErr := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "list failed", } cache.StoreUnavailableItem(ctx, qErr, 10*time.Second, ck) // LIST lookup fans out, should find the error on shard 0 hit, _, _, returnedErr, done := cache.Lookup(ctx, sst.SourceName, method, sst.Scope, sst.Type, "", false) defer done() if !hit { t.Fatal("expected cache hit for stored LIST error") } if returnedErr == nil { t.Fatal("expected error to be returned") } if returnedErr.GetErrorType() != sdp.QueryError_OTHER { t.Errorf("expected OTHER, got %v", returnedErr.GetErrorType()) } }) } func TestShardedCacheNewCacheFallback(t *testing.T) { ctx := t.Context() cache := NewCache(ctx) if cache == nil { t.Fatal("NewCache returned nil") } // Should be a ShardedCache in normal operation if _, ok := cache.(*ShardedCache); !ok { t.Logf("NewCache returned %T (may be MemoryCache if ShardedCache creation failed)", cache) } // Basic operation test item := GenerateRandomItem() ck := CacheKeyFromQuery(item.GetMetadata().GetSourceQuery(), item.GetMetadata().GetSourceName()) cache.StoreItem(ctx, item, 10*time.Second, ck) hit, _, items, qErr, done := cache.Lookup(ctx, item.GetMetadata().GetSourceName(), sdp.QueryMethod_GET, item.GetScope(), item.GetType(), item.UniqueAttributeValue(), false, ) defer done() if qErr != nil { t.Fatalf("unexpected error: %v", qErr) } if !hit { t.Fatal("expected cache hit") } if len(items) != 1 { t.Fatalf("expected 1 item, got %d", len(items)) } } func TestShardedCacheCompactThresholdScaling(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") parentThreshold := int64(1 * 1024 * 1024 * 1024) // 1GB perShardThreshold := parentThreshold / int64(DefaultShardCount) cache, err := NewShardedCache(dir, DefaultShardCount, WithCompactThreshold(perShardThreshold), ) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() expectedPerShard := parentThreshold / int64(DefaultShardCount) for i, shard := range cache.shards { if shard.CompactThreshold != expectedPerShard { t.Errorf("shard %d: expected CompactThreshold %d, got %d", i, expectedPerShard, shard.CompactThreshold) } } } func TestShardedCacheInvalidShardCount(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") _, err := NewShardedCache(dir, 0) if err == nil { t.Error("expected error for shard count 0") } _, err = NewShardedCache(dir, -1) if err == nil { t.Error("expected error for negative shard count") } } func TestShardedCacheConcurrentWriteThroughput(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "concurrent", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &method} var wg sync.WaitGroup numParallel := 100 for idx := range numParallel { wg.Go(func() { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName attrs := make(map[string]any) attrs["name"] = fmt.Sprintf("concurrent-item-%d", idx) attributes, _ := sdp.ToAttributes(attrs) item.Attributes = attributes cache.StoreItem(ctx, item, 10*time.Second, ck) }) } wg.Wait() items, searchErr := cache.searchAll(ctx, ck) if searchErr != nil { t.Fatalf("searchAll failed: %v", searchErr) } if len(items) != numParallel { t.Errorf("expected %d items, got %d", numParallel, len(items)) } } func TestShardedCachePurgeAggregation(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, 3) // Small count for easier verification if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() ctx := t.Context() sst := SST{SourceName: "purge", Scope: "scope", Type: "type"} method := sdp.QueryMethod_LIST ck := CacheKey{SST: sst, Method: &method} // Store items with short expiry for range 10 { item := GenerateRandomItem() item.Scope = sst.Scope item.Type = sst.Type item.Metadata.SourceName = sst.SourceName cache.StoreItem(ctx, item, 100*time.Millisecond, ck) } // Wait for expiry time.Sleep(200 * time.Millisecond) // Purge and check aggregated stats stats := cache.Purge(ctx, time.Now()) if stats.NumPurged != 10 { t.Errorf("expected 10 items purged, got %d", stats.NumPurged) } } // TestShardedCacheShardForBounds verifies that shardFor always returns a valid // index in [0, shardCount). func TestShardedCacheShardForBounds(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() for i := range 10000 { idx := cache.shardFor(SSTHash(fmt.Sprintf("hash-%d", i)), fmt.Sprintf("uav-%d", i)) if idx < 0 || idx >= DefaultShardCount { t.Fatalf("shardFor returned out-of-bounds index %d for shard count %d", idx, DefaultShardCount) } } } // TestShardedCacheFNV32aOverflow verifies that the FNV-32a hash mod operation // works correctly with uint32 values close to math.MaxUint32. func TestShardedCacheFNV32aOverflow(t *testing.T) { dir := filepath.Join(t.TempDir(), "shards") cache, err := NewShardedCache(dir, DefaultShardCount) if err != nil { t.Fatalf("failed to create ShardedCache: %v", err) } defer func() { _ = cache.CloseAndDestroy() }() // These are just strings; the test verifies no panic from the modulo arithmetic _ = cache.shardFor(SSTHash(fmt.Sprintf("%d", math.MaxUint32)), "test") _ = cache.shardFor(SSTHash(""), "") _ = cache.shardFor(SSTHash("a"), "b") } ================================================ FILE: go/tracing/deferlog.go ================================================ package tracing import ( "context" "fmt" "os" "runtime/debug" "github.com/getsentry/sentry-go" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // LogRecoverToReturn Recovers from a panic, logs and forwards it sentry and // otel, then returns. Does nothing when there is no panic. func LogRecoverToReturn(ctx context.Context, loc string) { err := recover() if err == nil { return } stack := string(debug.Stack()) HandleError(ctx, loc, err, stack) } // LogRecoverToError Recovers from a panic, logs and forwards it sentry and // otel, then returns a new error describing the panic. Does nothing when there // is no panic. func LogRecoverToError(ctx context.Context, loc string) error { err := recover() if err == nil { return nil } stack := string(debug.Stack()) HandleError(ctx, loc, err, stack) return fmt.Errorf("panic recovered: %v", err) } // LogRecoverToExit Recovers from a panic, logs and forwards it sentry and otel, // then exits the entire process. Does nothing when there is no panic. func LogRecoverToExit(ctx context.Context, loc string) { err := recover() if err == nil { return } stack := string(debug.Stack()) HandleError(ctx, loc, err, stack) // ensure that errors still get sent out ShutdownTracer(ctx) os.Exit(1) } func HandleError(ctx context.Context, loc string, err any, stack string) { msg := fmt.Sprintf("unhandled panic in %v, exiting: %v", loc, err) hub := sentry.CurrentHub() if hub != nil { hub.Recover(err) } // always log to stderr (no WithContext!) log.WithFields(log.Fields{"loc": loc, "stack": stack}).Error(msg) // if we have a context, try attaching additional info to the span if ctx != nil { log.WithContext(ctx).WithFields(log.Fields{"loc": loc, "stack": stack}).Error(msg) span := trace.SpanFromContext(ctx) span.SetAttributes(attribute.String("ovm.panic.loc", loc)) span.SetAttributes(attribute.String("ovm.panic.stack", stack)) } } ================================================ FILE: go/tracing/header_carrier.go ================================================ package tracing import "github.com/nats-io/nats.go" // HeaderCarrier is a custom wrapper on top of nats.Headers for otel's TextMapCarrier. type HeaderCarrier struct { headers nats.Header } // NewNatsHeaderCarrier creates a new HeaderCarrier. func NewNatsHeaderCarrier(h nats.Header) *HeaderCarrier { return &HeaderCarrier{ headers: h, } } func (c *HeaderCarrier) Get(key string) string { return c.headers.Get(key) } func (c *HeaderCarrier) Set(key, value string) { c.headers.Set(key, value) } func (c *HeaderCarrier) Keys() []string { keys := make([]string, 0, len(c.headers)) for key := range c.headers { keys = append(keys, key) } return keys } ================================================ FILE: go/tracing/main.go ================================================ package tracing import ( "context" "fmt" "net/http" "os" "path/filepath" "time" _ "embed" "github.com/MrAlias/otel-schema-utils/schema" "github.com/getsentry/sentry-go" log "github.com/sirupsen/logrus" "github.com/spf13/viper" "go.opentelemetry.io/contrib/detectors/aws/ec2/v2" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "go.opentelemetry.io/otel/trace" ) // logrusOtelErrorHandler routes OpenTelemetry SDK errors through logrus so they // appear in our structured log pipeline (and therefore in Honeycomb) instead of // being silently written to Go's default logger. type logrusOtelErrorHandler struct{} func (logrusOtelErrorHandler) Handle(err error) { log.WithError(err).Warn("OpenTelemetry SDK error") } const instrumentationName = "github.com/overmindtech/workspace" // the following vars will be set during the build using `ldflags`, eg: // // go build -ldflags "-X github.com/overmindtech/cli/go/tracing.version=$VERSION" -o your-app // // This allows caching to work for dev and removes the last `go generate` // requirement from the build. If we were embedding the version here each time // we would always produce a slightly different compiled binary, and therefore // it would look like there was a change each time var ( version = "dev" commit = "none" ) var ( tracer = otel.GetTracerProvider().Tracer( instrumentationName, trace.WithInstrumentationVersion(version), trace.WithInstrumentationAttributes( attribute.String("build.commit", commit), ), trace.WithSchemaURL(semconv.SchemaURL), ) ) func Tracer() trace.Tracer { return tracer } // hasGitDir returns true if the current directory or any parent directory contains a .git directory func hasGitDir() bool { // Start with the current working directory dir, err := os.Getwd() if err != nil { return false } // Check the current directory and all parent directories for { // Check if .git exists in this directory _, err := os.Stat(filepath.Join(dir, ".git")) if err == nil { return true // Found a .git directory } // Get the parent directory parentDir := filepath.Dir(dir) // If we've reached the root directory, stop searching if parentDir == dir { break } // Move up to the parent directory dir = parentDir } return false // No .git directory found } func tracingResource(component string) *resource.Resource { // Identify your application using resource detection resources := []*resource.Resource{} // the EC2 detector takes ~10s to time out outside EC2 // disable it if we're running from a git checkout if !hasGitDir() { ec2Res, err := resource.New(context.Background(), resource.WithDetectors(ec2.NewResourceDetector())) if err != nil { log.WithError(err).Error("error initialising EC2 resource detector") return nil } resources = append(resources, ec2Res) } // Needs https://github.com/open-telemetry/opentelemetry-go-contrib/issues/1856 fixed first // // the EKS detector is temperamental and doesn't like running outside of kube // // hence we need to keep it from running when we know there's no kube // if !viper.GetBool("disable-kube") { // // Use the AWS resource detector to detect information about the runtime environment // detectors = append(detectors, eks.NewResourceDetector()) // } hostRes, err := resource.New(context.Background(), resource.WithHost(), resource.WithOS(), resource.WithProcess(), resource.WithContainer(), resource.WithTelemetrySDK(), ) if err != nil { log.WithError(err).Error("error initialising host resource") return nil } resources = append(resources, hostRes) localRes, err := resource.New(context.Background(), resource.WithSchemaURL(semconv.SchemaURL), // Add your own custom attributes to identify your application resource.WithAttributes( semconv.ServiceNameKey.String(component), semconv.ServiceVersionKey.String(version), attribute.String("build.commit", commit), ), ) if err != nil { log.WithError(err).Error("error initialising local resource") return nil } resources = append(resources, localRes) conv := schema.NewConverter(schema.DefaultClient) res, err := conv.MergeResources(context.Background(), semconv.SchemaURL, resources...) if err != nil { log.WithError(err).Error("error merging resource") return nil } return res } var tp *sdktrace.TracerProvider // InitTracerWithUpstreams initialises the tracer with uploading directly to Honeycomb and sentry if `honeycombApiKey` and `sentryDSN` is set respectively. `component` is used as the service name. func InitTracerWithUpstreams(component, honeycombApiKey, sentryDSN string, opts ...otlptracehttp.Option) error { if sentryDSN != "" { var environment string switch viper.GetString("run-mode") { case "release": environment = "prod" case "test": environment = "dogfood" case "debug": environment = "local" default: // Fallback to dev for backward compatibility environment = "dev" } err := sentry.Init(sentry.ClientOptions{ Dsn: sentryDSN, AttachStacktrace: true, EnableTracing: false, Environment: environment, // Set TracesSampleRate to 1.0 to capture 100% // of transactions for performance monitoring. // We recommend adjusting this value in production, TracesSampleRate: 1.0, }) if err != nil { log.Errorf("sentry.Init: %s", err) } // setup recovery for an unexpected panic in this function defer sentry.Flush(2 * time.Second) defer sentry.Recover() log.Trace("sentry configured") } if honeycombApiKey != "" { opts = append(opts, otlptracehttp.WithEndpoint("api.honeycomb.io"), otlptracehttp.WithHeaders(map[string]string{"x-honeycomb-team": honeycombApiKey}), ) } else { // If no Honeycomb API key is provided, use the hardcoded OTLP collector // endpoint, which is provided by the otel-collector service in the otel // namespace. Since this a node-local service, it does not use TLS. opts = append(opts, otlptracehttp.WithEndpoint("otelcol-node-opentelemetry-collector.otel.svc.cluster.local:4318"), otlptracehttp.WithInsecure(), ) } return InitTracer(component, opts...) } // batcherOpts are the shared BatchSpanProcessor options applied to every // exporter. A large queue (8192, 4x the default 2048) reduces the chance of // silent span drops during burst load. We intentionally avoid WithBlocking() // because it causes test suites to hang when no collector is reachable (the // common case in CI). The 60s export timeout aligns with the OTLP HTTP // exporter's 1-minute retry budget. // // MaxExportBatchSize is lowered from the SDK default of 512 to 128 so that a // batch containing a handful of large LLM-payload spans stays well under the // otelcol-node HTTP receiver's body-size limit. api-server attaches full // prompts/responses/tool outputs to spans which can each run to hundreds of // KB, and 512-span batches routinely tripped the collector's 20 MiB default // cap with "400 Bad Request: http: request body too large" (ENG-3936). var batcherOpts = []sdktrace.BatchSpanProcessorOption{ sdktrace.WithMaxQueueSize(8192), sdktrace.WithExportTimeout(60 * time.Second), sdktrace.WithMaxExportBatchSize(128), } func InitTracer(component string, opts ...otlptracehttp.Option) error { otel.SetErrorHandler(logrusOtelErrorHandler{}) otlpExp, err := otlptrace.New(context.Background(), otlptracehttp.NewClient(opts...)) if err != nil { return fmt.Errorf("creating OTLP trace exporter: %w", err) } res := tracingResource(component) tracerOpts := []sdktrace.TracerProviderOption{ sdktrace.WithBatcher(otlpExp, batcherOpts...), sdktrace.WithResource(res), } if viper.GetBool("stdout-trace-dump") { stdoutExp, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) if err != nil { return err } tracerOpts = append(tracerOpts, sdktrace.WithBatcher(stdoutExp, batcherOpts...)) } tp = sdktrace.NewTracerProvider(tracerOpts...) otel.SetTracerProvider(tp) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) return nil } func ShutdownTracer(ctx context.Context) { defer sentry.Flush(5 * time.Second) ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() if tp != nil { if err := tp.ForceFlush(ctx); err != nil { log.WithContext(ctx).WithError(err).Error("Error flushing tracer provider") } if err := tp.Shutdown(ctx); err != nil { log.WithContext(ctx).WithError(err).Error("Error shutting down tracer provider") } } log.WithContext(ctx).Trace("tracing has shut down") } // Version returns the version baked into the binary at build time. func Version() string { return version } // HTTPClient returns an HTTP client with OpenTelemetry instrumentation. // This replaces the deprecated otelhttp.DefaultClient and should be used // throughout the codebase for HTTP requests that need tracing. func HTTPClient() *http.Client { return &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport), } } ================================================ FILE: go/tracing/main_test.go ================================================ package tracing import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "os" "testing" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" ) func TestTracingResource(t *testing.T) { resource := tracingResource("test-component") if resource == nil { t.Error("Could not initialize tracing resource. Check the log!") } } func TestShutdownProvider(t *testing.T) { exp := tracetest.NewInMemoryExporter() tp = sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp)) if tp == nil { t.Fatal("expected tp to be non-nil after init") } ShutdownTracer(context.Background()) // After shutdown, calling Shutdown again should be a safe no-op // (the SDK guards with stopOnce). if err := tp.Shutdown(context.Background()); err != nil { t.Errorf("second tp.Shutdown should be a no-op, got: %v", err) } } func TestShutdownIdempotent(t *testing.T) { exp := tracetest.NewInMemoryExporter() tp = sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp)) ShutdownTracer(context.Background()) ShutdownTracer(context.Background()) // must not panic } func TestErrorHandlerRegistered(t *testing.T) { otel.SetErrorHandler(logrusOtelErrorHandler{}) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) otel.Handle(fmt.Errorf("test SDK error")) if !bytes.Contains(buf.Bytes(), []byte("OpenTelemetry SDK error")) { t.Errorf("expected logrus to contain 'OpenTelemetry SDK error', got: %s", buf.String()) } if !bytes.Contains(buf.Bytes(), []byte("test SDK error")) { t.Errorf("expected logrus to contain the original error, got: %s", buf.String()) } } func TestBatcherOpts(t *testing.T) { var o sdktrace.BatchSpanProcessorOptions for _, opt := range batcherOpts { opt(&o) } if o.MaxQueueSize != 8192 { t.Errorf("batcherOpts should set MaxQueueSize to 8192, got %d", o.MaxQueueSize) } // Keep this in lock-step with the collector's max_request_body_size; see // ENG-3936 and the comment on batcherOpts in main.go. if o.MaxExportBatchSize != 128 { t.Errorf("batcherOpts should set MaxExportBatchSize to 128, got %d", o.MaxExportBatchSize) } } func TestInitTracerSetsErrorHandler(t *testing.T) { // Use a deliberately broken endpoint so the exporter creation succeeds // but no actual spans are shipped. err := InitTracer("test-component", otlptracehttp.WithEndpoint("localhost:0"), otlptracehttp.WithInsecure(), ) if err != nil { t.Fatalf("InitTracer failed: %v", err) } t.Cleanup(func() { ShutdownTracer(context.Background()) }) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) otel.Handle(fmt.Errorf("custom test error")) if !bytes.Contains(buf.Bytes(), []byte("OpenTelemetry SDK error")) { t.Errorf("after InitTracer, OTel errors should be routed to logrus; got: %s", buf.String()) } } func TestHTTPClient_ProducesSpans(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") })) defer server.Close() exp := tracetest.NewInMemoryExporter() testTP := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp)) t.Cleanup(func() { _ = testTP.Shutdown(context.Background()) }) origTP := otel.GetTracerProvider() otel.SetTracerProvider(testTP) t.Cleanup(func() { otel.SetTracerProvider(origTP) }) client := HTTPClient() ctx := context.Background() req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/test-path", nil) if err != nil { t.Fatalf("creating request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("HTTP request failed: %v", err) } resp.Body.Close() _ = testTP.ForceFlush(ctx) spans := exp.GetSpans() if len(spans) == 0 { t.Fatal("expected at least one span from HTTPClient(), got 0") } var found bool for _, s := range spans { if s.SpanKind.String() == "client" { found = true break } } if !found { names := make([]string, len(spans)) for i, s := range spans { names[i] = fmt.Sprintf("%s (kind=%s)", s.Name, s.SpanKind) } t.Fatalf("no client span found; spans: %v", names) } } ================================================ FILE: go/tracing/memory.go ================================================ package tracing import ( "runtime" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // safeUint64ToInt64 safely converts uint64 to int64 for OpenTelemetry attributes // Returns int64 max value if the uint64 exceeds int64 maximum to prevent overflow func safeUint64ToInt64(val uint64) int64 { const maxInt64 = 9223372036854775807 // 2^63 - 1 if val > maxInt64 { // Check if val exceeds int64 max return maxInt64 // Return int64 max value } return int64(val) } // MemoryStats represents memory statistics at a point in time, converted to // int64 for safe use as OpenTelemetry attributes. // // To diagnose OOMs we need to be able to tell three different "memory" // numbers apart, because Go's accounting and the Linux RSS view diverge: // - Alloc/HeapAlloc: live heap objects right now (drops on every GC). // - HeapInuse: bytes in non-empty spans (live + per-span fragmentation). // - HeapIdle: bytes in empty spans the runtime is hanging onto. // - HeapReleased: idle bytes the scavenger has handed back to the OS via // madvise(MADV_DONTNEED). RSS-equivalent ≈ HeapInuse + HeapIdle - HeapReleased. // - HeapSys / Sys: total mappings ever obtained from the OS. Effectively a // high-water mark — does NOT decrease when the scavenger releases memory, // because madvise keeps the mapping in place. Comparing Sys vs. HeapReleased // tells us whether a "8 GB Sys" reading is real RSS pressure or just // bookkeeping from a previous peak. type MemoryStats struct { Alloc int64 // bytes allocated and not yet freed HeapAlloc int64 // bytes allocated and not yet freed (same as Alloc above but specifically for heap objects) HeapInuse int64 // bytes in in-use spans HeapIdle int64 // bytes in idle (unused) spans HeapReleased int64 // bytes returned to the OS via madvise(MADV_DONTNEED) HeapSys int64 // bytes of heap memory obtained from the OS (high-water mark) Sys int64 // total bytes of memory obtained from the OS (heap + stacks + GC metadata + ...) NumGC int64 // number of completed GC cycles PauseTotal int64 // cumulative nanoseconds in GC stop-the-world pauses } // ReadMemoryStats captures current memory statistics and converts them to int64 func ReadMemoryStats() MemoryStats { var memStats runtime.MemStats runtime.ReadMemStats(&memStats) return MemoryStats{ Alloc: safeUint64ToInt64(memStats.Alloc), HeapAlloc: safeUint64ToInt64(memStats.HeapAlloc), HeapInuse: safeUint64ToInt64(memStats.HeapInuse), HeapIdle: safeUint64ToInt64(memStats.HeapIdle), HeapReleased: safeUint64ToInt64(memStats.HeapReleased), HeapSys: safeUint64ToInt64(memStats.HeapSys), Sys: safeUint64ToInt64(memStats.Sys), NumGC: int64(memStats.NumGC), PauseTotal: safeUint64ToInt64(memStats.PauseTotalNs), } } // SetMemoryAttributes sets memory-related attributes on a span with the given prefix func SetMemoryAttributes(span trace.Span, prefix string, memStats MemoryStats) { span.SetAttributes( attribute.Int64(prefix+".memoryBytes", memStats.Alloc), attribute.Int64(prefix+".memoryHeapBytes", memStats.HeapAlloc), attribute.Int64(prefix+".memoryHeapInuseBytes", memStats.HeapInuse), attribute.Int64(prefix+".memoryHeapIdleBytes", memStats.HeapIdle), attribute.Int64(prefix+".memoryHeapReleasedBytes", memStats.HeapReleased), attribute.Int64(prefix+".memoryHeapSysBytes", memStats.HeapSys), attribute.Int64(prefix+".memorySysBytes", memStats.Sys), attribute.Int64(prefix+".memoryNumGC", memStats.NumGC), attribute.Int64(prefix+".memoryPauseTotalNs", memStats.PauseTotal), ) } // SetMemoryDeltaAttributes sets memory delta attributes on a span with the given prefix // It calculates the difference between before and after memory stats func SetMemoryDeltaAttributes(span trace.Span, prefix string, before, after MemoryStats) { span.SetAttributes( attribute.Int64(prefix+".memoryDeltaBytes", after.Alloc-before.Alloc), attribute.Int64(prefix+".memoryDeltaHeapBytes", after.HeapAlloc-before.HeapAlloc), attribute.Int64(prefix+".memoryDeltaHeapInuseBytes", after.HeapInuse-before.HeapInuse), attribute.Int64(prefix+".memoryDeltaHeapIdleBytes", after.HeapIdle-before.HeapIdle), attribute.Int64(prefix+".memoryDeltaHeapReleasedBytes", after.HeapReleased-before.HeapReleased), attribute.Int64(prefix+".memoryDeltaHeapSysBytes", after.HeapSys-before.HeapSys), attribute.Int64(prefix+".memoryDeltaSysBytes", after.Sys-before.Sys), attribute.Int64(prefix+".memoryDeltaNumGC", after.NumGC-before.NumGC), attribute.Int64(prefix+".memoryDeltaPauseTotalNs", after.PauseTotal-before.PauseTotal), ) } ================================================ FILE: go/tracing/memory_test.go ================================================ package tracing import ( "testing" ) func TestSafeUint64ToInt64(t *testing.T) { t.Parallel() tests := []struct { name string input uint64 expected int64 }{ { name: "small value", input: 1000, expected: 1000, }, { name: "int64 max value", input: 9223372036854775807, // 2^63 - 1 expected: 9223372036854775807, }, { name: "int64 max + 1", input: 9223372036854775808, // 2^63 expected: 9223372036854775807, // Should be clamped to int64 max }, { name: "very large value", input: 18446744073709551615, // uint64 max expected: 9223372036854775807, // Should be clamped to int64 max }, } for _, tt := range tests { test := tt // capture loop variable t.Run(test.name, func(t *testing.T) { t.Parallel() result := safeUint64ToInt64(test.input) if result != test.expected { t.Errorf("safeUint64ToInt64(%d) = %d, expected %d", test.input, result, test.expected) } }) } } func TestReadMemoryStats(t *testing.T) { t.Parallel() stats := ReadMemoryStats() // Basic sanity checks - these values should be reasonable if stats.Alloc <= 0 { t.Errorf("Alloc should be greater than 0, got %d", stats.Alloc) } if stats.HeapAlloc <= 0 { t.Errorf("HeapAlloc should be greater than 0, got %d", stats.HeapAlloc) } if stats.Sys <= 0 { t.Errorf("Sys should be greater than 0, got %d", stats.Sys) } // Verify that values are within int64 range (they should be since we convert them) if stats.Alloc < 0 { t.Errorf("Alloc should not be negative, got %d", stats.Alloc) } if stats.HeapAlloc < 0 { t.Errorf("HeapAlloc should not be negative, got %d", stats.HeapAlloc) } if stats.Sys < 0 { t.Errorf("Sys should not be negative, got %d", stats.Sys) } } ================================================ FILE: go.mod ================================================ module github.com/overmindtech/cli go 1.26.0 replace github.com/anthropics/anthropic-sdk-go => github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.4 // Address an incompatibility between buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go and the kubernetes modules. // See https://github.com/overmindtech/workspace/pull/1124 and https://github.com/kubernetes/apiserver/issues/116 replace github.com/google/cel-go => github.com/google/cel-go v0.22.1 require ( atomicgo.dev/keyboard v0.2.9 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 buf.build/go/protovalidate v1.1.3 charm.land/lipgloss/v2 v2.0.3 cloud.google.com/go/aiplatform v1.124.0 cloud.google.com/go/auth v0.20.0 cloud.google.com/go/auth/oauth2adapt v0.2.8 cloud.google.com/go/bigquery v1.76.0 cloud.google.com/go/bigtable v1.46.0 cloud.google.com/go/certificatemanager v1.12.0 cloud.google.com/go/compute v1.60.0 cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/container v1.49.0 cloud.google.com/go/dataplex v1.32.0 cloud.google.com/go/dataproc/v2 v2.19.0 cloud.google.com/go/eventarc v1.21.0 cloud.google.com/go/filestore v1.13.0 cloud.google.com/go/functions v1.22.0 cloud.google.com/go/iam v1.9.0 cloud.google.com/go/kms v1.29.0 cloud.google.com/go/logging v1.16.0 cloud.google.com/go/monitoring v1.27.0 cloud.google.com/go/networksecurity v0.14.0 cloud.google.com/go/orgpolicy v1.18.0 cloud.google.com/go/redis v1.21.0 cloud.google.com/go/resourcemanager v1.13.0 cloud.google.com/go/run v1.19.0 cloud.google.com/go/secretmanager v1.19.0 cloud.google.com/go/securitycentermanagement v1.4.0 cloud.google.com/go/spanner v1.90.0 cloud.google.com/go/storage v1.62.1 cloud.google.com/go/storagetransfer v1.16.0 connectrpc.com/connect v1.18.1 // v1.19.0 was faulty, wait until it is above this version github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4 v4.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7 v7.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.0.2 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9 v9.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5 v5.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2 v2.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3 v3.0.0 github.com/Masterminds/semver/v3 v3.4.0 github.com/MrAlias/otel-schema-utils v0.4.0-alpha github.com/auth0/go-jwt-middleware/v3 v3.1.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/credentials v1.19.16 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 github.com/aws/aws-sdk-go-v2/service/apigateway v1.39.3 github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.2 github.com/aws/aws-sdk-go-v2/service/cloudfront v1.62.0 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.57.0 github.com/aws/aws-sdk-go-v2/service/directconnect v1.38.17 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3 github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1 github.com/aws/aws-sdk-go-v2/service/ecs v1.79.1 github.com/aws/aws-sdk-go-v2/service/efs v1.41.16 github.com/aws/aws-sdk-go-v2/service/eks v1.83.0 github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.33.25 github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.12 github.com/aws/aws-sdk-go-v2/service/iam v1.53.10 github.com/aws/aws-sdk-go-v2/service/kms v1.51.1 github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1 github.com/aws/aws-sdk-go-v2/service/networkfirewall v1.60.1 github.com/aws/aws-sdk-go-v2/service/networkmanager v1.41.10 github.com/aws/aws-sdk-go-v2/service/rds v1.118.2 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.7 github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 github.com/aws/aws-sdk-go-v2/service/sns v1.39.17 github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 github.com/aws/smithy-go v1.25.1 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/glamour v0.10.0 github.com/coder/websocket v1.8.14 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/getsentry/sentry-go v0.45.1 github.com/go-jose/go-jose/v4 v4.1.4 github.com/google/btree v1.1.3 github.com/google/uuid v1.6.0 github.com/googleapis/gax-go/v2 v2.22.0 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform-config-inspect v0.0.0-20260224005459-813a97530220 github.com/hashicorp/terraform-plugin-framework v1.19.0 github.com/hashicorp/terraform-plugin-go v0.31.0 github.com/hashicorp/terraform-plugin-testing v1.15.0 github.com/jedib0t/go-pretty/v6 v6.7.9 github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/micahhausler/aws-iam-policy v0.4.4 github.com/miekg/dns v1.1.72 github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/reflow v0.3.0 github.com/nats-io/jwt/v2 v2.8.1 github.com/nats-io/nats-server/v2 v2.12.7 github.com/nats-io/nats.go v1.51.0 github.com/nats-io/nkeys v0.4.15 github.com/onsi/ginkgo/v2 v2.28.1 // indirect github.com/onsi/gomega v1.39.1 // indirect github.com/openrdap/rdap v0.9.2-0.20240517203139-eb57b3a8dedd github.com/overmindtech/pterm v0.0.0-20240919144758-04d94ccb2297 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/sirupsen/logrus v1.9.4 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2 github.com/xiam/dig v0.0.0-20191116195832-893b5fb5093b github.com/zclconf/go-cty v1.18.1 go.etcd.io/bbolt v1.4.3 go.opentelemetry.io/contrib/detectors/aws/ec2/v2 v2.0.0-20250901115419-474a7992e57c go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/automaxprocs v1.6.0 go.uber.org/goleak v1.3.0 go.uber.org/mock v0.6.0 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/text v0.36.0 gonum.org/v1/gonum v0.17.0 google.golang.org/api v0.276.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/ini.v1 v1.67.1 k8s.io/api v0.35.4 k8s.io/apimachinery v0.35.4 k8s.io/client-go v0.35.4 sigs.k8s.io/kind v0.31.0 sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect ) require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/schedule v0.1.0 // indirect cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/longrunning v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/alecthomas/chroma/v2 v2.16.0 // indirect github.com/alecthomas/kingpin/v2 v2.4.0 // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow/go/v15 v15.0.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect; being pulled by glamour, this will be resolved in https://github.com/charmbracelet/glamour/pull/408 github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250417172821-98fd948af1b1 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/containerd/console v1.0.4 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/flatbuffers v23.5.26+incompatible // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/gookit/color v1.5.4 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cty v1.5.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.7.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/hc-install v0.9.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.25.0 // indirect github.com/hashicorp/terraform-json v0.27.2 // indirect github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.3 // indirect github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/oklog/run v1.1.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect github.com/valyala/fastjson v1.6.7 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xiam/to v0.0.0-20191116183551-8328998fc0ed // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.10 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/otel/log v0.11.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/schema v0.0.12 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) ================================================ FILE: go.sum ================================================ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE= buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/aiplatform v1.124.0 h1:77uy+1+G11yP98axztHeVaW85NcXVLA7WP6ynUXmOlE= cloud.google.com/go/aiplatform v1.124.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.76.0 h1:wnfVSXN6GEMlsAoHWdhzTC8NMsptOx2hsqPiI+lTs3I= cloud.google.com/go/bigquery v1.76.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= cloud.google.com/go/bigtable v1.46.0 h1:Bd6vITx01s6gsdEPvjIGJL/oMMdKvraGI34UiixR2gk= cloud.google.com/go/bigtable v1.46.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4= cloud.google.com/go/certificatemanager v1.12.0 h1:cGtIA5WPVpZDqC35E+i5FRZDziUPbIIKE4wo6mNzqCI= cloud.google.com/go/certificatemanager v1.12.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8= cloud.google.com/go/compute v1.60.0 h1:CqGt23ysz990ZZe1vq/9aDPKKnmwM6kcC7Y1Q05H2kI= cloud.google.com/go/compute v1.60.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/container v1.49.0 h1:K4nmtmJezHOzsIyedAOv1Ok36krw1apFmo4zXBaRL1A= cloud.google.com/go/container v1.49.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs= cloud.google.com/go/datacatalog v1.27.0 h1:AnghhtHKCqYIe62gTPHcn9nJr5jtxjZHV4D/Fob23gg= cloud.google.com/go/datacatalog v1.27.0/go.mod h1:YTI11pFlC5HCj4CphEf+qWCy/z9udd7o0HVN6c2Povg= cloud.google.com/go/dataplex v1.32.0 h1:FXcPlYhlp5jdnaIygqh+6n7HJyeSnunbIRV8Y1yfim8= cloud.google.com/go/dataplex v1.32.0/go.mod h1:sOazL+Bs/PTxiMHQ5yBboBvEW9qPrpGogx3+RAgfIt8= cloud.google.com/go/dataproc/v2 v2.19.0 h1:nigeuU3AoKMOuPoQ/F3QDCGRDdJCkFMR6mAhXC4uDfA= cloud.google.com/go/dataproc/v2 v2.19.0/go.mod h1:oARVSa38kAHvSuG+cozsrY2sE6UajGuvOOf9vS+ADHI= cloud.google.com/go/eventarc v1.21.0 h1:BzkhBK2K/FxEnaNNJuYyHq/cW5AdFWMFNNHWqeVqgxk= cloud.google.com/go/eventarc v1.21.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU= cloud.google.com/go/filestore v1.13.0 h1:7L8pvhPr6ZaTKpTinDmAuSwkYEt+B+eRl0OA/Pm882w= cloud.google.com/go/filestore v1.13.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM= cloud.google.com/go/functions v1.22.0 h1:rJ2bSt2KUEi0OBMsUKICI/lJYCsTOw3aMgzKxBmuyNo= cloud.google.com/go/functions v1.22.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0= cloud.google.com/go/iam v1.9.0 h1:89wyjxT6DL4b5rk/Nk8eBC9DHqf+JiMstrn5IEYxFw4= cloud.google.com/go/iam v1.9.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/kms v1.29.0 h1:bAW1C5FQf+6GhPkywQzPlsULALCG7c16qpXLFGV9ivY= cloud.google.com/go/kms v1.29.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= cloud.google.com/go/logging v1.16.0 h1:MMNgYRvZ/pEwiNSkcoJTKWfAbAJDqCqAMJiarZx+/CI= cloud.google.com/go/logging v1.16.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= cloud.google.com/go/monitoring v1.27.0 h1:BhYwMqao+e5Nn7JtWMM9m6zRtKtVUK6kJWMizXChkLU= cloud.google.com/go/monitoring v1.27.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/networksecurity v0.14.0 h1:v1NKTztDfEDIr7C5wHmdELebok4pmnaIC6nGAOv3AuY= cloud.google.com/go/networksecurity v0.14.0/go.mod h1:LMn10eRVf4K85PMF33yRoKAra7VhCOetxFcLDMh9A74= cloud.google.com/go/orgpolicy v1.18.0 h1:/DfoElp43vYbWrNSZqaz/NPeNty4vXfvpIzT1ZqOtsY= cloud.google.com/go/orgpolicy v1.18.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ= cloud.google.com/go/redis v1.21.0 h1:2sz4rMZ/1+UwDwMeS61LqQQHMddyzmE0FRO2rbU7MWk= cloud.google.com/go/redis v1.21.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw= cloud.google.com/go/resourcemanager v1.13.0 h1:cc291PxLoKrHKVxqoJ2uMMzrxVJj+sRe+iEb1DFlDNA= cloud.google.com/go/resourcemanager v1.13.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE= cloud.google.com/go/run v1.19.0 h1:kjXZKDwrUOeUYDd7/0TZ/iKsG3rJ3Lq3cyksTspcNSU= cloud.google.com/go/run v1.19.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU= cloud.google.com/go/secretmanager v1.19.0 h1:dm9BK06xl+hrxp2unT2psjZeypPj5c6uPiABb6fmicE= cloud.google.com/go/secretmanager v1.19.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU= cloud.google.com/go/securitycentermanagement v1.4.0 h1:nfHjWuyFtDGc+9XR4T1A7kzXhS9+xi5bGA7Y3DPDhBQ= cloud.google.com/go/securitycentermanagement v1.4.0/go.mod h1:2vT5sKJSeclefx8yku77inS/bAyEiLH9n1CHpshtDMQ= cloud.google.com/go/spanner v1.90.0 h1:tVUzYI9IZJY1SDvCmCzyuLoaAyotZgiyQq6go+lNH5A= cloud.google.com/go/spanner v1.90.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0= cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= cloud.google.com/go/storagetransfer v1.16.0 h1:9XRKD/zyjrbNcZdIk8Jf5D1a9MRIPCbYoBSCXiKMp3E= cloud.google.com/go/storagetransfer v1.16.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= 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.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 h1:qiir/pptnHqp6hV8QwV+IExYIf6cPsXBfUDUXQ27t2Y= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4 v4.0.0 h1:KBRoKIQlg79mFK5LRndDGPrCDGRl2xyFr/vG8afLGys= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4 v4.0.0/go.mod h1:w+PG/dv/phWHlE3OIKWa4CAITETZ52D8qznRGMbduPA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7 v7.3.0 h1:nyxugFxG2uhbMeJVCFFuD2j9wu+6KgeabITdINraQsE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7 v7.3.0/go.mod h1:e4RAYykLIz73CF52KhSooo4whZGXvXrD09m0jkgnWiU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0 h1:+EhRnIOLvffCvUMUfP+MgOp6PrtN1d6xt94DZtrC3lA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0/go.mod h1:Bb7kqorvA2acMCNFac+2ldoQWi7QrcMdH+9Gg9C7fSM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan v1.2.0 h1:8xYBtaMs3Msy1bFYTVrVFBh05JUGNMMP/v3z3x5hoIw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan v1.2.0/go.mod h1:bXxc3uCnIUCh68pl4njcH45qUgRuR0kZfR6v06k18/A= 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/internal/v3 v3.1.1 h1:1kpY4qe+BGAH2ykv4baVSqyx+AY5VjXeJ15SldlU6hs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.1/go.mod h1:nT6cWpWdUt+g81yuKmjeYPUtI73Ak3yQIT4PVVsCEEQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.0.2 h1:O2iuZYGa1nIMDk2uAFR0F7hDALVXMvz8Zwarz6itQ3E= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.0.2/go.mod h1:7t88hsh6P4xqFM9uzaMX2qYfVsqDFkgFR4qdIX/OP+U= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance v1.3.0 h1:rx/pIYQIlCjb+n7TzMyFUzIJYb+d0Gi7Vh+ozA0fSJA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance v1.3.0/go.mod h1:o8YD+BbSeK8ANH4SpxQFCiz5OIFKgHxV1uwF2FrQJYY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0 h1:akP6VpxJGgQRpDR1P462piz/8OhYLRCreDj48AyNabc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0/go.mod h1:8wzvopPfyZYPaQUoKW87Zfdul7jmJMDfp/k7YY3oJyA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 h1:L7G3dExHBgUxsO3qpTGhk/P2dgnYyW48yn7AO33Tbek= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0/go.mod h1:Ms6gYEy0+A2knfKrwdatsggTXYA2+ICKug8w7STorFw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9 v9.0.0 h1:CbHDMVJhcJSmXenq+UDWyIjumzVkZIb5pVUGzsCok5M= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9 v9.0.0/go.mod h1:raqbEXrok4aycS74XoU6p9Hne1dliAFpHLizlp+qJoM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights v1.2.0 h1:4FlNvfcPu7tTvOgOzXxIbZLvwvmZq1OdhQUdIa9g2N4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights v1.2.0/go.mod h1:A4nzEXwVd5pAyneR6KOvUAo72svUc5rmCzRHhAbP6lA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5 v5.0.0 h1:S7K+MLPEYe+g9AX9dLKldBpYV03bPl7zeDaWhiNDqqs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5 v5.0.0/go.mod h1:EHRrmrnS2Q8fB3+DE30TTk04JLqjui5ZJEF7eMVQ2/M= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v0.2.0 h1:bYq3jfB2x36hslKMHyge3+esWzROtJNk/4dCjsKlrl4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v0.2.0/go.mod h1:fewgRjNVE84QVVh798sIMFb7gPXPp7NmnekGnboSnXk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2 v2.1.0 h1:seyVIpxalxYmfjoo8MB4rRzWaobMG+KJ2+MAUrEvDGU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2 v2.1.0/go.mod h1:M3QD7IyKZBaC4uAKjitTOSOXdcPC6JS1A9oOW3hYjbQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7 h1:SLsVdG/8T65poVMw5ZJtI/dUL7iIwvbkq+koqmWdmu8= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7/go.mod h1:l9kSL5eB+KdZ2aovhkUYwyZE7oQwTEqVCxnpNKChi1U= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3 v3.0.0 h1:tqGq5xt/rNU57Eb52rf6bvrNWoKPSwLDVUQrJnF4C5U= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3 v3.0.0/go.mod h1:HfDdtu9K0iFBSMMxFsHJPkAAxFWd2IUOW8HU8kEdF3Y= 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.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/MrAlias/otel-schema-utils v0.4.0-alpha h1:6ZG9rw4NvxKwRp2Bmnfr8WJZVWLhK4e5n3+ezXE6Z2g= github.com/MrAlias/otel-schema-utils v0.4.0-alpha/go.mod h1:baehOhES9qiLv9xMcsY6ZQlKLBRR89XVJEvU7Yz3qJk= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA= github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE= github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/auth0/go-jwt-middleware/v3 v3.1.0 h1:1aqVJA9K0+B6hP6qqMjTsJUk/L14sJSUjiTGW2/mY64= github.com/auth0/go-jwt-middleware/v3 v3.1.0/go.mod h1:BBZCQAXmqC/QfwzWyHOqF/kwN4C66eMeayy9QS6TgT4= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= github.com/aws/aws-sdk-go-v2/service/apigateway v1.39.3 h1:7MJlB7KGFd+KNKtnPgoFWYf52PGO1pd+1VHp10lNKhI= github.com/aws/aws-sdk-go-v2/service/apigateway v1.39.3/go.mod h1:MwilTAruv11x8EFjsk1R0VfjMdCxB6JHVtanCqsTR5o= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.2 h1:pPd+/Ujqf2+DmPOdB47EN7ox1iC21lu2zlOccUlfHeo= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.2/go.mod h1:b3XHAIEe5I9cmeZ9MLvUqj5DRWcBuh1/hpKDPb7T6KE= github.com/aws/aws-sdk-go-v2/service/cloudfront v1.62.0 h1:Vd4U87ecTyeQwOTezwqAYW9qcWdZpwicC96MlqXd67M= github.com/aws/aws-sdk-go-v2/service/cloudfront v1.62.0/go.mod h1:brhMG/gR2xEB5lezxL2Cx+hqsEzGUn4LhNUtu7+ePFE= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.57.0 h1:dlkFtYOrwOuM7IIBD6FPLtt0Xvnph+8hqmmbzyowkCk= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.57.0/go.mod h1:7900IH3EvTrwNGLNx3QDKnQwPF/Cw+pD9cuvBDQ4org= github.com/aws/aws-sdk-go-v2/service/directconnect v1.38.17 h1:fkeDjhbAy9ddanOVlxP2vnY2dbTxA8HL+DdV9HezVSs= github.com/aws/aws-sdk-go-v2/service/directconnect v1.38.17/go.mod h1:kzj2OFWYl3uGXBkincAArVPtSG8QwXJRfCL8+Ztsw9o= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3 h1:XgjzLEE8CrNYnr4Xmi1W5PfKsKMjp4Pu1rWkJNO43JI= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3/go.mod h1:r7sfLXEN8RUA89tAHy1E7lCtVOOWIkqVy/FbnUdxW1E= github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1 h1:gQ9fSyFk3Y9Vm2fVbphBeJfXJlkJvEvC35TszBVjprg= github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY= github.com/aws/aws-sdk-go-v2/service/ecs v1.79.1 h1:tQNU4tC4cMoZo1e+7J8j3/GWM7PJFdXCN0VzEFwFqUE= github.com/aws/aws-sdk-go-v2/service/ecs v1.79.1/go.mod h1:TIKZ9zIFS6W2k9FeW+r5sGVnlxp+aUt9oQ/St3Suj1o= github.com/aws/aws-sdk-go-v2/service/efs v1.41.16 h1:qHmh61/S6g+scI9M4U3XYivCiEp1tUadKgyrczuLJpM= github.com/aws/aws-sdk-go-v2/service/efs v1.41.16/go.mod h1:Q7WcY1H6krqZEnFyxyuzfLAnEad1Q69U4CrBbY4P2Fg= github.com/aws/aws-sdk-go-v2/service/eks v1.83.0 h1:mS5rkyFt+NYryy0p4n8o80tJjBmXiQrRCQjP8jZcSLY= github.com/aws/aws-sdk-go-v2/service/eks v1.83.0/go.mod h1:JQcyECIV9iZHm+GMrWn1pTPTJYRavOVsqPvlCbjt+Fg= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.33.25 h1:VzmoYPRbNSUqk3pA04ZyGZUg52yfX259XXRqwr1lns4= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.33.25/go.mod h1:r7chQGimOmFs4oqawhO+i+o3ez2l69rzAco5KTb7bjY= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.12 h1:TJXv7kZjdXA2maPDaJFFEQPBrPmvPtMybN3qYDOpJ4Y= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.12/go.mod h1:lwjtb9DHOAmNt7EUW68Zd1Qd+cPyFxacXHN5c9JZ2VY= github.com/aws/aws-sdk-go-v2/service/iam v1.53.10 h1:kcN3I3llO7VwIY5w3Pc5FmEonpsr23Ou7Cwk4qf7dik= github.com/aws/aws-sdk-go-v2/service/iam v1.53.10/go.mod h1:1vkJzjCYC3byO0kIrBqLPzvZpuvYhPXkuyARs6E7tM4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23 h1:3Eo/PBBnjFi1+gYfaL286dpmFSW3mTfodBIybq36Qv4= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23/go.mod h1:3oh+5xGSd1iuxonVb3Qbm+WJYlbhczT9kbzr6doJLzY= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= github.com/aws/aws-sdk-go-v2/service/kms v1.51.1 h1:zuSf4olLKZW8cF/W9Y5wvGT+/0raY/3kVp49KsGs0QY= github.com/aws/aws-sdk-go-v2/service/kms v1.51.1/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1 h1:odCeJgHXfQoXEWQUIzPkKvsJTWcLMsaOWowNpovPFFw= github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1/go.mod h1:NbtJVztitG7JkuoI4GSrDUlsB32zeXqKBvXj6bUxcMo= github.com/aws/aws-sdk-go-v2/service/networkfirewall v1.60.1 h1:acbBwzoZSM3oet/FcUNddED5V7zBauXiRxsD2NJcD70= github.com/aws/aws-sdk-go-v2/service/networkfirewall v1.60.1/go.mod h1:oWCet/AjsuKhMkvcXOGEeS2QmssLJX1UmX2SiKCEsFM= github.com/aws/aws-sdk-go-v2/service/networkmanager v1.41.10 h1:fZdjuh4szziSdwiDhUT2xexjJ21sehyDU88mkUjw0KQ= github.com/aws/aws-sdk-go-v2/service/networkmanager v1.41.10/go.mod h1:x0O7AHep2gwquyfW6gmNql2OM4LEloyJGFflJfEJV+U= github.com/aws/aws-sdk-go-v2/service/rds v1.118.2 h1:pkEeQneYFpTAnGhyqSbyp/DlCPPJTGt0GkWahlLYzMA= github.com/aws/aws-sdk-go-v2/service/rds v1.118.2/go.mod h1:7gS+cGrKF0mH253QHFlStmx79ws+DlNk+04ZRfmw3U0= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.7 h1:twRRMmtSITnt/rrp+D7UDLzE5pKMZe759aalkUdN+OY= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.7/go.mod h1:ztM1lr+sRoCAI8336ZUvlRPbToue0d3gE/wd6jomSJ8= github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 h1:mxuT1xE+dI54NW3RkNjP8DUT5HXqbkiAFvfdyDFwE5c= github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= github.com/aws/aws-sdk-go-v2/service/sns v1.39.17 h1:synXIPC/L4Cc489P0XDcrVJzHSLj7krKRpFLalbGM2k= github.com/aws/aws-sdk-go-v2/service/sns v1.39.17/go.mod h1:4ABZnI23uNK37waIjGwkubnCwGhepIt9x1GvASfljJA= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 h1:QgaWXVmNDxv/U/3UIHfGb7ohvtFgerf/bYcYylj4i8E= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27/go.mod h1:8S6ExnLprS0oIeA8ZlHkJUJ0BMpKqnRPws/S0jegTqQ= github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6 h1:0LPJjbSNEDHidGOXa0LfvSVbdn9/GdlJUQTgE0kFpso= github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6/go.mod h1:SrZAopBP5/lyQ6NBVXKlRp8wPIXhzBCZU98sEozmv8Y= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250417172821-98fd948af1b1 h1:8fUBSeb8wmOWD0ToP8AJFhUCYrmR3aj/sLECrLGM0TI= github.com/charmbracelet/x/exp/slice v0.0.0-20250417172821-98fd948af1b1/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getsentry/sentry-go v0.45.1 h1:9rfzJtGiJG+MGIaWZXidDGHcH5GU1Z5y0WVJGf9nysw= github.com/getsentry/sentry-go v0.45.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.22.1 h1:AfVXx3chM2qwoSbM7Da8g8hX8OVSkBFwX+rz2+PcK40= github.com/google/cel-go v0.22.1/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8= github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e h1:XmA6L9IPRdUr28a+SK/oMchGgQy159wvzXA5tJ7l+40= github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e/go.mod h1:AFIo+02s+12CEg8Gzz9kzhCbmbq6JcKNrhHffCGA9z4= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hc-install v0.9.4 h1:KKWOpUG0EqIV63Qk2GGFrZ0s275NVs5lKf9N5vjBNoc= github.com/hashicorp/hc-install v0.9.4/go.mod h1:4LRYeEN2bMIFfIv57ldMWt9awfuZhvpbRt0vWmv51WU= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-config-inspect v0.0.0-20260224005459-813a97530220 h1:v0h6j7IMgA24b8aWG5+d6WStIP9G8e/p0DKK3Bmk7YQ= github.com/hashicorp/terraform-config-inspect v0.0.0-20260224005459-813a97530220/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= github.com/hashicorp/terraform-exec v0.25.0 h1:Bkt6m3VkJqYh+laFMrWIpy9KHYFITpOyzRMNI35rNaY= github.com/hashicorp/terraform-exec v0.25.0/go.mod h1:dl9IwsCfklDU6I4wq9/StFDp7dNbH/h5AnfS1RmiUl8= github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= github.com/hashicorp/terraform-plugin-testing v1.15.0 h1:/fimKyl0YgD7aAtJkuuAZjwBASXhCIwWqMbDLnKLMe4= github.com/hashicorp/terraform-plugin-testing v1.15.0/go.mod h1:bGXMw7bE95EiZhSBV3rM2W8TiffaPTDuLS+HFI/lIYs= github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.7.9 h1:frarzQWmkZd97syT81+TH8INKPpzoxQnk+Mk5EIHSrM= github.com/jedib0t/go-pretty/v6 v6.7.9/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.3 h1:WjLHWkDkgWXeIUrKi/7lS/sGq2DjkSAwdTbH5RHXAKs= github.com/lestrrat-go/httprc/v3 v3.0.3/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/micahhausler/aws-iam-policy v0.4.4 h1:1aMhJ+0CkvUJ8HGN1chX+noXHs8uvGLkD7xIBeYd31c= github.com/micahhausler/aws-iam-policy v0.4.4/go.mod h1:H+yWljTu4XWJjNJJYgrPUai0AUTGNHc8pumkN57/foI= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= github.com/nats-io/nats-server/v2 v2.12.7 h1:prQ9cPiWHcnwfT81Wi5lU9LL8TLY+7pxDru6fQYLCQQ= github.com/nats-io/nats-server/v2 v2.12.7/go.mod h1:dOnmkprKMluTmTF7/QHZioxlau3sKHUM/LBPy9AiBPw= github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/openrdap/rdap v0.9.2-0.20240517203139-eb57b3a8dedd h1:UuQycBx6K0lB0/IfHePshOYjlrptkF4FoApFP2Y4s3k= github.com/openrdap/rdap v0.9.2-0.20240517203139-eb57b3a8dedd/go.mod h1:391Ww1JbjG4FHOlvQqCd6n25CCCPE64JzC5cCYPxhyM= github.com/overmindtech/pterm v0.0.0-20240919144758-04d94ccb2297 h1:ih4bqBMHTCtg3lMwJszNkMGO9n7Uoe0WX5be1/x+s+g= github.com/overmindtech/pterm v0.0.0-20240919144758-04d94ccb2297/go.mod h1:bRQZYnvLrW1S5wYT6tbQnun8NpO5X6zP5cY3VKuDc4U= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= github.com/pterm/pterm v0.12.53 h1:8ERV5eXyvXlAIY8LRrhapPS34j7IKKDAnb7o1Ih3T0w= github.com/pterm/pterm v0.12.53/go.mod h1:BY2H3GtX2BX0ULqLY11C2CusIqnxsYerbkil3XvXIBg= 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= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 h1:OXcKh35JaYsGMRzpvFkLv/MEyPuL49CThT1pZ8aSml4= github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2 h1:H8wwQwTe5sL6x30z71lUgNiwBdeCHQjrphCfLwqIHGo= github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2/go.mod h1:/kR4beFhlz2g+V5ik8jW+3PMiMQAPt29y6K64NNY53c= github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0= github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xiam/dig v0.0.0-20191116195832-893b5fb5093b h1:ajy6PPLDeQaf7xf4P/4Ie/wsUTEqjy3Irl+xFelmjk0= github.com/xiam/dig v0.0.0-20191116195832-893b5fb5093b/go.mod h1:TkoiLoIgvAxmagjbnKWq18F2VlqnIcqAx/HzmFAqXNU= github.com/xiam/to v0.0.0-20191116183551-8328998fc0ed h1:Gjnw8buhv4V8qXaHtAWPnKXNpCNx62heQpjO8lOY0/M= github.com/xiam/to v0.0.0-20191116183551-8328998fc0ed/go.mod h1:cqbG7phSzrbdg3aj+Kn63bpVruzwDZi58CpxlZkjwzw= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI= github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/aws/ec2/v2 v2.0.0-20250901115419-474a7992e57c h1:YSqSR1Fil5Ip0N6AlNBFbNv7cvIHZ2j4+wqbVafAGmQ= go.opentelemetry.io/contrib/detectors/aws/ec2/v2 v2.0.0-20250901115419-474a7992e57c/go.mod h1:avnUkmc6cwMhcExsYaSv0SQVqygTfXGTn41eZ7xjKpo= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= go.opentelemetry.io/otel/log v0.11.0 h1:c24Hrlk5WJ8JWcwbQxdBqxZdOK7PcP/LFtOtwpDTe3Y= go.opentelemetry.io/otel/log v0.11.0/go.mod h1:U/sxQ83FPmT29trrifhQg+Zj2lo1/IPN1PF6RTFqdwc= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/schema v0.0.12 h1:X8NKrwH07Oe9SJruY/D1XmwHrb6D2+qrLs2POlZX7F4= go.opentelemetry.io/otel/schema v0.0.12/go.mod h1:+w+Q7DdGfykSNi+UU9GAQz5/rtYND6FkBJUWUXzZb0M= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kind v0.31.0 h1:UcT4nzm+YM7YEbqiAKECk+b6dsvc/HRZZu9U0FolL1g= sigs.k8s.io/kind v0.31.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: gon-amd64.json ================================================ { "source": ["./dist/overmind-macos_darwin_amd64_v1/overmind"], "bundle_id": "tech.overmind.cli", "apple_id": { "username": "dylanratcliffe@outlook.com", "provider": "248Q4U4VT7" }, "sign": { "application_identity": "CE61C10EED69BFB4B25EB349AD15B29D75A809B9" }, "dmg" :{ "output_path": "dist/overmind-cli-amd64.dmg", "volume_name": "Overmind" } } ================================================ FILE: gon-arm64.json ================================================ { "source": ["./dist/overmind-macos_darwin_arm64/overmind"], "bundle_id": "tech.overmind.cli", "apple_id": { "username": "dylanratcliffe@outlook.com", "provider": "248Q4U4VT7" }, "sign": { "application_identity": "CE61C10EED69BFB4B25EB349AD15B29D75A809B9" }, "dmg" :{ "output_path": "dist/overmind-cli-arm64.dmg", "volume_name": "Overmind" } } ================================================ FILE: k8s-source/acceptance/nats-server.conf ================================================ # Client port of 4222 on all interfaces port: 4222 # HTTP monitoring port monitor_port: 8222 # This is for clustering multiple servers together. cluster { # It is recommended to set a cluster name name: "my_cluster" # Route connections to be received on any interface on port 6222 port: 6222 # Routes are protected, so need to use them with --routes flag # e.g. --routes=nats-route://ruser:T0pS3cr3t@otherdockerhost:6222 authorization { user: ruser password: T0pS3cr3t timeout: 0.75 } # Routes are actively solicited and connected to from this server. # This Docker image has none by default, but you can pass a # flag to the nats-server docker image to create one to an existing server. routes = [] } websocket { port: 4433 no_tls: true } ================================================ FILE: k8s-source/adapters/clusterrole.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/rbac/v1" "k8s.io/client-go/kubernetes" ) func newClusterRoleAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.ClusterRole, *v1.ClusterRoleList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "ClusterRole", ClusterInterfaceBuilder: func() ItemInterface[*v1.ClusterRole, *v1.ClusterRoleList] { return cs.RbacV1().ClusterRoles() }, ListExtractor: func(list *v1.ClusterRoleList) ([]*v1.ClusterRole, error) { bindings := make([]*v1.ClusterRole, len(list.Items)) for i := range list.Items { bindings[i] = &list.Items[i] } return bindings, nil }, AdapterMetadata: clusterRoleAdapterMetadata, } } var clusterRoleAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ClusterRole", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, DescriptiveName: "Cluster Role", SupportedQueryMethods: DefaultSupportedQueryMethods("Cluster Role"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_cluster_role_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newClusterRoleAdapter) } ================================================ FILE: k8s-source/adapters/clusterrole_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var clusterRoleYAML = ` apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: read-only rules: - apiGroups: [""] resources: ["*"] verbs: ["get", "list", "watch"] ` func TestClusterRoleAdapter(t *testing.T) { adapter := newClusterRoleAdapter(CurrentCluster.ClientSet, CurrentCluster.Name, []string{}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "read-only", GetScope: CurrentCluster.Name, SetupYAML: clusterRoleYAML, GetQueryTests: QueryTests{}, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/clusterrolebinding.go ================================================ package adapters import ( v1 "k8s.io/api/rbac/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func clusterRoleBindingExtractor(resource *v1.ClusterRoleBinding, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: resource.RoleRef.Name, Type: resource.RoleRef.Kind, }, }) for _, subject := range resource.Subjects { sd := ScopeDetails{ ClusterName: scope, // Since this is a cluster role binding, the scope is the cluster name } if subject.Namespace != "" { sd.Namespace = subject.Namespace } queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: sd.String(), Method: sdp.QueryMethod_GET, Query: subject.Name, Type: subject.Kind, }, }) } return queries, nil } var clusterRoleBindingAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ClusterRoleBinding", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, PotentialLinks: []string{"ClusterRole", "ServiceAccount", "User", "Group"}, DescriptiveName: "Cluster Role Binding", SupportedQueryMethods: DefaultSupportedQueryMethods("Cluster Role Binding"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_cluster_role_binding_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_cluster_role_binding.metadata[0].name", }, }, }) func newClusterRoleBindingAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.ClusterRoleBinding, *v1.ClusterRoleBindingList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "ClusterRoleBinding", ClusterInterfaceBuilder: func() ItemInterface[*v1.ClusterRoleBinding, *v1.ClusterRoleBindingList] { return cs.RbacV1().ClusterRoleBindings() }, ListExtractor: func(list *v1.ClusterRoleBindingList) ([]*v1.ClusterRoleBinding, error) { bindings := make([]*v1.ClusterRoleBinding, len(list.Items)) for i := range list.Items { bindings[i] = &list.Items[i] } return bindings, nil }, LinkedItemQueryExtractor: clusterRoleBindingExtractor, AdapterMetadata: clusterRoleBindingAdapterMetadata, } } func init() { registerAdapterLoader(newClusterRoleBindingAdapter) } ================================================ FILE: k8s-source/adapters/clusterrolebinding_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var clusterRoleBindingYAML = ` apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: admin-binding subjects: - kind: Group name: system:serviceaccounts:default apiGroup: rbac.authorization.k8s.io roleRef: kind: ClusterRole name: admin apiGroup: rbac.authorization.k8s.io ` func TestClusterRoleBindingAdapter(t *testing.T) { adapter := newClusterRoleBindingAdapter(CurrentCluster.ClientSet, CurrentCluster.Name, []string{}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "admin-binding", GetScope: CurrentCluster.Name, SetupYAML: clusterRoleBindingYAML, GetQueryTests: QueryTests{ { ExpectedType: "ClusterRole", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "admin", ExpectedScope: CurrentCluster.Name, }, { ExpectedType: "Group", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "system:serviceaccounts:default", ExpectedScope: CurrentCluster.Name, }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/configmap.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func newConfigMapAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.ConfigMap, *v1.ConfigMapList]{ ClusterName: cluster, Namespaces: namespaces, TypeName: "ConfigMap", AutoQueryExtract: true, cache: cache, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.ConfigMap, *v1.ConfigMapList] { return cs.CoreV1().ConfigMaps(namespace) }, ListExtractor: func(list *v1.ConfigMapList) ([]*v1.ConfigMap, error) { bindings := make([]*v1.ConfigMap, len(list.Items)) for i := range list.Items { bindings[i] = &list.Items[i] } return bindings, nil }, AdapterMetadata: configMapAdapterMetadata, } } var configMapAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ConfigMap", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, DescriptiveName: "Config Map", SupportedQueryMethods: DefaultSupportedQueryMethods("Config Map"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_config_map_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_config_map.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newConfigMapAdapter) } ================================================ FILE: k8s-source/adapters/configmap_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var configMapYAML = ` apiVersion: v1 kind: ConfigMap metadata: name: my-configmap data: DATABASE_URL: "postgres://myuser:mypassword@mydbhost:5432/mydatabase" APP_SECRET_KEY: "mysecretkey123" ` var configMapWithS3ARNYAML = ` apiVersion: v1 kind: ConfigMap metadata: name: configmap-with-s3-arn data: S3_BUCKET_ARN: "arn:aws:s3:::example-bucket-name" S3_BUCKET_NAME: "example-bucket-name" ` func TestConfigMapAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newConfigMapAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "my-configmap", GetScope: sd.String(), SetupYAML: configMapYAML, GetQueryTests: QueryTests{}, } st.Execute(t) } func TestConfigMapAdapterWithS3ARN(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newConfigMapAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "configmap-with-s3-arn", GetScope: sd.String(), SetupYAML: configMapWithS3ARNYAML, GetQueryTests: QueryTests{ { ExpectedType: "s3-bucket", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "arn:aws:s3:::example-bucket-name", ExpectedScope: sdp.WILDCARD, }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/cronjob.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/batch/v1" "k8s.io/client-go/kubernetes" ) func newCronJobAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.CronJob, *v1.CronJobList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "CronJob", AutoQueryExtract: true, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.CronJob, *v1.CronJobList] { return cs.BatchV1().CronJobs(namespace) }, ListExtractor: func(list *v1.CronJobList) ([]*v1.CronJob, error) { bindings := make([]*v1.CronJob, len(list.Items)) for i := range list.Items { bindings[i] = &list.Items[i] } return bindings, nil }, // Cronjobs don't need linked items as the jobs they produce are linked // automatically AdapterMetadata: cronJobAdapterMetadata, } } var cronJobAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "CronJob", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, DescriptiveName: "Cron Job", SupportedQueryMethods: DefaultSupportedQueryMethods("Cron Job"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_cron_job_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_cron_job.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newCronJobAdapter) } ================================================ FILE: k8s-source/adapters/cronjob_test.go ================================================ package adapters import ( "context" "testing" "time" "github.com/overmindtech/cli/go/sdpcache" ) var cronJobYAML = ` apiVersion: batch/v1 kind: CronJob metadata: name: my-cronjob spec: schedule: "* * * * *" jobTemplate: spec: template: spec: containers: - name: my-container image: alpine command: ["/bin/sh", "-c"] args: - sleep 10; echo "Hello, world!" restartPolicy: OnFailure ` func TestCronJobAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newCronJobAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "my-cronjob", GetScope: sd.String(), SetupYAML: cronJobYAML, GetQueryTests: QueryTests{}, } st.Execute(t) // Additionally, make sure that the job has a link back to the cronjob that // created it jobAdapter := newJobAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) // Wait for the CronJob controller to spawn a Job. The schedule is // "* * * * *" (once per minute), so in the worst case we wait just over // 60 seconds. 120 s gives comfortable headroom and avoids flakes. err := WaitFor(120*time.Second, func() bool { jobs, err := jobAdapter.List(context.Background(), sd.String(), false) if err != nil { t.Fatal(err) return false } // Ensure that the job has a link back to the cronjob for _, job := range jobs { for _, q := range job.GetLinkedItemQueries() { if q.GetQuery() != nil { if q.GetQuery().GetQuery() == "my-cronjob" { return true } } } } return false }) if err != nil { t.Fatal(err) } } ================================================ FILE: k8s-source/adapters/daemonset.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/apps/v1" "k8s.io/client-go/kubernetes" ) func newDaemonSetAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.DaemonSet, *v1.DaemonSetList]{ ClusterName: cluster, Namespaces: namespaces, TypeName: "DaemonSet", AutoQueryExtract: true, cache: cache, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.DaemonSet, *v1.DaemonSetList] { return cs.AppsV1().DaemonSets(namespace) }, ListExtractor: func(list *v1.DaemonSetList) ([]*v1.DaemonSet, error) { extracted := make([]*v1.DaemonSet, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, // Pods are linked automatically AdapterMetadata: daemonSetAdapterMetadata, } } var daemonSetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "DaemonSet", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, DescriptiveName: "Daemon Set", SupportedQueryMethods: DefaultSupportedQueryMethods("Daemon Set"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_daemon_set_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_daemonset.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newDaemonSetAdapter) } ================================================ FILE: k8s-source/adapters/daemonset_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var daemonSetYAML = ` apiVersion: apps/v1 kind: DaemonSet metadata: name: my-daemonset spec: selector: matchLabels: app: my-app template: metadata: labels: app: my-app spec: containers: - name: my-container image: nginx:latest ports: - containerPort: 80 ` func TestDaemonSetSource(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newDaemonSetAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "my-daemonset", GetScope: sd.String(), SetupYAML: daemonSetYAML, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/deployment.go ================================================ package adapters import ( "regexp" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/apps/v1" "k8s.io/client-go/kubernetes" ) var replicaSetProgressedRegex = regexp.MustCompile(`ReplicaSet "([^"]+)" has successfully progressed`) func newDeploymentAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Deployment, *v1.DeploymentList]{ ClusterName: cluster, Namespaces: namespaces, TypeName: "Deployment", AutoQueryExtract: true, cache: cache, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Deployment, *v1.DeploymentList] { return cs.AppsV1().Deployments(namespace) }, ListExtractor: func(list *v1.DeploymentList) ([]*v1.Deployment, error) { extracted := make([]*v1.Deployment, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: func(deployment *v1.Deployment, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) for _, condition := range deployment.Status.Conditions { // Parse out conditions that mention replica sets e.g. // // - lastTransitionTime: "2023-06-16T14:23:33Z" // lastUpdateTime: "2023-09-15T13:07:07Z" // message: ReplicaSet "gateway-5cf5578d94" has successfully progressed. // reason: NewReplicaSetAvailable // status: "True" // type: Progressing if condition.Type == v1.DeploymentProgressing && condition.Reason == "NewReplicaSetAvailable" { matches := replicaSetProgressedRegex.FindStringSubmatch(condition.Message) if len(matches) > 1 { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ReplicaSet", Method: sdp.QueryMethod_GET, Query: matches[1], Scope: scope, }, }) } } } return queries, nil }, HealthExtractor: func(deployment *v1.Deployment) *sdp.Health { conditions := map[v1.DeploymentConditionType]bool{ v1.DeploymentAvailable: false, v1.DeploymentProgressing: false, v1.DeploymentReplicaFailure: false, } for _, condition := range deployment.Status.Conditions { // Extract the condition conditions[condition.Type] = condition.Status == "True" } // If there is a replica failure, the deployment is unhealthy if conditions[v1.DeploymentReplicaFailure] { return sdp.Health_HEALTH_ERROR.Enum() } // If the deployment is available then it's healthy if conditions[v1.DeploymentAvailable] { return sdp.Health_HEALTH_OK.Enum() } // If the deployment is progressing (but not healthy) then it's // pending if conditions[v1.DeploymentProgressing] { return sdp.Health_HEALTH_PENDING.Enum() } // We should never reach here return sdp.Health_HEALTH_UNKNOWN.Enum() }, AdapterMetadata: deploymentAdapterMetadata, } } var deploymentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Deployment", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, PotentialLinks: []string{"ReplicaSet"}, SupportedQueryMethods: DefaultSupportedQueryMethods("Deployment"), DescriptiveName: "Deployment", TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_deployment_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_deployment.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newDeploymentAdapter) } ================================================ FILE: k8s-source/adapters/deployment_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var deploymentYAML = ` apiVersion: apps/v1 kind: Deployment metadata: name: my-deployment spec: replicas: 1 selector: matchLabels: app: my-deployment template: metadata: labels: app: my-deployment spec: containers: - name: my-container image: nginx:latest ports: - containerPort: 80 ` func TestDeploymentSource(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newDeploymentAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "my-deployment", GetScope: sd.String(), SetupYAML: deploymentYAML, Wait: func(item *sdp.Item) bool { return item.GetHealth() == sdp.Health_HEALTH_OK }, GetQueryTests: QueryTests{ { ExpectedType: "ReplicaSet", ExpectedMethod: sdp.QueryMethod_GET, ExpectedScope: "local-tests.default", ExpectedQueryMatches: regexp.MustCompile("my-deployment"), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/endpoints.go ================================================ // This adapter uses the deprecated core/v1.Endpoints API intentionally. // // We use the latest K8s SDK version but balance that against supporting as many // Kubernetes versions as possible. Older clusters may not have the // discoveryv1.EndpointSlice API, so we retain this adapter for backward // compatibility. The staticcheck lint exceptions below are therefore expected // and acceptable. When the SDK eventually drops support for v1.Endpoints we // will need to split out version-specific builds of the k8s-source. package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func EndpointsExtractor(resource *v1.Endpoints, scope string) ([]*sdp.LinkedItemQuery, error) { //nolint:staticcheck,nolintlint // SA1019: v1.Endpoints deprecated; see note at top of file queries := make([]*sdp.LinkedItemQuery, 0) sd, err := ParseScope(scope, true) if err != nil { return nil, err } for _, subset := range resource.Subsets { for _, address := range subset.Addresses { if address.Hostname != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: "global", Method: sdp.QueryMethod_GET, Query: address.Hostname, Type: "dns", }, }) } if address.NodeName != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Node", Scope: sd.ClusterName, Method: sdp.QueryMethod_GET, Query: *address.NodeName, }, }) } if address.IP != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: address.IP, Scope: "global", }, }) } if address.TargetRef != nil { queries = append(queries, ObjectReferenceToQuery(address.TargetRef, sd)) } } } return queries, nil } func newEndpointsAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Endpoints, *v1.EndpointsList]{ //nolint:staticcheck,nolintlint // SA1019: v1.Endpoints deprecated; see note at top of file ClusterName: cluster, Namespaces: namespaces, TypeName: "Endpoints", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Endpoints, *v1.EndpointsList] { //nolint:staticcheck,nolintlint // SA1019 return cs.CoreV1().Endpoints(namespace) }, ListExtractor: func(list *v1.EndpointsList) ([]*v1.Endpoints, error) { //nolint:staticcheck,nolintlint // SA1019 extracted := make([]*v1.Endpoints, len(list.Items)) //nolint:staticcheck,nolintlint // SA1019 for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: EndpointsExtractor, AdapterMetadata: endpointsAdapterMetadata, cache: cache, } } var endpointsAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "Endpoints", Type: "Endpoints", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, SupportedQueryMethods: DefaultSupportedQueryMethods("Endpoints"), PotentialLinks: []string{"Node", "ip", "Pod", "ExternalName", "DNS"}, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_endpoints.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_endpoints_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newEndpointsAdapter) } ================================================ FILE: k8s-source/adapters/endpoints_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var endpointsYAML = ` apiVersion: apps/v1 kind: Deployment metadata: name: endpoint-deployment spec: replicas: 1 selector: matchLabels: app: endpoint-test template: metadata: labels: app: endpoint-test spec: containers: - name: endpoint-test image: nginx:latest ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: endpoint-service spec: selector: app: endpoint-test ports: - name: http port: 80 targetPort: 80 type: ClusterIP ` func TestEndpointsAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newEndpointsAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "endpoint-service", GetScope: sd.String(), SetupYAML: endpointsYAML, GetQueryTests: QueryTests{ { ExpectedQueryMatches: regexp.MustCompile(`^10\.`), ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedScope: "global", }, { ExpectedType: "Node", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "local-tests-control-plane", ExpectedScope: CurrentCluster.Name, }, { ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQueryMatches: regexp.MustCompile("endpoint-deployment"), ExpectedScope: sd.String(), }, }, Wait: func(item *sdp.Item) bool { return len(item.GetLinkedItemQueries()) > 0 }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/endpointslice.go ================================================ package adapters import ( "time" v1 "k8s.io/api/discovery/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func endpointSliceExtractor(resource *v1.EndpointSlice, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) sd, err := ParseScope(scope, true) if err != nil { return nil, err } if serviceName, ok := resource.Labels["kubernetes.io/service-name"]; ok && serviceName != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Service", Method: sdp.QueryMethod_GET, Query: serviceName, Scope: scope, }, }) } for _, endpoint := range resource.Endpoints { if endpoint.Hostname != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *endpoint.Hostname, Scope: "global", }, }) } if endpoint.NodeName != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Node", Method: sdp.QueryMethod_GET, Query: *endpoint.NodeName, Scope: sd.ClusterName, }, }) } if endpoint.TargetRef != nil { queries = append(queries, ObjectReferenceToQuery(endpoint.TargetRef, sd)) } for _, address := range endpoint.Addresses { switch resource.AddressType { case v1.AddressTypeIPv4, v1.AddressTypeIPv6: queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: address, Scope: "global", }, }) case v1.AddressTypeFQDN: queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: address, Scope: "global", }, }) } } } return queries, nil } func newEndpointSliceAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.EndpointSlice, *v1.EndpointSliceList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "EndpointSlice", CacheDuration: 1 * time.Minute, // very low since this changes a lot NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.EndpointSlice, *v1.EndpointSliceList] { return cs.DiscoveryV1().EndpointSlices(namespace) }, ListExtractor: func(list *v1.EndpointSliceList) ([]*v1.EndpointSlice, error) { extracted := make([]*v1.EndpointSlice, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: endpointSliceExtractor, AdapterMetadata: endpointSliceAdapterMetadata, } } var endpointSliceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "EndpointSlice", DescriptiveName: "Endpoint Slice", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"Node", "Pod", "dns", "ip", "Service"}, SupportedQueryMethods: DefaultSupportedQueryMethods("EndpointSlice"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_endpoints_slice_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_endpoints_slice.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newEndpointSliceAdapter) } ================================================ FILE: k8s-source/adapters/endpointslice_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var endpointSliceYAML = ` apiVersion: apps/v1 kind: Deployment metadata: name: endpointslice-deployment spec: replicas: 1 selector: matchLabels: app: endpointslice-test template: metadata: labels: app: endpointslice-test spec: containers: - name: endpointslice-test image: nginx:latest ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: endpointslice-service spec: selector: app: endpointslice-test ports: - name: http port: 80 targetPort: 80 type: ClusterIP ` func TestEndpointSliceAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newEndpointSliceAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQueryRegexp: regexp.MustCompile("endpoint-service"), GetScope: sd.String(), SetupYAML: endpointSliceYAML, GetQueryTests: QueryTests{ { ExpectedType: "Service", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "endpointslice-service", ExpectedScope: sd.String(), }, { ExpectedQueryMatches: regexp.MustCompile(`^10\.`), ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedScope: "global", }, { ExpectedType: "Node", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "local-tests-control-plane", ExpectedScope: CurrentCluster.Name, }, { ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQueryMatches: regexp.MustCompile("endpoint-deployment"), ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/generic_source.go ================================================ package adapters import ( "context" "errors" "fmt" "slices" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" corev1 "k8s.io/api/core/v1" k8serr "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const DefaultCacheDuration = 30 * time.Minute // NamespacedInterfaceBuilder The function that create a client to query a // namespaced resource. e.g. `CoreV1().Pods` type NamespacedInterfaceBuilder[Resource metav1.Object, ResourceList any] func(namespace string) ItemInterface[Resource, ResourceList] // ClusterInterfaceBuilder The function that create a client to query a // cluster-wide resource. e.g. `CoreV1().Nodes` type ClusterInterfaceBuilder[Resource metav1.Object, ResourceList any] func() ItemInterface[Resource, ResourceList] // ItemInterface An interface that matches the `Get` and `List` methods for K8s // resources since these are the ones that we use for getting Overmind data. // Kube's clients are usually namespaced when they are created, so this // interface is expected to only returns items from a single namespace type ItemInterface[Resource metav1.Object, ResourceList any] interface { Get(ctx context.Context, name string, opts metav1.GetOptions) (Resource, error) List(ctx context.Context, opts metav1.ListOptions) (ResourceList, error) } type KubeTypeAdapter[Resource metav1.Object, ResourceList any] struct { // The function that creates a client to query a namespaced resource. e.g. // `CoreV1().Pods`. Either this or `NamespacedInterfaceBuilder` must be // specified ClusterInterfaceBuilder ClusterInterfaceBuilder[Resource, ResourceList] // The function that creates a client to query a cluster-wide resource. e.g. // `CoreV1().Nodes`. Either this or `ClusterInterfaceBuilder` must be // specified NamespacedInterfaceBuilder NamespacedInterfaceBuilder[Resource, ResourceList] // A function that extracts a slice of Resources from a ResourceList ListExtractor func(ResourceList) ([]Resource, error) // A function that returns a list of linked item queries for a given // resource and scope LinkedItemQueryExtractor func(resource Resource, scope string) ([]*sdp.LinkedItemQuery, error) // A function that extracts health from the resource, this is optional HealthExtractor func(resource Resource) *sdp.Health // A function that redacts sensitive data from the resource, this is // optional Redact func(resource Resource) Resource // Whether to automatically extract the query from the item's attributes. // This should be enabled for resources that are likely to include // unstructured but interesting data like environment variables AutoQueryExtract bool // The type of items that this adapter should return. This should be the // "Kind" of the kubernetes resources, e.g. "Pod", "Node", "ServiceAccount" TypeName string // List of namespaces that this adapter should query Namespaces []string // The name of the cluster that this adapter is for. This is used to generate // scopes ClusterName string // AdapterMetadata for the adapter AdapterMetadata *sdp.AdapterMetadata CacheDuration time.Duration // How long to cache items for cache sdpcache.Cache // This is mandatory } func (s *KubeTypeAdapter[Resource, ResourceList]) cacheDuration() time.Duration { if s.CacheDuration == 0 { return DefaultCacheDuration } return s.CacheDuration } // validate Validates that the adapter is correctly set up func (s *KubeTypeAdapter[Resource, ResourceList]) Validate() error { if s.NamespacedInterfaceBuilder == nil && s.ClusterInterfaceBuilder == nil { return errors.New("either NamespacedInterfaceBuilder or ClusterInterfaceBuilder must be specified") } if s.ListExtractor == nil { return errors.New("listExtractor must be specified") } if s.TypeName == "" { return errors.New("typeName must be specified") } if s.namespaced() && len(s.Namespaces) == 0 { return errors.New("namespaces must be specified when NamespacedInterfaceBuilder is specified") } if s.ClusterName == "" { return errors.New("clusterName must be specified") } return nil } // namespaced Returns whether the adapter is namespaced or not func (s *KubeTypeAdapter[Resource, ResourceList]) namespaced() bool { return s.NamespacedInterfaceBuilder != nil } func (s *KubeTypeAdapter[Resource, ResourceList]) Type() string { return s.TypeName } func (s *KubeTypeAdapter[Resource, ResourceList]) Metadata() *sdp.AdapterMetadata { return s.AdapterMetadata } func (s *KubeTypeAdapter[Resource, ResourceList]) Name() string { return fmt.Sprintf("k8s-%v", s.TypeName) } func (s *KubeTypeAdapter[Resource, ResourceList]) Weight() int { return 10 } func (s *KubeTypeAdapter[Resource, ResourceList]) Scopes() []string { namespaces := make([]string, 0) if s.namespaced() { for _, ns := range s.Namespaces { sd := ScopeDetails{ ClusterName: s.ClusterName, Namespace: ns, } namespaces = append(namespaces, sd.String()) } } else { sd := ScopeDetails{ ClusterName: s.ClusterName, } namespaces = append(namespaces, sd.String()) } return namespaces } func (s *KubeTypeAdapter[Resource, ResourceList]) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() cacheHit, ck, cachedItems, qErr, done = s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) > 0 { return cachedItems[0], nil } else { return nil, nil } } i, err := s.itemInterface(scope) if err != nil { err = &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) return nil, err } resource, err := i.Get(ctx, query, metav1.GetOptions{}) if err != nil { statusErr := new(k8serr.StatusError) if errors.As(err, &statusErr) && statusErr.ErrStatus.Code == 404 { err = &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: statusErr.ErrStatus.Message, } } s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) return nil, err } item, err := s.resourceToItem(resource) if err != nil { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) return nil, err } s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) return item, nil } func (s *KubeTypeAdapter[Resource, ResourceList]) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() cacheHit, ck, cachedItems, qErr, done = s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.Type(), "", ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { return cachedItems, nil } items, err := s.listWithOptions(ctx, scope, metav1.ListOptions{}) if err != nil { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) return nil, err } for _, item := range items { s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) } return items, nil } // listWithOptions Runs the inbuilt list method with the given options func (s *KubeTypeAdapter[Resource, ResourceList]) listWithOptions(ctx context.Context, scope string, opts metav1.ListOptions) ([]*sdp.Item, error) { i, err := s.itemInterface(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } list, err := i.List(ctx, opts) if err != nil { return nil, err } resourceList, err := s.ListExtractor(list) if err != nil { return nil, err } items, err := s.resourcesToItems(resourceList) if err != nil { return nil, err } return items, nil } func (s *KubeTypeAdapter[Resource, ResourceList]) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { opts, err := QueryToListOptions(query) if err != nil { return nil, err } ck := sdpcache.CacheKeyFromParts(s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query) items, err := s.listWithOptions(ctx, scope, opts) if err != nil { s.cache.StoreUnavailableItem(ctx, err, s.cacheDuration(), ck) return nil, err } for _, item := range items { s.cache.StoreItem(ctx, item, s.cacheDuration(), ck) } return items, nil } // itemInterface Returns the correct interface depending on whether the adapter // is namespaced or not func (s *KubeTypeAdapter[Resource, ResourceList]) itemInterface(scope string) (ItemInterface[Resource, ResourceList], error) { // If this is a namespaced resource, then parse the scope to get the // namespace if s.namespaced() { details, err := ParseScope(scope, s.namespaced()) if err != nil { return nil, err } return s.NamespacedInterfaceBuilder(details.Namespace), nil } else { return s.ClusterInterfaceBuilder(), nil } } var ignoredMetadataFields = []string{ "managedFields", "binaryData", "immutable", "stringData", } func ignored(key string) bool { return slices.Contains(ignoredMetadataFields, key) } // resourcesToItems Converts a slice of resources to a slice of items func (s *KubeTypeAdapter[Resource, ResourceList]) resourcesToItems(resourceList []Resource) ([]*sdp.Item, error) { items := make([]*sdp.Item, len(resourceList)) var err error for i := range resourceList { items[i], err = s.resourceToItem(resourceList[i]) if err != nil { return nil, err } } return items, nil } // resourceToItem Converts a resource to an item func (s *KubeTypeAdapter[Resource, ResourceList]) resourceToItem(resource Resource) (*sdp.Item, error) { sd := ScopeDetails{ ClusterName: s.ClusterName, Namespace: resource.GetNamespace(), } // Redact sensitive data if required if s.Redact != nil { resource = s.Redact(resource) } attributes, err := sdp.ToAttributesViaJson(resource) if err != nil { return nil, err } // Promote the metadata to the top level if metadata, err := attributes.Get("metadata"); err == nil { // Cast to a type we can iterate over if metadataMap, ok := metadata.(map[string]any); ok { for key, value := range metadataMap { // Check that the key isn't in the ignored list if !ignored(key) { attributes.Set(key, value) } } } // Remove the metadata attribute delete(attributes.GetAttrStruct().GetFields(), "metadata") } // Make sure the name is set attributes.Set("name", resource.GetName()) item := &sdp.Item{ Type: s.TypeName, UniqueAttribute: "name", Scope: sd.String(), Attributes: attributes, Tags: resource.GetLabels(), } // Automatically create links to owner references for _, ref := range resource.GetOwnerReferences() { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ref.Kind, Method: sdp.QueryMethod_GET, Query: ref.Name, Scope: sd.String(), }, }) } if s.LinkedItemQueryExtractor != nil { // Add linked items newQueries, err := s.LinkedItemQueryExtractor(resource, sd.String()) if err != nil { return nil, err } item.LinkedItemQueries = append(item.LinkedItemQueries, newQueries...) } if s.AutoQueryExtract { // Automatically extract queries from the item's attributes item.LinkedItemQueries = append(item.LinkedItemQueries, sdp.ExtractLinksFromAttributes(attributes)...) } if s.HealthExtractor != nil { item.Health = s.HealthExtractor(resource) } return item, nil } // ObjectReferenceToQuery Converts a K8s ObjectReference to a linked item // request. Note that you must provide the parent scope since the reference // could be an object in a different namespace, if it is we need to re-use the // cluster name from the parent scope func ObjectReferenceToQuery(ref *corev1.ObjectReference, parentScope ScopeDetails) *sdp.LinkedItemQuery { if ref == nil { return nil } // Update the namespace, but keep the cluster the same parentScope.Namespace = ref.Namespace return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ref.Kind, Method: sdp.QueryMethod_GET, // Object references are to a specific object Query: ref.Name, Scope: parentScope.String(), }, } } // Returns the default supported query methods for a given resource type. The // user must pass in the name of the resource type e.g. "Config Map" func DefaultSupportedQueryMethods(name string) *sdp.AdapterSupportedQueryMethods { return &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: fmt.Sprintf("Get a %v by name", name), List: true, ListDescription: fmt.Sprintf("List all %vs", name), Search: true, SearchDescription: fmt.Sprintf(`Search for a %v using the ListOptions JSON format e.g. {"labelSelector": "app=wordpress"}`, name), } } ================================================ FILE: k8s-source/adapters/generic_source_test.go ================================================ package adapters import ( "context" "errors" "fmt" "regexp" "testing" "time" "github.com/google/uuid" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) type PodClient struct { GetError error ListError error } func (p PodClient) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1.Pod, error) { if p.GetError != nil { return nil, p.GetError } uid := uuid.NewString() return &v1.Pod{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", UID: types.UID(uid), ResourceVersion: "9164", CreationTimestamp: metav1.NewTime(time.Now()), }, Spec: v1.PodSpec{ Volumes: []v1.Volume{ { Name: "kube-api-access-hgq4d", }, }, RestartPolicy: "Always", DNSPolicy: "ClusterFirst", ServiceAccountName: "default", NodeName: "minikube", Containers: []v1.Container{ { Env: []v1.EnvVar{ { Name: "INTERESTING_URL", Value: "http://example.com", }, }, }, }, }, Status: v1.PodStatus{ Phase: "Running", HostIP: "10.0.0.1", PodIP: "10.244.0.6", }, }, nil } func (p PodClient) List(ctx context.Context, opts metav1.ListOptions) (*v1.PodList, error) { if p.ListError != nil { return nil, p.ListError } uid := uuid.NewString() return &v1.PodList{ Items: []v1.Pod{ { TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{ Name: "foo", Namespace: "default", UID: types.UID(uid), ResourceVersion: "9164", CreationTimestamp: metav1.NewTime(time.Now()), }, Spec: v1.PodSpec{ Volumes: []v1.Volume{ { Name: "kube-api-access-hgq4d", }, }, RestartPolicy: "Always", DNSPolicy: "ClusterFirst", ServiceAccountName: "default", NodeName: "minikube", }, Status: v1.PodStatus{ Phase: "Running", HostIP: "10.0.0.1", PodIP: "10.244.0.6", }, }, { TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{ Name: "bar", Namespace: "default", UID: types.UID(uid), ResourceVersion: "9164", CreationTimestamp: metav1.NewTime(time.Now()), }, Spec: v1.PodSpec{ Volumes: []v1.Volume{ { Name: "kube-api-access-c43w1", }, }, RestartPolicy: "Always", DNSPolicy: "ClusterFirst", ServiceAccountName: "default", NodeName: "minikube", }, Status: v1.PodStatus{ Phase: "Running", HostIP: "10.0.0.1", PodIP: "10.244.0.7", }, }, }, }, nil } func createAdapter(namespaced bool) *KubeTypeAdapter[*v1.Pod, *v1.PodList] { var clusterInterfaceBuilder ClusterInterfaceBuilder[*v1.Pod, *v1.PodList] var namespacedInterfaceBuilder NamespacedInterfaceBuilder[*v1.Pod, *v1.PodList] if namespaced { namespacedInterfaceBuilder = func(namespace string) ItemInterface[*v1.Pod, *v1.PodList] { return PodClient{} } } else { clusterInterfaceBuilder = func() ItemInterface[*v1.Pod, *v1.PodList] { return PodClient{} } } return &KubeTypeAdapter[*v1.Pod, *v1.PodList]{ ClusterInterfaceBuilder: clusterInterfaceBuilder, NamespacedInterfaceBuilder: namespacedInterfaceBuilder, ListExtractor: func(p *v1.PodList) ([]*v1.Pod, error) { pods := make([]*v1.Pod, len(p.Items)) for i := range p.Items { pods[i] = &p.Items[i] } return pods, nil }, LinkedItemQueryExtractor: func(p *v1.Pod, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if p.Spec.NodeName == "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "node", Method: sdp.QueryMethod_GET, Query: p.Spec.NodeName, Scope: scope, }, }) } return queries, nil }, HealthExtractor: func(resource *v1.Pod) *sdp.Health { return sdp.Health_HEALTH_OK.Enum() }, AutoQueryExtract: true, TypeName: "Pod", ClusterName: "minikube", Namespaces: []string{"default", "app1"}, cache: sdpcache.NewNoOpCache(), } } func TestAdapterValidate(t *testing.T) { t.Run("fully populated adapter", func(t *testing.T) { t.Parallel() adapter := createAdapter(false) err := adapter.Validate() if err != nil { t.Errorf("expected no error, got %s", err) } }) t.Run("missing ClusterInterfaceBuilder", func(t *testing.T) { t.Parallel() adapter := createAdapter(false) adapter.ClusterInterfaceBuilder = nil err := adapter.Validate() if err == nil { t.Errorf("expected error, got none") } }) t.Run("missing ListExtractor", func(t *testing.T) { t.Parallel() adapter := createAdapter(false) adapter.ListExtractor = nil err := adapter.Validate() if err == nil { t.Errorf("expected error, got none") } }) t.Run("missing TypeName", func(t *testing.T) { t.Parallel() adapter := createAdapter(false) adapter.TypeName = "" err := adapter.Validate() if err == nil { t.Errorf("expected error, got none") } }) t.Run("missing ClusterName", func(t *testing.T) { t.Parallel() adapter := createAdapter(false) adapter.ClusterName = "" err := adapter.Validate() if err == nil { t.Errorf("expected error, got none") } }) t.Run("missing namespaces", func(t *testing.T) { t.Run("when namespaced", func(t *testing.T) { t.Parallel() adapter := createAdapter(true) adapter.Namespaces = nil err := adapter.Validate() if err == nil { t.Errorf("expected error, got none") } adapter.Namespaces = []string{} err = adapter.Validate() if err == nil { t.Errorf("expected error, got none") } }) t.Run("when not namespaced", func(t *testing.T) { t.Parallel() adapter := createAdapter(false) adapter.Namespaces = nil err := adapter.Validate() if err != nil { t.Errorf("expected no error, got %s", err) } adapter.Namespaces = []string{} err = adapter.Validate() if err != nil { t.Errorf("expected no error, got %s", err) } }) }) } func TestType(t *testing.T) { adapter := createAdapter(false) if adapter.Type() != "Pod" { t.Errorf("expected type 'Pod', got %s", adapter.Type()) } } func TestName(t *testing.T) { adapter := createAdapter(false) if adapter.Name() == "" { t.Errorf("expected non-empty name, got none") } } func TestScopes(t *testing.T) { t.Run("when namespaced", func(t *testing.T) { adapter := createAdapter(true) if len(adapter.Scopes()) != len(adapter.Namespaces) { t.Errorf("expected %d scopes, got %d", len(adapter.Namespaces), len(adapter.Scopes())) } }) t.Run("when not namespaced", func(t *testing.T) { adapter := createAdapter(false) if len(adapter.Scopes()) != 1 { t.Errorf("expected 1 scope, got %d", len(adapter.Scopes())) } }) } func TestAdapterGet(t *testing.T) { t.Run("get existing item", func(t *testing.T) { adapter := createAdapter(false) item, err := adapter.Get(context.Background(), "foo", "example", false) if err != nil { t.Errorf("expected no error, got %s", err) } if item == nil { t.Errorf("expected item, got none") } if item.UniqueAttributeValue() != "example" { t.Errorf("expected item with unique attribute value 'example', got %s", item.UniqueAttributeValue()) } if item.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("expected item with health HEALTH_OK, got %s", item.GetHealth()) } if item.GetType() != "Pod" { t.Errorf("expected item with type Pod, got %s", item.GetType()) } var foundAutomaticLink bool for _, q := range item.GetLinkedItemQueries() { if q.GetQuery().GetType() == "http" && q.GetQuery().GetQuery() == "http://example.com" { foundAutomaticLink = true break } } if !foundAutomaticLink { t.Errorf("expected automatic link to http://example.com, got none") } }) t.Run("get non-existent item", func(t *testing.T) { adapter := createAdapter(false) adapter.ClusterInterfaceBuilder = func() ItemInterface[*v1.Pod, *v1.PodList] { return PodClient{ GetError: &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not found", }, } } _, err := adapter.Get(context.Background(), "foo", "example", false) if err == nil { t.Errorf("expected error, got none") } }) } func TestFailingQueryExtractor(t *testing.T) { adapter := createAdapter(false) adapter.LinkedItemQueryExtractor = func(_ *v1.Pod, _ string) ([]*sdp.LinkedItemQuery, error) { return nil, errors.New("failed to extract queries") } _, err := adapter.Get(context.Background(), "foo", "example", false) if err == nil { t.Errorf("expected error, got none") } } func TestList(t *testing.T) { t.Run("when namespaced", func(t *testing.T) { adapter := createAdapter(true) items, err := adapter.List(context.Background(), "foo.bar", false) if err != nil { t.Errorf("expected no error, got %s", err) } if len(items) != 2 { t.Errorf("expected 2 items, got %d", len(items)) } if items[0].GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("expected item with health HEALTH_OK, got %s", items[0].GetHealth()) } }) t.Run("when not namespaced", func(t *testing.T) { adapter := createAdapter(false) items, err := adapter.List(context.Background(), "foo", false) if err != nil { t.Errorf("expected no error, got %s", err) } if len(items) != 2 { t.Errorf("expected 2 items, got %d", len(items)) } }) t.Run("with failing list extractor", func(t *testing.T) { adapter := createAdapter(false) adapter.ListExtractor = func(_ *v1.PodList) ([]*v1.Pod, error) { return nil, errors.New("failed to extract list") } _, err := adapter.List(context.Background(), "foo", false) if err == nil { t.Errorf("expected error, got none") } }) t.Run("with failing query extractor", func(t *testing.T) { adapter := createAdapter(false) adapter.LinkedItemQueryExtractor = func(_ *v1.Pod, _ string) ([]*sdp.LinkedItemQuery, error) { return nil, errors.New("failed to extract queries") } _, err := adapter.List(context.Background(), "foo", false) if err == nil { t.Errorf("expected error, got none") } }) } func TestSearch(t *testing.T) { t.Run("with a valid query", func(t *testing.T) { adapter := createAdapter(false) items, err := adapter.Search(context.Background(), "foo", "{\"labelSelector\":\"app=foo\"}", false) if err != nil { t.Errorf("expected no error, got %s", err) } if len(items) != 2 { t.Errorf("expected 2 item, got %d", len(items)) } }) t.Run("with an invalid query", func(t *testing.T) { adapter := createAdapter(false) _, err := adapter.Search(context.Background(), "foo", "{{{{}", false) if err == nil { t.Errorf("expected error, got none") } }) } func TestRedact(t *testing.T) { adapter := createAdapter(true) adapter.Redact = func(resource *v1.Pod) *v1.Pod { resource.Spec.Hostname = "redacted" return resource } item, err := adapter.Get(context.Background(), "cluster.namespace", "test", false) if err != nil { t.Error(err) } hostname, err := item.GetAttributes().Get("spec.hostname") if err != nil { t.Error(err) } if hostname != "redacted" { t.Errorf("expected hostname to be redacted, got %v", hostname) } } type QueryTest struct { ExpectedType string ExpectedMethod sdp.QueryMethod ExpectedQuery string ExpectedScope string // Expect the query to match a regex, this takes precedence over // ExpectedQuery ExpectedQueryMatches *regexp.Regexp } type QueryTests []QueryTest func (i QueryTests) Execute(t *testing.T, item *sdp.Item) { t.Helper() for _, test := range i { var found bool for _, lir := range item.GetLinkedItemQueries() { if lirMatches(test, lir) { found = true break } } if !found { t.Errorf("could not find linked item request in %v requests.\nType: %v\nQuery: %v\nScope: %v", len(item.GetLinkedItemQueries()), test.ExpectedType, test.ExpectedQuery, test.ExpectedScope) } } } func lirMatches(test QueryTest, req *sdp.LinkedItemQuery) bool { if req.GetQuery() != nil { if test.ExpectedMethod != req.GetQuery().GetMethod() { return false } if test.ExpectedScope != req.GetQuery().GetScope() { return false } if test.ExpectedType != req.GetQuery().GetType() { return false } if test.ExpectedQueryMatches != nil { if !test.ExpectedQueryMatches.MatchString(req.GetQuery().GetQuery()) { return false } } else { if test.ExpectedQuery != req.GetQuery().GetQuery() { return false } } } // TODO: check for blast radius differences return true } type AdapterTests struct { // The adapter under test Adapter discovery.ListableAdapter // The get query to test GetQuery string GetScope string GetQueryTests QueryTests // If this is set,. the get query is determined by running a list, then // finding the first item that matches this regexp GetQueryRegexp *regexp.Regexp // YAML to apply before testing, it will be removed after SetupYAML string // An optional function to wait to return true before running the tests. It // is passed the current item that Get tests will be run against, and should // return a boolean indicating whether the tests should continue or wait Wait func(item *sdp.Item) bool } func (s AdapterTests) Execute(t *testing.T) { t.Helper() if s.SetupYAML != "" { err := CurrentCluster.Apply(s.SetupYAML) if err != nil { t.Fatal(fmt.Errorf("failed to apply setup YAML: %w", err)) } t.Cleanup(func() { err = CurrentCluster.Delete(s.SetupYAML) if err != nil { t.Fatal(fmt.Errorf("failed to delete setup YAML: %w", err)) } }) } var getQuery string if s.GetQueryRegexp != nil { items, err := s.Adapter.List(context.Background(), s.GetScope, false) if err != nil { t.Fatal(err) } for _, item := range items { if s.GetQueryRegexp.MatchString(item.UniqueAttributeValue()) { getQuery = item.UniqueAttributeValue() break } } } else { getQuery = s.GetQuery } if s.Wait != nil { t.Log("waiting before executing tests") err := WaitFor(20*time.Second, func() bool { item, err := s.Adapter.Get(context.Background(), s.GetScope, getQuery, true) if err != nil { return false } return s.Wait(item) }) if err != nil { t.Fatalf("timed out waiting before starting tests: %v", err) } } t.Run(s.Adapter.Name(), func(t *testing.T) { if getQuery != "" { t.Run(fmt.Sprintf("GET:%v", getQuery), func(t *testing.T) { item, err := s.Adapter.Get(context.Background(), s.GetScope, getQuery, false) if err != nil { t.Fatal(err) } if item == nil { t.Errorf("expected item, got none") } if err = item.Validate(); err != nil { t.Error(err) } s.GetQueryTests.Execute(t, item) }) } t.Run("LIST", func(t *testing.T) { items, err := s.Adapter.List(context.Background(), s.GetScope, false) if err != nil { t.Fatal(err) } if len(items) == 0 { t.Errorf("expected items, got none") } itemMap := make(map[string]*sdp.Item) for _, item := range items { itemMap[item.UniqueAttributeValue()] = item if err = item.Validate(); err != nil { t.Error(err) } } if len(itemMap) != len(items) { t.Errorf("expected %v unique items, got %v", len(items), len(itemMap)) } }) t.Run("Adapter Metadata", func(t *testing.T) { metadata := s.Adapter.Metadata() if metadata == nil { t.Fatal("expected metadata, got none") } if metadata.GetType() == "" { t.Error("expected metadata type, got none") } if metadata.GetDescriptiveName() == "" { t.Error("expected metadata descriptive name, got none") } }) }) } // WaitFor waits for a condition to be true, or returns an error if the timeout func WaitFor(timeout time.Duration, run func() bool) error { start := time.Now() for { if run() { return nil } if time.Since(start) > timeout { return fmt.Errorf("timeout exceeded") } time.Sleep(250 * time.Millisecond) } } func TestObjectReferenceToQuery(t *testing.T) { t.Run("with a valid object reference", func(t *testing.T) { ref := &v1.ObjectReference{ Kind: "Pod", Namespace: "default", Name: "foo", } query := ObjectReferenceToQuery(ref, ScopeDetails{ ClusterName: "test-cluster", Namespace: "default", }) if query.GetQuery().GetType() != "Pod" { t.Errorf("expected type Pod, got %s", query.GetQuery().GetType()) } if query.GetQuery().GetQuery() != "foo" { t.Errorf("expected query to be foo, got %s", query.GetQuery().GetQuery()) } if query.GetQuery().GetScope() != "test-cluster.default" { t.Errorf("expected scope to be test-cluster.default, got %s", query.GetQuery().GetScope()) } }) t.Run("with a nil object reference", func(t *testing.T) { query := ObjectReferenceToQuery(nil, ScopeDetails{}) if query != nil { t.Errorf("expected nil query, got %v", query) } }) } ================================================ FILE: k8s-source/adapters/horizontalpodautoscaler.go ================================================ package adapters import ( v2 "k8s.io/api/autoscaling/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func horizontalPodAutoscalerExtractor(resource *v2.HorizontalPodAutoscaler, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: resource.Spec.ScaleTargetRef.Kind, Method: sdp.QueryMethod_GET, Query: resource.Spec.ScaleTargetRef.Name, Scope: scope, }, }) return queries, nil } func newHorizontalPodAutoscalerAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v2.HorizontalPodAutoscaler, *v2.HorizontalPodAutoscalerList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "HorizontalPodAutoscaler", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v2.HorizontalPodAutoscaler, *v2.HorizontalPodAutoscalerList] { return cs.AutoscalingV2().HorizontalPodAutoscalers(namespace) }, ListExtractor: func(list *v2.HorizontalPodAutoscalerList) ([]*v2.HorizontalPodAutoscaler, error) { extracted := make([]*v2.HorizontalPodAutoscaler, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: horizontalPodAutoscalerExtractor, AdapterMetadata: horizontalPodAutoscalerAdapterMetadata, } } var horizontalPodAutoscalerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "HorizontalPodAutoscaler", DescriptiveName: "Horizontal Pod Autoscaler", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: DefaultSupportedQueryMethods("Horizontal Pod Autoscaler"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_horizontal_pod_autoscaler_v2.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newHorizontalPodAutoscalerAdapter) } ================================================ FILE: k8s-source/adapters/horizontalpodautoscaler_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var horizontalPodAutoscalerYAML = ` apiVersion: apps/v1 kind: Deployment metadata: name: hpa-deployment spec: replicas: 1 selector: matchLabels: app: hpa-app template: metadata: labels: app: hpa-app spec: containers: - name: hpa-container image: nginx:latest ports: - containerPort: 80 --- apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: my-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: hpa-deployment minReplicas: 1 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 50 ` func TestHorizontalPodAutoscalerAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newHorizontalPodAutoscalerAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "my-hpa", GetScope: sd.String(), SetupYAML: horizontalPodAutoscalerYAML, GetQueryTests: QueryTests{ { ExpectedType: "Deployment", ExpectedMethod: sdp.QueryMethod_GET, ExpectedScope: sd.String(), ExpectedQuery: "hpa-deployment", }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/ingress.go ================================================ package adapters import ( v1 "k8s.io/api/networking/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func ingressExtractor(resource *v1.Ingress, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.IngressClassName != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "IngressClass", Method: sdp.QueryMethod_GET, Query: *resource.Spec.IngressClassName, Scope: scope, }, }) } if resource.Spec.DefaultBackend != nil { if resource.Spec.DefaultBackend.Service != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Service", Method: sdp.QueryMethod_GET, Query: resource.Spec.DefaultBackend.Service.Name, Scope: scope, }, }) } if linkRes := resource.Spec.DefaultBackend.Resource; linkRes != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: linkRes.Kind, Method: sdp.QueryMethod_GET, Query: linkRes.Name, Scope: scope, }, }) } } for _, rule := range resource.Spec.Rules { if rule.Host != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: rule.Host, Scope: "global", }, }) } if rule.HTTP != nil { for _, path := range rule.HTTP.Paths { if path.Backend.Service != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Service", Method: sdp.QueryMethod_GET, Query: path.Backend.Service.Name, Scope: scope, }, }) } if path.Backend.Resource != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: path.Backend.Resource.Kind, Method: sdp.QueryMethod_GET, Query: path.Backend.Resource.Name, Scope: scope, }, }) } } } } return queries, nil } func newIngressAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Ingress, *v1.IngressList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "Ingress", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Ingress, *v1.IngressList] { return cs.NetworkingV1().Ingresses(namespace) }, ListExtractor: func(list *v1.IngressList) ([]*v1.Ingress, error) { extracted := make([]*v1.Ingress, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: ingressExtractor, AdapterMetadata: ingressAdapterMetadata, } } var ingressAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Ingress", DescriptiveName: "Ingress", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"Service", "IngressClass", "dns"}, SupportedQueryMethods: DefaultSupportedQueryMethods("Ingress"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_ingress_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newIngressAdapter) } ================================================ FILE: k8s-source/adapters/ingress_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var ingressYAML = ` apiVersion: apps/v1 kind: Deployment metadata: name: ingress-app spec: replicas: 1 selector: matchLabels: app: ingress-app template: metadata: labels: app: ingress-app spec: containers: - name: ingress-app image: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: ingress-app spec: selector: app: ingress-app ports: - name: http port: 80 targetPort: 80 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: ingress-app spec: rules: - host: example.com http: paths: - path: /ingress-app pathType: Prefix backend: service: name: ingress-app port: name: http ` func TestIngressAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newIngressAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "ingress-app", GetScope: sd.String(), SetupYAML: ingressYAML, GetQueryTests: QueryTests{ { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "example.com", ExpectedScope: "global", }, { ExpectedType: "Service", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ingress-app", ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/job.go ================================================ package adapters import ( v1 "k8s.io/api/batch/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func jobExtractor(resource *v1.Job, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.Selector != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(resource.Spec.Selector), Type: "Pod", }, }) } return queries, nil } func newJobAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Job, *v1.JobList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "Job", AutoQueryExtract: true, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Job, *v1.JobList] { return cs.BatchV1().Jobs(namespace) }, ListExtractor: func(list *v1.JobList) ([]*v1.Job, error) { extracted := make([]*v1.Job, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: jobExtractor, AdapterMetadata: jobAdapterMetadata, } } var jobAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Job", DescriptiveName: "Job", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, PotentialLinks: []string{"Pod"}, SupportedQueryMethods: DefaultSupportedQueryMethods("Job"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_job.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_job_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newJobAdapter) } ================================================ FILE: k8s-source/adapters/job_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var jobYAML = ` apiVersion: batch/v1 kind: Job metadata: name: my-job spec: template: spec: containers: - name: my-container image: nginx command: ["/bin/sh", "-c"] args: - echo "Hello, world!"; sleep 5 restartPolicy: OnFailure backoffLimit: 4 --- apiVersion: batch/v1 kind: Job metadata: name: my-job2 spec: template: spec: containers: - name: my-container image: nginx command: ["/bin/sh", "-c"] args: - echo "Hello, world!"; sleep 5 restartPolicy: OnFailure backoffLimit: 4 ` func TestJobAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newJobAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "my-job", GetScope: sd.String(), SetupYAML: jobYAML, GetQueryTests: QueryTests{ { ExpectedQueryMatches: regexp.MustCompile("controller-uid="), ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/limitrange.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func newLimitRangeAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.LimitRange, *v1.LimitRangeList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "LimitRange", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.LimitRange, *v1.LimitRangeList] { return cs.CoreV1().LimitRanges(namespace) }, ListExtractor: func(list *v1.LimitRangeList) ([]*v1.LimitRange, error) { extracted := make([]*v1.LimitRange, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, AdapterMetadata: limitRangeAdapterMetadata, } } var limitRangeAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "LimitRange", DescriptiveName: "Limit Range", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: DefaultSupportedQueryMethods("Limit Range"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_limit_range_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_limit_range.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newLimitRangeAdapter) } ================================================ FILE: k8s-source/adapters/limitrange_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var limitRangeYAML = ` apiVersion: v1 kind: LimitRange metadata: name: example-limit-range spec: limits: - type: Pod max: memory: 200Mi min: cpu: 50m - type: Container max: memory: 150Mi cpu: 100m min: memory: 50Mi cpu: 50m default: memory: 100Mi cpu: 50m defaultRequest: memory: 80Mi cpu: 50m ` func TestLimitRangeAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newLimitRangeAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "example-limit-range", GetScope: sd.String(), SetupYAML: limitRangeYAML, GetQueryTests: QueryTests{}, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/main.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) type AdapterLoader func(clientSet *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter var adapterLoaders []AdapterLoader func registerAdapterLoader(loader AdapterLoader) { adapterLoaders = append(adapterLoaders, loader) } func LoadAllAdapters(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) []discovery.Adapter { adapters := make([]discovery.Adapter, len(adapterLoaders)) for i, loader := range adapterLoaders { adapters[i] = loader(cs, cluster, namespaces, cache) } return adapters } ================================================ FILE: k8s-source/adapters/networkpolicy.go ================================================ package adapters import ( v1 "k8s.io/api/networking/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func NetworkPolicyExtractor(resource *v1.NetworkPolicy, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Pod", Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(&resource.Spec.PodSelector), Scope: scope, }, }) var peers []v1.NetworkPolicyPeer for _, ig := range resource.Spec.Ingress { peers = append(peers, ig.From...) } for _, eg := range resource.Spec.Egress { peers = append(peers, eg.To...) } // Link all peers for _, peer := range peers { if ps := peer.PodSelector; ps != nil { // TODO: Link to namespaces that are allowed to ingress e.g. // - namespaceSelector: // matchLabels: // project: something queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: LabelSelectorToQuery(ps), Type: "Pod", }, }) } } return queries, nil } func newNetworkPolicyAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.NetworkPolicy, *v1.NetworkPolicyList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "NetworkPolicy", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.NetworkPolicy, *v1.NetworkPolicyList] { return cs.NetworkingV1().NetworkPolicies(namespace) }, ListExtractor: func(list *v1.NetworkPolicyList) ([]*v1.NetworkPolicy, error) { extracted := make([]*v1.NetworkPolicy, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: NetworkPolicyExtractor, AdapterMetadata: networkPolicyAdapterMetadata, } } var networkPolicyAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "NetworkPolicy", DescriptiveName: "Network Policy", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, PotentialLinks: []string{"Pod"}, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_network_policy.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_network_policy_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newNetworkPolicyAdapter) } ================================================ FILE: k8s-source/adapters/networkpolicy_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var NetworkPolicyYAML = ` apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-nginx spec: podSelector: matchLabels: app: nginx policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: app: frontend ports: - protocol: TCP port: 80 ` func TestNetworkPolicyAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newNetworkPolicyAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "allow-nginx", GetScope: sd.String(), SetupYAML: NetworkPolicyYAML, GetQueryTests: QueryTests{ { ExpectedQueryMatches: regexp.MustCompile("nginx"), ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/node.go ================================================ package adapters import ( "strings" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func linkedItemExtractor(resource *v1.Node, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) for _, addr := range resource.Status.Addresses { switch addr.Type { case v1.NodeExternalDNS, v1.NodeInternalDNS, v1.NodeHostName: queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: addr.Address, Scope: "global", }, }) case v1.NodeExternalIP, v1.NodeInternalIP: queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: addr.Address, Scope: "global", }, }) } } for _, vol := range resource.Status.VolumesAttached { // Look for EBS volumes since they follow the format: // kubernetes.io/csi/ebs.csi.aws.com^vol-043e04d9cc6d72183 if strings.HasPrefix(string(vol.Name), "kubernetes.io/csi/ebs.csi.aws.com") { sections := strings.Split(string(vol.Name), "^") if len(sections) == 2 { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-volume", Method: sdp.QueryMethod_GET, Query: sections[1], Scope: "*", }, }) } } } return queries, nil } func newNodeAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Node, *v1.NodeList]{ ClusterName: cluster, Namespaces: namespaces, TypeName: "Node", cache: cache, ClusterInterfaceBuilder: func() ItemInterface[*v1.Node, *v1.NodeList] { return cs.CoreV1().Nodes() }, ListExtractor: func(list *v1.NodeList) ([]*v1.Node, error) { extracted := make([]*v1.Node, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: linkedItemExtractor, AdapterMetadata: nodeAdapterMetadata, } } var nodeAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Node", DescriptiveName: "Node", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, PotentialLinks: []string{"dns", "ip", "ec2-volume"}, SupportedQueryMethods: DefaultSupportedQueryMethods("Node"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_node_taint.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newNodeAdapter) } ================================================ FILE: k8s-source/adapters/node_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestNodeAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newNodeAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "local-tests-control-plane", GetScope: sd.String(), GetQueryTests: QueryTests{ { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedScope: "global", ExpectedQueryMatches: regexp.MustCompile(`172\.`), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/persistentvolume.go ================================================ package adapters import ( "regexp" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func PersistentVolumeExtractor(resource *v1.PersistentVolume, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) sd, err := ParseScope(scope, false) if err != nil { return nil, err } if resource.Spec.PersistentVolumeSource.AWSElasticBlockStore != nil { // Link to EBS volume queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ec2-volume", Method: sdp.QueryMethod_GET, Query: resource.Spec.PersistentVolumeSource.AWSElasticBlockStore.VolumeID, Scope: "*", }, }) } if resource.Spec.CSI != nil { // Link to an EFS file system access point efsVolumeHandle := regexp.MustCompile(`fs-[a-f0-9]+::(fsap-[a-f0-9]+)`) matches := efsVolumeHandle.FindStringSubmatch(resource.Spec.CSI.VolumeHandle) if matches != nil { if len(matches) == 2 { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "efs-access-point", Method: sdp.QueryMethod_GET, Query: matches[1], Scope: "*", }, }) } } } if resource.Spec.ClaimRef != nil { queries = append(queries, ObjectReferenceToQuery(resource.Spec.ClaimRef, sd)) } if resource.Spec.StorageClassName != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "StorageClass", Method: sdp.QueryMethod_GET, Query: resource.Spec.StorageClassName, Scope: sd.ClusterName, }, }) } return queries, nil } func newPersistentVolumeAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.PersistentVolume, *v1.PersistentVolumeList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "PersistentVolume", ClusterInterfaceBuilder: func() ItemInterface[*v1.PersistentVolume, *v1.PersistentVolumeList] { return cs.CoreV1().PersistentVolumes() }, ListExtractor: func(list *v1.PersistentVolumeList) ([]*v1.PersistentVolume, error) { extracted := make([]*v1.PersistentVolume, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: PersistentVolumeExtractor, AdapterMetadata: persistentVolumeAdapterMetadata, } } var persistentVolumeAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "PersistentVolume", DescriptiveName: "Persistent Volume", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, PotentialLinks: []string{"ec2-volume", "efs-access-point", "StorageClass"}, SupportedQueryMethods: DefaultSupportedQueryMethods("PersistentVolume"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_persistent_volume.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_persistent_volume_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newPersistentVolumeAdapter) } ================================================ FILE: k8s-source/adapters/persistentvolume_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var persistentVolumeYAML = ` --- apiVersion: v1 kind: PersistentVolume metadata: name: pv-test-pv spec: capacity: storage: 1Gi accessModes: - ReadWriteOnce hostPath: path: /tmp/pv-test-pv ` func TestPersistentVolumeAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "", } adapter := newPersistentVolumeAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "pv-test-pv", GetScope: sd.String(), SetupYAML: persistentVolumeYAML, GetQueryTests: QueryTests{}, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/persistentvolumeclaim.go ================================================ package adapters import ( "errors" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func PersistentVolumeClaimExtractor(resource *v1.PersistentVolumeClaim, scope string) ([]*sdp.LinkedItemQuery, error) { if resource == nil { return nil, errors.New("resource is nil") } links := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.VolumeName != "" { links = append(links, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "PersistentVolume", Method: sdp.QueryMethod_GET, Query: resource.Spec.VolumeName, Scope: scope, }, }) } return links, nil } func newPersistentVolumeClaimAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.PersistentVolumeClaim, *v1.PersistentVolumeClaimList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "PersistentVolumeClaim", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.PersistentVolumeClaim, *v1.PersistentVolumeClaimList] { return cs.CoreV1().PersistentVolumeClaims(namespace) }, ListExtractor: func(list *v1.PersistentVolumeClaimList) ([]*v1.PersistentVolumeClaim, error) { extracted := make([]*v1.PersistentVolumeClaim, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: PersistentVolumeClaimExtractor, AdapterMetadata: persistentVolumeClaimAdapterMetadata, } } var persistentVolumeClaimAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "PersistentVolumeClaim", DescriptiveName: "Persistent Volume Claim", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, PotentialLinks: []string{"PersistentVolume"}, SupportedQueryMethods: DefaultSupportedQueryMethods("PersistentVolumeClaim"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_persistent_volume_claim.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_persistent_volume_claim_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newPersistentVolumeClaimAdapter) } ================================================ FILE: k8s-source/adapters/persistentvolumeclaim_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var persistentVolumeClaimYAML = ` apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-test-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi --- apiVersion: v1 kind: PersistentVolume metadata: name: pvc-test-pv spec: capacity: storage: 1Gi accessModes: - ReadWriteOnce hostPath: path: /tmp/pvc-test-pv --- apiVersion: v1 kind: Pod metadata: name: pvc-test-pod spec: containers: - name: pvc-test-container image: nginx volumeMounts: - name: pvc-test-volume mountPath: /data volumes: - name: pvc-test-volume persistentVolumeClaim: claimName: pvc-test-pvc ` func TestPersistentVolumeClaimAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newPersistentVolumeClaimAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "pvc-test-pvc", GetScope: sd.String(), SetupYAML: persistentVolumeClaimYAML, GetQueryTests: QueryTests{ { ExpectedType: "PersistentVolume", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQueryMatches: regexp.MustCompile("pvc-"), ExpectedScope: sd.String(), }, }, Wait: func(item *sdp.Item) bool { phase, _ := item.GetAttributes().Get("status.phase") return phase != "Pending" }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/poddisruptionbudget.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/policy/v1" "k8s.io/client-go/kubernetes" ) func podDisruptionBudgetExtractor(resource *v1.PodDisruptionBudget, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.Selector != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Pod", Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(resource.Spec.Selector), Scope: scope, }, }) } return queries, nil } func newPodDisruptionBudgetAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.PodDisruptionBudget, *v1.PodDisruptionBudgetList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "PodDisruptionBudget", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.PodDisruptionBudget, *v1.PodDisruptionBudgetList] { return cs.PolicyV1().PodDisruptionBudgets(namespace) }, ListExtractor: func(list *v1.PodDisruptionBudgetList) ([]*v1.PodDisruptionBudget, error) { extracted := make([]*v1.PodDisruptionBudget, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: podDisruptionBudgetExtractor, AdapterMetadata: podDisruptionBudgetAdapterMetadata, } } var podDisruptionBudgetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "PodDisruptionBudget", DescriptiveName: "Pod Disruption Budget", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, PotentialLinks: []string{"Pod"}, SupportedQueryMethods: DefaultSupportedQueryMethods("PodDisruptionBudget"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_pod_disruption_budget_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newPodDisruptionBudgetAdapter) } ================================================ FILE: k8s-source/adapters/poddisruptionbudget_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var PodDisruptionBudgetYAML = ` apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: example-pdb spec: minAvailable: 2 selector: matchLabels: app: example-app ` func TestPodDisruptionBudgetAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newPodDisruptionBudgetAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "example-pdb", GetScope: sd.String(), SetupYAML: PodDisruptionBudgetYAML, GetQueryTests: QueryTests{ { ExpectedQueryMatches: regexp.MustCompile("app=example-app"), ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/pods.go ================================================ package adapters import ( "net" "slices" "time" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func PodExtractor(resource *v1.Pod, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) sd, err := ParseScope(scope, true) if err != nil { return nil, err } // Link service accounts if resource.Spec.ServiceAccountName != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ServiceAccount", Scope: scope, Method: sdp.QueryMethod_GET, Query: resource.Spec.ServiceAccountName, }, }) } // Link items from volumes for _, vol := range resource.Spec.Volumes { // Link PVCs if vol.PersistentVolumeClaim != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: vol.PersistentVolumeClaim.ClaimName, Type: "PersistentVolumeClaim", }, }) } // Link to EBS volumes if vol.AWSElasticBlockStore != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: "*", Method: sdp.QueryMethod_GET, Query: vol.AWSElasticBlockStore.VolumeID, Type: "ec2-volume", }, }) } // Link secrets if vol.Secret != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: vol.Secret.SecretName, Type: "Secret", }, }) } if vol.NFS != nil { // This is either the hostname or IP of the NFS server so we can // link to that. We'll try to parse the IP and if not fall back to // DNS for the hostname if net.ParseIP(vol.NFS.Server) != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: "global", Method: sdp.QueryMethod_GET, Query: vol.NFS.Server, Type: "ip", }, }) } else { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: "global", Method: sdp.QueryMethod_SEARCH, Type: "dns", Query: vol.NFS.Server, }, }) } } // Link config map volumes if vol.ConfigMap != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: vol.ConfigMap.Name, Type: "ConfigMap", }, }) } // Link projected volumes if vol.Projected != nil { for _, source := range vol.Projected.Sources { if source.ConfigMap != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: source.ConfigMap.Name, Type: "ConfigMap", }, }) } if source.Secret != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: source.Secret.Name, Type: "Secret", }, }) } } } } // Link items from containers for _, container := range resource.Spec.Containers { // Loop over environment variables for _, env := range container.Env { if env.ValueFrom != nil { if env.ValueFrom.SecretKeyRef != nil { // Add linked item from spec.containers[].env[].valueFrom.secretKeyRef queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: env.ValueFrom.SecretKeyRef.Name, Type: "Secret", }, }) } if env.ValueFrom.ConfigMapKeyRef != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: env.ValueFrom.ConfigMapKeyRef.Name, Type: "ConfigMap", }, }) } } } for _, envFrom := range container.EnvFrom { if envFrom.SecretRef != nil { // Add linked item from spec.containers[].EnvFrom[].secretKeyRef queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: envFrom.SecretRef.Name, Type: "Secret", }, }) } if envFrom.ConfigMapRef != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: envFrom.ConfigMapRef.Name, Type: "ConfigMap", }, }) } } } if resource.Spec.PriorityClassName != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: sd.ClusterName, Method: sdp.QueryMethod_GET, Query: resource.Spec.PriorityClassName, Type: "PriorityClass", }, }) } if len(resource.Status.PodIPs) > 0 { for _, ip := range resource.Status.PodIPs { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: "global", Method: sdp.QueryMethod_GET, Query: ip.IP, Type: "ip", }, }) } } else if resource.Status.PodIP != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: resource.Status.PodIP, Scope: "global", }, }) } return queries, nil } func newPodAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Pod, *v1.PodList]{ ClusterName: cluster, Namespaces: namespaces, TypeName: "Pod", CacheDuration: 10 * time.Minute, // somewhat low since pods are replaced a lot AutoQueryExtract: true, cache: cache, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Pod, *v1.PodList] { return cs.CoreV1().Pods(namespace) }, ListExtractor: func(list *v1.PodList) ([]*v1.Pod, error) { extracted := make([]*v1.Pod, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: PodExtractor, HealthExtractor: func(resource *v1.Pod) *sdp.Health { switch resource.Status.Phase { case v1.PodPending: // a special case were the pod has never actually started if hasWaitingContainerErrors(resource.Status.ContainerStatuses) { return sdp.Health_HEALTH_ERROR.Enum() } return sdp.Health_HEALTH_PENDING.Enum() case v1.PodRunning, v1.PodSucceeded: // a special case were the pod has started but it was modified if hasWaitingContainerErrors(resource.Status.ContainerStatuses) { return sdp.Health_HEALTH_ERROR.Enum() } return sdp.Health_HEALTH_OK.Enum() case v1.PodFailed: return sdp.Health_HEALTH_ERROR.Enum() case v1.PodUnknown: return sdp.Health_HEALTH_UNKNOWN.Enum() } return nil }, AdapterMetadata: podAdapterMetadata, } } // a pod's status phase can be ok, but the container may not be ok // this is a check for the container statuses // hasWaitingContainerErrors returns true if any of the container statuses are in a waiting state with an error reason func hasWaitingContainerErrors(containerStatuses []v1.ContainerStatus) bool { for _, c := range containerStatuses { if c.State.Waiting != nil { // list of image errors from https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/images/types.go#L27-L42 if slices.Contains([]string{"CrashLoopBackOff", "ImagePullBackOff", "ImageInspectError", "ErrImagePull", "ErrImageNeverPull", "InvalidImageName"}, c.State.Waiting.Reason) { return true } } } return false } var podAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Pod", DescriptiveName: "Pod", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, PotentialLinks: []string{ "ConfigMap", "ec2-volume", "dns", "ip", "PersistentVolumeClaim", "PriorityClass", "Secret", "ServiceAccount", }, SupportedQueryMethods: DefaultSupportedQueryMethods("Pod"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_pod.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_pod_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newPodAdapter) } ================================================ FILE: k8s-source/adapters/pods_test.go ================================================ package adapters import ( "context" "fmt" "regexp" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" ) var PodYAML = ` apiVersion: v1 kind: ServiceAccount metadata: name: pod-test-serviceaccount --- apiVersion: v1 kind: Secret metadata: name: pod-test-secret type: Opaque data: username: dXNlcm5hbWU= password: cGFzc3dvcmQ= --- apiVersion: v1 kind: ConfigMap metadata: name: pod-test-configmap data: config.ini: | [database] host=example.com port=5432 --- apiVersion: v1 kind: ConfigMap metadata: name: pod-test-configmap-cert data: ca.pem: | wow such cert --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pod-test-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi --- apiVersion: v1 kind: Pod metadata: name: pod-test-pod spec: serviceAccountName: pod-test-serviceaccount volumes: - name: pod-test-pvc-volume persistentVolumeClaim: claimName: pod-test-pvc - name: database-config configMap: name: pod-test-configmap - name: projected-config projected: sources: - configMap: name: pod-test-configmap-cert items: - key: ca.pem path: ca.pem containers: - name: pod-test-container image: nginx volumeMounts: - name: pod-test-pvc-volume mountPath: /mnt/data - name: database-config mountPath: /etc/database - name: projected-config mountPath: /etc/projected envFrom: - secretRef: name: pod-test-secret --- apiVersion: v1 kind: Pod metadata: name: pod-bad-pod spec: serviceAccountName: pod-test-serviceaccount volumes: - name: pod-test-pvc-volume persistentVolumeClaim: claimName: pod-test-pvc - name: database-config configMap: name: pod-test-configmap - name: projected-config projected: sources: - configMap: name: pod-test-configmap-cert items: - key: ca.pem path: ca.pem containers: - name: pod-test-container image: nginx:this-tag-does-not-exist volumeMounts: - name: pod-test-pvc-volume mountPath: /mnt/data - name: database-config mountPath: /etc/database - name: projected-config mountPath: /etc/projected envFrom: - secretRef: name: pod-test-secret ` func TestPodAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newPodAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "pod-test-pod", GetScope: sd.String(), SetupYAML: PodYAML, GetQueryTests: QueryTests{ { ExpectedQueryMatches: regexp.MustCompile(`10\.`), ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedScope: "global", }, { ExpectedType: "ServiceAccount", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pod-test-serviceaccount", ExpectedScope: sd.String(), }, { ExpectedType: "Secret", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pod-test-secret", ExpectedScope: sd.String(), }, { ExpectedType: "ConfigMap", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pod-test-configmap", ExpectedScope: sd.String(), }, { ExpectedType: "PersistentVolumeClaim", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pod-test-pvc", ExpectedScope: sd.String(), }, { ExpectedType: "ConfigMap", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pod-test-configmap-cert", ExpectedScope: sd.String(), }, }, Wait: func(item *sdp.Item) bool { return len(item.GetLinkedItemQueries()) >= 9 }, } st.Execute(t) // Wait for the bad pod's image pull to fail before asserting health. // The kubelet needs time to attempt the pull and enter // ErrImagePull / ImagePullBackOff, which surfaces as HEALTH_ERROR. var badPodItem *sdp.Item err := WaitFor(60*time.Second, func() bool { item, err := adapter.Get(context.Background(), sd.String(), "pod-bad-pod", true) if err != nil { return false } badPodItem = item return item.GetHealth() == sdp.Health_HEALTH_ERROR }) if err != nil { health := sdp.Health_HEALTH_UNKNOWN if badPodItem != nil { health = badPodItem.GetHealth() } t.Fatalf("expected bad pod health to reach HEALTH_ERROR, still %s after timeout", health) } // get the healthy pod healthyItem, err := adapter.Get(context.Background(), sd.String(), "pod-test-pod", true) if err != nil { t.Fatal(fmt.Errorf("failed to get pod: %w", err)) } if healthyItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("expected status to be healthy, got %s", healthyItem.GetHealth()) } } func TestHasWaitingContainerErrors(t *testing.T) { tests := []struct { name string containerStatuses []v1.ContainerStatus expectedResult bool }{ { name: "No waiting containers", containerStatuses: []v1.ContainerStatus{ { State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, expectedResult: false, }, { name: "Waiting container with non-error reason", containerStatuses: []v1.ContainerStatus{ { State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "ContainerCreating", }, }, }, }, expectedResult: false, }, { name: "Waiting container with error reason", containerStatuses: []v1.ContainerStatus{ { State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "ImagePullBackOff", }, }, }, }, expectedResult: true, }, { name: "Multiple containers with one error", containerStatuses: []v1.ContainerStatus{ { State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, { State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "ImagePullBackOff", }, }, }, }, expectedResult: true, }, { name: "Multiple containers with no errors", containerStatuses: []v1.ContainerStatus{ { State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, { State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "ContainerCreating", }, }, }, }, expectedResult: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := hasWaitingContainerErrors(tt.containerStatuses) if result != tt.expectedResult { t.Errorf("expected %v, got %v", tt.expectedResult, result) } }) } } ================================================ FILE: k8s-source/adapters/priorityclass.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/scheduling/v1" "k8s.io/client-go/kubernetes" ) func newPriorityClassAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.PriorityClass, *v1.PriorityClassList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "PriorityClass", ClusterInterfaceBuilder: func() ItemInterface[*v1.PriorityClass, *v1.PriorityClassList] { return cs.SchedulingV1().PriorityClasses() }, ListExtractor: func(list *v1.PriorityClassList) ([]*v1.PriorityClass, error) { extracted := make([]*v1.PriorityClass, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, AdapterMetadata: priorityClassAdapterMetadata, } } var priorityClassAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "PriorityClass", DescriptiveName: "Priority Class", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: DefaultSupportedQueryMethods("Priority Class"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_priority_class_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_priority_class.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newPriorityClassAdapter) } ================================================ FILE: k8s-source/adapters/priorityclass_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var priorityClassYAML = ` apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: ultra-mega-priority value: 1000000 globalDefault: false description: "This priority class should be used for ultra-mega-priority workloads" ` func TestPriorityClassAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newPriorityClassAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "ultra-mega-priority", GetScope: sd.String(), SetupYAML: priorityClassYAML, GetQueryTests: QueryTests{}, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/replicaset.go ================================================ package adapters import ( v1 "k8s.io/api/apps/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func replicaSetExtractor(resource *v1.ReplicaSet, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.Selector != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(resource.Spec.Selector), Type: "Pod", }, }) } return queries, nil } func newReplicaSetAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.ReplicaSet, *v1.ReplicaSetList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "ReplicaSet", AutoQueryExtract: true, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.ReplicaSet, *v1.ReplicaSetList] { return cs.AppsV1().ReplicaSets(namespace) }, ListExtractor: func(list *v1.ReplicaSetList) ([]*v1.ReplicaSet, error) { extracted := make([]*v1.ReplicaSet, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: replicaSetExtractor, AdapterMetadata: replicaSetAdapterMetadata, } } var replicaSetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ReplicaSet", DescriptiveName: "Replica Set", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, PotentialLinks: []string{"Pod"}, SupportedQueryMethods: DefaultSupportedQueryMethods("ReplicaSet"), }) func init() { registerAdapterLoader(newReplicaSetAdapter) } ================================================ FILE: k8s-source/adapters/replicaset_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var replicaSetYAML = ` apiVersion: apps/v1 kind: ReplicaSet metadata: name: replica-set-test spec: replicas: 1 selector: matchLabels: app: replica-set-test template: metadata: labels: app: replica-set-test spec: containers: - name: replica-set-test image: nginx:latest ports: - containerPort: 80 ` func TestReplicaSetAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newReplicaSetAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "replica-set-test", GetScope: sd.String(), SetupYAML: replicaSetYAML, GetQueryTests: QueryTests{ { ExpectedQueryMatches: regexp.MustCompile("app=replica-set-test"), ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/replicationcontroller.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) func replicationControllerExtractor(resource *v1.ReplicationController, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.Selector != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(&metaV1.LabelSelector{ MatchLabels: resource.Spec.Selector, }), Type: "Pod", }, }) } return queries, nil } func newReplicationControllerAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.ReplicationController, *v1.ReplicationControllerList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "ReplicationController", AutoQueryExtract: true, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.ReplicationController, *v1.ReplicationControllerList] { return cs.CoreV1().ReplicationControllers(namespace) }, ListExtractor: func(list *v1.ReplicationControllerList) ([]*v1.ReplicationController, error) { extracted := make([]*v1.ReplicationController, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: replicationControllerExtractor, AdapterMetadata: replicationControllerAdapterMetadata, } } var replicationControllerAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ReplicationController", DescriptiveName: "Replication Controller", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, PotentialLinks: []string{"Pod"}, SupportedQueryMethods: DefaultSupportedQueryMethods("ReplicationController"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_replication_controller.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_replication_controller_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newReplicationControllerAdapter) } ================================================ FILE: k8s-source/adapters/replicationcontroller_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var replicationControllerYAML = ` apiVersion: v1 kind: ReplicationController metadata: name: replication-controller-test spec: replicas: 1 selector: app: replication-controller-test template: metadata: labels: app: replication-controller-test spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 ` func TestReplicationControllerAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newReplicationControllerAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "replication-controller-test", GetScope: sd.String(), SetupYAML: replicationControllerYAML, GetQueryTests: QueryTests{ { ExpectedQueryMatches: regexp.MustCompile("app=replication-controller-test"), ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/resourcequota.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func newResourceQuotaAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.ResourceQuota, *v1.ResourceQuotaList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "ResourceQuota", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.ResourceQuota, *v1.ResourceQuotaList] { return cs.CoreV1().ResourceQuotas(namespace) }, ListExtractor: func(list *v1.ResourceQuotaList) ([]*v1.ResourceQuota, error) { extracted := make([]*v1.ResourceQuota, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, AdapterMetadata: resourceQuotaAdapterMetadata, } } var resourceQuotaAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ResourceQuota", DescriptiveName: "Resource Quota", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: DefaultSupportedQueryMethods("Resource Quota"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_resource_quota_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_resource_quota.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newResourceQuotaAdapter) } ================================================ FILE: k8s-source/adapters/resourcequota_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var resourceQuotaYAML = ` apiVersion: v1 kind: ResourceQuota metadata: name: quota-example spec: hard: pods: "10" requests.cpu: "2" requests.memory: 2Gi limits.cpu: "4" limits.memory: 4Gi ` func TestResourceQuotaAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newResourceQuotaAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "quota-example", GetScope: sd.String(), SetupYAML: resourceQuotaYAML, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/role.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/rbac/v1" "k8s.io/client-go/kubernetes" ) func newRoleAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Role, *v1.RoleList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "Role", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Role, *v1.RoleList] { return cs.RbacV1().Roles(namespace) }, ListExtractor: func(list *v1.RoleList) ([]*v1.Role, error) { extracted := make([]*v1.Role, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, AdapterMetadata: roleAdapterMetadata, } } var roleAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Role", DescriptiveName: "Role", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, SupportedQueryMethods: DefaultSupportedQueryMethods("Role"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_role_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_role.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newRoleAdapter) } ================================================ FILE: k8s-source/adapters/role_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var RoleYAML = ` apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: role-test-role rules: - apiGroups: - "" - "apps" - "batch" - "extensions" resources: - pods - deployments - jobs - cronjobs - configmaps - secrets verbs: - get - list - watch - create - update - delete ` func TestRoleAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newRoleAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "role-test-role", GetScope: sd.String(), SetupYAML: RoleYAML, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/rolebinding.go ================================================ package adapters import ( v1 "k8s.io/api/rbac/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func roleBindingExtractor(resource *v1.RoleBinding, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) sd, err := ParseScope(scope, true) if err != nil { return nil, err } for _, subject := range resource.Subjects { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Method: sdp.QueryMethod_GET, Query: subject.Name, Type: subject.Kind, Scope: ScopeDetails{ ClusterName: sd.ClusterName, Namespace: subject.Namespace, }.String(), }, }) } refSD := ScopeDetails{ ClusterName: sd.ClusterName, } switch resource.RoleRef.Kind { case "Role": // If this binding is linked to a role then it's in the same namespace refSD.Namespace = sd.Namespace case "ClusterRole": // If this is linked to a ClusterRole (which is not namespaced) we need // to make sure that we are querying the root scope i.e. the // non-namespaced scope refSD.Namespace = "" } queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: refSD.String(), Method: sdp.QueryMethod_GET, Query: resource.RoleRef.Name, Type: resource.RoleRef.Kind, }, }) return queries, nil } func newRoleBindingAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.RoleBinding, *v1.RoleBindingList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "RoleBinding", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.RoleBinding, *v1.RoleBindingList] { return cs.RbacV1().RoleBindings(namespace) }, ListExtractor: func(list *v1.RoleBindingList) ([]*v1.RoleBinding, error) { extracted := make([]*v1.RoleBinding, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: roleBindingExtractor, AdapterMetadata: roleBindingAdapterMetadata, } } var roleBindingAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "RoleBinding", DescriptiveName: "Role Binding", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, PotentialLinks: []string{"Role", "ClusterRole", "ServiceAccount", "User", "Group"}, SupportedQueryMethods: DefaultSupportedQueryMethods("RoleBinding"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_role_binding.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_role_binding_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newRoleBindingAdapter) } ================================================ FILE: k8s-source/adapters/rolebinding_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var roleBindingYAML = ` apiVersion: v1 kind: ServiceAccount metadata: name: rb-test-service-account --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: rb-test-role rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "watch", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: rb-test-role-binding subjects: - kind: ServiceAccount name: rb-test-service-account namespace: default roleRef: kind: Role name: rb-test-role apiGroup: rbac.authorization.k8s.io --- ` var roleBindingYAML2 = ` apiVersion: v1 kind: ServiceAccount metadata: name: rb-test-service-account2 --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: rb-test-cluster-role rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "watch", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: rb-test-role-binding-cluster namespace: default roleRef: kind: ClusterRole name: rb-test-cluster-role apiGroup: rbac.authorization.k8s.io subjects: - kind: ServiceAccount name: rb-test-service-account2 namespace: default ` func TestRoleBindingAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newRoleBindingAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) t.Run("With a Role", func(t *testing.T) { st := AdapterTests{ Adapter: adapter, GetQuery: "rb-test-role-binding", GetScope: sd.String(), SetupYAML: roleBindingYAML, GetQueryTests: QueryTests{ { ExpectedType: "Role", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rb-test-role", ExpectedScope: sd.String(), }, { ExpectedType: "ServiceAccount", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rb-test-service-account", ExpectedScope: sd.String(), }, }, } st.Execute(t) }) t.Run("With a ClusterRole", func(t *testing.T) { st := AdapterTests{ Adapter: adapter, GetQuery: "rb-test-role-binding-cluster", GetScope: sd.String(), SetupYAML: roleBindingYAML2, GetQueryTests: QueryTests{ { ExpectedType: "ClusterRole", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rb-test-cluster-role", ExpectedScope: sd.ClusterName, }, { ExpectedType: "ServiceAccount", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "rb-test-service-account2", ExpectedScope: sd.String(), }, }, } st.Execute(t) }) } ================================================ FILE: k8s-source/adapters/secret.go ================================================ package adapters import ( "crypto/sha512" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func newSecretAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Secret, *v1.SecretList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "Secret", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Secret, *v1.SecretList] { return cs.CoreV1().Secrets(namespace) }, ListExtractor: func(list *v1.SecretList) ([]*v1.Secret, error) { extracted := make([]*v1.Secret, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, Redact: func(resource *v1.Secret) *v1.Secret { // We want to redact the data from a secret, but we also went to // show people when it has changed, to that end we will hash all of // the data in the secret and return the hash hash := sha512.New() for k, v := range resource.Data { // Write the data into the hash hash.Write([]byte(k)) hash.Write(v) } resource.Data = map[string][]byte{ "data-redacted": hash.Sum(nil), } return resource }, AdapterMetadata: secretAdapterMetadata, } } var secretAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Secret", DescriptiveName: "Secret", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, SupportedQueryMethods: DefaultSupportedQueryMethods("Secret"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_secret_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_secret.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newSecretAdapter) } ================================================ FILE: k8s-source/adapters/secret_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var secretYAML = ` apiVersion: v1 kind: Secret metadata: name: secret-test-secret type: Opaque data: username: dXNlcm5hbWUx # base64-encoded "username1" password: cGFzc3dvcmQx # base64-encoded "password1" ` func TestSecretAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newSecretAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "secret-test-secret", GetScope: sd.String(), SetupYAML: secretYAML, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/service.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) func serviceExtractor(resource *v1.Service, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.Selector != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Pod", Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(&metaV1.LabelSelector{ MatchLabels: resource.Spec.Selector, }), Scope: scope, }, }) } ips := make([]string, 0) if len(resource.Spec.ClusterIPs) > 0 { ips = append(ips, resource.Spec.ClusterIPs...) } else if resource.Spec.ClusterIP != "" { ips = append(ips, resource.Spec.ClusterIP) } ips = append(ips, resource.Spec.ExternalIPs...) ips = append(ips, resource.Spec.LoadBalancerIP) for _, ip := range ips { if ip != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: ip, Scope: "global", }, }) } } if resource.Spec.ExternalName != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: resource.Spec.ExternalName, Scope: "global", }, }) } // Services generate an Endpoints object with the same name (older K8s API) queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Endpoints", Method: sdp.QueryMethod_GET, Query: resource.Name, Scope: scope, }, }) // Modern K8s clusters also create EndpointSlices labelled with the service name queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "EndpointSlice", Method: sdp.QueryMethod_SEARCH, Query: ListOptionsToQuery(&metaV1.ListOptions{ LabelSelector: "kubernetes.io/service-name=" + resource.Name, }), Scope: scope, }, }) for _, ingress := range resource.Status.LoadBalancer.Ingress { if ingress.IP != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: ingress.IP, Scope: "global", }, }) } if ingress.Hostname != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: ingress.Hostname, Scope: "global", }, }) } } return queries, nil } func newServiceAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.Service, *v1.ServiceList]{ ClusterName: cluster, Namespaces: namespaces, TypeName: "Service", cache: cache, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Service, *v1.ServiceList] { return cs.CoreV1().Services(namespace) }, ListExtractor: func(list *v1.ServiceList) ([]*v1.Service, error) { extracted := make([]*v1.Service, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: serviceExtractor, AdapterMetadata: serviceAdapterMetadata, } } var serviceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "Service", DescriptiveName: "Service", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"Pod", "ip", "dns", "Endpoints", "EndpointSlice"}, SupportedQueryMethods: DefaultSupportedQueryMethods("Service"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_service.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_service_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newServiceAdapter) } ================================================ FILE: k8s-source/adapters/service_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var serviceYAML = ` apiVersion: apps/v1 kind: Deployment metadata: name: service-test-deployment spec: selector: matchLabels: app: service-test replicas: 1 template: metadata: labels: app: service-test spec: containers: - name: my-container image: nginx ports: - containerPort: 8080 --- apiVersion: v1 kind: Service metadata: name: service-test-service spec: selector: app: service-test ports: - name: http protocol: TCP port: 80 targetPort: 8080 type: ExternalName externalName: service-test-external ` func TestServiceAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newServiceAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "service-test-service", GetScope: sd.String(), SetupYAML: serviceYAML, GetQueryTests: QueryTests{ { ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedScope: sd.String(), ExpectedQueryMatches: regexp.MustCompile(`app=service-test`), }, { ExpectedType: "Endpoints", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "service-test-service", ExpectedScope: sd.String(), }, { ExpectedType: "EndpointSlice", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedScope: sd.String(), ExpectedQueryMatches: regexp.MustCompile(`kubernetes\.io/service-name=service-test-service`), }, { ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "service-test-external", ExpectedScope: "global", }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/serviceaccount.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) func serviceAccountExtractor(resource *v1.ServiceAccount, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) for _, secret := range resource.Secrets { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: secret.Name, Type: "Secret", }, }) } for _, ipSecret := range resource.ImagePullSecrets { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_GET, Query: ipSecret.Name, Type: "Secret", }, }) } return queries, nil } func newServiceAccountAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.ServiceAccount, *v1.ServiceAccountList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "ServiceAccount", NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.ServiceAccount, *v1.ServiceAccountList] { return cs.CoreV1().ServiceAccounts(namespace) }, ListExtractor: func(list *v1.ServiceAccountList) ([]*v1.ServiceAccount, error) { extracted := make([]*v1.ServiceAccount, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: serviceAccountExtractor, AdapterMetadata: serviceAccountAdapterMetadata, } } var serviceAccountAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "ServiceAccount", DescriptiveName: "Service Account", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, PotentialLinks: []string{"Secret"}, SupportedQueryMethods: DefaultSupportedQueryMethods("ServiceAccount"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_service_account.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_service_account_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newServiceAccountAdapter) } ================================================ FILE: k8s-source/adapters/serviceaccount_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var serviceAccountYAML = ` apiVersion: v1 kind: Secret metadata: name: service-account-secret type: Opaque data: username: Zm9vCg== password: Zm9vCg== --- apiVersion: v1 kind: Secret metadata: name: service-account-secret-pull type: kubernetes.io/dockerconfigjson data: .dockerconfigjson: eyJhdXRocyI6eyJnaGNyLmlvIjp7InVzZXJuYW1lIjoiaHVudGVyIiwicGFzc3dvcmQiOiJodW50ZXIyIiwiZW1haWwiOiJmb29AYmFyLmNvbSIsImF1dGgiOiJhSFZ1ZEdWeU9taDFiblJsY2pJPSJ9fX0= --- apiVersion: v1 kind: ServiceAccount metadata: name: test-service-account secrets: - name: service-account-secret imagePullSecrets: - name: service-account-secret-pull ` func TestServiceAccountAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newServiceAccountAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "test-service-account", GetScope: sd.String(), SetupYAML: serviceAccountYAML, GetQueryTests: QueryTests{ { ExpectedType: "Secret", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "service-account-secret", ExpectedScope: sd.String(), }, { ExpectedType: "Secret", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "service-account-secret-pull", ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/shared_test.go ================================================ package adapters import ( "bytes" "context" "errors" "fmt" "log" "os" "os/exec" "path/filepath" "testing" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/homedir" "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" "sigs.k8s.io/kind/pkg/cluster" ) const TestNamespace = "k8s-source-testing" const TestNamespaceYAML = ` apiVersion: v1 kind: Namespace metadata: name: k8s-source-testing ` type TestCluster struct { Name string Kubeconfig string ClientSet *kubernetes.Clientset provider *cluster.Provider T *testing.T } func buildConfigWithContextFromFlags(context string, kubeconfigPath string) (*rest.Config, error) { return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, &clientcmd.ConfigOverrides{ CurrentContext: context, }).ClientConfig() } func (t *TestCluster) ConnectExisting(name string) error { kubeconfig := homedir.HomeDir() + "/.kube/config" var rc *rest.Config var err error // Load kubernetes config rc, err = buildConfigWithContextFromFlags("kind-"+name, kubeconfig) if err != nil { return err } var clientSet *kubernetes.Clientset // Create clientset clientSet, err = kubernetes.NewForConfig(rc) if err != nil { return err } // Validate that we can connect to the cluster _, err = clientSet.CoreV1().Namespaces().List(context.Background(), v1.ListOptions{}) if err != nil { return err } t.Name = name t.Kubeconfig = kubeconfig t.ClientSet = clientSet return nil } func (t *TestCluster) Start() error { clusterName := "local-tests" log.Println("🔍 Trying to connect to existing cluster") err := t.ConnectExisting(clusterName) if err != nil { // If there is an error then create out own cluster log.Println("🤞 Creating Kubernetes cluster using Kind") clusterConfig := new(v1alpha4.Cluster) // Read environment variables to check for kube version if version, ok := os.LookupEnv("KUBE_VERSION"); ok { log.Printf("⚙️ Setting custom Kubernetes version: %v\n", version) clusterConfig.Nodes = []v1alpha4.Node{ { Role: v1alpha4.ControlPlaneRole, Image: fmt.Sprintf("kindest/node:%v", version), }, } } t.provider = cluster.NewProvider() err = t.provider.Create(clusterName, cluster.CreateWithV1Alpha4Config(clusterConfig)) if err != nil { return err } // Connect to the cluster we just created err = t.ConnectExisting(clusterName) if err != nil { return err } err = t.provider.ExportKubeConfig(t.Name, t.Kubeconfig, false) if err != nil { return err } } log.Printf("🐚 Ensuring test namespace %v exists\n", TestNamespace) err = t.Apply(TestNamespaceYAML) if err != nil { return err } return nil } // func (t *TestCluster) ApplyBaselineConfig() error { // return t.Apply(ClusterBaseline) // } // Apply Runs of `kubectl apply -f` for a given string of YAML func (t *TestCluster) Apply(yaml string) error { return t.kubectl("apply", yaml) } // Delete Runs of `kubectl delete -f` for a given string of YAML func (t *TestCluster) Delete(yaml string) error { return t.kubectl("delete", yaml) } func (t *TestCluster) kubectl(method string, yaml string) error { var stdout bytes.Buffer var stderr bytes.Buffer // Create temp file to write config to config, err := os.CreateTemp("", "*-conf.yaml") if err != nil { return err } _, err = config.WriteString(yaml) if err != nil { return err } cmd := exec.CommandContext(context.Background(), "kubectl", method, "-f", config.Name()) cmd.Stdout = &stdout cmd.Stderr = &stderr cmd.Dir = filepath.Dir(config.Name()) // Inherit from the ENV cmd.Env = os.Environ() // Set KUBECONFIG location cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%v", t.Kubeconfig)) // Run the command err = cmd.Run() if err != nil { return fmt.Errorf("%w\nstdout: %v\nstderr: %v", err, stdout.String(), stderr.String()) } if e := stderr.String(); e != "" { return errors.New(e) } return nil } func (t *TestCluster) Stop() error { if t.provider != nil { log.Println("🏁 Destroying cluster") return t.provider.Delete(t.Name, t.Kubeconfig) } return nil } var CurrentCluster TestCluster func TestMain(m *testing.M) { CurrentCluster = TestCluster{} err := CurrentCluster.Start() if err != nil { log.Fatal(err) os.Exit(1) } // log.Println("🎁 Creating resources in cluster for testing") // err = CurrentCluster.ApplyBaselineConfig() // if err != nil { // log.Fatal(err) // os.Exit(1) // } log.Println("✅ Running tests") code := m.Run() err = CurrentCluster.Stop() if err != nil { log.Fatal(err) os.Exit(1) } os.Exit(code) } ================================================ FILE: k8s-source/adapters/shared_util.go ================================================ package adapters import ( "encoding/json" "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type ScopeDetails struct { ClusterName string Namespace string } func (sd ScopeDetails) String() string { if sd.Namespace == "" { return sd.ClusterName } return fmt.Sprintf("%v.%v", sd.ClusterName, sd.Namespace) } // ParseScope Parses the custer and scope name out of a given SDP scope given // that the naming convention is {clusterName}.{namespace}. Since all adapters // know whether they are namespaced or not, we can just pass that in to make // parsing easier func ParseScope(itemScope string, namespaced bool) (ScopeDetails, error) { sections := strings.Split(itemScope, ".") var namespace string var clusterEnd int var clusterName string if namespaced { if len(sections) < 2 { return ScopeDetails{}, fmt.Errorf("scope %v does not contain a namespace in the format: {clusterName}.{namespace}", itemScope) } namespace = sections[len(sections)-1] clusterEnd = len(sections) - 1 } else { namespace = "" clusterEnd = len(sections) } clusterName = strings.Join(sections[:clusterEnd], ".") if clusterName == "" { return ScopeDetails{}, fmt.Errorf("cluster name was blank for scope %v", itemScope) } return ScopeDetails{ ClusterName: clusterName, Namespace: namespace, }, nil } // Selector represents a set of key value pairs that we are going to use as a // selector type Selector map[string]string // String converts a set of key value pairs to the string format that a selector // is expecting func (l Selector) String() string { var conditions []string conditions = make([]string, 0) for k, v := range l { conditions = append(conditions, fmt.Sprintf("%v=%v", k, v)) } return strings.Join(conditions, ",") } func ListOptionsToQuery(lo *metav1.ListOptions) string { jsonData, err := json.Marshal(lo) if err == nil { return string(jsonData) } return "" } // LabelSelectorToQuery converts a LabelSelector to JSON so that it can be // passed to a SEARCH query func LabelSelectorToQuery(labelSelector *metav1.LabelSelector) string { return ListOptionsToQuery(&metav1.ListOptions{ LabelSelector: Selector(labelSelector.MatchLabels).String(), }) } // QueryToListOptions converts a Search() query string to a ListOptions object that can // be used to query the API func QueryToListOptions(query string) (metav1.ListOptions, error) { var queryBytes []byte var err error var listOptions metav1.ListOptions queryBytes = []byte(query) // Convert from JSON if err = json.Unmarshal(queryBytes, &listOptions); err != nil { return listOptions, err } // Override some of the things we don't want people to set listOptions.Watch = false return listOptions, nil } var Metadata = sdp.AdapterMetadataList{} ================================================ FILE: k8s-source/adapters/shared_util_test.go ================================================ package adapters import "testing" func TestParseScope(t *testing.T) { type ParseTest struct { Input string ClusterName string Namespace string IsNamespaced bool ExpectError bool } tests := []ParseTest{ { Input: "127.0.0.1:61081.default", ClusterName: "127.0.0.1:61081", Namespace: "default", IsNamespaced: true, }, { Input: "127.0.0.1:61081.kube-node-lease", ClusterName: "127.0.0.1:61081", Namespace: "kube-node-lease", IsNamespaced: true, }, { Input: "127.0.0.1:61081.kube-public", ClusterName: "127.0.0.1:61081", Namespace: "kube-public", IsNamespaced: true, }, { Input: "127.0.0.1:61081.kube-system", ClusterName: "127.0.0.1:61081", Namespace: "kube-system", IsNamespaced: true, }, { Input: "127.0.0.1:61081", ClusterName: "127.0.0.1:61081", Namespace: "", IsNamespaced: false, }, { Input: "cluster1.k8s.company.com:443", ClusterName: "cluster1.k8s.company.com:443", Namespace: "", IsNamespaced: false, }, { Input: "cluster1.k8s.company.com", ClusterName: "cluster1.k8s.company.com", Namespace: "", IsNamespaced: false, }, { Input: "test", ClusterName: "test", Namespace: "", IsNamespaced: false, }, { Input: "prod.default", ClusterName: "prod", Namespace: "default", IsNamespaced: true, }, { Input: "prod", ClusterName: "", Namespace: "prod", IsNamespaced: true, ExpectError: true, }, { Input: "prod.default.test", ClusterName: "prod.default.test", Namespace: "", IsNamespaced: false, }, { Input: "prod.default.test", ClusterName: "prod.default", Namespace: "test", IsNamespaced: true, }, { Input: "", ClusterName: "", Namespace: "", IsNamespaced: false, ExpectError: true, }, { Input: "", ClusterName: "", Namespace: "", IsNamespaced: true, ExpectError: true, }, } for _, test := range tests { result, err := ParseScope(test.Input, test.IsNamespaced) if test.ExpectError { if err == nil { t.Errorf("Expected error, but got none. Test %v", test) } } else { if err != nil { t.Errorf("Unexpected error: %v", err) } if test.ClusterName != result.ClusterName { t.Errorf("ClusterName did not match, expected %v, got %v", test.ClusterName, result.ClusterName) } if test.Namespace != result.Namespace { t.Errorf("Namespace did not match, expected %v, got %v", test.Namespace, result.Namespace) } } } } ================================================ FILE: k8s-source/adapters/statefulset.go ================================================ package adapters import ( v1 "k8s.io/api/apps/v1" metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "k8s.io/client-go/kubernetes" ) func statefulSetExtractor(resource *v1.StatefulSet, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.Selector != nil { // Stateful sets are linked to pods via their selector queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Pod", Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(resource.Spec.Selector), Scope: scope, }, }) if len(resource.Spec.VolumeClaimTemplates) > 0 { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "PersistentVolumeClaim", Method: sdp.QueryMethod_SEARCH, Query: LabelSelectorToQuery(resource.Spec.Selector), Scope: scope, }, }) } } if resource.Spec.ServiceName != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Scope: scope, Method: sdp.QueryMethod_SEARCH, Query: ListOptionsToQuery(&metaV1.ListOptions{ FieldSelector: Selector{ "metadata.name": resource.Spec.ServiceName, "metadata.namespace": resource.Namespace, }.String(), }), Type: "Service", }, }) } return queries, nil } func newStatefulSetAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.StatefulSet, *v1.StatefulSetList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "StatefulSet", AutoQueryExtract: true, NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.StatefulSet, *v1.StatefulSetList] { return cs.AppsV1().StatefulSets(namespace) }, ListExtractor: func(list *v1.StatefulSetList) ([]*v1.StatefulSet, error) { extracted := make([]*v1.StatefulSet, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: statefulSetExtractor, AdapterMetadata: statefulSetAdapterMetadata, } } var statefulSetAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "StatefulSet", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, DescriptiveName: "Stateful Set", SupportedQueryMethods: DefaultSupportedQueryMethods("Stateful Set"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_stateful_set_v1.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_stateful_set.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newStatefulSetAdapter) } ================================================ FILE: k8s-source/adapters/statefulset_test.go ================================================ package adapters import ( "regexp" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var statefulSetYAML = ` apiVersion: apps/v1 kind: StatefulSet metadata: name: stateful-set-test spec: serviceName: nginx replicas: 1 selector: matchLabels: app: stateful-set-test template: metadata: labels: app: stateful-set-test spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 volumeMounts: - name: stateful-set-test-storage mountPath: /usr/share/nginx/html volumeClaimTemplates: - metadata: name: stateful-set-test-storage spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 1Gi storageClassName: standard ` func TestStatefulSetAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newStatefulSetAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "stateful-set-test", GetScope: sd.String(), SetupYAML: statefulSetYAML, GetQueryTests: QueryTests{ { ExpectedType: "Pod", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQueryMatches: regexp.MustCompile(`app=stateful-set-test`), ExpectedScope: sd.String(), }, { ExpectedType: "PersistentVolumeClaim", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQueryMatches: regexp.MustCompile(`app=stateful-set-test`), ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/storageclass.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/storage/v1" "k8s.io/client-go/kubernetes" ) func newStorageClassAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.StorageClass, *v1.StorageClassList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "StorageClass", ClusterInterfaceBuilder: func() ItemInterface[*v1.StorageClass, *v1.StorageClassList] { return cs.StorageV1().StorageClasses() }, ListExtractor: func(list *v1.StorageClassList) ([]*v1.StorageClass, error) { extracted := make([]*v1.StorageClass, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, AdapterMetadata: storageClassAdapterMetadata, } } var storageClassAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "StorageClass", DescriptiveName: "Storage Class", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, SupportedQueryMethods: DefaultSupportedQueryMethods("Storage Class"), TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_storage_class.metadata[0].name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "kubernetes_storage_class_v1.metadata[0].name", }, }, }) func init() { registerAdapterLoader(newStorageClassAdapter) } ================================================ FILE: k8s-source/adapters/storageclass_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdpcache" ) var storageClassYAML = ` apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: storage-class-test provisioner: kubernetes.io/aws-ebs parameters: type: gp2 ` func TestStorageClassAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, Namespace: "default", } adapter := newStorageClassAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "storage-class-test", GetScope: sd.String(), SetupYAML: storageClassYAML, } st.Execute(t) } ================================================ FILE: k8s-source/adapters/volumeattachment.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" v1 "k8s.io/api/storage/v1" "k8s.io/client-go/kubernetes" ) func volumeAttachmentExtractor(resource *v1.VolumeAttachment, scope string) ([]*sdp.LinkedItemQuery, error) { queries := make([]*sdp.LinkedItemQuery, 0) if resource.Spec.Source.PersistentVolumeName != nil { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "PersistentVolume", Method: sdp.QueryMethod_GET, Query: *resource.Spec.Source.PersistentVolumeName, Scope: scope, }, }) } if resource.Spec.NodeName != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "Node", Method: sdp.QueryMethod_GET, Query: resource.Spec.NodeName, Scope: scope, }, }) } return queries, nil } func newVolumeAttachmentAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { return &KubeTypeAdapter[*v1.VolumeAttachment, *v1.VolumeAttachmentList]{ ClusterName: cluster, Namespaces: namespaces, cache: cache, TypeName: "VolumeAttachment", ClusterInterfaceBuilder: func() ItemInterface[*v1.VolumeAttachment, *v1.VolumeAttachmentList] { return cs.StorageV1().VolumeAttachments() }, ListExtractor: func(list *v1.VolumeAttachmentList) ([]*v1.VolumeAttachment, error) { extracted := make([]*v1.VolumeAttachment, len(list.Items)) for i := range list.Items { extracted[i] = &list.Items[i] } return extracted, nil }, LinkedItemQueryExtractor: volumeAttachmentExtractor, HealthExtractor: func(resource *v1.VolumeAttachment) *sdp.Health { if resource.Status.AttachError != nil || resource.Status.DetachError != nil { return sdp.Health_HEALTH_ERROR.Enum() } return sdp.Health_HEALTH_OK.Enum() }, AdapterMetadata: volumeAttachmentAdapterMetadata, } } var volumeAttachmentAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{ Type: "VolumeAttachment", DescriptiveName: "Volume Attachment", Category: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, PotentialLinks: []string{"PersistentVolume", "Node"}, SupportedQueryMethods: DefaultSupportedQueryMethods("VolumeAttachment"), }) func init() { registerAdapterLoader(newVolumeAttachmentAdapter) } ================================================ FILE: k8s-source/adapters/volumeattachment_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) var volumeAttachmentYAML = ` apiVersion: v1 kind: PersistentVolumeClaim metadata: name: volume-attachment-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi storageClassName: standard --- apiVersion: v1 kind: PersistentVolume metadata: name: volume-attachment-pv spec: capacity: storage: 1Gi accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain hostPath: path: /data --- apiVersion: v1 kind: Pod metadata: name: volume-attachment-pod spec: containers: - name: volume-attachment-container image: nginx volumeMounts: - name: volume-attachment-volume mountPath: /data volumes: - name: volume-attachment-volume persistentVolumeClaim: claimName: volume-attachment-pvc --- apiVersion: storage.k8s.io/v1 kind: VolumeAttachment metadata: name: volume-attachment-attachment spec: nodeName: local-tests-control-plane attacher: kubernetes.io source: persistentVolumeName: volume-attachment-pv ` func TestVolumeAttachmentAdapter(t *testing.T) { sd := ScopeDetails{ ClusterName: CurrentCluster.Name, } adapter := newVolumeAttachmentAdapter(CurrentCluster.ClientSet, sd.ClusterName, []string{sd.Namespace}, sdpcache.NewNoOpCache()) st := AdapterTests{ Adapter: adapter, GetQuery: "volume-attachment-attachment", GetScope: sd.String(), SetupYAML: volumeAttachmentYAML, GetQueryTests: QueryTests{ { ExpectedType: "PersistentVolume", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "volume-attachment-pv", ExpectedScope: sd.String(), }, { ExpectedType: "Node", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "local-tests-control-plane", ExpectedScope: sd.String(), }, }, } st.Execute(t) } ================================================ FILE: k8s-source/build/package/Dockerfile ================================================ # Build the source binary FROM golang:1.26.2-alpine3.23 AS builder ARG TARGETOS ARG TARGETARCH ARG BUILD_VERSION ARG BUILD_COMMIT # required for accessing the private dependencies and generating version descriptor RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg \ go mod download COPY go/ go/ COPY k8s-source/ k8s-source/ # Build RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source k8s-source/main.go FROM alpine:3.23.4 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 ENTRYPOINT ["/source"] ================================================ FILE: k8s-source/cmd/root.go ================================================ package cmd import ( "context" "crypto/sha256" "errors" "fmt" "math/rand" "net" "net/http" "net/url" "os" "os/signal" "strings" "syscall" "time" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/k8s-source/adapters" "github.com/overmindtech/cli/k8s-source/proc" "github.com/overmindtech/cli/go/logging" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/uptrace/opentelemetry-go-extra/otellogrus" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/flowcontrol" ) var cfgFile string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "k8s-source", Short: "Kubernetes source", SilenceUsage: true, Long: `Gathers details from existing kubernetes clusters `, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() defer tracing.LogRecoverToReturn(ctx, "k8s-source.root") // get engine config engineConfig, err := discovery.EngineConfigFromViper("k8s", tracing.Version()) if err != nil { log.WithError(err).Error("Could not get engine config from viper") return fmt.Errorf("could not get engine config from viper: %w", err) } // Best-effort: derive cluster-specific NATS queue name before Start(). // This loads the kubeconfig just to hash the rest config string for the // queue name. If it fails (e.g. in-cluster config not yet available), // we continue with the default queue name — the underlying error will // surface again after Start() via SetInitError. if restCfg, loadErr := loadRestConfig(viper.GetString("kubeconfig")); loadErr == nil { configHash := fmt.Sprintf("%x", sha256.Sum256([]byte(restCfg.String()))) engineConfig.NATSQueueName = fmt.Sprintf("k8s-source-%v", configHash) } if engineConfig.HeartbeatOptions == nil { engineConfig.HeartbeatOptions = &discovery.HeartbeatOptions{} } e, err := discovery.NewEngine(engineConfig) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Error initializing Engine") return fmt.Errorf("error initializing engine: %w", err) } // ReadinessCheck verifies adapters are healthy by using a Node adapter // Timeout is handled by SendHeartbeat, HTTP handlers rely on request context e.SetReadinessCheck(func(ctx context.Context) error { // Find a Node adapter to verify adapter health adapters := e.AdaptersByType("Node") if len(adapters) == 0 { return fmt.Errorf("readiness check failed: no Node adapters available") } // Use first adapter and try to list from first scope adapter := adapters[0] scopes := adapter.Scopes() if len(scopes) == 0 { return fmt.Errorf("readiness check failed: no scopes available for Node adapter") } listableAdapter, ok := adapter.(discovery.ListableAdapter) if !ok { return fmt.Errorf("readiness check failed: Node adapter is not listable") } _, err := listableAdapter.List(ctx, scopes[0], true) if err != nil { return fmt.Errorf("readiness check (listing nodes) failed: %w", err) } return nil }) // Serve health probes before initialization so they're available even on failure e.ServeHealthProbes(viper.GetInt("health-check-port")) // Start the engine (NATS connection) before config validation so heartbeats work err = e.Start(ctx) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not start engine") return fmt.Errorf("could not start engine: %w", err) } // Config validation and K8s client setup (permanent errors — SetInitError, stay running) var loadAdapters func(ctx context.Context) error reload := make(chan watch.Event, 1024) k8sCfg, clientSet, clusterName, cfgErr := createK8sClient() if cfgErr != nil { log.WithError(cfgErr).Error("K8s source config error - pod will stay running with error status") e.SetInitError(cfgErr) sentry.CaptureException(cfgErr) } else { log.WithFields(log.Fields{ "kubeconfig": k8sCfg.Kubeconfig, "cluster-name": clusterName, }).Info("Got config") // loadAdapters is the single-attempt adapter init function that lists // namespaces, creates adapters, and adds them to the engine. loadAdapters = func(ctx context.Context) error { log.Info("Listing namespaces") list, err := clientSet.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) if err != nil { return fmt.Errorf("could not list namespaces: %w", err) } namespaces := make([]string, len(list.Items)) for i := range list.Items { namespaces[i] = list.Items[i].Name } log.WithField("count", len(namespaces)).Info("Got namespaces") // Create a shared cache for all adapters in this source sharedCache := sdpcache.NewCache(ctx) // Create the adapter list adapterList := adapters.LoadAllAdapters(clientSet, clusterName, namespaces, sharedCache) // Add adapters to the engine return e.AddAdapters(adapterList...) } // Use InitialiseAdapters for the initial load (retries with backoff) e.InitialiseAdapters(ctx, loadAdapters) // Set up namespace watch for dynamic restarts watchCtx, watchCancel := context.WithCancel(ctx) defer watchCancel() go func() { defer tracing.LogRecoverToReturn(watchCtx, "Namespace watch setup") // Wait briefly for initial adapter loading to complete or make progress // before starting the namespace watch wi, err := watchNamespaces(watchCtx, clientSet) if err != nil { watchErr := fmt.Errorf("could not start namespace watch: %w", err) log.WithError(watchErr).Error("K8s namespace watch failed - pod will stay running with error status") e.SetInitError(watchErr) sentry.CaptureException(watchErr) return } defer tracing.LogRecoverToReturn(watchCtx, "Namespace watch") attempts := 0 sleep := 1 * time.Second for { select { case event, ok := <-wi.ResultChan(): if !ok { // When the channel is closed then we need to restart the // watch. This happens regularly on EKS. log.Debug("Namespace watch channel closed, re-subscribing") wi, err = watchNamespaces(watchCtx, clientSet) // Check for transient network errors if err != nil { var netErr *net.OpError if errors.As(err, &netErr) { // Mark a failure attempts++ // If we have had less than 3 failures then retry if attempts < 4 { // The watch interface will be nil if we // couldn't connect, so create a fake watcher // that is closed so that we end up in this loop // again wi = watch.NewFake() wi.Stop() jitter := time.Duration(rand.Int63n(int64(sleep))) //nolint:gosec // we don't need cryptographically secure randomness here sleep = sleep + jitter/2 log.WithError(err).WithField("retry_in", sleep.String()).Error("Transient network error, retrying") time.Sleep(sleep) continue } } sentry.CaptureException(err) log.WithError(err).Error("could not resubscribe to namespace watch") // Send a fatal event reload <- watch.Event{ Type: watch.EventType("FATAL"), } return } // If it's worked, reset the failure counter attempts = 0 } else { // If a watch event is received then we need to reload adapters reload <- event } case <-watchCtx.Done(): return } } }() } defer func() { err := e.Stop() if err != nil { sentry.CaptureException(fmt.Errorf("could not stop engine: %w", err)) log.WithError(err).Error("Could not stop engine") } }() for { select { case <-ctx.Done(): log.Info("Stopping engine") return nil case event := <-reload: switch event.Type { //nolint:exhaustive // we on purpose fall through to default case "": // Discard empty events. After a certain period kubernetes // starts sending occasional empty events, I can't work out why, // maybe it's to keep the connection open. Either way they don't // represent anything and should be discarded log.Debug("Discarding empty event") case "FATAL": // This is a custom event type from permanent watch failures // Don't exit - store error and continue in degraded state fatalErr := fmt.Errorf("permanent failure in namespace watch after retries") log.WithError(fatalErr).Error("K8s namespace watch failed permanently - pod will stay running with error status") e.SetInitError(fatalErr) sentry.CaptureException(fatalErr) case "MODIFIED": log.Debug("Namespace modified, ignoring") default: // Namespace added/deleted: reload adapters log.WithField("event_type", event.Type).Info("Namespace change detected, reloading adapters") e.ClearAdapters() if reloadErr := loadAdapters(ctx); reloadErr != nil { initErr := fmt.Errorf("could not reload adapters after namespace change: %w", reloadErr) log.WithError(initErr).Error("K8s source reload failed - pod will stay running with error status") e.SetInitError(initErr) sentry.CaptureException(initErr) } else { // Reload succeeded, clear any previous init error e.SetInitError(nil) log.Info("K8s source reloaded successfully") } } } } }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } // loadRestConfig loads a Kubernetes rest.Config from the given kubeconfig path. // If the path is empty, in-cluster config is used. func loadRestConfig(kubeconfig string) (*rest.Config, error) { if kubeconfig == "" { return rest.InClusterConfig() } return clientcmd.BuildConfigFromFlags("", kubeconfig) } // createK8sClient validates the K8s source config from viper, creates a // Kubernetes client, and determines the cluster name. All failures are // permanent config errors that should be reported via SetInitError. func createK8sClient() (*proc.K8sConfig, *kubernetes.Clientset, string, error) { k8sCfg, err := proc.ConfigFromViper() if err != nil { return nil, nil, "", err } restConfig, err := loadRestConfig(k8sCfg.Kubeconfig) if err != nil { return nil, nil, "", fmt.Errorf("could not load kubernetes config: %w", err) } restConfig.Wrap(func(rt http.RoundTripper) http.RoundTripper { return otelhttp.NewTransport(rt) }) restConfig.RateLimiter = flowcontrol.NewTokenBucketRateLimiter( float32(k8sCfg.RateLimitQPS), k8sCfg.RateLimitBurst, ) clientSet, err := kubernetes.NewForConfig(restConfig) if err != nil { return nil, nil, "", fmt.Errorf("could not create kubernetes client: %w", err) } k8sURL, err := url.Parse(restConfig.Host) if err != nil { return nil, nil, "", fmt.Errorf("could not parse kubernetes url %v: %w", restConfig.Host, err) } if k8sURL.Port() == "" { switch k8sURL.Scheme { case "http": k8sURL.Host = k8sURL.Host + ":80" case "https": k8sURL.Host = k8sURL.Host + ":443" } } clusterName := k8sCfg.ClusterName if clusterName == "" { clusterName = k8sURL.Host } return k8sCfg, clientSet, clusterName, nil } // Watches k8s namespaces from the current state, sending new events for each change func watchNamespaces(ctx context.Context, clientSet *kubernetes.Clientset) (watch.Interface, error) { // Get the initial starting point list, err := clientSet.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) if err != nil { return nil, err } // Watch namespaces from here wi, err := clientSet.CoreV1().Namespaces().Watch(ctx, metav1.ListOptions{ ResourceVersion: list.ResourceVersion, }) if err != nil { return nil, err } return wi, nil } func init() { cobra.OnInitialize(initConfig) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. var logLevel string rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "/etc/srcman/config/source.yaml", "config file path") rootCmd.PersistentFlags().StringVar(&logLevel, "log", "info", "Set the log level. Valid values: panic, fatal, error, warn, info, debug, trace") rootCmd.PersistentFlags().Int("health-check-port", 8080, "The port on which to serve health check endpoints (/healthz/alive, /healthz/ready)") // engine flags discovery.AddEngineFlags(rootCmd) // source-specific flags rootCmd.PersistentFlags().String("kubeconfig", "", "Path to the kubeconfig file containing cluster details. If this is blank, the in-cluster config will be used") rootCmd.PersistentFlags().Float32("rate-limit-qps", 10.0, "The maximum sustained queries per second from this source to the kubernetes API") rootCmd.PersistentFlags().Int("rate-limit-burst", 30, "The maximum burst of queries from this source to the kubernetes API") rootCmd.PersistentFlags().String("cluster-name", "", "The descriptive name of the cluster this source is running on. If this is blank, the hostname will be used from the Kube config") // tracing rootCmd.PersistentFlags().String("honeycomb-api-key", "", "If specified, configures opentelemetry libraries to submit traces to honeycomb") rootCmd.PersistentFlags().String("sentry-dsn", "", "If specified, configures sentry libraries to capture errors") rootCmd.PersistentFlags().String("run-mode", "release", "Set the run mode for this service, 'release', 'debug' or 'test'. Defaults to 'release'.") rootCmd.PersistentFlags().Bool("json-log", true, "Set to false to emit logs as text for easier reading in development.") cobra.CheckErr(viper.BindEnv("json-log", "K8S_SOURCE_JSON_LOG", "JSON_LOG")) // fallback to global config // Bind these to viper cobra.CheckErr(viper.BindPFlags(rootCmd.PersistentFlags())) // Run this before we do anything to set up the loglevel rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if lvl, err := log.ParseLevel(logLevel); err == nil { log.SetLevel(lvl) } else { log.SetLevel(log.InfoLevel) } log.AddHook(TerminationLogHook{}) log.AddHook(otellogrus.NewHook(otellogrus.WithLevels( log.AllLevels[:log.GetLevel()+1]..., ))) // Bind flags that haven't been set to the values from viper of we have them var bindErr error cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { // Bind the flag to viper only if it has a non-empty default if f.DefValue != "" || f.Changed { if err := viper.BindPFlag(f.Name, f); err != nil { bindErr = err } } }) if bindErr != nil { log.WithError(bindErr).Error("could not bind flag to viper") return fmt.Errorf("could not bind flag to viper: %w", bindErr) } if viper.GetBool("json-log") { logging.ConfigureLogrusJSON(log.StandardLogger()) } if err := tracing.InitTracerWithUpstreams("k8s-source", viper.GetString("honeycomb-api-key"), viper.GetString("sentry-dsn")); err != nil { log.WithError(err).Error("could not init tracer") return fmt.Errorf("could not init tracer: %w", err) } return nil } // shut down tracing at the end of the process rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { tracing.ShutdownTracer(context.Background()) } } // initConfig reads in config file and ENV variables if set. func initConfig() { viper.SetConfigFile(cfgFile) replacer := strings.NewReplacer("-", "_") viper.SetEnvKeyReplacer(replacer) viper.AutomaticEnv() // read in environment variables that match // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil { log.Infof("Using config file: %v", viper.ConfigFileUsed()) } } // TerminationLogHook A hook that logs fatal errors to the termination log type TerminationLogHook struct{} func (t TerminationLogHook) Levels() []log.Level { return []log.Level{log.FatalLevel} } func (t TerminationLogHook) Fire(e *log.Entry) error { // shutdown tracing first to ensure all spans are flushed tracing.ShutdownTracer(context.Background()) tLog, err := os.OpenFile("/dev/termination-log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } var message string message = e.Message for k, v := range e.Data { message = fmt.Sprintf("%v %v=%v", message, k, v) } _, err = tLog.WriteString(message) return err } ================================================ FILE: k8s-source/config.json ================================================ { "auths": { "ghcr.io": {}, }, "credsStore": "desktop" } ================================================ FILE: k8s-source/cr.sh ================================================ #!/usr/bin/env bash # Copyright The Helm Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. set -o errexit set -o nounset set -o pipefail DEFAULT_CHART_RELEASER_VERSION=v1.5.0 show_help() { cat << EOF Usage: $(basename "$0") -h, --help Display help -v, --version The chart-releaser version to use (default: $DEFAULT_CHART_RELEASER_VERSION)" --config The path to the chart-releaser config file -d, --charts-dir The charts directory (default: charts) -o, --owner The repo owner -r, --repo The repo name -n, --install-dir The Path to install the cr tool -i, --install-only Just install the cr tool -s, --skip-packaging Skip the packaging step (run your own packaging before using the releaser) --skip-existing Skip package upload if release exists -l, --mark-as-latest Mark the created GitHub release as 'latest' (default: true) EOF } main() { local version="$DEFAULT_CHART_RELEASER_VERSION" local config= local charts_dir=charts local owner= local repo= local install_dir= local install_only= local skip_packaging= local skip_existing= local mark_as_latest=true parse_command_line "$@" : "${CR_TOKEN:?Environment variable CR_TOKEN must be set}" local repo_root repo_root=$(git rev-parse --show-toplevel) pushd "$repo_root" > /dev/null if ! [[ -n "$skip_packaging" ]]; then echo 'Looking up latest tag...' local latest_tag latest_tag=$(lookup_latest_tag) echo "Discovering changed charts since '$latest_tag'..." local changed_charts=() readarray -t changed_charts <<< "$(lookup_changed_charts "$latest_tag")" if [[ -n "${changed_charts[*]}" ]]; then install_chart_releaser rm -rf .cr-release-packages mkdir -p .cr-release-packages rm -rf .cr-index mkdir -p .cr-index for chart in "${changed_charts[@]}"; do if [[ -d "$chart" ]]; then package_chart "$chart" else echo "Chart '$chart' no longer exists in repo. Skipping it..." fi done release_charts update_index else echo "Nothing to do. No chart changes detected." fi else install_chart_releaser rm -rf .cr-index mkdir -p .cr-index release_charts update_index fi popd > /dev/null } parse_command_line() { while :; do case "${1:-}" in -h|--help) show_help exit ;; --config) if [[ -n "${2:-}" ]]; then config="$2" shift else echo "ERROR: '--config' cannot be empty." >&2 show_help exit 1 fi ;; -v|--version) if [[ -n "${2:-}" ]]; then version="$2" shift else echo "ERROR: '-v|--version' cannot be empty." >&2 show_help exit 1 fi ;; -d|--charts-dir) if [[ -n "${2:-}" ]]; then charts_dir="$2" shift else echo "ERROR: '-d|--charts-dir' cannot be empty." >&2 show_help exit 1 fi ;; -o|--owner) if [[ -n "${2:-}" ]]; then owner="$2" shift else echo "ERROR: '--owner' cannot be empty." >&2 show_help exit 1 fi ;; -r|--repo) if [[ -n "${2:-}" ]]; then repo="$2" shift else echo "ERROR: '--repo' cannot be empty." >&2 show_help exit 1 fi ;; -n|--install-dir) if [[ -n "${2:-}" ]]; then install_dir="$2" shift fi ;; -i|--install-only) if [[ -n "${2:-}" ]]; then install_only="$2" shift fi ;; -s|--skip-packaging) if [[ -n "${2:-}" ]]; then skip_packaging="$2" shift fi ;; --skip-existing) if [[ -n "${2:-}" ]]; then skip_existing="$2" shift fi ;; -l|--mark-as-latest) if [[ -n "${2:-}" ]]; then mark_as_latest="$2" shift fi ;; *) break ;; esac shift done if [[ -z "$owner" ]]; then echo "ERROR: '-o|--owner' is required." >&2 show_help exit 1 fi if [[ -z "$repo" ]]; then echo "ERROR: '-r|--repo' is required." >&2 show_help exit 1 fi if [[ -z "$install_dir" ]]; then local arch arch=$(uname -m) install_dir="$RUNNER_TOOL_CACHE/cr/$version/$arch" fi if [[ -n "$install_only" ]]; then echo "Will install cr tool and not run it..." install_chart_releaser exit 0 fi } install_chart_releaser() { if [[ ! -d "$RUNNER_TOOL_CACHE" ]]; then echo "Cache directory '$RUNNER_TOOL_CACHE' does not exist" >&2 exit 1 fi if [[ ! -d "$install_dir" ]]; then mkdir -p "$install_dir" echo "Installing chart-releaser on $install_dir..." curl -sSLo cr.tar.gz "https://github.com/helm/chart-releaser/releases/download/$version/chart-releaser_${version#v}_linux_amd64.tar.gz" tar -xzf cr.tar.gz -C "$install_dir" rm -f cr.tar.gz fi echo 'Adding cr directory to PATH...' export PATH="$install_dir:$PATH" } lookup_latest_tag() { git fetch --tags > /dev/null 2>&1 if ! git describe --tags --abbrev=0 HEAD~ 2> /dev/null; then git rev-list --max-parents=0 --first-parent HEAD fi } filter_charts() { while read -r chart; do [[ ! -d "$chart" ]] && continue local file="$chart/Chart.yaml" if [[ -f "$file" ]]; then echo "$chart" else echo "WARNING: $file is missing, assuming that '$chart' is not a Helm chart. Skipping." 1>&2 fi done } lookup_changed_charts() { local commit="$1" local changed_files changed_files=$(git diff --find-renames --name-only "$commit" -- "$charts_dir") local depth=$(( $(tr "/" "\n" <<< "$charts_dir" | sed '/^\(\.\)*$/d' | wc -l) + 1 )) local fields="1-${depth}" cut -d '/' -f "$fields" <<< "$changed_files" | uniq | filter_charts } package_chart() { local chart="$1" local args=("$chart" --package-path .cr-release-packages) if [[ -n "$config" ]]; then args+=(--config "$config") fi echo "Packaging chart '$chart'..." cr package "${args[@]}" } release_charts() { local args=(-o "$owner" -r "$repo" -c "$(git rev-parse HEAD)") if [[ -n "$config" ]]; then args+=(--config "$config") fi if [[ -n "$skip_existing" ]]; then args+=(--skip-existing) fi if [[ "$mark_as_latest" = false ]]; then args+=(--make-release-latest=false) fi echo 'Releasing charts...' cr upload "${args[@]}" } update_index() { local args=(-o "$owner" -r "$repo" --push) if [[ -n "$config" ]]; then args+=(--config "$config") fi echo 'Updating charts repo index...' cr index "${args[@]}" } main "$@" ================================================ FILE: k8s-source/deployments/overmind-kube-source/.helmignore ================================================ # Patterns to ignore when building packages. # This supports shell glob matching, relative path matching, and # negation (prefixed with !). Only one pattern per line. .DS_Store # Common VCS dirs .git/ .gitignore .bzr/ .bzrignore .hg/ .hgignore .svn/ # Common backup files *.swp *.bak *.tmp *.orig *~ # Various IDEs .project .idea/ *.tmproj .vscode/ ================================================ FILE: k8s-source/deployments/overmind-kube-source/Chart.yaml ================================================ apiVersion: v2 name: overmind-kube-source description: A source that allows Overmind to read from the current Kubernetes cluster # A chart can be either an 'application' or a 'library' chart. # # Application charts are a collection of templates that can be packaged into versioned archives # to be deployed. # # Library charts provide useful utilities or functions for the chart developer. They're included as # a dependency of application charts to inject those utilities and functions into the rendering # pipeline. Library charts do not define any templates and therefore cannot be deployed. type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) # # This is set during CI version: 0.0.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. # # This is set during CI appVersion: "0.0.0" ================================================ FILE: k8s-source/deployments/overmind-kube-source/README.md ================================================ # K8s Source Helm Chart ## Developing Installing into a local cluster: ```shell helm install k8s-source deployments/overmind-kube-source \ --set source.apiKey.value=YOUR_API_KEY \ --set source.clusterName=my-cluster ``` ### Production Configuration Example For production deployments (single replica with PDB enabled by default): ```shell helm install k8s-source deployments/overmind-kube-source \ --set source.apiKey.value=YOUR_API_KEY \ --set source.clusterName=production-cluster ``` **Note**: The k8s source typically has very low load, so a single replica is usually sufficient. PDB is enabled by default to protect against maintenance operations, and the deployment uses a rolling update strategy with `maxUnavailable: 1` to ensure zero-downtime updates. Removing the chart: ```shell helm uninstall k8s-source ``` ## Releasing These charts are automatically released and pushed to Cloudsmith when the monorepo is tagged with a version in the following format `k8s-source/v1.2.3`. This will cause the docker container to be built, tagged with `1.2.3`, pushed, and a new corresponding helm chart released. See `.github/workflows/k8s-source-release.yml` for more details ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/NOTES.txt ================================================ The overmind source has now been installed ✅ ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/_helpers.tpl ================================================ {{/* Expand the name of the chart. */}} {{- define "overmind-kube-source.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} {{- define "overmind-kube-source.fullname" -}} {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} {{- $name := default .Chart.Name .Values.nameOverride }} {{- if contains $name .Release.Name }} {{- .Release.Name | trunc 63 | trimSuffix "-" }} {{- else }} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{- end }} {{- end }} {{- end }} {{/* Create chart name and version as used by the chart label. */}} {{- define "overmind-kube-source.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels */}} {{- define "overmind-kube-source.labels" -}} helm.sh/chart: {{ include "overmind-kube-source.chart" . }} {{ include "overmind-kube-source.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} {{/* Selector labels */}} {{- define "overmind-kube-source.selectorLabels" -}} app.kubernetes.io/name: {{ include "overmind-kube-source.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* Create the name of the service account to use */}} {{- define "overmind-kube-source.serviceAccountName" -}} {{- default (include "overmind-kube-source.fullname" .) .Values.serviceAccount.name }} {{- end }} {{/* Create the name of the cluster role to use */}} {{- define "overmind-kube-source.clusterRoleName" -}} {{- default (include "overmind-kube-source.fullname" .) }} {{- end }} {{/* Create the name of the cluster role binidng to use */}} {{- define "overmind-kube-source.clusterRoleBindingName" -}} {{- default (include "overmind-kube-source.fullname" .) }} {{- end }} {{/* Validate API Key configuration */}} {{- define "overmind-kube-source.validateAPIKey" -}} {{- if and .Values.source.apiKey.existingSecretName (not .Values.source.apiKey.value) }} {{- $secret := lookup "v1" "Secret" .Release.Namespace .Values.source.apiKey.existingSecretName }} {{- if not $secret }} {{- fail (printf "Secret %q not found in namespace %q" .Values.source.apiKey.existingSecretName .Release.Namespace) }} {{- end }} {{- if not (get $secret.data "API_KEY") }} {{- fail (printf "Secret %q does not contain required key 'API_KEY'" .Values.source.apiKey.existingSecretName) }} {{- end }} {{- else if not .Values.source.apiKey.value }} {{- fail "Either source.apiKey.value or source.apiKey.existingSecretName must be set" }} {{- end }} {{- end }} ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/clusterrole.yaml ================================================ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: {{ include "overmind-kube-source.clusterRoleName" . }} rules: - apiGroups: ["*"] resources: ["*"] verbs: ["get", "list", "watch"] ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/clusterrolebinding.yaml ================================================ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: {{ include "overmind-kube-source.clusterRoleBindingName" . }} subjects: - kind: ServiceAccount name: {{ include "overmind-kube-source.serviceAccountName" . }} namespace: {{ .Release.Namespace }} roleRef: kind: ClusterRole name: {{ include "overmind-kube-source.clusterRoleName" . }} apiGroup: rbac.authorization.k8s.io ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/configmap.yaml ================================================ --- # ConfigMap definition apiVersion: v1 kind: ConfigMap metadata: name: {{ include "overmind-kube-source.fullname" . }}-config data: LOG: {{ .Values.source.log }} MAX_PARALLEL: {{ .Values.source.maxParallel | quote }} SOURCE_NAME: {{ include "overmind-kube-source.fullname" . }} RATE_LIMIT_QPS: {{ .Values.source.rateLimitQPS | quote }} RATE_LIMIT_BURST: {{ .Values.source.rateLimitBurst | quote }} {{- if .Values.source.clusterName }} CLUSTER_NAME: {{ .Values.source.clusterName | quote }} {{- end }} {{- if .Values.source.app }} APP: {{ .Values.source.app | quote }} {{- end }} {{- if .Values.source.honeycombApiKey }} HONEYCOMB_API_KEY: {{ .Values.source.honeycombApiKey | quote }} {{- end }} --- ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/deployment.yaml ================================================ {{- template "overmind-kube-source.validateAPIKey" . }} apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "overmind-kube-source.fullname" . }} labels: {{- include "overmind-kube-source.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 selector: matchLabels: {{- include "overmind-kube-source.selectorLabels" . | nindent 6 }} template: metadata: {{- with .Values.podAnnotations }} annotations: {{- toYaml . | nindent 8 }} {{- end }} labels: {{- include "overmind-kube-source.selectorLabels" . | nindent 8 }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include "overmind-kube-source.serviceAccountName" . }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - name: {{ .Chart.Name }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} envFrom: - configMapRef: name: {{ include "overmind-kube-source.fullname" . }}-config {{- if .Values.source.apiKey.existingSecretName }} - secretRef: name: {{ .Values.source.apiKey.existingSecretName }} {{- else }} - secretRef: name: {{ include "overmind-kube-source.fullname" . }}-secrets {{- end }} env: - name: HEALTH_CHECK_PORT value: "8080" livenessProbe: httpGet: path: /healthz/alive port: 8080 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 successThreshold: 1 readinessProbe: httpGet: path: /healthz/ready port: 8080 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 successThreshold: 1 resources: {{- toYaml .Values.resources | nindent 12 }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} {{- end }} ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/poddisruptionbudget.yaml ================================================ {{- if .Values.podDisruptionBudget.enabled }} apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: {{ include "overmind-kube-source.fullname" . }} labels: {{- include "overmind-kube-source.labels" . | nindent 4 }} spec: maxUnavailable: 1 selector: matchLabels: {{- include "overmind-kube-source.selectorLabels" . | nindent 6 }} {{- end }} ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/secret.yaml ================================================ {{- if .Values.source.apiKey.value }} apiVersion: v1 kind: Secret metadata: name: {{ include "overmind-kube-source.fullname" . }}-secrets type: Opaque data: API_KEY: {{ .Values.source.apiKey.value | b64enc }} {{- end }} ================================================ FILE: k8s-source/deployments/overmind-kube-source/templates/serviceaccount.yaml ================================================ apiVersion: v1 kind: ServiceAccount metadata: name: {{ include "overmind-kube-source.serviceAccountName" . }} labels: {{- include "overmind-kube-source.labels" . | nindent 4 }} {{- with .Values.serviceAccount.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} ================================================ FILE: k8s-source/deployments/overmind-kube-source/values.yaml ================================================ # Default values for overmind-kube-source. # This is a YAML-formatted file. # Declare variables to be passed into your templates. replicaCount: 1 image: repository: ghcr.io/overmindtech/workspace/k8s-source pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. tag: "" imagePullSecrets: [] nameOverride: "" fullnameOverride: "" serviceAccount: # Annotations to add to the service account annotations: {} # The name of the service account to use. # If not set and create is true, a name is generated using the fullname template name: "" clusterRole: # The name of the service account to use. # If not set and create is true, a name is generated using the fullname template name: "" # Annotations to add to the cluster role annotations: {} podAnnotations: {} podSecurityContext: {} # fsGroup: 2000 securityContext: {} # capabilities: # drop: # - ALL # readOnlyRootFilesystem: true # runAsNonRoot: true # runAsUser: 1000 resources: {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following # lines, adjust them as necessary, and remove the curly braces after 'resources:'. # limits: # cpu: 100m # memory: 128Mi # requests: # cpu: 100m # memory: 128Mi podDisruptionBudget: enabled: true # Pod Disruption Budget protects against maintenance operations (node drains, cluster upgrades) # Uses maxUnavailable: 1 which works for both single and multi-replica deployments nodeSelector: {} tolerations: [] affinity: {} # Source config source: # The log level for the source (info, debug, trace etc.) log: info # API Key configuration apiKey: # Directly provided value (not recommended for production) value: "" # Reference to existing secret existingSecretName: "" # The URL of the Overmind instance to connect to app: "https://app.overmind.tech" # How many requests to run in parallel maxParallel: 20 # The maximum sustained queries per second from this source to the kubernetes API rateLimitQPS: 10 # The maximum burst of queries from this source to the kubernetes API rateLimitBurst: 30 # The descriptive name of the cluster this source is running on clusterName: "" # An optional Honeycomb API key to send traces and metrics honeycombApiKey: "" ================================================ FILE: k8s-source/main.go ================================================ /* Copyright © 2021 Dylan Ratcliffe Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "github.com/overmindtech/cli/k8s-source/cmd" _ "go.uber.org/automaxprocs" ) func main() { cmd.Execute() } ================================================ FILE: k8s-source/proc/proc.go ================================================ package proc import ( "fmt" "github.com/spf13/viper" ) // K8sConfig holds configuration for the k8s source read from viper. type K8sConfig struct { Kubeconfig string ClusterName string RateLimitQPS float64 RateLimitBurst int HealthCheckPort int } // ConfigFromViper reads and validates k8s source configuration from viper. // Kubeconfig may be empty (in-cluster config). Returns an error if rate limits // or health-check-port are invalid. func ConfigFromViper() (*K8sConfig, error) { rateLimitQPS := viper.GetFloat64("rate-limit-qps") rateLimitBurst := viper.GetInt("rate-limit-burst") healthCheckPort := viper.GetInt("health-check-port") if rateLimitQPS <= 0 { return nil, fmt.Errorf("rate-limit-qps must be positive, got %v", rateLimitQPS) } if rateLimitBurst <= 0 { return nil, fmt.Errorf("rate-limit-burst must be positive, got %v", rateLimitBurst) } if healthCheckPort < 1 || healthCheckPort > 65535 { return nil, fmt.Errorf("health-check-port must be between 1 and 65535, got %v", healthCheckPort) } return &K8sConfig{ Kubeconfig: viper.GetString("kubeconfig"), ClusterName: viper.GetString("cluster-name"), RateLimitQPS: rateLimitQPS, RateLimitBurst: rateLimitBurst, HealthCheckPort: healthCheckPort, }, nil } ================================================ FILE: knowledge/discover.go ================================================ package knowledge import ( "context" "fmt" "io/fs" "os" "path/filepath" "regexp" "sort" "strings" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "go.yaml.in/yaml/v3" ) // KnowledgeFile represents a discovered and validated knowledge file type KnowledgeFile struct { Name string Description string Content string // markdown body only (excluding frontmatter) FileName string // path relative to .overmind/knowledge/ SourceDir string // absolute path to the knowledge directory this file came from } // Warning represents a validation or parsing issue with a knowledge file type Warning struct { Path string // relative path within .overmind/knowledge/ Reason string } // frontmatter represents the YAML frontmatter structure type frontmatter struct { Name string `yaml:"name"` Description string `yaml:"description"` } // nameRegex validates knowledge file names (kebab-case: lowercase letters, digits, hyphens) // Must start with a letter, end with letter or digit, 1-64 chars total var nameRegex = regexp.MustCompile(`^[a-z]([a-z0-9-]*[a-z0-9])?$`) const ( // maxFileSize is the maximum allowed size for a knowledge file (10MB) // This prevents memory exhaustion and excessive API payload sizes maxFileSize = 10 * 1024 * 1024 // 10MB ) // FindKnowledgeDir walks up from startDir looking for a .overmind/knowledge/ // directory. Returns the absolute path if found, or empty string if not. // Stops at the repository root (.git boundary) or filesystem root to avoid // picking up knowledge files from unrelated parent projects. func FindKnowledgeDir(startDir string) string { dir, err := filepath.Abs(startDir) if err != nil { return "" } for { candidate := filepath.Join(dir, ".overmind", "knowledge") if info, err := os.Stat(candidate); err == nil && info.IsDir() { return candidate } if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { break } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return "" } // ResolveKnowledgeDirs returns the list of knowledge directories to use. // If explicitDirs is non-empty, returns those directories (warning about any that don't exist). // If explicitDirs is empty, falls back to FindKnowledgeDir(startDir) for backward compatibility. // Returns an empty slice if no directories are found or specified. func ResolveKnowledgeDirs(startDir string, explicitDirs []string) []string { if len(explicitDirs) == 0 { // Fallback to auto-discovery for backward compatibility dir := FindKnowledgeDir(startDir) if dir != "" { return []string{dir} } return []string{} } // Use explicit directories, warning about missing ones but tolerating them var resolved []string for _, dir := range explicitDirs { absDir, err := filepath.Abs(dir) if err != nil { log.WithField("dir", dir).Warn("Failed to resolve absolute path for knowledge directory, skipping") continue } if _, err := os.Stat(absDir); err != nil { log.WithField("dir", absDir).WithError(err).Warn("Cannot access knowledge directory, skipping") continue } resolved = append(resolved, absDir) } return resolved } // Discover walks the knowledge directories and discovers all valid knowledge files. // Accepts a list of knowledge directories to search. Later directories in the list // override earlier ones when the same knowledge file name appears in multiple directories // (emits a warning when this happens). // Returns valid files and any warnings encountered during discovery. func Discover(knowledgeDirs ...string) ([]KnowledgeFile, []Warning) { // Handle legacy single-directory signature for backward compatibility if len(knowledgeDirs) == 1 && knowledgeDirs[0] == "" { return []KnowledgeFile{}, []Warning{} } var allFiles []KnowledgeFile var allWarnings []Warning // Track seen names across all directories for cross-directory deduplication // Maps name -> {sourceDir, relPath} of the file that won type nameOwner struct { sourceDir string relPath string } seenNames := make(map[string]nameOwner) // Process each directory in order for _, knowledgeDir := range knowledgeDirs { if knowledgeDir == "" { continue } files, warnings := discoverOne(knowledgeDir) allWarnings = append(allWarnings, warnings...) // Apply cross-directory deduplication: later directories override earlier ones for _, kf := range files { if owner, exists := seenNames[kf.Name]; exists { // Name collision across directories: later wins, emit warning log only log.WithField("name", kf.Name). WithField("earlier", filepath.Join(owner.sourceDir, owner.relPath)). WithField("later", filepath.Join(kf.SourceDir, kf.FileName)). Warn("Knowledge file name collision across directories, using later directory") // Remove the earlier file from allFiles and replace with the new one for i, f := range allFiles { if f.Name == kf.Name { allFiles = append(allFiles[:i], allFiles[i+1:]...) break } } } seenNames[kf.Name] = nameOwner{ sourceDir: kf.SourceDir, relPath: kf.FileName, } allFiles = append(allFiles, kf) } } return allFiles, allWarnings } // discoverOne walks a single knowledge directory and discovers valid knowledge files. // This is the internal implementation that processes one directory. // Returns valid files and any warnings encountered during discovery. func discoverOne(knowledgeDir string) ([]KnowledgeFile, []Warning) { var files []KnowledgeFile var warnings []Warning // Check if directory exists if _, err := os.Stat(knowledgeDir); os.IsNotExist(err) { return files, warnings } // Make knowledgeDir absolute for consistent SourceDir tracking absKnowledgeDir, err := filepath.Abs(knowledgeDir) if err != nil { warnings = append(warnings, Warning{ Path: knowledgeDir, Reason: fmt.Sprintf("failed to resolve absolute path: %v", err), }) return files, warnings } // Collect all markdown files first for deterministic ordering type fileInfo struct { path string relPath string } var mdFiles []fileInfo err = filepath.WalkDir(absKnowledgeDir, func(path string, d fs.DirEntry, err error) error { if err != nil { // Warn about directories/files we can't access relPath, _ := filepath.Rel(absKnowledgeDir, path) warnings = append(warnings, Warning{ Path: relPath, Reason: fmt.Sprintf("cannot access: %v", err), }) return nil // Continue walking } // Skip directories if d.IsDir() { return nil } // Only process .md files if !strings.HasSuffix(d.Name(), ".md") { return nil } relPath, err := filepath.Rel(absKnowledgeDir, path) if err != nil { return err } mdFiles = append(mdFiles, fileInfo{ path: path, relPath: relPath, }) return nil }) if err != nil { warnings = append(warnings, Warning{ Path: "", Reason: fmt.Sprintf("error walking directory: %v", err), }) return files, warnings } // Sort files lexicographically for deterministic processing sort.Slice(mdFiles, func(i, j int) bool { return mdFiles[i].relPath < mdFiles[j].relPath }) // Track seen names within this directory for intra-directory deduplication seenNames := make(map[string]string) // name -> first file path // Process each file for _, f := range mdFiles { kf, warn := processFile(f.path, f.relPath, absKnowledgeDir) if warn != nil { warnings = append(warnings, *warn) continue } // Check for duplicate names within this directory if firstPath, exists := seenNames[kf.Name]; exists { warnings = append(warnings, Warning{ Path: f.relPath, Reason: fmt.Sprintf("duplicate name %q (already loaded from %q)", kf.Name, firstPath), }) continue } seenNames[kf.Name] = f.relPath files = append(files, *kf) } return files, warnings } // processFile reads and validates a single knowledge file func processFile(path, relPath, sourceDir string) (*KnowledgeFile, *Warning) { // Check file size before reading fileInfo, err := os.Stat(path) if err != nil { return nil, &Warning{ Path: relPath, Reason: fmt.Sprintf("cannot stat file: %v", err), } } if fileInfo.Size() > maxFileSize { return nil, &Warning{ Path: relPath, Reason: fmt.Sprintf("file size %d bytes exceeds maximum allowed size of %d bytes", fileInfo.Size(), maxFileSize), } } // Read file content content, err := os.ReadFile(path) if err != nil { return nil, &Warning{ Path: relPath, Reason: fmt.Sprintf("cannot read file: %v", err), } } // Parse frontmatter name, description, body, err := parseFrontmatter(string(content)) if err != nil { return nil, &Warning{ Path: relPath, Reason: err.Error(), } } // Validate name if err := validateName(name); err != nil { return nil, &Warning{ Path: relPath, Reason: err.Error(), } } // Validate description if err := validateDescription(description); err != nil { return nil, &Warning{ Path: relPath, Reason: err.Error(), } } return &KnowledgeFile{ Name: name, Description: description, Content: body, FileName: relPath, SourceDir: sourceDir, }, nil } // parseFrontmatter extracts YAML frontmatter from markdown content // Returns name, description, body (without frontmatter), and any error func parseFrontmatter(content string) (string, string, string, error) { // Frontmatter must start at the beginning of the file if !strings.HasPrefix(content, "---\n") && !strings.HasPrefix(content, "---\r\n") { return "", "", "", fmt.Errorf("frontmatter is required (must start with ---)") } // Determine opening delimiter length startIdx := 4 // "---\n" if strings.HasPrefix(content, "---\r\n") { startIdx = 5 // "---\r\n" } // Find the closing delimiter remaining := content[startIdx:] // Handle edge case: empty frontmatter where second --- is immediately after first if strings.HasPrefix(remaining, "---\n") || strings.HasPrefix(remaining, "---\r\n") { bodyStartIdx := startIdx + 4 // "---\n" if strings.HasPrefix(remaining, "---\r\n") { bodyStartIdx = startIdx + 5 // "---\r\n" } body := strings.TrimLeft(content[bodyStartIdx:], "\n\r") // Empty frontmatter will result in empty name/description which will fail validation var fm frontmatter return fm.Name, fm.Description, body, nil } // Find closing delimiter and track which type we found var endIdx int var closingDelimLen int // Try CRLF first (more specific), then LF endIdx = strings.Index(remaining, "\n---\r\n") if endIdx != -1 { closingDelimLen = 6 // "\n---\r\n" } else { endIdx = strings.Index(remaining, "\n---\n") if endIdx != -1 { closingDelimLen = 5 // "\n---\n" } else { // Check for closing delimiter at end of file (more specific first) if strings.HasSuffix(remaining, "\r\n---") { endIdx = len(remaining) - 5 closingDelimLen = 5 // "\r\n---" (no trailing newline) } else if strings.HasSuffix(remaining, "\n---") { endIdx = len(remaining) - 4 closingDelimLen = 4 // "\n---" (no trailing newline) } else { return "", "", "", fmt.Errorf("frontmatter closing delimiter (---) not found") } } } // Extract YAML content yamlContent := remaining[:endIdx] // Parse YAML with strict mode (unknown fields will cause error) var fm frontmatter decoder := yaml.NewDecoder(strings.NewReader(yamlContent)) decoder.KnownFields(true) // Reject unknown fields if err := decoder.Decode(&fm); err != nil { if strings.Contains(err.Error(), "field") && strings.Contains(err.Error(), "not found") { return "", "", "", fmt.Errorf("only 'name' and 'description' fields are allowed in frontmatter") } return "", "", "", fmt.Errorf("invalid YAML in frontmatter: %w", err) } // Extract body using the correct offset for the delimiter type found bodyStartIdx := min(startIdx+endIdx+closingDelimLen, len(content)) body := strings.TrimLeft(content[bodyStartIdx:], "\n\r") // Trim whitespace from name and description as per validation return strings.TrimSpace(fm.Name), strings.TrimSpace(fm.Description), body, nil } // validateName checks if the name meets the specification requirements func validateName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("name is required") } if len(name) > 64 { return fmt.Errorf("name must be 64 characters or less") } if !nameRegex.MatchString(name) { return fmt.Errorf("name must use kebab-case (lowercase letters, digits, hyphens; start with letter, end with letter or digit)") } return nil } // validateDescription checks if the description meets the specification requirements func validateDescription(description string) error { description = strings.TrimSpace(description) if description == "" { return fmt.Errorf("description is required") } if len(description) > 1024 { return fmt.Errorf("description must be 1024 characters or less") } return nil } // DiscoverAndConvert discovers knowledge files and converts them to SDP Knowledge messages. // This is a convenience function that combines discovery, warning logging, and conversion // to reduce code duplication across commands. // Accepts a variable number of knowledge directories to search. func DiscoverAndConvert(ctx context.Context, knowledgeDirs ...string) []*sdp.Knowledge { if len(knowledgeDirs) > 0 { log.WithContext(ctx).WithField("knowledgeDirs", knowledgeDirs).Debug("Resolved knowledge directories") } knowledgeFiles, warnings := Discover(knowledgeDirs...) // Log warnings for _, w := range warnings { log.WithContext(ctx).WithField("path", w.Path).WithField("reason", w.Reason).Warn("Skipping knowledge file") } // Convert to SDP Knowledge messages sdpKnowledge := make([]*sdp.Knowledge, 0, len(knowledgeFiles)) for _, kf := range knowledgeFiles { sdpKnowledge = append(sdpKnowledge, &sdp.Knowledge{ Name: kf.Name, Description: kf.Description, Content: kf.Content, FileName: kf.FileName, }) } // Log when knowledge files are loaded if len(knowledgeFiles) > 0 { log.WithContext(ctx).WithField("knowledgeCount", len(knowledgeFiles)).Info("Loaded knowledge files") } return sdpKnowledge } ================================================ FILE: knowledge/discover_test.go ================================================ package knowledge import ( "os" "path/filepath" "strings" "testing" ) func TestDiscover_EmptyDirectory(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") err := os.Mkdir(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } files, warnings := Discover(knowledgeDir) if len(files) != 0 { t.Errorf("expected 0 files, got %d", len(files)) } if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d", len(warnings)) } } func TestDiscover_DirectoryDoesNotExist(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "nonexistent") files, warnings := Discover(knowledgeDir) if len(files) != 0 { t.Errorf("expected 0 files, got %d", len(files)) } if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d", len(warnings)) } } func TestDiscover_ValidFiles(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") err := os.Mkdir(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create valid files at root writeFile(t, filepath.Join(knowledgeDir, "aws-s3.md"), `--- name: aws-s3-security description: Security best practices for S3 buckets --- # AWS S3 Security Content here. `) // Create valid file in subfolder subdir := filepath.Join(knowledgeDir, "cloud") err = os.Mkdir(subdir, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(subdir, "gcp.md"), `--- name: gcp-compute description: GCP Compute Engine guidelines --- # GCP Compute Content here. `) files, warnings := Discover(knowledgeDir) if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d: %v", len(warnings), warnings) } if len(files) != 2 { t.Fatalf("expected 2 files, got %d", len(files)) } // Check first file (lexicographic order) if files[0].Name != "aws-s3-security" { t.Errorf("expected name 'aws-s3-security', got %q", files[0].Name) } if files[0].Description != "Security best practices for S3 buckets" { t.Errorf("unexpected description: %q", files[0].Description) } if files[0].FileName != "aws-s3.md" { t.Errorf("expected fileName 'aws-s3.md', got %q", files[0].FileName) } if files[0].Content != "# AWS S3 Security\nContent here.\n" { t.Errorf("unexpected content: %q", files[0].Content) } // Check second file if files[1].Name != "gcp-compute" { t.Errorf("expected name 'gcp-compute', got %q", files[1].Name) } if files[1].FileName != filepath.Join("cloud", "gcp.md") { t.Errorf("expected fileName 'cloud/gcp.md', got %q", files[1].FileName) } } func TestDiscover_NonMarkdownFilesSkipped(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") err := os.Mkdir(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create non-markdown files writeFile(t, filepath.Join(knowledgeDir, "readme.txt"), "This is a text file") writeFile(t, filepath.Join(knowledgeDir, "config.yaml"), "key: value") writeFile(t, filepath.Join(knowledgeDir, "script.sh"), "#!/bin/bash") // Create one valid markdown file writeFile(t, filepath.Join(knowledgeDir, "valid.md"), `--- name: valid-file description: A valid knowledge file --- Content `) files, warnings := Discover(knowledgeDir) if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d: %v", len(warnings), warnings) } if len(files) != 1 { t.Errorf("expected 1 file, got %d", len(files)) } } func TestDiscover_NestedSubfolders(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") // Create nested directory structure deepDir := filepath.Join(knowledgeDir, "cloud", "aws", "services") err := os.MkdirAll(deepDir, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(deepDir, "s3.md"), `--- name: deep-s3 description: Deeply nested file --- Content `) files, warnings := Discover(knowledgeDir) if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d: %v", len(warnings), warnings) } if len(files) != 1 { t.Fatalf("expected 1 file, got %d", len(files)) } expectedPath := filepath.Join("cloud", "aws", "services", "s3.md") if files[0].FileName != expectedPath { t.Errorf("expected fileName %q, got %q", expectedPath, files[0].FileName) } } func TestParseFrontmatter_Valid(t *testing.T) { content := `--- name: test-file description: Test description --- # Markdown content Here is some content. ` name, desc, body, err := parseFrontmatter(content) if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "test-file" { t.Errorf("expected name 'test-file', got %q", name) } if desc != "Test description" { t.Errorf("expected description 'Test description', got %q", desc) } if body != "# Markdown content\nHere is some content.\n" { t.Errorf("unexpected body: %q", body) } } func TestParseFrontmatter_CRLF(t *testing.T) { // Test with Windows-style CRLF line endings content := "---\r\nname: windows-file\r\ndescription: File with CRLF endings\r\n---\r\n# Windows content\r\nWith CRLF.\r\n" name, desc, body, err := parseFrontmatter(content) if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "windows-file" { t.Errorf("expected name 'windows-file', got %q", name) } if desc != "File with CRLF endings" { t.Errorf("expected description 'File with CRLF endings', got %q", desc) } // Body should have CRLF stripped by TrimLeft if !strings.Contains(body, "Windows content") { t.Errorf("unexpected body: %q", body) } } func TestParseFrontmatter_CRLFAtEOF(t *testing.T) { // Test CRLF with frontmatter at end of file (no trailing content) content := "---\r\nname: eof-test\r\ndescription: Frontmatter at EOF\r\n---" name, desc, _, err := parseFrontmatter(content) if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "eof-test" { t.Errorf("expected name 'eof-test', got %q", name) } if desc != "Frontmatter at EOF" { t.Errorf("expected description 'Frontmatter at EOF', got %q", desc) } } func TestParseFrontmatter_MixedLineEndings(t *testing.T) { // Test with LF in frontmatter but CRLF in closing delimiter content := "---\nname: mixed-file\ndescription: Mixed line endings\n---\r\n# Content\nHere.\n" name, desc, body, err := parseFrontmatter(content) if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "mixed-file" { t.Errorf("expected name 'mixed-file', got %q", name) } if desc != "Mixed line endings" { t.Errorf("expected description 'Mixed line endings', got %q", desc) } if !strings.Contains(body, "Content") { t.Errorf("unexpected body: %q", body) } } func TestParseFrontmatter_Whitespace(t *testing.T) { // Test that whitespace is trimmed from name and description content := `--- name: whitespace-name description: Lots of whitespace --- Content ` name, desc, _, err := parseFrontmatter(content) if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "whitespace-name" { t.Errorf("expected trimmed name 'whitespace-name', got %q", name) } if desc != "Lots of whitespace" { t.Errorf("expected trimmed description 'Lots of whitespace', got %q", desc) } } func TestParseFrontmatter_MissingFrontmatter(t *testing.T) { content := `# Just markdown content No frontmatter here. ` _, _, _, err := parseFrontmatter(content) if err == nil { t.Error("expected error for missing frontmatter") } } func TestParseFrontmatter_EmptyFrontmatter(t *testing.T) { content := `--- --- Content ` name, desc, _, err := parseFrontmatter(content) // Empty frontmatter parses successfully but will fail validation if err != nil { t.Fatalf("unexpected parse error: %v", err) } if name != "" || desc != "" { t.Error("expected empty name and description") } } func TestParseFrontmatter_UnknownFields(t *testing.T) { content := `--- name: test description: Test license: MIT author: Someone --- Content ` _, _, _, err := parseFrontmatter(content) if err == nil { t.Error("expected error for unknown fields") } if err != nil && err.Error() != "only 'name' and 'description' fields are allowed in frontmatter" { t.Errorf("unexpected error message: %v", err) } } func TestParseFrontmatter_InvalidYAML(t *testing.T) { content := `--- name: test description: [unclosed bracket --- Content ` _, _, _, err := parseFrontmatter(content) if err == nil { t.Error("expected error for invalid YAML") } } func TestParseFrontmatter_NoClosingDelimiter(t *testing.T) { content := `--- name: test description: No closing delimiter ` _, _, _, err := parseFrontmatter(content) if err == nil { t.Error("expected error for missing closing delimiter") } } func TestValidateName_Valid(t *testing.T) { validNames := []string{ "a", "a1", "aws-s3-security", "kubernetes-resource-limits", "test123", "a-b-c-1-2-3", } for _, name := range validNames { err := validateName(name) if err != nil { t.Errorf("expected %q to be valid, got error: %v", name, err) } } } func TestValidateName_Invalid(t *testing.T) { tests := []struct { name string expectedErr string }{ {"", "name is required"}, {" ", "name is required"}, {"AWS-S3", "name must use kebab-case"}, {"-leading-hyphen", "name must use kebab-case"}, {"trailing-hyphen-", "name must use kebab-case"}, {"123-starts-with-digit", "name must use kebab-case"}, {"has_underscores", "name must use kebab-case"}, {"has spaces", "name must use kebab-case"}, {"Capital-Letter", "name must use kebab-case"}, {string(make([]byte, 65)), "name must be 64 characters or less"}, // 65 chars } for _, tt := range tests { err := validateName(tt.name) if err == nil { t.Errorf("expected %q to be invalid", tt.name) } else if !strings.Contains(err.Error(), tt.expectedErr) { t.Errorf("for name %q, expected error containing %q, got %q", tt.name, tt.expectedErr, err.Error()) } } } func TestValidateDescription_Valid(t *testing.T) { validDescs := []string{ "A", "Short description", string(make([]byte, 1024)), // exactly 1024 chars } for _, desc := range validDescs { err := validateDescription(desc) if err != nil { t.Errorf("expected %q to be valid, got error: %v", desc, err) } } } func TestValidateDescription_Invalid(t *testing.T) { tests := []struct { desc string expectedErr string }{ {"", "description is required"}, {" ", "description is required"}, {string(make([]byte, 1025)), "description must be 1024 characters or less"}, } for _, tt := range tests { err := validateDescription(tt.desc) if err == nil { t.Errorf("expected description to be invalid") } else if !strings.Contains(err.Error(), tt.expectedErr) { t.Errorf("expected error containing %q, got %q", tt.expectedErr, err.Error()) } } } func TestDiscover_Deduplication(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") err := os.Mkdir(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create two files with same name writeFile(t, filepath.Join(knowledgeDir, "aws-s3.md"), `--- name: duplicate-name description: First file --- First `) writeFile(t, filepath.Join(knowledgeDir, "s3-aws.md"), `--- name: duplicate-name description: Second file --- Second `) files, warnings := Discover(knowledgeDir) if len(files) != 1 { t.Errorf("expected 1 file (first wins), got %d", len(files)) } if len(warnings) != 1 { t.Fatalf("expected 1 warning for duplicate, got %d", len(warnings)) } // First file (lexicographic order) should win if files[0].Description != "First file" { t.Errorf("expected first file to win, got description: %q", files[0].Description) } // Check warning message if !strings.Contains(warnings[0].Reason, "duplicate name") { t.Errorf("expected warning about duplicate name, got: %q", warnings[0].Reason) } if !strings.Contains(warnings[0].Reason, "aws-s3.md") { t.Errorf("expected warning to mention first file, got: %q", warnings[0].Reason) } } func TestDiscover_DuplicateInSubfolder(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") subdir := filepath.Join(knowledgeDir, "cloud") err := os.MkdirAll(subdir, 0o755) if err != nil { t.Fatal(err) } // Create files with same name in different folders writeFile(t, filepath.Join(knowledgeDir, "aws.md"), `--- name: aws-service description: Root file --- Root `) writeFile(t, filepath.Join(subdir, "aws.md"), `--- name: aws-service description: Subfolder file --- Subfolder `) files, warnings := Discover(knowledgeDir) if len(files) != 1 { t.Errorf("expected 1 file, got %d", len(files)) } if len(warnings) != 1 { t.Errorf("expected 1 warning for duplicate, got %d", len(warnings)) } } func TestDiscover_InvalidFilesProduceWarnings(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") err := os.Mkdir(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Invalid name writeFile(t, filepath.Join(knowledgeDir, "invalid-name.md"), `--- name: INVALID-NAME description: Invalid name with uppercase --- Content `) // Missing description writeFile(t, filepath.Join(knowledgeDir, "no-desc.md"), `--- name: no-description --- Content `) // Invalid frontmatter writeFile(t, filepath.Join(knowledgeDir, "bad-yaml.md"), `Not yaml frontmatter `) // Valid file writeFile(t, filepath.Join(knowledgeDir, "valid.md"), `--- name: valid-file description: This one is valid --- Content `) files, warnings := Discover(knowledgeDir) if len(files) != 1 { t.Errorf("expected 1 valid file, got %d", len(files)) } if len(warnings) != 3 { t.Fatalf("expected 3 warnings, got %d: %v", len(warnings), warnings) } // Check that all warnings have paths and reasons for _, w := range warnings { if w.Path == "" { t.Error("warning path should not be empty") } if w.Reason == "" { t.Error("warning reason should not be empty") } } } func TestDiscover_FileSizeLimit(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") err := os.Mkdir(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create a file that exceeds the size limit // Generate content larger than 10MB largeContent := "---\nname: large-file\ndescription: Too large\n---\n" largeContent += strings.Repeat("x", 11*1024*1024) // 11MB of content writeFile(t, filepath.Join(knowledgeDir, "large.md"), largeContent) // Create a valid small file writeFile(t, filepath.Join(knowledgeDir, "small.md"), `--- name: small-file description: Normal size --- Content `) files, warnings := Discover(knowledgeDir) if len(files) != 1 { t.Errorf("expected 1 valid file, got %d", len(files)) } if len(warnings) != 1 { t.Fatalf("expected 1 warning for large file, got %d", len(warnings)) } if !strings.Contains(warnings[0].Reason, "exceeds maximum") { t.Errorf("expected warning about file size, got: %q", warnings[0].Reason) } } func TestDiscover_LexicographicOrdering(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, "knowledge") err := os.Mkdir(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create files in non-alphabetical order writeFile(t, filepath.Join(knowledgeDir, "zebra.md"), `--- name: z-file description: Last alphabetically --- Z `) writeFile(t, filepath.Join(knowledgeDir, "apple.md"), `--- name: a-file description: First alphabetically --- A `) writeFile(t, filepath.Join(knowledgeDir, "middle.md"), `--- name: m-file description: Middle alphabetically --- M `) files, warnings := Discover(knowledgeDir) if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d", len(warnings)) } if len(files) != 3 { t.Fatalf("expected 3 files, got %d", len(files)) } // Files should be processed in lexicographic order if files[0].Name != "a-file" { t.Errorf("expected first file to be 'a-file', got %q", files[0].Name) } if files[1].Name != "m-file" { t.Errorf("expected second file to be 'm-file', got %q", files[1].Name) } if files[2].Name != "z-file" { t.Errorf("expected third file to be 'z-file', got %q", files[2].Name) } } // FindKnowledgeDir tests func TestFindKnowledgeDir_InCWD(t *testing.T) { root := t.TempDir() knowledgeDir := filepath.Join(root, ".overmind", "knowledge") if err := os.MkdirAll(knowledgeDir, 0o755); err != nil { t.Fatal(err) } result := FindKnowledgeDir(root) if result != knowledgeDir { t.Errorf("expected %q, got %q", knowledgeDir, result) } } func TestFindKnowledgeDir_InParent(t *testing.T) { root := t.TempDir() knowledgeDir := filepath.Join(root, ".overmind", "knowledge") if err := os.MkdirAll(knowledgeDir, 0o755); err != nil { t.Fatal(err) } childDir := filepath.Join(root, "environments", "prod") if err := os.MkdirAll(childDir, 0o755); err != nil { t.Fatal(err) } result := FindKnowledgeDir(childDir) if result != knowledgeDir { t.Errorf("expected %q, got %q", knowledgeDir, result) } } func TestFindKnowledgeDir_InGrandparent(t *testing.T) { root := t.TempDir() knowledgeDir := filepath.Join(root, ".overmind", "knowledge") if err := os.MkdirAll(knowledgeDir, 0o755); err != nil { t.Fatal(err) } deepDir := filepath.Join(root, "a", "b", "c") if err := os.MkdirAll(deepDir, 0o755); err != nil { t.Fatal(err) } result := FindKnowledgeDir(deepDir) if result != knowledgeDir { t.Errorf("expected %q, got %q", knowledgeDir, result) } } func TestFindKnowledgeDir_StopsAtGitBoundary(t *testing.T) { root := t.TempDir() // Knowledge above the git boundary -- should NOT be found knowledgeDir := filepath.Join(root, ".overmind", "knowledge") if err := os.MkdirAll(knowledgeDir, 0o755); err != nil { t.Fatal(err) } // Git repo is a subdirectory repoDir := filepath.Join(root, "my-repo") if err := os.MkdirAll(filepath.Join(repoDir, ".git"), 0o755); err != nil { t.Fatal(err) } workDir := filepath.Join(repoDir, "environments", "prod") if err := os.MkdirAll(workDir, 0o755); err != nil { t.Fatal(err) } result := FindKnowledgeDir(workDir) if result != "" { t.Errorf("expected empty string (should not escape .git boundary), got %q", result) } } func TestFindKnowledgeDir_CWDTakesPriority(t *testing.T) { root := t.TempDir() // Knowledge at root rootKnowledge := filepath.Join(root, ".overmind", "knowledge") if err := os.MkdirAll(rootKnowledge, 0o755); err != nil { t.Fatal(err) } // Knowledge also in subdirectory childDir := filepath.Join(root, "sub") childKnowledge := filepath.Join(childDir, ".overmind", "knowledge") if err := os.MkdirAll(childKnowledge, 0o755); err != nil { t.Fatal(err) } result := FindKnowledgeDir(childDir) if result != childKnowledge { t.Errorf("expected CWD knowledge %q to take priority, got %q", childKnowledge, result) } } func TestFindKnowledgeDir_NotFoundAnywhere(t *testing.T) { root := t.TempDir() workDir := filepath.Join(root, "some", "dir") if err := os.MkdirAll(workDir, 0o755); err != nil { t.Fatal(err) } // Place .git at root to create a boundary if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatal(err) } result := FindKnowledgeDir(workDir) if result != "" { t.Errorf("expected empty string, got %q", result) } } func TestFindKnowledgeDir_GitBoundaryWithKnowledge(t *testing.T) { root := t.TempDir() // .git and .overmind/knowledge at the same level if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatal(err) } knowledgeDir := filepath.Join(root, ".overmind", "knowledge") if err := os.MkdirAll(knowledgeDir, 0o755); err != nil { t.Fatal(err) } workDir := filepath.Join(root, "environments", "prod") if err := os.MkdirAll(workDir, 0o755); err != nil { t.Fatal(err) } result := FindKnowledgeDir(workDir) // Should find knowledge at repo root before the .git stop triggers if result != knowledgeDir { t.Errorf("expected %q, got %q", knowledgeDir, result) } } // Multi-directory tests func TestResolveKnowledgeDirs_EmptyExplicit(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Empty explicit dirs should fall back to auto-discovery result := ResolveKnowledgeDirs(dir, []string{}) if len(result) != 1 { t.Fatalf("expected 1 directory, got %d", len(result)) } if result[0] != knowledgeDir { t.Errorf("expected %q, got %q", knowledgeDir, result[0]) } } func TestResolveKnowledgeDirs_ExplicitDirs(t *testing.T) { dir := t.TempDir() dir1 := filepath.Join(dir, "global", ".overmind", "knowledge") dir2 := filepath.Join(dir, "local", ".overmind", "knowledge") err := os.MkdirAll(dir1, 0o755) if err != nil { t.Fatal(err) } err = os.MkdirAll(dir2, 0o755) if err != nil { t.Fatal(err) } result := ResolveKnowledgeDirs(".", []string{dir1, dir2}) if len(result) != 2 { t.Fatalf("expected 2 directories, got %d", len(result)) } } func TestResolveKnowledgeDirs_MissingDirTolerated(t *testing.T) { dir := t.TempDir() existingDir := filepath.Join(dir, "existing") missingDir := filepath.Join(dir, "missing") err := os.Mkdir(existingDir, 0o755) if err != nil { t.Fatal(err) } result := ResolveKnowledgeDirs(".", []string{existingDir, missingDir}) if len(result) != 1 { t.Fatalf("expected 1 directory (missing should be skipped), got %d", len(result)) } absExisting, _ := filepath.Abs(existingDir) if result[0] != absExisting { t.Errorf("expected %q, got %q", absExisting, result[0]) } } func TestResolveKnowledgeDirs_AllMissing(t *testing.T) { dir := t.TempDir() missing1 := filepath.Join(dir, "missing1") missing2 := filepath.Join(dir, "missing2") result := ResolveKnowledgeDirs(".", []string{missing1, missing2}) if len(result) != 0 { t.Errorf("expected 0 directories, got %d", len(result)) } } func TestDiscover_MultipleDirectories(t *testing.T) { dir := t.TempDir() // Create global directory with one file globalDir := filepath.Join(dir, "global", ".overmind", "knowledge") err := os.MkdirAll(globalDir, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(globalDir, "global.md"), `--- name: global-file description: Global knowledge file --- Global content `) // Create local directory with another file localDir := filepath.Join(dir, "local", ".overmind", "knowledge") err = os.MkdirAll(localDir, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(localDir, "local.md"), `--- name: local-file description: Local knowledge file --- Local content `) files, warnings := Discover(globalDir, localDir) if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d: %v", len(warnings), warnings) } if len(files) != 2 { t.Fatalf("expected 2 files, got %d", len(files)) } // Check both files are present names := make(map[string]bool) for _, f := range files { names[f.Name] = true } if !names["global-file"] { t.Error("expected global-file") } if !names["local-file"] { t.Error("expected local-file") } } func TestDiscover_CrossDirOverride(t *testing.T) { dir := t.TempDir() // Create global directory with a file globalDir := filepath.Join(dir, "global", ".overmind", "knowledge") err := os.MkdirAll(globalDir, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(globalDir, "shared.md"), `--- name: shared-config description: Global version --- Global content `) // Create local directory with file of same name localDir := filepath.Join(dir, "local", ".overmind", "knowledge") err = os.MkdirAll(localDir, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(localDir, "shared.md"), `--- name: shared-config description: Local override --- Local content `) files, warnings := Discover(globalDir, localDir) // Should have exactly 1 file (local overrides global) if len(files) != 1 { t.Fatalf("expected 1 file (local should override global), got %d", len(files)) } // Cross-directory override is logged but not added to warnings if len(warnings) != 0 { t.Errorf("expected 0 warnings (cross-dir override is logged only), got %d", len(warnings)) } // The local version should win if files[0].Description != "Local override" { t.Errorf("expected local version to win, got description: %q", files[0].Description) } if files[0].Content != "Local content\n" { t.Errorf("expected local content, got: %q", files[0].Content) } // Check SourceDir is set correctly absLocalDir, _ := filepath.Abs(localDir) if files[0].SourceDir != absLocalDir { t.Errorf("expected SourceDir %q, got %q", absLocalDir, files[0].SourceDir) } } func TestDiscover_WithinDirDuplicateStillWarns(t *testing.T) { dir := t.TempDir() knowledgeDir := filepath.Join(dir, ".overmind", "knowledge") err := os.MkdirAll(knowledgeDir, 0o755) if err != nil { t.Fatal(err) } // Create two files with same name in the same directory writeFile(t, filepath.Join(knowledgeDir, "file1.md"), `--- name: duplicate-name description: First --- First `) writeFile(t, filepath.Join(knowledgeDir, "file2.md"), `--- name: duplicate-name description: Second --- Second `) files, warnings := Discover(knowledgeDir) if len(files) != 1 { t.Errorf("expected 1 file, got %d", len(files)) } if len(warnings) != 1 { t.Errorf("expected 1 warning for within-dir duplicate, got %d", len(warnings)) } } func TestDiscover_MixedExistingAndMissing(t *testing.T) { dir := t.TempDir() existingDir := filepath.Join(dir, "existing") err := os.Mkdir(existingDir, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(existingDir, "test.md"), `--- name: test-file description: Test --- Content `) missingDir := filepath.Join(dir, "missing") // Should silently skip missing directory files, warnings := Discover(existingDir, missingDir) if len(files) != 1 { t.Errorf("expected 1 file, got %d", len(files)) } if len(warnings) != 0 { t.Errorf("expected 0 warnings (missing dir skipped), got %d", len(warnings)) } } func TestDiscover_DeterministicOrdering(t *testing.T) { dir := t.TempDir() dir1 := filepath.Join(dir, "dir1") err := os.Mkdir(dir1, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(dir1, "a.md"), `--- name: file-a description: A --- A `) dir2 := filepath.Join(dir, "dir2") err = os.Mkdir(dir2, 0o755) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(dir2, "b.md"), `--- name: file-b description: B --- B `) // Run multiple times to ensure deterministic ordering for i := range 3 { files, _ := Discover(dir1, dir2) if len(files) != 2 { t.Fatalf("iteration %d: expected 2 files, got %d", i, len(files)) } // Files from each directory are sorted lexicographically, then combined // Since both files are in different directories, they should appear in order if files[0].Name != "file-a" || files[1].Name != "file-b" { t.Errorf("iteration %d: unexpected order: %s, %s", i, files[0].Name, files[1].Name) } } } func TestDiscover_EmptyList(t *testing.T) { files, warnings := Discover() if len(files) != 0 { t.Errorf("expected 0 files, got %d", len(files)) } if len(warnings) != 0 { t.Errorf("expected 0 warnings, got %d", len(warnings)) } } // Helper functions func writeFile(t *testing.T, path, content string) { t.Helper() err := os.WriteFile(path, []byte(content), 0o644) if err != nil { t.Fatalf("failed to write file %s: %v", path, err) } } ================================================ FILE: main.go ================================================ package main import ( "github.com/overmindtech/cli/cmd" ) func main() { // Execute the root command cmd.Execute() } ================================================ FILE: main.tf ================================================ # This is a very simple example to deploy a few cheap resources into AWS to test the new `terraform plan` and `terraform apply` subcommands. terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = ">= 4.56" } google = { source = "hashicorp/google" version = ">= 4.0" } random = { source = "hashicorp/random" version = ">= 3.0" } } } provider "aws" {} provider "aws" { alias = "aliased" region = "us-east-1" } provider "google" { project = "overmind-demo" region = "us-central1" } provider "google" { alias = "west" project = "overmind-demo-west" region = "us-west1" zone = "us-west1-a" } provider "google" { alias = "dogfood" project = "ovm-dogfood" region = "europe-west2" zone = "europe-west2-a" } variable "bucket_postfix" { type = string description = "The prefix to apply to the bucket name." default = "test" } module "bucket" { source = "terraform-aws-modules/s3-bucket/aws" version = "~> 5.0" bucket_prefix = "cli-test${var.bucket_postfix}" control_object_ownership = true object_ownership = "BucketOwnerEnforced" block_public_policy = true block_public_acls = true ignore_public_acls = true restrict_public_buckets = true } # Simple GCP storage buckets for testing multiple providers resource "google_storage_bucket" "test" { name = "cli-test-${var.bucket_postfix}-${random_id.bucket_suffix.hex}" location = "US" uniform_bucket_level_access = true versioning { enabled = true } } resource "google_storage_bucket" "test_west" { provider = google.west name = "cli-test-west-${var.bucket_postfix}-${random_id.bucket_suffix.hex}" location = "US-WEST1" uniform_bucket_level_access = true versioning { enabled = true } } resource "random_id" "bucket_suffix" { byte_length = 8 } ================================================ FILE: sources/aws/apigateway-api-key.go ================================================ package aws import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources" awsshared "github.com/overmindtech/cli/sources/aws/shared" "github.com/overmindtech/cli/sources/shared" ) var APIGWAPIKey = shared.NewItemType(awsshared.AWS, awsshared.APIGateway, awsshared.APIKey) // apiGatewayKeyWrapper is a struct that wraps the AWS API Gateway API Key functionality type apiGatewayKeyWrapper struct { client *apigateway.Client *Base } // NewApiGatewayAPIKey creates a new apiGatewayKeyWrapper for AWS API Gateway API Key func NewApiGatewayAPIKey(client *apigateway.Client, accountID, region string) sources.SearchableListableWrapper { return &apiGatewayKeyWrapper{ client: client, Base: NewBase( accountID, region, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, APIGWAPIKey, ), } } // TerraformMappings returns the Terraform mappings for the API Key func (d *apiGatewayKeyWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_api_gateway_api_key.id", }, } } // GetLookups returns the ItemTypeLookups for the Get operation func (d *apiGatewayKeyWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ APIGWAPIKeyLookupByID, } } // Get retrieves an API Key by its ID and converts it to an sdp.Item func (d *apiGatewayKeyWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { out, err := d.client.GetApiKey(ctx, &apigateway.GetApiKeyInput{ ApiKey: &queryParts[0], }) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.awsToSdpItem(convertGetApiKeyOutputToApiKey(out), scope) } // SearchLookups returns the ItemTypeLookups for the Search operation func (d *apiGatewayKeyWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { APIGWAPIKeyLookupByName, }, } } // Search retrieves API Keys by a search query and converts them to sdp.Items func (d *apiGatewayKeyWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { out, err := d.client.GetApiKeys(ctx, &apigateway.GetApiKeysInput{ NameQuery: &queryParts[0], }) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.mapper(out.Items, scope) } // List retrieves all API Keys and converts them to sdp.Items func (d *apiGatewayKeyWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { out, err := d.client.GetApiKeys(ctx, &apigateway.GetApiKeysInput{}) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.mapper(out.Items, scope) } // mapper converts a list of AWS API Keys to a list of sdp.Items func (d *apiGatewayKeyWrapper) mapper(apiKeys []types.ApiKey, scope string) ([]*sdp.Item, *sdp.QueryError) { var items []*sdp.Item for _, apiKey := range apiKeys { sdpItem, err := d.awsToSdpItem(apiKey, scope) if err != nil { return nil, err } items = append(items, sdpItem) } return items, nil } func (d *apiGatewayKeyWrapper) awsToSdpItem(apiKey types.ApiKey, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := adapters.ToAttributesWithExclude(apiKey, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } item := &sdp.Item{ Type: d.Type(), UniqueAttribute: "Id", Attributes: attributes, Scope: scope, Tags: apiKey.Tags, } for _, key := range apiKey.StageKeys { // {restApiId}/{stage} if sections := strings.Split(key, "/"); len(sections) == 2 { restAPIID := sections[0] if restAPIID != "" { linkedItem := shared.NewItemType(awsshared.AWS, awsshared.APIGateway, awsshared.RESTAPI) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: linkedItem.String(), Method: sdp.QueryMethod_GET, Query: restAPIID, Scope: scope, }, }) } } } return item, nil } // convertGetApiKeyOutputToApiKey converts a GetApiKeyOutput to an ApiKey func convertGetApiKeyOutputToApiKey(output *apigateway.GetApiKeyOutput) types.ApiKey { return types.ApiKey{ Id: output.Id, Name: output.Name, Enabled: output.Enabled, CreatedDate: output.CreatedDate, LastUpdatedDate: output.LastUpdatedDate, StageKeys: output.StageKeys, Tags: output.Tags, } } ================================================ FILE: sources/aws/apigateway-stage.go ================================================ package aws import ( "context" "strings" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigateway/types" "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources" awsshared "github.com/overmindtech/cli/sources/aws/shared" "github.com/overmindtech/cli/sources/shared" ) var ( APIGWRestAPI = shared.NewItemType(awsshared.AWS, awsshared.APIGateway, awsshared.RESTAPI) APIGWStage = shared.NewItemType(awsshared.AWS, awsshared.APIGateway, awsshared.Stage) WAFv2WebACL = shared.NewItemType(awsshared.AWS, awsshared.WAFv2, awsshared.WebACL) APIGWDeployment = shared.NewItemType(awsshared.AWS, awsshared.APIGateway, awsshared.Deployment) APIGWRestAPILookupByID = shared.NewItemTypeLookup("id", APIGWRestAPI) APIGWDeploymentLookupByName = shared.NewItemTypeLookup("name", APIGWDeployment) APIGWStageLookupByName = shared.NewItemTypeLookup("name", APIGWStage) APIGWAPIKeyLookupByID = shared.NewItemTypeLookup("id", APIGWAPIKey) APIGWAPIKeyLookupByName = shared.NewItemTypeLookup("name", APIGWAPIKey) ) // apiGatewayKeyWrapper is a struct that wraps the AWS API Gateway Stage functionality type apiGatewayStageWrapper struct { client *apigateway.Client *Base } // NewAPIGatewayStage creates a new apiGatewayKeyWrapper for AWS API Gateway Stage func NewAPIGatewayStage(client *apigateway.Client, accountID, region string) sources.SearchableWrapper { return &apiGatewayStageWrapper{ client: client, Base: NewBase( accountID, region, sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, APIGWStage), } } func (d *apiGatewayStageWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet(WAFv2WebACL) } // TerraformMappings returns the Terraform mappings for the Stage func (d *apiGatewayStageWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "aws_api_gateway_stage.id", }, } } func (d *apiGatewayStageWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ APIGWRestAPILookupByID, APIGWStageLookupByName, } } func (d *apiGatewayStageWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { out, err := d.client.GetStage(ctx, &apigateway.GetStageInput{ RestApiId: &queryParts[0], StageName: &queryParts[1], }) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.awsToSdpItem(convertGetStageOutputToStage(out), scope, queryParts[0]) } // SearchLookups returns the ItemTypeLookups for the Search operation func (d *apiGatewayStageWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { APIGWRestAPILookupByID, }, { APIGWRestAPILookupByID, APIGWDeploymentLookupByName, }, } } // Search retrieves Stages by a search query and converts them to sdp.Items func (d *apiGatewayStageWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { var input *apigateway.GetStagesInput switch len(queryParts) { case 1: input = &apigateway.GetStagesInput{ RestApiId: &queryParts[0], } case 2: input = &apigateway.GetStagesInput{ RestApiId: &queryParts[0], DeploymentId: &queryParts[1], } } out, err := d.client.GetStages(ctx, input) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.mapper(out.Item, scope, queryParts[0]) } // mapper converts a list of AWS Stages to a list of sdp.Items func (d *apiGatewayStageWrapper) mapper(stages []types.Stage, scope, query string) ([]*sdp.Item, *sdp.QueryError) { var items []*sdp.Item for _, stage := range stages { sdpItem, err := d.awsToSdpItem(stage, scope, query) if err != nil { return nil, err } items = append(items, sdpItem) } return items, nil } func (d *apiGatewayStageWrapper) awsToSdpItem(stage types.Stage, scope, query string) (*sdp.Item, *sdp.QueryError) { attributes, err := adapters.ToAttributesWithExclude(stage, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } restAPIID := strings.Split(query, "/")[0] err = attributes.Set("UniqueAttribute", query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } item := &sdp.Item{ Type: d.Type(), UniqueAttribute: "StageName", Attributes: attributes, Scope: scope, Tags: stage.Tags, } if stage.DeploymentId != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: APIGWDeployment.String(), Method: sdp.QueryMethod_GET, Query: restAPIID + "/" + *stage.DeploymentId, Scope: scope, }, }) } linkedItemRestAPI := shared.NewItemType(awsshared.AWS, awsshared.APIGateway, awsshared.RESTAPI) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: linkedItemRestAPI.String(), Method: sdp.QueryMethod_GET, Query: restAPIID, Scope: scope, }, }) return item, nil } // convertGetStageOutputToStage converts a GetStageOutput to a Stage func convertGetStageOutputToStage(output *apigateway.GetStageOutput) types.Stage { return types.Stage{ DeploymentId: output.DeploymentId, StageName: output.StageName, Description: output.Description, CreatedDate: output.CreatedDate, LastUpdatedDate: output.LastUpdatedDate, Variables: output.Variables, AccessLogSettings: output.AccessLogSettings, CacheClusterEnabled: output.CacheClusterEnabled, CacheClusterSize: output.CacheClusterSize, CacheClusterStatus: output.CacheClusterStatus, CanarySettings: output.CanarySettings, ClientCertificateId: output.ClientCertificateId, DocumentationVersion: output.DocumentationVersion, MethodSettings: output.MethodSettings, TracingEnabled: output.TracingEnabled, WebAclArn: output.WebAclArn, Tags: output.Tags, } } ================================================ FILE: sources/aws/base.go ================================================ package aws import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) type Base struct { accountID string region string *shared.Base } func NewBase( accountID string, region string, category sdp.AdapterCategory, item shared.ItemType, ) *Base { return &Base{ accountID: accountID, region: region, Base: shared.NewBase( category, item, []string{fmt.Sprintf("%s.%s", accountID, region)}, ), } } func (m *Base) AccountID() string { return m.accountID } func (m *Base) Region() string { return m.region } ================================================ FILE: sources/aws/errors.go ================================================ package aws import ( "errors" "slices" awsHttp "github.com/aws/smithy-go/transport/http" "github.com/overmindtech/cli/go/sdp-go" ) // queryError takes an error and returns a sdp.QueryError. func queryError(err error, scope string, itemType string) *sdp.QueryError { var responseErr *awsHttp.ResponseError if errors.As(err, &responseErr) { // If the input is bad, access is denied, or the thing wasn't found then // we should assume that it is not exist for this adapter if slices.Contains([]int{400, 403, 404}, responseErr.HTTPStatusCode()) { return &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), SourceName: "aws-source", Scope: scope, ItemType: itemType, } } } return &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), SourceName: "aws-source", Scope: scope, ItemType: itemType, } } ================================================ FILE: sources/aws/shared/item-types.go ================================================ package shared import "github.com/overmindtech/cli/sources/shared" var ( KinesisStream = shared.NewItemType(AWS, Kinesis, Stream) KinesisStreamConsumer = shared.NewItemType(AWS, Kinesis, StreamConsumer) IAMRole = shared.NewItemType(AWS, IAM, Role) MSKCluster = shared.NewItemType(AWS, MSK, Cluster) ) ================================================ FILE: sources/aws/shared/models.go ================================================ package shared import ( "github.com/overmindtech/cli/sources/shared" ) const ( AWS shared.Source = "aws" ) // APIs const ( APIGateway shared.API = "api-gateway" WAFv2 shared.API = "wafv2" Kinesis shared.API = "kinesis" IAM shared.API = "iam" MSK shared.API = "msk" ) // Resources const ( APIKey shared.Resource = "api-key" Stage shared.Resource = "stage" RESTAPI shared.Resource = "rest-api" Deployment shared.Resource = "deployment" WebACL shared.Resource = "web-acl" Stream shared.Resource = "stream" StreamConsumer shared.Resource = "stream-consumer" Role shared.Resource = "role" Cluster shared.Resource = "cluster" ) ================================================ FILE: sources/aws/validation_test.go ================================================ package aws import ( "encoding/json" "os" "strings" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" ) type Validate interface { Validate() error } func TestAdaptersValidation(t *testing.T) { accountID := "123456789012" region := "us-east-1" var adapters []discovery.Adapter adapters = append(adapters, sources.WrapperToAdapter(NewAPIGatewayStage(nil, accountID, region), sdpcache.NewNoOpCache()), sources.WrapperToAdapter(NewApiGatewayAPIKey(nil, accountID, region), sdpcache.NewNoOpCache()), ) for _, adapter := range adapters { t.Run(adapter.Name(), func(t *testing.T) { // Test the adapter a, ok := adapter.(Validate) if !ok { t.Fatalf("Adapter %s does not implement Validate", adapter.Name()) } if err := a.Validate(); err != nil { t.Fatalf("Adapter %s failed validation: %v", adapter.Name(), err) } if strings.EqualFold(os.Getenv("LOG_LEVEL"), "debug") { // Pretty print the adapter metadata via json jsonData, err := json.MarshalIndent(adapter.Metadata(), "", " ") if err != nil { t.Fatalf("Failed to marshal adapter metadata: %v", err) } t.Logf("Adapter %s metadata: %s", adapter.Name(), string(jsonData)) } }) } } ================================================ FILE: sources/azure/README.MD ================================================ # Azure Source Adapters ## Scope Design Azure adapters use a two-level scope hierarchy based on how Azure SDK clients uniquely identify resources: ### Scope Levels 1. **Subscription ID** (highest level) - The primary container for Azure resources - Provides billing and access control boundary - Required to initialize all Azure SDK clients 2. **Resource Group** - Logical container within a subscription - Resources are uniquely identified by: `subscription + resourceGroup + resourceName` - Resource names must be unique within a resource group Resource name is unique within the resource group. Resources in the same group can't share the same name, but identical names can exist in different resource groups. For example, a virtual network named `vnet-prod-westus-001` can exist in multiple resource groups, but only once within a single resource group. ## Naming Convention for Adapters Azure adapter names follow a structured pattern derived from the official Azure REST API documentation: ### Pattern: `azure-{api}-{resource}` 1. **Source**: Always `azure` 2. **API**: Derived from the second part of the REST API provider namespace 3. **Resource**: Singular form of the resource type from the REST API path ### How to Determine the Naming **Step 1: Find the REST API Documentation** Locate the official Azure REST API documentation for the resource. It can be searched from this reference: https://learn.microsoft.com/en-us/rest/api/compute/operation-groups?view=rest-compute-2025-04-01 **Step 2: Extract the API Name** From the REST API path, identify the resource provider namespace. The API name is derived from the second part after `Microsoft.`: ``` REST API Path: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName} ^^^^^^^^ Provider Namespace: Microsoft.Compute API Name: compute (lowercase, singular) ``` **Step 3: Extract the Resource Name** The resource name comes from the resource type in the REST API path, converted to singular form and kebab-case: ``` REST API Path: /providers/Microsoft.Compute/virtualMachines/{vmName} ^^^^^^^^^^^^^^ Resource Type: virtualMachines Resource Name: virtual-machine (singular, kebab-case) ``` ### Examples **Virtual Machine:** - REST API: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get - Provider: `Microsoft.Compute` - Resource Type: `virtualMachines` - **Item Type**: `azure-compute-virtual-machine` **Virtual Network:** - REST API: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/virtual-networks/get - Provider: `Microsoft.Network` - Resource Type: `virtualNetworks` - **Item Type**: `azure-network-virtual-network` ### Special Cases - If the API name and resource name would result in stuttering (e.g., `storage-storage`), keep both parts for clarity - Always use singular form for the resource name (e.g., `virtual-machine` not `virtual-machines`) - Convert PascalCase to kebab-case (e.g., `virtualMachines` → `virtual-machine`) - Keep compound words together (e.g., `publicIPAddress` → `public-ip-address`) ## Code Structure ### Models (`shared/models.go`) Defines constants for building item types: - **Source**: `Azure` - identifies the cloud provider - **API**: Resource provider namespaces (e.g., `Compute` → `Microsoft.Compute`) - **Resource**: Specific resource types within each provider (e.g., `VirtualMachine`, `Disk`) ### Item Types (`shared/item-types.go`) Pre-defined item type variables combining source + API + resource: ```go // Example: azure-compute-virtual-machine ComputeVirtualMachine = shared.NewItemType(Azure, Compute, VirtualMachine) // Example: azure-network-virtual-network NetworkVirtualNetwork = shared.NewItemType(Azure, Network, VirtualNetwork) ``` ### Base Structs (`shared/base.go`) Provides foundation structs for adapters based on scope: - **`ResourceGroupBase`**: For resources scoped to a resource group (most common) - **`SubscriptionBase`**: For subscription-level resources - **`AzureBase`**: Common base providing `SubscriptionID()` method ## Official References - [Azure REST API Reference](https://learn.microsoft.com/en-us/rest/api/azure/) - [Azure Resource Providers and Types](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types) - [Azure Resource Naming Guidelines](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-naming-and-tagging-decision-guide) - [Azure Resource Name Rules](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules) ================================================ FILE: sources/azure/build/package/Dockerfile ================================================ # Build the source binary FROM golang:1.26.2-alpine3.23 AS builder ARG TARGETOS ARG TARGETARCH ARG BUILD_VERSION ARG BUILD_COMMIT # required for generating the version descriptor RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg \ go mod download COPY go/ go/ COPY sources/ sources/ # Build RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source sources/azure/main.go FROM alpine:3.23.4 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 ENTRYPOINT ["/source"] ================================================ FILE: sources/azure/clients/application-gateways-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_application_gateways_client.go -package=mocks -source=application-gateways-client.go // ApplicationGatewaysPager is a type alias for the generic Pager interface with application gateway response type. // This uses the generic Pager[T] interface to avoid code duplication. type ApplicationGatewaysPager = Pager[armnetwork.ApplicationGatewaysClientListResponse] // ApplicationGatewaysClient is an interface for interacting with Azure application gateways type ApplicationGatewaysClient interface { Get(ctx context.Context, resourceGroupName string, applicationGatewayName string, options *armnetwork.ApplicationGatewaysClientGetOptions) (armnetwork.ApplicationGatewaysClientGetResponse, error) List(resourceGroupName string, options *armnetwork.ApplicationGatewaysClientListOptions) ApplicationGatewaysPager } type applicationGatewaysClient struct { client *armnetwork.ApplicationGatewaysClient } func (a *applicationGatewaysClient) Get(ctx context.Context, resourceGroupName string, applicationGatewayName string, options *armnetwork.ApplicationGatewaysClientGetOptions) (armnetwork.ApplicationGatewaysClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, applicationGatewayName, options) } func (a *applicationGatewaysClient) List(resourceGroupName string, options *armnetwork.ApplicationGatewaysClientListOptions) ApplicationGatewaysPager { return a.client.NewListPager(resourceGroupName, options) } // NewApplicationGatewaysClient creates a new ApplicationGatewaysClient from the Azure SDK client func NewApplicationGatewaysClient(client *armnetwork.ApplicationGatewaysClient) ApplicationGatewaysClient { return &applicationGatewaysClient{client: client} } ================================================ FILE: sources/azure/clients/application-security-groups-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_application_security_groups_client.go -package=mocks -source=application-security-groups-client.go // ApplicationSecurityGroupsPager is a type alias for the generic Pager interface with application security group response type. type ApplicationSecurityGroupsPager = Pager[armnetwork.ApplicationSecurityGroupsClientListResponse] // ApplicationSecurityGroupsClient is an interface for interacting with Azure application security groups. type ApplicationSecurityGroupsClient interface { Get(ctx context.Context, resourceGroupName string, applicationSecurityGroupName string, options *armnetwork.ApplicationSecurityGroupsClientGetOptions) (armnetwork.ApplicationSecurityGroupsClientGetResponse, error) NewListPager(resourceGroupName string, options *armnetwork.ApplicationSecurityGroupsClientListOptions) ApplicationSecurityGroupsPager } type applicationSecurityGroupsClient struct { client *armnetwork.ApplicationSecurityGroupsClient } func (c *applicationSecurityGroupsClient) Get(ctx context.Context, resourceGroupName string, applicationSecurityGroupName string, options *armnetwork.ApplicationSecurityGroupsClientGetOptions) (armnetwork.ApplicationSecurityGroupsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, applicationSecurityGroupName, options) } func (c *applicationSecurityGroupsClient) NewListPager(resourceGroupName string, options *armnetwork.ApplicationSecurityGroupsClientListOptions) ApplicationSecurityGroupsPager { return c.client.NewListPager(resourceGroupName, options) } // NewApplicationSecurityGroupsClient creates a new ApplicationSecurityGroupsClient from the Azure SDK client. func NewApplicationSecurityGroupsClient(client *armnetwork.ApplicationSecurityGroupsClient) ApplicationSecurityGroupsClient { return &applicationSecurityGroupsClient{client: client} } ================================================ FILE: sources/azure/clients/availability-sets-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_availability_sets_client.go -package=mocks -source=availability-sets-client.go // AvailabilitySetsPager is a type alias for the generic Pager interface with availability set response type. // This uses the generic Pager[T] interface to avoid code duplication. type AvailabilitySetsPager = Pager[armcompute.AvailabilitySetsClientListResponse] // AvailabilitySetsClient is an interface for interacting with Azure availability sets type AvailabilitySetsClient interface { NewListPager(resourceGroupName string, options *armcompute.AvailabilitySetsClientListOptions) AvailabilitySetsPager Get(ctx context.Context, resourceGroupName string, availabilitySetName string, options *armcompute.AvailabilitySetsClientGetOptions) (armcompute.AvailabilitySetsClientGetResponse, error) } type availabilitySetsClient struct { client *armcompute.AvailabilitySetsClient } func (a *availabilitySetsClient) NewListPager(resourceGroupName string, options *armcompute.AvailabilitySetsClientListOptions) AvailabilitySetsPager { return a.client.NewListPager(resourceGroupName, options) } func (a *availabilitySetsClient) Get(ctx context.Context, resourceGroupName string, availabilitySetName string, options *armcompute.AvailabilitySetsClientGetOptions) (armcompute.AvailabilitySetsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, availabilitySetName, options) } // NewAvailabilitySetsClient creates a new AvailabilitySetsClient from the Azure SDK client func NewAvailabilitySetsClient(client *armcompute.AvailabilitySetsClient) AvailabilitySetsClient { return &availabilitySetsClient{client: client} } ================================================ FILE: sources/azure/clients/batch-accounts-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" ) //go:generate mockgen -destination=../shared/mocks/mock_batch_accounts_client.go -package=mocks -source=batch-accounts-client.go // BatchAccountsPager is a type alias for the generic Pager interface with batch account response type. // This uses the generic Pager[T] interface to avoid code duplication. type BatchAccountsPager = Pager[armbatch.AccountClientListByResourceGroupResponse] // BatchAccountsClient is an interface for interacting with Azure batch accounts type BatchAccountsClient interface { ListByResourceGroup(ctx context.Context, resourceGroupName string) BatchAccountsPager Get(ctx context.Context, resourceGroupName string, accountName string) (armbatch.AccountClientGetResponse, error) } type batchAccountsClient struct { client *armbatch.AccountClient } func (c *batchAccountsClient) ListByResourceGroup(ctx context.Context, resourceGroupName string) BatchAccountsPager { return c.client.NewListByResourceGroupPager(resourceGroupName, nil) } func (c *batchAccountsClient) Get(ctx context.Context, resourceGroupName string, accountName string) (armbatch.AccountClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, nil) } // NewBatchAccountsClient creates a new BatchAccountsClient from the Azure SDK client func NewBatchAccountsClient(client *armbatch.AccountClient) BatchAccountsClient { return &batchAccountsClient{client: client} } ================================================ FILE: sources/azure/clients/batch-application-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" ) //go:generate mockgen -destination=../shared/mocks/mock_batch_application_client.go -package=mocks -source=batch-application-client.go // BatchApplicationsPager is a type alias for the generic Pager interface with batch application response type. type BatchApplicationsPager = Pager[armbatch.ApplicationClientListResponse] // BatchApplicationsClient is an interface for interacting with Azure Batch applications type BatchApplicationsClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, applicationName string) (armbatch.ApplicationClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string) BatchApplicationsPager } type batchApplicationsClient struct { client *armbatch.ApplicationClient } func (c *batchApplicationsClient) Get(ctx context.Context, resourceGroupName string, accountName string, applicationName string) (armbatch.ApplicationClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, applicationName, nil) } func (c *batchApplicationsClient) List(ctx context.Context, resourceGroupName string, accountName string) BatchApplicationsPager { return c.client.NewListPager(resourceGroupName, accountName, nil) } // NewBatchApplicationsClient creates a new BatchApplicationsClient from the Azure SDK client func NewBatchApplicationsClient(client *armbatch.ApplicationClient) BatchApplicationsClient { return &batchApplicationsClient{client: client} } ================================================ FILE: sources/azure/clients/batch-application-package-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" ) //go:generate mockgen -destination=../shared/mocks/mock_batch_application_package_client.go -package=mocks -source=batch-application-package-client.go // BatchApplicationPackagesPager is a type alias for the generic Pager interface with batch application package response type. type BatchApplicationPackagesPager = Pager[armbatch.ApplicationPackageClientListResponse] // BatchApplicationPackagesClient is an interface for interacting with Azure Batch application packages. type BatchApplicationPackagesClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, applicationName string, versionName string) (armbatch.ApplicationPackageClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string, applicationName string) BatchApplicationPackagesPager } type batchApplicationPackagesClient struct { client *armbatch.ApplicationPackageClient } func (c *batchApplicationPackagesClient) Get(ctx context.Context, resourceGroupName string, accountName string, applicationName string, versionName string) (armbatch.ApplicationPackageClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, applicationName, versionName, nil) } func (c *batchApplicationPackagesClient) List(ctx context.Context, resourceGroupName string, accountName string, applicationName string) BatchApplicationPackagesPager { return c.client.NewListPager(resourceGroupName, accountName, applicationName, nil) } // NewBatchApplicationPackagesClient creates a new BatchApplicationPackagesClient from the Azure SDK client. func NewBatchApplicationPackagesClient(client *armbatch.ApplicationPackageClient) BatchApplicationPackagesClient { return &batchApplicationPackagesClient{client: client} } ================================================ FILE: sources/azure/clients/batch-pool-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" ) //go:generate mockgen -destination=../shared/mocks/mock_batch_pool_client.go -package=mocks -source=batch-pool-client.go // BatchPoolsPager is a type alias for the generic Pager interface with batch pool response type. type BatchPoolsPager = Pager[armbatch.PoolClientListByBatchAccountResponse] // BatchPoolsClient is an interface for interacting with Azure Batch pools (child of Batch account). type BatchPoolsClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, poolName string) (armbatch.PoolClientGetResponse, error) ListByBatchAccount(ctx context.Context, resourceGroupName string, accountName string) BatchPoolsPager } type batchPoolsClient struct { client *armbatch.PoolClient } func (c *batchPoolsClient) Get(ctx context.Context, resourceGroupName string, accountName string, poolName string) (armbatch.PoolClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, poolName, nil) } func (c *batchPoolsClient) ListByBatchAccount(ctx context.Context, resourceGroupName string, accountName string) BatchPoolsPager { return c.client.NewListByBatchAccountPager(resourceGroupName, accountName, nil) } // NewBatchPoolsClient creates a new BatchPoolsClient from the Azure SDK client. func NewBatchPoolsClient(client *armbatch.PoolClient) BatchPoolsClient { return &batchPoolsClient{client: client} } ================================================ FILE: sources/azure/clients/batch-private-endpoint-connection-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" ) //go:generate mockgen -destination=../shared/mocks/mock_batch_private_endpoint_connection_client.go -package=mocks -source=batch-private-endpoint-connection-client.go // BatchPrivateEndpointConnectionPager is a type alias for the generic Pager interface with Batch private endpoint connection list response type. type BatchPrivateEndpointConnectionPager = Pager[armbatch.PrivateEndpointConnectionClientListByBatchAccountResponse] // BatchPrivateEndpointConnectionClient is an interface for interacting with Azure Batch private endpoint connections. type BatchPrivateEndpointConnectionClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, privateEndpointConnectionName string) (armbatch.PrivateEndpointConnectionClientGetResponse, error) ListByBatchAccount(ctx context.Context, resourceGroupName string, accountName string) BatchPrivateEndpointConnectionPager } type batchPrivateEndpointConnectionClient struct { client *armbatch.PrivateEndpointConnectionClient } func (c *batchPrivateEndpointConnectionClient) Get(ctx context.Context, resourceGroupName string, accountName string, privateEndpointConnectionName string) (armbatch.PrivateEndpointConnectionClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, privateEndpointConnectionName, nil) } func (c *batchPrivateEndpointConnectionClient) ListByBatchAccount(ctx context.Context, resourceGroupName string, accountName string) BatchPrivateEndpointConnectionPager { return c.client.NewListByBatchAccountPager(resourceGroupName, accountName, nil) } // NewBatchPrivateEndpointConnectionClient creates a new BatchPrivateEndpointConnectionClient from the Azure SDK client. func NewBatchPrivateEndpointConnectionClient(client *armbatch.PrivateEndpointConnectionClient) BatchPrivateEndpointConnectionClient { return &batchPrivateEndpointConnectionClient{client: client} } ================================================ FILE: sources/azure/clients/blob-containers-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_blob_containers_client.go -package=mocks -source=blob-containers-client.go // BlobContainersPager is a type alias for the generic Pager interface with blob container response type. // This uses the generic Pager[T] interface to avoid code duplication. type BlobContainersPager = Pager[armstorage.BlobContainersClientListResponse] // BlobContainersClient is an interface for interacting with Azure blob containers type BlobContainersClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, containerName string) (armstorage.BlobContainersClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string) BlobContainersPager } type blobContainersClient struct { client *armstorage.BlobContainersClient } func (a *blobContainersClient) Get(ctx context.Context, resourceGroupName string, accountName string, containerName string) (armstorage.BlobContainersClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, accountName, containerName, nil) } func (a *blobContainersClient) List(ctx context.Context, resourceGroupName string, accountName string) BlobContainersPager { return a.client.NewListPager(resourceGroupName, accountName, nil) } // NewBlobContainersClient creates a new BlobContainersClient from the Azure SDK client func NewBlobContainersClient(client *armstorage.BlobContainersClient) BlobContainersClient { return &blobContainersClient{client: client} } ================================================ FILE: sources/azure/clients/capacity-reservation-groups-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_capacity_reservation_groups_client.go -package=mocks -source=capacity-reservation-groups-client.go // CapacityReservationGroupsPager is a type alias for the generic Pager interface with capacity reservation group response type. // This uses the generic Pager[T] interface to avoid code duplication. type CapacityReservationGroupsPager = Pager[armcompute.CapacityReservationGroupsClientListByResourceGroupResponse] // CapacityReservationGroupsClient is an interface for interacting with Azure capacity reservation groups type CapacityReservationGroupsClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.CapacityReservationGroupsClientListByResourceGroupOptions) CapacityReservationGroupsPager Get(ctx context.Context, resourceGroupName string, capacityReservationGroupName string, options *armcompute.CapacityReservationGroupsClientGetOptions) (armcompute.CapacityReservationGroupsClientGetResponse, error) } type capacityReservationGroupsClient struct { client *armcompute.CapacityReservationGroupsClient } func (a *capacityReservationGroupsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.CapacityReservationGroupsClientListByResourceGroupOptions) CapacityReservationGroupsPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *capacityReservationGroupsClient) Get(ctx context.Context, resourceGroupName string, capacityReservationGroupName string, options *armcompute.CapacityReservationGroupsClientGetOptions) (armcompute.CapacityReservationGroupsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, capacityReservationGroupName, options) } // NewCapacityReservationGroupsClient creates a new CapacityReservationGroupsClient from the Azure SDK client func NewCapacityReservationGroupsClient(client *armcompute.CapacityReservationGroupsClient) CapacityReservationGroupsClient { return &capacityReservationGroupsClient{client: client} } ================================================ FILE: sources/azure/clients/capacity-reservations-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_capacity_reservations_client.go -package=mocks -source=capacity-reservations-client.go // CapacityReservationsPager is a type alias for the generic Pager interface with capacity reservations list response type. type CapacityReservationsPager = Pager[armcompute.CapacityReservationsClientListByCapacityReservationGroupResponse] // CapacityReservationsClient is an interface for interacting with Azure capacity reservations type CapacityReservationsClient interface { NewListByCapacityReservationGroupPager(resourceGroupName string, capacityReservationGroupName string, options *armcompute.CapacityReservationsClientListByCapacityReservationGroupOptions) CapacityReservationsPager Get(ctx context.Context, resourceGroupName string, capacityReservationGroupName string, capacityReservationName string, options *armcompute.CapacityReservationsClientGetOptions) (armcompute.CapacityReservationsClientGetResponse, error) } type capacityReservationsClient struct { client *armcompute.CapacityReservationsClient } func (c *capacityReservationsClient) NewListByCapacityReservationGroupPager(resourceGroupName string, capacityReservationGroupName string, options *armcompute.CapacityReservationsClientListByCapacityReservationGroupOptions) CapacityReservationsPager { return c.client.NewListByCapacityReservationGroupPager(resourceGroupName, capacityReservationGroupName, options) } func (c *capacityReservationsClient) Get(ctx context.Context, resourceGroupName string, capacityReservationGroupName string, capacityReservationName string, options *armcompute.CapacityReservationsClientGetOptions) (armcompute.CapacityReservationsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, capacityReservationGroupName, capacityReservationName, options) } // NewCapacityReservationsClient creates a new CapacityReservationsClient from the Azure SDK client func NewCapacityReservationsClient(client *armcompute.CapacityReservationsClient) CapacityReservationsClient { return &capacityReservationsClient{client: client} } ================================================ FILE: sources/azure/clients/compute-disk-access-private-endpoint-connection-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go -package=mocks -source=compute-disk-access-private-endpoint-connection-client.go // ComputeDiskAccessPrivateEndpointConnectionsPager is a type alias for the generic Pager interface with disk access private endpoint connection list response type. type ComputeDiskAccessPrivateEndpointConnectionsPager = Pager[armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse] // ComputeDiskAccessPrivateEndpointConnectionsClient is an interface for interacting with Azure disk access private endpoint connections. type ComputeDiskAccessPrivateEndpointConnectionsClient interface { Get(ctx context.Context, resourceGroupName string, diskAccessName string, privateEndpointConnectionName string) (armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse, error) NewListPrivateEndpointConnectionsPager(resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) ComputeDiskAccessPrivateEndpointConnectionsPager } type computeDiskAccessPrivateEndpointConnectionsClient struct { client *armcompute.DiskAccessesClient } func (c *computeDiskAccessPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName string, diskAccessName string, privateEndpointConnectionName string) (armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse, error) { return c.client.GetAPrivateEndpointConnection(ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName, nil) } func (c *computeDiskAccessPrivateEndpointConnectionsClient) NewListPrivateEndpointConnectionsPager(resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) ComputeDiskAccessPrivateEndpointConnectionsPager { return c.client.NewListPrivateEndpointConnectionsPager(resourceGroupName, diskAccessName, options) } // NewComputeDiskAccessPrivateEndpointConnectionsClient creates a new ComputeDiskAccessPrivateEndpointConnectionsClient from the Azure SDK DiskAccesses client. func NewComputeDiskAccessPrivateEndpointConnectionsClient(client *armcompute.DiskAccessesClient) ComputeDiskAccessPrivateEndpointConnectionsClient { return &computeDiskAccessPrivateEndpointConnectionsClient{client: client} } ================================================ FILE: sources/azure/clients/dbforpostgresql-configurations-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_dbforpostgresql_configurations_client.go -package=mocks -source=dbforpostgresql-configurations-client.go // PostgreSQLConfigurationsPager is a type alias for the generic Pager interface. type PostgreSQLConfigurationsPager = Pager[armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse] // PostgreSQLConfigurationsClient is an interface for interacting with Azure PostgreSQL Flexible Server configurations. type PostgreSQLConfigurationsClient interface { Get(ctx context.Context, resourceGroupName string, serverName string, configurationName string, options *armpostgresqlflexibleservers.ConfigurationsClientGetOptions) (armpostgresqlflexibleservers.ConfigurationsClientGetResponse, error) NewListByServerPager(resourceGroupName string, serverName string, options *armpostgresqlflexibleservers.ConfigurationsClientListByServerOptions) PostgreSQLConfigurationsPager } type postgreSQLConfigurationsClient struct { client *armpostgresqlflexibleservers.ConfigurationsClient } func (c *postgreSQLConfigurationsClient) Get(ctx context.Context, resourceGroupName string, serverName string, configurationName string, options *armpostgresqlflexibleservers.ConfigurationsClientGetOptions) (armpostgresqlflexibleservers.ConfigurationsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, serverName, configurationName, options) } func (c *postgreSQLConfigurationsClient) NewListByServerPager(resourceGroupName string, serverName string, options *armpostgresqlflexibleservers.ConfigurationsClientListByServerOptions) PostgreSQLConfigurationsPager { return c.client.NewListByServerPager(resourceGroupName, serverName, options) } // NewPostgreSQLConfigurationsClient creates a new PostgreSQLConfigurationsClient from the Azure SDK client. func NewPostgreSQLConfigurationsClient(client *armpostgresqlflexibleservers.ConfigurationsClient) PostgreSQLConfigurationsClient { return &postgreSQLConfigurationsClient{client: client} } ================================================ FILE: sources/azure/clients/dbforpostgresql-flexible-server-administrator-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_administrator_client.go -package=mocks -source=dbforpostgresql-flexible-server-administrator-client.go // DBforPostgreSQLFlexibleServerAdministratorPager is a type alias for the generic Pager interface with administrator response type. type DBforPostgreSQLFlexibleServerAdministratorPager = Pager[armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse] // DBforPostgreSQLFlexibleServerAdministratorClient is an interface for interacting with Azure PostgreSQL Flexible Server Administrators type DBforPostgreSQLFlexibleServerAdministratorClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerAdministratorPager Get(ctx context.Context, resourceGroupName string, serverName string, objectID string) (armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientGetResponse, error) } type dbforPostgreSQLFlexibleServerAdministratorClient struct { client *armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClient } func (a *dbforPostgreSQLFlexibleServerAdministratorClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerAdministratorPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *dbforPostgreSQLFlexibleServerAdministratorClient) Get(ctx context.Context, resourceGroupName string, serverName string, objectID string) (armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, objectID, nil) } // NewDBforPostgreSQLFlexibleServerAdministratorClient creates a new DBforPostgreSQLFlexibleServerAdministratorClient from the Azure SDK client func NewDBforPostgreSQLFlexibleServerAdministratorClient(client *armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClient) DBforPostgreSQLFlexibleServerAdministratorClient { return &dbforPostgreSQLFlexibleServerAdministratorClient{client: client} } ================================================ FILE: sources/azure/clients/dbforpostgresql-flexible-server-backup-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_backup_client.go -package=mocks -source=dbforpostgresql-flexible-server-backup-client.go type DBforPostgreSQLFlexibleServerBackupPager = Pager[armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse] type DBforPostgreSQLFlexibleServerBackupClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerBackupPager Get(ctx context.Context, resourceGroupName string, serverName string, backupName string) (armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientGetResponse, error) } type dbforPostgreSQLFlexibleServerBackupClient struct { client *armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClient } func (a *dbforPostgreSQLFlexibleServerBackupClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerBackupPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *dbforPostgreSQLFlexibleServerBackupClient) Get(ctx context.Context, resourceGroupName string, serverName string, backupName string) (armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, backupName, nil) } func NewDBforPostgreSQLFlexibleServerBackupClient(client *armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClient) DBforPostgreSQLFlexibleServerBackupClient { return &dbforPostgreSQLFlexibleServerBackupClient{client: client} } ================================================ FILE: sources/azure/clients/dbforpostgresql-flexible-server-private-endpoint-connection-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_private_endpoint_connection_client.go -package=mocks -source=dbforpostgresql-flexible-server-private-endpoint-connection-client.go // DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager is a type alias for the generic Pager interface with PostgreSQL flexible server private endpoint connection list response type. type DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager = Pager[armpostgresqlflexibleservers.PrivateEndpointConnectionsClientListByServerResponse] // DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient is an interface for interacting with Azure DB for PostgreSQL flexible server private endpoint connections. type DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient interface { Get(ctx context.Context, resourceGroupName string, serverName string, privateEndpointConnectionName string) (armpostgresqlflexibleservers.PrivateEndpointConnectionsClientGetResponse, error) ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager } type dbforpostgresqlFlexibleServerPrivateEndpointConnectionsClient struct { client *armpostgresqlflexibleservers.PrivateEndpointConnectionsClient } func (c *dbforpostgresqlFlexibleServerPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName string, serverName string, privateEndpointConnectionName string) (armpostgresqlflexibleservers.PrivateEndpointConnectionsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, serverName, privateEndpointConnectionName, nil) } func (c *dbforpostgresqlFlexibleServerPrivateEndpointConnectionsClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager { return c.client.NewListByServerPager(resourceGroupName, serverName, nil) } // NewDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient creates a new DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient from the Azure SDK client. func NewDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(client *armpostgresqlflexibleservers.PrivateEndpointConnectionsClient) DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient { return &dbforpostgresqlFlexibleServerPrivateEndpointConnectionsClient{client: client} } ================================================ FILE: sources/azure/clients/dbforpostgresql-flexible-server-replica-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_replica_client.go -package=mocks -source=dbforpostgresql-flexible-server-replica-client.go type DBforPostgreSQLFlexibleServerReplicaPager = Pager[armpostgresqlflexibleservers.ReplicasClientListByServerResponse] type DBforPostgreSQLFlexibleServerReplicaClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerReplicaPager Get(ctx context.Context, resourceGroupName string, replicaName string) (armpostgresqlflexibleservers.ServersClientGetResponse, error) } type dbforPostgreSQLFlexibleServerReplicaClient struct { replicasClient *armpostgresqlflexibleservers.ReplicasClient serversClient *armpostgresqlflexibleservers.ServersClient } func (a *dbforPostgreSQLFlexibleServerReplicaClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerReplicaPager { return a.replicasClient.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *dbforPostgreSQLFlexibleServerReplicaClient) Get(ctx context.Context, resourceGroupName string, replicaName string) (armpostgresqlflexibleservers.ServersClientGetResponse, error) { return a.serversClient.Get(ctx, resourceGroupName, replicaName, nil) } func NewDBforPostgreSQLFlexibleServerReplicaClient(replicasClient *armpostgresqlflexibleservers.ReplicasClient, serversClient *armpostgresqlflexibleservers.ServersClient) DBforPostgreSQLFlexibleServerReplicaClient { return &dbforPostgreSQLFlexibleServerReplicaClient{ replicasClient: replicasClient, serversClient: serversClient, } } ================================================ FILE: sources/azure/clients/dbforpostgresql-flexible-server-virtual-endpoint-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_virtual_endpoint_client.go -package=mocks -source=dbforpostgresql-flexible-server-virtual-endpoint-client.go type DBforPostgreSQLFlexibleServerVirtualEndpointPager = Pager[armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse] type DBforPostgreSQLFlexibleServerVirtualEndpointClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerVirtualEndpointPager Get(ctx context.Context, resourceGroupName string, serverName string, virtualEndpointName string) (armpostgresqlflexibleservers.VirtualEndpointsClientGetResponse, error) } type dbforPostgreSQLFlexibleServerVirtualEndpointClient struct { client *armpostgresqlflexibleservers.VirtualEndpointsClient } func (a *dbforPostgreSQLFlexibleServerVirtualEndpointClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) DBforPostgreSQLFlexibleServerVirtualEndpointPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *dbforPostgreSQLFlexibleServerVirtualEndpointClient) Get(ctx context.Context, resourceGroupName string, serverName string, virtualEndpointName string) (armpostgresqlflexibleservers.VirtualEndpointsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, virtualEndpointName, nil) } func NewDBforPostgreSQLFlexibleServerVirtualEndpointClient(client *armpostgresqlflexibleservers.VirtualEndpointsClient) DBforPostgreSQLFlexibleServerVirtualEndpointClient { return &dbforPostgreSQLFlexibleServerVirtualEndpointClient{client: client} } ================================================ FILE: sources/azure/clients/ddos-protection-plans-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_ddos_protection_plans_client.go -package=mocks -source=ddos-protection-plans-client.go // DdosProtectionPlansPager is a type alias for the generic Pager interface with DDoS protection plan list response type. type DdosProtectionPlansPager = Pager[armnetwork.DdosProtectionPlansClientListByResourceGroupResponse] // DdosProtectionPlansClient is an interface for interacting with Azure DDoS protection plans. type DdosProtectionPlansClient interface { Get(ctx context.Context, resourceGroupName string, ddosProtectionPlanName string, options *armnetwork.DdosProtectionPlansClientGetOptions) (armnetwork.DdosProtectionPlansClientGetResponse, error) NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.DdosProtectionPlansClientListByResourceGroupOptions) DdosProtectionPlansPager } type ddosProtectionPlansClient struct { client *armnetwork.DdosProtectionPlansClient } func (c *ddosProtectionPlansClient) Get(ctx context.Context, resourceGroupName string, ddosProtectionPlanName string, options *armnetwork.DdosProtectionPlansClientGetOptions) (armnetwork.DdosProtectionPlansClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, ddosProtectionPlanName, options) } func (c *ddosProtectionPlansClient) NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.DdosProtectionPlansClientListByResourceGroupOptions) DdosProtectionPlansPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } // NewDdosProtectionPlansClient creates a new DdosProtectionPlansClient from the Azure SDK client. func NewDdosProtectionPlansClient(client *armnetwork.DdosProtectionPlansClient) DdosProtectionPlansClient { return &ddosProtectionPlansClient{client: client} } ================================================ FILE: sources/azure/clients/dedicated-host-groups-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_dedicated_host_groups_client.go -package=mocks -source=dedicated-host-groups-client.go // DedicatedHostGroupsPager is a type alias for the generic Pager interface with dedicated host group response type. // This uses the generic Pager[T] interface to avoid code duplication. type DedicatedHostGroupsPager = Pager[armcompute.DedicatedHostGroupsClientListByResourceGroupResponse] // DedicatedHostGroupsClient is an interface for interacting with Azure dedicated host groups type DedicatedHostGroupsClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DedicatedHostGroupsClientListByResourceGroupOptions) DedicatedHostGroupsPager Get(ctx context.Context, resourceGroupName string, dedicatedHostGroupName string, options *armcompute.DedicatedHostGroupsClientGetOptions) (armcompute.DedicatedHostGroupsClientGetResponse, error) } type dedicatedHostGroupsClient struct { client *armcompute.DedicatedHostGroupsClient } func (a *dedicatedHostGroupsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DedicatedHostGroupsClientListByResourceGroupOptions) DedicatedHostGroupsPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *dedicatedHostGroupsClient) Get(ctx context.Context, resourceGroupName string, dedicatedHostGroupName string, options *armcompute.DedicatedHostGroupsClientGetOptions) (armcompute.DedicatedHostGroupsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, dedicatedHostGroupName, options) } // NewDedicatedHostGroupsClient creates a new DedicatedHostGroupsClient from the Azure SDK client func NewDedicatedHostGroupsClient(client *armcompute.DedicatedHostGroupsClient) DedicatedHostGroupsClient { return &dedicatedHostGroupsClient{client: client} } ================================================ FILE: sources/azure/clients/dedicated-hosts-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_dedicated_hosts_client.go -package=mocks -source=dedicated-hosts-client.go // DedicatedHostsPager is a type alias for the generic Pager interface with dedicated hosts list response type. type DedicatedHostsPager = Pager[armcompute.DedicatedHostsClientListByHostGroupResponse] // DedicatedHostsClient is an interface for interacting with Azure dedicated hosts type DedicatedHostsClient interface { NewListByHostGroupPager(resourceGroupName string, hostGroupName string, options *armcompute.DedicatedHostsClientListByHostGroupOptions) DedicatedHostsPager Get(ctx context.Context, resourceGroupName string, hostGroupName string, hostName string, options *armcompute.DedicatedHostsClientGetOptions) (armcompute.DedicatedHostsClientGetResponse, error) } type dedicatedHostsClient struct { client *armcompute.DedicatedHostsClient } func (c *dedicatedHostsClient) NewListByHostGroupPager(resourceGroupName string, hostGroupName string, options *armcompute.DedicatedHostsClientListByHostGroupOptions) DedicatedHostsPager { return c.client.NewListByHostGroupPager(resourceGroupName, hostGroupName, options) } func (c *dedicatedHostsClient) Get(ctx context.Context, resourceGroupName string, hostGroupName string, hostName string, options *armcompute.DedicatedHostsClientGetOptions) (armcompute.DedicatedHostsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, hostGroupName, hostName, options) } // NewDedicatedHostsClient creates a new DedicatedHostsClient from the Azure SDK client func NewDedicatedHostsClient(client *armcompute.DedicatedHostsClient) DedicatedHostsClient { return &dedicatedHostsClient{client: client} } ================================================ FILE: sources/azure/clients/default-security-rules-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_default_security_rules_client.go -package=mocks -source=default-security-rules-client.go // DefaultSecurityRulesPager is a type alias for the generic Pager interface with default security rules list response type. type DefaultSecurityRulesPager = Pager[armnetwork.DefaultSecurityRulesClientListResponse] // DefaultSecurityRulesClient is an interface for interacting with Azure NSG default security rules (child of network security group). type DefaultSecurityRulesClient interface { Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, defaultSecurityRuleName string, options *armnetwork.DefaultSecurityRulesClientGetOptions) (armnetwork.DefaultSecurityRulesClientGetResponse, error) NewListPager(resourceGroupName string, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) DefaultSecurityRulesPager } type defaultSecurityRulesClient struct { client *armnetwork.DefaultSecurityRulesClient } func (c *defaultSecurityRulesClient) Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, defaultSecurityRuleName string, options *armnetwork.DefaultSecurityRulesClientGetOptions) (armnetwork.DefaultSecurityRulesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options) } func (c *defaultSecurityRulesClient) NewListPager(resourceGroupName string, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) DefaultSecurityRulesPager { return c.client.NewListPager(resourceGroupName, networkSecurityGroupName, options) } // NewDefaultSecurityRulesClient creates a new DefaultSecurityRulesClient from the Azure SDK client. func NewDefaultSecurityRulesClient(client *armnetwork.DefaultSecurityRulesClient) DefaultSecurityRulesClient { return &defaultSecurityRulesClient{client: client} } ================================================ FILE: sources/azure/clients/disk-accesses-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_disk_accesses_client.go -package=mocks -source=disk-accesses-client.go // DiskAccessesPager is a type alias for the generic Pager interface with disk access response type. // This uses the generic Pager[T] interface to avoid code duplication. type DiskAccessesPager = Pager[armcompute.DiskAccessesClientListByResourceGroupResponse] // DiskAccessesClient is an interface for interacting with Azure disk access type DiskAccessesClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DiskAccessesClientListByResourceGroupOptions) DiskAccessesPager Get(ctx context.Context, resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientGetOptions) (armcompute.DiskAccessesClientGetResponse, error) } type diskAccessesClient struct { client *armcompute.DiskAccessesClient } func (a *diskAccessesClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DiskAccessesClientListByResourceGroupOptions) DiskAccessesPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *diskAccessesClient) Get(ctx context.Context, resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientGetOptions) (armcompute.DiskAccessesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, diskAccessName, options) } // NewDiskAccessesClient creates a new DiskAccessesClient from the Azure SDK client func NewDiskAccessesClient(client *armcompute.DiskAccessesClient) DiskAccessesClient { return &diskAccessesClient{client: client} } ================================================ FILE: sources/azure/clients/disk-encryption-sets-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_disk_encryption_sets_client.go -package=mocks -source=disk-encryption-sets-client.go // DiskEncryptionSetsPager is a type alias for the generic Pager interface with disk encryption set response type. // This uses the generic Pager[T] interface to avoid code duplication. type DiskEncryptionSetsPager = Pager[armcompute.DiskEncryptionSetsClientListByResourceGroupResponse] // DiskEncryptionSetsClient is an interface for interacting with Azure disk encryption sets type DiskEncryptionSetsClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DiskEncryptionSetsClientListByResourceGroupOptions) DiskEncryptionSetsPager Get(ctx context.Context, resourceGroupName string, diskEncryptionSetName string, options *armcompute.DiskEncryptionSetsClientGetOptions) (armcompute.DiskEncryptionSetsClientGetResponse, error) } type diskEncryptionSetsClient struct { client *armcompute.DiskEncryptionSetsClient } func (a *diskEncryptionSetsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DiskEncryptionSetsClientListByResourceGroupOptions) DiskEncryptionSetsPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *diskEncryptionSetsClient) Get(ctx context.Context, resourceGroupName string, diskEncryptionSetName string, options *armcompute.DiskEncryptionSetsClientGetOptions) (armcompute.DiskEncryptionSetsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, diskEncryptionSetName, options) } // NewDiskEncryptionSetsClient creates a new DiskEncryptionSetsClient from the Azure SDK client func NewDiskEncryptionSetsClient(client *armcompute.DiskEncryptionSetsClient) DiskEncryptionSetsClient { return &diskEncryptionSetsClient{client: client} } ================================================ FILE: sources/azure/clients/disks-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_disks_client.go -package=mocks -source=disks-client.go // DisksPager is a type alias for the generic Pager interface with disk response type. // This uses the generic Pager[T] interface to avoid code duplication. type DisksPager = Pager[armcompute.DisksClientListByResourceGroupResponse] // DisksClient is an interface for interacting with Azure disks type DisksClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DisksClientListByResourceGroupOptions) DisksPager Get(ctx context.Context, resourceGroupName string, diskName string, options *armcompute.DisksClientGetOptions) (armcompute.DisksClientGetResponse, error) } type disksClient struct { client *armcompute.DisksClient } func (a *disksClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DisksClientListByResourceGroupOptions) DisksPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *disksClient) Get(ctx context.Context, resourceGroupName string, diskName string, options *armcompute.DisksClientGetOptions) (armcompute.DisksClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, diskName, options) } // NewDisksClient creates a new DisksClient from the Azure SDK client func NewDisksClient(client *armcompute.DisksClient) DisksClient { return &disksClient{client: client} } ================================================ FILE: sources/azure/clients/documentdb-database-accounts-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_documentdb_database_accounts_client.go -package=mocks -source=documentdb-database-accounts-client.go // DocumentDBDatabaseAccountsPager is a type alias for the generic Pager interface with documentdb database account response type. // This uses the generic Pager[T] interface to avoid code duplication. type DocumentDBDatabaseAccountsPager = Pager[armcosmos.DatabaseAccountsClientListByResourceGroupResponse] // DocumentDBDatabaseAccountsClient is an interface for interacting with Azure documentdb database accounts type DocumentDBDatabaseAccountsClient interface { ListByResourceGroup(resourceGroupName string) DocumentDBDatabaseAccountsPager Get(ctx context.Context, resourceGroupName string, accountName string) (armcosmos.DatabaseAccountsClientGetResponse, error) } type documentDBDatabaseAccountsClient struct { client *armcosmos.DatabaseAccountsClient } func (a *documentDBDatabaseAccountsClient) ListByResourceGroup(resourceGroupName string) DocumentDBDatabaseAccountsPager { return a.client.NewListByResourceGroupPager(resourceGroupName, nil) } func (a *documentDBDatabaseAccountsClient) Get(ctx context.Context, resourceGroupName string, accountName string) (armcosmos.DatabaseAccountsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, accountName, nil) } // NewDocumentDBDatabaseAccountsClient creates a new DocumentDBDatabaseAccountsClient from the Azure SDK client func NewDocumentDBDatabaseAccountsClient(client *armcosmos.DatabaseAccountsClient) DocumentDBDatabaseAccountsClient { return &documentDBDatabaseAccountsClient{client: client} } ================================================ FILE: sources/azure/clients/documentdb-private-endpoint-connection-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_documentdb_private_endpoint_connection_client.go -package=mocks -source=documentdb-private-endpoint-connection-client.go // DocumentDBPrivateEndpointConnectionsPager is a type alias for the generic Pager interface with Cosmos DB private endpoint connection list response type. type DocumentDBPrivateEndpointConnectionsPager = Pager[armcosmos.PrivateEndpointConnectionsClientListByDatabaseAccountResponse] // DocumentDBPrivateEndpointConnectionsClient is an interface for interacting with Azure Cosmos DB (DocumentDB) database account private endpoint connections. type DocumentDBPrivateEndpointConnectionsClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, privateEndpointConnectionName string) (armcosmos.PrivateEndpointConnectionsClientGetResponse, error) ListByDatabaseAccount(ctx context.Context, resourceGroupName string, accountName string) DocumentDBPrivateEndpointConnectionsPager } type documentDBPrivateEndpointConnectionsClient struct { client *armcosmos.PrivateEndpointConnectionsClient } func (c *documentDBPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName string, accountName string, privateEndpointConnectionName string) (armcosmos.PrivateEndpointConnectionsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, privateEndpointConnectionName, nil) } func (c *documentDBPrivateEndpointConnectionsClient) ListByDatabaseAccount(ctx context.Context, resourceGroupName string, accountName string) DocumentDBPrivateEndpointConnectionsPager { return c.client.NewListByDatabaseAccountPager(resourceGroupName, accountName, nil) } // NewDocumentDBPrivateEndpointConnectionsClient creates a new DocumentDBPrivateEndpointConnectionsClient from the Azure SDK client. func NewDocumentDBPrivateEndpointConnectionsClient(client *armcosmos.PrivateEndpointConnectionsClient) DocumentDBPrivateEndpointConnectionsClient { return &documentDBPrivateEndpointConnectionsClient{client: client} } ================================================ FILE: sources/azure/clients/elastic-san-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" ) //go:generate mockgen -destination=../shared/mocks/mock_elastic_san_client.go -package=mocks -source=elastic-san-client.go // ElasticSanPager is a type alias for the generic Pager interface with Elastic SAN list response type. type ElasticSanPager = Pager[armelasticsan.ElasticSansClientListByResourceGroupResponse] // ElasticSanClient is an interface for interacting with Azure Elastic SAN (pool) resources. type ElasticSanClient interface { Get(ctx context.Context, resourceGroupName string, elasticSanName string, options *armelasticsan.ElasticSansClientGetOptions) (armelasticsan.ElasticSansClientGetResponse, error) NewListByResourceGroupPager(resourceGroupName string, options *armelasticsan.ElasticSansClientListByResourceGroupOptions) ElasticSanPager } type elasticSanClient struct { client *armelasticsan.ElasticSansClient } func (c *elasticSanClient) Get(ctx context.Context, resourceGroupName string, elasticSanName string, options *armelasticsan.ElasticSansClientGetOptions) (armelasticsan.ElasticSansClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, elasticSanName, options) } func (c *elasticSanClient) NewListByResourceGroupPager(resourceGroupName string, options *armelasticsan.ElasticSansClientListByResourceGroupOptions) ElasticSanPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } // NewElasticSanClient creates a new ElasticSanClient from the Azure SDK client. func NewElasticSanClient(client *armelasticsan.ElasticSansClient) ElasticSanClient { return &elasticSanClient{client: client} } ================================================ FILE: sources/azure/clients/elastic-san-volume-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" ) //go:generate mockgen -destination=../shared/mocks/mock_elastic_san_volume_client.go -package=mocks -source=elastic-san-volume-client.go // ElasticSanVolumePager is a type alias for the generic Pager interface with volume list response type. type ElasticSanVolumePager = Pager[armelasticsan.VolumesClientListByVolumeGroupResponse] // ElasticSanVolumeClient is an interface for interacting with Azure Elastic SAN volumes. type ElasticSanVolumeClient interface { Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, volumeName string, options *armelasticsan.VolumesClientGetOptions) (armelasticsan.VolumesClientGetResponse, error) NewListByVolumeGroupPager(resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumesClientListByVolumeGroupOptions) ElasticSanVolumePager } type elasticSanVolumeClient struct { client *armelasticsan.VolumesClient } func (c *elasticSanVolumeClient) Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, volumeName string, options *armelasticsan.VolumesClientGetOptions) (armelasticsan.VolumesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, options) } func (c *elasticSanVolumeClient) NewListByVolumeGroupPager(resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumesClientListByVolumeGroupOptions) ElasticSanVolumePager { return c.client.NewListByVolumeGroupPager(resourceGroupName, elasticSanName, volumeGroupName, options) } // NewElasticSanVolumeClient creates a new ElasticSanVolumeClient from the Azure SDK client. func NewElasticSanVolumeClient(client *armelasticsan.VolumesClient) ElasticSanVolumeClient { return &elasticSanVolumeClient{client: client} } ================================================ FILE: sources/azure/clients/elastic-san-volume-group-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" ) //go:generate mockgen -destination=../shared/mocks/mock_elastic_san_volume_group_client.go -package=mocks -source=elastic-san-volume-group-client.go // ElasticSanVolumeGroupPager is a type alias for the generic Pager interface with volume group list response type. type ElasticSanVolumeGroupPager = Pager[armelasticsan.VolumeGroupsClientListByElasticSanResponse] // ElasticSanVolumeGroupClient is an interface for interacting with Azure Elastic SAN volume groups. type ElasticSanVolumeGroupClient interface { Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeGroupsClientGetOptions) (armelasticsan.VolumeGroupsClientGetResponse, error) NewListByElasticSanPager(resourceGroupName string, elasticSanName string, options *armelasticsan.VolumeGroupsClientListByElasticSanOptions) ElasticSanVolumeGroupPager } type elasticSanVolumeGroupClient struct { client *armelasticsan.VolumeGroupsClient } func (c *elasticSanVolumeGroupClient) Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeGroupsClientGetOptions) (armelasticsan.VolumeGroupsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, options) } func (c *elasticSanVolumeGroupClient) NewListByElasticSanPager(resourceGroupName string, elasticSanName string, options *armelasticsan.VolumeGroupsClientListByElasticSanOptions) ElasticSanVolumeGroupPager { return c.client.NewListByElasticSanPager(resourceGroupName, elasticSanName, options) } // NewElasticSanVolumeGroupClient creates a new ElasticSanVolumeGroupClient from the Azure SDK client. func NewElasticSanVolumeGroupClient(client *armelasticsan.VolumeGroupsClient) ElasticSanVolumeGroupClient { return &elasticSanVolumeGroupClient{client: client} } ================================================ FILE: sources/azure/clients/elastic-san-volume-snapshot-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" ) //go:generate mockgen -destination=../shared/mocks/mock_elastic_san_volume_snapshot_client.go -package=mocks -source=elastic-san-volume-snapshot-client.go // ElasticSanVolumeSnapshotPager is a type alias for the generic Pager interface with volume snapshot list response type. type ElasticSanVolumeSnapshotPager = Pager[armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse] // ElasticSanVolumeSnapshotClient is an interface for interacting with Azure Elastic SAN volume snapshots. type ElasticSanVolumeSnapshotClient interface { Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, snapshotName string, options *armelasticsan.VolumeSnapshotsClientGetOptions) (armelasticsan.VolumeSnapshotsClientGetResponse, error) ListByVolumeGroup(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeSnapshotsClientListByVolumeGroupOptions) ElasticSanVolumeSnapshotPager } type elasticSanVolumeSnapshotClient struct { client *armelasticsan.VolumeSnapshotsClient } func (c *elasticSanVolumeSnapshotClient) Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, snapshotName string, options *armelasticsan.VolumeSnapshotsClientGetOptions) (armelasticsan.VolumeSnapshotsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options) } func (c *elasticSanVolumeSnapshotClient) ListByVolumeGroup(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeSnapshotsClientListByVolumeGroupOptions) ElasticSanVolumeSnapshotPager { return c.client.NewListByVolumeGroupPager(resourceGroupName, elasticSanName, volumeGroupName, options) } // NewElasticSanVolumeSnapshotClient creates a new ElasticSanVolumeSnapshotClient from the Azure SDK client. func NewElasticSanVolumeSnapshotClient(client *armelasticsan.VolumeSnapshotsClient) ElasticSanVolumeSnapshotClient { return &elasticSanVolumeSnapshotClient{client: client} } ================================================ FILE: sources/azure/clients/encryption-scopes-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_encryption_scopes_client.go -package=mocks -source=encryption-scopes-client.go // EncryptionScopesPager is a type alias for the generic Pager interface with encryption scope list response type. type EncryptionScopesPager = Pager[armstorage.EncryptionScopesClientListResponse] // EncryptionScopesClient is an interface for interacting with Azure storage encryption scopes type EncryptionScopesClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, encryptionScopeName string) (armstorage.EncryptionScopesClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string) EncryptionScopesPager } type encryptionScopesClient struct { client *armstorage.EncryptionScopesClient } func (c *encryptionScopesClient) Get(ctx context.Context, resourceGroupName string, accountName string, encryptionScopeName string) (armstorage.EncryptionScopesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, encryptionScopeName, nil) } func (c *encryptionScopesClient) List(ctx context.Context, resourceGroupName string, accountName string) EncryptionScopesPager { return c.client.NewListPager(resourceGroupName, accountName, nil) } // NewEncryptionScopesClient creates a new EncryptionScopesClient from the Azure SDK client func NewEncryptionScopesClient(client *armstorage.EncryptionScopesClient) EncryptionScopesClient { return &encryptionScopesClient{client: client} } ================================================ FILE: sources/azure/clients/federated-identity-credentials-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" ) //go:generate mockgen -destination=../shared/mocks/mock_federated_identity_credentials_client.go -package=mocks -source=federated-identity-credentials-client.go // FederatedIdentityCredentialsPager is a pager for listing federated identity credentials type FederatedIdentityCredentialsPager = Pager[armmsi.FederatedIdentityCredentialsClientListResponse] // FederatedIdentityCredentialsClient is the client interface for interacting with federated identity credentials type FederatedIdentityCredentialsClient interface { NewListPager(resourceGroupName string, resourceName string, options *armmsi.FederatedIdentityCredentialsClientListOptions) FederatedIdentityCredentialsPager Get(ctx context.Context, resourceGroupName string, resourceName string, federatedIdentityCredentialResourceName string, options *armmsi.FederatedIdentityCredentialsClientGetOptions) (armmsi.FederatedIdentityCredentialsClientGetResponse, error) } type federatedIdentityCredentialsClient struct { client *armmsi.FederatedIdentityCredentialsClient } func (f *federatedIdentityCredentialsClient) NewListPager(resourceGroupName string, resourceName string, options *armmsi.FederatedIdentityCredentialsClientListOptions) FederatedIdentityCredentialsPager { return f.client.NewListPager(resourceGroupName, resourceName, options) } func (f *federatedIdentityCredentialsClient) Get(ctx context.Context, resourceGroupName string, resourceName string, federatedIdentityCredentialResourceName string, options *armmsi.FederatedIdentityCredentialsClientGetOptions) (armmsi.FederatedIdentityCredentialsClientGetResponse, error) { return f.client.Get(ctx, resourceGroupName, resourceName, federatedIdentityCredentialResourceName, options) } // NewFederatedIdentityCredentialsClient creates a new FederatedIdentityCredentialsClient func NewFederatedIdentityCredentialsClient(client *armmsi.FederatedIdentityCredentialsClient) FederatedIdentityCredentialsClient { return &federatedIdentityCredentialsClient{client: client} } ================================================ FILE: sources/azure/clients/fileshares-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_file_shares_client.go -package=mocks -source=fileshares-client.go // FileSharesPager is a type alias for the generic Pager interface with file share response type. // This uses the generic Pager[T] interface to avoid code duplication. type FileSharesPager = Pager[armstorage.FileSharesClientListResponse] // FileSharesClient is an interface for interacting with Azure file shares type FileSharesClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, shareName string) (armstorage.FileSharesClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string) FileSharesPager } type fileSharesClient struct { client *armstorage.FileSharesClient } func (a *fileSharesClient) Get(ctx context.Context, resourceGroupName string, accountName string, shareName string) (armstorage.FileSharesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, accountName, shareName, nil) } func (a *fileSharesClient) List(ctx context.Context, resourceGroupName string, accountName string) FileSharesPager { return a.client.NewListPager(resourceGroupName, accountName, nil) } // NewFileSharesClient creates a new FileSharesClient from the Azure SDK client func NewFileSharesClient(client *armstorage.FileSharesClient) FileSharesClient { return &fileSharesClient{client: client} } ================================================ FILE: sources/azure/clients/flow-logs-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_flow_logs_client.go -package=mocks -source=flow-logs-client.go // FlowLogsPager is a type alias for the generic Pager interface with flow logs list response type. type FlowLogsPager = Pager[armnetwork.FlowLogsClientListResponse] // FlowLogsClient is an interface for interacting with Azure flow logs (child of network watcher). type FlowLogsClient interface { Get(ctx context.Context, resourceGroupName string, networkWatcherName string, flowLogName string, options *armnetwork.FlowLogsClientGetOptions) (armnetwork.FlowLogsClientGetResponse, error) NewListPager(resourceGroupName string, networkWatcherName string, options *armnetwork.FlowLogsClientListOptions) FlowLogsPager } type flowLogsClient struct { client *armnetwork.FlowLogsClient } func (a *flowLogsClient) Get(ctx context.Context, resourceGroupName string, networkWatcherName string, flowLogName string, options *armnetwork.FlowLogsClientGetOptions) (armnetwork.FlowLogsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, networkWatcherName, flowLogName, options) } func (a *flowLogsClient) NewListPager(resourceGroupName string, networkWatcherName string, options *armnetwork.FlowLogsClientListOptions) FlowLogsPager { return a.client.NewListPager(resourceGroupName, networkWatcherName, options) } // NewFlowLogsClient creates a new FlowLogsClient from the Azure SDK client. func NewFlowLogsClient(client *armnetwork.FlowLogsClient) FlowLogsClient { return &flowLogsClient{client: client} } ================================================ FILE: sources/azure/clients/galleries-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_galleries_client.go -package=mocks -source=galleries-client.go // GalleriesPager is a type alias for the generic Pager interface with gallery response type. type GalleriesPager = Pager[armcompute.GalleriesClientListByResourceGroupResponse] // GalleriesClient is an interface for interacting with Azure compute galleries type GalleriesClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.GalleriesClientListByResourceGroupOptions) GalleriesPager Get(ctx context.Context, resourceGroupName string, galleryName string, options *armcompute.GalleriesClientGetOptions) (armcompute.GalleriesClientGetResponse, error) } type galleriesClient struct { client *armcompute.GalleriesClient } func (c *galleriesClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.GalleriesClientListByResourceGroupOptions) GalleriesPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } func (c *galleriesClient) Get(ctx context.Context, resourceGroupName string, galleryName string, options *armcompute.GalleriesClientGetOptions) (armcompute.GalleriesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, galleryName, options) } // NewGalleriesClient creates a new GalleriesClient from the Azure SDK client func NewGalleriesClient(client *armcompute.GalleriesClient) GalleriesClient { return &galleriesClient{client: client} } ================================================ FILE: sources/azure/clients/gallery-application-versions-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_gallery_application_versions_client.go -package=mocks -source=gallery-application-versions-client.go // GalleryApplicationVersionsPager is a type alias for the generic Pager interface with gallery application version response type. // This uses the generic Pager[T] interface to avoid code duplication. type GalleryApplicationVersionsPager = Pager[armcompute.GalleryApplicationVersionsClientListByGalleryApplicationResponse] // GalleryApplicationVersionsClient is an interface for interacting with Azure gallery application versions type GalleryApplicationVersionsClient interface { NewListByGalleryApplicationPager(resourceGroupName string, galleryName string, galleryApplicationName string, options *armcompute.GalleryApplicationVersionsClientListByGalleryApplicationOptions) GalleryApplicationVersionsPager Get(ctx context.Context, resourceGroupName string, galleryName string, galleryApplicationName string, galleryApplicationVersionName string, options *armcompute.GalleryApplicationVersionsClientGetOptions) (armcompute.GalleryApplicationVersionsClientGetResponse, error) } type galleryApplicationVersionsClient struct { client *armcompute.GalleryApplicationVersionsClient } func (c *galleryApplicationVersionsClient) NewListByGalleryApplicationPager(resourceGroupName string, galleryName string, galleryApplicationName string, options *armcompute.GalleryApplicationVersionsClientListByGalleryApplicationOptions) GalleryApplicationVersionsPager { return c.client.NewListByGalleryApplicationPager(resourceGroupName, galleryName, galleryApplicationName, options) } func (c *galleryApplicationVersionsClient) Get(ctx context.Context, resourceGroupName string, galleryName string, galleryApplicationName string, galleryApplicationVersionName string, options *armcompute.GalleryApplicationVersionsClientGetOptions) (armcompute.GalleryApplicationVersionsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, galleryName, galleryApplicationName, galleryApplicationVersionName, options) } // NewGalleryApplicationVersionsClient creates a new GalleryApplicationVersionsClient from the Azure SDK client func NewGalleryApplicationVersionsClient(client *armcompute.GalleryApplicationVersionsClient) GalleryApplicationVersionsClient { return &galleryApplicationVersionsClient{client: client} } ================================================ FILE: sources/azure/clients/gallery-applications-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_gallery_applications_client.go -package=mocks -source=gallery-applications-client.go // GalleryApplicationsPager is a type alias for the generic Pager interface with gallery application response type. type GalleryApplicationsPager = Pager[armcompute.GalleryApplicationsClientListByGalleryResponse] // GalleryApplicationsClient is an interface for interacting with Azure gallery applications type GalleryApplicationsClient interface { NewListByGalleryPager(resourceGroupName string, galleryName string, options *armcompute.GalleryApplicationsClientListByGalleryOptions) GalleryApplicationsPager Get(ctx context.Context, resourceGroupName string, galleryName string, galleryApplicationName string, options *armcompute.GalleryApplicationsClientGetOptions) (armcompute.GalleryApplicationsClientGetResponse, error) } type galleryApplicationsClient struct { client *armcompute.GalleryApplicationsClient } func (c *galleryApplicationsClient) NewListByGalleryPager(resourceGroupName string, galleryName string, options *armcompute.GalleryApplicationsClientListByGalleryOptions) GalleryApplicationsPager { return c.client.NewListByGalleryPager(resourceGroupName, galleryName, options) } func (c *galleryApplicationsClient) Get(ctx context.Context, resourceGroupName string, galleryName string, galleryApplicationName string, options *armcompute.GalleryApplicationsClientGetOptions) (armcompute.GalleryApplicationsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, galleryName, galleryApplicationName, options) } // NewGalleryApplicationsClient creates a new GalleryApplicationsClient from the Azure SDK client func NewGalleryApplicationsClient(client *armcompute.GalleryApplicationsClient) GalleryApplicationsClient { return &galleryApplicationsClient{client: client} } ================================================ FILE: sources/azure/clients/gallery-images-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_gallery_images_client.go -package=mocks -source=gallery-images-client.go // GalleryImagesPager is a type alias for the generic Pager interface with gallery image response type. // This uses the generic Pager[T] interface to avoid code duplication. type GalleryImagesPager = Pager[armcompute.GalleryImagesClientListByGalleryResponse] // GalleryImagesClient is an interface for interacting with Azure gallery image definitions type GalleryImagesClient interface { NewListByGalleryPager(resourceGroupName string, galleryName string, options *armcompute.GalleryImagesClientListByGalleryOptions) GalleryImagesPager Get(ctx context.Context, resourceGroupName string, galleryName string, galleryImageName string, options *armcompute.GalleryImagesClientGetOptions) (armcompute.GalleryImagesClientGetResponse, error) } type galleryImagesClient struct { client *armcompute.GalleryImagesClient } func (c *galleryImagesClient) NewListByGalleryPager(resourceGroupName string, galleryName string, options *armcompute.GalleryImagesClientListByGalleryOptions) GalleryImagesPager { return c.client.NewListByGalleryPager(resourceGroupName, galleryName, options) } func (c *galleryImagesClient) Get(ctx context.Context, resourceGroupName string, galleryName string, galleryImageName string, options *armcompute.GalleryImagesClientGetOptions) (armcompute.GalleryImagesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, galleryName, galleryImageName, options) } // NewGalleryImagesClient creates a new GalleryImagesClient from the Azure SDK client func NewGalleryImagesClient(client *armcompute.GalleryImagesClient) GalleryImagesClient { return &galleryImagesClient{client: client} } ================================================ FILE: sources/azure/clients/images-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_images_client.go -package=mocks -source=images-client.go // ImagesPager is a type alias for the generic Pager interface with image response type. // This uses the generic Pager[T] interface to avoid code duplication. type ImagesPager = Pager[armcompute.ImagesClientListByResourceGroupResponse] // ImagesClient is an interface for interacting with Azure images type ImagesClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.ImagesClientListByResourceGroupOptions) ImagesPager Get(ctx context.Context, resourceGroupName string, imageName string, options *armcompute.ImagesClientGetOptions) (armcompute.ImagesClientGetResponse, error) } type imagesClient struct { client *armcompute.ImagesClient } func (a *imagesClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.ImagesClientListByResourceGroupOptions) ImagesPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *imagesClient) Get(ctx context.Context, resourceGroupName string, imageName string, options *armcompute.ImagesClientGetOptions) (armcompute.ImagesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, imageName, options) } // NewImagesClient creates a new ImagesClient from the Azure SDK client func NewImagesClient(client *armcompute.ImagesClient) ImagesClient { return &imagesClient{client: client} } ================================================ FILE: sources/azure/clients/interface-ip-configurations-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_interface_ip_configurations_client.go -package=mocks -source=interface-ip-configurations-client.go // InterfaceIPConfigurationsPager is a type alias for the generic Pager interface with InterfaceIPConfiguration response type. type InterfaceIPConfigurationsPager = Pager[armnetwork.InterfaceIPConfigurationsClientListResponse] // InterfaceIPConfigurationsClient is an interface for interacting with Azure network interface IP configurations type InterfaceIPConfigurationsClient interface { Get(ctx context.Context, resourceGroupName string, networkInterfaceName string, ipConfigurationName string) (armnetwork.InterfaceIPConfigurationsClientGetResponse, error) List(ctx context.Context, resourceGroupName string, networkInterfaceName string) InterfaceIPConfigurationsPager } type interfaceIPConfigurationsClient struct { client *armnetwork.InterfaceIPConfigurationsClient } func (a *interfaceIPConfigurationsClient) Get(ctx context.Context, resourceGroupName string, networkInterfaceName string, ipConfigurationName string) (armnetwork.InterfaceIPConfigurationsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, networkInterfaceName, ipConfigurationName, nil) } func (a *interfaceIPConfigurationsClient) List(ctx context.Context, resourceGroupName string, networkInterfaceName string) InterfaceIPConfigurationsPager { return a.client.NewListPager(resourceGroupName, networkInterfaceName, nil) } // NewInterfaceIPConfigurationsClient creates a new InterfaceIPConfigurationsClient from the Azure SDK client func NewInterfaceIPConfigurationsClient(client *armnetwork.InterfaceIPConfigurationsClient) InterfaceIPConfigurationsClient { return &interfaceIPConfigurationsClient{client: client} } ================================================ FILE: sources/azure/clients/ip-groups-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_ip_groups_client.go -package=mocks -source=ip-groups-client.go // IPGroupsPager is a type alias for the generic Pager interface with IP groups response type. type IPGroupsPager = Pager[armnetwork.IPGroupsClientListByResourceGroupResponse] // IPGroupsClient is an interface for interacting with Azure IP Groups. type IPGroupsClient interface { Get(ctx context.Context, resourceGroupName string, ipGroupsName string, options *armnetwork.IPGroupsClientGetOptions) (armnetwork.IPGroupsClientGetResponse, error) NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.IPGroupsClientListByResourceGroupOptions) IPGroupsPager } type ipGroupsClient struct { client *armnetwork.IPGroupsClient } func (c *ipGroupsClient) Get(ctx context.Context, resourceGroupName string, ipGroupsName string, options *armnetwork.IPGroupsClientGetOptions) (armnetwork.IPGroupsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, ipGroupsName, options) } func (c *ipGroupsClient) NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.IPGroupsClientListByResourceGroupOptions) IPGroupsPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } // NewIPGroupsClient creates a new IPGroupsClient from the Azure SDK client. func NewIPGroupsClient(client *armnetwork.IPGroupsClient) IPGroupsClient { return &ipGroupsClient{client: client} } ================================================ FILE: sources/azure/clients/keyvault-key-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_keyvault_key_client.go -package=mocks -source=keyvault-key-client.go // KeysPager is a type alias for the generic Pager interface with keys response type. type KeysPager = Pager[armkeyvault.KeysClientListResponse] // KeysClient is an interface for interacting with Azure Key Vault keys type KeysClient interface { NewListPager(resourceGroupName string, vaultName string, options *armkeyvault.KeysClientListOptions) KeysPager Get(ctx context.Context, resourceGroupName string, vaultName string, keyName string, options *armkeyvault.KeysClientGetOptions) (armkeyvault.KeysClientGetResponse, error) } type keysClient struct { client *armkeyvault.KeysClient } func (c *keysClient) NewListPager(resourceGroupName string, vaultName string, options *armkeyvault.KeysClientListOptions) KeysPager { return c.client.NewListPager(resourceGroupName, vaultName, options) } func (c *keysClient) Get(ctx context.Context, resourceGroupName string, vaultName string, keyName string, options *armkeyvault.KeysClientGetOptions) (armkeyvault.KeysClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, vaultName, keyName, options) } // NewKeysClient creates a new KeysClient from the Azure SDK client func NewKeysClient(client *armkeyvault.KeysClient) KeysClient { return &keysClient{client: client} } ================================================ FILE: sources/azure/clients/keyvault-managed-hsm-private-endpoint-connection-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_keyvault_managed_hsm_private_endpoint_connection_client.go -package=mocks -source=keyvault-managed-hsm-private-endpoint-connection-client.go // KeyVaultManagedHSMPrivateEndpointConnectionsPager is a type alias for the generic Pager interface with MHSM private endpoint connection list response type. type KeyVaultManagedHSMPrivateEndpointConnectionsPager = Pager[armkeyvault.MHSMPrivateEndpointConnectionsClientListByResourceResponse] // KeyVaultManagedHSMPrivateEndpointConnectionsClient is an interface for interacting with Azure Key Vault Managed HSM private endpoint connections. type KeyVaultManagedHSMPrivateEndpointConnectionsClient interface { Get(ctx context.Context, resourceGroupName string, hsmName string, privateEndpointConnectionName string) (armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse, error) ListByResource(ctx context.Context, resourceGroupName string, hsmName string) KeyVaultManagedHSMPrivateEndpointConnectionsPager } type keyvaultManagedHSMPrivateEndpointConnectionsClient struct { client *armkeyvault.MHSMPrivateEndpointConnectionsClient } func (c *keyvaultManagedHSMPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName string, hsmName string, privateEndpointConnectionName string) (armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, hsmName, privateEndpointConnectionName, nil) } func (c *keyvaultManagedHSMPrivateEndpointConnectionsClient) ListByResource(ctx context.Context, resourceGroupName string, hsmName string) KeyVaultManagedHSMPrivateEndpointConnectionsPager { return c.client.NewListByResourcePager(resourceGroupName, hsmName, nil) } // NewKeyVaultManagedHSMPrivateEndpointConnectionsClient creates a new KeyVaultManagedHSMPrivateEndpointConnectionsClient from the Azure SDK client. func NewKeyVaultManagedHSMPrivateEndpointConnectionsClient(client *armkeyvault.MHSMPrivateEndpointConnectionsClient) KeyVaultManagedHSMPrivateEndpointConnectionsClient { return &keyvaultManagedHSMPrivateEndpointConnectionsClient{client: client} } ================================================ FILE: sources/azure/clients/load-balancer-backend-address-pools-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_load_balancer_backend_address_pools_client.go -package=mocks -source=load-balancer-backend-address-pools-client.go // LoadBalancerBackendAddressPoolsPager is a type alias for the generic Pager interface. type LoadBalancerBackendAddressPoolsPager = Pager[armnetwork.LoadBalancerBackendAddressPoolsClientListResponse] // LoadBalancerBackendAddressPoolsClient is an interface for interacting with Azure load balancer backend address pools. type LoadBalancerBackendAddressPoolsClient interface { Get(ctx context.Context, resourceGroupName string, loadBalancerName string, backendAddressPoolName string) (armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse, error) NewListPager(resourceGroupName string, loadBalancerName string) LoadBalancerBackendAddressPoolsPager } type loadBalancerBackendAddressPoolsClient struct { client *armnetwork.LoadBalancerBackendAddressPoolsClient } func (a *loadBalancerBackendAddressPoolsClient) Get(ctx context.Context, resourceGroupName string, loadBalancerName string, backendAddressPoolName string) (armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, loadBalancerName, backendAddressPoolName, nil) } func (a *loadBalancerBackendAddressPoolsClient) NewListPager(resourceGroupName string, loadBalancerName string) LoadBalancerBackendAddressPoolsPager { return a.client.NewListPager(resourceGroupName, loadBalancerName, nil) } // NewLoadBalancerBackendAddressPoolsClient creates a new LoadBalancerBackendAddressPoolsClient from the Azure SDK client. func NewLoadBalancerBackendAddressPoolsClient(client *armnetwork.LoadBalancerBackendAddressPoolsClient) LoadBalancerBackendAddressPoolsClient { return &loadBalancerBackendAddressPoolsClient{client: client} } ================================================ FILE: sources/azure/clients/load-balancer-frontend-ip-configurations-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_load_balancer_frontend_ip_configurations_client.go -package=mocks -source=load-balancer-frontend-ip-configurations-client.go // LoadBalancerFrontendIPConfigurationsPager is a type alias for the generic Pager interface. type LoadBalancerFrontendIPConfigurationsPager = Pager[armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse] // LoadBalancerFrontendIPConfigurationsClient is an interface for interacting with Azure load balancer frontend IP configurations. type LoadBalancerFrontendIPConfigurationsClient interface { Get(ctx context.Context, resourceGroupName string, loadBalancerName string, frontendIPConfigurationName string) (armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse, error) NewListPager(resourceGroupName string, loadBalancerName string) LoadBalancerFrontendIPConfigurationsPager } type loadBalancerFrontendIPConfigurationsClient struct { client *armnetwork.LoadBalancerFrontendIPConfigurationsClient } func (a *loadBalancerFrontendIPConfigurationsClient) Get(ctx context.Context, resourceGroupName string, loadBalancerName string, frontendIPConfigurationName string) (armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, loadBalancerName, frontendIPConfigurationName, nil) } func (a *loadBalancerFrontendIPConfigurationsClient) NewListPager(resourceGroupName string, loadBalancerName string) LoadBalancerFrontendIPConfigurationsPager { return a.client.NewListPager(resourceGroupName, loadBalancerName, nil) } // NewLoadBalancerFrontendIPConfigurationsClient creates a new LoadBalancerFrontendIPConfigurationsClient from the Azure SDK client. func NewLoadBalancerFrontendIPConfigurationsClient(client *armnetwork.LoadBalancerFrontendIPConfigurationsClient) LoadBalancerFrontendIPConfigurationsClient { return &loadBalancerFrontendIPConfigurationsClient{client: client} } ================================================ FILE: sources/azure/clients/load-balancer-probes-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_load_balancer_probes_client.go -package=mocks -source=load-balancer-probes-client.go type LoadBalancerProbesPager = Pager[armnetwork.LoadBalancerProbesClientListResponse] type LoadBalancerProbesClient interface { Get(ctx context.Context, resourceGroupName string, loadBalancerName string, probeName string) (armnetwork.LoadBalancerProbesClientGetResponse, error) NewListPager(resourceGroupName string, loadBalancerName string) LoadBalancerProbesPager } type loadBalancerProbesClient struct { client *armnetwork.LoadBalancerProbesClient } func (a *loadBalancerProbesClient) Get(ctx context.Context, resourceGroupName string, loadBalancerName string, probeName string) (armnetwork.LoadBalancerProbesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, loadBalancerName, probeName, nil) } func (a *loadBalancerProbesClient) NewListPager(resourceGroupName string, loadBalancerName string) LoadBalancerProbesPager { return a.client.NewListPager(resourceGroupName, loadBalancerName, nil) } func NewLoadBalancerProbesClient(client *armnetwork.LoadBalancerProbesClient) LoadBalancerProbesClient { return &loadBalancerProbesClient{client: client} } ================================================ FILE: sources/azure/clients/load-balancers-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_load_balancers_client.go -package=mocks -source=load-balancers-client.go // LoadBalancersPager is a type alias for the generic Pager interface with load balancer response type. // This uses the generic Pager[T] interface to avoid code duplication. type LoadBalancersPager = Pager[armnetwork.LoadBalancersClientListResponse] // LoadBalancersClient is an interface for interacting with Azure load balancers type LoadBalancersClient interface { Get(ctx context.Context, resourceGroupName string, loadBalancerName string) (armnetwork.LoadBalancersClientGetResponse, error) List(resourceGroupName string) LoadBalancersPager } type loadBalancersClient struct { client *armnetwork.LoadBalancersClient } func (a *loadBalancersClient) Get(ctx context.Context, resourceGroupName string, loadBalancerName string) (armnetwork.LoadBalancersClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, loadBalancerName, nil) } func (a *loadBalancersClient) List(resourceGroupName string) LoadBalancersPager { return a.client.NewListPager(resourceGroupName, nil) } // NewLoadBalancersClient creates a new LoadBalancersClient from the Azure SDK client func NewLoadBalancersClient(client *armnetwork.LoadBalancersClient) LoadBalancersClient { return &loadBalancersClient{client: client} } ================================================ FILE: sources/azure/clients/local-network-gateways-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_local_network_gateways_client.go -package=mocks -source=local-network-gateways-client.go // LocalNetworkGatewaysPager is a type alias for the generic Pager interface with local network gateway list response type. type LocalNetworkGatewaysPager = Pager[armnetwork.LocalNetworkGatewaysClientListResponse] // LocalNetworkGatewaysClient is an interface for interacting with Azure local network gateways. type LocalNetworkGatewaysClient interface { Get(ctx context.Context, resourceGroupName string, localNetworkGatewayName string, options *armnetwork.LocalNetworkGatewaysClientGetOptions) (armnetwork.LocalNetworkGatewaysClientGetResponse, error) NewListPager(resourceGroupName string, options *armnetwork.LocalNetworkGatewaysClientListOptions) LocalNetworkGatewaysPager } type localNetworkGatewaysClient struct { client *armnetwork.LocalNetworkGatewaysClient } func (c *localNetworkGatewaysClient) Get(ctx context.Context, resourceGroupName string, localNetworkGatewayName string, options *armnetwork.LocalNetworkGatewaysClientGetOptions) (armnetwork.LocalNetworkGatewaysClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, localNetworkGatewayName, options) } func (c *localNetworkGatewaysClient) NewListPager(resourceGroupName string, options *armnetwork.LocalNetworkGatewaysClientListOptions) LocalNetworkGatewaysPager { return c.client.NewListPager(resourceGroupName, options) } // NewLocalNetworkGatewaysClient creates a new LocalNetworkGatewaysClient from the Azure SDK client. func NewLocalNetworkGatewaysClient(client *armnetwork.LocalNetworkGatewaysClient) LocalNetworkGatewaysClient { return &localNetworkGatewaysClient{client: client} } ================================================ FILE: sources/azure/clients/maintenance-configuration-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance" ) //go:generate mockgen -destination=../shared/mocks/mock_maintenance_configuration_client.go -package=mocks -source=maintenance-configuration-client.go // MaintenanceConfigurationPager is a type alias for the generic Pager interface with maintenance configuration response type. type MaintenanceConfigurationPager = Pager[armmaintenance.ConfigurationsForResourceGroupClientListResponse] // MaintenanceConfigurationClient is an interface for interacting with Azure maintenance configurations type MaintenanceConfigurationClient interface { NewListPager(resourceGroupName string, options *armmaintenance.ConfigurationsForResourceGroupClientListOptions) MaintenanceConfigurationPager Get(ctx context.Context, resourceGroupName string, resourceName string, options *armmaintenance.ConfigurationsClientGetOptions) (armmaintenance.ConfigurationsClientGetResponse, error) } type maintenanceConfigurationClient struct { configurationsClient *armmaintenance.ConfigurationsClient configurationsForResourceGroupClient *armmaintenance.ConfigurationsForResourceGroupClient } func (c *maintenanceConfigurationClient) NewListPager(resourceGroupName string, options *armmaintenance.ConfigurationsForResourceGroupClientListOptions) MaintenanceConfigurationPager { return c.configurationsForResourceGroupClient.NewListPager(resourceGroupName, options) } func (c *maintenanceConfigurationClient) Get(ctx context.Context, resourceGroupName string, resourceName string, options *armmaintenance.ConfigurationsClientGetOptions) (armmaintenance.ConfigurationsClientGetResponse, error) { return c.configurationsClient.Get(ctx, resourceGroupName, resourceName, options) } // NewMaintenanceConfigurationClient creates a new MaintenanceConfigurationClient from the Azure SDK clients func NewMaintenanceConfigurationClient(configurationsClient *armmaintenance.ConfigurationsClient, configurationsForResourceGroupClient *armmaintenance.ConfigurationsForResourceGroupClient) MaintenanceConfigurationClient { return &maintenanceConfigurationClient{ configurationsClient: configurationsClient, configurationsForResourceGroupClient: configurationsForResourceGroupClient, } } ================================================ FILE: sources/azure/clients/managed-hsms-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_managed_hsms_client.go -package=mocks -source=managed-hsms-client.go // ManagedHSMsPager is a type alias for the generic Pager interface with managed HSM response type. // This uses the generic Pager[T] interface to avoid code duplication. type ManagedHSMsPager = Pager[armkeyvault.ManagedHsmsClientListByResourceGroupResponse] // ManagedHSMsClient is an interface for interacting with Azure managed HSMs type ManagedHSMsClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.ManagedHsmsClientListByResourceGroupOptions) ManagedHSMsPager Get(ctx context.Context, resourceGroupName string, name string, options *armkeyvault.ManagedHsmsClientGetOptions) (armkeyvault.ManagedHsmsClientGetResponse, error) } type managedHSMsClient struct { client *armkeyvault.ManagedHsmsClient } func (c *managedHSMsClient) Get(ctx context.Context, resourceGroupName string, name string, options *armkeyvault.ManagedHsmsClientGetOptions) (armkeyvault.ManagedHsmsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, name, options) } func (c *managedHSMsClient) NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.ManagedHsmsClientListByResourceGroupOptions) ManagedHSMsPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } // NewManagedHSMsClient creates a new ManagedHSMsClient from the Azure SDK client func NewManagedHSMsClient(client *armkeyvault.ManagedHsmsClient) ManagedHSMsClient { return &managedHSMsClient{client: client} } ================================================ FILE: sources/azure/clients/nat-gateways-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_nat_gateways_client.go -package=mocks -source=nat-gateways-client.go // NatGatewaysPager is a type alias for the generic Pager interface with NAT gateway list response type. type NatGatewaysPager = Pager[armnetwork.NatGatewaysClientListResponse] // NatGatewaysClient is an interface for interacting with Azure NAT gateways. type NatGatewaysClient interface { Get(ctx context.Context, resourceGroupName string, natGatewayName string, options *armnetwork.NatGatewaysClientGetOptions) (armnetwork.NatGatewaysClientGetResponse, error) NewListPager(resourceGroupName string, options *armnetwork.NatGatewaysClientListOptions) NatGatewaysPager } type natGatewaysClient struct { client *armnetwork.NatGatewaysClient } func (c *natGatewaysClient) Get(ctx context.Context, resourceGroupName string, natGatewayName string, options *armnetwork.NatGatewaysClientGetOptions) (armnetwork.NatGatewaysClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, natGatewayName, options) } func (c *natGatewaysClient) NewListPager(resourceGroupName string, options *armnetwork.NatGatewaysClientListOptions) NatGatewaysPager { return c.client.NewListPager(resourceGroupName, options) } // NewNatGatewaysClient creates a new NatGatewaysClient from the Azure SDK client. func NewNatGatewaysClient(client *armnetwork.NatGatewaysClient) NatGatewaysClient { return &natGatewaysClient{client: client} } ================================================ FILE: sources/azure/clients/network-interfaces-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_network_interfaces_client.go -package=mocks -source=network-interfaces-client.go // NetworkInterfacesPager is a type alias for the generic Pager interface with network interface response type. // This uses the generic Pager[T] interface to avoid code duplication. type NetworkInterfacesPager = Pager[armnetwork.InterfacesClientListResponse] // NetworkInterfacesClient is an interface for interacting with Azure network interfaces type NetworkInterfacesClient interface { Get(ctx context.Context, resourceGroupName string, networkInterfaceName string) (armnetwork.InterfacesClientGetResponse, error) List(ctx context.Context, resourceGroupName string) NetworkInterfacesPager } type networkInterfacesClient struct { client *armnetwork.InterfacesClient } func (a *networkInterfacesClient) Get(ctx context.Context, resourceGroupName string, networkInterfaceName string) (armnetwork.InterfacesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, networkInterfaceName, nil) } func (a *networkInterfacesClient) List(ctx context.Context, resourceGroupName string) NetworkInterfacesPager { return a.client.NewListPager(resourceGroupName, nil) } // NewNetworkInterfacesClient creates a new NetworkInterfacesClient from the Azure SDK client func NewNetworkInterfacesClient(client *armnetwork.InterfacesClient) NetworkInterfacesClient { return &networkInterfacesClient{client: client} } ================================================ FILE: sources/azure/clients/network-private-endpoint-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_network_private_endpoint_client.go -package=mocks -source=network-private-endpoint-client.go // PrivateEndpointsPager is a type alias for the generic Pager interface with private endpoint response type. type PrivateEndpointsPager = Pager[armnetwork.PrivateEndpointsClientListResponse] // PrivateEndpointsClient is an interface for interacting with Azure private endpoints. type PrivateEndpointsClient interface { Get(ctx context.Context, resourceGroupName string, privateEndpointName string) (armnetwork.PrivateEndpointsClientGetResponse, error) List(resourceGroupName string) PrivateEndpointsPager } type privateEndpointsClient struct { client *armnetwork.PrivateEndpointsClient } func (c *privateEndpointsClient) Get(ctx context.Context, resourceGroupName string, privateEndpointName string) (armnetwork.PrivateEndpointsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, privateEndpointName, nil) } func (c *privateEndpointsClient) List(resourceGroupName string) PrivateEndpointsPager { return c.client.NewListPager(resourceGroupName, nil) } // NewPrivateEndpointsClient creates a new PrivateEndpointsClient from the Azure SDK client. func NewPrivateEndpointsClient(client *armnetwork.PrivateEndpointsClient) PrivateEndpointsClient { return &privateEndpointsClient{client: client} } ================================================ FILE: sources/azure/clients/network-security-groups-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_network_security_groups_client.go -package=mocks -source=network-security-groups-client.go // NetworkSecurityGroupsPager is a type alias for the generic Pager interface with network security group response type. // This uses the generic Pager[T] interface to avoid code duplication. type NetworkSecurityGroupsPager = Pager[armnetwork.SecurityGroupsClientListResponse] // NetworkSecurityGroupsClient is an interface for interacting with Azure network security groups type NetworkSecurityGroupsClient interface { Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, options *armnetwork.SecurityGroupsClientGetOptions) (armnetwork.SecurityGroupsClientGetResponse, error) List(ctx context.Context, resourceGroupName string, options *armnetwork.SecurityGroupsClientListOptions) NetworkSecurityGroupsPager } type networkSecurityGroupsClient struct { client *armnetwork.SecurityGroupsClient } func (a *networkSecurityGroupsClient) Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, options *armnetwork.SecurityGroupsClientGetOptions) (armnetwork.SecurityGroupsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, networkSecurityGroupName, options) } func (a *networkSecurityGroupsClient) List(ctx context.Context, resourceGroupName string, options *armnetwork.SecurityGroupsClientListOptions) NetworkSecurityGroupsPager { return a.client.NewListPager(resourceGroupName, options) } // NewNetworkSecurityGroupsClient creates a new NetworkSecurityGroupsClient from the Azure SDK client func NewNetworkSecurityGroupsClient(client *armnetwork.SecurityGroupsClient) NetworkSecurityGroupsClient { return &networkSecurityGroupsClient{client: client} } ================================================ FILE: sources/azure/clients/network-watchers-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_network_watchers_client.go -package=mocks -source=network-watchers-client.go // NetworkWatchersPager is a type alias for the generic Pager interface with network watchers response type. type NetworkWatchersPager = Pager[armnetwork.WatchersClientListResponse] // NetworkWatchersClient is an interface for interacting with Azure Network Watchers type NetworkWatchersClient interface { NewListPager(resourceGroupName string, options *armnetwork.WatchersClientListOptions) NetworkWatchersPager Get(ctx context.Context, resourceGroupName string, networkWatcherName string, options *armnetwork.WatchersClientGetOptions) (armnetwork.WatchersClientGetResponse, error) } type networkWatchersClient struct { client *armnetwork.WatchersClient } func (c *networkWatchersClient) NewListPager(resourceGroupName string, options *armnetwork.WatchersClientListOptions) NetworkWatchersPager { return c.client.NewListPager(resourceGroupName, options) } func (c *networkWatchersClient) Get(ctx context.Context, resourceGroupName string, networkWatcherName string, options *armnetwork.WatchersClientGetOptions) (armnetwork.WatchersClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, networkWatcherName, options) } // NewNetworkWatchersClient creates a new NetworkWatchersClient from the Azure SDK client func NewNetworkWatchersClient(client *armnetwork.WatchersClient) NetworkWatchersClient { return &networkWatchersClient{client: client} } ================================================ FILE: sources/azure/clients/operational-insights-workspace-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights" ) //go:generate mockgen -destination=../shared/mocks/mock_operational_insights_workspace_client.go -package=mocks -source=operational-insights-workspace-client.go // OperationalInsightsWorkspacePager is a type alias for the generic Pager interface with workspace response type. // This uses the generic Pager[T] interface to avoid code duplication. type OperationalInsightsWorkspacePager = Pager[armoperationalinsights.WorkspacesClientListByResourceGroupResponse] // OperationalInsightsWorkspaceClient is an interface for interacting with Azure Log Analytics Workspaces type OperationalInsightsWorkspaceClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armoperationalinsights.WorkspacesClientListByResourceGroupOptions) OperationalInsightsWorkspacePager Get(ctx context.Context, resourceGroupName string, workspaceName string, options *armoperationalinsights.WorkspacesClientGetOptions) (armoperationalinsights.WorkspacesClientGetResponse, error) } type operationalInsightsWorkspaceClient struct { client *armoperationalinsights.WorkspacesClient } func (o *operationalInsightsWorkspaceClient) NewListByResourceGroupPager(resourceGroupName string, options *armoperationalinsights.WorkspacesClientListByResourceGroupOptions) OperationalInsightsWorkspacePager { return o.client.NewListByResourceGroupPager(resourceGroupName, options) } func (o *operationalInsightsWorkspaceClient) Get(ctx context.Context, resourceGroupName string, workspaceName string, options *armoperationalinsights.WorkspacesClientGetOptions) (armoperationalinsights.WorkspacesClientGetResponse, error) { return o.client.Get(ctx, resourceGroupName, workspaceName, options) } // NewOperationalInsightsWorkspaceClient creates a new OperationalInsightsWorkspaceClient from the Azure SDK client func NewOperationalInsightsWorkspaceClient(client *armoperationalinsights.WorkspacesClient) OperationalInsightsWorkspaceClient { return &operationalInsightsWorkspaceClient{client: client} } ================================================ FILE: sources/azure/clients/pager.go ================================================ package clients import "context" // Pager is a generic interface for paging through Azure API results. // T represents the response type returned by NextPage. // This generic interface eliminates the need to define a separate Pager interface // for each Azure client type, reducing code duplication. type Pager[T any] interface { More() bool NextPage(ctx context.Context) (T, error) } ================================================ FILE: sources/azure/clients/pager_mocks.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) // These interfaces are defined specifically for mock generation. // They represent the concrete Pager types used in tests. // Since type aliases cannot be mocked directly, we define concrete interfaces // that match the Pager[T] interface for specific types. // VirtualMachinesPagerInterface is a concrete interface for VirtualMachinesPager to enable mock generation // //go:generate mockgen -destination=../shared/mocks/mock_virtual_machines_pager.go -package=mocks github.com/overmindtech/cli/sources/azure/clients VirtualMachinesPagerInterface type VirtualMachinesPagerInterface interface { More() bool NextPage(ctx context.Context) (armcompute.VirtualMachinesClientListResponse, error) } // StorageAccountsPagerInterface is a concrete interface for StorageAccountsPager to enable mock generation // //go:generate mockgen -destination=../shared/mocks/mock_storage_accounts_pager.go -package=mocks github.com/overmindtech/cli/sources/azure/clients StorageAccountsPagerInterface type StorageAccountsPagerInterface interface { More() bool NextPage(ctx context.Context) (armstorage.AccountsClientListByResourceGroupResponse, error) } ================================================ FILE: sources/azure/clients/postgresql-databases-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_postgresql_databases_client.go -package=mocks -source=postgresql-databases-client.go // PostgreSQLDatabasesPager is a type alias for the generic Pager interface with postgresql database response type. // This uses the generic Pager[T] interface to avoid code duplication. type PostgreSQLDatabasesPager = Pager[armpostgresqlflexibleservers.DatabasesClientListByServerResponse] // PostgreSQLDatabasesClient is an interface for interacting with Azure postgresql databases type PostgreSQLDatabasesClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) PostgreSQLDatabasesPager Get(ctx context.Context, resourceGroupName string, serverName string, databaseName string) (armpostgresqlflexibleservers.DatabasesClientGetResponse, error) } type postgresqlDatabasesClient struct { client *armpostgresqlflexibleservers.DatabasesClient } func (a *postgresqlDatabasesClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) PostgreSQLDatabasesPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *postgresqlDatabasesClient) Get(ctx context.Context, resourceGroupName string, serverName string, databaseName string) (armpostgresqlflexibleservers.DatabasesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, databaseName, nil) } // NewPostgreSQLDatabasesClient creates a new PostgreSQLDatabasesClient from the Azure SDK client func NewPostgreSQLDatabasesClient(client *armpostgresqlflexibleservers.DatabasesClient) PostgreSQLDatabasesClient { return &postgresqlDatabasesClient{client: client} } ================================================ FILE: sources/azure/clients/postgresql-flexible-server-firewall-rule-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_postgresql_flexible_server_firewall_rule_client.go -package=mocks -source=postgresql-flexible-server-firewall-rule-client.go // PostgreSQLFlexibleServerFirewallRulePager is a type alias for the generic Pager interface with PostgreSQL flexible server firewall rule response type. type PostgreSQLFlexibleServerFirewallRulePager = Pager[armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse] // PostgreSQLFlexibleServerFirewallRuleClient is an interface for interacting with Azure PostgreSQL flexible server firewall rules. type PostgreSQLFlexibleServerFirewallRuleClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) PostgreSQLFlexibleServerFirewallRulePager Get(ctx context.Context, resourceGroupName string, serverName string, firewallRuleName string) (armpostgresqlflexibleservers.FirewallRulesClientGetResponse, error) } type postgresqlFlexibleServerFirewallRuleClient struct { client *armpostgresqlflexibleservers.FirewallRulesClient } func (a *postgresqlFlexibleServerFirewallRuleClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) PostgreSQLFlexibleServerFirewallRulePager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *postgresqlFlexibleServerFirewallRuleClient) Get(ctx context.Context, resourceGroupName string, serverName string, firewallRuleName string) (armpostgresqlflexibleservers.FirewallRulesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, firewallRuleName, nil) } // NewPostgreSQLFlexibleServerFirewallRuleClient creates a new PostgreSQLFlexibleServerFirewallRuleClient from the Azure SDK client. func NewPostgreSQLFlexibleServerFirewallRuleClient(client *armpostgresqlflexibleservers.FirewallRulesClient) PostgreSQLFlexibleServerFirewallRuleClient { return &postgresqlFlexibleServerFirewallRuleClient{client: client} } ================================================ FILE: sources/azure/clients/postgresql-flexible-servers-client.go ================================================ package clients import ( "context" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" ) //go:generate mockgen -destination=../shared/mocks/mock_postgresql_flexible_servers_client.go -package=mocks -source=postgresql-flexible-servers-client.go // PostgreSQLFlexibleServersPager is a type alias for the generic Pager interface with postgresql flexible server response type. // This uses the generic Pager[T] interface to avoid code duplication. type PostgreSQLFlexibleServersPager = Pager[armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse] // PostgreSQLFlexibleServersClient is an interface for interacting with Azure postgresql flexible servers type PostgreSQLFlexibleServersClient interface { ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armpostgresqlflexibleservers.ServersClientListByResourceGroupOptions) PostgreSQLFlexibleServersPager Get(ctx context.Context, resourceGroupName string, serverName string, options *armpostgresqlflexibleservers.ServersClientGetOptions) (armpostgresqlflexibleservers.ServersClientGetResponse, error) } type postgresqlFlexibleServersClient struct { client *armpostgresqlflexibleservers.ServersClient } func (a *postgresqlFlexibleServersClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armpostgresqlflexibleservers.ServersClientListByResourceGroupOptions) PostgreSQLFlexibleServersPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *postgresqlFlexibleServersClient) Get(ctx context.Context, resourceGroupName string, serverName string, options *armpostgresqlflexibleservers.ServersClientGetOptions) (armpostgresqlflexibleservers.ServersClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, options) } // NewPostgreSQLFlexibleServersClient creates a new PostgreSQLFlexibleServersClient from the Azure SDK client func NewPostgreSQLFlexibleServersClient(client *armpostgresqlflexibleservers.ServersClient) PostgreSQLFlexibleServersClient { return &postgresqlFlexibleServersClient{client: client} } ================================================ FILE: sources/azure/clients/private-dns-zones-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" ) //go:generate mockgen -destination=../shared/mocks/mock_private_dns_zones_client.go -package=mocks -source=private-dns-zones-client.go // PrivateDNSZonesPager is a type alias for the generic Pager interface with private zone response type. type PrivateDNSZonesPager = Pager[armprivatedns.PrivateZonesClientListByResourceGroupResponse] // PrivateDNSZonesClient is an interface for interacting with Azure Private DNS zones. type PrivateDNSZonesClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armprivatedns.PrivateZonesClientListByResourceGroupOptions) PrivateDNSZonesPager Get(ctx context.Context, resourceGroupName string, privateZoneName string, options *armprivatedns.PrivateZonesClientGetOptions) (armprivatedns.PrivateZonesClientGetResponse, error) } type privateDNSZonesClient struct { client *armprivatedns.PrivateZonesClient } func (c *privateDNSZonesClient) NewListByResourceGroupPager(resourceGroupName string, options *armprivatedns.PrivateZonesClientListByResourceGroupOptions) PrivateDNSZonesPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } func (c *privateDNSZonesClient) Get(ctx context.Context, resourceGroupName string, privateZoneName string, options *armprivatedns.PrivateZonesClientGetOptions) (armprivatedns.PrivateZonesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, privateZoneName, options) } // NewPrivateDNSZonesClient creates a new PrivateDNSZonesClient from the Azure SDK client. func NewPrivateDNSZonesClient(client *armprivatedns.PrivateZonesClient) PrivateDNSZonesClient { return &privateDNSZonesClient{client: client} } ================================================ FILE: sources/azure/clients/private-link-services-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_private_link_services_client.go -package=mocks -source=private-link-services-client.go // PrivateLinkServicesPager is a type alias for the generic Pager interface with private link service response type. type PrivateLinkServicesPager = Pager[armnetwork.PrivateLinkServicesClientListResponse] // PrivateLinkServicesClient is an interface for interacting with Azure private link services. type PrivateLinkServicesClient interface { Get(ctx context.Context, resourceGroupName string, serviceName string) (armnetwork.PrivateLinkServicesClientGetResponse, error) List(resourceGroupName string) PrivateLinkServicesPager } type privateLinkServicesClient struct { client *armnetwork.PrivateLinkServicesClient } func (c *privateLinkServicesClient) Get(ctx context.Context, resourceGroupName string, serviceName string) (armnetwork.PrivateLinkServicesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, serviceName, nil) } func (c *privateLinkServicesClient) List(resourceGroupName string) PrivateLinkServicesPager { return c.client.NewListPager(resourceGroupName, nil) } // NewPrivateLinkServicesClient creates a new PrivateLinkServicesClient from the Azure SDK client. func NewPrivateLinkServicesClient(client *armnetwork.PrivateLinkServicesClient) PrivateLinkServicesClient { return &privateLinkServicesClient{client: client} } ================================================ FILE: sources/azure/clients/proximity-placement-groups-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_proximity_placement_groups_client.go -package=mocks -source=proximity-placement-groups-client.go // ProximityPlacementGroupsPager is a type alias for the generic Pager interface with proximity placement group response type. // This uses the generic Pager[T] interface to avoid code duplication. type ProximityPlacementGroupsPager = Pager[armcompute.ProximityPlacementGroupsClientListByResourceGroupResponse] // ProximityPlacementGroupsClient is an interface for interacting with Azure proximity placement groups type ProximityPlacementGroupsClient interface { ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armcompute.ProximityPlacementGroupsClientListByResourceGroupOptions) ProximityPlacementGroupsPager Get(ctx context.Context, resourceGroupName string, proximityPlacementGroupName string, options *armcompute.ProximityPlacementGroupsClientGetOptions) (armcompute.ProximityPlacementGroupsClientGetResponse, error) } type proximityPlacementGroupsClient struct { client *armcompute.ProximityPlacementGroupsClient } func (a *proximityPlacementGroupsClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armcompute.ProximityPlacementGroupsClientListByResourceGroupOptions) ProximityPlacementGroupsPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *proximityPlacementGroupsClient) Get(ctx context.Context, resourceGroupName string, proximityPlacementGroupName string, options *armcompute.ProximityPlacementGroupsClientGetOptions) (armcompute.ProximityPlacementGroupsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, proximityPlacementGroupName, options) } // NewProximityPlacementGroupsClient creates a new ProximityPlacementGroupsClient from the Azure SDK client func NewProximityPlacementGroupsClient(client *armcompute.ProximityPlacementGroupsClient) ProximityPlacementGroupsClient { return &proximityPlacementGroupsClient{client: client} } ================================================ FILE: sources/azure/clients/public-ip-addresses.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_public_ip_addresses_client.go -package=mocks -source=public-ip-addresses.go // PublicIPAddressesPager is a type alias for the generic Pager interface with public IP address response type. // This uses the generic Pager[T] interface to avoid code duplication. type PublicIPAddressesPager = Pager[armnetwork.PublicIPAddressesClientListResponse] // PublicIPAddressesClient is an interface for interacting with Azure public IP addresses type PublicIPAddressesClient interface { Get(ctx context.Context, resourceGroupName string, publicIPAddressName string) (armnetwork.PublicIPAddressesClientGetResponse, error) List(ctx context.Context, resourceGroupName string) PublicIPAddressesPager } type publicIPAddressesClient struct { client *armnetwork.PublicIPAddressesClient } func (a *publicIPAddressesClient) Get(ctx context.Context, resourceGroupName string, publicIPAddressName string) (armnetwork.PublicIPAddressesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, publicIPAddressName, nil) } func (a *publicIPAddressesClient) List(ctx context.Context, resourceGroupName string) PublicIPAddressesPager { return a.client.NewListPager(resourceGroupName, nil) } // NewPublicIPAddressesClient creates a new PublicIPAddressesClient from the Azure SDK client func NewPublicIPAddressesClient(client *armnetwork.PublicIPAddressesClient) PublicIPAddressesClient { return &publicIPAddressesClient{client: client} } ================================================ FILE: sources/azure/clients/public-ip-prefixes-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_public_ip_prefixes_client.go -package=mocks -source=public-ip-prefixes-client.go // PublicIPPrefixesPager is a type alias for the generic Pager interface with public IP prefix response type. type PublicIPPrefixesPager = Pager[armnetwork.PublicIPPrefixesClientListResponse] // PublicIPPrefixesClient is an interface for interacting with Azure public IP prefixes. type PublicIPPrefixesClient interface { Get(ctx context.Context, resourceGroupName string, publicIPPrefixName string, options *armnetwork.PublicIPPrefixesClientGetOptions) (armnetwork.PublicIPPrefixesClientGetResponse, error) NewListPager(resourceGroupName string, options *armnetwork.PublicIPPrefixesClientListOptions) PublicIPPrefixesPager } type publicIPPrefixesClient struct { client *armnetwork.PublicIPPrefixesClient } func (c *publicIPPrefixesClient) Get(ctx context.Context, resourceGroupName string, publicIPPrefixName string, options *armnetwork.PublicIPPrefixesClientGetOptions) (armnetwork.PublicIPPrefixesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, publicIPPrefixName, options) } func (c *publicIPPrefixesClient) NewListPager(resourceGroupName string, options *armnetwork.PublicIPPrefixesClientListOptions) PublicIPPrefixesPager { return c.client.NewListPager(resourceGroupName, options) } // NewPublicIPPrefixesClient creates a new PublicIPPrefixesClient from the Azure SDK client. func NewPublicIPPrefixesClient(client *armnetwork.PublicIPPrefixesClient) PublicIPPrefixesClient { return &publicIPPrefixesClient{client: client} } ================================================ FILE: sources/azure/clients/queues-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_queues_client.go -package=mocks -source=queues-client.go // QueuesPager is a type alias for the generic Pager interface with queue response type. // This uses the generic Pager[T] interface to avoid code duplication. type QueuesPager = Pager[armstorage.QueueClientListResponse] // QueuesClient is an interface for interacting with Azure queues type QueuesClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, queueName string) (armstorage.QueueClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string) QueuesPager } type queuesClient struct { client *armstorage.QueueClient } func (a *queuesClient) Get(ctx context.Context, resourceGroupName string, accountName string, queueName string) (armstorage.QueueClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, accountName, queueName, nil) } func (a *queuesClient) List(ctx context.Context, resourceGroupName string, accountName string) QueuesPager { return a.client.NewListPager(resourceGroupName, accountName, nil) } // NewQueuesClient creates a new QueuesClient from the Azure SDK client func NewQueuesClient(client *armstorage.QueueClient) QueuesClient { return &queuesClient{client: client} } ================================================ FILE: sources/azure/clients/record-sets-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" ) //go:generate mockgen -destination=../shared/mocks/mock_record_sets_client.go -package=mocks -source=record-sets-client.go // RecordSetsPager is a type alias for the generic Pager interface with record sets list response type. type RecordSetsPager = Pager[armdns.RecordSetsClientListAllByDNSZoneResponse] // RecordSetsClient is an interface for interacting with Azure DNS record sets type RecordSetsClient interface { Get(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType armdns.RecordType, options *armdns.RecordSetsClientGetOptions) (armdns.RecordSetsClientGetResponse, error) NewListAllByDNSZonePager(resourceGroupName string, zoneName string, options *armdns.RecordSetsClientListAllByDNSZoneOptions) RecordSetsPager } type recordSetsClient struct { client *armdns.RecordSetsClient } func (c *recordSetsClient) Get(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType armdns.RecordType, options *armdns.RecordSetsClientGetOptions) (armdns.RecordSetsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, zoneName, relativeRecordSetName, recordType, options) } func (c *recordSetsClient) NewListAllByDNSZonePager(resourceGroupName string, zoneName string, options *armdns.RecordSetsClientListAllByDNSZoneOptions) RecordSetsPager { return c.client.NewListAllByDNSZonePager(resourceGroupName, zoneName, options) } // NewRecordSetsClient creates a new RecordSetsClient from the Azure SDK client func NewRecordSetsClient(client *armdns.RecordSetsClient) RecordSetsClient { return &recordSetsClient{client: client} } ================================================ FILE: sources/azure/clients/role-assignments-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_role_assignments_client.go -package=mocks -source=role-assignments-client.go // RoleAssignmentsPager is a type alias for the generic Pager interface with role assignment response type. // This uses the generic Pager[T] interface to avoid code duplication. type RoleAssignmentsPager = Pager[armauthorization.RoleAssignmentsClientListForResourceGroupResponse] // RoleAssignmentsClient is an interface for interacting with Azure role assignments type RoleAssignmentsClient interface { ListForResourceGroup(resourceGroupName string, options *armauthorization.RoleAssignmentsClientListForResourceGroupOptions) RoleAssignmentsPager Get(ctx context.Context, scope string, roleAssignmentName string, options *armauthorization.RoleAssignmentsClientGetOptions) (armauthorization.RoleAssignmentsClientGetResponse, error) } type roleAssignmentsClient struct { client *armauthorization.RoleAssignmentsClient } func (c *roleAssignmentsClient) ListForResourceGroup(resourceGroupName string, options *armauthorization.RoleAssignmentsClientListForResourceGroupOptions) RoleAssignmentsPager { return c.client.NewListForResourceGroupPager(resourceGroupName, options) } func (c *roleAssignmentsClient) Get(ctx context.Context, scope string, roleAssignmentName string, options *armauthorization.RoleAssignmentsClientGetOptions) (armauthorization.RoleAssignmentsClientGetResponse, error) { return c.client.Get(ctx, scope, roleAssignmentName, options) } // NewRoleAssignmentsClient creates a new RoleAssignmentsClient from the Azure SDK client func NewRoleAssignmentsClient(client *armauthorization.RoleAssignmentsClient) RoleAssignmentsClient { return &roleAssignmentsClient{client: client} } ================================================ FILE: sources/azure/clients/role-definitions-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_role_definitions_client.go -package=mocks -source=role-definitions-client.go // RoleDefinitionsPager is a type alias for the generic Pager interface with role definition response type. type RoleDefinitionsPager = Pager[armauthorization.RoleDefinitionsClientListResponse] // RoleDefinitionsClient is an interface for interacting with Azure role definitions type RoleDefinitionsClient interface { NewListPager(scope string, options *armauthorization.RoleDefinitionsClientListOptions) RoleDefinitionsPager Get(ctx context.Context, scope string, roleDefinitionID string, options *armauthorization.RoleDefinitionsClientGetOptions) (armauthorization.RoleDefinitionsClientGetResponse, error) } type roleDefinitionsClient struct { client *armauthorization.RoleDefinitionsClient } func (c *roleDefinitionsClient) NewListPager(scope string, options *armauthorization.RoleDefinitionsClientListOptions) RoleDefinitionsPager { return c.client.NewListPager(scope, options) } func (c *roleDefinitionsClient) Get(ctx context.Context, scope string, roleDefinitionID string, options *armauthorization.RoleDefinitionsClientGetOptions) (armauthorization.RoleDefinitionsClientGetResponse, error) { return c.client.Get(ctx, scope, roleDefinitionID, options) } // NewRoleDefinitionsClient creates a new RoleDefinitionsClient from the Azure SDK client func NewRoleDefinitionsClient(client *armauthorization.RoleDefinitionsClient) RoleDefinitionsClient { return &roleDefinitionsClient{client: client} } ================================================ FILE: sources/azure/clients/route-tables-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_route_tables_client.go -package=mocks -source=route-tables-client.go // RouteTablesPager is a type alias for the generic Pager interface with route table response type. // This uses the generic Pager[T] interface to avoid code duplication. type RouteTablesPager = Pager[armnetwork.RouteTablesClientListResponse] // RouteTablesClient is an interface for interacting with Azure route tables type RouteTablesClient interface { Get(ctx context.Context, resourceGroupName string, routeTableName string, options *armnetwork.RouteTablesClientGetOptions) (armnetwork.RouteTablesClientGetResponse, error) List(resourceGroupName string, options *armnetwork.RouteTablesClientListOptions) RouteTablesPager } type routeTablesClient struct { client *armnetwork.RouteTablesClient } func (a *routeTablesClient) Get(ctx context.Context, resourceGroupName string, routeTableName string, options *armnetwork.RouteTablesClientGetOptions) (armnetwork.RouteTablesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, routeTableName, options) } func (a *routeTablesClient) List(resourceGroupName string, options *armnetwork.RouteTablesClientListOptions) RouteTablesPager { return a.client.NewListPager(resourceGroupName, options) } // NewRouteTablesClient creates a new RouteTablesClient from the Azure SDK client func NewRouteTablesClient(client *armnetwork.RouteTablesClient) RouteTablesClient { return &routeTablesClient{client: client} } ================================================ FILE: sources/azure/clients/routes-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_routes_client.go -package=mocks -source=routes-client.go // RoutesPager is a type alias for the generic Pager interface with routes list response type. type RoutesPager = Pager[armnetwork.RoutesClientListResponse] // RoutesClient is an interface for interacting with Azure routes (child of route table). type RoutesClient interface { Get(ctx context.Context, resourceGroupName string, routeTableName string, routeName string, options *armnetwork.RoutesClientGetOptions) (armnetwork.RoutesClientGetResponse, error) NewListPager(resourceGroupName string, routeTableName string, options *armnetwork.RoutesClientListOptions) RoutesPager } type routesClient struct { client *armnetwork.RoutesClient } func (a *routesClient) Get(ctx context.Context, resourceGroupName string, routeTableName string, routeName string, options *armnetwork.RoutesClientGetOptions) (armnetwork.RoutesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, routeTableName, routeName, options) } func (a *routesClient) NewListPager(resourceGroupName string, routeTableName string, options *armnetwork.RoutesClientListOptions) RoutesPager { return a.client.NewListPager(resourceGroupName, routeTableName, options) } // NewRoutesClient creates a new RoutesClient from the Azure SDK client. func NewRoutesClient(client *armnetwork.RoutesClient) RoutesClient { return &routesClient{client: client} } ================================================ FILE: sources/azure/clients/secrets-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_secrets_client.go -package=mocks -source=secrets-client.go // SecretsPager is a type alias for the generic Pager interface with secret response type. // This uses the generic Pager[T] interface to avoid code duplication. type SecretsPager = Pager[armkeyvault.SecretsClientListResponse] // SecretsClient is an interface for interacting with Azure secrets type SecretsClient interface { NewListPager(resourceGroupName string, vaultName string, options *armkeyvault.SecretsClientListOptions) SecretsPager Get(ctx context.Context, resourceGroupName string, vaultName string, secretName string, options *armkeyvault.SecretsClientGetOptions) (armkeyvault.SecretsClientGetResponse, error) } type secretsClient struct { client *armkeyvault.SecretsClient } func (c *secretsClient) NewListPager(resourceGroupName string, vaultName string, options *armkeyvault.SecretsClientListOptions) SecretsPager { return c.client.NewListPager(resourceGroupName, vaultName, options) } func (c *secretsClient) Get(ctx context.Context, resourceGroupName string, vaultName string, secretName string, options *armkeyvault.SecretsClientGetOptions) (armkeyvault.SecretsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, vaultName, secretName, options) } // NewSecretsClient creates a new SecretsClient from the Azure SDK client func NewSecretsClient(client *armkeyvault.SecretsClient) SecretsClient { return &secretsClient{client: client} } ================================================ FILE: sources/azure/clients/security-rules-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_security_rules_client.go -package=mocks -source=security-rules-client.go // SecurityRulesPager is a type alias for the generic Pager interface with security rules list response type. type SecurityRulesPager = Pager[armnetwork.SecurityRulesClientListResponse] // SecurityRulesClient is an interface for interacting with Azure NSG security rules (child of network security group). type SecurityRulesClient interface { Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, securityRuleName string, options *armnetwork.SecurityRulesClientGetOptions) (armnetwork.SecurityRulesClientGetResponse, error) NewListPager(resourceGroupName string, networkSecurityGroupName string, options *armnetwork.SecurityRulesClientListOptions) SecurityRulesPager } type securityRulesClient struct { client *armnetwork.SecurityRulesClient } func (a *securityRulesClient) Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, securityRuleName string, options *armnetwork.SecurityRulesClientGetOptions) (armnetwork.SecurityRulesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, networkSecurityGroupName, securityRuleName, options) } func (a *securityRulesClient) NewListPager(resourceGroupName string, networkSecurityGroupName string, options *armnetwork.SecurityRulesClientListOptions) SecurityRulesPager { return a.client.NewListPager(resourceGroupName, networkSecurityGroupName, options) } // NewSecurityRulesClient creates a new SecurityRulesClient from the Azure SDK client. func NewSecurityRulesClient(client *armnetwork.SecurityRulesClient) SecurityRulesClient { return &securityRulesClient{client: client} } ================================================ FILE: sources/azure/clients/shared-gallery-images-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_shared_gallery_images_client.go -package=mocks -source=shared-gallery-images-client.go type SharedGalleryImagesPager = Pager[armcompute.SharedGalleryImagesClientListResponse] type SharedGalleryImagesClient interface { NewListPager(location string, galleryUniqueName string, options *armcompute.SharedGalleryImagesClientListOptions) SharedGalleryImagesPager Get(ctx context.Context, location string, galleryUniqueName string, galleryImageName string, options *armcompute.SharedGalleryImagesClientGetOptions) (armcompute.SharedGalleryImagesClientGetResponse, error) } type sharedGalleryImagesClient struct { client *armcompute.SharedGalleryImagesClient } func (c *sharedGalleryImagesClient) NewListPager(location string, galleryUniqueName string, options *armcompute.SharedGalleryImagesClientListOptions) SharedGalleryImagesPager { return c.client.NewListPager(location, galleryUniqueName, options) } func (c *sharedGalleryImagesClient) Get(ctx context.Context, location string, galleryUniqueName string, galleryImageName string, options *armcompute.SharedGalleryImagesClientGetOptions) (armcompute.SharedGalleryImagesClientGetResponse, error) { return c.client.Get(ctx, location, galleryUniqueName, galleryImageName, options) } func NewSharedGalleryImagesClient(client *armcompute.SharedGalleryImagesClient) SharedGalleryImagesClient { return &sharedGalleryImagesClient{client: client} } ================================================ FILE: sources/azure/clients/snapshots-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_snapshots_client.go -package=mocks -source=snapshots-client.go // SnapshotsPager is a type alias for the generic Pager interface with snapshot response type. // This uses the generic Pager[T] interface to avoid code duplication. type SnapshotsPager = Pager[armcompute.SnapshotsClientListByResourceGroupResponse] // SnapshotsClient is an interface for interacting with Azure snapshots type SnapshotsClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armcompute.SnapshotsClientListByResourceGroupOptions) SnapshotsPager Get(ctx context.Context, resourceGroupName string, snapshotName string, options *armcompute.SnapshotsClientGetOptions) (armcompute.SnapshotsClientGetResponse, error) } type snapshotsClient struct { client *armcompute.SnapshotsClient } func (a *snapshotsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.SnapshotsClientListByResourceGroupOptions) SnapshotsPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *snapshotsClient) Get(ctx context.Context, resourceGroupName string, snapshotName string, options *armcompute.SnapshotsClientGetOptions) (armcompute.SnapshotsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, snapshotName, options) } // NewSnapshotsClient creates a new SnapshotsClient from the Azure SDK client func NewSnapshotsClient(client *armcompute.SnapshotsClient) SnapshotsClient { return &snapshotsClient{client: client} } ================================================ FILE: sources/azure/clients/sql-database-schemas-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_database_schemas_client.go -package=mocks -source=sql-database-schemas-client.go // SqlDatabaseSchemasPager is a type alias for the generic Pager interface with database schema response type. type SqlDatabaseSchemasPager = Pager[armsql.DatabaseSchemasClientListByDatabaseResponse] // SqlDatabaseSchemasClient is an interface for interacting with Azure SQL database schemas type SqlDatabaseSchemasClient interface { Get(ctx context.Context, resourceGroupName, serverName, databaseName, schemaName string) (armsql.DatabaseSchemasClientGetResponse, error) ListByDatabase(ctx context.Context, resourceGroupName, serverName, databaseName string) SqlDatabaseSchemasPager } type sqlDatabaseSchemasClient struct { client *armsql.DatabaseSchemasClient } func (c *sqlDatabaseSchemasClient) Get(ctx context.Context, resourceGroupName, serverName, databaseName, schemaName string) (armsql.DatabaseSchemasClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, serverName, databaseName, schemaName, nil) } func (c *sqlDatabaseSchemasClient) ListByDatabase(ctx context.Context, resourceGroupName, serverName, databaseName string) SqlDatabaseSchemasPager { return c.client.NewListByDatabasePager(resourceGroupName, serverName, databaseName, nil) } // NewSqlDatabaseSchemasClient creates a new SqlDatabaseSchemasClient from the Azure SDK client func NewSqlDatabaseSchemasClient(client *armsql.DatabaseSchemasClient) SqlDatabaseSchemasClient { return &sqlDatabaseSchemasClient{client: client} } ================================================ FILE: sources/azure/clients/sql-databases-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_databases_client.go -package=mocks -source=sql-databases-client.go // SqlDatabasesPager is a type alias for the generic Pager interface with sql database response type. // This uses the generic Pager[T] interface to avoid code duplication. type SqlDatabasesPager = Pager[armsql.DatabasesClientListByServerResponse] // SqlDatabasesClient is an interface for interacting with Azure sql databases type SqlDatabasesClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlDatabasesPager Get(ctx context.Context, resourceGroupName string, serverName string, databaseName string) (armsql.DatabasesClientGetResponse, error) } type sqlDatabasesClient struct { client *armsql.DatabasesClient } func (a *sqlDatabasesClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlDatabasesPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *sqlDatabasesClient) Get(ctx context.Context, resourceGroupName string, serverName string, databaseName string) (armsql.DatabasesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, databaseName, nil) } // NewSqlDatabasesClient creates a new SqlDatabasesClient from the Azure SDK client func NewSqlDatabasesClient(client *armsql.DatabasesClient) SqlDatabasesClient { return &sqlDatabasesClient{client: client} } ================================================ FILE: sources/azure/clients/sql-elastic-pool-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_elastic_pool_client.go -package=mocks -source=sql-elastic-pool-client.go // SqlElasticPoolPager is a type alias for the generic Pager interface with SQL elastic pool list response type. type SqlElasticPoolPager = Pager[armsql.ElasticPoolsClientListByServerResponse] // SqlElasticPoolClient is an interface for interacting with Azure SQL elastic pools. type SqlElasticPoolClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlElasticPoolPager Get(ctx context.Context, resourceGroupName string, serverName string, elasticPoolName string) (armsql.ElasticPoolsClientGetResponse, error) } type sqlElasticPoolClient struct { client *armsql.ElasticPoolsClient } func (a *sqlElasticPoolClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlElasticPoolPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *sqlElasticPoolClient) Get(ctx context.Context, resourceGroupName string, serverName string, elasticPoolName string) (armsql.ElasticPoolsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, elasticPoolName, nil) } // NewSqlElasticPoolClient creates a new SqlElasticPoolClient from the Azure SDK client. func NewSqlElasticPoolClient(client *armsql.ElasticPoolsClient) SqlElasticPoolClient { return &sqlElasticPoolClient{client: client} } ================================================ FILE: sources/azure/clients/sql-failover-groups-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_failover_groups_client.go -package=mocks -source=sql-failover-groups-client.go // SqlFailoverGroupsPager is a type alias for the generic Pager interface with failover groups response type. type SqlFailoverGroupsPager = Pager[armsql.FailoverGroupsClientListByServerResponse] // SqlFailoverGroupsClient is an interface for interacting with Azure SQL Server Failover Groups type SqlFailoverGroupsClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlFailoverGroupsPager Get(ctx context.Context, resourceGroupName string, serverName string, failoverGroupName string) (armsql.FailoverGroupsClientGetResponse, error) } type sqlFailoverGroupsClient struct { client *armsql.FailoverGroupsClient } func (a *sqlFailoverGroupsClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlFailoverGroupsPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *sqlFailoverGroupsClient) Get(ctx context.Context, resourceGroupName string, serverName string, failoverGroupName string) (armsql.FailoverGroupsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, failoverGroupName, nil) } // NewSqlFailoverGroupsClient creates a new SqlFailoverGroupsClient from the Azure SDK client func NewSqlFailoverGroupsClient(client *armsql.FailoverGroupsClient) SqlFailoverGroupsClient { return &sqlFailoverGroupsClient{client: client} } ================================================ FILE: sources/azure/clients/sql-server-firewall-rule-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_server_firewall_rule_client.go -package=mocks -source=sql-server-firewall-rule-client.go // SqlServerFirewallRulePager is a type alias for the generic Pager interface with SQL server firewall rule response type. type SqlServerFirewallRulePager = Pager[armsql.FirewallRulesClientListByServerResponse] // SqlServerFirewallRuleClient is an interface for interacting with Azure SQL server firewall rules. type SqlServerFirewallRuleClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlServerFirewallRulePager Get(ctx context.Context, resourceGroupName string, serverName string, firewallRuleName string) (armsql.FirewallRulesClientGetResponse, error) } type sqlServerFirewallRuleClient struct { client *armsql.FirewallRulesClient } func (a *sqlServerFirewallRuleClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlServerFirewallRulePager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *sqlServerFirewallRuleClient) Get(ctx context.Context, resourceGroupName string, serverName string, firewallRuleName string) (armsql.FirewallRulesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, firewallRuleName, nil) } // NewSqlServerFirewallRuleClient creates a new SqlServerFirewallRuleClient from the Azure SDK client. func NewSqlServerFirewallRuleClient(client *armsql.FirewallRulesClient) SqlServerFirewallRuleClient { return &sqlServerFirewallRuleClient{client: client} } ================================================ FILE: sources/azure/clients/sql-server-keys-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_server_keys_client.go -package=mocks -source=sql-server-keys-client.go // SqlServerKeysPager is a type alias for the generic Pager interface with sql server keys response type. type SqlServerKeysPager = Pager[armsql.ServerKeysClientListByServerResponse] // SqlServerKeysClient is an interface for interacting with Azure SQL server keys type SqlServerKeysClient interface { NewListByServerPager(resourceGroupName string, serverName string) SqlServerKeysPager Get(ctx context.Context, resourceGroupName string, serverName string, keyName string) (armsql.ServerKeysClientGetResponse, error) } type sqlServerKeysClient struct { client *armsql.ServerKeysClient } func (a *sqlServerKeysClient) NewListByServerPager(resourceGroupName string, serverName string) SqlServerKeysPager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *sqlServerKeysClient) Get(ctx context.Context, resourceGroupName string, serverName string, keyName string) (armsql.ServerKeysClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, keyName, nil) } // NewSqlServerKeysClient creates a new SqlServerKeysClient from the Azure SDK client func NewSqlServerKeysClient(client *armsql.ServerKeysClient) SqlServerKeysClient { return &sqlServerKeysClient{client: client} } ================================================ FILE: sources/azure/clients/sql-server-private-endpoint-connection-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_server_private_endpoint_connection_client.go -package=mocks -source=sql-server-private-endpoint-connection-client.go // SQLServerPrivateEndpointConnectionsPager is a type alias for the generic Pager interface with SQL server private endpoint connection list response type. type SQLServerPrivateEndpointConnectionsPager = Pager[armsql.PrivateEndpointConnectionsClientListByServerResponse] // SQLServerPrivateEndpointConnectionsClient is an interface for interacting with Azure SQL server private endpoint connections. type SQLServerPrivateEndpointConnectionsClient interface { Get(ctx context.Context, resourceGroupName string, serverName string, privateEndpointConnectionName string) (armsql.PrivateEndpointConnectionsClientGetResponse, error) ListByServer(ctx context.Context, resourceGroupName string, serverName string) SQLServerPrivateEndpointConnectionsPager } type sqlServerPrivateEndpointConnectionsClient struct { client *armsql.PrivateEndpointConnectionsClient } func (c *sqlServerPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName string, serverName string, privateEndpointConnectionName string) (armsql.PrivateEndpointConnectionsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, serverName, privateEndpointConnectionName, nil) } func (c *sqlServerPrivateEndpointConnectionsClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) SQLServerPrivateEndpointConnectionsPager { return c.client.NewListByServerPager(resourceGroupName, serverName, nil) } // NewSQLServerPrivateEndpointConnectionsClient creates a new SQLServerPrivateEndpointConnectionsClient from the Azure SDK client. func NewSQLServerPrivateEndpointConnectionsClient(client *armsql.PrivateEndpointConnectionsClient) SQLServerPrivateEndpointConnectionsClient { return &sqlServerPrivateEndpointConnectionsClient{client: client} } ================================================ FILE: sources/azure/clients/sql-server-virtual-network-rule-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_server_virtual_network_rule_client.go -package=mocks -source=sql-server-virtual-network-rule-client.go // SqlServerVirtualNetworkRulePager is a type alias for the generic Pager interface with SQL server virtual network rule list response type. type SqlServerVirtualNetworkRulePager = Pager[armsql.VirtualNetworkRulesClientListByServerResponse] // SqlServerVirtualNetworkRuleClient is an interface for interacting with Azure SQL server virtual network rules. type SqlServerVirtualNetworkRuleClient interface { ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlServerVirtualNetworkRulePager Get(ctx context.Context, resourceGroupName string, serverName string, virtualNetworkRuleName string) (armsql.VirtualNetworkRulesClientGetResponse, error) } type sqlServerVirtualNetworkRuleClient struct { client *armsql.VirtualNetworkRulesClient } func (a *sqlServerVirtualNetworkRuleClient) ListByServer(ctx context.Context, resourceGroupName string, serverName string) SqlServerVirtualNetworkRulePager { return a.client.NewListByServerPager(resourceGroupName, serverName, nil) } func (a *sqlServerVirtualNetworkRuleClient) Get(ctx context.Context, resourceGroupName string, serverName string, virtualNetworkRuleName string) (armsql.VirtualNetworkRulesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, virtualNetworkRuleName, nil) } // NewSqlServerVirtualNetworkRuleClient creates a new SqlServerVirtualNetworkRuleClient from the Azure SDK client. func NewSqlServerVirtualNetworkRuleClient(client *armsql.VirtualNetworkRulesClient) SqlServerVirtualNetworkRuleClient { return &sqlServerVirtualNetworkRuleClient{client: client} } ================================================ FILE: sources/azure/clients/sql-servers-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_sql_servers_client.go -package=mocks -source=sql-servers-client.go // SqlServersPager is a type alias for the generic Pager interface with sql server response type. // This uses the generic Pager[T] interface to avoid code duplication. type SqlServersPager = Pager[armsql.ServersClientListByResourceGroupResponse] // SqlServersClient is an interface for interacting with Azure sql servers type SqlServersClient interface { ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armsql.ServersClientListByResourceGroupOptions) SqlServersPager Get(ctx context.Context, resourceGroupName string, serverName string, options *armsql.ServersClientGetOptions) (armsql.ServersClientGetResponse, error) } type sqlServersClient struct { client *armsql.ServersClient } func (a *sqlServersClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armsql.ServersClientListByResourceGroupOptions) SqlServersPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *sqlServersClient) Get(ctx context.Context, resourceGroupName string, serverName string, options *armsql.ServersClientGetOptions) (armsql.ServersClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, serverName, options) } // NewSqlServersClient creates a new SqlServersClient from the Azure SDK client func NewSqlServersClient(client *armsql.ServersClient) SqlServersClient { return &sqlServersClient{client: client} } ================================================ FILE: sources/azure/clients/storage-accounts-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_storage_accounts_client.go -package=mocks -source=storage-accounts-client.go // StorageAccountsPager is a type alias for the generic Pager interface with storage account response type. // This uses the generic Pager[T] interface to avoid code duplication. type StorageAccountsPager = Pager[armstorage.AccountsClientListByResourceGroupResponse] // StorageAccountsClient is an interface for interacting with Azure storage accounts type StorageAccountsClient interface { Get(ctx context.Context, resourceGroupName string, accountName string) (armstorage.AccountsClientGetPropertiesResponse, error) NewListByResourceGroupPager(resourceGroupName string, options *armstorage.AccountsClientListByResourceGroupOptions) StorageAccountsPager } type storageAccountsClient struct { client *armstorage.AccountsClient } func (a *storageAccountsClient) Get(ctx context.Context, resourceGroupName string, accountName string) (armstorage.AccountsClientGetPropertiesResponse, error) { return a.client.GetProperties(ctx, resourceGroupName, accountName, nil) } func (a *storageAccountsClient) NewListByResourceGroupPager(resourceGroupName string, options *armstorage.AccountsClientListByResourceGroupOptions) StorageAccountsPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } // NewStorageAccountsClient creates a new StorageAccountsClient from the Azure SDK client func NewStorageAccountsClient(client *armstorage.AccountsClient) StorageAccountsClient { return &storageAccountsClient{client: client} } ================================================ FILE: sources/azure/clients/storage-private-endpoint-connection-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_storage_private_endpoint_connection_client.go -package=mocks -source=storage-private-endpoint-connection-client.go // PrivateEndpointConnectionsPager is a type alias for the generic Pager interface with storage private endpoint connection list response type. type PrivateEndpointConnectionsPager = Pager[armstorage.PrivateEndpointConnectionsClientListResponse] // StoragePrivateEndpointConnectionsClient is an interface for interacting with Azure storage account private endpoint connections. type StoragePrivateEndpointConnectionsClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, privateEndpointConnectionName string) (armstorage.PrivateEndpointConnectionsClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string) PrivateEndpointConnectionsPager } type storagePrivateEndpointConnectionsClient struct { client *armstorage.PrivateEndpointConnectionsClient } func (c *storagePrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName string, accountName string, privateEndpointConnectionName string) (armstorage.PrivateEndpointConnectionsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, accountName, privateEndpointConnectionName, nil) } func (c *storagePrivateEndpointConnectionsClient) List(ctx context.Context, resourceGroupName string, accountName string) PrivateEndpointConnectionsPager { return c.client.NewListPager(resourceGroupName, accountName, nil) } // NewStoragePrivateEndpointConnectionsClient creates a new StoragePrivateEndpointConnectionsClient from the Azure SDK client. func NewStoragePrivateEndpointConnectionsClient(client *armstorage.PrivateEndpointConnectionsClient) StoragePrivateEndpointConnectionsClient { return &storagePrivateEndpointConnectionsClient{client: client} } ================================================ FILE: sources/azure/clients/subnets-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_subnets_client.go -package=mocks -source=subnets-client.go // SubnetsPager is a type alias for the generic Pager interface with subnet list response type. type SubnetsPager = Pager[armnetwork.SubnetsClientListResponse] // SubnetsClient is an interface for interacting with Azure virtual network subnets. type SubnetsClient interface { Get(ctx context.Context, resourceGroupName string, virtualNetworkName string, subnetName string, options *armnetwork.SubnetsClientGetOptions) (armnetwork.SubnetsClientGetResponse, error) NewListPager(resourceGroupName string, virtualNetworkName string, options *armnetwork.SubnetsClientListOptions) SubnetsPager } type subnetsClientAdapter struct { client *armnetwork.SubnetsClient } func (a *subnetsClientAdapter) Get(ctx context.Context, resourceGroupName string, virtualNetworkName string, subnetName string, options *armnetwork.SubnetsClientGetOptions) (armnetwork.SubnetsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, virtualNetworkName, subnetName, options) } func (a *subnetsClientAdapter) NewListPager(resourceGroupName string, virtualNetworkName string, options *armnetwork.SubnetsClientListOptions) SubnetsPager { return a.client.NewListPager(resourceGroupName, virtualNetworkName, options) } // NewSubnetsClient creates a new SubnetsClient from the Azure SDK client. func NewSubnetsClient(client *armnetwork.SubnetsClient) SubnetsClient { return &subnetsClientAdapter{client: client} } ================================================ FILE: sources/azure/clients/tables-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" ) //go:generate mockgen -destination=../shared/mocks/mock_tables_client.go -package=mocks -source=tables-client.go // TablesPager is a type alias for the generic Pager interface with table response type. // This uses the generic Pager[T] interface to avoid code duplication. type TablesPager = Pager[armstorage.TableClientListResponse] // TablesClient is an interface for interacting with Azure tables type TablesClient interface { Get(ctx context.Context, resourceGroupName string, accountName string, tableName string) (armstorage.TableClientGetResponse, error) List(ctx context.Context, resourceGroupName string, accountName string) TablesPager } type tablesClient struct { client *armstorage.TableClient } func (a *tablesClient) Get(ctx context.Context, resourceGroupName string, accountName string, tableName string) (armstorage.TableClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, accountName, tableName, nil) } func (a *tablesClient) List(ctx context.Context, resourceGroupName string, accountName string) TablesPager { return a.client.NewListPager(resourceGroupName, accountName, nil) } // NewTablesClient creates a new TablesClient from the Azure SDK client func NewTablesClient(client *armstorage.TableClient) TablesClient { return &tablesClient{client: client} } ================================================ FILE: sources/azure/clients/user-assigned-identities-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" ) //go:generate mockgen -destination=../shared/mocks/mock_user_assigned_identities_client.go -package=mocks -source=user-assigned-identities-client.go // UserAssignedIdentitiesPager is a type alias for the generic Pager interface with user assigned identity response type. // This uses the generic Pager[T] interface to avoid code duplication. type UserAssignedIdentitiesPager = Pager[armmsi.UserAssignedIdentitiesClientListByResourceGroupResponse] // UserAssignedIdentitiesClient is an interface for interacting with Azure user assigned identities type UserAssignedIdentitiesClient interface { Get(ctx context.Context, resourceGroupName string, resourceName string, options *armmsi.UserAssignedIdentitiesClientGetOptions) (armmsi.UserAssignedIdentitiesClientGetResponse, error) ListByResourceGroup(resourceGroupName string, options *armmsi.UserAssignedIdentitiesClientListByResourceGroupOptions) UserAssignedIdentitiesPager } type userAssignedIdentitiesClient struct { client *armmsi.UserAssignedIdentitiesClient } func (c *userAssignedIdentitiesClient) Get(ctx context.Context, resourceGroupName string, resourceName string, options *armmsi.UserAssignedIdentitiesClientGetOptions) (armmsi.UserAssignedIdentitiesClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, resourceName, options) } func (c *userAssignedIdentitiesClient) ListByResourceGroup(resourceGroupName string, options *armmsi.UserAssignedIdentitiesClientListByResourceGroupOptions) UserAssignedIdentitiesPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } // NewUserAssignedIdentitiesClient creates a new UserAssignedIdentitiesClient from the Azure SDK client func NewUserAssignedIdentitiesClient(client *armmsi.UserAssignedIdentitiesClient) UserAssignedIdentitiesClient { return &userAssignedIdentitiesClient{client: client} } ================================================ FILE: sources/azure/clients/vaults-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" ) //go:generate mockgen -destination=../shared/mocks/mock_vaults_client.go -package=mocks -source=vaults-client.go // VaultsPager is a type alias for the generic Pager interface with vault response type. // This uses the generic Pager[T] interface to avoid code duplication. type VaultsPager = Pager[armkeyvault.VaultsClientListByResourceGroupResponse] // VaultsClient is an interface for interacting with Azure vaults type VaultsClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.VaultsClientListByResourceGroupOptions) VaultsPager Get(ctx context.Context, resourceGroupName string, vaultName string, options *armkeyvault.VaultsClientGetOptions) (armkeyvault.VaultsClientGetResponse, error) } type vaultsClient struct { client *armkeyvault.VaultsClient } func (c *vaultsClient) Get(ctx context.Context, resourceGroupName string, vaultName string, options *armkeyvault.VaultsClientGetOptions) (armkeyvault.VaultsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, vaultName, options) } func (c *vaultsClient) NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.VaultsClientListByResourceGroupOptions) VaultsPager { return c.client.NewListByResourceGroupPager(resourceGroupName, options) } // NewVaultsClient creates a new VaultsClient from the Azure SDK client func NewVaultsClient(client *armkeyvault.VaultsClient) VaultsClient { return &vaultsClient{client: client} } ================================================ FILE: sources/azure/clients/virtual-machine-extensions-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_machine_extensions_client.go -package=mocks -source=virtual-machine-extensions-client.go // VirtualMachineExtensionsClient is an interface for interacting with Azure virtual machine extensions type VirtualMachineExtensionsClient interface { List(ctx context.Context, resourceGroupName string, virtualMachineName string, options *armcompute.VirtualMachineExtensionsClientListOptions) (armcompute.VirtualMachineExtensionsClientListResponse, error) Get(ctx context.Context, resourceGroupName string, virtualMachineName string, vmExtensionName string, options *armcompute.VirtualMachineExtensionsClientGetOptions) (armcompute.VirtualMachineExtensionsClientGetResponse, error) } // virtualMachineExtensionsClientAdapter adapts the concrete Azure SDK client to our interface type virtualMachineExtensionsClientAdapter struct { client *armcompute.VirtualMachineExtensionsClient } func (a *virtualMachineExtensionsClientAdapter) List(ctx context.Context, resourceGroupName string, virtualMachineName string, options *armcompute.VirtualMachineExtensionsClientListOptions) (armcompute.VirtualMachineExtensionsClientListResponse, error) { return a.client.List(ctx, resourceGroupName, virtualMachineName, options) } func (a *virtualMachineExtensionsClientAdapter) Get(ctx context.Context, resourceGroupName string, virtualMachineName string, vmExtensionName string, options *armcompute.VirtualMachineExtensionsClientGetOptions) (armcompute.VirtualMachineExtensionsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, virtualMachineName, vmExtensionName, options) } // NewVirtualMachineExtensionsClient creates a new VirtualMachineExtensionsClient from the Azure SDK client func NewVirtualMachineExtensionsClient(client *armcompute.VirtualMachineExtensionsClient) VirtualMachineExtensionsClient { return &virtualMachineExtensionsClientAdapter{client: client} } ================================================ FILE: sources/azure/clients/virtual-machine-run-commands-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_machine_run_commands_client.go -package=mocks -source=virtual-machine-run-commands-client.go // VirtualMachineRunCommandsPager is a type alias for the generic Pager interface with virtual machine run command response type. // This uses the generic Pager[T] interface to avoid code duplication. type VirtualMachineRunCommandsPager = Pager[armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse] type VirtualMachineRunCommandsClient interface { NewListByVirtualMachinePager(resourceGroupName string, virtualMachineName string, options *armcompute.VirtualMachineRunCommandsClientListByVirtualMachineOptions) VirtualMachineRunCommandsPager GetByVirtualMachine(ctx context.Context, resourceGroupName string, virtualMachineName string, runCommandName string, options *armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineOptions) (armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse, error) } type virtualMachineRunCommandsClient struct { client *armcompute.VirtualMachineRunCommandsClient } func (a *virtualMachineRunCommandsClient) NewListByVirtualMachinePager(resourceGroupName string, virtualMachineName string, options *armcompute.VirtualMachineRunCommandsClientListByVirtualMachineOptions) VirtualMachineRunCommandsPager { return a.client.NewListByVirtualMachinePager(resourceGroupName, virtualMachineName, options) } func (a *virtualMachineRunCommandsClient) GetByVirtualMachine( ctx context.Context, resourceGroupName string, virtualMachineName string, runCommandName string, options *armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineOptions, ) (armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse, error) { return a.client.GetByVirtualMachine( ctx, resourceGroupName, virtualMachineName, runCommandName, options, ) } // NewVirtualMachineRunCommandsClient creates a new VirtualMachineRunCommandsClient from the Azure SDK client func NewVirtualMachineRunCommandsClient(client *armcompute.VirtualMachineRunCommandsClient) VirtualMachineRunCommandsClient { return &virtualMachineRunCommandsClient{client: client} } ================================================ FILE: sources/azure/clients/virtual-machine-scale-sets-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_machine_scale_sets_client.go -package=mocks -source=virtual-machine-scale-sets-client.go // VirtualMachineScaleSetsPager is a type alias for the generic Pager interface with virtual machine scale set response type. // This uses the generic Pager[T] interface to avoid code duplication. type VirtualMachineScaleSetsPager = Pager[armcompute.VirtualMachineScaleSetsClientListResponse] // VirtualMachineScaleSetsClient is an interface for interacting with Azure virtual machine scale sets type VirtualMachineScaleSetsClient interface { NewListPager(resourceGroupName string, options *armcompute.VirtualMachineScaleSetsClientListOptions) VirtualMachineScaleSetsPager Get(ctx context.Context, resourceGroupName string, virtualMachineScaleSetName string, options *armcompute.VirtualMachineScaleSetsClientGetOptions) (armcompute.VirtualMachineScaleSetsClientGetResponse, error) } type virtualMachineScaleSetsClient struct { client *armcompute.VirtualMachineScaleSetsClient } func (a *virtualMachineScaleSetsClient) NewListPager(resourceGroupName string, options *armcompute.VirtualMachineScaleSetsClientListOptions) VirtualMachineScaleSetsPager { return a.client.NewListPager(resourceGroupName, options) } func (a *virtualMachineScaleSetsClient) Get(ctx context.Context, resourceGroupName string, virtualMachineScaleSetName string, options *armcompute.VirtualMachineScaleSetsClientGetOptions) (armcompute.VirtualMachineScaleSetsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, virtualMachineScaleSetName, options) } // NewVirtualMachineScaleSetsClient creates a new VirtualMachineScaleSetsClient from the Azure SDK client func NewVirtualMachineScaleSetsClient(client *armcompute.VirtualMachineScaleSetsClient) VirtualMachineScaleSetsClient { return &virtualMachineScaleSetsClient{client: client} } ================================================ FILE: sources/azure/clients/virtual-machines-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_machines_client.go -package=mocks -source=virtual-machines-client.go // VirtualMachinesPager is a type alias for the generic Pager interface with virtual machine response type. // This uses the generic Pager[T] interface to avoid code duplication. type VirtualMachinesPager = Pager[armcompute.VirtualMachinesClientListResponse] // VirtualMachinesClient is an interface for interacting with Azure virtual machines type VirtualMachinesClient interface { Get(ctx context.Context, resourceGroupName string, vmName string, options *armcompute.VirtualMachinesClientGetOptions) (armcompute.VirtualMachinesClientGetResponse, error) NewListPager(resourceGroupName string, options *armcompute.VirtualMachinesClientListOptions) VirtualMachinesPager } // virtualMachinesClientAdapter adapts the concrete Azure SDK client to our interface type virtualMachinesClientAdapter struct { client *armcompute.VirtualMachinesClient } func (a *virtualMachinesClientAdapter) Get(ctx context.Context, resourceGroupName string, vmName string, options *armcompute.VirtualMachinesClientGetOptions) (armcompute.VirtualMachinesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, vmName, options) } func (a *virtualMachinesClientAdapter) NewListPager(resourceGroupName string, options *armcompute.VirtualMachinesClientListOptions) VirtualMachinesPager { return a.client.NewListPager(resourceGroupName, options) } // NewVirtualMachinesClient creates a new VirtualMachinesClient from the Azure SDK client func NewVirtualMachinesClient(client *armcompute.VirtualMachinesClient) VirtualMachinesClient { return &virtualMachinesClientAdapter{client: client} } ================================================ FILE: sources/azure/clients/virtual-network-gateway-connections-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_network_gateway_connections_client.go -package=mocks -source=virtual-network-gateway-connections-client.go // VirtualNetworkGatewayConnectionsPager is a type alias for the generic Pager interface with virtual network gateway connection list response type. type VirtualNetworkGatewayConnectionsPager = Pager[armnetwork.VirtualNetworkGatewayConnectionsClientListResponse] // VirtualNetworkGatewayConnectionsClient is an interface for interacting with Azure virtual network gateway connections. type VirtualNetworkGatewayConnectionsClient interface { Get(ctx context.Context, resourceGroupName string, virtualNetworkGatewayConnectionName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientListOptions) VirtualNetworkGatewayConnectionsPager } type virtualNetworkGatewayConnectionsClient struct { client *armnetwork.VirtualNetworkGatewayConnectionsClient } func (c *virtualNetworkGatewayConnectionsClient) Get(ctx context.Context, resourceGroupName string, virtualNetworkGatewayConnectionName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, virtualNetworkGatewayConnectionName, options) } func (c *virtualNetworkGatewayConnectionsClient) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientListOptions) VirtualNetworkGatewayConnectionsPager { return c.client.NewListPager(resourceGroupName, options) } // NewVirtualNetworkGatewayConnectionsClient creates a new VirtualNetworkGatewayConnectionsClient from the Azure SDK client. func NewVirtualNetworkGatewayConnectionsClient(client *armnetwork.VirtualNetworkGatewayConnectionsClient) VirtualNetworkGatewayConnectionsClient { return &virtualNetworkGatewayConnectionsClient{client: client} } ================================================ FILE: sources/azure/clients/virtual-network-gateways-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_network_gateways_client.go -package=mocks -source=virtual-network-gateways-client.go // VirtualNetworkGatewaysPager is a type alias for the generic Pager interface with virtual network gateway list response type. type VirtualNetworkGatewaysPager = Pager[armnetwork.VirtualNetworkGatewaysClientListResponse] // VirtualNetworkGatewaysClient is an interface for interacting with Azure virtual network gateways. type VirtualNetworkGatewaysClient interface { Get(ctx context.Context, resourceGroupName string, virtualNetworkGatewayName string, options *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewaysClientListOptions) VirtualNetworkGatewaysPager } type virtualNetworkGatewaysClient struct { client *armnetwork.VirtualNetworkGatewaysClient } func (c *virtualNetworkGatewaysClient) Get(ctx context.Context, resourceGroupName string, virtualNetworkGatewayName string, options *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, virtualNetworkGatewayName, options) } func (c *virtualNetworkGatewaysClient) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewaysClientListOptions) VirtualNetworkGatewaysPager { return c.client.NewListPager(resourceGroupName, options) } // NewVirtualNetworkGatewaysClient creates a new VirtualNetworkGatewaysClient from the Azure SDK client. func NewVirtualNetworkGatewaysClient(client *armnetwork.VirtualNetworkGatewaysClient) VirtualNetworkGatewaysClient { return &virtualNetworkGatewaysClient{client: client} } ================================================ FILE: sources/azure/clients/virtual-network-links-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_network_links_client.go -package=mocks -source=virtual-network-links-client.go type VirtualNetworkLinksPager = Pager[armprivatedns.VirtualNetworkLinksClientListResponse] type VirtualNetworkLinksClient interface { NewListPager(resourceGroupName string, privateZoneName string, options *armprivatedns.VirtualNetworkLinksClientListOptions) VirtualNetworkLinksPager Get(ctx context.Context, resourceGroupName string, privateZoneName string, virtualNetworkLinkName string, options *armprivatedns.VirtualNetworkLinksClientGetOptions) (armprivatedns.VirtualNetworkLinksClientGetResponse, error) } type virtualNetworkLinksClient struct { client *armprivatedns.VirtualNetworkLinksClient } func (c *virtualNetworkLinksClient) NewListPager(resourceGroupName string, privateZoneName string, options *armprivatedns.VirtualNetworkLinksClientListOptions) VirtualNetworkLinksPager { return c.client.NewListPager(resourceGroupName, privateZoneName, options) } func (c *virtualNetworkLinksClient) Get(ctx context.Context, resourceGroupName string, privateZoneName string, virtualNetworkLinkName string, options *armprivatedns.VirtualNetworkLinksClientGetOptions) (armprivatedns.VirtualNetworkLinksClientGetResponse, error) { return c.client.Get(ctx, resourceGroupName, privateZoneName, virtualNetworkLinkName, options) } func NewVirtualNetworkLinksClient(client *armprivatedns.VirtualNetworkLinksClient) VirtualNetworkLinksClient { return &virtualNetworkLinksClient{client: client} } ================================================ FILE: sources/azure/clients/virtual-network-peerings-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_network_peerings_client.go -package=mocks -source=virtual-network-peerings-client.go // VirtualNetworkPeeringsPager is a type alias for the generic Pager interface with virtual network peerings list response type. type VirtualNetworkPeeringsPager = Pager[armnetwork.VirtualNetworkPeeringsClientListResponse] // VirtualNetworkPeeringsClient is an interface for interacting with Azure virtual network peerings. type VirtualNetworkPeeringsClient interface { Get(ctx context.Context, resourceGroupName string, virtualNetworkName string, peeringName string, options *armnetwork.VirtualNetworkPeeringsClientGetOptions) (armnetwork.VirtualNetworkPeeringsClientGetResponse, error) NewListPager(resourceGroupName string, virtualNetworkName string, options *armnetwork.VirtualNetworkPeeringsClientListOptions) VirtualNetworkPeeringsPager } type virtualNetworkPeeringsClientAdapter struct { client *armnetwork.VirtualNetworkPeeringsClient } func (a *virtualNetworkPeeringsClientAdapter) Get(ctx context.Context, resourceGroupName string, virtualNetworkName string, peeringName string, options *armnetwork.VirtualNetworkPeeringsClientGetOptions) (armnetwork.VirtualNetworkPeeringsClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, virtualNetworkName, peeringName, options) } func (a *virtualNetworkPeeringsClientAdapter) NewListPager(resourceGroupName string, virtualNetworkName string, options *armnetwork.VirtualNetworkPeeringsClientListOptions) VirtualNetworkPeeringsPager { return a.client.NewListPager(resourceGroupName, virtualNetworkName, options) } // NewVirtualNetworkPeeringsClient creates a new VirtualNetworkPeeringsClient from the Azure SDK client. func NewVirtualNetworkPeeringsClient(client *armnetwork.VirtualNetworkPeeringsClient) VirtualNetworkPeeringsClient { return &virtualNetworkPeeringsClientAdapter{client: client} } ================================================ FILE: sources/azure/clients/virtual-networks-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" ) //go:generate mockgen -destination=../shared/mocks/mock_virtual_networks_client.go -package=mocks -source=virtual-networks-client.go // VirtualNetworksPager is a type alias for the generic Pager interface with virtual network response type. // This uses the generic Pager[T] interface to avoid code duplication. type VirtualNetworksPager = Pager[armnetwork.VirtualNetworksClientListResponse] // VirtualNetworksClient is an interface for interacting with Azure virtual networks type VirtualNetworksClient interface { Get(ctx context.Context, resourceGroupName string, virtualNetworkName string, options *armnetwork.VirtualNetworksClientGetOptions) (armnetwork.VirtualNetworksClientGetResponse, error) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworksClientListOptions) VirtualNetworksPager } // virtualNetworksClientAdapter adapts the concrete Azure SDK client to our interface type virtualNetworksClientAdapter struct { client *armnetwork.VirtualNetworksClient } func (a *virtualNetworksClientAdapter) Get(ctx context.Context, resourceGroupName string, virtualNetworkName string, options *armnetwork.VirtualNetworksClientGetOptions) (armnetwork.VirtualNetworksClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, virtualNetworkName, options) } func (a *virtualNetworksClientAdapter) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworksClientListOptions) VirtualNetworksPager { return a.client.NewListPager(resourceGroupName, options) } // NewVirtualNetworksClient creates a new VirtualNetworksClient from the Azure SDK client func NewVirtualNetworksClient(client *armnetwork.VirtualNetworksClient) VirtualNetworksClient { return &virtualNetworksClientAdapter{client: client} } ================================================ FILE: sources/azure/clients/zones-client.go ================================================ package clients import ( "context" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" ) //go:generate mockgen -destination=../shared/mocks/mock_zones_client.go -package=mocks -source=zones-client.go // ZonesPager is a type alias for the generic Pager interface with zone response type. // This uses the generic Pager[T] interface to avoid code duplication. type ZonesPager = Pager[armdns.ZonesClientListByResourceGroupResponse] // ZonesClient is an interface for interacting with Azure zones type ZonesClient interface { NewListByResourceGroupPager(resourceGroupName string, options *armdns.ZonesClientListByResourceGroupOptions) ZonesPager Get(ctx context.Context, resourceGroupName string, zoneName string, options *armdns.ZonesClientGetOptions) (armdns.ZonesClientGetResponse, error) } type zonesClient struct { client *armdns.ZonesClient } func (a *zonesClient) NewListByResourceGroupPager(resourceGroupName string, options *armdns.ZonesClientListByResourceGroupOptions) ZonesPager { return a.client.NewListByResourceGroupPager(resourceGroupName, options) } func (a *zonesClient) Get(ctx context.Context, resourceGroupName string, zoneName string, options *armdns.ZonesClientGetOptions) (armdns.ZonesClientGetResponse, error) { return a.client.Get(ctx, resourceGroupName, zoneName, options) } // NewZonesClient creates a new ZonesClient from the Azure SDK client func NewZonesClient(client *armdns.ZonesClient) ZonesClient { return &zonesClient{client: client} } ================================================ FILE: sources/azure/cmd/root.go ================================================ package cmd import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/logging" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/tracing" "github.com/overmindtech/cli/sources/azure/proc" ) var cfgFile string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "azure-source", Short: "Remote primary source for Azure", SilenceUsage: true, Long: `This sources looks for Azure resources in your account. `, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() defer tracing.LogRecoverToReturn(ctx, "azure-source.root") healthCheckPort := viper.GetInt("health-check-port") engineConfig, err := discovery.EngineConfigFromViper("azure", tracing.Version()) if err != nil { log.WithError(err).Error("Could not create engine config") return fmt.Errorf("could not create engine config: %w", err) } // Create a basic engine first so we can serve health probes and heartbeats even if init fails e, err := discovery.NewEngine(engineConfig) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not create engine") return fmt.Errorf("could not create engine: %w", err) } // Serve health probes before initialization so they're available even on failure e.ServeHealthProbes(healthCheckPort) // Start the engine (NATS connection) before adapter init so heartbeats work err = e.Start(ctx) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not start engine") return fmt.Errorf("could not start engine: %w", err) } // Config validation (permanent errors — no retry, just idle with error) azureCfg, cfgErr := proc.ConfigFromViper() if cfgErr != nil { log.WithError(cfgErr).Error("Azure source config error - pod will stay running with error status") e.SetInitError(cfgErr) sentry.CaptureException(cfgErr) } else { // Adapter init (retryable errors — backoff capped at 5 min) e.InitialiseAdapters(ctx, func(ctx context.Context) error { return proc.InitializeAdapters(ctx, e, azureCfg) }) } <-ctx.Done() log.Info("Stopping engine") err = e.Stop() if err != nil { log.WithError(err).Error("Could not stop engine") return fmt.Errorf("could not stop engine: %w", err) } log.Info("Stopped") return nil }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } func init() { cobra.OnInitialize(initConfig) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. var logLevel string // add engine flags discovery.AddEngineFlags(rootCmd) // General config options rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "/etc/srcman/config/source.yaml", "config file path") rootCmd.PersistentFlags().StringVar(&logLevel, "log", "info", "Set the log level. Valid values: panic, fatal, error, warn, info, debug, trace") // Custom flags for this source rootCmd.PersistentFlags().IntP("health-check-port", "", 8080, "The port that the health check should run on") rootCmd.PersistentFlags().String("azure-subscription-id", "", "Azure Subscription ID that this source should operate in") rootCmd.PersistentFlags().String("azure-tenant-id", "", "Azure Tenant ID (Azure AD tenant) for authentication") rootCmd.PersistentFlags().String("azure-client-id", "", "Azure Client ID (Application ID) for federated credentials authentication") rootCmd.PersistentFlags().String("azure-regions", "", "Comma-separated list of Azure regions that this source should operate in") // tracing rootCmd.PersistentFlags().String("honeycomb-api-key", "", "If specified, configures opentelemetry libraries to submit traces to honeycomb") rootCmd.PersistentFlags().String("sentry-dsn", "", "If specified, configures sentry libraries to capture errors") rootCmd.PersistentFlags().String("run-mode", "release", "Set the run mode for this service, 'release', 'debug' or 'test'. Defaults to 'release'.") rootCmd.PersistentFlags().Bool("json-log", true, "Set to false to emit logs as text for easier reading in development.") cobra.CheckErr(viper.BindEnv("json-log", "AZURE_SOURCE_JSON_LOG", "JSON_LOG")) // Bind these to viper cobra.CheckErr(viper.BindPFlags(rootCmd.PersistentFlags())) // Run this before we do anything to set up the loglevel rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if lvl, err := log.ParseLevel(logLevel); err == nil { log.SetLevel(lvl) } else { log.SetLevel(log.InfoLevel) log.WithFields(log.Fields{ "error": err, }).Error("Could not parse log level") } log.AddHook(TerminationLogHook{}) // Bind flags that haven't been set to the values from viper of we have them var bindErr error cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { // Bind the flag to viper only if it has a non-empty default if f.DefValue != "" || f.Changed { if err := viper.BindPFlag(f.Name, f); err != nil { bindErr = err } } }) if bindErr != nil { log.WithError(bindErr).Error("could not bind flag to viper") return fmt.Errorf("could not bind flag to viper: %w", bindErr) } if viper.GetBool("json-log") { logging.ConfigureLogrusJSON(log.StandardLogger()) } if err := tracing.InitTracerWithUpstreams("azure-source", viper.GetString("honeycomb-api-key"), viper.GetString("sentry-dsn")); err != nil { log.WithError(err).Error("could not init tracer") return fmt.Errorf("could not init tracer: %w", err) } return nil } // shut down tracing at the end of the process rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { tracing.ShutdownTracer(context.Background()) } } // initConfig reads in config file and ENV variables if set. func initConfig() { viper.SetConfigFile(cfgFile) replacer := strings.NewReplacer("-", "_") viper.SetEnvKeyReplacer(replacer) viper.AutomaticEnv() // read in environment variables that match // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil { log.Infof("Using config file: %v", viper.ConfigFileUsed()) } } // TerminationLogHook A hook that logs fatal errors to the termination log type TerminationLogHook struct{} func (t TerminationLogHook) Levels() []log.Level { return []log.Level{log.FatalLevel} } func (t TerminationLogHook) Fire(e *log.Entry) error { // shutdown tracing first to ensure all spans are flushed tracing.ShutdownTracer(context.Background()) tLog, err := os.OpenFile("/dev/termination-log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } var message string message = e.Message for k, v := range e.Data { message = fmt.Sprintf("%v %v=%v", message, k, v) } _, err = tLog.WriteString(message) return err } ================================================ FILE: sources/azure/cmd/root_test.go ================================================ package cmd import ( "bytes" "strings" "testing" ) func TestRootCommand_ShowsUsageWithoutOptions(t *testing.T) { // Capture stdout and stderr var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) // Execute the command with --help flag to simulate usage request rootCmd.SetArgs([]string{"--help"}) err := rootCmd.Execute() // Get the output output := buf.String() // Verify that usage information is present in the output usageIndicators := []string{ "azure-source", "This sources looks for Azure resources in your account", "Usage:", "Flags:", } for _, indicator := range usageIndicators { if !strings.Contains(output, indicator) { t.Errorf("Expected usage output to contain %q, but it didn't. Output: %s", indicator, output) } } // --help should not produce an error if err != nil { t.Errorf("Expected Execute() with --help to return nil, but got error: %v", err) } } ================================================ FILE: sources/azure/docs/federated-credentials.md ================================================ # Azure Federated Credentials Implementation ## Overview The Azure source now supports federated credential authentication using the Azure SDK's `DefaultAzureCredential`. This provides a flexible authentication mechanism that automatically handles multiple authentication methods, making it suitable for various deployment scenarios including Kubernetes workload identity, managed identity, and local development. ## How It Works ### DefaultAzureCredential Chain The `DefaultAzureCredential` attempts authentication using multiple methods in the following order: 1. **Environment Variables** - Service principal or workload identity via environment variables 2. **Workload Identity** - Kubernetes/EKS with OIDC federation (via `AZURE_FEDERATED_TOKEN_FILE`) 3. **Managed Identity** - When running on Azure infrastructure (VMs, App Service, Functions, etc.) 4. **Azure CLI** - Uses credentials from `az login` (ideal for local development) The first successful authentication method is used, and subsequent methods are not attempted. ### Implementation Details #### Credential Initialization The credential initialization is handled in `sources/azure/shared/credentials.go`: ```go func NewAzureCredential(ctx context.Context) (*azidentity.DefaultAzureCredential, error) { cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { return nil, fmt.Errorf("failed to create Azure credential: %w", err) } return cred, nil } ``` #### Client Initialization Azure SDK clients are initialized with the credential in `sources/azure/proc/proc.go`: ```go // Initialize Azure credentials cred, err := azureshared.NewAzureCredential(ctx) if err != nil { return fmt.Errorf("error creating Azure credentials: %w", err) } // Pass credentials to adapters discoveryAdapters, err := adapters(ctx, cfg.SubscriptionID, cfg.TenantID, cfg.ClientID, cfg.Regions, cred, linker, true) ``` #### Resource Group Discovery The implementation automatically discovers all resource groups in the subscription and creates adapters for each: ```go // Discover resource groups in the subscription rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) pager := rgClient.NewListPager(nil) for pager.More() { page, err := pager.NextPage(ctx) for _, rg := range page.Value { resourceGroups = append(resourceGroups, *rg.Name) } } ``` #### Permission Verification The source verifies subscription access at startup: ```go func checkSubscriptionAccess(ctx context.Context, subscriptionID string, cred *azidentity.DefaultAzureCredential) error { client, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { return fmt.Errorf("failed to create resource groups client: %w", err) } // Try to list resource groups to verify access pager := client.NewListPager(nil) _, err = pager.NextPage(ctx) if err != nil { return fmt.Errorf("failed to verify subscription access: %w", err) } return nil } ``` ## Environment Variables ### Required Variables These variables must be set for the Azure source to function: - `AZURE_SUBSCRIPTION_ID` - The Azure subscription ID to discover resources in - `AZURE_TENANT_ID` - The Azure AD tenant ID - `AZURE_CLIENT_ID` - The application/client ID ### Authentication Method Variables Depending on your authentication method, you may need additional variables: #### Service Principal with Client Secret ```bash export AZURE_CLIENT_SECRET="your-client-secret" ``` #### Service Principal with Certificate ```bash export AZURE_CLIENT_CERTIFICATE_PATH="/path/to/certificate.pem" ``` #### Federated Workload Identity (Kubernetes/EKS) ```bash export AZURE_FEDERATED_TOKEN_FILE="/var/run/secrets/azure/tokens/azure-identity-token" ``` This is typically set automatically by the Azure Workload Identity webhook when running in Kubernetes with proper annotations. ## Authentication Methods ### 1. Workload Identity (Kubernetes with OIDC Federation) **Use Case:** Running in Kubernetes clusters (AKS, EKS, GKE) with Azure Workload Identity configured. **How It Works:** - The Kubernetes pod is annotated with an Azure AD application - Azure AD trusts the OIDC token from the Kubernetes cluster - A federated token file is mounted into the pod - `DefaultAzureCredential` reads this token and exchanges it for Azure credentials **Configuration:** ```yaml # Pod annotation azure.workload.identity/client-id: "00000000-0000-0000-0000-000000000000" azure.workload.identity/tenant-id: "00000000-0000-0000-0000-000000000000" # Environment variables (set automatically by webhook) AZURE_CLIENT_ID: "00000000-0000-0000-0000-000000000000" AZURE_TENANT_ID: "00000000-0000-0000-0000-000000000000" AZURE_FEDERATED_TOKEN_FILE: "/var/run/secrets/azure/tokens/azure-identity-token" ``` **Reference:** [Azure Workload Identity Documentation](https://azure.github.io/azure-workload-identity/docs/) ### 2. Service Principal (Environment Variables) **Use Case:** CI/CD pipelines, containerized deployments, or any scenario where you have a service principal. **Configuration:** ```bash export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_TENANT_ID="00000000-0000-0000-0000-000000000000" export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" export AZURE_CLIENT_SECRET="your-client-secret" ``` ### 3. Managed Identity **Use Case:** Running on Azure infrastructure (VMs, App Service, Container Instances, etc.) **How It Works:** - Azure automatically provides credentials to the service - No credentials need to be stored or configured - `DefaultAzureCredential` automatically detects and uses managed identity **Configuration:** - System-assigned identity: No configuration needed - User-assigned identity: Set `AZURE_CLIENT_ID` to the identity's client ID ### 4. Azure CLI (Local Development) **Use Case:** Local development and testing **Setup:** ```bash # Login with Azure CLI az login # Set the subscription az account set --subscription "your-subscription-id" ``` **Configuration:** ```bash # Only subscription ID is needed from environment export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_TENANT_ID="00000000-0000-0000-0000-000000000000" export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" ``` The Azure source will use the credentials from `az login` automatically. ## Required Azure Permissions The Azure source requires the following permissions on the subscription: ### Built-in Role The minimum required role is **Reader** at the subscription level. ### Specific Permissions - `Microsoft.Resources/subscriptions/resourceGroups/read` - List resource groups - `Microsoft.Compute/virtualMachines/read` - Read virtual machines - Additional read permissions for other resource types as adapters are added ## Troubleshooting ### Common Issues #### 1. "DefaultAzureCredential failed to retrieve a token" **Cause:** No valid authentication method is available. **Solution:** - Verify environment variables are set correctly - For local development, run `az login` - For workload identity, verify pod annotations and service account configuration #### 2. "Failed to verify subscription access" **Cause:** Credentials don't have access to the subscription, or subscription ID is incorrect. **Solution:** - Verify the subscription ID is correct - Ensure the identity has at least Reader role on the subscription - Check Azure AD tenant ID matches the subscription's tenant #### 3. "Failed to list resource groups" **Cause:** Missing permissions or network connectivity issues. **Solution:** - Verify the identity has `Microsoft.Resources/subscriptions/resourceGroups/read` permission - Check network connectivity to Azure (firewall, proxy) - Verify subscription ID is correct ### Debugging Enable debug logging to see authentication details: ```bash export LOG_LEVEL=debug ``` The logs will show: - Which authentication method is being used - Subscription access verification results - Resource group discovery progress - Adapter initialization details ## Security Best Practices 1. **Use Workload Identity in Kubernetes**: Preferred method as it avoids storing credentials 2. **Use Managed Identity on Azure**: No credential management needed 3. **Avoid Client Secrets in Code**: Always use environment variables 4. **Rotate Credentials Regularly**: If using service principals with secrets 5. **Principle of Least Privilege**: Grant only Reader role unless more is needed 6. **Separate Identities per Environment**: Don't reuse production credentials in development ## References - [Azure Identity SDK for Go](https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity) - [DefaultAzureCredential Documentation](https://learn.microsoft.com/en-us/azure/developer/go/sdk/authentication/credential-chains) - [Azure Workload Identity](https://azure.github.io/azure-workload-identity/docs/) - [Azure Managed Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) - [Azure RBAC Roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles) ================================================ FILE: sources/azure/docs/testing-federated-auth.md ================================================ # Testing Azure Federated Authentication ## Overview This document provides comprehensive testing scenarios for Azure federated authentication, including cross-cloud identity federation from AWS and GCP. These scenarios help verify that the Azure source correctly handles federated credentials in various deployment contexts. ## Table of Contents 1. [Local Testing with Azure CLI](#local-testing-with-azure-cli) 2. [Service Principal Testing](#service-principal-testing) 3. [AWS Identity to Azure Federation](#aws-identity-to-azure-federation) 4. [GCP Service Account to Azure Federation](#gcp-service-account-to-azure-federation) 5. [Kubernetes Workload Identity Testing](#kubernetes-workload-identity-testing) 6. [Verification and Validation](#verification-and-validation) ## Prerequisites ### Azure Setup 1. **Azure Subscription** with resources to discover 2. **Azure AD Application** registered 3. **Reader role** assigned to the application on the subscription 4. **Resource Groups and VMs** created for testing (optional but recommended) ### Tools Required - Azure CLI (`az`) - AWS CLI (`aws`) - for AWS federation testing - GCP CLI (`gcloud`) - for GCP federation testing - `kubectl` - for Kubernetes testing - `curl` or similar HTTP client - `jq` - for JSON parsing --- ## Local Testing with Azure CLI ### Objective Verify that the Azure source works with Azure CLI credentials on a developer workstation. ### Setup 1. **Install Azure CLI:** ```bash # macOS brew install azure-cli # Linux curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash # Windows # Download from https://aka.ms/installazurecliwindows ``` 2. **Login to Azure:** ```bash az login ``` 3. **Select subscription:** ```bash # List available subscriptions az account list --output table # Set active subscription az account set --subscription "your-subscription-id" # Verify az account show ``` ### Configuration ```bash # Set environment variables export AZURE_SUBSCRIPTION_ID=$(az account show --query id -o tsv) export AZURE_TENANT_ID=$(az account show --query tenantId -o tsv) export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" # Your app's client ID export LOG_LEVEL=debug ``` ### Run the Source ```bash cd /workspace/sources/azure go run main.go ``` ### Expected Output ``` INFO Using config from viper INFO Successfully initialized Azure credentials INFO Discovered resource groups count=5 INFO Initialized Azure adapters adapter_count=5 INFO Successfully verified subscription access INFO Starting healthcheck server port=8080 INFO Sources initialized ``` ### Verification ```bash # Check health endpoint curl http://localhost:8080/healthz/alive # Expected: "ok" # Check logs for authentication method # Should see: "Successfully initialized Azure credentials" ``` ### Success Criteria - ✅ Source starts without errors - ✅ Health check returns "ok" - ✅ Resource groups discovered - ✅ Adapters initialized for each resource group - ✅ No authentication errors in logs --- ## Service Principal Testing ### Objective Verify authentication using a service principal with client secret. ### Setup 1. **Create Service Principal:** ```bash # Create with Reader role on subscription az ad sp create-for-rbac \ --name "test-overmind-azure-source" \ --role Reader \ --scopes "/subscriptions/$(az account show --query id -o tsv)" \ --output json > sp-credentials.json # View credentials cat sp-credentials.json ``` 2. **Extract Credentials:** ```bash export AZURE_SUBSCRIPTION_ID=$(az account show --query id -o tsv) export AZURE_TENANT_ID=$(jq -r '.tenant' sp-credentials.json) export AZURE_CLIENT_ID=$(jq -r '.appId' sp-credentials.json) export AZURE_CLIENT_SECRET=$(jq -r '.password' sp-credentials.json) export LOG_LEVEL=debug ``` ### Test Service Principal ```bash # Verify the service principal can authenticate az login --service-principal \ --username $AZURE_CLIENT_ID \ --password $AZURE_CLIENT_SECRET \ --tenant $AZURE_TENANT_ID # List resource groups to verify permissions az group list --output table # Logout (so the source uses environment variables, not CLI cache) az logout ``` ### Run the Source ```bash cd /workspace/sources/azure go run main.go ``` ### Expected Output ``` DEBUG Initializing Azure credentials using DefaultAzureCredential INFO Successfully initialized Azure credentials auth.method=default-azure-credential INFO Discovered resource groups count=5 INFO Successfully verified subscription access ``` ### Verification ```bash # Monitor logs for authentication # Should use environment variables, not Azure CLI # Verify it still works after Azure CLI logout curl http://localhost:8080/healthz/alive ``` ### Cleanup ```bash # Delete test service principal az ad sp delete --id $AZURE_CLIENT_ID # Remove credentials file rm sp-credentials.json ``` ### Success Criteria - ✅ Authentication works without Azure CLI session - ✅ Service principal credentials used from environment - ✅ All resources discovered successfully - ✅ Health check passes --- ## AWS Identity to Azure Federation ### Objective Configure AWS IAM identity to authenticate to Azure using OIDC federation, simulating a scenario where the Azure source runs in EKS with AWS IRSA. ### Architecture ``` AWS EKS Pod → AWS IAM Role → OIDC Token → Azure AD Federated Credential → Azure Access ``` ### Prerequisites - AWS account with EKS cluster - Azure subscription and Azure AD tenant - OIDC issuer configured on EKS cluster ### Step 1: Configure Azure AD Application ```bash # Create Azure AD application az ad app create --display-name "test-aws-to-azure-federation" \ --output json > azure-app.json APP_OBJECT_ID=$(jq -r '.id' azure-app.json) APP_CLIENT_ID=$(jq -r '.appId' azure-app.json) echo "Azure AD Application Client ID: $APP_CLIENT_ID" ``` ### Step 2: Get AWS EKS OIDC Issuer ```bash # Get OIDC issuer URL from your EKS cluster export OIDC_ISSUER=$(aws eks describe-cluster \ --name your-eks-cluster-name \ --query "cluster.identity.oidc.issuer" \ --output text) # Remove https:// prefix export OIDC_ISSUER_URL=${OIDC_ISSUER#https://} echo "OIDC Issuer: $OIDC_ISSUER" ``` ### Step 3: Create Federated Identity Credential in Azure ```bash # Create federated credential that trusts AWS EKS OIDC az ad app federated-credential create \ --id $APP_OBJECT_ID \ --parameters '{ "name": "aws-eks-federation", "issuer": "'"$OIDC_ISSUER"'", "subject": "system:serviceaccount:default:azure-source-sa", "audiences": ["sts.amazonaws.com"], "description": "Federated credential for AWS EKS to Azure" }' # Verify creation az ad app federated-credential list --id $APP_OBJECT_ID ``` ### Step 4: Assign Azure Permissions ```bash # Create service principal from app az ad sp create --id $APP_CLIENT_ID # Assign Reader role az role assignment create \ --role Reader \ --assignee $APP_CLIENT_ID \ --scope /subscriptions/$(az account show --query id -o tsv) ``` ### Step 5: Configure AWS IAM Role ```bash # Create IAM role with trust policy for EKS service account cat > trust-policy.json < azure-app-gcp.json APP_OBJECT_ID=$(jq -r '.id' azure-app-gcp.json) APP_CLIENT_ID=$(jq -r '.appId' azure-app-gcp.json) # Create federated credential az ad app federated-credential create \ --id $APP_OBJECT_ID \ --parameters '{ "name": "gcp-gke-federation", "issuer": "'"$OIDC_ISSUER"'", "subject": "system:serviceaccount:default:azure-source-ksa", "audiences": ["azure"], "description": "Federated credential for GCP GKE to Azure" }' # Create service principal and assign Reader role az ad sp create --id $APP_CLIENT_ID az role assignment create \ --role Reader \ --assignee $APP_CLIENT_ID \ --scope /subscriptions/$(az account show --query id -o tsv) ``` ### Step 4: Configure GKE Resources ```yaml # azure-source-gke.yaml apiVersion: v1 kind: ServiceAccount metadata: name: azure-source-ksa namespace: default annotations: iam.gke.io/gcp-service-account: azure-source-gsa@YOUR_PROJECT.iam.gserviceaccount.com --- apiVersion: apps/v1 kind: Deployment metadata: name: azure-source namespace: default spec: replicas: 1 selector: matchLabels: app: azure-source template: metadata: labels: app: azure-source spec: serviceAccountName: azure-source-ksa containers: - name: azure-source image: your-registry/azure-source:latest env: - name: AZURE_SUBSCRIPTION_ID value: "your-azure-subscription-id" - name: AZURE_TENANT_ID value: "your-azure-tenant-id" - name: AZURE_CLIENT_ID value: "your-azure-app-client-id" - name: LOG_LEVEL value: "debug" # GKE will inject GOOGLE_APPLICATION_CREDENTIALS automatically ``` ### Step 5: Bind Service Accounts ```bash # Allow Kubernetes service account to impersonate GCP service account gcloud iam service-accounts add-iam-policy-binding $GSA_EMAIL \ --role roles/iam.workloadIdentityUser \ --member "serviceAccount:$PROJECT_ID.svc.id.goog[default/azure-source-ksa]" # Deploy to GKE kubectl apply -f azure-source-gke.yaml # Wait for pod kubectl wait --for=condition=ready pod -l app=azure-source --timeout=60s ``` ### Step 6: Verify ```bash # Check logs kubectl logs -l app=azure-source --tail=50 # Check health kubectl port-forward deployment/azure-source 8080:8080 & curl http://localhost:8080/healthz/alive # Verify GCP token is available kubectl exec -it deployment/azure-source -- env | grep GOOGLE ``` ### Troubleshooting ```bash # Check workload identity binding gcloud iam service-accounts get-iam-policy $GSA_EMAIL # Verify token can be obtained kubectl exec -it deployment/azure-source -- \ gcloud auth print-identity-token # Check Azure federated credential az ad app federated-credential list --id $APP_OBJECT_ID ``` ### Cleanup ```bash # Delete GKE resources kubectl delete -f azure-source-gke.yaml # Delete GCP service account gcloud iam service-accounts delete $GSA_EMAIL --quiet # Delete Azure resources az ad app federated-credential delete \ --id $APP_OBJECT_ID \ --federated-credential-id gcp-gke-federation az ad app delete --id $APP_OBJECT_ID ``` ### Success Criteria - ✅ GCP OIDC token exchanged for Azure credentials - ✅ Source authenticates to Azure from GKE - ✅ Resources discovered successfully - ✅ Health check passes --- ## Kubernetes Workload Identity Testing ### Objective Test native Azure Workload Identity in AKS (Azure Kubernetes Service). ### Prerequisites - AKS cluster with OIDC issuer and Workload Identity enabled - Azure AD application registered - Azure Workload Identity webhook installed ### Setup ```bash # Enable OIDC and Workload Identity on AKS az aks update \ --resource-group myResourceGroup \ --name myAKSCluster \ --enable-oidc-issuer \ --enable-workload-identity # Install Azure Workload Identity webhook (if not installed) helm repo add azure-workload-identity https://azure.github.io/azure-workload-identity/charts helm install workload-identity-webhook azure-workload-identity/workload-identity-webhook \ --namespace azure-workload-identity-system \ --create-namespace # Get OIDC issuer URL export OIDC_ISSUER_URL=$(az aks show \ --resource-group myResourceGroup \ --name myAKSCluster \ --query "oidcIssuerProfile.issuerUrl" -o tsv) ``` ### Configure Azure AD ```bash # Create application az ad app create --display-name "azure-source-aks-workload-id" \ --output json > app.json APP_OBJECT_ID=$(jq -r '.id' app.json) APP_CLIENT_ID=$(jq -r '.appId' app.json) # Create federated credential az ad app federated-credential create \ --id $APP_OBJECT_ID \ --parameters "{ \"name\": \"aks-workload-identity\", \"issuer\": \"$OIDC_ISSUER_URL\", \"subject\": \"system:serviceaccount:default:azure-source-sa\", \"audiences\": [\"api://AzureADTokenExchange\"] }" # Assign permissions az ad sp create --id $APP_CLIENT_ID az role assignment create \ --role Reader \ --assignee $APP_CLIENT_ID \ --scope /subscriptions/$(az account show --query id -o tsv) ``` ### Deploy ```yaml # azure-source-aks.yaml apiVersion: v1 kind: ServiceAccount metadata: name: azure-source-sa annotations: azure.workload.identity/client-id: "YOUR_APP_CLIENT_ID" azure.workload.identity/tenant-id: "YOUR_TENANT_ID" --- apiVersion: apps/v1 kind: Deployment metadata: name: azure-source spec: replicas: 1 selector: matchLabels: app: azure-source template: metadata: labels: app: azure-source azure.workload.identity/use: "true" spec: serviceAccountName: azure-source-sa containers: - name: azure-source image: your-registry/azure-source:latest env: - name: AZURE_SUBSCRIPTION_ID value: "your-subscription-id" - name: AZURE_TENANT_ID value: "your-tenant-id" - name: AZURE_CLIENT_ID value: "your-client-id" ``` ```bash kubectl apply -f azure-source-aks.yaml kubectl wait --for=condition=ready pod -l app=azure-source --timeout=60s kubectl logs -l app=azure-source ``` ### Success Criteria - ✅ Workload Identity webhook injects token volume - ✅ Source authenticates using projected token - ✅ Resources discovered - ✅ Health check passes --- ## Verification and Validation ### Standard Checks After completing any test scenario, perform these verification steps: #### 1. Health Check ```bash # Forward port kubectl port-forward deployment/azure-source 8080:8080 & # Check health curl http://localhost:8080/healthz/alive # Expected: "ok" ``` #### 2. Log Analysis ```bash # Check for successful authentication kubectl logs -l app=azure-source | grep "Successfully initialized Azure credentials" # Check for resource discovery kubectl logs -l app=azure-source | grep "Discovered resource groups" # Check for subscription verification kubectl logs -l app=azure-source | grep "Successfully verified subscription access" # Look for errors kubectl logs -l app=azure-source | grep -i error ``` #### 3. Metrics and Observability If Honeycomb/Sentry integration is enabled: ```bash # Check traces in Honeycomb for: # - Authentication attempts # - Resource discovery operations # - Health check calls # Check Sentry for any error reports ``` ### Validation Checklist - [ ] Source starts successfully - [ ] No authentication errors - [ ] Subscription access verified - [ ] Resource groups discovered - [ ] Adapters initialized - [ ] Health check returns 200 OK - [ ] Logs show expected authentication method - [ ] No error traces in observability tools - [ ] Source survives pod restarts - [ ] Token refresh works (for long-running tests) ### Performance Testing ```bash # Measure startup time kubectl logs -l app=azure-source --timestamps | \ awk '/Started/ {print $1}' # Check memory usage kubectl top pod -l app=azure-source # Monitor over time watch -n 5 'kubectl top pod -l app=azure-source' ``` ### Common Issues and Solutions | Issue | Possible Cause | Solution | |-------|---------------|----------| | "DefaultAzureCredential failed" | No auth method available | Check environment variables, verify OIDC token injection | | "Failed to verify subscription access" | Insufficient permissions | Verify Reader role assignment | | "Failed to list resource groups" | Network/permissions issue | Check network policies, verify subscription ID | | Pod crashloops | Invalid configuration | Check logs with `kubectl logs`, verify all required env vars | | Health check fails | Credentials expired/invalid | Check credential validity, verify RBAC | ## Summary This testing guide covers: - ✅ Local development with Azure CLI - ✅ Service principal authentication - ✅ AWS to Azure federation (EKS→Azure) - ✅ GCP to Azure federation (GKE→Azure) - ✅ Native Azure Workload Identity (AKS) - ✅ Comprehensive verification steps These scenarios ensure the Azure source correctly handles federated credentials across all deployment contexts. ================================================ FILE: sources/azure/docs/usage.md ================================================ # Azure Source Usage Guide ## Quick Start This guide provides quick configuration examples for running the Azure source in various environments. ## Prerequisites 1. **Azure Subscription**: An active Azure subscription 2. **Azure AD Application**: A registered application in Azure AD with appropriate permissions 3. **Permissions**: At minimum, Reader role on the subscription ## Configuration Methods The Azure source can be configured using: 1. **Command-line flags** 2. **Environment variables** 3. **Configuration file** (YAML) ### Environment Variables Environment variables use underscores instead of hyphens and are automatically uppercased: - Flag: `--azure-subscription-id` → Environment: `AZURE_SUBSCRIPTION_ID` - Flag: `--azure-tenant-id` → Environment: `AZURE_TENANT_ID` - Flag: `--azure-client-id` → Environment: `AZURE_CLIENT_ID` ## Common Scenarios ### Scenario 1: Local Development with Azure CLI **Use Case:** Testing the source on your local machine **Prerequisites:** ```bash # Install Azure CLI # https://learn.microsoft.com/en-us/cli/azure/install-azure-cli # Login to Azure az login # Set active subscription (optional, if you have multiple) az account set --subscription "your-subscription-name-or-id" # Verify current subscription az account show ``` **Configuration:** ```bash # Set required environment variables export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_TENANT_ID="00000000-0000-0000-0000-000000000000" export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" # Run the source ./azure-source ``` **Command-line Alternative:** ```bash ./azure-source \ --azure-subscription-id="00000000-0000-0000-0000-000000000000" \ --azure-tenant-id="00000000-0000-0000-0000-000000000000" \ --azure-client-id="00000000-0000-0000-0000-000000000000" ``` ### Scenario 2: Service Principal with Client Secret **Use Case:** CI/CD pipelines, Docker containers, non-Azure environments **Setup Service Principal:** ```bash # Create a service principal az ad sp create-for-rbac --name "overmind-azure-source" \ --role Reader \ --scopes "/subscriptions/00000000-0000-0000-0000-000000000000" # Output will include: # { # "appId": "00000000-0000-0000-0000-000000000000", # "displayName": "overmind-azure-source", # "password": "your-client-secret", # "tenant": "00000000-0000-0000-0000-000000000000" # } ``` **Configuration:** ```bash export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_TENANT_ID="00000000-0000-0000-0000-000000000000" # From 'tenant' in output export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" # From 'appId' in output export AZURE_CLIENT_SECRET="your-client-secret" # From 'password' in output # Run the source ./azure-source ``` **Docker Example:** ```dockerfile FROM ubuntu:22.04 COPY azure-source /usr/local/bin/ ENV AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" ENV AZURE_TENANT_ID="00000000-0000-0000-0000-000000000000" ENV AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" # Client secret should be passed at runtime, not baked into image # docker run -e AZURE_CLIENT_SECRET="..." your-image ENTRYPOINT ["/usr/local/bin/azure-source"] ``` ### Scenario 3: Kubernetes with Workload Identity **Use Case:** Running in Kubernetes (AKS, EKS, GKE) with Azure Workload Identity **Prerequisites:** 1. Azure Workload Identity installed in cluster 2. OIDC issuer configured 3. Federated identity credential configured in Azure AD **Setup Azure Workload Identity:** 1. **Enable OIDC on your cluster** (example for AKS): ```bash az aks update \ --resource-group myResourceGroup \ --name myAKSCluster \ --enable-oidc-issuer \ --enable-workload-identity ``` 2. **Get OIDC Issuer URL:** ```bash az aks show --resource-group myResourceGroup --name myAKSCluster \ --query "oidcIssuerProfile.issuerUrl" -o tsv ``` 3. **Create Azure AD Application:** ```bash az ad app create --display-name overmind-azure-source ``` 4. **Create Federated Credential:** ```bash az ad app federated-credential create \ --id \ --parameters '{ "name": "overmind-k8s-federation", "issuer": "", "subject": "system:serviceaccount:default:overmind-azure-source", "audiences": ["api://AzureADTokenExchange"] }' ``` 5. **Assign Reader role:** ```bash az role assignment create \ --role Reader \ --assignee \ --scope /subscriptions/ ``` **Kubernetes Deployment:** ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: overmind-azure-source namespace: default annotations: azure.workload.identity/client-id: "00000000-0000-0000-0000-000000000000" azure.workload.identity/tenant-id: "00000000-0000-0000-0000-000000000000" --- apiVersion: apps/v1 kind: Deployment metadata: name: azure-source namespace: default spec: replicas: 1 selector: matchLabels: app: azure-source template: metadata: labels: app: azure-source azure.workload.identity/use: "true" # Important! spec: serviceAccountName: overmind-azure-source containers: - name: azure-source image: your-registry/azure-source:latest env: - name: AZURE_SUBSCRIPTION_ID value: "00000000-0000-0000-0000-000000000000" - name: AZURE_TENANT_ID value: "00000000-0000-0000-0000-000000000000" - name: AZURE_CLIENT_ID value: "00000000-0000-0000-0000-000000000000" # AZURE_FEDERATED_TOKEN_FILE is set automatically by the webhook ``` ### Scenario 4: Azure VM with Managed Identity **Use Case:** Running on an Azure Virtual Machine **Setup:** 1. **Enable System-Assigned Managed Identity on VM:** ```bash az vm identity assign \ --resource-group myResourceGroup \ --name myVM ``` 2. **Assign Reader role to the managed identity:** ```bash # Get the principal ID PRINCIPAL_ID=$(az vm show --resource-group myResourceGroup --name myVM \ --query identity.principalId -o tsv) # Assign role az role assignment create \ --role Reader \ --assignee $PRINCIPAL_ID \ --scope /subscriptions/ ``` **Configuration on VM:** ```bash # Only subscription info is needed - managed identity is automatic export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_TENANT_ID="00000000-0000-0000-0000-000000000000" export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" ./azure-source ``` ### Scenario 5: Specify Azure Regions (Optional) **Use Case:** Limit discovery to specific regions for performance **Configuration:** ```bash export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_TENANT_ID="00000000-0000-0000-0000-000000000000" export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" export AZURE_REGIONS="eastus,westus2,northeurope" ./azure-source ``` **Command-line:** ```bash ./azure-source \ --azure-subscription-id="00000000-0000-0000-0000-000000000000" \ --azure-tenant-id="00000000-0000-0000-0000-000000000000" \ --azure-client-id="00000000-0000-0000-0000-000000000000" \ --azure-regions="eastus,westus2,northeurope" ``` **Note:** If regions are not specified, the source will discover resources in all regions. ## Configuration File You can also use a YAML configuration file (default location: `/etc/srcman/config/source.yaml`): ```yaml # Azure Configuration azure-subscription-id: "00000000-0000-0000-0000-000000000000" azure-tenant-id: "00000000-0000-0000-0000-000000000000" azure-client-id: "00000000-0000-0000-0000-000000000000" azure-regions: "eastus,westus2" # Source Configuration nats-url: "nats://nats:4222" max-parallel-executions: 1000 # Logging log: "info" # panic, fatal, error, warn, info, debug, trace # Health Check health-check-port: 8080 # Tracing (Optional) honeycomb-api-key: "your-honeycomb-key" sentry-dsn: "your-sentry-dsn" run-mode: "release" # release, debug, or test ``` **Run with config file:** ```bash ./azure-source --config /path/to/config.yaml ``` ## Available Flags All configuration can be provided via command-line flags: ```bash ./azure-source --help Flags: # Azure-specific flags --azure-subscription-id string Azure Subscription ID that this source should operate in --azure-tenant-id string Azure Tenant ID (Azure AD tenant) for authentication --azure-client-id string Azure Client ID (Application ID) for federated credentials authentication --azure-regions string Comma-separated list of Azure regions that this source should operate in # General flags --config string config file path (default "/etc/srcman/config/source.yaml") --log string Set the log level (default "info") --health-check-port int The port that the health check should run on (default 8080) # NATS flags --nats-url string NATS server URL --nats-name-prefix string Prefix for NATS connection name --max-parallel-executions int Max number of requests to execute in parallel # Tracing flags --honeycomb-api-key string Honeycomb API key for tracing --sentry-dsn string Sentry DSN for error tracking --run-mode string Run mode: release, debug, or test (default "release") ``` ## Health Check The source exposes a health check endpoint: ```bash # Check health curl http://localhost:8080/healthz/alive # Response: "ok" (HTTP 200) if healthy # Response: Error message (HTTP 503 Service Unavailable) if unhealthy ``` The health check verifies: 1. Source is running 2. Credentials are valid 3. Subscription is accessible ## Troubleshooting ### Check Logs ```bash # Enable debug logging export LOG_LEVEL=debug ./azure-source # Or with flag ./azure-source --log=debug ``` ### Verify Authentication ```bash # Test Azure CLI authentication az account show # Test service principal authentication az login --service-principal \ --username $AZURE_CLIENT_ID \ --password $AZURE_CLIENT_SECRET \ --tenant $AZURE_TENANT_ID # List resource groups to verify permissions az group list --subscription $AZURE_SUBSCRIPTION_ID ``` ### Common Issues **Issue:** "failed to create Azure credential" - **Solution:** Verify environment variables are set correctly. For local development, ensure `az login` is completed. **Issue:** "failed to verify subscription access" - **Solution:** Verify the identity has Reader role on the subscription. Check subscription ID is correct. **Issue:** "No resource groups found" - **Solution:** This may be normal if the subscription has no resource groups. The source will still run successfully. ## Best Practices 1. **Use Workload Identity in Production**: Most secure method, no credential management needed 2. **Never Hard-code Secrets**: Always use environment variables or secret management systems 3. **Use Least Privilege**: Grant only Reader role unless write access is needed 4. **Rotate Credentials**: If using service principals, rotate secrets regularly 5. **Monitor Health Endpoint**: Integrate health checks into your orchestration system 6. **Enable Tracing**: Use Honeycomb and Sentry for production observability ## Next Steps - See [federated-credentials.md](./federated-credentials.md) for detailed authentication information - See [testing-federated-auth.md](./testing-federated-auth.md) for testing scenarios with external identities - Review [Azure RBAC documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/) for permission management ## Support For issues or questions: 1. Check logs with `--log=debug` 2. Verify Azure permissions with Azure CLI 3. Review the federated credentials documentation 4. Check the health endpoint for status ================================================ FILE: sources/azure/integration-tests/README.md ================================================ # Running Integration Tests for Azure Integration tests are defined in an individual file for each resource. Test names follow the pattern `TestIntegration`, where `` is the API name and `` is the resource name. For example, `TestComputeVirtualMachineIntegration` tests the Compute API's VirtualMachine resource. ## Setup your local environment for testing 1. Create your Azure account here, `https://portal.azure.com/` 2. Use your brex credit card information 3. You can see the other overmind subscriptions, they will be under Subscriptions in the Azure portal. 4. Login to Azure CLI `az login` on the terminal. 5. To run the **integration tests in debug mode** you need to set the following environment variables. `~/.config/Cursor/User/settings.json` ```json { "window.commandCenter": true, "workbench.activityBar.orientation": "vertical", "go.testEnvVars": { "RUN_AZURE_INTEGRATION_TESTS": "true", "AZURE_SUBSCRIPTION_ID": "your-subscription-id", "AZURE_TENANT_ID": "your-tenant-id", "AZURE_CLIENT_ID": "your-client-id", "AZURE_INTEGRATION_TEST_RUN_ID": "local-dev-1" } } ``` > **Note:** Replace the placeholder values with your own Azure subscription ID, tenant ID, and client ID. Or you can run them in the CLI by using: ```bash export RUN_AZURE_INTEGRATION_TESTS=true # For Azure export AZURE_SUBSCRIPTION_ID="your-subscription-id" # your Azure subscription ID export AZURE_TENANT_ID="your-tenant-id" # your Azure AD tenant ID export AZURE_CLIENT_ID="your-client-id" # your Azure application/client ID export AZURE_REGIONS="eastus,westus2" # optional: comma-separated list of regions export AZURE_INTEGRATION_TEST_RUN_ID="local-dev-1" # optional: isolate this run's resource group # For SQL Database integration tests export AZURE_SQL_SERVER_ADMIN_LOGIN="sqladmin" # SQL server administrator login export AZURE_SQL_SERVER_ADMIN_PASSWORD="your-secure-password" # SQL server administrator password # For PostgreSQL Flexible Server integration tests export AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN="pgadmin" # PostgreSQL Flexible Server administrator login export AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD="your-secure-password" # PostgreSQL Flexible Server administrator password ``` 6. Integration tests are using Azure SDK for Go to interact with Azure resources. For local development, you can authenticate using: - **Azure CLI**: `az login` (recommended for local development) - **Service Principal**: Set `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, and `AZURE_SUBSCRIPTION_ID` environment variables - **Managed Identity**: When running in Azure (automatically detected) - **Workload Identity Federation**: When running in Kubernetes/EKS (automatically detected via federated credentials) See the [official documentation](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication) for more authentication options. 7. **optional** You may need to set the default subscription `az account set --subscription "your-subscription-id"`. Use your own subscription ID here. 8. You can now run integration tests. Each test has `Setup`, `Run`, and `Teardown` methods. - `Setup` is used to create any resources needed for the test. - `Run` is where the actual test logic is implemented. - `Teardown` is used to clean up any resources created during the test. The `Setup` and `Teardown` methods are idempotent, meaning they can be run multiple times without causing issues. This allows for flexibility in running tests in different orders or multiple times. We can easily run all `Setup` tests to create resources, then run all `Run` tests to execute the actual tests, and finally run all `Teardown` tests to clean up resources. **Run after Setup:** `Run` subtests skip with a clear message when `Setup` did not complete successfully (for example Setup was skipped, failed, or you ran only `Run` without a prior successful Setup). That avoids noisy failures that are not adapter bugs. ### Skips, quotas, and slow Azure operations Some tests intentionally call `t.Skip` for Azure conditions that are external to adapter correctness, for example: - Batch account quota exhaustion (`SubscriptionQuotaExceeded`) - **Gallery application version** (`compute-gallery-application-version_test.go`): requires env vars `AZURE_TEST_GALLERY_NAME`, `AZURE_TEST_GALLERY_APPLICATION_NAME`, and `AZURE_TEST_GALLERY_APPLICATION_VERSION` pointing at an existing gallery application version; if the version is missing (`404`), the test skips after preflight - **Role assignments** (`authorization-role-assignment_test.go`): may wait for RBAC eventual consistency before asserting adapter behaviour VM/VMSS/role-assignment ghost `409 Conflict` states are now handled with "auto-remediate then fail": tests attempt cleanup and a retry, and fail loudly if the resource is still unrecoverable. Also note that PostgreSQL Flexible Server creation and Key Vault purge/recreate can take many minutes. If a run times out, increase `go test -timeout` (for example `-timeout 30m`) before assuming the test is stuck. From the `sources/azure/integration-tests` directory: For building up the infra for the Compute API resources. ```bash go test ./integration-tests -run "TestCompute.*/Setup" -v ``` For running the actual tests for the Compute API resources. ```bash go test ./integration-tests -run "TestCompute.*/Run" -v ``` For tearing down the infra for the Compute API resources. ```bash go test ./integration-tests -run "TestCompute.*/Teardown" -v ``` ## Running Integration Tests via Cloud Agents Cursor Cloud Agents can run Azure integration tests autonomously when configured with the correct credentials. ### Prerequisites 1. **1Password vault**: Azure credentials are stored in the "cursor" 1Password vault under the item "Azure Integration Tests" 2. **Cursor Cloud Agent secret**: Configure only `OP_SERVICE_ACCOUNT_TOKEN` in `https://cursor.com/dashboard/cloud-agents` 3. **Repo env files**: `op.azure-cloud-agent.secret` and `op.azure-cloud-agent.env` exist with required `op://...` references ### How it works When a Cloud Agent picks up a Linear issue to create an Azure adapter: 1. Cursor injects `OP_SERVICE_ACCOUNT_TOKEN` into the Cloud Agent environment 2. `inject-secrets` reads `op://...` references from env files using the 1Password SDK 3. `inject-secrets` writes resolved values to a local env file 4. The shell sources that file before test execution 5. The `DefaultAzureCredential` chain picks up `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and `AZURE_TENANT_ID` from environment 6. Integration tests use `AZURE_SUBSCRIPTION_ID` and `RUN_AZURE_INTEGRATION_TESTS=true` To inject credentials manually (e.g. for debugging), run: ```bash go run build/inject-secrets/main.go \ --no-ping \ --secret-file .github/env/op.azure-cloud-agent.secret \ --env-file .github/env/op.azure-cloud-agent.env \ --output-file .env.azure-cloud-agent set -a source .env.azure-cloud-agent set +a ``` ### Security - The service principal has **read-write access** scoped to the integration test subscription only - Cloud Agent dashboard stores only the bootstrap token (`OP_SERVICE_ACCOUNT_TOKEN`) - Azure credentials remain in 1Password and are resolved only at runtime via `inject-secrets` - By default test resources are created in `overmind-integration-tests`; set `AZURE_INTEGRATION_TEST_RUN_ID` to isolate parallel runs into per-run resource groups (for example `overmind-integration-tests-agent-42`) - Teardown steps clean up created resources after each test run ================================================ FILE: sources/azure/integration-tests/authorization-role-assignment_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "os/exec" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) func TestAuthorizationRoleAssignmentIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients roleAssignmentsClient, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Role Assignments client: %v", err) } roleDefinitionsClient, err := armauthorization.NewRoleDefinitionsClient(cred, nil) if err != nil { t.Fatalf("Failed to create Role Definitions client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Get current user's principal ID for role assignment // We'll use the current authenticated user/principal principalID, err := getCurrentUserPrincipalID(t.Context(), cred) if err != nil { t.Fatalf("Failed to get current user principal ID: %v", err) } // Get the Reader role definition ID (built-in role) readerRoleDefinitionID, err := getReaderRoleDefinitionID(t.Context(), roleDefinitionsClient, subscriptionID) if err != nil { t.Fatalf("Failed to get Reader role definition ID: %v", err) } // Deterministic role assignment name so re-runs reuse the same assignment ID // instead of conflicting with a prior run's different UUID for the same principal+role combo roleAssignmentName := uuid.NewSHA1(uuid.NameSpaceURL, []byte(principalID+readerRoleDefinitionID+integrationTestResourceGroup)).String() azureScope := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", subscriptionID, integrationTestResourceGroup) setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create role assignment at resource group scope actualName, createErr := createRoleAssignment(ctx, roleAssignmentsClient, azureScope, roleAssignmentName, principalID, readerRoleDefinitionID) if createErr != nil { t.Fatalf("Failed to create role assignment: %v", createErr) } roleAssignmentName = actualName err = waitForRoleAssignmentAvailable(ctx, roleAssignmentsClient, azureScope, roleAssignmentName) if err != nil { t.Fatalf("Failed waiting for role assignment to be available: %v", err) } setupCompleted = true log.Printf("Created role assignment %s at scope %s", roleAssignmentName, azureScope) }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetRoleAssignment", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving role assignment %s at scope %s", roleAssignmentName, azureScope) roleAssignmentWrapper := manual.NewAuthorizationRoleAssignment( clients.NewRoleAssignmentsClient(roleAssignmentsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := roleAssignmentWrapper.Scopes()[0] roleAssignmentAdapter := sources.WrapperToAdapter(roleAssignmentWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := roleAssignmentAdapter.Get(ctx, scope, roleAssignmentName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.AuthorizationRoleAssignment.String() { t.Errorf("Expected type %s, got %s", azureshared.AuthorizationRoleAssignment.String(), sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(integrationTestResourceGroup, roleAssignmentName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved role assignment %s", roleAssignmentName) }) t.Run("ListRoleAssignments", func(t *testing.T) { ctx := t.Context() log.Printf("Listing role assignments in resource group %s", integrationTestResourceGroup) roleAssignmentWrapper := manual.NewAuthorizationRoleAssignment( clients.NewRoleAssignmentsClient(roleAssignmentsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := roleAssignmentWrapper.Scopes()[0] roleAssignmentAdapter := sources.WrapperToAdapter(roleAssignmentWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list listable, ok := roleAssignmentAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list role assignments: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one role assignment, got %d", len(sdpItems)) } var found bool expectedUniqueAttrValue := shared.CompositeLookupKey(integrationTestResourceGroup, roleAssignmentName) for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { if v == expectedUniqueAttrValue { found = true break } } } if !found { t.Fatalf("Expected to find role assignment %s in the list results", roleAssignmentName) } log.Printf("Found %d role assignments in list results", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for role assignment %s", roleAssignmentName) roleAssignmentWrapper := manual.NewAuthorizationRoleAssignment( clients.NewRoleAssignmentsClient(roleAssignmentsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := roleAssignmentWrapper.Scopes()[0] roleAssignmentAdapter := sources.WrapperToAdapter(roleAssignmentWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := roleAssignmentAdapter.Get(ctx, scope, roleAssignmentName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.AuthorizationRoleAssignment.String() { t.Errorf("Expected item type %s, got %s", azureshared.AuthorizationRoleAssignment.String(), sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } // Verify that principal ID is in attributes principalIDAttr, err := sdpItem.GetAttributes().Get("properties.principalId") if err != nil { t.Logf("Warning: Could not get principalId attribute (may be in different format): %v", err) } else if principalIDAttr != principalID { t.Logf("Warning: Principal ID mismatch (expected %s, got %s), but this may be due to attribute format", principalID, principalIDAttr) } log.Printf("Verified item attributes for role assignment %s", roleAssignmentName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for role assignment %s", roleAssignmentName) roleAssignmentWrapper := manual.NewAuthorizationRoleAssignment( clients.NewRoleAssignmentsClient(roleAssignmentsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := roleAssignmentWrapper.Scopes()[0] roleAssignmentAdapter := sources.WrapperToAdapter(roleAssignmentWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := roleAssignmentAdapter.Get(ctx, scope, roleAssignmentName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify linked item queries are created linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Error("Expected at least one linked item query (role definition), got 0") } // Verify role definition link exists foundRoleDefinitionLink := false for _, linkedQuery := range linkedQueries { if linkedQuery.GetQuery().GetType() == azureshared.AuthorizationRoleDefinition.String() { foundRoleDefinitionLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected role definition link method to be GET, got %v", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetScope() != subscriptionID { t.Errorf("Expected role definition link scope to be subscription ID %s, got %s", subscriptionID, linkedQuery.GetQuery().GetScope()) } break } } if !foundRoleDefinitionLink { t.Error("Expected to find role definition linked item query") } log.Printf("Verified linked items for role assignment %s (found %d linked queries)", roleAssignmentName, len(linkedQueries)) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete role assignment err := deleteRoleAssignment(ctx, roleAssignmentsClient, azureScope, roleAssignmentName) if err != nil { t.Fatalf("Failed to delete role assignment: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // getCurrentUserPrincipalID gets the principal ID of the current authenticated user // It tries to get it from environment variable first, then falls back to Azure CLI func getCurrentUserPrincipalID(ctx context.Context, cred azcore.TokenCredential) (string, error) { // First, try to get from environment variable (useful for CI/CD) if principalID := os.Getenv("AZURE_PRINCIPAL_ID"); principalID != "" { log.Printf("Using principal ID from AZURE_PRINCIPAL_ID environment variable") return strings.TrimSpace(principalID), nil } // For local development, use Azure CLI to get the current user's object ID // This requires the user to be logged in via `az login` cmd := exec.CommandContext(ctx, "az", "ad", "signed-in-user", "show", "--query", "id", "-o", "tsv") output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get principal ID from Azure CLI (make sure you're logged in with 'az login'): %w. Alternatively, set AZURE_PRINCIPAL_ID environment variable", err) } principalID := strings.TrimSpace(string(output)) if principalID == "" { return "", fmt.Errorf("Azure CLI returned empty principal ID. Please set AZURE_PRINCIPAL_ID environment variable or ensure you're logged in with 'az login'") } log.Printf("Retrieved principal ID from Azure CLI") return principalID, nil } // getReaderRoleDefinitionID gets the Reader role definition ID func getReaderRoleDefinitionID(ctx context.Context, client *armauthorization.RoleDefinitionsClient, subscriptionID string) (string, error) { scope := fmt.Sprintf("/subscriptions/%s", subscriptionID) filter := "roleName eq 'Reader'" pager := client.NewListPager(scope, &armauthorization.RoleDefinitionsClientListOptions{ Filter: &filter, }) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return "", fmt.Errorf("failed to get role definitions page: %w", err) } for _, roleDef := range page.Value { if roleDef.Properties != nil && roleDef.Properties.RoleName != nil && *roleDef.Properties.RoleName == "Reader" { if roleDef.ID != nil { return *roleDef.ID, nil } } } } return "", fmt.Errorf("Reader role definition not found") } // createRoleAssignment creates an Azure role assignment (idempotent). // Returns the actual assignment name used (may differ from input if a prior run // created the same principal+role combo under a different UUID). func createRoleAssignment(ctx context.Context, client *armauthorization.RoleAssignmentsClient, scope, roleAssignmentName, principalID, roleDefinitionID string) (string, error) { return createRoleAssignmentWithRemediation(ctx, client, scope, roleAssignmentName, principalID, roleDefinitionID, 0) } func createRoleAssignmentWithRemediation(ctx context.Context, client *armauthorization.RoleAssignmentsClient, scope, roleAssignmentName, principalID, roleDefinitionID string, remediationAttempt int) (string, error) { _, err := client.Get(ctx, scope, roleAssignmentName, nil) if err == nil { log.Printf("Role assignment %s already exists, skipping creation", roleAssignmentName) return roleAssignmentName, nil } if principalID == "" { return "", fmt.Errorf("principal ID is required to create role assignment") } parameters := armauthorization.RoleAssignmentCreateParameters{ Properties: &armauthorization.RoleAssignmentProperties{ PrincipalID: new(principalID), RoleDefinitionID: new(roleDefinitionID), }, } resp, err := client.Create(ctx, scope, roleAssignmentName, parameters, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusConflict { if strings.Contains(respErr.Error(), "RoleAssignmentExists") { existingID := extractExistingRoleAssignmentID(respErr.Error()) if existingID != "" { log.Printf("Role assignment for this principal+role already exists at scope %s with ID %s, reusing", scope, existingID) return existingID, nil } log.Printf("Role assignment for this principal+role already exists at scope %s, treating as success", scope) return roleAssignmentName, nil } existing, getErr := client.Get(ctx, scope, roleAssignmentName, nil) if getErr == nil && existing.RoleAssignment.ID != nil && *existing.RoleAssignment.ID != "" { log.Printf("Role assignment %s already exists (conflict), verified readable, skipping creation", roleAssignmentName) return roleAssignmentName, nil } var getRespErr *azcore.ResponseError if errors.As(getErr, &getRespErr) && getRespErr.StatusCode == http.StatusNotFound { if remediationAttempt >= 1 { return "", fmt.Errorf("role assignment %s still in ghost conflict state after remediation (scope=%s): %w", roleAssignmentName, scope, err) } log.Printf("Detected ghost role-assignment conflict for %s at %s, attempting automatic remediation", roleAssignmentName, scope) if deleteErr := deleteRoleAssignment(ctx, client, scope, roleAssignmentName); deleteErr != nil { return "", fmt.Errorf("failed to remediate ghost role assignment %s before retry: %w", roleAssignmentName, deleteErr) } time.Sleep(5 * time.Second) return createRoleAssignmentWithRemediation(ctx, client, scope, roleAssignmentName, principalID, roleDefinitionID, remediationAttempt+1) } return "", fmt.Errorf("role assignment conflict for %s and failed to verify existing role assignment: %w", roleAssignmentName, getErr) } if respErr.StatusCode == http.StatusForbidden { return "", fmt.Errorf("insufficient permissions to create role assignment: %w", err) } } return "", fmt.Errorf("failed to create role assignment: %w", err) } if resp.RoleAssignment.ID == nil { return "", fmt.Errorf("role assignment created but ID is unknown") } log.Printf("Role assignment %s created successfully at scope %s", roleAssignmentName, scope) return roleAssignmentName, nil } // extractExistingRoleAssignmentID parses the existing assignment ID from the // RoleAssignmentExists error message (format: "...The ID of the existing role // assignment is ."). func extractExistingRoleAssignmentID(errMsg string) string { const marker = "The ID of the existing role assignment is " _, after, ok := strings.Cut(errMsg, marker) if !ok { return "" } rest := after if dotIdx := strings.Index(rest, "."); dotIdx > 0 { rest = rest[:dotIdx] } rest = strings.TrimSpace(rest) if len(rest) != 32 { return rest } // Convert 32-char hex to UUID format (8-4-4-4-12) return rest[:8] + "-" + rest[8:12] + "-" + rest[12:16] + "-" + rest[16:20] + "-" + rest[20:] } // deleteRoleAssignment deletes an Azure role assignment func deleteRoleAssignment(ctx context.Context, client *armauthorization.RoleAssignmentsClient, scope, roleAssignmentName string) error { _, err := client.Delete(ctx, scope, roleAssignmentName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Role assignment %s not found, skipping deletion", roleAssignmentName) return nil } return fmt.Errorf("failed to delete role assignment: %w", err) } log.Printf("Role assignment %s deleted successfully", roleAssignmentName) return nil } func waitForRoleAssignmentAvailable(ctx context.Context, client *armauthorization.RoleAssignmentsClient, scope, roleAssignmentName string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { _, err := client.Get(ctx, scope, roleAssignmentName, nil) if err == nil { return nil } var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { time.Sleep(pollInterval) continue } return fmt.Errorf("error checking role assignment availability: %w", err) } return fmt.Errorf("timeout waiting for role assignment %s to be available", roleAssignmentName) } ================================================ FILE: sources/azure/integration-tests/authorization-role-definition_test.go ================================================ package integrationtests import ( "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) func TestAuthorizationRoleDefinitionIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } roleDefinitionsClient, err := armauthorization.NewRoleDefinitionsClient(cred, nil) if err != nil { t.Fatalf("Failed to create Role Definitions client: %v", err) } // Use a built-in role definition ID that always exists: "Reader" // The Reader role ID is the same across all Azure subscriptions readerRoleDefinitionID := "acdd72a7-3385-48ef-bd42-f606fba81ae7" t.Run("Setup", func(t *testing.T) { // No setup required for role definitions - they are built-in Azure resources log.Printf("Using built-in Reader role definition ID: %s", readerRoleDefinitionID) }) t.Run("Run", func(t *testing.T) { t.Run("GetRoleDefinition", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving role definition %s", readerRoleDefinitionID) wrapper := manual.NewAuthorizationRoleDefinition( clients.NewRoleDefinitionsClient(roleDefinitionsClient), subscriptionID, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, readerRoleDefinitionID, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.AuthorizationRoleDefinition.String() { t.Errorf("Expected type %s, got %s", azureshared.AuthorizationRoleDefinition.String(), sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "name" { t.Errorf("Expected unique attribute 'name', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != readerRoleDefinitionID { t.Errorf("Expected unique attribute value %s, got %s", readerRoleDefinitionID, uniqueAttrValue) } if sdpItem.GetScope() != subscriptionID { t.Errorf("Expected scope %s, got %s", subscriptionID, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved role definition %s", readerRoleDefinitionID) }) t.Run("ListRoleDefinitions", func(t *testing.T) { ctx := t.Context() log.Printf("Listing role definitions in subscription %s", subscriptionID) wrapper := manual.NewAuthorizationRoleDefinition( clients.NewRoleDefinitionsClient(roleDefinitionsClient), subscriptionID, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list role definitions: %v", err) } // Azure has many built-in role definitions, expect at least a few if len(sdpItems) < 5 { t.Fatalf("Expected at least 5 role definitions, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { if v == readerRoleDefinitionID { found = true break } } } if !found { t.Fatalf("Expected to find Reader role definition %s in the list results", readerRoleDefinitionID) } log.Printf("Found %d role definitions in list results", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for role definition %s", readerRoleDefinitionID) wrapper := manual.NewAuthorizationRoleDefinition( clients.NewRoleDefinitionsClient(roleDefinitionsClient), subscriptionID, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, readerRoleDefinitionID, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.AuthorizationRoleDefinition.String() { t.Errorf("Expected item type %s, got %s", azureshared.AuthorizationRoleDefinition.String(), sdpItem.GetType()) } // Verify scope if sdpItem.GetScope() != subscriptionID { t.Errorf("Expected scope %s, got %s", subscriptionID, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } // Verify role name is Reader roleName, err := sdpItem.GetAttributes().Get("properties.roleName") if err != nil { t.Logf("Warning: Could not get roleName attribute: %v", err) } else if roleName != "Reader" { t.Errorf("Expected role name 'Reader', got %s", roleName) } log.Printf("Verified item attributes for role definition %s", readerRoleDefinitionID) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for role definition %s", readerRoleDefinitionID) wrapper := manual.NewAuthorizationRoleDefinition( clients.NewRoleDefinitionsClient(roleDefinitionsClient), subscriptionID, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, readerRoleDefinitionID, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Role definitions link to AssignableScopes (subscriptions and resource groups) // The built-in Reader role has "/" as its assignable scope, which may not produce links // Custom roles would have specific subscription/resource group scopes linkedQueries := sdpItem.GetLinkedItemQueries() // Verify each linked query has proper attributes for _, linkedQuery := range linkedQueries { query := linkedQuery.GetQuery() if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has invalid Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } // Verify linked types are either subscription or resource group validTypes := map[string]bool{ azureshared.ResourcesSubscription.String(): true, azureshared.ResourcesResourceGroup.String(): true, } if !validTypes[query.GetType()] { t.Errorf("Unexpected linked item type: %s", query.GetType()) } } log.Printf("Verified linked items for role definition %s (found %d linked queries)", readerRoleDefinitionID, len(linkedQueries)) }) t.Run("VerifyBuiltInRoles", func(t *testing.T) { ctx := t.Context() // Verify some well-known built-in role definitions exist builtInRoles := map[string]string{ "acdd72a7-3385-48ef-bd42-f606fba81ae7": "Reader", "b24988ac-6180-42a0-ab88-20f7382dd24c": "Contributor", "8e3af657-a8ff-443c-a75c-2fe8c4bcb635": "Owner", } wrapper := manual.NewAuthorizationRoleDefinition( clients.NewRoleDefinitionsClient(roleDefinitionsClient), subscriptionID, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) for roleID, roleName := range builtInRoles { t.Run(fmt.Sprintf("Get%sRole", roleName), func(t *testing.T) { sdpItem, qErr := adapter.Get(ctx, scope, roleID, true) if qErr != nil { t.Fatalf("Failed to get %s role definition: %v", roleName, qErr) } if sdpItem == nil { t.Fatalf("Expected %s role definition to be non-nil", roleName) } actualRoleName, err := sdpItem.GetAttributes().Get("properties.roleName") if err != nil { t.Logf("Warning: Could not get roleName attribute for %s: %v", roleName, err) } else if actualRoleName != roleName { t.Errorf("Expected role name '%s', got '%s'", roleName, actualRoleName) } log.Printf("Successfully verified built-in role: %s (ID: %s)", roleName, roleID) }) } }) }) t.Run("Teardown", func(t *testing.T) { // No teardown required - role definitions are built-in Azure resources log.Printf("No teardown required for role definitions (built-in Azure resources)") }) } ================================================ FILE: sources/azure/integration-tests/batch-batch-accounts_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "math/rand" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestBatchAccountName = "ovm-integ-test-batch" integrationTestSANameForBatch = "ovm-integ-test-sa-batch" ) var errBatchQuotaExceeded = errors.New("azure batch quota exceeded") func TestBatchAccountIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients batchClient, err := armbatch.NewAccountClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Batch Account client: %v", err) } saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique names (batch account names must be globally unique, 3-24 chars, lowercase alphanumeric) batchAccountName := generateBatchAccountName(integrationTestBatchAccountName) storageAccountName := generateStorageAccountName(integrationTestSANameForBatch) setupCompleted := false var storageAccountID string t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create storage account (required for batch account auto-storage) err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } // Wait for storage account to be fully available err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account to be available: %v", err) } // Get storage account ID saResp, err := saClient.GetProperties(ctx, integrationTestResourceGroup, storageAccountName, nil) if err != nil { t.Fatalf("Failed to get storage account properties: %v", err) } storageAccountID = *saResp.ID // Create batch account err = createBatchAccount(ctx, batchClient, integrationTestResourceGroup, batchAccountName, integrationTestLocation, storageAccountID) if err != nil { if errors.Is(err, errBatchQuotaExceeded) { t.Skipf("Skipping Batch account integration test due to Azure subscription quota: %v", err) } t.Fatalf("Failed to create batch account: %v", err) } // Wait for batch account to be fully available err = waitForBatchAccountAvailable(ctx, batchClient, integrationTestResourceGroup, batchAccountName) if err != nil { t.Fatalf("Failed waiting for batch account to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetBatchAccount", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving batch account %s, subscription %s, resource group %s", batchAccountName, subscriptionID, integrationTestResourceGroup) batchWrapper := manual.NewBatchAccount( clients.NewBatchAccountsClient(batchClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := batchWrapper.Scopes()[0] batchAdapter := sources.WrapperToAdapter(batchWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := batchAdapter.Get(ctx, scope, batchAccountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != batchAccountName { t.Fatalf("Expected unique attribute value to be %s, got %s", batchAccountName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.BatchBatchAccount.String() { t.Fatalf("Expected type %s, got %s", azureshared.BatchBatchAccount, sdpItem.GetType()) } log.Printf("Successfully retrieved batch account %s", batchAccountName) }) t.Run("ListBatchAccounts", func(t *testing.T) { ctx := t.Context() log.Printf("Listing batch accounts in resource group %s", integrationTestResourceGroup) batchWrapper := manual.NewBatchAccount( clients.NewBatchAccountsClient(batchClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := batchWrapper.Scopes()[0] batchAdapter := sources.WrapperToAdapter(batchWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list listable, ok := batchAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list batch accounts: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one batch account, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == batchAccountName { found = true if item.GetType() != azureshared.BatchBatchAccount.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchAccount, item.GetType()) } break } } if !found { t.Fatalf("Expected to find batch account %s in the list results", batchAccountName) } log.Printf("Found %d batch accounts in list results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for batch account %s", batchAccountName) batchWrapper := manual.NewBatchAccount( clients.NewBatchAccountsClient(batchClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := batchWrapper.Scopes()[0] batchAdapter := sources.WrapperToAdapter(batchWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := batchAdapter.Get(ctx, scope, batchAccountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected linked item types expectedLinkedTypes := map[string]bool{ azureshared.StorageAccount.String(): false, // External resource azureshared.BatchBatchApplication.String(): false, // Child resource azureshared.BatchBatchPool.String(): false, // Child resource azureshared.BatchBatchCertificate.String(): false, // Child resource azureshared.BatchBatchPrivateEndpointConnection.String(): false, // Child resource azureshared.BatchBatchPrivateLinkResource.String(): false, // Child resource azureshared.BatchBatchDetector.String(): false, // Child resource } for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() if _, exists := expectedLinkedTypes[linkedType]; exists { expectedLinkedTypes[linkedType] = true // Verify the query method queryMethod := liq.GetQuery().GetMethod() if linkedType == azureshared.StorageAccount.String() { // External resources use GET if queryMethod != sdp.QueryMethod_GET { t.Errorf("Expected linked query method to be GET for %s, got %s", linkedType, queryMethod) } } else { // Child resources use SEARCH if queryMethod != sdp.QueryMethod_SEARCH { t.Errorf("Expected linked query method to be SEARCH for %s, got %s", linkedType, queryMethod) } } } } // Verify all expected linked types were found for linkedType, found := range expectedLinkedTypes { if !found { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } log.Printf("Verified %d linked item queries for batch account %s", len(linkedQueries), batchAccountName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete batch account err := deleteBatchAccount(ctx, batchClient, integrationTestResourceGroup, batchAccountName) if err != nil { t.Fatalf("Failed to delete batch account: %v", err) } // Delete storage account (function is defined in storage-blob-container_test.go) err = deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed to delete storage account: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // generateBatchAccountName generates a unique batch account name // Batch account names must be globally unique, 3-24 characters, lowercase alphanumeric func generateBatchAccountName(baseName string) string { // Ensure base name is lowercase and valid baseName = strings.ToLower(baseName) baseName = strings.ReplaceAll(baseName, "-", "") // Add random suffix to ensure uniqueness rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(os.Getpid()))) suffix := fmt.Sprintf("%04d", rng.Intn(10000)) name := baseName + suffix // Ensure length is within limits (3-24 chars) if len(name) > 24 { name = name[:24] } if len(name) < 3 { name = name + "000" // pad if too short } return name } // createBatchAccount creates an Azure Batch account (idempotent) func createBatchAccount(ctx context.Context, client *armbatch.AccountClient, resourceGroupName, accountName, location, storageAccountID string) error { // Check if batch account already exists _, err := client.Get(ctx, resourceGroupName, accountName, nil) if err == nil { log.Printf("Batch account %s already exists, skipping creation", accountName) return nil } // Create the batch account poller, err := client.BeginCreate(ctx, resourceGroupName, accountName, armbatch.AccountCreateParameters{ Location: new(location), Properties: &armbatch.AccountCreateProperties{ AutoStorage: &armbatch.AutoStorageBaseProperties{ StorageAccountID: new(storageAccountID), }, PoolAllocationMode: new(armbatch.PoolAllocationModeBatchService), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("batch-account"), }, }, nil) if err != nil { // Check if batch account already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusConflict { log.Printf("Batch account %s already exists (conflict), skipping creation", accountName) return nil } if respErr.ErrorCode == "SubscriptionQuotaExceeded" { return fmt.Errorf("%w: %s", errBatchQuotaExceeded, respErr.Error()) } } return fmt.Errorf("failed to begin creating batch account: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.ErrorCode == "SubscriptionQuotaExceeded" { return fmt.Errorf("%w: %s", errBatchQuotaExceeded, respErr.Error()) } return fmt.Errorf("failed to create batch account: %w", err) } // Verify the batch account was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("batch account created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != armbatch.ProvisioningStateSucceeded { return fmt.Errorf("batch account provisioning state is %s, expected %s", provisioningState, armbatch.ProvisioningStateSucceeded) } log.Printf("Batch account %s created successfully with provisioning state: %s", accountName, provisioningState) return nil } // waitForBatchAccountAvailable polls until the batch account is available via the Get API func waitForBatchAccountAvailable(ctx context.Context, client *armbatch.AccountClient, resourceGroupName, accountName string) error { maxAttempts := 20 pollInterval := 10 * time.Second log.Printf("Waiting for batch account %s to be available via API...", accountName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, accountName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Batch account %s not yet available (attempt %d/%d), waiting %v...", accountName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking batch account availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == armbatch.ProvisioningStateSucceeded { log.Printf("Batch account %s is available with provisioning state: %s", accountName, state) return nil } if state == armbatch.ProvisioningStateFailed { return fmt.Errorf("batch account provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Batch account %s provisioning state: %s (attempt %d/%d), waiting...", accountName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Batch account exists but no provisioning state - consider it available log.Printf("Batch account %s is available", accountName) return nil } return fmt.Errorf("timeout waiting for batch account %s to be available after %d attempts", accountName, maxAttempts) } // deleteBatchAccount deletes an Azure Batch account func deleteBatchAccount(ctx context.Context, client *armbatch.AccountClient, resourceGroupName, accountName string) error { log.Printf("Deleting batch account %s...", accountName) poller, err := client.BeginDelete(ctx, resourceGroupName, accountName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Batch account %s not found, skipping deletion", accountName) return nil } return fmt.Errorf("failed to begin deleting batch account: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete batch account: %w", err) } log.Printf("Batch account %s deleted successfully", accountName) return nil } ================================================ FILE: sources/azure/integration-tests/batch-batch-application-package_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestBatchAppPkgAccountName = "ovm-integ-test-sa-pkg" integrationTestBatchAppPkgBatchName = "ovm-integ-test-pkg" integrationTestBatchAppName = "ovm-integ-test-app" integrationTestBatchAppPkgVersion = "1.0" ) func TestBatchApplicationPackageIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } batchAccountClient, err := armbatch.NewAccountClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Batch Account client: %v", err) } batchAppClient, err := armbatch.NewApplicationClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Batch Application client: %v", err) } batchAppPkgClient, err := armbatch.NewApplicationPackageClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Batch Application Package client: %v", err) } storageAccountName := generateStorageAccountName(integrationTestBatchAppPkgAccountName) batchAccountName := generateBatchAccountName(integrationTestBatchAppPkgBatchName) setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account: %v", err) } saResp, err := saClient.GetProperties(ctx, integrationTestResourceGroup, storageAccountName, nil) if err != nil { t.Fatalf("Failed to get storage account properties: %v", err) } storageAccountID := *saResp.ID err = createBatchAccount(ctx, batchAccountClient, integrationTestResourceGroup, batchAccountName, integrationTestLocation, storageAccountID) if err != nil { if errors.Is(err, errBatchQuotaExceeded) { t.Skipf("Skipping Batch application package integration test due to Azure subscription quota: %v", err) } t.Fatalf("Failed to create batch account: %v", err) } err = waitForBatchAccountAvailable(ctx, batchAccountClient, integrationTestResourceGroup, batchAccountName) if err != nil { t.Fatalf("Failed waiting for batch account: %v", err) } err = createBatchApplication(ctx, batchAppClient, integrationTestResourceGroup, batchAccountName, integrationTestBatchAppName) if err != nil { t.Fatalf("Failed to create batch application: %v", err) } err = createBatchApplicationPackage(ctx, batchAppPkgClient, integrationTestResourceGroup, batchAccountName, integrationTestBatchAppName, integrationTestBatchAppPkgVersion) if err != nil { t.Fatalf("Failed to create batch application package: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetApplicationPackage", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewBatchBatchApplicationPackage( clients.NewBatchApplicationPackagesClient(batchAppPkgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(batchAccountName, integrationTestBatchAppName, integrationTestBatchAppPkgVersion) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } expectedUnique := shared.CompositeLookupKey(batchAccountName, integrationTestBatchAppName, integrationTestBatchAppPkgVersion) uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, uniqueAttrValue) } log.Printf("Successfully retrieved application package %s", integrationTestBatchAppPkgVersion) }) t.Run("SearchApplicationPackages", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewBatchBatchApplicationPackage( clients.NewBatchApplicationPackagesClient(batchAppPkgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := shared.CompositeLookupKey(batchAccountName, integrationTestBatchAppName) sdpItems, err := searchable.Search(ctx, scope, searchQuery, true) if err != nil { t.Fatalf("Failed to search application packages: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one application package, got %d", len(sdpItems)) } expectedUnique := shared.CompositeLookupKey(batchAccountName, integrationTestBatchAppName, integrationTestBatchAppPkgVersion) var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, getErr := item.GetAttributes().Get(uniqueAttrKey); getErr == nil && v == expectedUnique { found = true break } } if !found { t.Fatalf("Expected to find application package %s in search results", integrationTestBatchAppPkgVersion) } log.Printf("Found %d application packages in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewBatchBatchApplicationPackage( clients.NewBatchApplicationPackagesClient(batchAppPkgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(batchAccountName, integrationTestBatchAppName, integrationTestBatchAppPkgVersion) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } for _, liq := range linkedQueries { q := liq.GetQuery() if q.GetType() == "" { t.Error("Linked item query has empty Type") } if q.GetQuery() == "" { t.Error("Linked item query has empty Query") } if q.GetScope() == "" { t.Error("Linked item query has empty Scope") } if q.GetMethod() != sdp.QueryMethod_GET && q.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has invalid Method: %s", q.GetMethod()) } } // Verify parent application link exists var hasAppLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.BatchBatchApplication.String() { hasAppLink = true break } } if !hasAppLink { t.Error("Expected linked query to parent BatchBatchApplication, but didn't find one") } // Verify parent account link exists var hasAccountLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.BatchBatchAccount.String() { hasAccountLink = true break } } if !hasAccountLink { t.Error("Expected linked query to parent BatchBatchAccount, but didn't find one") } log.Printf("Verified %d linked item queries", len(linkedQueries)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewBatchBatchApplicationPackage( clients.NewBatchApplicationPackagesClient(batchAppPkgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(batchAccountName, integrationTestBatchAppName, integrationTestBatchAppPkgVersion) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.BatchBatchApplicationPackage.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchApplicationPackage.String(), sdpItem.GetType()) } expectedScope := subscriptionID + "." + integrationTestResourceGroup if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteBatchApplicationPackage(ctx, batchAppPkgClient, integrationTestResourceGroup, batchAccountName, integrationTestBatchAppName, integrationTestBatchAppPkgVersion) if err != nil { t.Logf("Warning: failed to delete application package: %v", err) } err = deleteBatchApplication(ctx, batchAppClient, integrationTestResourceGroup, batchAccountName, integrationTestBatchAppName) if err != nil { t.Logf("Warning: failed to delete batch application: %v", err) } err = deleteBatchAccount(ctx, batchAccountClient, integrationTestResourceGroup, batchAccountName) if err != nil { t.Logf("Warning: failed to delete batch account: %v", err) } err = deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Logf("Warning: failed to delete storage account: %v", err) } }) } func createBatchApplication(ctx context.Context, client *armbatch.ApplicationClient, resourceGroupName, accountName, applicationName string) error { _, err := client.Get(ctx, resourceGroupName, accountName, applicationName, nil) if err == nil { log.Printf("Batch application %s already exists, skipping creation", applicationName) return nil } allowUpdates := true _, err = client.Create(ctx, resourceGroupName, accountName, applicationName, armbatch.Application{ Properties: &armbatch.ApplicationProperties{ DisplayName: new("Integration Test Application"), AllowUpdates: &allowUpdates, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Batch application %s already exists (conflict), skipping creation", applicationName) return nil } return fmt.Errorf("failed to create batch application: %w", err) } log.Printf("Batch application %s created successfully", applicationName) return nil } func createBatchApplicationPackage(ctx context.Context, client *armbatch.ApplicationPackageClient, resourceGroupName, accountName, applicationName, versionName string) error { _, err := client.Get(ctx, resourceGroupName, accountName, applicationName, versionName, nil) if err == nil { log.Printf("Batch application package %s already exists, skipping creation", versionName) return nil } _, err = client.Create(ctx, resourceGroupName, accountName, applicationName, versionName, armbatch.ApplicationPackage{}, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Batch application package %s already exists (conflict), skipping creation", versionName) return nil } return fmt.Errorf("failed to create batch application package: %w", err) } log.Printf("Batch application package %s created successfully", versionName) // Wait briefly for the package to become available maxAttempts := 10 for attempt := 1; attempt <= maxAttempts; attempt++ { _, getErr := client.Get(ctx, resourceGroupName, accountName, applicationName, versionName, nil) if getErr == nil { return nil } time.Sleep(2 * time.Second) } return nil } func deleteBatchApplicationPackage(ctx context.Context, client *armbatch.ApplicationPackageClient, resourceGroupName, accountName, applicationName, versionName string) error { _, err := client.Delete(ctx, resourceGroupName, accountName, applicationName, versionName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Batch application package %s not found, skipping deletion", versionName) return nil } return fmt.Errorf("failed to delete batch application package: %w", err) } log.Printf("Batch application package %s deleted successfully", versionName) return nil } func deleteBatchApplication(ctx context.Context, client *armbatch.ApplicationClient, resourceGroupName, accountName, applicationName string) error { _, err := client.Delete(ctx, resourceGroupName, accountName, applicationName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Batch application %s not found, skipping deletion", applicationName) return nil } return fmt.Errorf("failed to delete batch application: %w", err) } log.Printf("Batch application %s deleted successfully", applicationName) return nil } ================================================ FILE: sources/azure/integration-tests/batch-private-endpoint-connection_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestBatchPECAccountName = "ovm-integ-test-bpec" integrationTestBatchPECSAName = "ovm-integ-test-sa-bpec" integrationTestBatchPECVNetName = "ovm-integ-test-vnet-bpec" integrationTestBatchPECSubnetName = "ovm-integ-test-subnet-bpec" integrationTestBatchPECPEName = "ovm-integ-test-pe-bpec" ) func TestBatchPrivateEndpointConnectionIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } batchClient, err := armbatch.NewAccountClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Batch Account client: %v", err) } pecClient, err := armbatch.NewPrivateEndpointConnectionClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Batch Private Endpoint Connection client: %v", err) } saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } peClient, err := armnetwork.NewPrivateEndpointsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Private Endpoints client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } batchAccountName := generateBatchAccountName(integrationTestBatchPECAccountName) storageAccountName := generateStorageAccountName(integrationTestBatchPECSAName) vnetName := integrationTestBatchPECVNetName subnetName := integrationTestBatchPECSubnetName peName := integrationTestBatchPECPEName setupCompleted := false var privateEndpointConnectionName string t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account to be available: %v", err) } saResp, err := saClient.GetProperties(ctx, integrationTestResourceGroup, storageAccountName, nil) if err != nil { t.Fatalf("Failed to get storage account properties: %v", err) } storageAccountID := *saResp.ID err = createBatchAccountWithPrivateEndpointPolicy(ctx, batchClient, integrationTestResourceGroup, batchAccountName, integrationTestLocation, storageAccountID) if err != nil { if errors.Is(err, errBatchQuotaExceeded) { t.Skipf("Skipping Batch private endpoint connection integration test due to Azure subscription quota: %v", err) } t.Fatalf("Failed to create batch account: %v", err) } err = waitForBatchAccountAvailable(ctx, batchClient, integrationTestResourceGroup, batchAccountName) if err != nil { t.Fatalf("Failed waiting for batch account to be available: %v", err) } err = createVNetForBatchPEC(ctx, vnetClient, integrationTestResourceGroup, vnetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create VNet: %v", err) } err = createSubnetForBatchPEC(ctx, subnetClient, integrationTestResourceGroup, vnetName, subnetName) if err != nil { t.Fatalf("Failed to create subnet: %v", err) } batchResp, err := batchClient.Get(ctx, integrationTestResourceGroup, batchAccountName, nil) if err != nil { t.Fatalf("Failed to get batch account: %v", err) } batchAccountID := *batchResp.ID err = createPrivateEndpointForBatch(ctx, peClient, integrationTestResourceGroup, peName, integrationTestLocation, batchAccountID, vnetName, subnetName) if err != nil { t.Fatalf("Failed to create private endpoint: %v", err) } privateEndpointConnectionName, err = waitForBatchPrivateEndpointConnection(ctx, pecClient, integrationTestResourceGroup, batchAccountName) if err != nil { t.Fatalf("Failed waiting for private endpoint connection: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetPrivateEndpointConnection", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving batch private endpoint connection %s in account %s", privateEndpointConnectionName, batchAccountName) pecWrapper := manual.NewBatchPrivateEndpointConnection( clients.NewBatchPrivateEndpointConnectionClient(pecClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pecWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(pecWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(batchAccountName, privateEndpointConnectionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.BatchBatchPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchPrivateEndpointConnection, sdpItem.GetType()) } expectedUniqueAttr := shared.CompositeLookupKey(batchAccountName, privateEndpointConnectionName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttr { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttr, sdpItem.UniqueAttributeValue()) } log.Printf("Successfully retrieved private endpoint connection %s", privateEndpointConnectionName) }) t.Run("SearchPrivateEndpointConnections", func(t *testing.T) { ctx := t.Context() log.Printf("Searching private endpoint connections in batch account %s", batchAccountName) pecWrapper := manual.NewBatchPrivateEndpointConnection( clients.NewBatchPrivateEndpointConnectionClient(pecClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pecWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(pecWrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, batchAccountName, true) if err != nil { t.Fatalf("Failed to search private endpoint connections: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one private endpoint connection, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == shared.CompositeLookupKey(batchAccountName, privateEndpointConnectionName) { found = true break } } if !found { t.Fatalf("Expected to find private endpoint connection %s in the search results", privateEndpointConnectionName) } log.Printf("Found %d private endpoint connections in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for private endpoint connection %s", privateEndpointConnectionName) pecWrapper := manual.NewBatchPrivateEndpointConnection( clients.NewBatchPrivateEndpointConnectionClient(pecClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pecWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(pecWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(batchAccountName, privateEndpointConnectionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } for _, liq := range linkedQueries { query := liq.GetQuery() if query.GetType() == "" { t.Error("LinkedItemQuery has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("LinkedItemQuery has invalid Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("LinkedItemQuery has empty Query") } if query.GetScope() == "" { t.Error("LinkedItemQuery has empty Scope") } } var hasBatchAccountLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.BatchBatchAccount.String() { hasBatchAccountLink = true if liq.GetQuery().GetQuery() != batchAccountName { t.Errorf("Expected linked query to batch account %s, got %s", batchAccountName, liq.GetQuery().GetQuery()) } break } } if !hasBatchAccountLink { t.Error("Expected linked query to batch account, but didn't find one") } log.Printf("Verified %d linked item queries for private endpoint connection %s", len(linkedQueries), privateEndpointConnectionName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() pecWrapper := manual.NewBatchPrivateEndpointConnection( clients.NewBatchPrivateEndpointConnectionClient(pecClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pecWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(pecWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(batchAccountName, privateEndpointConnectionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.BatchBatchPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchPrivateEndpointConnection, sdpItem.GetType()) } expectedScope := subscriptionID + "." + integrationTestResourceGroup if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deletePrivateEndpointForBatch(ctx, peClient, integrationTestResourceGroup, peName) if err != nil { t.Errorf("Failed to delete private endpoint: %v", err) } err = deleteBatchAccount(ctx, batchClient, integrationTestResourceGroup, batchAccountName) if err != nil { t.Errorf("Failed to delete batch account: %v", err) } err = deleteSubnetForBatchPEC(ctx, subnetClient, integrationTestResourceGroup, vnetName, subnetName) if err != nil { t.Errorf("Failed to delete subnet: %v", err) } err = deleteVNetForBatchPEC(ctx, vnetClient, integrationTestResourceGroup, vnetName) if err != nil { t.Errorf("Failed to delete VNet: %v", err) } err = deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Errorf("Failed to delete storage account: %v", err) } }) } func createBatchAccountWithPrivateEndpointPolicy(ctx context.Context, client *armbatch.AccountClient, resourceGroupName, accountName, location, storageAccountID string) error { _, err := client.Get(ctx, resourceGroupName, accountName, nil) if err == nil { log.Printf("Batch account %s already exists, skipping creation", accountName) return nil } publicNetworkDisabled := armbatch.PublicNetworkAccessTypeDisabled poller, err := client.BeginCreate(ctx, resourceGroupName, accountName, armbatch.AccountCreateParameters{ Location: new(location), Properties: &armbatch.AccountCreateProperties{ AutoStorage: &armbatch.AutoStorageBaseProperties{ StorageAccountID: new(storageAccountID), }, PoolAllocationMode: new(armbatch.PoolAllocationModeBatchService), PublicNetworkAccess: &publicNetworkDisabled, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("batch-private-endpoint-connection"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusConflict { log.Printf("Batch account %s already exists (conflict), skipping creation", accountName) return nil } if respErr.ErrorCode == "SubscriptionQuotaExceeded" { return fmt.Errorf("%w: %s", errBatchQuotaExceeded, respErr.Error()) } } return fmt.Errorf("failed to begin creating batch account: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.ErrorCode == "SubscriptionQuotaExceeded" { return fmt.Errorf("%w: %s", errBatchQuotaExceeded, respErr.Error()) } return fmt.Errorf("failed to create batch account: %w", err) } if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("batch account created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != armbatch.ProvisioningStateSucceeded { return fmt.Errorf("batch account provisioning state is %s, expected %s", provisioningState, armbatch.ProvisioningStateSucceeded) } log.Printf("Batch account %s created successfully with private endpoint support", accountName) return nil } func createVNetForBatchPEC(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("VNet %s already exists, skipping creation", vnetName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.0.0.0/16")}, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("VNet %s already exists (conflict), skipping creation", vnetName) return nil } return fmt.Errorf("failed to begin creating VNet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create VNet: %w", err) } log.Printf("VNet %s created successfully", vnetName) return nil } func createSubnetForBatchPEC(ctx context.Context, client *armnetwork.SubnetsClient, resourceGroupName, vnetName, subnetName string) error { _, err := client.Get(ctx, resourceGroupName, vnetName, subnetName, nil) if err == nil { log.Printf("Subnet %s already exists, skipping creation", subnetName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, subnetName, armnetwork.Subnet{ Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.0.1.0/24"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Subnet %s already exists (conflict), skipping creation", subnetName) return nil } return fmt.Errorf("failed to begin creating subnet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create subnet: %w", err) } log.Printf("Subnet %s created successfully", subnetName) return nil } func createPrivateEndpointForBatch(ctx context.Context, client *armnetwork.PrivateEndpointsClient, resourceGroupName, peName, location, batchAccountID, vnetName, subnetName string) error { _, err := client.Get(ctx, resourceGroupName, peName, nil) if err == nil { log.Printf("Private endpoint %s already exists, skipping creation", peName) return nil } subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s/subnets/%s", os.Getenv("AZURE_SUBSCRIPTION_ID"), resourceGroupName, vnetName, subnetName) poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, peName, armnetwork.PrivateEndpoint{ Location: new(location), Properties: &armnetwork.PrivateEndpointProperties{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateLinkServiceConnections: []*armnetwork.PrivateLinkServiceConnection{ { Name: new(peName + "-connection"), Properties: &armnetwork.PrivateLinkServiceConnectionProperties{ PrivateLinkServiceID: new(batchAccountID), GroupIDs: []*string{new("batchAccount")}, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Private endpoint %s already exists (conflict), skipping creation", peName) return nil } return fmt.Errorf("failed to begin creating private endpoint: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create private endpoint: %w", err) } log.Printf("Private endpoint %s created successfully", peName) return nil } func waitForBatchPrivateEndpointConnection(ctx context.Context, client *armbatch.PrivateEndpointConnectionClient, resourceGroupName, accountName string) (string, error) { maxAttempts := 30 pollInterval := 10 * time.Second log.Printf("Waiting for private endpoint connection on batch account %s...", accountName) for attempt := 1; attempt <= maxAttempts; attempt++ { pager := client.NewListByBatchAccountPager(resourceGroupName, accountName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { log.Printf("Error listing private endpoint connections (attempt %d/%d): %v", attempt, maxAttempts, err) break } for _, conn := range page.Value { if conn != nil && conn.Name != nil { log.Printf("Found private endpoint connection: %s", *conn.Name) return *conn.Name, nil } } } log.Printf("No private endpoint connections found yet (attempt %d/%d), waiting...", attempt, maxAttempts) time.Sleep(pollInterval) } return "", fmt.Errorf("timeout waiting for private endpoint connection on batch account %s", accountName) } func deletePrivateEndpointForBatch(ctx context.Context, client *armnetwork.PrivateEndpointsClient, resourceGroupName, peName string) error { log.Printf("Deleting private endpoint %s...", peName) poller, err := client.BeginDelete(ctx, resourceGroupName, peName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Private endpoint %s not found, skipping deletion", peName) return nil } return fmt.Errorf("failed to begin deleting private endpoint: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete private endpoint: %w", err) } log.Printf("Private endpoint %s deleted successfully", peName) return nil } func deleteSubnetForBatchPEC(ctx context.Context, client *armnetwork.SubnetsClient, resourceGroupName, vnetName, subnetName string) error { log.Printf("Deleting subnet %s...", subnetName) poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, subnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Subnet %s not found, skipping deletion", subnetName) return nil } return fmt.Errorf("failed to begin deleting subnet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete subnet: %w", err) } log.Printf("Subnet %s deleted successfully", subnetName) return nil } func deleteVNetForBatchPEC(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { log.Printf("Deleting VNet %s...", vnetName) poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("VNet %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting VNet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete VNet: %w", err) } log.Printf("VNet %s deleted successfully", vnetName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-availability-set_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestAvailabilitySetName = "ovm-integ-test-avset" integrationTestVMForAVSetName = "ovm-integ-test-vm-avset" integrationTestNICForAVSetName = "ovm-integ-test-nic-avset" integrationTestVNetForAVSetName = "ovm-integ-test-vnet-avset" integrationTestSubnetForAVSetName = "default" ) func TestComputeAvailabilitySetIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients avSetClient, err := armcompute.NewAvailabilitySetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Availability Sets client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vmClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Machines client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } nicClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Interfaces client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create availability set err = createAvailabilitySet(ctx, avSetClient, integrationTestResourceGroup, integrationTestAvailabilitySetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create availability set: %v", err) } // Wait for availability set to be fully available via the API err = waitForAvailabilitySetAvailable(ctx, avSetClient, integrationTestResourceGroup, integrationTestAvailabilitySetName) if err != nil { t.Fatalf("Failed waiting for availability set to be available: %v", err) } // Create virtual network for VM err = createVirtualNetworkForAVSet(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetForAVSetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for NIC creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestVNetForAVSetName, integrationTestSubnetForAVSetName, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create network interface err = createNetworkInterfaceForAVSet(ctx, nicClient, integrationTestResourceGroup, integrationTestNICForAVSetName, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create network interface: %v", err) } // Get NIC ID and Availability Set ID for VM creation nicResp, err := nicClient.Get(ctx, integrationTestResourceGroup, integrationTestNICForAVSetName, nil) if err != nil { t.Fatalf("Failed to get network interface: %v", err) } avSetResp, err := avSetClient.Get(ctx, integrationTestResourceGroup, integrationTestAvailabilitySetName, nil) if err != nil { t.Fatalf("Failed to get availability set: %v", err) } // Create virtual machine with availability set err = createVirtualMachineWithAvailabilitySet(ctx, vmClient, integrationTestResourceGroup, integrationTestVMForAVSetName, integrationTestLocation, *nicResp.ID, *avSetResp.ID) if err != nil { t.Fatalf("Failed to create virtual machine: %v", err) } // Wait for VM to be fully available via the API err = waitForVMAvailable(ctx, vmClient, integrationTestResourceGroup, integrationTestVMForAVSetName) if err != nil { t.Fatalf("Failed waiting for VM to be available: %v", err) } // Wait a bit for the availability set to reflect the VM association time.Sleep(10 * time.Second) setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } // Check if availability set exists - if Setup failed, skip Run tests ctx := t.Context() _, err := avSetClient.Get(ctx, integrationTestResourceGroup, integrationTestAvailabilitySetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { t.Skipf("Availability set %s does not exist - Setup may have failed. Skipping Run tests.", integrationTestAvailabilitySetName) } } t.Run("GetAvailabilitySet", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving availability set %s in subscription %s, resource group %s", integrationTestAvailabilitySetName, subscriptionID, integrationTestResourceGroup) avSetWrapper := manual.NewComputeAvailabilitySet( clients.NewAvailabilitySetsClient(avSetClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := avSetWrapper.Scopes()[0] avSetAdapter := sources.WrapperToAdapter(avSetWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := avSetAdapter.Get(ctx, scope, integrationTestAvailabilitySetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestAvailabilitySetName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestAvailabilitySetName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.ComputeAvailabilitySet.String() { t.Fatalf("Expected type %s, got %s", azureshared.ComputeAvailabilitySet, sdpItem.GetType()) } log.Printf("Successfully retrieved availability set %s", integrationTestAvailabilitySetName) }) t.Run("ListAvailabilitySets", func(t *testing.T) { ctx := t.Context() log.Printf("Listing availability sets in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) avSetWrapper := manual.NewComputeAvailabilitySet( clients.NewAvailabilitySetsClient(avSetClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := avSetWrapper.Scopes()[0] avSetAdapter := sources.WrapperToAdapter(avSetWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := avSetAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list availability sets: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one availability set, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestAvailabilitySetName { found = true if item.GetType() != azureshared.ComputeAvailabilitySet.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeAvailabilitySet, item.GetType()) } break } } if !found { t.Fatalf("Expected to find availability set %s in the list of availability sets", integrationTestAvailabilitySetName) } log.Printf("Found %d availability sets in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for availability set %s", integrationTestAvailabilitySetName) avSetWrapper := manual.NewComputeAvailabilitySet( clients.NewAvailabilitySetsClient(avSetClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := avSetWrapper.Scopes()[0] avSetAdapter := sources.WrapperToAdapter(avSetWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := avSetAdapter.Get(ctx, scope, integrationTestAvailabilitySetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasVMLink bool for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.ComputeVirtualMachine.String(): hasVMLink = true // Verify VM link properties if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected VM link method to be GET, got %s", liq.GetQuery().GetMethod()) } case azureshared.ComputeProximityPlacementGroup.String(): // PPG may or may not be present depending on availability set setup // Verify PPG link properties if present if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected PPG link method to be GET, got %s", liq.GetQuery().GetMethod()) } } } // VM link should be present if we created a VM with this availability set if !hasVMLink { t.Logf("No VM link found - this is expected if VM creation failed or VM is not yet associated") } log.Printf("Verified %d linked item queries for availability set %s", len(linkedQueries), integrationTestAvailabilitySetName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for availability set %s", integrationTestAvailabilitySetName) avSetWrapper := manual.NewComputeAvailabilitySet( clients.NewAvailabilitySetsClient(avSetClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := avSetWrapper.Scopes()[0] avSetAdapter := sources.WrapperToAdapter(avSetWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := avSetAdapter.Get(ctx, scope, integrationTestAvailabilitySetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.ComputeAvailabilitySet.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeAvailabilitySet, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for availability set %s", integrationTestAvailabilitySetName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete VM first (it must be deleted before availability set can be deleted) err := deleteVirtualMachine(ctx, vmClient, integrationTestResourceGroup, integrationTestVMForAVSetName) if err != nil { t.Fatalf("Failed to delete virtual machine: %v", err) } // Delete NIC err = deleteNetworkInterface(ctx, nicClient, integrationTestResourceGroup, integrationTestNICForAVSetName) if err != nil { t.Fatalf("Failed to delete network interface: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetwork(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetForAVSetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } // Delete availability set err = deleteAvailabilitySet(ctx, avSetClient, integrationTestResourceGroup, integrationTestAvailabilitySetName) if err != nil { t.Fatalf("Failed to delete availability set: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createAvailabilitySet creates an Azure availability set (idempotent) func createAvailabilitySet(ctx context.Context, client *armcompute.AvailabilitySetsClient, resourceGroupName, avSetName, location string) error { // Check if availability set already exists _, err := client.Get(ctx, resourceGroupName, avSetName, nil) if err == nil { log.Printf("Availability set %s already exists, skipping creation", avSetName) return nil } // Create the availability set resp, err := client.CreateOrUpdate(ctx, resourceGroupName, avSetName, armcompute.AvailabilitySet{ Location: new(location), SKU: &armcompute.SKU{ Name: new("Aligned"), }, Properties: &armcompute.AvailabilitySetProperties{ PlatformFaultDomainCount: new(int32(2)), PlatformUpdateDomainCount: new(int32(2)), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-availability-set"), }, }, nil) if err != nil { // Check if availability set already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Availability set %s already exists (conflict), skipping creation", avSetName) return nil } return fmt.Errorf("failed to create availability set: %w", err) } // Verify the availability set was created successfully if resp.Name == nil { return fmt.Errorf("availability set created but name is nil") } log.Printf("Availability set %s created successfully", avSetName) return nil } // waitForAvailabilitySetAvailable polls until the availability set is available via the Get API func waitForAvailabilitySetAvailable(ctx context.Context, client *armcompute.AvailabilitySetsClient, resourceGroupName, avSetName string) error { maxAttempts := defaultMaxPollAttempts pollInterval := defaultPollInterval log.Printf("Waiting for availability set %s to be available via API...", avSetName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, avSetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Availability set %s not yet available (attempt %d/%d), waiting %v...", avSetName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking availability set availability: %w", err) } // Availability set exists - consider it available if resp.Name != nil { log.Printf("Availability set %s is available", avSetName) return nil } // Wait and retry if attempt < maxAttempts { log.Printf("Availability set %s not yet ready (attempt %d/%d), waiting %v...", avSetName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } } return fmt.Errorf("timeout waiting for availability set %s to be available after %d attempts", avSetName, maxAttempts) } // deleteAvailabilitySet deletes an Azure availability set func deleteAvailabilitySet(ctx context.Context, client *armcompute.AvailabilitySetsClient, resourceGroupName, avSetName string) error { _, err := client.Delete(ctx, resourceGroupName, avSetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Availability set %s not found, skipping deletion", avSetName) return nil } return fmt.Errorf("failed to delete availability set: %w", err) } log.Printf("Availability set %s deleted successfully", avSetName) return nil } // createVirtualNetworkForAVSet creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetworkForAVSet(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.2.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetForAVSetName), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.2.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // createNetworkInterfaceForAVSet creates an Azure network interface (idempotent) func createNetworkInterfaceForAVSet(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName, location, subnetID string) error { // Check if NIC already exists _, err := client.Get(ctx, resourceGroupName, nicName, nil) if err == nil { log.Printf("Network interface %s already exists, skipping creation", nicName) return nil } // Create the NIC poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, nicName, armnetwork.Interface{ Location: new(location), Properties: &armnetwork.InterfacePropertiesFormat{ IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new("ipconfig1"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create network interface: %w", err) } log.Printf("Network interface %s created successfully", nicName) return nil } // createVirtualMachineWithAvailabilitySet creates an Azure virtual machine with an availability set (idempotent) func createVirtualMachineWithAvailabilitySet(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID, availabilitySetID string) error { return createVirtualMachineWithAvailabilitySetWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, availabilitySetID, 0) } func createVirtualMachineWithAvailabilitySetWithRemediation(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID, availabilitySetID string, remediationAttempt int) error { // Check if VM already exists existingVM, err := client.Get(ctx, resourceGroupName, vmName, nil) if err == nil { // VM exists, check its state if existingVM.Properties != nil && existingVM.Properties.ProvisioningState != nil { state := *existingVM.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Virtual machine %s already exists with state %s, skipping creation", vmName, state) return nil } log.Printf("Virtual machine %s exists but in state %s, will wait for it", vmName, state) } else { log.Printf("Virtual machine %s already exists, skipping creation", vmName) return nil } } // Create the VM poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmName, armcompute.VirtualMachine{ Location: new(location), Properties: &armcompute.VirtualMachineProperties{ HardwareProfile: &armcompute.HardwareProfile{ VMSize: new(armcompute.VirtualMachineSizeTypes("Standard_D2s_v3")), }, StorageProfile: &armcompute.StorageProfile{ ImageReference: &armcompute.ImageReference{ Publisher: new("Canonical"), Offer: new("0001-com-ubuntu-server-jammy"), SKU: new("22_04-lts"), Version: new("latest"), }, OSDisk: &armcompute.OSDisk{ Name: new(fmt.Sprintf("%s-osdisk", vmName)), CreateOption: new(armcompute.DiskCreateOptionTypesFromImage), ManagedDisk: &armcompute.ManagedDiskParameters{ StorageAccountType: new(armcompute.StorageAccountTypesStandardLRS), }, DeleteOption: new(armcompute.DiskDeleteOptionTypesDelete), }, }, OSProfile: &armcompute.OSProfile{ ComputerName: new(vmName), AdminUsername: new("azureuser"), // Use password authentication for integration tests (simpler than SSH keys) AdminPassword: new("OvmIntegTest2024!"), LinuxConfiguration: &armcompute.LinuxConfiguration{ DisablePasswordAuthentication: new(false), }, }, NetworkProfile: &armcompute.NetworkProfile{ NetworkInterfaces: []*armcompute.NetworkInterfaceReference{ { ID: new(nicID), Properties: &armcompute.NetworkInterfaceReferenceProperties{ Primary: new(true), }, }, }, }, AvailabilitySet: &armcompute.SubResource{ ID: new(availabilitySetID), }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-availability-set"), }, }, nil) if err != nil { // Check if VM already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { existing, getErr := client.Get(ctx, resourceGroupName, vmName, nil) if getErr == nil { if existing.Properties != nil && existing.Properties.ProvisioningState != nil { log.Printf("Virtual machine %s already exists (conflict) with state %s, skipping creation", vmName, *existing.Properties.ProvisioningState) } else { log.Printf("Virtual machine %s already exists (conflict), skipping creation", vmName) } return nil } var getRespErr *azcore.ResponseError if errors.As(getErr, &getRespErr) && getRespErr.StatusCode == http.StatusNotFound { if remediationAttempt >= 1 { return fmt.Errorf("vm %s still in ghost conflict state after remediation (resourceGroup=%s): %w", vmName, resourceGroupName, err) } log.Printf("Detected ghost VM conflict for availability-set test VM %s in %s, attempting automatic remediation", vmName, resourceGroupName) if deleteErr := deleteVirtualMachine(ctx, client, resourceGroupName, vmName); deleteErr != nil { return fmt.Errorf("failed to remediate ghost VM %s before retry: %w", vmName, deleteErr) } time.Sleep(20 * time.Second) return createVirtualMachineWithAvailabilitySetWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, availabilitySetID, remediationAttempt+1) } return fmt.Errorf("vm creation conflict for %s and failed to verify existing VM: %w", vmName, getErr) } return fmt.Errorf("failed to begin creating virtual machine: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual machine: %w", err) } // Verify the VM was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("VM created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("VM provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Virtual machine %s created successfully with provisioning state: %s", vmName, provisioningState) return nil } ================================================ FILE: sources/azure/integration-tests/compute-capacity-reservation-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestCapacityReservationGroupName = "ovm-integ-test-capacity-reservation-group" ) func TestComputeCapacityReservationGroupIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } capacityReservationGroupsClient, err := armcompute.NewCapacityReservationGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Capacity Reservation Groups client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createCapacityReservationGroup(ctx, capacityReservationGroupsClient, integrationTestResourceGroup, integrationTestCapacityReservationGroupName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create capacity reservation group: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetCapacityReservationGroup", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving capacity reservation group %s in subscription %s, resource group %s", integrationTestCapacityReservationGroupName, subscriptionID, integrationTestResourceGroup) capacityReservationGroupWrapper := manual.NewComputeCapacityReservationGroup( clients.NewCapacityReservationGroupsClient(capacityReservationGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := capacityReservationGroupWrapper.Scopes()[0] capacityReservationGroupAdapter := sources.WrapperToAdapter(capacityReservationGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := capacityReservationGroupAdapter.Get(ctx, scope, integrationTestCapacityReservationGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestCapacityReservationGroupName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestCapacityReservationGroupName, uniqueAttrValue) } log.Printf("Successfully retrieved capacity reservation group %s", integrationTestCapacityReservationGroupName) }) t.Run("ListCapacityReservationGroups", func(t *testing.T) { ctx := t.Context() log.Printf("Listing capacity reservation groups in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) capacityReservationGroupWrapper := manual.NewComputeCapacityReservationGroup( clients.NewCapacityReservationGroupsClient(capacityReservationGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := capacityReservationGroupWrapper.Scopes()[0] capacityReservationGroupAdapter := sources.WrapperToAdapter(capacityReservationGroupWrapper, sdpcache.NewNoOpCache()) listable, ok := capacityReservationGroupAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list capacity reservation groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one capacity reservation group, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestCapacityReservationGroupName { found = true break } } if !found { t.Fatalf("Expected to find capacity reservation group %s in the list of capacity reservation groups", integrationTestCapacityReservationGroupName) } log.Printf("Found %d capacity reservation groups in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for capacity reservation group %s", integrationTestCapacityReservationGroupName) capacityReservationGroupWrapper := manual.NewComputeCapacityReservationGroup( clients.NewCapacityReservationGroupsClient(capacityReservationGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := capacityReservationGroupWrapper.Scopes()[0] capacityReservationGroupAdapter := sources.WrapperToAdapter(capacityReservationGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := capacityReservationGroupAdapter.Get(ctx, scope, integrationTestCapacityReservationGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeCapacityReservationGroup.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeCapacityReservationGroup.String(), sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for capacity reservation group %s", integrationTestCapacityReservationGroupName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for capacity reservation group %s", integrationTestCapacityReservationGroupName) capacityReservationGroupWrapper := manual.NewComputeCapacityReservationGroup( clients.NewCapacityReservationGroupsClient(capacityReservationGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := capacityReservationGroupWrapper.Scopes()[0] capacityReservationGroupAdapter := sources.WrapperToAdapter(capacityReservationGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := capacityReservationGroupAdapter.Get(ctx, scope, integrationTestCapacityReservationGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for capacity reservation group %s", len(linkedQueries), integrationTestCapacityReservationGroupName) // Capacity reservation group may have zero or more linked queries (capacity reservations, VMs) depending on configuration for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteCapacityReservationGroup(ctx, capacityReservationGroupsClient, integrationTestResourceGroup, integrationTestCapacityReservationGroupName) if err != nil { t.Fatalf("Failed to delete capacity reservation group: %v", err) } }) } // createCapacityReservationGroup creates an Azure capacity reservation group resource (idempotent). func createCapacityReservationGroup(ctx context.Context, client *armcompute.CapacityReservationGroupsClient, resourceGroupName, groupName, location string) error { _, err := client.Get(ctx, resourceGroupName, groupName, nil) if err == nil { log.Printf("Capacity reservation group %s already exists, skipping creation", groupName) return nil } var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("unexpected error checking capacity reservation group: %w", err) } _, err = client.CreateOrUpdate(ctx, resourceGroupName, groupName, armcompute.CapacityReservationGroup{ Location: new(location), Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-capacity-reservation-group"), }, }, nil) if err != nil { if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Capacity reservation group %s already exists (conflict), skipping creation", groupName) return nil } return fmt.Errorf("failed to create capacity reservation group: %w", err) } log.Printf("Capacity reservation group %s created successfully", groupName) return nil } // deleteCapacityReservationGroup deletes an Azure capacity reservation group resource. // Azure may return 202 Accepted for async delete; treat that as success. func deleteCapacityReservationGroup(ctx context.Context, client *armcompute.CapacityReservationGroupsClient, resourceGroupName, groupName string) error { _, err := client.Delete(ctx, resourceGroupName, groupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { switch respErr.StatusCode { case http.StatusNotFound: log.Printf("Capacity reservation group %s not found, skipping deletion", groupName) return nil case http.StatusAccepted: // Async delete accepted; resource deletion is in progress log.Printf("Capacity reservation group %s delete accepted (202), teardown complete", groupName) return nil } } return fmt.Errorf("failed to delete capacity reservation group: %w", err) } log.Printf("Capacity reservation group %s deleted successfully", groupName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-dedicated-host-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestDedicatedHostGroupName = "ovm-integ-test-dedicated-host-group" ) func TestComputeDedicatedHostGroupIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } dedicatedHostGroupsClient, err := armcompute.NewDedicatedHostGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Dedicated Host Groups client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createDedicatedHostGroup(ctx, dedicatedHostGroupsClient, integrationTestResourceGroup, integrationTestDedicatedHostGroupName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create dedicated host group: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetDedicatedHostGroup", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving dedicated host group %s in subscription %s, resource group %s", integrationTestDedicatedHostGroupName, subscriptionID, integrationTestResourceGroup) dedicatedHostGroupWrapper := manual.NewComputeDedicatedHostGroup( clients.NewDedicatedHostGroupsClient(dedicatedHostGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := dedicatedHostGroupWrapper.Scopes()[0] dedicatedHostGroupAdapter := sources.WrapperToAdapter(dedicatedHostGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := dedicatedHostGroupAdapter.Get(ctx, scope, integrationTestDedicatedHostGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestDedicatedHostGroupName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestDedicatedHostGroupName, uniqueAttrValue) } log.Printf("Successfully retrieved dedicated host group %s", integrationTestDedicatedHostGroupName) }) t.Run("ListDedicatedHostGroups", func(t *testing.T) { ctx := t.Context() log.Printf("Listing dedicated host groups in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) dedicatedHostGroupWrapper := manual.NewComputeDedicatedHostGroup( clients.NewDedicatedHostGroupsClient(dedicatedHostGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := dedicatedHostGroupWrapper.Scopes()[0] dedicatedHostGroupAdapter := sources.WrapperToAdapter(dedicatedHostGroupWrapper, sdpcache.NewNoOpCache()) listable, ok := dedicatedHostGroupAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list dedicated host groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one dedicated host group, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestDedicatedHostGroupName { found = true break } } if !found { t.Fatalf("Expected to find dedicated host group %s in the list of dedicated host groups", integrationTestDedicatedHostGroupName) } log.Printf("Found %d dedicated host groups in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for dedicated host group %s", integrationTestDedicatedHostGroupName) dedicatedHostGroupWrapper := manual.NewComputeDedicatedHostGroup( clients.NewDedicatedHostGroupsClient(dedicatedHostGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := dedicatedHostGroupWrapper.Scopes()[0] dedicatedHostGroupAdapter := sources.WrapperToAdapter(dedicatedHostGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := dedicatedHostGroupAdapter.Get(ctx, scope, integrationTestDedicatedHostGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDedicatedHostGroup.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeDedicatedHostGroup.String(), sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for dedicated host group %s", integrationTestDedicatedHostGroupName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for dedicated host group %s", integrationTestDedicatedHostGroupName) dedicatedHostGroupWrapper := manual.NewComputeDedicatedHostGroup( clients.NewDedicatedHostGroupsClient(dedicatedHostGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := dedicatedHostGroupWrapper.Scopes()[0] dedicatedHostGroupAdapter := sources.WrapperToAdapter(dedicatedHostGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := dedicatedHostGroupAdapter.Get(ctx, scope, integrationTestDedicatedHostGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for dedicated host group %s", len(linkedQueries), integrationTestDedicatedHostGroupName) // Dedicated host group may have zero or more linked queries (ComputeDedicatedHost) depending on whether hosts exist for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteDedicatedHostGroup(ctx, dedicatedHostGroupsClient, integrationTestResourceGroup, integrationTestDedicatedHostGroupName) if err != nil { t.Fatalf("Failed to delete dedicated host group: %v", err) } }) } // createDedicatedHostGroup creates an Azure dedicated host group resource (idempotent). func createDedicatedHostGroup(ctx context.Context, client *armcompute.DedicatedHostGroupsClient, resourceGroupName, hostGroupName, location string) error { _, err := client.Get(ctx, resourceGroupName, hostGroupName, nil) if err == nil { log.Printf("Dedicated host group %s already exists, skipping creation", hostGroupName) return nil } var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("unexpected error checking dedicated host group: %w", err) } _, err = client.CreateOrUpdate(ctx, resourceGroupName, hostGroupName, armcompute.DedicatedHostGroup{ Location: new(location), Properties: &armcompute.DedicatedHostGroupProperties{ PlatformFaultDomainCount: new(int32(1)), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-dedicated-host-group"), }, }, nil) if err != nil { if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Dedicated host group %s already exists (conflict), skipping creation", hostGroupName) return nil } return fmt.Errorf("failed to create dedicated host group: %w", err) } log.Printf("Dedicated host group %s created successfully", hostGroupName) return nil } // deleteDedicatedHostGroup deletes an Azure dedicated host group resource. func deleteDedicatedHostGroup(ctx context.Context, client *armcompute.DedicatedHostGroupsClient, resourceGroupName, hostGroupName string) error { _, err := client.Delete(ctx, resourceGroupName, hostGroupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Dedicated host group %s not found, skipping deletion", hostGroupName) return nil } return fmt.Errorf("failed to delete dedicated host group: %w", err) } log.Printf("Dedicated host group %s deleted successfully", hostGroupName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-disk-access_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestDiskAccessName = "ovm-integ-test-disk-access" ) func TestComputeDiskAccessIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } diskAccessClient, err := armcompute.NewDiskAccessesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Disk Accesses client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createDiskAccess(ctx, diskAccessClient, integrationTestResourceGroup, integrationTestDiskAccessName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create disk access: %v", err) } err = waitForDiskAccessAvailable(ctx, diskAccessClient, integrationTestResourceGroup, integrationTestDiskAccessName) if err != nil { t.Fatalf("Failed waiting for disk access to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetDiskAccess", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving disk access %s in subscription %s, resource group %s", integrationTestDiskAccessName, subscriptionID, integrationTestResourceGroup) diskAccessWrapper := manual.NewComputeDiskAccess( clients.NewDiskAccessesClient(diskAccessClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskAccessWrapper.Scopes()[0] diskAccessAdapter := sources.WrapperToAdapter(diskAccessWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := diskAccessAdapter.Get(ctx, scope, integrationTestDiskAccessName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestDiskAccessName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestDiskAccessName, uniqueAttrValue) } log.Printf("Successfully retrieved disk access %s", integrationTestDiskAccessName) }) t.Run("ListDiskAccesses", func(t *testing.T) { ctx := t.Context() log.Printf("Listing disk accesses in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) diskAccessWrapper := manual.NewComputeDiskAccess( clients.NewDiskAccessesClient(diskAccessClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskAccessWrapper.Scopes()[0] diskAccessAdapter := sources.WrapperToAdapter(diskAccessWrapper, sdpcache.NewNoOpCache()) listable, ok := diskAccessAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list disk accesses: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one disk access, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestDiskAccessName { found = true break } } if !found { t.Fatalf("Expected to find disk access %s in the list of disk accesses", integrationTestDiskAccessName) } log.Printf("Found %d disk accesses in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for disk access %s", integrationTestDiskAccessName) diskAccessWrapper := manual.NewComputeDiskAccess( clients.NewDiskAccessesClient(diskAccessClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskAccessWrapper.Scopes()[0] diskAccessAdapter := sources.WrapperToAdapter(diskAccessWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := diskAccessAdapter.Get(ctx, scope, integrationTestDiskAccessName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDiskAccess.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeDiskAccess.String(), sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for disk access %s", integrationTestDiskAccessName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for disk access %s", integrationTestDiskAccessName) diskAccessWrapper := manual.NewComputeDiskAccess( clients.NewDiskAccessesClient(diskAccessClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskAccessWrapper.Scopes()[0] diskAccessAdapter := sources.WrapperToAdapter(diskAccessWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := diskAccessAdapter.Get(ctx, scope, integrationTestDiskAccessName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for disk access %s", len(linkedQueries), integrationTestDiskAccessName) // Disk access always has at least one linked query: ComputeDiskAccessPrivateEndpointConnection (SEARCH) if len(linkedQueries) < 1 { t.Errorf("Expected at least one linked item query (private endpoint connection), got %d", len(linkedQueries)) } for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteDiskAccess(ctx, diskAccessClient, integrationTestResourceGroup, integrationTestDiskAccessName) if err != nil { t.Fatalf("Failed to delete disk access: %v", err) } }) } // createDiskAccess creates an Azure disk access resource (idempotent). func createDiskAccess(ctx context.Context, client *armcompute.DiskAccessesClient, resourceGroupName, diskAccessName, location string) error { existing, err := client.Get(ctx, resourceGroupName, diskAccessName, nil) if err == nil { if existing.Properties != nil && existing.Properties.ProvisioningState != nil { state := *existing.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Disk access %s already exists with state %s, skipping creation", diskAccessName, state) return nil } log.Printf("Disk access %s exists but in state %s, will wait for it", diskAccessName, state) } else { log.Printf("Disk access %s already exists, skipping creation", diskAccessName) return nil } } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, diskAccessName, armcompute.DiskAccess{ Location: new(location), Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-disk-access"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Disk access %s already exists (conflict), skipping creation", diskAccessName) return nil } return fmt.Errorf("failed to begin creating disk access: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create disk access: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state != "Succeeded" { return fmt.Errorf("disk access provisioning state is %s, expected Succeeded", state) } log.Printf("Disk access %s created successfully with provisioning state: %s", diskAccessName, state) } else { log.Printf("Disk access %s created successfully", diskAccessName) } return nil } // waitForDiskAccessAvailable polls until the disk access is available via the Get API. func waitForDiskAccessAvailable(ctx context.Context, client *armcompute.DiskAccessesClient, resourceGroupName, diskAccessName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for disk access %s to be available via API...", diskAccessName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, diskAccessName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Disk access %s not yet available (attempt %d/%d), waiting %v...", diskAccessName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking disk access availability: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Disk access %s is available with provisioning state: %s", diskAccessName, state) return nil } if state == "Failed" { return fmt.Errorf("disk access provisioning failed with state: %s", state) } log.Printf("Disk access %s provisioning state: %s (attempt %d/%d), waiting...", diskAccessName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } log.Printf("Disk access %s is available", diskAccessName) return nil } return fmt.Errorf("timeout waiting for disk access %s to be available after %d attempts", diskAccessName, maxAttempts) } // deleteDiskAccess deletes an Azure disk access resource. func deleteDiskAccess(ctx context.Context, client *armcompute.DiskAccessesClient, resourceGroupName, diskAccessName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, diskAccessName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Disk access %s not found, skipping deletion", diskAccessName) return nil } return fmt.Errorf("failed to begin deleting disk access: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete disk access: %w", err) } log.Printf("Disk access %s deleted successfully", diskAccessName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-disk-encryption-set_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestDiskEncryptionSetName = "ovm-integ-test-des" integrationTestKeyVaultKeyName = "ovm-integ-test-des-key" ) func TestComputeDiskEncryptionSetIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } desClient, err := armcompute.NewDiskEncryptionSetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Disk Encryption Sets client: %v", err) } identityClient, err := armmsi.NewUserAssignedIdentitiesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create User Assigned Identities client: %v", err) } keyVaultClient, err := armkeyvault.NewVaultsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Key Vault client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var vaultID string var keyURL string var identityResourceID string var identityPrincipalID string var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create RG if needed if err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation); err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Ensure a Key Vault exists (shared with other tests) if err := createKeyVault(ctx, keyVaultClient, integrationTestResourceGroup, integrationTestKeyVaultName, integrationTestLocation); err != nil { t.Fatalf("Failed to create Key Vault: %v", err) } if err := waitForKeyVaultAvailable(ctx, keyVaultClient, integrationTestResourceGroup, integrationTestKeyVaultName); err != nil { t.Fatalf("Failed waiting for Key Vault to be available: %v", err) } vault, err := keyVaultClient.Get(ctx, integrationTestResourceGroup, integrationTestKeyVaultName, nil) if err != nil { t.Fatalf("Failed to get Key Vault: %v", err) } if vault.ID == nil || *vault.ID == "" { t.Fatalf("Key Vault ID is nil/empty") } if vault.Properties == nil || vault.Properties.EnablePurgeProtection == nil || !*vault.Properties.EnablePurgeProtection { t.Skipf( "Disk Encryption Set integration requires Key Vault purge protection enabled on %s; enable it once with: az keyvault update --name %s --resource-group %s --enable-purge-protection true", integrationTestKeyVaultName, integrationTestKeyVaultName, integrationTestResourceGroup, ) } vaultID = *vault.ID // Ensure a user-assigned identity exists (shared with other tests) if err := createUserAssignedIdentity(ctx, identityClient, integrationTestResourceGroup, integrationTestUserAssignedIdentityName, integrationTestLocation); err != nil { t.Fatalf("Failed to create User Assigned Identity: %v", err) } if err := waitForUserAssignedIdentityAvailable(ctx, identityClient, integrationTestResourceGroup, integrationTestUserAssignedIdentityName); err != nil { t.Fatalf("Failed waiting for User Assigned Identity to be available: %v", err) } identity, err := identityClient.Get(ctx, integrationTestResourceGroup, integrationTestUserAssignedIdentityName, nil) if err != nil { t.Fatalf("Failed to get User Assigned Identity: %v", err) } if identity.ID == nil || *identity.ID == "" { t.Fatalf("User Assigned Identity ID is nil/empty") } if identity.Properties == nil || identity.Properties.PrincipalID == nil || *identity.Properties.PrincipalID == "" { t.Fatalf("User Assigned Identity principalID is nil/empty") } identityResourceID = *identity.ID identityPrincipalID = *identity.Properties.PrincipalID // Ensure a Key Vault key exists (data-plane via Azure CLI). keyURL, err = ensureKeyVaultKey(ctx, integrationTestKeyVaultName, integrationTestKeyVaultKeyName) if err != nil { t.Fatalf("Failed to ensure Key Vault key: %v", err) } // Grant the identity access to the Key Vault key material. Different vaults may be configured // for access-policy or RBAC authorization, so we try both approaches. if err := grantKeyVaultCryptoAccess(ctx, integrationTestKeyVaultName, vaultID, identityPrincipalID); err != nil { t.Fatalf("Failed to grant Key Vault crypto access to identity: %v", err) } // Create DES (idempotent) and wait for it to be available. if err := createDiskEncryptionSet(ctx, desClient, integrationTestResourceGroup, integrationTestDiskEncryptionSetName, integrationTestLocation, vaultID, keyURL, identityResourceID); err != nil { t.Fatalf("Failed to create Disk Encryption Set: %v", err) } if err := waitForDiskEncryptionSetAvailable(ctx, desClient, integrationTestResourceGroup, integrationTestDiskEncryptionSetName); err != nil { t.Fatalf("Failed waiting for Disk Encryption Set to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetDiskEncryptionSet", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving disk encryption set %s in subscription %s, resource group %s", integrationTestDiskEncryptionSetName, subscriptionID, integrationTestResourceGroup) desWrapper := manual.NewComputeDiskEncryptionSet( clients.NewDiskEncryptionSetsClient(desClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := desWrapper.Scopes()[0] desAdapter := sources.WrapperToAdapter(desWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := desAdapter.Get(ctx, scope, integrationTestDiskEncryptionSetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestDiskEncryptionSetName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestDiskEncryptionSetName, uniqueAttrValue) } if err := sdpItem.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } }) t.Run("ListDiskEncryptionSets", func(t *testing.T) { ctx := t.Context() desWrapper := manual.NewComputeDiskEncryptionSet( clients.NewDiskEncryptionSetsClient(desClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := desWrapper.Scopes()[0] desAdapter := sources.WrapperToAdapter(desWrapper, sdpcache.NewNoOpCache()) listable, ok := desAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list Disk Encryption Sets: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one Disk Encryption Set, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestDiskEncryptionSetName { found = true break } } if !found { t.Fatalf("Expected to find Disk Encryption Set %s in the list", integrationTestDiskEncryptionSetName) } }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() desWrapper := manual.NewComputeDiskEncryptionSet( clients.NewDiskEncryptionSetsClient(desClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := desWrapper.Scopes()[0] desAdapter := sources.WrapperToAdapter(desWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := desAdapter.Get(ctx, scope, integrationTestDiskEncryptionSetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDiskEncryptionSet.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeDiskEncryptionSet, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() desWrapper := manual.NewComputeDiskEncryptionSet( clients.NewDiskEncryptionSetsClient(desClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := desWrapper.Scopes()[0] desAdapter := sources.WrapperToAdapter(desWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := desAdapter.Get(ctx, scope, integrationTestDiskEncryptionSetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasKeyVaultLink bool var hasKeyVaultKeyLink bool var hasUserAssignedIdentityLink bool var hasDNSLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } switch query.GetType() { case azureshared.KeyVaultVault.String(): hasKeyVaultLink = true if query.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected Key Vault link method GET, got %s", query.GetMethod()) } if query.GetQuery() != integrationTestKeyVaultName { t.Errorf("Expected Key Vault link query %s, got %s", integrationTestKeyVaultName, query.GetQuery()) } if query.GetScope() != scope { t.Errorf("Expected Key Vault link scope %s, got %s", scope, query.GetScope()) } case azureshared.KeyVaultKey.String(): hasKeyVaultKeyLink = true if query.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected Key Vault Key link method GET, got %s", query.GetMethod()) } if query.GetQuery() != shared.CompositeLookupKey(integrationTestKeyVaultName, integrationTestKeyVaultKeyName) { t.Errorf("Expected Key Vault Key link query %s, got %s", shared.CompositeLookupKey(integrationTestKeyVaultName, integrationTestKeyVaultKeyName), query.GetQuery()) } // Key Vault URI doesn't contain resource group, adapter uses DES scope as best effort if query.GetScope() != scope { t.Errorf("Expected Key Vault Key link scope %s, got %s", scope, query.GetScope()) } case azureshared.ManagedIdentityUserAssignedIdentity.String(): hasUserAssignedIdentityLink = true if query.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected User Assigned Identity link method GET, got %s", query.GetMethod()) } if query.GetQuery() != integrationTestUserAssignedIdentityName { t.Errorf("Expected User Assigned Identity link query %s, got %s", integrationTestUserAssignedIdentityName, query.GetQuery()) } if query.GetScope() != scope { t.Errorf("Expected User Assigned Identity link scope %s, got %s", scope, query.GetScope()) } case "dns": hasDNSLink = true if query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected DNS link method SEARCH, got %s", query.GetMethod()) } expectedDNS := azureshared.ExtractDNSFromURL(keyURL) if query.GetQuery() != expectedDNS { t.Errorf("Expected DNS link query %s, got %s", expectedDNS, query.GetQuery()) } if query.GetScope() != "global" { t.Errorf("Expected DNS link scope global, got %s", query.GetScope()) } default: t.Errorf("Unexpected linked item type: %s", query.GetType()) } } if !hasKeyVaultLink { t.Error("Expected linked query to Key Vault, but didn't find one") } if !hasUserAssignedIdentityLink { t.Error("Expected linked query to User Assigned Identity, but didn't find one") } if !hasKeyVaultKeyLink { t.Error("Expected linked query to Key Vault Key, but didn't find one") } if !hasDNSLink { t.Error("Expected linked query to DNS, but didn't find one") } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() if err := deleteDiskEncryptionSet(ctx, desClient, integrationTestResourceGroup, integrationTestDiskEncryptionSetName); err != nil { t.Fatalf("Failed to delete Disk Encryption Set: %v", err) } }) } func createDiskEncryptionSet(ctx context.Context, client *armcompute.DiskEncryptionSetsClient, resourceGroupName, desName, location, vaultID, keyURL, userAssignedIdentityResourceID string) error { // If it exists and is succeeded, skip creation. existing, err := client.Get(ctx, resourceGroupName, desName, nil) if err == nil { if existing.Properties != nil && existing.Properties.ProvisioningState != nil && *existing.Properties.ProvisioningState == "Succeeded" { log.Printf("Disk Encryption Set %s already exists and is ready, skipping creation", desName) return nil } log.Printf("Disk Encryption Set %s already exists, will wait for it to be ready", desName) return nil } // New DES creation. des := armcompute.DiskEncryptionSet{ Location: new(location), Identity: &armcompute.EncryptionSetIdentity{ Type: new(armcompute.DiskEncryptionSetIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armcompute.UserAssignedIdentitiesValue{ userAssignedIdentityResourceID: {}, }, }, Properties: &armcompute.EncryptionSetProperties{ EncryptionType: new(armcompute.DiskEncryptionSetTypeEncryptionAtRestWithCustomerKey), ActiveKey: &armcompute.KeyForDiskEncryptionSet{ KeyURL: new(keyURL), SourceVault: &armcompute.SourceVault{ ID: new(vaultID), }, }, RotationToLatestKeyVersionEnabled: new(false), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-disk-encryption-set"), }, } // DES creation can fail briefly due to RBAC propagation; retry a few times. var lastErr error for attempt := 1; attempt <= 6; attempt++ { poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, desName, des, nil) if err != nil { lastErr = err } else { _, err = poller.PollUntilDone(ctx, nil) if err == nil { log.Printf("Disk Encryption Set %s created", desName) return nil } lastErr = err } log.Printf("Disk Encryption Set create attempt %d/6 failed: %v; retrying...", attempt, lastErr) time.Sleep(time.Duration(attempt) * 10 * time.Second) } return fmt.Errorf("failed to create Disk Encryption Set after retries: %w", lastErr) } func waitForDiskEncryptionSetAvailable(ctx context.Context, client *armcompute.DiskEncryptionSetsClient, resourceGroupName, desName string) error { maxAttempts := 30 pollInterval := 10 * time.Second log.Printf("Waiting for Disk Encryption Set %s to be available via API...", desName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, desName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { time.Sleep(pollInterval) continue } return fmt.Errorf("error checking Disk Encryption Set availability: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Disk Encryption Set %s is available with provisioning state: %s", desName, state) return nil } if state == "Failed" { return fmt.Errorf("disk encryption set provisioning failed with state: %s", state) } } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for Disk Encryption Set %s to be available after %d attempts", desName, maxAttempts) } func deleteDiskEncryptionSet(ctx context.Context, client *armcompute.DiskEncryptionSetsClient, resourceGroupName, desName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, desName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Disk Encryption Set %s not found, skipping deletion", desName) return nil } return fmt.Errorf("failed to begin deleting Disk Encryption Set: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete Disk Encryption Set: %w", err) } log.Printf("Disk Encryption Set %s deleted successfully", desName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-disk_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestDiskName = "ovm-integ-test-disk" ) func TestComputeDiskIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients diskClient, err := armcompute.NewDisksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Disks client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create disk err = createDisk(ctx, diskClient, integrationTestResourceGroup, integrationTestDiskName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create disk: %v", err) } // Wait for disk to be fully available err = waitForDiskAvailable(ctx, diskClient, integrationTestResourceGroup, integrationTestDiskName) if err != nil { t.Fatalf("Failed waiting for disk to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetDisk", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving disk %s in subscription %s, resource group %s", integrationTestDiskName, subscriptionID, integrationTestResourceGroup) diskWrapper := manual.NewComputeDisk( clients.NewDisksClient(diskClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskWrapper.Scopes()[0] diskAdapter := sources.WrapperToAdapter(diskWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := diskAdapter.Get(ctx, scope, integrationTestDiskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestDiskName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestDiskName, uniqueAttrValue) } log.Printf("Successfully retrieved disk %s", integrationTestDiskName) }) t.Run("ListDisks", func(t *testing.T) { ctx := t.Context() log.Printf("Listing disks in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) diskWrapper := manual.NewComputeDisk( clients.NewDisksClient(diskClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskWrapper.Scopes()[0] diskAdapter := sources.WrapperToAdapter(diskWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := diskAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list disks: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one disk, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestDiskName { found = true break } } if !found { t.Fatalf("Expected to find disk %s in the list of disks", integrationTestDiskName) } log.Printf("Found %d disks in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for disk %s", integrationTestDiskName) diskWrapper := manual.NewComputeDisk( clients.NewDisksClient(diskClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskWrapper.Scopes()[0] diskAdapter := sources.WrapperToAdapter(diskWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := diskAdapter.Get(ctx, scope, integrationTestDiskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.ComputeDisk.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeDisk, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for disk %s", integrationTestDiskName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for disk %s", integrationTestDiskName) diskWrapper := manual.NewComputeDisk( clients.NewDisksClient(diskClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := diskWrapper.Scopes()[0] diskAdapter := sources.WrapperToAdapter(diskWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := diskAdapter.Get(ctx, scope, integrationTestDiskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (if any) linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for disk %s", len(linkedQueries), integrationTestDiskName) // For a standalone empty disk, there may not be any linked items // But we should verify the structure is correct if links exist for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } // Verify query has required fields if query.GetType() == "" { t.Error("Linked item query has empty Type") } // Method should be GET or SEARCH (not empty) if query.GetMethod() == sdp.QueryMethod_GET || query.GetMethod() == sdp.QueryMethod_SEARCH { // Valid method } else { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete disk err := deleteDisk(ctx, diskClient, integrationTestResourceGroup, integrationTestDiskName) if err != nil { t.Fatalf("Failed to delete disk: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createDisk creates an Azure managed disk (idempotent) func createDisk(ctx context.Context, client *armcompute.DisksClient, resourceGroupName, diskName, location string) error { // Check if disk already exists existingDisk, err := client.Get(ctx, resourceGroupName, diskName, nil) if err == nil { // Disk exists, check its state if existingDisk.Properties != nil && existingDisk.Properties.ProvisioningState != nil { state := *existingDisk.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Disk %s already exists with state %s, skipping creation", diskName, state) return nil } log.Printf("Disk %s exists but in state %s, will wait for it", diskName, state) } else { log.Printf("Disk %s already exists, skipping creation", diskName) return nil } } // Create an empty disk (DiskCreateOptionEmpty) // This is the simplest type of disk to create for testing poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, diskName, armcompute.Disk{ Location: new(location), Properties: &armcompute.DiskProperties{ CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionEmpty), }, DiskSizeGB: new(int32(10)), // 10 GB disk }, SKU: &armcompute.DiskSKU{ Name: new(armcompute.DiskStorageAccountTypesStandardLRS), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-disk"), }, }, nil) if err != nil { // Check if disk already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Disk %s already exists (conflict), skipping creation", diskName) return nil } return fmt.Errorf("failed to begin creating disk: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create disk: %w", err) } // Verify the disk was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("disk created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("disk provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Disk %s created successfully with provisioning state: %s", diskName, provisioningState) return nil } // waitForDiskAvailable polls until the disk is available via the Get API // This is needed because even after creation succeeds, there can be a delay before the disk is queryable func waitForDiskAvailable(ctx context.Context, client *armcompute.DisksClient, resourceGroupName, diskName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for disk %s to be available via API...", diskName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, diskName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Disk %s not yet available (attempt %d/%d), waiting %v...", diskName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking disk availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Disk %s is available with provisioning state: %s", diskName, state) return nil } if state == "Failed" { return fmt.Errorf("disk provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Disk %s provisioning state: %s (attempt %d/%d), waiting...", diskName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Disk exists but no provisioning state - consider it available log.Printf("Disk %s is available", diskName) return nil } return fmt.Errorf("timeout waiting for disk %s to be available after %d attempts", diskName, maxAttempts) } // deleteDisk deletes an Azure managed disk func deleteDisk(ctx context.Context, client *armcompute.DisksClient, resourceGroupName, diskName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, diskName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Disk %s not found, skipping deletion", diskName) return nil } return fmt.Errorf("failed to begin deleting disk: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete disk: %w", err) } log.Printf("Disk %s deleted successfully", diskName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-gallery-application-version_test.go ================================================ package integrationtests import ( "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) // Gallery application version integration tests require pre-existing Azure resources // (gallery, gallery application, and gallery application version) because creating // a version requires a source blob URL. Set these env vars to run the tests: // // AZURE_TEST_GALLERY_NAME - name of the gallery // AZURE_TEST_GALLERY_APPLICATION_NAME - name of the gallery application // AZURE_TEST_GALLERY_APPLICATION_VERSION - name of the gallery application version // // Optional: AZURE_TEST_GALLERY_RESOURCE_GROUP (defaults to overmind-integration-tests) func getGalleryApplicationVersionTestConfig(t *testing.T) (resourceGroup, galleryName, applicationName, versionName string, skip bool) { galleryName = os.Getenv("AZURE_TEST_GALLERY_NAME") applicationName = os.Getenv("AZURE_TEST_GALLERY_APPLICATION_NAME") versionName = os.Getenv("AZURE_TEST_GALLERY_APPLICATION_VERSION") resourceGroup = os.Getenv("AZURE_TEST_GALLERY_RESOURCE_GROUP") if resourceGroup == "" { resourceGroup = integrationTestResourceGroup } if galleryName == "" || applicationName == "" || versionName == "" { t.Skip("Skipping gallery application version integration test: set AZURE_TEST_GALLERY_NAME, AZURE_TEST_GALLERY_APPLICATION_NAME, and AZURE_TEST_GALLERY_APPLICATION_VERSION to run") return "", "", "", "", true } return resourceGroup, galleryName, applicationName, versionName, false } func TestComputeGalleryApplicationVersionIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } resourceGroup, galleryName, applicationName, versionName, skip := getGalleryApplicationVersionTestConfig(t) if skip { return } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } galleryApplicationVersionsClient, err := armcompute.NewGalleryApplicationVersionsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Gallery Application Versions client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Run", func(t *testing.T) { ctx := t.Context() // Ensure resource group exists (may be used for pre-created gallery) err := createResourceGroup(ctx, rgClient, resourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create/verify resource group: %v", err) } _, getErr := galleryApplicationVersionsClient.Get(ctx, resourceGroup, galleryName, applicationName, versionName, nil) if getErr != nil { var respErr *azcore.ResponseError if errors.As(getErr, &respErr) && respErr.StatusCode == http.StatusNotFound { t.Skipf("Skipping gallery application version integration test: resource %s/%s/%s not found in %s", galleryName, applicationName, versionName, resourceGroup) } t.Fatalf("Failed to verify gallery application version %s/%s/%s existence: %v", galleryName, applicationName, versionName, getErr) } t.Run("GetGalleryApplicationVersion", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving gallery application version %s/%s/%s in subscription %s, resource group %s", galleryName, applicationName, versionName, subscriptionID, resourceGroup) wrapper := manual.NewComputeGalleryApplicationVersion( clients.NewGalleryApplicationVersionsClient(galleryApplicationVersionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, applicationName, versionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttr := shared.CompositeLookupKey(galleryName, applicationName, versionName) if uniqueAttrValue != expectedUniqueAttr { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedUniqueAttr, uniqueAttrValue) } log.Printf("Successfully retrieved gallery application version %s", versionName) }) t.Run("SearchGalleryApplicationVersions", func(t *testing.T) { ctx := t.Context() log.Printf("Searching gallery application versions for gallery %s, application %s in subscription %s, resource group %s", galleryName, applicationName, subscriptionID, resourceGroup) wrapper := manual.NewComputeGalleryApplicationVersion( clients.NewGalleryApplicationVersionsClient(galleryApplicationVersionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := galleryName + shared.QuerySeparator + applicationName sdpItems, err := searchable.Search(ctx, scope, searchQuery, true) if err != nil { t.Fatalf("Failed to search gallery application versions: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one gallery application version, got %d", len(sdpItems)) } var found bool expectedUniqueAttr := shared.CompositeLookupKey(galleryName, applicationName, versionName) for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUniqueAttr { found = true break } } if !found { t.Fatalf("Expected to find gallery application version %s in the search results", versionName) } log.Printf("Found %d gallery application versions in resource group %s", len(sdpItems), resourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for gallery application version %s", versionName) wrapper := manual.NewComputeGalleryApplicationVersion( clients.NewGalleryApplicationVersionsClient(galleryApplicationVersionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, applicationName, versionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeGalleryApplicationVersion.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeGalleryApplicationVersion.String(), sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for gallery application version %s", versionName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for gallery application version %s", versionName) wrapper := manual.NewComputeGalleryApplicationVersion( clients.NewGalleryApplicationVersionsClient(galleryApplicationVersionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, applicationName, versionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for gallery application version %s", len(linkedQueries), versionName) // Should have at least Gallery and Gallery Application parent links if len(linkedQueries) < 2 { t.Fatalf("Expected at least 2 linked item queries (Gallery, Gallery Application), got %d", len(linkedQueries)) } for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } } }) }) } ================================================ FILE: sources/azure/integration-tests/compute-image_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestImageName = "ovm-integ-test-image" integrationTestImageDiskName = "ovm-integ-test-image-disk" ) func TestComputeImageIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients imageClient, err := armcompute.NewImagesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Images client: %v", err) } diskClient, err := armcompute.NewDisksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Disks client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create disk first (required for image creation) err = createDisk(ctx, diskClient, integrationTestResourceGroup, integrationTestImageDiskName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create disk: %v", err) } // Wait for disk to be fully available err = waitForDiskAvailable(ctx, diskClient, integrationTestResourceGroup, integrationTestImageDiskName) if err != nil { t.Fatalf("Failed waiting for disk to be available: %v", err) } // Get the disk ID for image creation disk, err := diskClient.Get(ctx, integrationTestResourceGroup, integrationTestImageDiskName, nil) if err != nil { t.Fatalf("Failed to get disk: %v", err) } if disk.ID == nil || *disk.ID == "" { t.Fatalf("Disk ID is nil or empty") } // Create image from the disk err = createImage(ctx, imageClient, integrationTestResourceGroup, integrationTestImageName, integrationTestLocation, *disk.ID) if err != nil { t.Fatalf("Failed to create image: %v", err) } // Wait for image to be fully available err = waitForImageAvailable(ctx, imageClient, integrationTestResourceGroup, integrationTestImageName) if err != nil { t.Fatalf("Failed waiting for image to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetImage", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving image %s in subscription %s, resource group %s", integrationTestImageName, subscriptionID, integrationTestResourceGroup) imageWrapper := manual.NewComputeImage( clients.NewImagesClient(imageClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := imageWrapper.Scopes()[0] imageAdapter := sources.WrapperToAdapter(imageWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := imageAdapter.Get(ctx, scope, integrationTestImageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestImageName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestImageName, uniqueAttrValue) } log.Printf("Successfully retrieved image %s", integrationTestImageName) }) t.Run("ListImages", func(t *testing.T) { ctx := t.Context() log.Printf("Listing images in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) imageWrapper := manual.NewComputeImage( clients.NewImagesClient(imageClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := imageWrapper.Scopes()[0] imageAdapter := sources.WrapperToAdapter(imageWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := imageAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list images: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one image, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestImageName { found = true break } } if !found { t.Fatalf("Expected to find image %s in the list of images", integrationTestImageName) } log.Printf("Found %d images in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for image %s", integrationTestImageName) imageWrapper := manual.NewComputeImage( clients.NewImagesClient(imageClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := imageWrapper.Scopes()[0] imageAdapter := sources.WrapperToAdapter(imageWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := imageAdapter.Get(ctx, scope, integrationTestImageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.ComputeImage.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeImage, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for image %s", integrationTestImageName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for image %s", integrationTestImageName) imageWrapper := manual.NewComputeImage( clients.NewImagesClient(imageClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := imageWrapper.Scopes()[0] imageAdapter := sources.WrapperToAdapter(imageWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := imageAdapter.Get(ctx, scope, integrationTestImageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (image should link to the source disk) linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for image %s", len(linkedQueries), integrationTestImageName) // An image created from a managed disk should have at least one linked item (the disk) if len(linkedQueries) < 1 { t.Errorf("Expected at least one linked item query for image created from disk, got %d", len(linkedQueries)) } // Verify linked item structure var foundDiskLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } // Verify query has required fields if query.GetType() == "" { t.Error("Linked item query has empty Type") } // Method should be GET or SEARCH (not empty) if query.GetMethod() == sdp.QueryMethod_GET || query.GetMethod() == sdp.QueryMethod_SEARCH { // Valid method } else { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } // Check if this is a link to the source disk if query.GetType() == azureshared.ComputeDisk.String() && query.GetQuery() == integrationTestImageDiskName { foundDiskLink = true } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } // Verify we found the expected disk link if !foundDiskLink { t.Errorf("Expected to find linked item query for disk %s, but it was not found", integrationTestImageDiskName) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete image first err := deleteImage(ctx, imageClient, integrationTestResourceGroup, integrationTestImageName) if err != nil { t.Fatalf("Failed to delete image: %v", err) } // Delete disk err = deleteDisk(ctx, diskClient, integrationTestResourceGroup, integrationTestImageDiskName) if err != nil { t.Fatalf("Failed to delete disk: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createImage creates an Azure compute image from a managed disk (idempotent) func createImage(ctx context.Context, client *armcompute.ImagesClient, resourceGroupName, imageName, location, sourceDiskID string) error { // Check if image already exists existingImage, err := client.Get(ctx, resourceGroupName, imageName, nil) if err == nil { // Image exists, check its state if existingImage.Properties != nil && existingImage.Properties.ProvisioningState != nil { state := *existingImage.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Image %s already exists with state %s, skipping creation", imageName, state) return nil } log.Printf("Image %s exists but in state %s, will wait for it", imageName, state) } else { log.Printf("Image %s already exists, skipping creation", imageName) return nil } } // Create an image from a managed disk poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, imageName, armcompute.Image{ Location: new(location), Properties: &armcompute.ImageProperties{ HyperVGeneration: new(armcompute.HyperVGenerationTypesV1), StorageProfile: &armcompute.ImageStorageProfile{ OSDisk: &armcompute.ImageOSDisk{ ManagedDisk: &armcompute.SubResource{ ID: new(sourceDiskID), }, OSState: new(armcompute.OperatingSystemStateTypesGeneralized), OSType: new(armcompute.OperatingSystemTypesLinux), }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-image"), }, }, nil) if err != nil { // Check if image already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Image %s already exists (conflict), skipping creation", imageName) return nil } return fmt.Errorf("failed to begin creating image: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create image: %w", err) } // Verify the image was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("image created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("image provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Image %s created successfully with provisioning state: %s", imageName, provisioningState) return nil } // waitForImageAvailable polls until the image is available via the Get API // This is needed because even after creation succeeds, there can be a delay before the image is queryable func waitForImageAvailable(ctx context.Context, client *armcompute.ImagesClient, resourceGroupName, imageName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for image %s to be available via API...", imageName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, imageName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Image %s not yet available (attempt %d/%d), waiting %v...", imageName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking image availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Image %s is available with provisioning state: %s", imageName, state) return nil } if state == "Failed" { return fmt.Errorf("image provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Image %s provisioning state: %s (attempt %d/%d), waiting...", imageName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Image exists but no provisioning state - consider it available log.Printf("Image %s is available", imageName) return nil } return fmt.Errorf("timeout waiting for image %s to be available after %d attempts", imageName, maxAttempts) } // deleteImage deletes an Azure compute image func deleteImage(ctx context.Context, client *armcompute.ImagesClient, resourceGroupName, imageName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, imageName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Image %s not found, skipping deletion", imageName) return nil } return fmt.Errorf("failed to begin deleting image: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete image: %w", err) } log.Printf("Image %s deleted successfully", imageName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-proximity-placement-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestProximityPlacementGroupName = "ovm-integ-test-ppg" ) func TestComputeProximityPlacementGroupIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } ppgClient, err := armcompute.NewProximityPlacementGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Proximity Placement Groups client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createProximityPlacementGroup(ctx, ppgClient, integrationTestResourceGroup, integrationTestProximityPlacementGroupName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create proximity placement group: %v", err) } err = waitForProximityPlacementGroupAvailable(ctx, ppgClient, integrationTestResourceGroup, integrationTestProximityPlacementGroupName) if err != nil { t.Fatalf("Failed waiting for proximity placement group to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { ctx := t.Context() _, err := ppgClient.Get(ctx, integrationTestResourceGroup, integrationTestProximityPlacementGroupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { t.Skipf("Proximity placement group %s does not exist - Setup may have failed. Skipping Run tests.", integrationTestProximityPlacementGroupName) } } t.Run("GetProximityPlacementGroup", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving proximity placement group %s in subscription %s, resource group %s", integrationTestProximityPlacementGroupName, subscriptionID, integrationTestResourceGroup) ppgWrapper := manual.NewComputeProximityPlacementGroup( clients.NewProximityPlacementGroupsClient(ppgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ppgWrapper.Scopes()[0] ppgAdapter := sources.WrapperToAdapter(ppgWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := ppgAdapter.Get(ctx, scope, integrationTestProximityPlacementGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestProximityPlacementGroupName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestProximityPlacementGroupName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.ComputeProximityPlacementGroup.String() { t.Fatalf("Expected type %s, got %s", azureshared.ComputeProximityPlacementGroup.String(), sdpItem.GetType()) } log.Printf("Successfully retrieved proximity placement group %s", integrationTestProximityPlacementGroupName) }) t.Run("ListProximityPlacementGroups", func(t *testing.T) { ctx := t.Context() log.Printf("Listing proximity placement groups in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) ppgWrapper := manual.NewComputeProximityPlacementGroup( clients.NewProximityPlacementGroupsClient(ppgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ppgWrapper.Scopes()[0] ppgAdapter := sources.WrapperToAdapter(ppgWrapper, sdpcache.NewNoOpCache()) listable, ok := ppgAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list proximity placement groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one proximity placement group, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestProximityPlacementGroupName { found = true if item.GetType() != azureshared.ComputeProximityPlacementGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeProximityPlacementGroup.String(), item.GetType()) } break } } if !found { t.Fatalf("Expected to find proximity placement group %s in the list", integrationTestProximityPlacementGroupName) } log.Printf("Found %d proximity placement groups in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for proximity placement group %s", integrationTestProximityPlacementGroupName) ppgWrapper := manual.NewComputeProximityPlacementGroup( clients.NewProximityPlacementGroupsClient(ppgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ppgWrapper.Scopes()[0] ppgAdapter := sources.WrapperToAdapter(ppgWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := ppgAdapter.Get(ctx, scope, integrationTestProximityPlacementGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for proximity placement group %s", len(linkedQueries), integrationTestProximityPlacementGroupName) for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected link method to be GET, got %s", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } } }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for proximity placement group %s", integrationTestProximityPlacementGroupName) ppgWrapper := manual.NewComputeProximityPlacementGroup( clients.NewProximityPlacementGroupsClient(ppgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ppgWrapper.Scopes()[0] ppgAdapter := sources.WrapperToAdapter(ppgWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := ppgAdapter.Get(ctx, scope, integrationTestProximityPlacementGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeProximityPlacementGroup.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeProximityPlacementGroup.String(), sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for proximity placement group %s", integrationTestProximityPlacementGroupName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteProximityPlacementGroup(ctx, ppgClient, integrationTestResourceGroup, integrationTestProximityPlacementGroupName) if err != nil { t.Fatalf("Failed to delete proximity placement group: %v", err) } }) } func createProximityPlacementGroup(ctx context.Context, client *armcompute.ProximityPlacementGroupsClient, resourceGroupName, ppgName, location string) error { _, err := client.Get(ctx, resourceGroupName, ppgName, nil) if err == nil { log.Printf("Proximity placement group %s already exists, skipping creation", ppgName) return nil } resp, err := client.CreateOrUpdate(ctx, resourceGroupName, ppgName, armcompute.ProximityPlacementGroup{ Location: new(location), Properties: &armcompute.ProximityPlacementGroupProperties{ ProximityPlacementGroupType: new(armcompute.ProximityPlacementGroupTypeStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-proximity-placement-group"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Proximity placement group %s already exists (conflict), skipping creation", ppgName) return nil } return fmt.Errorf("failed to create proximity placement group: %w", err) } if resp.Name == nil { return fmt.Errorf("proximity placement group created but name is nil") } log.Printf("Proximity placement group %s created successfully", ppgName) return nil } func waitForProximityPlacementGroupAvailable(ctx context.Context, client *armcompute.ProximityPlacementGroupsClient, resourceGroupName, ppgName string) error { const maxAttempts = 10 pollInterval := 2 * time.Second log.Printf("Waiting for proximity placement group %s to be available via API...", ppgName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, ppgName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Proximity placement group %s not yet available (attempt %d/%d), waiting %v...", ppgName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking proximity placement group availability: %w", err) } if resp.Name != nil { log.Printf("Proximity placement group %s is available", ppgName) return nil } if attempt < maxAttempts { log.Printf("Proximity placement group %s not yet ready (attempt %d/%d), waiting %v...", ppgName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } } return fmt.Errorf("timeout waiting for proximity placement group %s to be available after %d attempts", ppgName, maxAttempts) } func deleteProximityPlacementGroup(ctx context.Context, client *armcompute.ProximityPlacementGroupsClient, resourceGroupName, ppgName string) error { _, err := client.Delete(ctx, resourceGroupName, ppgName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Proximity placement group %s not found, skipping deletion", ppgName) return nil } return fmt.Errorf("failed to delete proximity placement group: %w", err) } log.Printf("Proximity placement group %s deleted successfully", ppgName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-snapshot_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestSnapshotName = "ovm-integ-test-snapshot" integrationTestDiskForSnapName = "ovm-integ-test-disk-for-snap" ) func TestComputeSnapshotIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } snapshotClient, err := armcompute.NewSnapshotsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Snapshots client: %v", err) } diskClient, err := armcompute.NewDisksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Disks client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create a disk to snapshot from err = createDisk(ctx, diskClient, integrationTestResourceGroup, integrationTestDiskForSnapName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create disk: %v", err) } err = waitForDiskAvailable(ctx, diskClient, integrationTestResourceGroup, integrationTestDiskForSnapName) if err != nil { t.Fatalf("Failed waiting for disk to be available: %v", err) } // Get disk ID for snapshot creation diskResp, err := diskClient.Get(ctx, integrationTestResourceGroup, integrationTestDiskForSnapName, nil) if err != nil { t.Fatalf("Failed to get disk: %v", err) } // Create snapshot from the disk err = createSnapshot(ctx, snapshotClient, integrationTestResourceGroup, integrationTestSnapshotName, integrationTestLocation, *diskResp.ID) if err != nil { t.Fatalf("Failed to create snapshot: %v", err) } err = waitForSnapshotAvailable(ctx, snapshotClient, integrationTestResourceGroup, integrationTestSnapshotName) if err != nil { t.Fatalf("Failed waiting for snapshot to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { ctx := t.Context() _, err := snapshotClient.Get(ctx, integrationTestResourceGroup, integrationTestSnapshotName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { t.Skipf("Snapshot %s does not exist - Setup may have failed. Skipping Run tests.", integrationTestSnapshotName) } } t.Run("GetSnapshot", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving snapshot %s in subscription %s, resource group %s", integrationTestSnapshotName, subscriptionID, integrationTestResourceGroup) snapshotWrapper := manual.NewComputeSnapshot( clients.NewSnapshotsClient(snapshotClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := snapshotWrapper.Scopes()[0] snapshotAdapter := sources.WrapperToAdapter(snapshotWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := snapshotAdapter.Get(ctx, scope, integrationTestSnapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestSnapshotName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestSnapshotName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.ComputeSnapshot.String() { t.Fatalf("Expected type %s, got %s", azureshared.ComputeSnapshot, sdpItem.GetType()) } log.Printf("Successfully retrieved snapshot %s", integrationTestSnapshotName) }) t.Run("ListSnapshots", func(t *testing.T) { ctx := t.Context() log.Printf("Listing snapshots in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) snapshotWrapper := manual.NewComputeSnapshot( clients.NewSnapshotsClient(snapshotClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := snapshotWrapper.Scopes()[0] snapshotAdapter := sources.WrapperToAdapter(snapshotWrapper, sdpcache.NewNoOpCache()) listable, ok := snapshotAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list snapshots: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one snapshot, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestSnapshotName { found = true if item.GetType() != azureshared.ComputeSnapshot.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeSnapshot, item.GetType()) } break } } if !found { t.Fatalf("Expected to find snapshot %s in the list of snapshots", integrationTestSnapshotName) } log.Printf("Found %d snapshots in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for snapshot %s", integrationTestSnapshotName) snapshotWrapper := manual.NewComputeSnapshot( clients.NewSnapshotsClient(snapshotClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := snapshotWrapper.Scopes()[0] snapshotAdapter := sources.WrapperToAdapter(snapshotWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := snapshotAdapter.Get(ctx, scope, integrationTestSnapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeSnapshot.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeSnapshot, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %s", sdpItem.GetHealth()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for snapshot %s", integrationTestSnapshotName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for snapshot %s", integrationTestSnapshotName) snapshotWrapper := manual.NewComputeSnapshot( clients.NewSnapshotsClient(snapshotClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := snapshotWrapper.Scopes()[0] snapshotAdapter := sources.WrapperToAdapter(snapshotWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := snapshotAdapter.Get(ctx, scope, integrationTestSnapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for snapshot %s", len(linkedQueries), integrationTestSnapshotName) // The snapshot was created from a disk, so we expect a link to the source disk var hasDiskLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } if query.GetType() == azureshared.ComputeDisk.String() { hasDiskLink = true if query.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected disk link method to be GET, got %s", query.GetMethod()) } if query.GetQuery() != integrationTestDiskForSnapName { t.Errorf("Expected disk link query to be %s, got %s", integrationTestDiskForSnapName, query.GetQuery()) } } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } if !hasDiskLink { t.Error("Expected to find a link to the source disk") } log.Printf("Verified %d linked item queries for snapshot %s", len(linkedQueries), integrationTestSnapshotName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete snapshot first err := deleteSnapshot(ctx, snapshotClient, integrationTestResourceGroup, integrationTestSnapshotName) if err != nil { t.Fatalf("Failed to delete snapshot: %v", err) } // Delete the source disk err = deleteDisk(ctx, diskClient, integrationTestResourceGroup, integrationTestDiskForSnapName) if err != nil { t.Fatalf("Failed to delete disk: %v", err) } }) } // createSnapshot creates an Azure snapshot from a source disk (idempotent) func createSnapshot(ctx context.Context, client *armcompute.SnapshotsClient, resourceGroupName, snapshotName, location, sourceDiskID string) error { existingSnapshot, err := client.Get(ctx, resourceGroupName, snapshotName, nil) if err == nil { if existingSnapshot.Properties != nil && existingSnapshot.Properties.ProvisioningState != nil { state := *existingSnapshot.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Snapshot %s already exists with state %s, skipping creation", snapshotName, state) return nil } log.Printf("Snapshot %s exists but in state %s, will wait for it", snapshotName, state) } else { log.Printf("Snapshot %s already exists, skipping creation", snapshotName) return nil } } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, snapshotName, armcompute.Snapshot{ Location: new(location), Properties: &armcompute.SnapshotProperties{ CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionCopy), SourceResourceID: new(sourceDiskID), }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-snapshot"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Snapshot %s already exists (conflict), skipping creation", snapshotName) return nil } return fmt.Errorf("failed to begin creating snapshot: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create snapshot: %w", err) } if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("snapshot created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("snapshot provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Snapshot %s created successfully with provisioning state: %s", snapshotName, provisioningState) return nil } // waitForSnapshotAvailable polls until the snapshot is available via the Get API func waitForSnapshotAvailable(ctx context.Context, client *armcompute.SnapshotsClient, resourceGroupName, snapshotName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for snapshot %s to be available via API...", snapshotName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, snapshotName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Snapshot %s not yet available (attempt %d/%d), waiting %v...", snapshotName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking snapshot availability: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Snapshot %s is available with provisioning state: %s", snapshotName, state) return nil } if state == "Failed" { return fmt.Errorf("snapshot provisioning failed with state: %s", state) } log.Printf("Snapshot %s provisioning state: %s (attempt %d/%d), waiting...", snapshotName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } log.Printf("Snapshot %s is available", snapshotName) return nil } return fmt.Errorf("timeout waiting for snapshot %s to be available after %d attempts", snapshotName, maxAttempts) } // deleteSnapshot deletes an Azure snapshot func deleteSnapshot(ctx context.Context, client *armcompute.SnapshotsClient, resourceGroupName, snapshotName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, snapshotName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Snapshot %s not found, skipping deletion", snapshotName) return nil } return fmt.Errorf("failed to begin deleting snapshot: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete snapshot: %w", err) } log.Printf("Snapshot %s deleted successfully", snapshotName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-virtual-machine-extension_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) const ( integrationTestExtensionVMName = "ovm-integ-test-ext-vm" integrationTestExtensionNICName = "ovm-integ-test-ext-nic" integrationTestExtensionVNetName = "ovm-integ-test-ext-vnet" integrationTestExtensionSubnetName = "default" integrationTestExtensionName = "ovm-integ-test-extension" ) func TestComputeVirtualMachineExtensionIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients vmClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Machines client: %v", err) } extensionClient, err := armcompute.NewVirtualMachineExtensionsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Machine Extensions client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } nicClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Interfaces client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network err = createVirtualNetworkForExtension(ctx, vnetClient, integrationTestResourceGroup, integrationTestExtensionVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for NIC creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestExtensionVNetName, integrationTestExtensionSubnetName, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create network interface err = createNetworkInterfaceForExtension(ctx, nicClient, integrationTestResourceGroup, integrationTestExtensionNICName, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create network interface: %v", err) } // Get NIC ID for VM creation nicResp, err := nicClient.Get(ctx, integrationTestResourceGroup, integrationTestExtensionNICName, nil) if err != nil { t.Fatalf("Failed to get network interface: %v", err) } // Create virtual machine err = createVirtualMachineForExtension(ctx, vmClient, integrationTestResourceGroup, integrationTestExtensionVMName, integrationTestLocation, *nicResp.ID) if err != nil { t.Fatalf("Failed to create virtual machine: %v", err) } // Wait for VM to be fully available via the API err = waitForVMAvailableForExtension(ctx, vmClient, integrationTestResourceGroup, integrationTestExtensionVMName) if err != nil { t.Fatalf("Failed waiting for VM to be available: %v", err) } // Create extension err = createVirtualMachineExtension(ctx, extensionClient, integrationTestResourceGroup, integrationTestExtensionVMName, integrationTestExtensionName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual machine extension: %v", err) } // Wait for extension to be available err = waitForExtensionAvailable(ctx, extensionClient, integrationTestResourceGroup, integrationTestExtensionVMName, integrationTestExtensionName) if err != nil { t.Fatalf("Failed waiting for extension to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetVirtualMachineExtension", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving virtual machine extension %s for VM %s in subscription %s, resource group %s", integrationTestExtensionName, integrationTestExtensionVMName, subscriptionID, integrationTestResourceGroup) extensionWrapper := manual.NewComputeVirtualMachineExtension( clients.NewVirtualMachineExtensionsClient(extensionClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := extensionWrapper.Scopes()[0] extensionAdapter := sources.WrapperToAdapter(extensionWrapper, sdpcache.NewNoOpCache()) // Get requires virtualMachineName and extensionName as query parts query := integrationTestExtensionVMName + shared.QuerySeparator + integrationTestExtensionName sdpItem, qErr := extensionAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttr := shared.CompositeLookupKey(integrationTestExtensionVMName, integrationTestExtensionName) if uniqueAttrValue != expectedUniqueAttr { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedUniqueAttr, uniqueAttrValue) } // Verify the extension name attribute nameAttr, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get name attribute: %v", err) } if nameAttr != integrationTestExtensionName { t.Fatalf("Expected name attribute to be %s, got %s", integrationTestExtensionName, nameAttr) } log.Printf("Successfully retrieved virtual machine extension %s", integrationTestExtensionName) }) t.Run("SearchVirtualMachineExtensions", func(t *testing.T) { ctx := t.Context() log.Printf("Searching virtual machine extensions for VM %s", integrationTestExtensionVMName) extensionWrapper := manual.NewComputeVirtualMachineExtension( clients.NewVirtualMachineExtensionsClient(extensionClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := extensionWrapper.Scopes()[0] extensionAdapter := sources.WrapperToAdapter(extensionWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := extensionAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestExtensionVMName, true) if err != nil { t.Fatalf("Failed to search virtual machine extensions: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one virtual machine extension, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() uniqueAttrValue, err := item.GetAttributes().Get(uniqueAttrKey) if err != nil { continue } expectedUniqueAttr := shared.CompositeLookupKey(integrationTestExtensionVMName, integrationTestExtensionName) if uniqueAttrValue == expectedUniqueAttr { found = true break } } if !found { t.Fatalf("Expected to find extension %s in the search results", integrationTestExtensionName) } log.Printf("Found %d virtual machine extensions in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for virtual machine extension %s", integrationTestExtensionName) extensionWrapper := manual.NewComputeVirtualMachineExtension( clients.NewVirtualMachineExtensionsClient(extensionClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := extensionWrapper.Scopes()[0] extensionAdapter := sources.WrapperToAdapter(extensionWrapper, sdpcache.NewNoOpCache()) query := integrationTestExtensionVMName + shared.QuerySeparator + integrationTestExtensionName sdpItem, qErr := extensionAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (VM should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasVMLink bool for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.ComputeVirtualMachine.String(): hasVMLink = true // Verify VM link properties if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected VM link method to be GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() != integrationTestExtensionVMName { t.Errorf("Expected VM link query to be %s, got %s", integrationTestExtensionVMName, liq.GetQuery().GetQuery()) } case azureshared.KeyVaultVault.String(): // Key Vault links may be present if ProtectedSettingsFromKeyVault is set case stdlib.NetworkHTTP.String(): // HTTP links may be present if settings contain URLs case stdlib.NetworkDNS.String(): // DNS links may be present if settings contain DNS names case stdlib.NetworkIP.String(): // IP links may be present if settings contain IP addresses } } if !hasVMLink { t.Error("Expected linked query to virtual machine, but didn't find one") } log.Printf("Verified %d linked item queries for extension %s", len(linkedQueries), integrationTestExtensionName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete extension first err := deleteVirtualMachineExtension(ctx, extensionClient, integrationTestResourceGroup, integrationTestExtensionVMName, integrationTestExtensionName) if err != nil { t.Fatalf("Failed to delete virtual machine extension: %v", err) } // Delete VM (it must be deleted before NIC can be deleted) err = deleteVirtualMachineForExtension(ctx, vmClient, integrationTestResourceGroup, integrationTestExtensionVMName) if err != nil { t.Fatalf("Failed to delete virtual machine: %v", err) } // Delete NIC err = deleteNetworkInterfaceForExtension(ctx, nicClient, integrationTestResourceGroup, integrationTestExtensionNICName) if err != nil { t.Fatalf("Failed to delete network interface: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForExtension(ctx, vnetClient, integrationTestResourceGroup, integrationTestExtensionVNetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createVirtualNetworkForExtension creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetworkForExtension(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.2.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestExtensionSubnetName), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.2.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-extension"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // createNetworkInterfaceForExtension creates an Azure network interface (idempotent) func createNetworkInterfaceForExtension(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName, location, subnetID string) error { // Check if NIC already exists _, err := client.Get(ctx, resourceGroupName, nicName, nil) if err == nil { log.Printf("Network interface %s already exists, skipping creation", nicName) return nil } // Create the NIC poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, nicName, armnetwork.Interface{ Location: new(location), Properties: &armnetwork.InterfacePropertiesFormat{ IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new("ipconfig1"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-extension"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create network interface: %w", err) } log.Printf("Network interface %s created successfully", nicName) return nil } // createVirtualMachineForExtension creates an Azure virtual machine (idempotent) func createVirtualMachineForExtension(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID string) error { return createVirtualMachineForExtensionWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, 0) } func createVirtualMachineForExtensionWithRemediation(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID string, remediationAttempt int) error { // Check if VM already exists existingVM, err := client.Get(ctx, resourceGroupName, vmName, nil) if err == nil { // VM exists, check its state if existingVM.Properties != nil && existingVM.Properties.ProvisioningState != nil { state := *existingVM.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Virtual machine %s already exists with state %s, skipping creation", vmName, state) return nil } log.Printf("Virtual machine %s exists but in state %s, will wait for it", vmName, state) } else { log.Printf("Virtual machine %s already exists, skipping creation", vmName) return nil } } // Create the VM poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmName, armcompute.VirtualMachine{ Location: new(location), Properties: &armcompute.VirtualMachineProperties{ HardwareProfile: &armcompute.HardwareProfile{ VMSize: new(armcompute.VirtualMachineSizeTypes("Standard_D2s_v3")), }, StorageProfile: &armcompute.StorageProfile{ ImageReference: &armcompute.ImageReference{ Publisher: new("Canonical"), Offer: new("0001-com-ubuntu-server-jammy"), SKU: new("22_04-lts"), Version: new("latest"), }, OSDisk: &armcompute.OSDisk{ Name: new(fmt.Sprintf("%s-osdisk", vmName)), CreateOption: new(armcompute.DiskCreateOptionTypesFromImage), ManagedDisk: &armcompute.ManagedDiskParameters{ StorageAccountType: new(armcompute.StorageAccountTypesStandardLRS), }, DeleteOption: new(armcompute.DiskDeleteOptionTypesDelete), }, }, OSProfile: &armcompute.OSProfile{ ComputerName: new(vmName), AdminUsername: new("azureuser"), // Use password authentication for integration tests (simpler than SSH keys) AdminPassword: new("OvmIntegTest2024!"), LinuxConfiguration: &armcompute.LinuxConfiguration{ DisablePasswordAuthentication: new(false), }, }, NetworkProfile: &armcompute.NetworkProfile{ NetworkInterfaces: []*armcompute.NetworkInterfaceReference{ { ID: new(nicID), Properties: &armcompute.NetworkInterfaceReferenceProperties{ Primary: new(true), }, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-extension"), }, }, nil) if err != nil { // Check if VM already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { existing, getErr := client.Get(ctx, resourceGroupName, vmName, nil) if getErr == nil { if existing.Properties != nil && existing.Properties.ProvisioningState != nil { log.Printf("Virtual machine %s already exists (conflict) with state %s, skipping creation", vmName, *existing.Properties.ProvisioningState) } else { log.Printf("Virtual machine %s already exists (conflict), skipping creation", vmName) } return nil } var getRespErr *azcore.ResponseError if errors.As(getErr, &getRespErr) && getRespErr.StatusCode == http.StatusNotFound { if remediationAttempt >= 1 { return fmt.Errorf("vm %s still in ghost conflict state after remediation (resourceGroup=%s): %w", vmName, resourceGroupName, err) } log.Printf("Detected ghost VM conflict for extension test VM %s in %s, attempting automatic remediation", vmName, resourceGroupName) if deleteErr := deleteVirtualMachineForExtension(ctx, client, resourceGroupName, vmName); deleteErr != nil { return fmt.Errorf("failed to remediate ghost VM %s before retry: %w", vmName, deleteErr) } time.Sleep(20 * time.Second) return createVirtualMachineForExtensionWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, remediationAttempt+1) } return fmt.Errorf("vm creation conflict for %s and failed to verify existing VM: %w", vmName, getErr) } return fmt.Errorf("failed to begin creating virtual machine: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual machine: %w", err) } // Verify the VM was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("VM created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("VM provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Virtual machine %s created successfully with provisioning state: %s", vmName, provisioningState) return nil } // waitForVMAvailableForExtension polls until the VM is available via the Get API func waitForVMAvailableForExtension(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName string) error { maxAttempts := defaultMaxPollAttempts pollInterval := defaultPollInterval maxNotFoundAttempts := 5 log.Printf("Waiting for VM %s to be available via API...", vmName) notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, vmName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("VM %s not found after %d attempts (possible stale conflict or failed creation)", vmName, notFoundCount) } log.Printf("VM %s not yet available (attempt %d/%d), waiting %v...", vmName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking VM availability: %w", err) } notFoundCount = 0 // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("VM %s is available with provisioning state: %s", vmName, state) return nil } if state == "Failed" { return fmt.Errorf("VM provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("VM %s provisioning state: %s (attempt %d/%d), waiting...", vmName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // VM exists but no provisioning state - consider it available log.Printf("VM %s is available", vmName) return nil } return fmt.Errorf("timeout waiting for VM %s to be available after %d attempts", vmName, maxAttempts) } // createVirtualMachineExtension creates an Azure virtual machine extension (idempotent) func createVirtualMachineExtension(ctx context.Context, client *armcompute.VirtualMachineExtensionsClient, resourceGroupName, vmName, extensionName, location string) error { // Check if extension already exists _, err := client.Get(ctx, resourceGroupName, vmName, extensionName, nil) if err == nil { log.Printf("Virtual machine extension %s already exists, skipping creation", extensionName) return nil } // Create the extension with CustomScript extension // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-extensions/create-or-update?view=rest-compute-2025-04-01&tabs=HTTP poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmName, extensionName, armcompute.VirtualMachineExtension{ Location: new(location), Properties: &armcompute.VirtualMachineExtensionProperties{ Publisher: new("Microsoft.Azure.Extensions"), Type: new("CustomScript"), TypeHandlerVersion: new("2.1"), Settings: map[string]any{ "commandToExecute": "echo 'Hello from Overmind integration test'", }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-extension"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Virtual machine extension %s already exists (conflict), skipping creation", extensionName) return nil } return fmt.Errorf("failed to begin creating virtual machine extension: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual machine extension: %w", err) } log.Printf("Virtual machine extension %s created successfully", extensionName) return nil } // waitForExtensionAvailable polls until the extension is available via the Get API func waitForExtensionAvailable(ctx context.Context, client *armcompute.VirtualMachineExtensionsClient, resourceGroupName, vmName, extensionName string) error { maxAttempts := 10 pollInterval := 5 * time.Second log.Printf("Waiting for extension %s to be available via API...", extensionName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, vmName, extensionName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Extension %s not yet available (attempt %d/%d), waiting %v...", extensionName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking extension availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Extension %s is available with provisioning state: %s", extensionName, state) return nil } if state == "Failed" { return fmt.Errorf("Extension provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Extension %s provisioning state: %s (attempt %d/%d), waiting...", extensionName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Extension exists but no provisioning state - consider it available log.Printf("Extension %s is available", extensionName) return nil } return fmt.Errorf("timeout waiting for extension %s to be available after %d attempts", extensionName, maxAttempts) } // deleteVirtualMachineExtension deletes an Azure virtual machine extension func deleteVirtualMachineExtension(ctx context.Context, client *armcompute.VirtualMachineExtensionsClient, resourceGroupName, vmName, extensionName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vmName, extensionName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual machine extension %s not found, skipping deletion", extensionName) return nil } return fmt.Errorf("failed to begin deleting virtual machine extension: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual machine extension: %w", err) } log.Printf("Virtual machine extension %s deleted successfully", extensionName) return nil } // deleteVirtualMachineForExtension deletes an Azure virtual machine func deleteVirtualMachineForExtension(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName string) error { // Use forceDeletion to speed up cleanup poller, err := client.BeginDelete(ctx, resourceGroupName, vmName, &armcompute.VirtualMachinesClientBeginDeleteOptions{ ForceDeletion: new(true), }) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual machine %s not found, skipping deletion", vmName) return nil } return fmt.Errorf("failed to begin deleting virtual machine: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual machine: %w", err) } log.Printf("Virtual machine %s deleted successfully", vmName) // Wait a bit to allow Azure to release associated resources log.Printf("Waiting 30 seconds for Azure to release associated resources...") time.Sleep(30 * time.Second) return nil } // deleteNetworkInterfaceForExtension deletes an Azure network interface with retry logic func deleteNetworkInterfaceForExtension(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName string) error { maxRetries := 4 retryDelay := 60 * time.Second for attempt := 1; attempt <= maxRetries; attempt++ { poller, err := client.BeginDelete(ctx, resourceGroupName, nicName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusNotFound { log.Printf("Network interface %s not found, skipping deletion", nicName) return nil } // Handle NicReservedForAnotherVm error - retry after delay if respErr.ErrorCode == "NicReservedForAnotherVm" && attempt < maxRetries { log.Printf("NIC %s is reserved, waiting %v before retry (attempt %d/%d)", nicName, retryDelay, attempt, maxRetries) time.Sleep(retryDelay) continue } } return fmt.Errorf("failed to begin deleting network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete network interface: %w", err) } log.Printf("Network interface %s deleted successfully", nicName) return nil } return fmt.Errorf("failed to delete network interface %s after %d attempts", nicName, maxRetries) } // deleteVirtualNetworkForExtension deletes an Azure virtual network func deleteVirtualNetworkForExtension(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-virtual-machine-run-command_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) const ( integrationTestRunCommandVMName = "ovm-integ-test-rc-vm" integrationTestRunCommandNICName = "ovm-integ-test-rc-nic" integrationTestRunCommandVNetName = "ovm-integ-test-rc-vnet" integrationTestRunCommandSubnetName = "default" integrationTestRunCommandName = "ovm-integ-test-run-command" integrationTestRunCommandPIPName = "ovm-integ-test-rc-pip" ) func TestComputeVirtualMachineRunCommandIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients vmClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Machines client: %v", err) } runCommandClient, err := armcompute.NewVirtualMachineRunCommandsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Machine Run Commands client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } nicClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Interfaces client: %v", err) } pipClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network err = createVirtualNetworkForRunCommand(ctx, vnetClient, integrationTestResourceGroup, integrationTestRunCommandVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Create public IP for outbound connectivity (required for VM agent communication) err = createPublicIPForRunCommand(ctx, pipClient, integrationTestResourceGroup, integrationTestRunCommandPIPName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP address: %v", err) } pipResp, err := pipClient.Get(ctx, integrationTestResourceGroup, integrationTestRunCommandPIPName, nil) if err != nil { t.Fatalf("Failed to get public IP address: %v", err) } // Get subnet ID for NIC creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestRunCommandVNetName, integrationTestRunCommandSubnetName, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create network interface with public IP attached err = createNetworkInterfaceForRunCommand(ctx, nicClient, integrationTestResourceGroup, integrationTestRunCommandNICName, integrationTestLocation, *subnetResp.ID, *pipResp.ID) if err != nil { t.Fatalf("Failed to create network interface: %v", err) } // Get NIC ID for VM creation nicResp, err := nicClient.Get(ctx, integrationTestResourceGroup, integrationTestRunCommandNICName, nil) if err != nil { t.Fatalf("Failed to get network interface: %v", err) } // Create virtual machine err = createVirtualMachineForRunCommand(ctx, vmClient, integrationTestResourceGroup, integrationTestRunCommandVMName, integrationTestLocation, *nicResp.ID) if err != nil { t.Fatalf("Failed to create virtual machine: %v", err) } // Wait for VM to be fully available via the API err = waitForVMAvailableForRunCommand(ctx, vmClient, integrationTestResourceGroup, integrationTestRunCommandVMName) if err != nil { t.Fatalf("Failed waiting for VM to be available: %v", err) } // Create run command. This depends on the VM agent being able to // communicate with Azure, which consistently fails in CI with // VMAgentStatusCommunicationError. Skip gracefully when that happens. err = createVirtualMachineRunCommand(ctx, runCommandClient, integrationTestResourceGroup, integrationTestRunCommandVMName, integrationTestRunCommandName, integrationTestLocation) if err != nil { t.Skipf("Skipping: VM agent cannot execute run command (Azure infrastructure issue): %v", err) } // Wait for run command to be available err = waitForRunCommandAvailable(ctx, runCommandClient, integrationTestResourceGroup, integrationTestRunCommandVMName, integrationTestRunCommandName) if err != nil { t.Skipf("Skipping: run command not available (Azure infrastructure issue): %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetVirtualMachineRunCommand", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving virtual machine run command %s for VM %s in subscription %s, resource group %s", integrationTestRunCommandName, integrationTestRunCommandVMName, subscriptionID, integrationTestResourceGroup) runCommandWrapper := manual.NewComputeVirtualMachineRunCommand( clients.NewVirtualMachineRunCommandsClient(runCommandClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := runCommandWrapper.Scopes()[0] runCommandAdapter := sources.WrapperToAdapter(runCommandWrapper, sdpcache.NewNoOpCache()) // Get requires virtualMachineName and runCommandName as query parts query := integrationTestRunCommandVMName + shared.QuerySeparator + integrationTestRunCommandName sdpItem, qErr := runCommandAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttr := shared.CompositeLookupKey(integrationTestRunCommandVMName, integrationTestRunCommandName) if uniqueAttrValue != expectedUniqueAttr { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedUniqueAttr, uniqueAttrValue) } // Verify the run command name attribute nameAttr, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get name attribute: %v", err) } if nameAttr != integrationTestRunCommandName { t.Fatalf("Expected name attribute to be %s, got %s", integrationTestRunCommandName, nameAttr) } log.Printf("Successfully retrieved virtual machine run command %s", integrationTestRunCommandName) }) t.Run("SearchVirtualMachineRunCommands", func(t *testing.T) { ctx := t.Context() log.Printf("Searching virtual machine run commands for VM %s", integrationTestRunCommandVMName) runCommandWrapper := manual.NewComputeVirtualMachineRunCommand( clients.NewVirtualMachineRunCommandsClient(runCommandClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := runCommandWrapper.Scopes()[0] runCommandAdapter := sources.WrapperToAdapter(runCommandWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := runCommandAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestRunCommandVMName, true) if err != nil { t.Fatalf("Failed to search virtual machine run commands: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one virtual machine run command, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() uniqueAttrValue, err := item.GetAttributes().Get(uniqueAttrKey) if err != nil { continue } expectedUniqueAttr := shared.CompositeLookupKey(integrationTestRunCommandVMName, integrationTestRunCommandName) if uniqueAttrValue == expectedUniqueAttr { found = true break } } if !found { t.Fatalf("Expected to find run command %s in the search results", integrationTestRunCommandName) } log.Printf("Found %d virtual machine run commands in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for virtual machine run command %s", integrationTestRunCommandName) runCommandWrapper := manual.NewComputeVirtualMachineRunCommand( clients.NewVirtualMachineRunCommandsClient(runCommandClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := runCommandWrapper.Scopes()[0] runCommandAdapter := sources.WrapperToAdapter(runCommandWrapper, sdpcache.NewNoOpCache()) query := integrationTestRunCommandVMName + shared.QuerySeparator + integrationTestRunCommandName sdpItem, qErr := runCommandAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (VM should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasVMLink bool for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.ComputeVirtualMachine.String(): hasVMLink = true // Verify VM link properties if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected VM link method to be GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() != integrationTestRunCommandVMName { t.Errorf("Expected VM link query to be %s, got %s", integrationTestRunCommandVMName, liq.GetQuery().GetQuery()) } case azureshared.StorageAccount.String(): // Storage account links may be present if outputBlobUri, errorBlobUri, or scriptUri are set case azureshared.StorageBlobContainer.String(): // Blob container links may be present if outputBlobUri, errorBlobUri, or scriptUri are set case stdlib.NetworkHTTP.String(): // HTTP links may be present if scriptUri is HTTP/HTTPS case stdlib.NetworkDNS.String(): // DNS links may be present if scriptUri contains a DNS name } } if !hasVMLink { t.Error("Expected linked query to virtual machine, but didn't find one") } log.Printf("Verified %d linked item queries for run command %s", len(linkedQueries), integrationTestRunCommandName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete run command first. Non-fatal: if the VM agent is unresponsive // (VMAgentStatusCommunicationError) this will timeout after 5 min. // Force-deleting the VM below will clean up the run command anyway. err := deleteVirtualMachineRunCommand(ctx, runCommandClient, integrationTestResourceGroup, integrationTestRunCommandVMName, integrationTestRunCommandName) if err != nil { t.Logf("Warning: failed to delete run command (will be cleaned up with VM): %v", err) } // Delete VM (it must be deleted before NIC can be deleted) err = deleteVirtualMachineForRunCommand(ctx, vmClient, integrationTestResourceGroup, integrationTestRunCommandVMName) if err != nil { t.Fatalf("Failed to delete virtual machine: %v", err) } // Delete NIC err = deleteNetworkInterfaceForRunCommand(ctx, nicClient, integrationTestResourceGroup, integrationTestRunCommandNICName) if err != nil { t.Fatalf("Failed to delete network interface: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForRunCommand(ctx, vnetClient, integrationTestResourceGroup, integrationTestRunCommandVNetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } // Delete public IP (must be after NIC deletion since NIC references it) err = deletePublicIPForRunCommand(ctx, pipClient, integrationTestResourceGroup, integrationTestRunCommandPIPName) if err != nil { t.Fatalf("Failed to delete public IP address: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createVirtualNetworkForRunCommand creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetworkForRunCommand(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.1.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestRunCommandSubnetName), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.1.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-run-command"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // createNetworkInterfaceForRunCommand creates an Azure network interface with a public IP (idempotent) func createNetworkInterfaceForRunCommand(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName, location, subnetID, publicIPID string) error { // Check if NIC already exists and has the public IP attached existing, err := client.Get(ctx, resourceGroupName, nicName, nil) if err == nil { hasPublicIP := false if existing.Properties != nil { for _, ipConfig := range existing.Properties.IPConfigurations { if ipConfig.Properties != nil && ipConfig.Properties.PublicIPAddress != nil { hasPublicIP = true break } } } if hasPublicIP { log.Printf("Network interface %s already exists with public IP, skipping creation", nicName) return nil } log.Printf("Network interface %s exists without public IP, updating it", nicName) } // Create the NIC with a public IP for outbound connectivity. // The VM agent requires outbound access to Azure management endpoints; // without a public IP or NAT gateway, run command operations fail with // VMAgentStatusCommunicationError. poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, nicName, armnetwork.Interface{ Location: new(location), Properties: &armnetwork.InterfacePropertiesFormat{ IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new("ipconfig1"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-run-command"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create network interface: %w", err) } log.Printf("Network interface %s created successfully", nicName) return nil } // createVirtualMachineForRunCommand creates an Azure virtual machine (idempotent) func createVirtualMachineForRunCommand(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID string) error { return createVirtualMachineForRunCommandWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, 0) } func createVirtualMachineForRunCommandWithRemediation(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID string, remediationAttempt int) error { // Check if VM already exists existingVM, err := client.Get(ctx, resourceGroupName, vmName, nil) if err == nil { // VM exists, check its state if existingVM.Properties != nil && existingVM.Properties.ProvisioningState != nil { state := *existingVM.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Virtual machine %s already exists with state %s, skipping creation", vmName, state) return nil } log.Printf("Virtual machine %s exists but in state %s, will wait for it", vmName, state) } else { log.Printf("Virtual machine %s already exists, skipping creation", vmName) return nil } } // Create the VM poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmName, armcompute.VirtualMachine{ Location: new(location), Properties: &armcompute.VirtualMachineProperties{ HardwareProfile: &armcompute.HardwareProfile{ VMSize: new(armcompute.VirtualMachineSizeTypes("Standard_D2s_v3")), }, StorageProfile: &armcompute.StorageProfile{ ImageReference: &armcompute.ImageReference{ Publisher: new("Canonical"), Offer: new("0001-com-ubuntu-server-jammy"), SKU: new("22_04-lts"), Version: new("latest"), }, OSDisk: &armcompute.OSDisk{ Name: new(fmt.Sprintf("%s-osdisk", vmName)), CreateOption: new(armcompute.DiskCreateOptionTypesFromImage), ManagedDisk: &armcompute.ManagedDiskParameters{ StorageAccountType: new(armcompute.StorageAccountTypesStandardLRS), }, DeleteOption: new(armcompute.DiskDeleteOptionTypesDelete), }, }, OSProfile: &armcompute.OSProfile{ ComputerName: new(vmName), AdminUsername: new("azureuser"), // Use password authentication for integration tests (simpler than SSH keys) AdminPassword: new("OvmIntegTest2024!"), LinuxConfiguration: &armcompute.LinuxConfiguration{ DisablePasswordAuthentication: new(false), }, }, NetworkProfile: &armcompute.NetworkProfile{ NetworkInterfaces: []*armcompute.NetworkInterfaceReference{ { ID: new(nicID), Properties: &armcompute.NetworkInterfaceReferenceProperties{ Primary: new(true), }, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-run-command"), }, }, nil) if err != nil { // Check if VM already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { existing, getErr := client.Get(ctx, resourceGroupName, vmName, nil) if getErr == nil { if existing.Properties != nil && existing.Properties.ProvisioningState != nil { log.Printf("Virtual machine %s already exists (conflict) with state %s, skipping creation", vmName, *existing.Properties.ProvisioningState) } else { log.Printf("Virtual machine %s already exists (conflict), skipping creation", vmName) } return nil } var getRespErr *azcore.ResponseError if errors.As(getErr, &getRespErr) && getRespErr.StatusCode == http.StatusNotFound { if remediationAttempt >= 1 { return fmt.Errorf("vm %s still in ghost conflict state after remediation (resourceGroup=%s): %w", vmName, resourceGroupName, err) } log.Printf("Detected ghost VM conflict for run-command test VM %s in %s, attempting automatic remediation", vmName, resourceGroupName) if deleteErr := deleteVirtualMachineForRunCommand(ctx, client, resourceGroupName, vmName); deleteErr != nil { return fmt.Errorf("failed to remediate ghost VM %s before retry: %w", vmName, deleteErr) } time.Sleep(20 * time.Second) return createVirtualMachineForRunCommandWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, remediationAttempt+1) } return fmt.Errorf("vm creation conflict for %s and failed to verify existing VM: %w", vmName, getErr) } return fmt.Errorf("failed to begin creating virtual machine: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual machine: %w", err) } // Verify the VM was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("VM created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("VM provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Virtual machine %s created successfully with provisioning state: %s", vmName, provisioningState) return nil } // waitForVMAvailableForRunCommand polls until the VM is available via the Get API func waitForVMAvailableForRunCommand(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName string) error { maxAttempts := defaultMaxPollAttempts pollInterval := defaultPollInterval maxNotFoundAttempts := 5 log.Printf("Waiting for VM %s to be available via API...", vmName) notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, vmName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("VM %s not found after %d attempts (possible stale conflict or failed creation)", vmName, notFoundCount) } log.Printf("VM %s not yet available (attempt %d/%d), waiting %v...", vmName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking VM availability: %w", err) } notFoundCount = 0 // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("VM %s is available with provisioning state: %s", vmName, state) return nil } if state == "Failed" { return fmt.Errorf("VM provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("VM %s provisioning state: %s (attempt %d/%d), waiting...", vmName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // VM exists but no provisioning state - consider it available log.Printf("VM %s is available", vmName) return nil } return fmt.Errorf("timeout waiting for VM %s to be available after %d attempts", vmName, maxAttempts) } // createVirtualMachineRunCommand creates an Azure virtual machine run command (idempotent) func createVirtualMachineRunCommand(ctx context.Context, client *armcompute.VirtualMachineRunCommandsClient, resourceGroupName, vmName, runCommandName, location string) error { // Check if run command already exists existing, err := client.GetByVirtualMachine(ctx, resourceGroupName, vmName, runCommandName, nil) if err == nil { // If the existing run command is in a Failed state (e.g. from a previous // run with VMAgentStatusCommunicationError), delete and recreate it. if existing.Properties != nil && existing.Properties.ProvisioningState != nil && *existing.Properties.ProvisioningState == "Failed" { log.Printf("Virtual machine run command %s exists in Failed state, deleting before recreate", runCommandName) if delErr := deleteVirtualMachineRunCommand(ctx, client, resourceGroupName, vmName, runCommandName); delErr != nil { log.Printf("Warning: failed to delete stale run command: %v", delErr) } } else { log.Printf("Virtual machine run command %s already exists, skipping creation", runCommandName) return nil } } // Create the run command with a simple shell script // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-run-commands/create-or-update?view=rest-compute-2025-04-01&tabs=HTTP poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmName, runCommandName, armcompute.VirtualMachineRunCommand{ Location: new(location), Properties: &armcompute.VirtualMachineRunCommandProperties{ Source: &armcompute.VirtualMachineRunCommandScriptSource{ Script: new("#!/bin/bash\necho 'Hello from Overmind integration test'\n"), }, AsyncExecution: new(false), RunAsUser: new("azureuser"), TimeoutInSeconds: new(int32(3600)), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-run-command"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Virtual machine run command %s already exists (conflict), skipping creation", runCommandName) return nil } return fmt.Errorf("failed to begin creating virtual machine run command: %w", err) } // Use a short timeout: if the VM agent is healthy this completes in <2 min. // VMAgentStatusCommunicationError hangs for ~25 min otherwise. pollCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() _, err = poller.PollUntilDone(pollCtx, nil) if err != nil { return fmt.Errorf("failed to create virtual machine run command: %w", err) } log.Printf("Virtual machine run command %s created successfully", runCommandName) return nil } // waitForRunCommandAvailable polls until the run command is available via the Get API func waitForRunCommandAvailable(ctx context.Context, client *armcompute.VirtualMachineRunCommandsClient, resourceGroupName, vmName, runCommandName string) error { maxAttempts := 10 pollInterval := 5 * time.Second log.Printf("Waiting for run command %s to be available via API...", runCommandName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.GetByVirtualMachine(ctx, resourceGroupName, vmName, runCommandName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Run command %s not yet available (attempt %d/%d), waiting %v...", runCommandName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking run command availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Run command %s is available with provisioning state: %s", runCommandName, state) return nil } if state == "Failed" { return fmt.Errorf("Run command provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Run command %s provisioning state: %s (attempt %d/%d), waiting...", runCommandName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Run command exists but no provisioning state - consider it available log.Printf("Run command %s is available", runCommandName) return nil } return fmt.Errorf("timeout waiting for run command %s to be available after %d attempts", runCommandName, maxAttempts) } // deleteVirtualMachineRunCommand deletes an Azure virtual machine run command func deleteVirtualMachineRunCommand(ctx context.Context, client *armcompute.VirtualMachineRunCommandsClient, resourceGroupName, vmName, runCommandName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vmName, runCommandName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual machine run command %s not found, skipping deletion", runCommandName) return nil } return fmt.Errorf("failed to begin deleting virtual machine run command: %w", err) } // Use a short timeout: VMAgentStatusCommunicationError hangs for ~25 min. // The run command will be cleaned up when the VM is force-deleted. pollCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() _, err = poller.PollUntilDone(pollCtx, nil) if err != nil { return fmt.Errorf("failed to delete virtual machine run command: %w", err) } log.Printf("Virtual machine run command %s deleted successfully", runCommandName) return nil } // deleteVirtualMachineForRunCommand deletes an Azure virtual machine func deleteVirtualMachineForRunCommand(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName string) error { // Use forceDeletion to speed up cleanup poller, err := client.BeginDelete(ctx, resourceGroupName, vmName, &armcompute.VirtualMachinesClientBeginDeleteOptions{ ForceDeletion: new(true), }) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual machine %s not found, skipping deletion", vmName) return nil } return fmt.Errorf("failed to begin deleting virtual machine: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual machine: %w", err) } log.Printf("Virtual machine %s deleted successfully", vmName) // Wait a bit to allow Azure to release associated resources log.Printf("Waiting 30 seconds for Azure to release associated resources...") time.Sleep(30 * time.Second) return nil } // deleteNetworkInterfaceForRunCommand deletes an Azure network interface with retry logic func deleteNetworkInterfaceForRunCommand(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName string) error { maxRetries := 4 retryDelay := 60 * time.Second for attempt := 1; attempt <= maxRetries; attempt++ { poller, err := client.BeginDelete(ctx, resourceGroupName, nicName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusNotFound { log.Printf("Network interface %s not found, skipping deletion", nicName) return nil } // Handle NicReservedForAnotherVm error - retry after delay if respErr.ErrorCode == "NicReservedForAnotherVm" && attempt < maxRetries { log.Printf("NIC %s is reserved, waiting %v before retry (attempt %d/%d)", nicName, retryDelay, attempt, maxRetries) time.Sleep(retryDelay) continue } } return fmt.Errorf("failed to begin deleting network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete network interface: %w", err) } log.Printf("Network interface %s deleted successfully", nicName) return nil } return fmt.Errorf("failed to delete network interface %s after %d attempts", nicName, maxRetries) } // deleteVirtualNetworkForRunCommand deletes an Azure virtual network func deleteVirtualNetworkForRunCommand(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } // createPublicIPForRunCommand creates a Standard SKU public IP address (idempotent) func createPublicIPForRunCommand(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName, location string) error { _, err := client.Get(ctx, resourceGroupName, publicIPName, nil) if err == nil { log.Printf("Public IP address %s already exists, skipping creation", publicIPName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, publicIPName, armnetwork.PublicIPAddress{ Location: new(location), Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), }, SKU: &armnetwork.PublicIPAddressSKU{ Name: new(armnetwork.PublicIPAddressSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-run-command"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Public IP address %s already exists (conflict), skipping creation", publicIPName) return nil } return fmt.Errorf("failed to begin creating public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create public IP address: %w", err) } log.Printf("Public IP address %s created successfully", publicIPName) return nil } // deletePublicIPForRunCommand deletes an Azure public IP address func deletePublicIPForRunCommand(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, publicIPName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP address %s not found, skipping deletion", publicIPName) return nil } return fmt.Errorf("failed to begin deleting public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete public IP address: %w", err) } log.Printf("Public IP address %s deleted successfully", publicIPName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-virtual-machine-scale-set_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestVMSSName = "ovm-integ-test-vmss" integrationTestVMSSVNetName = "ovm-integ-test-vmss-vnet" integrationTestVMSSSubnetName = "default" ) func TestComputeVirtualMachineScaleSetIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients vmssClient, err := armcompute.NewVirtualMachineScaleSetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Machine Scale Sets client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network err = createVirtualNetworkForVMSS(ctx, vnetClient, integrationTestResourceGroup, integrationTestVMSSVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for VMSS creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestVMSSVNetName, integrationTestVMSSSubnetName, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create virtual machine scale set err = createVirtualMachineScaleSet(ctx, vmssClient, integrationTestResourceGroup, integrationTestVMSSName, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create virtual machine scale set: %v", err) } // Wait for VMSS to be fully available via the API err = waitForVMSSAvailable(ctx, vmssClient, integrationTestResourceGroup, integrationTestVMSSName) if err != nil { t.Fatalf("Failed waiting for VMSS to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { // Check if VMSS exists - if Setup failed (e.g., quota issues), skip Run tests ctx := t.Context() _, err := vmssClient.Get(ctx, integrationTestResourceGroup, integrationTestVMSSName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { t.Skipf("VMSS %s does not exist - Setup may have failed (e.g., quota issues). Skipping Run tests.", integrationTestVMSSName) } } t.Run("GetVirtualMachineScaleSet", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving virtual machine scale set %s in subscription %s, resource group %s", integrationTestVMSSName, subscriptionID, integrationTestResourceGroup) vmssWrapper := manual.NewComputeVirtualMachineScaleSet( clients.NewVirtualMachineScaleSetsClient(vmssClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vmssWrapper.Scopes()[0] vmssAdapter := sources.WrapperToAdapter(vmssWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := vmssAdapter.Get(ctx, scope, integrationTestVMSSName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestVMSSName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestVMSSName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.ComputeVirtualMachineScaleSet.String() { t.Fatalf("Expected type %s, got %s", azureshared.ComputeVirtualMachineScaleSet, sdpItem.GetType()) } log.Printf("Successfully retrieved virtual machine scale set %s", integrationTestVMSSName) }) t.Run("ListVirtualMachineScaleSets", func(t *testing.T) { ctx := t.Context() log.Printf("Listing virtual machine scale sets in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) vmssWrapper := manual.NewComputeVirtualMachineScaleSet( clients.NewVirtualMachineScaleSetsClient(vmssClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vmssWrapper.Scopes()[0] vmssAdapter := sources.WrapperToAdapter(vmssWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := vmssAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list virtual machine scale sets: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one virtual machine scale set, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestVMSSName { found = true if item.GetType() != azureshared.ComputeVirtualMachineScaleSet.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeVirtualMachineScaleSet, item.GetType()) } break } } if !found { t.Fatalf("Expected to find VMSS %s in the list of virtual machine scale sets", integrationTestVMSSName) } log.Printf("Found %d virtual machine scale sets in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for virtual machine scale set %s", integrationTestVMSSName) vmssWrapper := manual.NewComputeVirtualMachineScaleSet( clients.NewVirtualMachineScaleSetsClient(vmssClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vmssWrapper.Scopes()[0] vmssAdapter := sources.WrapperToAdapter(vmssWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := vmssAdapter.Get(ctx, scope, integrationTestVMSSName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasSubnetLink, hasVMLink bool for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.NetworkSubnet.String(): hasSubnetLink = true // Verify subnet link properties if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected subnet link method to be GET, got %s", liq.GetQuery().GetMethod()) } case azureshared.ComputeVirtualMachine.String(): hasVMLink = true // Verify VM link properties (VM instances are linked via SEARCH) if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected VM link method to be SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() != integrationTestVMSSName { t.Errorf("Expected VM link query to be %s, got %s", integrationTestVMSSName, liq.GetQuery().GetQuery()) } case azureshared.ComputeVirtualMachineExtension.String(): // Extensions may or may not be present depending on VMSS setup // Verify extension link properties if present if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected extension link method to be GET, got %s", liq.GetQuery().GetMethod()) } } } if !hasSubnetLink { t.Error("Expected linked query to subnet, but didn't find one") } // VM instances link should always be present (even if no instances exist) if !hasVMLink { t.Error("Expected linked query to VM instances, but didn't find one") } log.Printf("Verified %d linked item queries for VMSS %s", len(linkedQueries), integrationTestVMSSName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for VMSS %s", integrationTestVMSSName) vmssWrapper := manual.NewComputeVirtualMachineScaleSet( clients.NewVirtualMachineScaleSetsClient(vmssClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vmssWrapper.Scopes()[0] vmssAdapter := sources.WrapperToAdapter(vmssWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := vmssAdapter.Get(ctx, scope, integrationTestVMSSName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.ComputeVirtualMachineScaleSet.String() { t.Errorf("Expected item type %s, got %s", azureshared.ComputeVirtualMachineScaleSet, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } // Verify health status (should be OK if provisioning succeeded) if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Logf("VMSS health status is %s (may be pending if still provisioning)", sdpItem.GetHealth()) } log.Printf("Verified item attributes for VMSS %s", integrationTestVMSSName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete VMSS first err := deleteVirtualMachineScaleSet(ctx, vmssClient, integrationTestResourceGroup, integrationTestVMSSName) if err != nil { t.Fatalf("Failed to delete virtual machine scale set: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForVMSS(ctx, vnetClient, integrationTestResourceGroup, integrationTestVMSSVNetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createVirtualNetworkForVMSS creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetworkForVMSS(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.1.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestVMSSSubnetName), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.1.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // createVirtualMachineScaleSet creates an Azure virtual machine scale set (idempotent) func createVirtualMachineScaleSet(ctx context.Context, client *armcompute.VirtualMachineScaleSetsClient, resourceGroupName, vmssName, location, subnetID string) error { // Check if VMSS already exists existingVMSS, err := client.Get(ctx, resourceGroupName, vmssName, nil) if err == nil { // VMSS exists, check its state if existingVMSS.Properties != nil && existingVMSS.Properties.ProvisioningState != nil { state := *existingVMSS.Properties.ProvisioningState switch state { case "Succeeded", "Updating": // VMSS exists and is in a good state - we'll wait for it to be fully available log.Printf("Virtual machine scale set %s already exists with state %s, will verify availability", vmssName, state) return nil case "Failed", "Deleting", "Deleted": // VMSS is in a bad state - delete it so we can recreate log.Printf("Virtual machine scale set %s exists but in state %s, deleting before recreation", vmssName, state) deleteErr := deleteVirtualMachineScaleSet(ctx, client, resourceGroupName, vmssName) if deleteErr != nil { return fmt.Errorf("failed to delete VMSS in bad state: %w", deleteErr) } // Wait a bit after deletion before recreating time.Sleep(10 * time.Second) default: // Creating, etc. - wait for it log.Printf("Virtual machine scale set %s exists but in state %s, will wait for it", vmssName, state) return nil } } else { log.Printf("Virtual machine scale set %s already exists, will verify availability", vmssName) return nil } } // Create the VMSS poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmssName, armcompute.VirtualMachineScaleSet{ Location: new(location), SKU: &armcompute.SKU{ Name: new("Standard_D2s_v3"), Tier: new("Standard"), Capacity: new(int64(1)), // Start with 1 instance for testing }, Properties: &armcompute.VirtualMachineScaleSetProperties{ UpgradePolicy: &armcompute.UpgradePolicy{ Mode: new(armcompute.UpgradeModeManual), }, VirtualMachineProfile: &armcompute.VirtualMachineScaleSetVMProfile{ OSProfile: &armcompute.VirtualMachineScaleSetOSProfile{ ComputerNamePrefix: new(vmssName), AdminUsername: new("azureuser"), AdminPassword: new("OvmIntegTest2024!"), LinuxConfiguration: &armcompute.LinuxConfiguration{ DisablePasswordAuthentication: new(false), }, }, StorageProfile: &armcompute.VirtualMachineScaleSetStorageProfile{ ImageReference: &armcompute.ImageReference{ Publisher: new("Canonical"), Offer: new("0001-com-ubuntu-server-jammy"), SKU: new("22_04-lts"), // x64 image for B-series VM Version: new("latest"), }, OSDisk: &armcompute.VirtualMachineScaleSetOSDisk{ CreateOption: new(armcompute.DiskCreateOptionTypesFromImage), ManagedDisk: &armcompute.VirtualMachineScaleSetManagedDiskParameters{ StorageAccountType: new(armcompute.StorageAccountTypesStandardLRS), }, }, }, NetworkProfile: &armcompute.VirtualMachineScaleSetNetworkProfile{ NetworkInterfaceConfigurations: []*armcompute.VirtualMachineScaleSetNetworkConfiguration{ { Name: new("vmss-nic-config"), Properties: &armcompute.VirtualMachineScaleSetNetworkConfigurationProperties{ Primary: new(true), IPConfigurations: []*armcompute.VirtualMachineScaleSetIPConfiguration{ { Name: new("ipconfig1"), Properties: &armcompute.VirtualMachineScaleSetIPConfigurationProperties{ Subnet: &armcompute.APIEntityReference{ ID: new(subnetID), }, Primary: new(true), }, }, }, }, }, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-scale-set"), }, }, nil) if err != nil { // Check if VMSS already exists (conflict) or quota issue var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusConflict { log.Printf("Virtual machine scale set %s already exists (conflict), verifying it exists", vmssName) // Verify the VMSS actually exists _, getErr := client.Get(ctx, resourceGroupName, vmssName, nil) if getErr != nil { // If we get a conflict but VMSS doesn't exist, treat it as a ghost/stale control-plane record. // Try to remediate once by forcing a delete, then retry creation. log.Printf("VMSS %s not found after conflict, attempting remediation delete before retry", vmssName) if deleteErr := deleteVirtualMachineScaleSet(ctx, client, resourceGroupName, vmssName); deleteErr != nil { return fmt.Errorf("failed to remediate VMSS ghost state for %s: %w", vmssName, deleteErr) } time.Sleep(30 * time.Second) // Retry creation retryPoller, retryErr := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmssName, armcompute.VirtualMachineScaleSet{ Location: new(location), SKU: &armcompute.SKU{ Name: new("Standard_D2s_v3"), Tier: new("Standard"), Capacity: new(int64(1)), }, Properties: &armcompute.VirtualMachineScaleSetProperties{ UpgradePolicy: &armcompute.UpgradePolicy{ Mode: new(armcompute.UpgradeModeManual), }, VirtualMachineProfile: &armcompute.VirtualMachineScaleSetVMProfile{ OSProfile: &armcompute.VirtualMachineScaleSetOSProfile{ ComputerNamePrefix: new(vmssName), AdminUsername: new("azureuser"), AdminPassword: new("OvmIntegTest2024!"), LinuxConfiguration: &armcompute.LinuxConfiguration{ DisablePasswordAuthentication: new(false), }, }, StorageProfile: &armcompute.VirtualMachineScaleSetStorageProfile{ ImageReference: &armcompute.ImageReference{ Publisher: new("Canonical"), Offer: new("0001-com-ubuntu-server-jammy"), SKU: new("22_04-lts"), Version: new("latest"), }, OSDisk: &armcompute.VirtualMachineScaleSetOSDisk{ CreateOption: new(armcompute.DiskCreateOptionTypesFromImage), ManagedDisk: &armcompute.VirtualMachineScaleSetManagedDiskParameters{ StorageAccountType: new(armcompute.StorageAccountTypesStandardLRS), }, }, }, NetworkProfile: &armcompute.VirtualMachineScaleSetNetworkProfile{ NetworkInterfaceConfigurations: []*armcompute.VirtualMachineScaleSetNetworkConfiguration{ { Name: new("vmss-nic-config"), Properties: &armcompute.VirtualMachineScaleSetNetworkConfigurationProperties{ Primary: new(true), IPConfigurations: []*armcompute.VirtualMachineScaleSetIPConfiguration{ { Name: new("ipconfig1"), Properties: &armcompute.VirtualMachineScaleSetIPConfigurationProperties{ Subnet: &armcompute.APIEntityReference{ ID: new(subnetID), }, Primary: new(true), }, }, }, }, }, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine-scale-set"), }, }, nil) if retryErr != nil { var retryRespErr *azcore.ResponseError if errors.As(retryErr, &retryRespErr) && retryRespErr.StatusCode == http.StatusConflict { // Still conflict - check if it exists now _, finalCheckErr := client.Get(ctx, resourceGroupName, vmssName, nil) if finalCheckErr != nil { return fmt.Errorf("vmss %s still in ghost conflict state after remediation retry (resourceGroup=%s): %w", vmssName, resourceGroupName, retryErr) } log.Printf("VMSS %s exists after retry conflict", vmssName) return nil } return fmt.Errorf("failed to retry creating virtual machine scale set after conflict: %w", retryErr) } // Poll the retry poller retryResp, retryPollErr := retryPoller.PollUntilDone(ctx, nil) if retryPollErr != nil { return fmt.Errorf("failed to create virtual machine scale set on retry: %w", retryPollErr) } if retryResp.Properties != nil && retryResp.Properties.ProvisioningState != nil { log.Printf("Virtual machine scale set %s created successfully on retry with state: %s", vmssName, *retryResp.Properties.ProvisioningState) } // Successfully created on retry - return nil is correct here return nil } // getErr is nil, meaning VMSS exists - return nil is correct here // VMSS exists, will wait for it in waitForVMSSAvailable log.Printf("VMSS %s exists", vmssName) return nil } // Handle quota errors gracefully - log but don't fail the test setup if respErr.ErrorCode == "OperationNotAllowed" && strings.Contains(respErr.Error(), "quota") { log.Printf("VMSS creation failed due to quota limits: %s. Skipping VMSS creation for this test run.", respErr.Error()) return nil // Skip creation, test will fail gracefully in Run phase } } return fmt.Errorf("failed to begin creating virtual machine scale set: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual machine scale set: %w", err) } // Verify the VMSS was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("VMSS created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("VMSS provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Virtual machine scale set %s created successfully with provisioning state: %s", vmssName, provisioningState) return nil } // waitForVMSSAvailable polls until the VMSS is available via the Get API // This is needed because even after creation succeeds, there can be a delay before the VMSS is queryable func waitForVMSSAvailable(ctx context.Context, client *armcompute.VirtualMachineScaleSetsClient, resourceGroupName, vmssName string) error { maxAttempts := defaultMaxPollAttempts pollInterval := defaultPollInterval maxNotFoundAttempts := 5 // Fail faster if VMSS doesn't exist log.Printf("Waiting for VMSS %s to be available via API...", vmssName) notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, vmssName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusNotFound { notFoundCount++ // If VMSS doesn't exist, fail after a few attempts // This indicates the VMSS was never created or was deleted if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("VMSS %s not found after %d attempts - creation may have failed or VMSS was deleted", vmssName, notFoundCount) } // Early attempts might be transient, wait a bit if attempt < maxAttempts { log.Printf("VMSS %s not yet available (attempt %d/%d, not found %d/%d), waiting %v...", vmssName, attempt, maxAttempts, notFoundCount, maxNotFoundAttempts, pollInterval) time.Sleep(pollInterval) continue } } } return fmt.Errorf("error checking VMSS availability: %w", err) } // Reset not found count if we successfully found the VMSS notFoundCount = 0 // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState switch state { case "Succeeded": log.Printf("VMSS %s is available with provisioning state: %s", vmssName, state) return nil case "Failed": // If failed, log details but still consider it "available" for testing purposes // The test will fail if needed when trying to use it log.Printf("VMSS %s is in Failed state but will proceed with test", vmssName) return nil case "Deleting", "Deleted": // If being deleted or already deleted, this is a problem return fmt.Errorf("VMSS %s is in state %s - may need to be recreated", vmssName, state) default: // Still provisioning or in transition state, wait and retry if attempt < maxAttempts { log.Printf("VMSS %s provisioning state: %s (attempt %d/%d), waiting %v...", vmssName, state, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } // On last attempt, accept it as available even if not Succeeded // Some states like "Updating" might persist log.Printf("VMSS %s is in state %s after %d attempts, proceeding", vmssName, state, maxAttempts) return nil } } // VMSS exists but no provisioning state - consider it available log.Printf("VMSS %s is available (no provisioning state)", vmssName) return nil } return fmt.Errorf("timeout waiting for VMSS %s to be available after %d attempts", vmssName, maxAttempts) } // deleteVirtualMachineScaleSet deletes an Azure virtual machine scale set func deleteVirtualMachineScaleSet(ctx context.Context, client *armcompute.VirtualMachineScaleSetsClient, resourceGroupName, vmssName string) error { // Use forceDeletion to speed up cleanup poller, err := client.BeginDelete(ctx, resourceGroupName, vmssName, &armcompute.VirtualMachineScaleSetsClientBeginDeleteOptions{ ForceDeletion: new(true), }) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual machine scale set %s not found, skipping deletion", vmssName) return nil } return fmt.Errorf("failed to begin deleting virtual machine scale set: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual machine scale set: %w", err) } log.Printf("Virtual machine scale set %s deleted successfully", vmssName) // Wait a bit to allow Azure to release associated resources log.Printf("Waiting 30 seconds for Azure to release associated resources...") time.Sleep(30 * time.Second) return nil } // deleteVirtualNetworkForVMSS deletes an Azure virtual network func deleteVirtualNetworkForVMSS(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } ================================================ FILE: sources/azure/integration-tests/compute-virtual-machine_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestVMName = "ovm-integ-test-vm" integrationTestNICName = "ovm-integ-test-nic" integrationTestVNetName = "ovm-integ-test-vnet" integrationTestSubnetName = "default" defaultMaxPollAttempts = 20 defaultPollInterval = 15 * time.Second ) func TestComputeVirtualMachineIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients vmClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Machines client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } nicClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Interfaces client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network err = createVirtualNetwork(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for NIC creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestVNetName, integrationTestSubnetName, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create network interface err = createNetworkInterface(ctx, nicClient, integrationTestResourceGroup, integrationTestNICName, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create network interface: %v", err) } // Get NIC ID for VM creation nicResp, err := nicClient.Get(ctx, integrationTestResourceGroup, integrationTestNICName, nil) if err != nil { t.Fatalf("Failed to get network interface: %v", err) } // Create virtual machine err = createVirtualMachine(ctx, vmClient, integrationTestResourceGroup, integrationTestVMName, integrationTestLocation, *nicResp.ID) if err != nil { t.Fatalf("Failed to create virtual machine: %v", err) } // Wait for VM to be fully available via the API err = waitForVMAvailable(ctx, vmClient, integrationTestResourceGroup, integrationTestVMName) if err != nil { t.Fatalf("Failed waiting for VM to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetVirtualMachine", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving virtual machine %s in subscription %s, resource group %s", integrationTestVMName, subscriptionID, integrationTestResourceGroup) vmWrapper := manual.NewComputeVirtualMachine( clients.NewVirtualMachinesClient(vmClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vmWrapper.Scopes()[0] vmAdapter := sources.WrapperToAdapter(vmWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := vmAdapter.Get(ctx, scope, integrationTestVMName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestVMName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestVMName, uniqueAttrValue) } log.Printf("Successfully retrieved virtual machine %s", integrationTestVMName) }) t.Run("ListVirtualMachines", func(t *testing.T) { ctx := t.Context() log.Printf("Listing virtual machines in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) vmWrapper := manual.NewComputeVirtualMachine( clients.NewVirtualMachinesClient(vmClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vmWrapper.Scopes()[0] vmAdapter := sources.WrapperToAdapter(vmWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := vmAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list virtual machines: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one virtual machine, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestVMName { found = true break } } if !found { t.Fatalf("Expected to find VM %s in the list of virtual machines", integrationTestVMName) } log.Printf("Found %d virtual machines in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for virtual machine %s", integrationTestVMName) vmWrapper := manual.NewComputeVirtualMachine( clients.NewVirtualMachinesClient(vmClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vmWrapper.Scopes()[0] vmAdapter := sources.WrapperToAdapter(vmWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := vmAdapter.Get(ctx, scope, integrationTestVMName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (OS disk, NIC, run commands should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasDiskLink, hasNICLink, hasRunCommandLink bool for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.ComputeDisk.String(): hasDiskLink = true case azureshared.NetworkNetworkInterface.String(): hasNICLink = true case azureshared.ComputeVirtualMachineRunCommand.String(): hasRunCommandLink = true // Verify run command link properties if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected run command link method to be SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() != integrationTestVMName { t.Errorf("Expected run command link query to be %s, got %s", integrationTestVMName, liq.GetQuery().GetQuery()) } case azureshared.ComputeVirtualMachineExtension.String(): // Extensions may or may not be present depending on VM setup // Verify extension link properties if present if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected extension link method to be GET, got %s", liq.GetQuery().GetMethod()) } } } if !hasDiskLink { t.Error("Expected linked query to OS disk, but didn't find one") } if !hasNICLink { t.Error("Expected linked query to network interface, but didn't find one") } // Run commands link should always be present (even if no run commands exist) if !hasRunCommandLink { t.Error("Expected linked query to run commands, but didn't find one") } log.Printf("Verified %d linked item queries for VM %s", len(linkedQueries), integrationTestVMName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete VM first (it must be deleted before NIC can be deleted) err := deleteVirtualMachine(ctx, vmClient, integrationTestResourceGroup, integrationTestVMName) if err != nil { t.Fatalf("Failed to delete virtual machine: %v", err) } // Delete NIC err = deleteNetworkInterface(ctx, nicClient, integrationTestResourceGroup, integrationTestNICName) if err != nil { t.Fatalf("Failed to delete network interface: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetwork(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createVirtualNetwork creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetwork(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.0.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetName), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.0.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // createNetworkInterface creates an Azure network interface (idempotent) func createNetworkInterface(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName, location, subnetID string) error { // Check if NIC already exists _, err := client.Get(ctx, resourceGroupName, nicName, nil) if err == nil { log.Printf("Network interface %s already exists, skipping creation", nicName) return nil } // Create the NIC poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, nicName, armnetwork.Interface{ Location: new(location), Properties: &armnetwork.InterfacePropertiesFormat{ IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new("ipconfig1"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create network interface: %w", err) } log.Printf("Network interface %s created successfully", nicName) return nil } // createVirtualMachine creates an Azure virtual machine (idempotent) func createVirtualMachine(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID string) error { return createVirtualMachineWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, 0) } func createVirtualMachineWithRemediation(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName, location, nicID string, remediationAttempt int) error { // Check if VM already exists existingVM, err := client.Get(ctx, resourceGroupName, vmName, nil) if err == nil { // VM exists, check its state if existingVM.Properties != nil && existingVM.Properties.ProvisioningState != nil { state := *existingVM.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Virtual machine %s already exists with state %s, skipping creation", vmName, state) return nil } log.Printf("Virtual machine %s exists but in state %s, will wait for it", vmName, state) } else { log.Printf("Virtual machine %s already exists, skipping creation", vmName) return nil } } // Create the VM poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vmName, armcompute.VirtualMachine{ Location: new(location), Properties: &armcompute.VirtualMachineProperties{ HardwareProfile: &armcompute.HardwareProfile{ VMSize: new(armcompute.VirtualMachineSizeTypes("Standard_D2s_v3")), }, StorageProfile: &armcompute.StorageProfile{ ImageReference: &armcompute.ImageReference{ Publisher: new("Canonical"), Offer: new("0001-com-ubuntu-server-jammy"), SKU: new("22_04-lts"), Version: new("latest"), }, OSDisk: &armcompute.OSDisk{ Name: new(fmt.Sprintf("%s-osdisk", vmName)), CreateOption: new(armcompute.DiskCreateOptionTypesFromImage), ManagedDisk: &armcompute.ManagedDiskParameters{ StorageAccountType: new(armcompute.StorageAccountTypesStandardLRS), }, DeleteOption: new(armcompute.DiskDeleteOptionTypesDelete), }, }, OSProfile: &armcompute.OSProfile{ ComputerName: new(vmName), AdminUsername: new("azureuser"), // Use password authentication for integration tests (simpler than SSH keys) AdminPassword: new("OvmIntegTest2024!"), LinuxConfiguration: &armcompute.LinuxConfiguration{ DisablePasswordAuthentication: new(false), }, }, NetworkProfile: &armcompute.NetworkProfile{ NetworkInterfaces: []*armcompute.NetworkInterfaceReference{ { ID: new(nicID), Properties: &armcompute.NetworkInterfaceReferenceProperties{ Primary: new(true), }, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("compute-virtual-machine"), }, }, nil) if err != nil { // Check if VM already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { // Azure can return conflict while the VM is in a stale/ghost state. // Verify that the VM can actually be retrieved before treating this as success. existing, getErr := client.Get(ctx, resourceGroupName, vmName, nil) if getErr == nil { if existing.Properties != nil && existing.Properties.ProvisioningState != nil { log.Printf("Virtual machine %s already exists (conflict) with state %s, skipping creation", vmName, *existing.Properties.ProvisioningState) } else { log.Printf("Virtual machine %s already exists (conflict), skipping creation", vmName) } return nil } var getRespErr *azcore.ResponseError if errors.As(getErr, &getRespErr) && getRespErr.StatusCode == http.StatusNotFound { if remediationAttempt >= 1 { return fmt.Errorf("vm %s still in ghost conflict state after remediation (resourceGroup=%s): %w", vmName, resourceGroupName, err) } log.Printf("Detected ghost VM conflict for %s in %s, attempting automatic remediation", vmName, resourceGroupName) if deleteErr := deleteVirtualMachine(ctx, client, resourceGroupName, vmName); deleteErr != nil { return fmt.Errorf("failed to remediate ghost VM %s before retry: %w", vmName, deleteErr) } time.Sleep(20 * time.Second) return createVirtualMachineWithRemediation(ctx, client, resourceGroupName, vmName, location, nicID, remediationAttempt+1) } return fmt.Errorf("vm creation conflict for %s and failed to verify existing VM: %w", vmName, getErr) } return fmt.Errorf("failed to begin creating virtual machine: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual machine: %w", err) } // Verify the VM was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("VM created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("VM provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Virtual machine %s created successfully with provisioning state: %s", vmName, provisioningState) return nil } // waitForVMAvailable polls until the VM is available via the Get API // This is needed because even after creation succeeds, there can be a delay before the VM is queryable func waitForVMAvailable(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName string) error { maxAttempts := defaultMaxPollAttempts pollInterval := defaultPollInterval maxNotFoundAttempts := 5 log.Printf("Waiting for VM %s to be available via API...", vmName) notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, vmName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("VM %s not found after %d attempts (possible stale conflict or failed creation)", vmName, notFoundCount) } log.Printf("VM %s not yet available (attempt %d/%d), waiting %v...", vmName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking VM availability: %w", err) } notFoundCount = 0 // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("VM %s is available with provisioning state: %s", vmName, state) return nil } if state == "Failed" { return fmt.Errorf("VM provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("VM %s provisioning state: %s (attempt %d/%d), waiting...", vmName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // VM exists but no provisioning state - consider it available log.Printf("VM %s is available", vmName) return nil } return fmt.Errorf("timeout waiting for VM %s to be available after %d attempts", vmName, maxAttempts) } // deleteVirtualMachine deletes an Azure virtual machine func deleteVirtualMachine(ctx context.Context, client *armcompute.VirtualMachinesClient, resourceGroupName, vmName string) error { // Use forceDeletion to speed up cleanup poller, err := client.BeginDelete(ctx, resourceGroupName, vmName, &armcompute.VirtualMachinesClientBeginDeleteOptions{ ForceDeletion: new(true), }) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual machine %s not found, skipping deletion", vmName) return nil } return fmt.Errorf("failed to begin deleting virtual machine: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual machine: %w", err) } log.Printf("Virtual machine %s deleted successfully", vmName) // Wait a bit to allow Azure to release associated resources log.Printf("Waiting 30 seconds for Azure to release associated resources...") time.Sleep(30 * time.Second) return nil } // deleteNetworkInterface deletes an Azure network interface with retry logic // Azure reserves NICs for 180 seconds after VM deletion, so we may need to retry func deleteNetworkInterface(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName string) error { maxRetries := 4 retryDelay := 60 * time.Second for attempt := 1; attempt <= maxRetries; attempt++ { poller, err := client.BeginDelete(ctx, resourceGroupName, nicName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { if respErr.StatusCode == http.StatusNotFound { log.Printf("Network interface %s not found, skipping deletion", nicName) return nil } // Handle NicReservedForAnotherVm error - retry after delay if respErr.ErrorCode == "NicReservedForAnotherVm" && attempt < maxRetries { log.Printf("NIC %s is reserved, waiting %v before retry (attempt %d/%d)", nicName, retryDelay, attempt, maxRetries) time.Sleep(retryDelay) continue } } return fmt.Errorf("failed to begin deleting network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete network interface: %w", err) } log.Printf("Network interface %s deleted successfully", nicName) return nil } return fmt.Errorf("failed to delete network interface %s after %d attempts", nicName, maxRetries) } // deleteVirtualNetwork deletes an Azure virtual network func deleteVirtualNetwork(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } // Bound teardown latency so one stuck ARM delete does not consume the full suite timeout. deleteCtx, cancel := context.WithTimeout(ctx, 8*time.Minute) defer cancel() _, err = poller.PollUntilDone(deleteCtx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } ================================================ FILE: sources/azure/integration-tests/dbforpostgresql-database_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "math/rand" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestPostgreSQLServerName = "ovm-integ-test-pg-server" integrationTestPostgreSQLDatabaseName = "ovm-integ-test-database" ) func TestDBforPostgreSQLDatabaseIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients postgreSQLServerClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Servers client: %v", err) } postgreSQLDatabaseClient, err := armpostgresqlflexibleservers.NewDatabasesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Databases client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique PostgreSQL server name (must be globally unique, lowercase, no special chars) postgreSQLServerName := generatePostgreSQLServerName(integrationTestPostgreSQLServerName) setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create PostgreSQL Flexible Server err = createPostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, postgreSQLServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Server: %v", err) } // Wait for PostgreSQL server to be available err = waitForPostgreSQLServerAvailable(ctx, postgreSQLServerClient, integrationTestResourceGroup, postgreSQLServerName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL server to be available: %v", err) } // Create PostgreSQL database err = createPostgreSQLDatabase(ctx, postgreSQLDatabaseClient, integrationTestResourceGroup, postgreSQLServerName, integrationTestPostgreSQLDatabaseName) if err != nil { t.Fatalf("Failed to create PostgreSQL database: %v", err) } // Wait for PostgreSQL database to be available err = waitForPostgreSQLDatabaseAvailable(ctx, postgreSQLDatabaseClient, integrationTestResourceGroup, postgreSQLServerName, integrationTestPostgreSQLDatabaseName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL database to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetPostgreSQLDatabase", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving PostgreSQL database %s in server %s, subscription %s, resource group %s", integrationTestPostgreSQLDatabaseName, postgreSQLServerName, subscriptionID, integrationTestResourceGroup) pgDbWrapper := manual.NewDBforPostgreSQLDatabase( clients.NewPostgreSQLDatabasesClient(postgreSQLDatabaseClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pgDbWrapper.Scopes()[0] pgDbAdapter := sources.WrapperToAdapter(pgDbWrapper, sdpcache.NewNoOpCache()) // Get requires serverName and databaseName as query parts query := shared.CompositeLookupKey(postgreSQLServerName, integrationTestPostgreSQLDatabaseName) sdpItem, qErr := pgDbAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.DBforPostgreSQLDatabase.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLDatabase, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(postgreSQLServerName, integrationTestPostgreSQLDatabaseName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved PostgreSQL database %s", integrationTestPostgreSQLDatabaseName) }) t.Run("SearchPostgreSQLDatabases", func(t *testing.T) { ctx := t.Context() log.Printf("Searching PostgreSQL databases in server %s", postgreSQLServerName) pgDbWrapper := manual.NewDBforPostgreSQLDatabase( clients.NewPostgreSQLDatabasesClient(postgreSQLDatabaseClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pgDbWrapper.Scopes()[0] pgDbAdapter := sources.WrapperToAdapter(pgDbWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := pgDbAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, postgreSQLServerName, true) if err != nil { t.Fatalf("Failed to search PostgreSQL databases: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one PostgreSQL database, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { expectedValue := shared.CompositeLookupKey(postgreSQLServerName, integrationTestPostgreSQLDatabaseName) if v == expectedValue { found = true break } } } if !found { t.Fatalf("Expected to find database %s in the search results", integrationTestPostgreSQLDatabaseName) } log.Printf("Found %d PostgreSQL databases in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for PostgreSQL database %s", integrationTestPostgreSQLDatabaseName) pgDbWrapper := manual.NewDBforPostgreSQLDatabase( clients.NewPostgreSQLDatabasesClient(postgreSQLDatabaseClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pgDbWrapper.Scopes()[0] pgDbAdapter := sources.WrapperToAdapter(pgDbWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(postgreSQLServerName, integrationTestPostgreSQLDatabaseName) sdpItem, qErr := pgDbAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (PostgreSQL Flexible Server should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasPostgreSQLServerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() { hasPostgreSQLServerLink = true if liq.GetQuery().GetQuery() != postgreSQLServerName { t.Errorf("Expected linked query to PostgreSQL server %s, got %s", postgreSQLServerName, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } break } } if !hasPostgreSQLServerLink { t.Error("Expected linked query to PostgreSQL Flexible Server, but didn't find one") } log.Printf("Verified %d linked item queries for PostgreSQL database %s", len(linkedQueries), integrationTestPostgreSQLDatabaseName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete PostgreSQL database err := deletePostgreSQLDatabase(ctx, postgreSQLDatabaseClient, integrationTestResourceGroup, postgreSQLServerName, integrationTestPostgreSQLDatabaseName) if err != nil { t.Fatalf("Failed to delete PostgreSQL database: %v", err) } // Delete PostgreSQL Flexible Server err = deletePostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, postgreSQLServerName) if err != nil { t.Fatalf("Failed to delete PostgreSQL Flexible Server: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // generatePostgreSQLServerName generates a unique PostgreSQL Flexible Server name // PostgreSQL server names must be globally unique, 1-63 characters, lowercase letters, numbers, and hyphens func generatePostgreSQLServerName(baseName string) string { // Ensure base name is lowercase and valid baseName = strings.ToLower(baseName) // Remove any invalid characters (only alphanumeric and hyphens allowed) baseName = strings.ReplaceAll(baseName, "_", "-") // Remove any invalid characters baseName = strings.ReplaceAll(baseName, " ", "-") // Add random suffix to ensure uniqueness suffix := rand.Intn(10000) return fmt.Sprintf("%s-%d", baseName, suffix) } // createPostgreSQLFlexibleServer creates an Azure PostgreSQL Flexible Server (idempotent) func createPostgreSQLFlexibleServer(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName, location string) error { // Check if PostgreSQL server already exists _, err := client.Get(ctx, resourceGroupName, serverName, nil) if err == nil { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } // Get administrator credentials from environment variables // Note: PostgreSQL Flexible Servers require administrator login credentials // Credentials are read from environment variables to avoid committing secrets to source control adminLogin := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { return fmt.Errorf("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN and AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD environment variables must be set for integration tests") } // Create the PostgreSQL Flexible Server // Using Burstable tier for cost-effective testing opCtx, cancel := context.WithTimeout(ctx, 25*time.Minute) defer cancel() poller, err := client.BeginCreateOrUpdate(opCtx, resourceGroupName, serverName, armpostgresqlflexibleservers.Server{ Location: new(location), Properties: &armpostgresqlflexibleservers.ServerProperties{ AdministratorLogin: new(adminLogin), AdministratorLoginPassword: new(adminPassword), Version: new(armpostgresqlflexibleservers.PostgresMajorVersion("14")), Storage: &armpostgresqlflexibleservers.Storage{StorageSizeGB: new(int32(32))}, Backup: &armpostgresqlflexibleservers.Backup{BackupRetentionDays: new(int32(7)), GeoRedundantBackup: new(armpostgresqlflexibleservers.GeographicallyRedundantBackupDisabled)}, Network: &armpostgresqlflexibleservers.Network{PublicNetworkAccess: new(armpostgresqlflexibleservers.ServerPublicNetworkAccessStateEnabled)}, HighAvailability: nil, // High availability disabled by not setting it }, SKU: &armpostgresqlflexibleservers.SKU{ Name: new("Standard_B1ms"), // Burstable tier, 1 vCore, 2GB RAM Tier: new(armpostgresqlflexibleservers.SKUTierBurstable), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("dbforpostgresql-database"), }, }, nil) if err != nil { // Check if PostgreSQL server already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } return fmt.Errorf("failed to begin creating PostgreSQL Flexible Server: %w", err) } resp, err := poller.PollUntilDone(opCtx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL Flexible Server: %w", err) } // Verify the PostgreSQL server was created successfully if resp.Properties == nil { return fmt.Errorf("PostgreSQL Flexible Server created but properties are nil") } log.Printf("PostgreSQL Flexible Server %s created successfully", serverName) return nil } // waitForPostgreSQLServerAvailable waits for a PostgreSQL Flexible Server to be fully available func waitForPostgreSQLServerAvailable(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName string) error { maxAttempts := 120 pollInterval := 15 * time.Second log.Printf("Waiting for PostgreSQL Flexible Server %s to be available via API...", serverName) for attempt := range maxAttempts { resp, err := client.Get(ctx, resourceGroupName, serverName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL Flexible Server %s not yet available (attempt %d/%d), waiting %v...", serverName, attempt+1, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking PostgreSQL Flexible Server availability: %w", err) } // Check if server is ready (State should be "Ready") if resp.Properties != nil && resp.Properties.State != nil { state := *resp.Properties.State if state == armpostgresqlflexibleservers.ServerStateReady { log.Printf("PostgreSQL Flexible Server %s is available with state: %s", serverName, state) return nil } if state == armpostgresqlflexibleservers.ServerStateDisabled || state == armpostgresqlflexibleservers.ServerStateDropping { return fmt.Errorf("PostgreSQL Flexible Server provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("PostgreSQL Flexible Server %s state: %s (attempt %d/%d), waiting...", serverName, state, attempt+1, maxAttempts) time.Sleep(pollInterval) continue } // PostgreSQL server exists but no state - consider it available log.Printf("PostgreSQL Flexible Server %s is available", serverName) return nil } return fmt.Errorf("timeout waiting for PostgreSQL Flexible Server %s to be available after %d attempts", serverName, maxAttempts) } // createPostgreSQLDatabase creates an Azure PostgreSQL Database (idempotent) func createPostgreSQLDatabase(ctx context.Context, client *armpostgresqlflexibleservers.DatabasesClient, resourceGroupName, serverName, databaseName string) error { // Check if PostgreSQL database already exists _, err := client.Get(ctx, resourceGroupName, serverName, databaseName, nil) if err == nil { log.Printf("PostgreSQL database %s already exists, skipping creation", databaseName) return nil } // Create the PostgreSQL database poller, err := client.BeginCreate(ctx, resourceGroupName, serverName, databaseName, armpostgresqlflexibleservers.Database{ Properties: &armpostgresqlflexibleservers.DatabaseProperties{ Charset: new("UTF8"), Collation: new("en_US.utf8"), }, }, nil) if err != nil { // Check if PostgreSQL database already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("PostgreSQL database %s already exists, skipping creation", databaseName) return nil } return fmt.Errorf("failed to begin creating PostgreSQL database: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL database: %w", err) } log.Printf("PostgreSQL database %s created successfully", databaseName) return nil } // waitForPostgreSQLDatabaseAvailable waits for a PostgreSQL Database to be fully available func waitForPostgreSQLDatabaseAvailable(ctx context.Context, client *armpostgresqlflexibleservers.DatabasesClient, resourceGroupName, serverName, databaseName string) error { maxAttempts := 60 pollInterval := 10 * time.Second log.Printf("Waiting for PostgreSQL database %s to be available via API...", databaseName) for attempt := range maxAttempts { _, err := client.Get(ctx, resourceGroupName, serverName, databaseName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL database %s not yet available (attempt %d/%d), waiting %v...", databaseName, attempt+1, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking PostgreSQL database availability: %w", err) } // If we can get the database, it's available log.Printf("PostgreSQL database %s is available", databaseName) return nil } return fmt.Errorf("timeout waiting for PostgreSQL database %s to be available after %d attempts", databaseName, maxAttempts) } // deletePostgreSQLDatabase deletes an Azure PostgreSQL Database func deletePostgreSQLDatabase(ctx context.Context, client *armpostgresqlflexibleservers.DatabasesClient, resourceGroupName, serverName, databaseName string) error { // Check if PostgreSQL database exists _, err := client.Get(ctx, resourceGroupName, serverName, databaseName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL database %s does not exist, skipping deletion", databaseName) return nil } return fmt.Errorf("error checking PostgreSQL database existence: %w", err) } // Delete the PostgreSQL database poller, err := client.BeginDelete(ctx, resourceGroupName, serverName, databaseName, nil) if err != nil { return fmt.Errorf("failed to begin deleting PostgreSQL database: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete PostgreSQL database: %w", err) } log.Printf("PostgreSQL database %s deleted successfully", databaseName) return nil } // deletePostgreSQLFlexibleServer deletes an Azure PostgreSQL Flexible Server func deletePostgreSQLFlexibleServer(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName string) error { // Check if PostgreSQL server exists _, err := client.Get(ctx, resourceGroupName, serverName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL Flexible Server %s does not exist, skipping deletion", serverName) return nil } return fmt.Errorf("error checking PostgreSQL Flexible Server existence: %w", err) } // Delete the PostgreSQL Flexible Server poller, err := client.BeginDelete(ctx, resourceGroupName, serverName, nil) if err != nil { return fmt.Errorf("failed to begin deleting PostgreSQL Flexible Server: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete PostgreSQL Flexible Server: %w", err) } log.Printf("PostgreSQL Flexible Server %s deleted successfully", serverName) return nil } ================================================ FILE: sources/azure/integration-tests/dbforpostgresql-flexible-server-administrator_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestPGAdminServerName = "ovm-integ-test-pg-admin" ) func TestDBforPostgreSQLFlexibleServerAdministratorIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } adminLogin := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { t.Skip("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN and AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD must be set for PostgreSQL tests") } entraAdminObjectID := os.Getenv("AZURE_POSTGRESQL_ENTRA_ADMIN_OBJECT_ID") entraAdminPrincipalName := os.Getenv("AZURE_POSTGRESQL_ENTRA_ADMIN_PRINCIPAL_NAME") entraAdminTenantID := os.Getenv("AZURE_POSTGRESQL_ENTRA_ADMIN_TENANT_ID") if entraAdminObjectID == "" || entraAdminPrincipalName == "" || entraAdminTenantID == "" { t.Skip("AZURE_POSTGRESQL_ENTRA_ADMIN_OBJECT_ID, AZURE_POSTGRESQL_ENTRA_ADMIN_PRINCIPAL_NAME, and AZURE_POSTGRESQL_ENTRA_ADMIN_TENANT_ID must be set for PostgreSQL Administrator tests") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } postgreSQLServerClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Servers client: %v", err) } administratorsClient, err := armpostgresqlflexibleservers.NewAdministratorsMicrosoftEntraClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Administrators client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } pgServerName := generatePostgreSQLServerName(integrationTestPGAdminServerName) var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createPostgreSQLFlexibleServerWithMicrosoftEntraAuth(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Server: %v", err) } err = waitForPostgreSQLServerAvailable(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL server to be available: %v", err) } err = createPostgreSQLAdministrator(ctx, administratorsClient, integrationTestResourceGroup, pgServerName, entraAdminObjectID, entraAdminPrincipalName, entraAdminTenantID) if err != nil { t.Fatalf("Failed to create PostgreSQL Administrator: %v", err) } err = waitForPostgreSQLAdministratorAvailable(ctx, administratorsClient, integrationTestResourceGroup, pgServerName, entraAdminObjectID) if err != nil { t.Fatalf("Failed waiting for PostgreSQL Administrator to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetPostgreSQLFlexibleServerAdministrator", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator( clients.NewDBforPostgreSQLFlexibleServerAdministratorClient(administratorsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, entraAdminObjectID) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerAdministrator.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerAdministrator, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(pgServerName, entraAdminObjectID) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved administrator %s", entraAdminObjectID) }) t.Run("SearchPostgreSQLFlexibleServerAdministrators", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator( clients.NewDBforPostgreSQLFlexibleServerAdministratorClient(administratorsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, pgServerName, true) if err != nil { t.Fatalf("Failed to search administrators: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one administrator, got %d", len(sdpItems)) } var foundAdmin bool for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } if item.GetType() != azureshared.DBforPostgreSQLFlexibleServerAdministrator.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerAdministrator, item.GetType()) } expectedUniqueValue := shared.CompositeLookupKey(pgServerName, entraAdminObjectID) if item.UniqueAttributeValue() == expectedUniqueValue { foundAdmin = true } } if !foundAdmin { t.Errorf("Expected to find administrator %s in search results", entraAdminObjectID) } log.Printf("Found %d administrators in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator( clients.NewDBforPostgreSQLFlexibleServerAdministratorClient(administratorsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, entraAdminObjectID) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } for _, liq := range linkedQueries { if liq.GetQuery().GetType() == "" { t.Error("Expected linked query Type to be non-empty") } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET && liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected linked query Method to be GET or SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() == "" { t.Error("Expected linked query Query to be non-empty") } if liq.GetQuery().GetScope() == "" { t.Error("Expected linked query Scope to be non-empty") } } var hasServerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() { hasServerLink = true if liq.GetQuery().GetQuery() != pgServerName { t.Errorf("Expected linked query to server %s, got %s", pgServerName, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } break } } if !hasServerLink { t.Error("Expected linked query to PostgreSQL Flexible Server, but didn't find one") } log.Printf("Verified %d linked item queries for administrator %s", len(linkedQueries), entraAdminObjectID) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator( clients.NewDBforPostgreSQLFlexibleServerAdministratorClient(administratorsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, entraAdminObjectID) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerAdministrator.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerAdministrator, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deletePostgreSQLAdministrator(ctx, administratorsClient, integrationTestResourceGroup, pgServerName, entraAdminObjectID) if err != nil { log.Printf("Warning: Failed to delete PostgreSQL Administrator: %v", err) } err = deletePostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed to delete PostgreSQL Flexible Server: %v", err) } }) } // createPostgreSQLFlexibleServerWithMicrosoftEntraAuth creates a PostgreSQL Flexible Server with Microsoft Entra authentication enabled func createPostgreSQLFlexibleServerWithMicrosoftEntraAuth(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName, location string) error { _, err := client.Get(ctx, resourceGroupName, serverName, nil) if err == nil { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } adminLogin := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { return fmt.Errorf("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN and AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD environment variables must be set for integration tests") } opCtx, cancel := context.WithTimeout(ctx, 25*time.Minute) defer cancel() poller, err := client.BeginCreateOrUpdate(opCtx, resourceGroupName, serverName, armpostgresqlflexibleservers.Server{ Location: new(location), Properties: &armpostgresqlflexibleservers.ServerProperties{ AdministratorLogin: new(adminLogin), AdministratorLoginPassword: new(adminPassword), Version: new(armpostgresqlflexibleservers.PostgresMajorVersion("14")), Storage: &armpostgresqlflexibleservers.Storage{StorageSizeGB: new(int32(32))}, Backup: &armpostgresqlflexibleservers.Backup{BackupRetentionDays: new(int32(7)), GeoRedundantBackup: new(armpostgresqlflexibleservers.GeographicallyRedundantBackupDisabled)}, Network: &armpostgresqlflexibleservers.Network{PublicNetworkAccess: new(armpostgresqlflexibleservers.ServerPublicNetworkAccessStateEnabled)}, HighAvailability: nil, AuthConfig: &armpostgresqlflexibleservers.AuthConfig{ ActiveDirectoryAuth: new(armpostgresqlflexibleservers.MicrosoftEntraAuthEnabled), PasswordAuth: new(armpostgresqlflexibleservers.PasswordBasedAuthEnabled), }, }, SKU: &armpostgresqlflexibleservers.SKU{ Name: new("Standard_B1ms"), Tier: new(armpostgresqlflexibleservers.SKUTierBurstable), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("dbforpostgresql-administrator"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } return fmt.Errorf("failed to begin creating PostgreSQL Flexible Server: %w", err) } resp, err := poller.PollUntilDone(opCtx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL Flexible Server: %w", err) } if resp.Properties == nil { return fmt.Errorf("PostgreSQL Flexible Server created but properties are nil") } log.Printf("PostgreSQL Flexible Server %s created successfully with Microsoft Entra authentication enabled", serverName) return nil } // createPostgreSQLAdministrator creates a Microsoft Entra administrator for a PostgreSQL Flexible Server func createPostgreSQLAdministrator(ctx context.Context, client *armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClient, resourceGroupName, serverName, objectID, principalName, tenantID string) error { _, err := client.Get(ctx, resourceGroupName, serverName, objectID, nil) if err == nil { log.Printf("PostgreSQL Administrator %s already exists on server %s, skipping creation", objectID, serverName) return nil } opCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() principalType := armpostgresqlflexibleservers.PrincipalTypeServicePrincipal poller, err := client.BeginCreateOrUpdate(opCtx, resourceGroupName, serverName, objectID, armpostgresqlflexibleservers.AdministratorMicrosoftEntraAdd{ Properties: &armpostgresqlflexibleservers.AdministratorMicrosoftEntraPropertiesForAdd{ PrincipalName: new(principalName), PrincipalType: &principalType, TenantID: new(tenantID), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("PostgreSQL Administrator %s already exists on server %s, skipping creation", objectID, serverName) return nil } return fmt.Errorf("failed to begin creating PostgreSQL Administrator: %w", err) } _, err = poller.PollUntilDone(opCtx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL Administrator: %w", err) } log.Printf("PostgreSQL Administrator %s created successfully on server %s", objectID, serverName) return nil } // waitForPostgreSQLAdministratorAvailable waits for a PostgreSQL Administrator to be fully available func waitForPostgreSQLAdministratorAvailable(ctx context.Context, client *armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClient, resourceGroupName, serverName, objectID string) error { maxAttempts := 30 pollInterval := 10 * time.Second log.Printf("Waiting for PostgreSQL Administrator %s to be available on server %s...", objectID, serverName) for attempt := 1; attempt <= maxAttempts; attempt++ { _, err := client.Get(ctx, resourceGroupName, serverName, objectID, nil) if err == nil { log.Printf("PostgreSQL Administrator %s is available on server %s", objectID, serverName) return nil } var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL Administrator %s not yet available (attempt %d/%d), waiting %v...", objectID, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking PostgreSQL Administrator availability: %w", err) } return fmt.Errorf("timeout waiting for PostgreSQL Administrator %s to be available on server %s", objectID, serverName) } // deletePostgreSQLAdministrator deletes a Microsoft Entra administrator from a PostgreSQL Flexible Server func deletePostgreSQLAdministrator(ctx context.Context, client *armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClient, resourceGroupName, serverName, objectID string) error { opCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() poller, err := client.BeginDelete(opCtx, resourceGroupName, serverName, objectID, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL Administrator %s already deleted or does not exist on server %s", objectID, serverName) return nil } return fmt.Errorf("failed to begin deleting PostgreSQL Administrator: %w", err) } _, err = poller.PollUntilDone(opCtx, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL Administrator %s already deleted", objectID) return nil } return fmt.Errorf("failed to delete PostgreSQL Administrator: %w", err) } log.Printf("PostgreSQL Administrator %s deleted successfully from server %s", objectID, serverName) return nil } ================================================ FILE: sources/azure/integration-tests/dbforpostgresql-flexible-server-backup_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestPGBackupServerName = "ovm-integ-test-pg-backup" integrationTestPGBackupName = "ovm-integ-test-backup" ) func TestDBforPostgreSQLFlexibleServerBackupIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } postgreSQLServerClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Servers client: %v", err) } backupsClient, err := armpostgresqlflexibleservers.NewBackupsAutomaticAndOnDemandClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Backups client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } pgServerName := generatePostgreSQLServerName(integrationTestPGBackupServerName) setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createPostgreSQLFlexibleServerForBackup(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Server: %v", err) } err = waitForPostgreSQLServerAvailable(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL server to be available: %v", err) } err = createOnDemandBackup(ctx, backupsClient, integrationTestResourceGroup, pgServerName, integrationTestPGBackupName) if err != nil { t.Fatalf("Failed to create on-demand backup: %v", err) } err = waitForBackupAvailable(ctx, backupsClient, integrationTestResourceGroup, pgServerName, integrationTestPGBackupName) if err != nil { t.Fatalf("Failed waiting for backup to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetPostgreSQLFlexibleServerBackup", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup( clients.NewDBforPostgreSQLFlexibleServerBackupClient(backupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, integrationTestPGBackupName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerBackup.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerBackup, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(pgServerName, integrationTestPGBackupName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved backup %s", integrationTestPGBackupName) }) t.Run("SearchPostgreSQLFlexibleServerBackups", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup( clients.NewDBforPostgreSQLFlexibleServerBackupClient(backupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, pgServerName, true) if err != nil { t.Fatalf("Failed to search backups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one backup, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { expectedValue := shared.CompositeLookupKey(pgServerName, integrationTestPGBackupName) if v == expectedValue { found = true break } } } if !found { t.Fatalf("Expected to find backup %s in the search results", integrationTestPGBackupName) } log.Printf("Found %d backups in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup( clients.NewDBforPostgreSQLFlexibleServerBackupClient(backupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, integrationTestPGBackupName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasServerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() { hasServerLink = true if liq.GetQuery().GetQuery() != pgServerName { t.Errorf("Expected linked query to server %s, got %s", pgServerName, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } break } } if !hasServerLink { t.Error("Expected linked query to PostgreSQL Flexible Server, but didn't find one") } log.Printf("Verified %d linked item queries for backup %s", len(linkedQueries), integrationTestPGBackupName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup( clients.NewDBforPostgreSQLFlexibleServerBackupClient(backupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, integrationTestPGBackupName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerBackup.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerBackup, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteOnDemandBackup(ctx, backupsClient, integrationTestResourceGroup, pgServerName, integrationTestPGBackupName) if err != nil { log.Printf("Warning: failed to delete backup (may have been auto-cleaned): %v", err) } err = deletePostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed to delete PostgreSQL Flexible Server: %v", err) } }) } // createPostgreSQLFlexibleServerForBackup creates a GeneralPurpose-tier server // because Azure does not allow on-demand backups on Burstable-tier servers. func createPostgreSQLFlexibleServerForBackup(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName, location string) error { _, err := client.Get(ctx, resourceGroupName, serverName, nil) if err == nil { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } adminLogin := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { return fmt.Errorf("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN and AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD must be set") } opCtx, cancel := context.WithTimeout(ctx, 25*time.Minute) defer cancel() poller, err := client.BeginCreateOrUpdate(opCtx, resourceGroupName, serverName, armpostgresqlflexibleservers.Server{ Location: new(location), Properties: &armpostgresqlflexibleservers.ServerProperties{ AdministratorLogin: new(adminLogin), AdministratorLoginPassword: new(adminPassword), Version: new(armpostgresqlflexibleservers.PostgresMajorVersion("14")), Storage: &armpostgresqlflexibleservers.Storage{StorageSizeGB: new(int32(32))}, Backup: &armpostgresqlflexibleservers.Backup{BackupRetentionDays: new(int32(7)), GeoRedundantBackup: new(armpostgresqlflexibleservers.GeographicallyRedundantBackupDisabled)}, Network: &armpostgresqlflexibleservers.Network{PublicNetworkAccess: new(armpostgresqlflexibleservers.ServerPublicNetworkAccessStateEnabled)}, }, SKU: &armpostgresqlflexibleservers.SKU{ Name: new("Standard_D2s_v3"), Tier: new(armpostgresqlflexibleservers.SKUTierGeneralPurpose), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("dbforpostgresql-backup"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } return fmt.Errorf("failed to begin creating PostgreSQL Flexible Server: %w", err) } _, err = poller.PollUntilDone(opCtx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL Flexible Server: %w", err) } log.Printf("PostgreSQL Flexible Server %s (GeneralPurpose) created successfully", serverName) return nil } func createOnDemandBackup(ctx context.Context, client *armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClient, resourceGroupName, serverName, backupName string) error { _, err := client.Get(ctx, resourceGroupName, serverName, backupName, nil) if err == nil { log.Printf("Backup %s already exists, skipping creation", backupName) return nil } poller, err := client.BeginCreate(ctx, resourceGroupName, serverName, backupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Backup %s already exists (conflict), skipping", backupName) return nil } return fmt.Errorf("failed to begin creating backup: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create backup: %w", err) } log.Printf("Backup %s created successfully", backupName) return nil } func waitForBackupAvailable(ctx context.Context, client *armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClient, resourceGroupName, serverName, backupName string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { _, err := client.Get(ctx, resourceGroupName, serverName, backupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Backup %s not yet available (attempt %d/%d), waiting...", backupName, attempt, maxAttempts) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking backup availability: %w", err) } log.Printf("Backup %s is available", backupName) return nil } return fmt.Errorf("timeout waiting for backup %s to be available", backupName) } func deleteOnDemandBackup(ctx context.Context, client *armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClient, resourceGroupName, serverName, backupName string) error { _, err := client.Get(ctx, resourceGroupName, serverName, backupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Backup %s does not exist, skipping deletion", backupName) return nil } return fmt.Errorf("error checking backup existence: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroupName, serverName, backupName, nil) if err != nil { return fmt.Errorf("failed to begin deleting backup: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete backup: %w", err) } log.Printf("Backup %s deleted successfully", backupName) return nil } ================================================ FILE: sources/azure/integration-tests/dbforpostgresql-flexible-server-configuration_test.go ================================================ package integrationtests import ( "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestPGConfigServerName = "ovm-integ-test-pg-config" ) func TestDBforPostgreSQLFlexibleServerConfigurationIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } adminLogin := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { t.Skip("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN and AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD must be set for PostgreSQL tests") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } postgreSQLServerClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Servers client: %v", err) } configurationsClient, err := armpostgresqlflexibleservers.NewConfigurationsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Configurations client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } pgServerName := generatePostgreSQLServerName(integrationTestPGConfigServerName) var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createPostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Server: %v", err) } err = waitForPostgreSQLServerAvailable(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL server to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetPostgreSQLFlexibleServerConfiguration", func(t *testing.T) { ctx := t.Context() pager := configurationsClient.NewListByServerPager(integrationTestResourceGroup, pgServerName, nil) var configName string if pager.More() { page, err := pager.NextPage(ctx) if err != nil { t.Fatalf("Failed to list configurations: %v", err) } if len(page.Value) > 0 && page.Value[0].Name != nil { configName = *page.Value[0].Name } } if configName == "" { t.Skip("No configurations found on server") } log.Printf("Testing with configuration: %s", configName) wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration( clients.NewPostgreSQLConfigurationsClient(configurationsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, configName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerConfiguration, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(pgServerName, configName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved configuration %s", configName) }) t.Run("SearchPostgreSQLFlexibleServerConfigurations", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration( clients.NewPostgreSQLConfigurationsClient(configurationsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, pgServerName, true) if err != nil { t.Fatalf("Failed to search configurations: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one configuration, got %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } if item.GetType() != azureshared.DBforPostgreSQLFlexibleServerConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerConfiguration, item.GetType()) } } log.Printf("Found %d configurations in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() pager := configurationsClient.NewListByServerPager(integrationTestResourceGroup, pgServerName, nil) var configName string if pager.More() { page, err := pager.NextPage(ctx) if err != nil { t.Fatalf("Failed to list configurations: %v", err) } if len(page.Value) > 0 && page.Value[0].Name != nil { configName = *page.Value[0].Name } } if configName == "" { t.Skip("No configurations found on server") } wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration( clients.NewPostgreSQLConfigurationsClient(configurationsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, configName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasServerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() { hasServerLink = true if liq.GetQuery().GetQuery() != pgServerName { t.Errorf("Expected linked query to server %s, got %s", pgServerName, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } break } } if !hasServerLink { t.Error("Expected linked query to PostgreSQL Flexible Server, but didn't find one") } log.Printf("Verified %d linked item queries for configuration %s", len(linkedQueries), configName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() pager := configurationsClient.NewListByServerPager(integrationTestResourceGroup, pgServerName, nil) var configName string if pager.More() { page, err := pager.NextPage(ctx) if err != nil { t.Fatalf("Failed to list configurations: %v", err) } if len(page.Value) > 0 && page.Value[0].Name != nil { configName = *page.Value[0].Name } } if configName == "" { t.Skip("No configurations found on server") } wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration( clients.NewPostgreSQLConfigurationsClient(configurationsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, configName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerConfiguration, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deletePostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed to delete PostgreSQL Flexible Server: %v", err) } }) } ================================================ FILE: sources/azure/integration-tests/dbforpostgresql-flexible-server-replica_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestPgServerName = "ovm-integ-test-pg-server" integrationTestPgReplicaName = "ovm-integ-test-pg-replica" ) func TestDBforPostgreSQLFlexibleServerReplicaIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } serversClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Servers client: %v", err) } replicasClient, err := armpostgresqlflexibleservers.NewReplicasClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Replicas client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 30*time.Minute) defer cancel() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createPostgreSQLServerForReplica(ctx, serversClient, subscriptionID, integrationTestResourceGroup, integrationTestPgServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL flexible server: %v", err) } err = waitForPostgreSQLServerReady(ctx, serversClient, integrationTestResourceGroup, integrationTestPgServerName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL server to be ready: %v", err) } err = createPostgreSQLReplica(ctx, serversClient, subscriptionID, integrationTestResourceGroup, integrationTestPgServerName, integrationTestPgReplicaName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL replica: %v", err) } err = waitForPostgreSQLServerReady(ctx, serversClient, integrationTestResourceGroup, integrationTestPgReplicaName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL replica to be ready: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetReplica", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving replica %s under server %s", integrationTestPgReplicaName, integrationTestPgServerName) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica( clients.NewDBforPostgreSQLFlexibleServerReplicaClient(replicasClient, serversClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestPgServerName, integrationTestPgReplicaName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttr := shared.CompositeLookupKey(integrationTestPgServerName, integrationTestPgReplicaName) if uniqueAttrValue != expectedUniqueAttr { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttr, uniqueAttrValue) } log.Printf("Successfully retrieved replica %s", integrationTestPgReplicaName) }) t.Run("SearchReplicas", func(t *testing.T) { ctx := t.Context() log.Printf("Searching replicas under server %s", integrationTestPgServerName) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica( clients.NewDBforPostgreSQLFlexibleServerReplicaClient(replicasClient, serversClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestPgServerName, true) if err != nil { t.Fatalf("Failed to search replicas: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one replica, got %d", len(sdpItems)) } var found bool expectedUniqueAttr := shared.CompositeLookupKey(integrationTestPgServerName, integrationTestPgReplicaName) for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUniqueAttr { found = true break } } if !found { t.Fatalf("Expected to find replica %s in search results", integrationTestPgReplicaName) } log.Printf("Found %d replicas in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for replica %s", integrationTestPgReplicaName) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica( clients.NewDBforPostgreSQLFlexibleServerReplicaClient(replicasClient, serversClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestPgServerName, integrationTestPgReplicaName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasSourceServerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() { hasSourceServerLink = true if liq.GetQuery().GetQuery() != integrationTestPgServerName { t.Errorf("Expected linked query to source server %s, got %s", integrationTestPgServerName, liq.GetQuery().GetQuery()) } break } } if !hasSourceServerLink { t.Error("Expected linked query to source server, but didn't find one") } log.Printf("Verified %d linked item queries for replica %s", len(linkedQueries), integrationTestPgReplicaName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica( clients.NewDBforPostgreSQLFlexibleServerReplicaClient(replicasClient, serversClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestPgServerName, integrationTestPgReplicaName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerReplica.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerReplica.String(), sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 20*time.Minute) defer cancel() err := deletePostgreSQLServer(ctx, serversClient, integrationTestResourceGroup, integrationTestPgReplicaName) if err != nil { t.Logf("Warning: Failed to delete replica %s: %v", integrationTestPgReplicaName, err) } err = deletePostgreSQLServer(ctx, serversClient, integrationTestResourceGroup, integrationTestPgServerName) if err != nil { t.Logf("Warning: Failed to delete server %s: %v", integrationTestPgServerName, err) } }) } func createPostgreSQLServerForReplica(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, subscriptionID, resourceGroupName, serverName, location string) error { _, err := client.Get(ctx, resourceGroupName, serverName, nil) if err == nil { log.Printf("PostgreSQL server %s already exists, skipping creation", serverName) return nil } version := armpostgresqlflexibleservers.PostgresMajorVersionSixteen createMode := armpostgresqlflexibleservers.CreateModeDefault adminLogin := "ovmadmin" adminPassword := "TestPassword123!" skuName := "Standard_D2ds_v5" skuTier := armpostgresqlflexibleservers.SKUTierGeneralPurpose storageSizeGB := int32(32) poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, serverName, armpostgresqlflexibleservers.Server{ Location: &location, SKU: &armpostgresqlflexibleservers.SKU{ Name: &skuName, Tier: &skuTier, }, Properties: &armpostgresqlflexibleservers.ServerProperties{ Version: &version, CreateMode: &createMode, AdministratorLogin: &adminLogin, AdministratorLoginPassword: &adminPassword, Storage: &armpostgresqlflexibleservers.Storage{ StorageSizeGB: &storageSizeGB, }, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, serverName, nil); getErr == nil { log.Printf("PostgreSQL server %s already exists (conflict), skipping creation", serverName) return nil } return fmt.Errorf("server %s conflict but not retrievable: %w", serverName, err) } return fmt.Errorf("failed to create PostgreSQL server: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL server: %w", err) } log.Printf("PostgreSQL server %s created successfully", serverName) return nil } func createPostgreSQLReplica(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, subscriptionID, resourceGroupName, primaryServerName, replicaName, location string) error { _, err := client.Get(ctx, resourceGroupName, replicaName, nil) if err == nil { log.Printf("PostgreSQL replica %s already exists, skipping creation", replicaName) return nil } sourceServerID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.DBforPostgreSQL/flexibleServers/%s", subscriptionID, resourceGroupName, primaryServerName) createMode := armpostgresqlflexibleservers.CreateModeReplica poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, replicaName, armpostgresqlflexibleservers.Server{ Location: &location, Properties: &armpostgresqlflexibleservers.ServerProperties{ CreateMode: &createMode, SourceServerResourceID: &sourceServerID, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, replicaName, nil); getErr == nil { log.Printf("PostgreSQL replica %s already exists (conflict), skipping creation", replicaName) return nil } return fmt.Errorf("replica %s conflict but not retrievable: %w", replicaName, err) } return fmt.Errorf("failed to create PostgreSQL replica: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL replica: %w", err) } log.Printf("PostgreSQL replica %s created successfully", replicaName) return nil } func waitForPostgreSQLServerReady(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName string) error { maxAttempts := 60 pollInterval := 30 * time.Second maxNotFoundAttempts := 5 notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, serverName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("server %s not found after %d attempts", serverName, notFoundCount) } log.Printf("Server %s not found yet (attempt %d/%d), waiting...", serverName, attempt, maxAttempts) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking server: %w", err) } notFoundCount = 0 if resp.Properties != nil && resp.Properties.State != nil { state := *resp.Properties.State log.Printf("Server %s state: %s (attempt %d/%d)", serverName, state, attempt, maxAttempts) if state == armpostgresqlflexibleservers.ServerStateReady { return nil } } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for server %s to be ready", serverName) } func deletePostgreSQLServer(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, serverName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("PostgreSQL server %s not found, skipping deletion", serverName) return nil } return fmt.Errorf("failed to delete PostgreSQL server: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete PostgreSQL server: %w", err) } log.Printf("PostgreSQL server %s deleted successfully", serverName) return nil } ================================================ FILE: sources/azure/integration-tests/dbforpostgresql-flexible-server-virtual-endpoint_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestPGVirtualEndpointServerName = "ovm-integ-test-pg-vep" integrationTestPGVirtualEndpointName = "ovm-integ-test-vep" ) func TestDBforPostgreSQLFlexibleServerVirtualEndpointIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } adminLogin := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { t.Skip("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN and AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD must be set for PostgreSQL tests") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } postgreSQLServerClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Servers client: %v", err) } virtualEndpointsClient, err := armpostgresqlflexibleservers.NewVirtualEndpointsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Virtual Endpoints client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } pgServerName := generatePostgreSQLServerName(integrationTestPGVirtualEndpointServerName) var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createPostgreSQLFlexibleServerForVirtualEndpoint(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Server: %v", err) } err = waitForPostgreSQLServerAvailable(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL server to be available: %v", err) } err = createVirtualEndpoint(ctx, virtualEndpointsClient, integrationTestResourceGroup, pgServerName, integrationTestPGVirtualEndpointName) if err != nil { t.Fatalf("Failed to create virtual endpoint: %v", err) } err = waitForVirtualEndpointAvailable(ctx, virtualEndpointsClient, integrationTestResourceGroup, pgServerName, integrationTestPGVirtualEndpointName) if err != nil { t.Fatalf("Failed waiting for virtual endpoint to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetVirtualEndpoint", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint( clients.NewDBforPostgreSQLFlexibleServerVirtualEndpointClient(virtualEndpointsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, integrationTestPGVirtualEndpointName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(pgServerName, integrationTestPGVirtualEndpointName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved virtual endpoint %s", integrationTestPGVirtualEndpointName) }) t.Run("SearchVirtualEndpoints", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint( clients.NewDBforPostgreSQLFlexibleServerVirtualEndpointClient(virtualEndpointsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, pgServerName, true) if err != nil { t.Fatalf("Failed to search virtual endpoints: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one virtual endpoint, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { expectedValue := shared.CompositeLookupKey(pgServerName, integrationTestPGVirtualEndpointName) if v == expectedValue { found = true break } } } if !found { t.Fatalf("Expected to find virtual endpoint %s in the search results", integrationTestPGVirtualEndpointName) } log.Printf("Found %d virtual endpoints in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint( clients.NewDBforPostgreSQLFlexibleServerVirtualEndpointClient(virtualEndpointsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, integrationTestPGVirtualEndpointName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasServerLink bool for _, liq := range linkedQueries { q := liq.GetQuery() if q.GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() { hasServerLink = true if q.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", q.GetMethod()) } if q.GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, q.GetScope()) } break } } if !hasServerLink { t.Error("Expected linked query to PostgreSQL Flexible Server, but didn't find one") } log.Printf("Verified %d linked item queries for virtual endpoint %s", len(linkedQueries), integrationTestPGVirtualEndpointName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint( clients.NewDBforPostgreSQLFlexibleServerVirtualEndpointClient(virtualEndpointsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(pgServerName, integrationTestPGVirtualEndpointName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteVirtualEndpoint(ctx, virtualEndpointsClient, integrationTestResourceGroup, pgServerName, integrationTestPGVirtualEndpointName) if err != nil { log.Printf("Warning: failed to delete virtual endpoint: %v", err) } err = deletePostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, pgServerName) if err != nil { t.Fatalf("Failed to delete PostgreSQL Flexible Server: %v", err) } }) } func createPostgreSQLFlexibleServerForVirtualEndpoint(ctx context.Context, client *armpostgresqlflexibleservers.ServersClient, resourceGroupName, serverName, location string) error { _, err := client.Get(ctx, resourceGroupName, serverName, nil) if err == nil { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } adminLogin := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { return fmt.Errorf("AZURE_POSTGRESQL_SERVER_ADMIN_LOGIN and AZURE_POSTGRESQL_SERVER_ADMIN_PASSWORD must be set") } opCtx, cancel := context.WithTimeout(ctx, 25*time.Minute) defer cancel() poller, err := client.BeginCreateOrUpdate(opCtx, resourceGroupName, serverName, armpostgresqlflexibleservers.Server{ Location: new(location), Properties: &armpostgresqlflexibleservers.ServerProperties{ AdministratorLogin: new(adminLogin), AdministratorLoginPassword: new(adminPassword), Version: new(armpostgresqlflexibleservers.PostgresMajorVersion("14")), Storage: &armpostgresqlflexibleservers.Storage{StorageSizeGB: new(int32(32))}, Backup: &armpostgresqlflexibleservers.Backup{BackupRetentionDays: new(int32(7)), GeoRedundantBackup: new(armpostgresqlflexibleservers.GeographicallyRedundantBackupDisabled)}, Network: &armpostgresqlflexibleservers.Network{PublicNetworkAccess: new(armpostgresqlflexibleservers.ServerPublicNetworkAccessStateEnabled)}, }, SKU: &armpostgresqlflexibleservers.SKU{ Name: new("Standard_D2s_v3"), Tier: new(armpostgresqlflexibleservers.SKUTierGeneralPurpose), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("dbforpostgresql-virtual-endpoint"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("PostgreSQL Flexible Server %s already exists, skipping creation", serverName) return nil } return fmt.Errorf("failed to begin creating PostgreSQL Flexible Server: %w", err) } _, err = poller.PollUntilDone(opCtx, nil) if err != nil { return fmt.Errorf("failed to create PostgreSQL Flexible Server: %w", err) } log.Printf("PostgreSQL Flexible Server %s (GeneralPurpose) created successfully", serverName) return nil } func createVirtualEndpoint(ctx context.Context, client *armpostgresqlflexibleservers.VirtualEndpointsClient, resourceGroupName, serverName, virtualEndpointName string) error { _, err := client.Get(ctx, resourceGroupName, serverName, virtualEndpointName, nil) if err == nil { log.Printf("Virtual endpoint %s already exists, skipping creation", virtualEndpointName) return nil } opCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) defer cancel() endpointType := armpostgresqlflexibleservers.VirtualEndpointTypeReadWrite poller, err := client.BeginCreate(opCtx, resourceGroupName, serverName, virtualEndpointName, armpostgresqlflexibleservers.VirtualEndpoint{ Properties: &armpostgresqlflexibleservers.VirtualEndpointResourceProperties{ EndpointType: &endpointType, Members: []*string{new(serverName)}, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, serverName, virtualEndpointName, nil); getErr == nil { log.Printf("Virtual endpoint %s already exists (conflict), skipping", virtualEndpointName) return nil } return fmt.Errorf("virtual endpoint %s conflict but not retrievable: %w", virtualEndpointName, err) } return fmt.Errorf("failed to begin creating virtual endpoint: %w", err) } _, err = poller.PollUntilDone(opCtx, nil) if err != nil { return fmt.Errorf("failed to create virtual endpoint: %w", err) } log.Printf("Virtual endpoint %s created successfully", virtualEndpointName) return nil } func waitForVirtualEndpointAvailable(ctx context.Context, client *armpostgresqlflexibleservers.VirtualEndpointsClient, resourceGroupName, serverName, virtualEndpointName string) error { maxAttempts := 30 pollInterval := 10 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { _, err := client.Get(ctx, resourceGroupName, serverName, virtualEndpointName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual endpoint %s not yet available (attempt %d/%d), waiting...", virtualEndpointName, attempt, maxAttempts) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking virtual endpoint availability: %w", err) } log.Printf("Virtual endpoint %s is available", virtualEndpointName) return nil } return fmt.Errorf("timeout waiting for virtual endpoint %s to be available", virtualEndpointName) } func deleteVirtualEndpoint(ctx context.Context, client *armpostgresqlflexibleservers.VirtualEndpointsClient, resourceGroupName, serverName, virtualEndpointName string) error { _, err := client.Get(ctx, resourceGroupName, serverName, virtualEndpointName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual endpoint %s does not exist, skipping deletion", virtualEndpointName) return nil } return fmt.Errorf("error checking virtual endpoint existence: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroupName, serverName, virtualEndpointName, nil) if err != nil { return fmt.Errorf("failed to begin deleting virtual endpoint: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual endpoint: %w", err) } log.Printf("Virtual endpoint %s deleted successfully", virtualEndpointName) return nil } ================================================ FILE: sources/azure/integration-tests/dbforpostgresql-flexible-server_test.go ================================================ package integrationtests import ( "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/stdlib" ) const ( integrationTestPostgreSQLFlexibleServerName = "ovm-integ-test-pg-server" ) func TestDBforPostgreSQLFlexibleServerIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients postgreSQLServerClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Servers client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique PostgreSQL server name (must be globally unique, lowercase, no special chars) postgreSQLServerName := generatePostgreSQLServerName(integrationTestPostgreSQLFlexibleServerName) setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create PostgreSQL Flexible Server err = createPostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, postgreSQLServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create PostgreSQL Flexible Server: %v", err) } // Wait for PostgreSQL server to be available err = waitForPostgreSQLServerAvailable(ctx, postgreSQLServerClient, integrationTestResourceGroup, postgreSQLServerName) if err != nil { t.Fatalf("Failed waiting for PostgreSQL server to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetPostgreSQLFlexibleServer", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving PostgreSQL Flexible Server %s in subscription %s, resource group %s", postgreSQLServerName, subscriptionID, integrationTestResourceGroup) pgServerWrapper := manual.NewDBforPostgreSQLFlexibleServer( clients.NewPostgreSQLFlexibleServersClient(postgreSQLServerClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pgServerWrapper.Scopes()[0] pgServerAdapter := sources.WrapperToAdapter(pgServerWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := pgServerAdapter.Get(ctx, scope, postgreSQLServerName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServer.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServer, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "name" { t.Errorf("Expected unique attribute 'name', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != postgreSQLServerName { t.Errorf("Expected unique attribute value %s, got %s", postgreSQLServerName, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved PostgreSQL Flexible Server %s", postgreSQLServerName) }) t.Run("ListPostgreSQLFlexibleServers", func(t *testing.T) { ctx := t.Context() log.Printf("Listing PostgreSQL Flexible Servers in resource group %s", integrationTestResourceGroup) pgServerWrapper := manual.NewDBforPostgreSQLFlexibleServer( clients.NewPostgreSQLFlexibleServersClient(postgreSQLServerClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pgServerWrapper.Scopes()[0] pgServerAdapter := sources.WrapperToAdapter(pgServerWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list listable, ok := pgServerAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list PostgreSQL Flexible Servers: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one PostgreSQL Flexible Server, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { if v == postgreSQLServerName { found = true break } } } if !found { t.Fatalf("Expected to find PostgreSQL Flexible Server %s in the list results", postgreSQLServerName) } log.Printf("Found %d PostgreSQL Flexible Servers in list results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for PostgreSQL Flexible Server %s", postgreSQLServerName) pgServerWrapper := manual.NewDBforPostgreSQLFlexibleServer( clients.NewPostgreSQLFlexibleServersClient(postgreSQLServerClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := pgServerWrapper.Scopes()[0] pgServerAdapter := sources.WrapperToAdapter(pgServerWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := pgServerAdapter.Get(ctx, scope, postgreSQLServerName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (PostgreSQL Flexible Server has many child resources) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected child resource links exist expectedChildResources := map[string]bool{ azureshared.DBforPostgreSQLDatabase.String(): false, azureshared.DBforPostgreSQLFlexibleServerFirewallRule.String(): false, azureshared.DBforPostgreSQLFlexibleServerConfiguration.String(): false, } // These are conditional links (only present if server uses private networking or has FQDN) hasSubnetLink := false hasVirtualNetworkLink := false hasDNSLink := false for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() if expectedChildResources[linkedType] { t.Errorf("Found duplicate linked query for type %s", linkedType) } if _, exists := expectedChildResources[linkedType]; exists { expectedChildResources[linkedType] = true // Verify query method is SEARCH for child resources if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected linked query method SEARCH for %s, got %s", linkedType, liq.GetQuery().GetMethod()) } // Verify query is the server name if liq.GetQuery().GetQuery() != postgreSQLServerName { t.Errorf("Expected linked query to use server name %s, got %s", postgreSQLServerName, liq.GetQuery().GetQuery()) } // Verify scope matches if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } } // Check for conditional links if linkedType == azureshared.NetworkSubnet.String() { hasSubnetLink = true } if linkedType == azureshared.NetworkVirtualNetwork.String() { hasVirtualNetworkLink = true } if linkedType == stdlib.NetworkDNS.String() { hasDNSLink = true } } // Check that all expected child resources are linked for resourceType, found := range expectedChildResources { if !found { t.Errorf("Expected linked query to %s, but didn't find one", resourceType) } } log.Printf("Verified %d linked item queries for PostgreSQL Flexible Server %s (hasSubnet: %v, hasVNet: %v, hasDNS: %v)", len(linkedQueries), postgreSQLServerName, hasSubnetLink, hasVirtualNetworkLink, hasDNSLink) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete PostgreSQL Flexible Server err := deletePostgreSQLFlexibleServer(ctx, postgreSQLServerClient, integrationTestResourceGroup, postgreSQLServerName) if err != nil { t.Fatalf("Failed to delete PostgreSQL Flexible Server: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } ================================================ FILE: sources/azure/integration-tests/documentdb-database-accounts_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestCosmosDBAccountName = "ovm-integ-test-cosmos" ) func TestDocumentDBDatabaseAccountsIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients cosmosClient, err := armcosmos.NewDatabaseAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Cosmos DB client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create Cosmos DB account err = createCosmosDBAccount(ctx, cosmosClient, integrationTestResourceGroup, integrationTestCosmosDBAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create Cosmos DB account: %v", err) } // Wait for Cosmos DB account to be fully available err = waitForCosmosDBAccountAvailable(ctx, cosmosClient, integrationTestResourceGroup, integrationTestCosmosDBAccountName) if err != nil { t.Fatalf("Failed waiting for Cosmos DB account to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetCosmosDBAccount", func(t *testing.T) { ctx := t.Context() // Try to get the test account, skip if it doesn't exist _, err := cosmosClient.Get(ctx, integrationTestResourceGroup, integrationTestCosmosDBAccountName, nil) if err != nil { t.Skipf("Cosmos DB account %s does not exist in resource group %s, skipping test. Error: %v", integrationTestCosmosDBAccountName, integrationTestResourceGroup, err) } log.Printf("Retrieving Cosmos DB account %s in subscription %s, resource group %s", integrationTestCosmosDBAccountName, subscriptionID, integrationTestResourceGroup) cosmosWrapper := manual.NewDocumentDBDatabaseAccounts( clients.NewDocumentDBDatabaseAccountsClient(cosmosClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := cosmosWrapper.Scopes()[0] cosmosAdapter := sources.WrapperToAdapter(cosmosWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := cosmosAdapter.Get(ctx, scope, integrationTestCosmosDBAccountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestCosmosDBAccountName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestCosmosDBAccountName, uniqueAttrValue) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } log.Printf("Successfully retrieved Cosmos DB account %s", integrationTestCosmosDBAccountName) }) t.Run("ListCosmosDBAccounts", func(t *testing.T) { ctx := t.Context() log.Printf("Listing Cosmos DB accounts in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) cosmosWrapper := manual.NewDocumentDBDatabaseAccounts( clients.NewDocumentDBDatabaseAccountsClient(cosmosClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := cosmosWrapper.Scopes()[0] cosmosAdapter := sources.WrapperToAdapter(cosmosWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := cosmosAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list Cosmos DB accounts: %v", err) } // Note: len(sdpItems) can be 0 or more, which is valid _ = len(sdpItems) // Validate all items for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } } log.Printf("Successfully listed %d Cosmos DB accounts", len(sdpItems)) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete Cosmos DB account err := deleteCosmosDBAccount(ctx, cosmosClient, integrationTestResourceGroup, integrationTestCosmosDBAccountName) if err != nil { t.Fatalf("Failed to delete Cosmos DB account: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createCosmosDBAccount creates an Azure Cosmos DB account (idempotent) func createCosmosDBAccount(ctx context.Context, client *armcosmos.DatabaseAccountsClient, resourceGroupName, accountName, location string) error { // Check if Cosmos DB account already exists _, err := client.Get(ctx, resourceGroupName, accountName, nil) if err == nil { log.Printf("Cosmos DB account %s already exists, skipping creation", accountName) return nil } // Create the Cosmos DB account // Using SQL API as the default, which is the most common poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, accountName, armcosmos.DatabaseAccountCreateUpdateParameters{ Location: new(location), Kind: new(armcosmos.DatabaseAccountKindGlobalDocumentDB), Properties: &armcosmos.DatabaseAccountCreateUpdateProperties{ DatabaseAccountOfferType: new("Standard"), Locations: []*armcosmos.Location{ { LocationName: new(location), FailoverPriority: new(int32(0)), IsZoneRedundant: new(false), }, }, ConsistencyPolicy: &armcosmos.ConsistencyPolicy{ DefaultConsistencyLevel: new(armcosmos.DefaultConsistencyLevelSession), }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("documentdb-database-accounts"), }, }, nil) if err != nil { // Check if Cosmos DB account already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Cosmos DB account %s already exists (conflict), skipping creation", accountName) return nil } return fmt.Errorf("failed to begin creating Cosmos DB account: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create Cosmos DB account: %w", err) } // Verify the Cosmos DB account was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("Cosmos DB account created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("Cosmos DB account provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Cosmos DB account %s created successfully with provisioning state: %s", accountName, provisioningState) return nil } // waitForCosmosDBAccountAvailable waits for a Cosmos DB account to be fully available func waitForCosmosDBAccountAvailable(ctx context.Context, client *armcosmos.DatabaseAccountsClient, resourceGroupName, accountName string) error { maxAttempts := 20 pollInterval := 10 * time.Second for attempt := range maxAttempts { resp, err := client.Get(ctx, resourceGroupName, accountName, nil) if err != nil { return fmt.Errorf("failed to get Cosmos DB account: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Cosmos DB account %s is available", accountName) return nil } log.Printf("Cosmos DB account %s provisioning state: %s (attempt %d/%d)", accountName, state, attempt+1, maxAttempts) } time.Sleep(pollInterval) } return fmt.Errorf("Cosmos DB account %s did not become available within the timeout period", accountName) } // deleteCosmosDBAccount deletes an Azure Cosmos DB account (idempotent) func deleteCosmosDBAccount(ctx context.Context, client *armcosmos.DatabaseAccountsClient, resourceGroupName, accountName string) error { // Check if Cosmos DB account exists _, err := client.Get(ctx, resourceGroupName, accountName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Cosmos DB account %s does not exist, skipping deletion", accountName) return nil } return fmt.Errorf("failed to check if Cosmos DB account exists: %w", err) } // Delete the Cosmos DB account poller, err := client.BeginDelete(ctx, resourceGroupName, accountName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Cosmos DB account %s does not exist, skipping deletion", accountName) return nil } return fmt.Errorf("failed to begin deleting Cosmos DB account: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete Cosmos DB account: %w", err) } log.Printf("Cosmos DB account %s deleted successfully", accountName) return nil } ================================================ FILE: sources/azure/integration-tests/elastic-san-volume_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestElasticSanName = "ovm-integ-test-esan" integrationTestVolumeGroupName = "ovm-integ-test-vg" integrationTestVolumeName = "ovm-integ-test-vol" integrationTestElasticSanBaseTiB = int64(1) integrationTestVolumeSizeGiB = int64(1) ) func TestElasticSanVolumeIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients esClient, err := armelasticsan.NewElasticSansClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Elastic SAN client: %v", err) } vgClient, err := armelasticsan.NewVolumeGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Volume Groups client: %v", err) } volClient, err := armelasticsan.NewVolumesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Volumes client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create Elastic SAN err = createElasticSan(ctx, esClient, integrationTestResourceGroup, integrationTestElasticSanName, integrationTestLocation, integrationTestElasticSanBaseTiB) if err != nil { t.Fatalf("Failed to create Elastic SAN: %v", err) } // Wait for Elastic SAN to be available err = waitForElasticSanAvailable(ctx, esClient, integrationTestResourceGroup, integrationTestElasticSanName) if err != nil { t.Fatalf("Failed waiting for Elastic SAN to be available: %v", err) } // Create Volume Group err = createVolumeGroup(ctx, vgClient, integrationTestResourceGroup, integrationTestElasticSanName, integrationTestVolumeGroupName) if err != nil { t.Fatalf("Failed to create Volume Group: %v", err) } // Wait for Volume Group to be available err = waitForVolumeGroupAvailable(ctx, vgClient, integrationTestResourceGroup, integrationTestElasticSanName, integrationTestVolumeGroupName) if err != nil { t.Fatalf("Failed waiting for Volume Group to be available: %v", err) } // Create Volume err = createVolume(ctx, volClient, integrationTestResourceGroup, integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName, integrationTestVolumeSizeGiB) if err != nil { t.Fatalf("Failed to create Volume: %v", err) } // Wait for Volume to be available err = waitForVolumeAvailable(ctx, volClient, integrationTestResourceGroup, integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName) if err != nil { t.Fatalf("Failed waiting for Volume to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetVolume", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving volume %s in volume group %s, elastic san %s, subscription %s, resource group %s", integrationTestVolumeName, integrationTestVolumeGroupName, integrationTestElasticSanName, subscriptionID, integrationTestResourceGroup) volWrapper := manual.NewElasticSanVolume( clients.NewElasticSanVolumeClient(volClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := volWrapper.Scopes()[0] volAdapter := sources.WrapperToAdapter(volWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName) sdpItem, qErr := volAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUnique := shared.CompositeLookupKey(integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName) if uniqueAttrValue != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, uniqueAttrValue) } log.Printf("Successfully retrieved volume %s", integrationTestVolumeName) }) t.Run("SearchVolumes", func(t *testing.T) { ctx := t.Context() log.Printf("Searching volumes in volume group %s, elastic san %s", integrationTestVolumeGroupName, integrationTestElasticSanName) volWrapper := manual.NewElasticSanVolume( clients.NewElasticSanVolumeClient(volClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := volWrapper.Scopes()[0] volAdapter := sources.WrapperToAdapter(volWrapper, sdpcache.NewNoOpCache()) searchable, ok := volAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } query := shared.CompositeLookupKey(integrationTestElasticSanName, integrationTestVolumeGroupName) sdpItems, err := searchable.Search(ctx, scope, query, true) if err != nil { t.Fatalf("Failed to search volumes: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one volume, got %d", len(sdpItems)) } var found bool expectedUnique := shared.CompositeLookupKey(integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName) for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUnique { found = true break } } if !found { t.Fatalf("Expected to find volume %s in the search results", integrationTestVolumeName) } log.Printf("Found %d volumes in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for volume %s", integrationTestVolumeName) volWrapper := manual.NewElasticSanVolume( clients.NewElasticSanVolumeClient(volClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := volWrapper.Scopes()[0] volAdapter := sources.WrapperToAdapter(volWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName) sdpItem, qErr := volAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasElasticSanLink bool var hasVolumeGroupLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } if query.GetType() == azureshared.ElasticSan.String() { hasElasticSanLink = true if query.GetQuery() != integrationTestElasticSanName { t.Errorf("Expected linked query to elastic san %s, got %s", integrationTestElasticSanName, query.GetQuery()) } } if query.GetType() == azureshared.ElasticSanVolumeGroup.String() { hasVolumeGroupLink = true expectedQuery := shared.CompositeLookupKey(integrationTestElasticSanName, integrationTestVolumeGroupName) if query.GetQuery() != expectedQuery { t.Errorf("Expected linked query to volume group %s, got %s", expectedQuery, query.GetQuery()) } } } if !hasElasticSanLink { t.Error("Expected linked query to elastic san, but didn't find one") } if !hasVolumeGroupLink { t.Error("Expected linked query to volume group, but didn't find one") } log.Printf("Verified %d linked item queries for volume %s", len(linkedQueries), integrationTestVolumeName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() volWrapper := manual.NewElasticSanVolume( clients.NewElasticSanVolumeClient(volClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := volWrapper.Scopes()[0] volAdapter := sources.WrapperToAdapter(volWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName) sdpItem, qErr := volAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.ElasticSanVolume.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolume.String(), sdpItem.GetType()) } // Verify scope expectedScope := subscriptionID + "." + integrationTestResourceGroup if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } // Validate item if err := sdpItem.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } log.Printf("Verified item attributes for volume %s", integrationTestVolumeName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete Volume err := deleteVolume(ctx, volClient, integrationTestResourceGroup, integrationTestElasticSanName, integrationTestVolumeGroupName, integrationTestVolumeName) if err != nil { t.Logf("Failed to delete volume: %v", err) } // Delete Volume Group err = deleteVolumeGroup(ctx, vgClient, integrationTestResourceGroup, integrationTestElasticSanName, integrationTestVolumeGroupName) if err != nil { t.Logf("Failed to delete volume group: %v", err) } // Delete Elastic SAN err = deleteElasticSan(ctx, esClient, integrationTestResourceGroup, integrationTestElasticSanName) if err != nil { t.Logf("Failed to delete elastic san: %v", err) } // Resource group is kept for faster subsequent test runs }) } // createElasticSan creates an Azure Elastic SAN (idempotent) func createElasticSan(ctx context.Context, client *armelasticsan.ElasticSansClient, resourceGroupName, elasticSanName, location string, baseSizeTiB int64) error { _, err := client.Get(ctx, resourceGroupName, elasticSanName, nil) if err == nil { log.Printf("Elastic SAN %s already exists, skipping creation", elasticSanName) return nil } extendedCapacitySizeTiB := int64(0) poller, err := client.BeginCreate(ctx, resourceGroupName, elasticSanName, armelasticsan.ElasticSan{ Location: &location, Properties: &armelasticsan.Properties{ BaseSizeTiB: &baseSizeTiB, ExtendedCapacitySizeTiB: &extendedCapacitySizeTiB, SKU: &armelasticsan.SKU{ Name: new(armelasticsan.SKUNamePremiumLRS), }, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, elasticSanName, nil); getErr == nil { log.Printf("Elastic SAN %s already exists (conflict), skipping creation", elasticSanName) return nil } return fmt.Errorf("elastic san %s conflict but not retrievable: %w", elasticSanName, err) } return fmt.Errorf("failed to create elastic san: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create elastic san: %w", err) } log.Printf("Elastic SAN %s created successfully", elasticSanName) return nil } // waitForElasticSanAvailable waits for Elastic SAN to be available func waitForElasticSanAvailable(ctx context.Context, client *armelasticsan.ElasticSansClient, resourceGroupName, elasticSanName string) error { maxAttempts := 30 pollInterval := 10 * time.Second maxNotFoundAttempts := 5 notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, elasticSanName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("elastic san %s not found after %d attempts", elasticSanName, notFoundCount) } time.Sleep(pollInterval) continue } return fmt.Errorf("error checking elastic san: %w", err) } notFoundCount = 0 if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armelasticsan.ProvisioningStatesSucceeded { return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for elastic san %s", elasticSanName) } // createVolumeGroup creates an Azure Elastic SAN Volume Group (idempotent) func createVolumeGroup(ctx context.Context, client *armelasticsan.VolumeGroupsClient, resourceGroupName, elasticSanName, volumeGroupName string) error { _, err := client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, nil) if err == nil { log.Printf("Volume Group %s already exists, skipping creation", volumeGroupName) return nil } poller, err := client.BeginCreate(ctx, resourceGroupName, elasticSanName, volumeGroupName, armelasticsan.VolumeGroup{ Properties: &armelasticsan.VolumeGroupProperties{ ProtocolType: new(armelasticsan.StorageTargetTypeIscsi), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, nil); getErr == nil { log.Printf("Volume Group %s already exists (conflict), skipping creation", volumeGroupName) return nil } return fmt.Errorf("volume group %s conflict but not retrievable: %w", volumeGroupName, err) } return fmt.Errorf("failed to create volume group: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create volume group: %w", err) } log.Printf("Volume Group %s created successfully", volumeGroupName) return nil } // waitForVolumeGroupAvailable waits for Volume Group to be available func waitForVolumeGroupAvailable(ctx context.Context, client *armelasticsan.VolumeGroupsClient, resourceGroupName, elasticSanName, volumeGroupName string) error { maxAttempts := 20 pollInterval := 5 * time.Second maxNotFoundAttempts := 5 notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("volume group %s not found after %d attempts", volumeGroupName, notFoundCount) } time.Sleep(pollInterval) continue } return fmt.Errorf("error checking volume group: %w", err) } notFoundCount = 0 if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armelasticsan.ProvisioningStatesSucceeded { return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for volume group %s", volumeGroupName) } // createVolume creates an Azure Elastic SAN Volume (idempotent) func createVolume(ctx context.Context, client *armelasticsan.VolumesClient, resourceGroupName, elasticSanName, volumeGroupName, volumeName string, sizeGiB int64) error { _, err := client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, nil) if err == nil { log.Printf("Volume %s already exists, skipping creation", volumeName) return nil } poller, err := client.BeginCreate(ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, armelasticsan.Volume{ Properties: &armelasticsan.VolumeProperties{ SizeGiB: &sizeGiB, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, nil); getErr == nil { log.Printf("Volume %s already exists (conflict), skipping creation", volumeName) return nil } return fmt.Errorf("volume %s conflict but not retrievable: %w", volumeName, err) } return fmt.Errorf("failed to create volume: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create volume: %w", err) } log.Printf("Volume %s created successfully", volumeName) return nil } // waitForVolumeAvailable waits for Volume to be available func waitForVolumeAvailable(ctx context.Context, client *armelasticsan.VolumesClient, resourceGroupName, elasticSanName, volumeGroupName, volumeName string) error { maxAttempts := 20 pollInterval := 5 * time.Second maxNotFoundAttempts := 5 notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("volume %s not found after %d attempts", volumeName, notFoundCount) } time.Sleep(pollInterval) continue } return fmt.Errorf("error checking volume: %w", err) } notFoundCount = 0 if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armelasticsan.ProvisioningStatesSucceeded { return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for volume %s", volumeName) } // deleteVolume deletes an Azure Elastic SAN Volume func deleteVolume(ctx context.Context, client *armelasticsan.VolumesClient, resourceGroupName, elasticSanName, volumeGroupName, volumeName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Volume %s not found, skipping deletion", volumeName) return nil } return fmt.Errorf("failed to delete volume: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete volume: %w", err) } log.Printf("Volume %s deleted successfully", volumeName) return nil } // deleteVolumeGroup deletes an Azure Elastic SAN Volume Group func deleteVolumeGroup(ctx context.Context, client *armelasticsan.VolumeGroupsClient, resourceGroupName, elasticSanName, volumeGroupName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, elasticSanName, volumeGroupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Volume Group %s not found, skipping deletion", volumeGroupName) return nil } return fmt.Errorf("failed to delete volume group: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete volume group: %w", err) } log.Printf("Volume Group %s deleted successfully", volumeGroupName) return nil } // deleteElasticSan deletes an Azure Elastic SAN func deleteElasticSan(ctx context.Context, client *armelasticsan.ElasticSansClient, resourceGroupName, elasticSanName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, elasticSanName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Elastic SAN %s not found, skipping deletion", elasticSanName) return nil } return fmt.Errorf("failed to delete elastic san: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete elastic san: %w", err) } log.Printf("Elastic SAN %s deleted successfully", elasticSanName) return nil } ================================================ FILE: sources/azure/integration-tests/helpers_test.go ================================================ package integrationtests import ( "context" "fmt" "os" "regexp" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" ) // Shared constants for integration tests const ( integrationTestResourceGroupBase = "overmind-integration-tests" integrationTestLocation = "westus2" ) var integrationTestResourceGroup = resolveIntegrationTestResourceGroup() var invalidRunIDSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) // resolveIntegrationTestResourceGroup returns the default integration test resource group, // optionally scoped by AZURE_INTEGRATION_TEST_RUN_ID for parallel runs. // // Example: // // AZURE_INTEGRATION_TEST_RUN_ID=agent-42 // => overmind-integration-tests-agent-42 func resolveIntegrationTestResourceGroup() string { runID := normalizeIntegrationTestRunID(os.Getenv("AZURE_INTEGRATION_TEST_RUN_ID")) if runID == "" { return integrationTestResourceGroupBase } // Azure resource group names can be up to 90 characters. name := integrationTestResourceGroupBase + "-" + runID if len(name) > 90 { return name[:90] } return name } func normalizeIntegrationTestRunID(runID string) string { normalized := strings.ToLower(strings.TrimSpace(runID)) if normalized == "" { return "" } normalized = invalidRunIDSanitizer.ReplaceAllString(normalized, "-") normalized = strings.Trim(normalized, "-") if len(normalized) > 30 { normalized = normalized[:30] } return normalized } // createResourceGroup creates an Azure resource group if it doesn't already exist (idempotent) func createResourceGroup(ctx context.Context, client *armresources.ResourceGroupsClient, resourceGroupName, location string) error { // Check if resource group already exists _, err := client.Get(ctx, resourceGroupName, nil) if err == nil { log.Printf("Resource group %s already exists, skipping creation", resourceGroupName) return nil } // Create the resource group _, err = client.CreateOrUpdate(ctx, resourceGroupName, armresources.ResourceGroup{ Location: new(location), Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to create resource group: %w", err) } log.Printf("Resource group %s created successfully in location %s", resourceGroupName, location) return nil } ================================================ FILE: sources/azure/integration-tests/keyvault-managed-hsm_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestManagedHSMName = "ovm-integ-test-hsm" ) func TestKeyVaultManagedHSMIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients managedHSMClient, err := armkeyvault.NewManagedHsmsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Managed HSM client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Check if Managed HSM already exists first (quick check) existingHSM, err := managedHSMClient.Get(ctx, integrationTestResourceGroup, integrationTestManagedHSMName, nil) if err == nil { // Managed HSM exists, check if it's ready if existingHSM.Properties != nil && existingHSM.Properties.ProvisioningState != nil { state := *existingHSM.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Managed HSM %s already exists and is ready, skipping creation", integrationTestManagedHSMName) } else { log.Printf("Managed HSM %s exists but in state %s, waiting for it to be ready", integrationTestManagedHSMName, state) err = waitForManagedHSMAvailable(ctx, managedHSMClient, integrationTestResourceGroup, integrationTestManagedHSMName) if err != nil { t.Fatalf("Failed waiting for existing Managed HSM to be ready: %v", err) } } } else { log.Printf("Managed HSM %s already exists, verifying availability", integrationTestManagedHSMName) err = waitForManagedHSMAvailable(ctx, managedHSMClient, integrationTestResourceGroup, integrationTestManagedHSMName) if err != nil { t.Fatalf("Failed waiting for Managed HSM to be available: %v", err) } } log.Printf("Setup completed: Managed HSM %s is available", integrationTestManagedHSMName) } else { // Managed HSM doesn't exist // Managed HSM creation takes 30-60 minutes which exceeds test timeout // For integration tests, we require the Managed HSM to already exist // However, we don't skip the entire test suite - individual tests will skip if needed log.Printf("Managed HSM %s does not exist", integrationTestManagedHSMName) log.Printf("Managed HSM creation takes 30-60 minutes, which exceeds the test timeout of 5 minutes.") log.Printf("Please create the Managed HSM manually or wait for a previous creation to complete.") log.Printf("Note: Managed HSMs are only available in specific regions (e.g., eastus2, westus2, westeurope)") log.Printf("Tests that require the Managed HSM will be skipped, but ListManagedHSMs will still run.") } }) t.Run("Run", func(t *testing.T) { t.Run("GetManagedHSM", func(t *testing.T) { ctx := t.Context() // Try to get the test Managed HSM, skip if it doesn't exist _, err := managedHSMClient.Get(ctx, integrationTestResourceGroup, integrationTestManagedHSMName, nil) if err != nil { t.Skipf("Managed HSM %s does not exist in resource group %s, skipping test. Error: %v", integrationTestManagedHSMName, integrationTestResourceGroup, err) } log.Printf("Retrieving Managed HSM %s in subscription %s, resource group %s", integrationTestManagedHSMName, subscriptionID, integrationTestResourceGroup) hsmWrapper := manual.NewKeyVaultManagedHSM( clients.NewManagedHSMsClient(managedHSMClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := hsmWrapper.Scopes()[0] hsmAdapter := sources.WrapperToAdapter(hsmWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := hsmAdapter.Get(ctx, scope, integrationTestManagedHSMName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestManagedHSMName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestManagedHSMName, uniqueAttrValue) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } log.Printf("Successfully retrieved Managed HSM %s", integrationTestManagedHSMName) }) t.Run("ListManagedHSMs", func(t *testing.T) { ctx := t.Context() log.Printf("Listing Managed HSMs in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) hsmWrapper := manual.NewKeyVaultManagedHSM( clients.NewManagedHSMsClient(managedHSMClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := hsmWrapper.Scopes()[0] hsmAdapter := sources.WrapperToAdapter(hsmWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := hsmAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list Managed HSMs: %v", err) } // Note: len(sdpItems) can be 0 or more, which is valid if len(sdpItems) == 0 { log.Printf("No Managed HSMs found in resource group %s", integrationTestResourceGroup) } // Validate all items for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } } log.Printf("Successfully listed %d Managed HSMs", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() // Try to get the test Managed HSM, skip if it doesn't exist _, err := managedHSMClient.Get(ctx, integrationTestResourceGroup, integrationTestManagedHSMName, nil) if err != nil { t.Skipf("Managed HSM %s does not exist in resource group %s, skipping test. Error: %v", integrationTestManagedHSMName, integrationTestResourceGroup, err) } hsmWrapper := manual.NewKeyVaultManagedHSM( clients.NewManagedHSMsClient(managedHSMClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := hsmWrapper.Scopes()[0] hsmAdapter := sources.WrapperToAdapter(hsmWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := hsmAdapter.Get(ctx, scope, integrationTestManagedHSMName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.KeyVaultManagedHSM.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultManagedHSM, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for Managed HSM %s", integrationTestManagedHSMName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() // Try to get the test Managed HSM, skip if it doesn't exist _, err := managedHSMClient.Get(ctx, integrationTestResourceGroup, integrationTestManagedHSMName, nil) if err != nil { t.Skipf("Managed HSM %s does not exist in resource group %s, skipping test. Error: %v", integrationTestManagedHSMName, integrationTestResourceGroup, err) } hsmWrapper := manual.NewKeyVaultManagedHSM( clients.NewManagedHSMsClient(managedHSMClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := hsmWrapper.Scopes()[0] hsmAdapter := sources.WrapperToAdapter(hsmWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := hsmAdapter.Get(ctx, scope, integrationTestManagedHSMName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (if any) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { log.Printf("No linked item queries found for Managed HSM %s (this is valid if the HSM has no private endpoints, network ACLs, or managed identities)", integrationTestManagedHSMName) } // Verify expected linked item types for Managed HSM expectedLinkedTypes := map[string]bool{ azureshared.NetworkPrivateEndpoint.String(): false, azureshared.NetworkSubnet.String(): false, azureshared.ManagedIdentityUserAssignedIdentity.String(): false, } for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } linkedType := query.GetType() if _, exists := expectedLinkedTypes[linkedType]; exists { expectedLinkedTypes[linkedType] = true } // Verify query has required fields if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } } log.Printf("Verified %d linked item queries for Managed HSM %s", len(linkedQueries), integrationTestManagedHSMName) }) }) t.Run("Teardown", func(t *testing.T) { // Optionally delete the Managed HSM // Note: We keep the Managed HSM for faster subsequent test runs since creation takes 30-60 minutes // The Setup phase instructs users to pre-create the Managed HSM manually, so we don't delete it here // Uncomment the following if you want to clean up completely: // ctx := t.Context() // err := deleteManagedHSM(ctx, managedHSMClient, integrationTestResourceGroup, integrationTestManagedHSMName) // if err != nil { // t.Fatalf("Failed to delete Managed HSM: %v", err) // } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // waitForManagedHSMAvailable waits for a Managed HSM to be fully available func waitForManagedHSMAvailable(ctx context.Context, client *armkeyvault.ManagedHsmsClient, resourceGroupName, hsmName string) error { maxAttempts := 30 pollInterval := 10 * time.Second log.Printf("Waiting for Managed HSM %s to be available via API...", hsmName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, hsmName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Managed HSM %s not yet available (attempt %d/%d), waiting %v...", hsmName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking Managed HSM availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Managed HSM %s is available with provisioning state: %s", hsmName, state) return nil } if state == "Failed" { return fmt.Errorf("Managed HSM provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Managed HSM %s provisioning state: %s (attempt %d/%d), waiting...", hsmName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Managed HSM exists but no provisioning state - consider it available log.Printf("Managed HSM %s is available", hsmName) return nil } return fmt.Errorf("timeout waiting for Managed HSM %s to be available after %d attempts", hsmName, maxAttempts) } // deleteManagedHSM deletes an Azure Managed HSM (idempotent) // This function is kept for potential use when uncommenting the teardown deletion code // //nolint:unused // Intentionally kept for optional teardown cleanup func deleteManagedHSM(ctx context.Context, client *armkeyvault.ManagedHsmsClient, resourceGroupName, hsmName string) error { // Check if Managed HSM exists _, err := client.Get(ctx, resourceGroupName, hsmName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Managed HSM %s does not exist, skipping deletion", hsmName) return nil } return fmt.Errorf("failed to check if Managed HSM exists: %w", err) } // Delete the Managed HSM // Note: Managed HSMs may require purging after deletion if soft-delete is enabled // For integration tests, we'll attempt deletion poller, err := client.BeginDelete(ctx, resourceGroupName, hsmName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Managed HSM %s does not exist, skipping deletion", hsmName) return nil } return fmt.Errorf("failed to begin deleting Managed HSM: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete Managed HSM: %w", err) } log.Printf("Managed HSM %s deleted successfully", hsmName) return nil } ================================================ FILE: sources/azure/integration-tests/keyvault-secret_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "os/exec" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestSecretName = "ovm-integ-test-secret" ) func TestKeyVaultSecretIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients keyVaultClient, err := armkeyvault.NewVaultsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Key Vault client: %v", err) } secretsClient, err := armkeyvault.NewSecretsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Secrets client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Use the same Key Vault name as the vault integration test // Note: integrationTestKeyVaultName is defined in keyvault-vault_test.go vaultName := integrationTestKeyVaultName setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Verify Key Vault exists, create if it doesn't _, err = keyVaultClient.Get(ctx, integrationTestResourceGroup, vaultName, nil) var respErr *azcore.ResponseError if err != nil { // Check if it's a 404 (not found) - if so, create the vault if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Key Vault %s does not exist, creating it", vaultName) err = createKeyVault(ctx, keyVaultClient, integrationTestResourceGroup, vaultName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create Key Vault: %v", err) } } else { // Some other error occurred t.Fatalf("Failed to check if Key Vault exists: %v", err) } } else { log.Printf("Key Vault %s already exists", vaultName) } // Wait for Key Vault to be fully available err = waitForKeyVaultAvailable(ctx, keyVaultClient, integrationTestResourceGroup, vaultName) if err != nil { t.Fatalf("Failed waiting for Key Vault to be available: %v", err) } // Get the Key Vault to retrieve its properties (vault URI) vault, err := keyVaultClient.Get(ctx, integrationTestResourceGroup, vaultName, nil) if err != nil { t.Fatalf("Failed to get Key Vault: %v", err) } if vault.Properties == nil || vault.Properties.VaultURI == nil { t.Fatalf("Key Vault properties or VaultURI is nil") } // Create secret using Azure CLI (data plane operations require data plane SDK) err = createKeyVaultSecret(ctx, vaultName, integrationTestSecretName) if err != nil { t.Fatalf("Failed to create Key Vault secret: %v", err) } // After create/recover, ARM control plane can lag briefly before GET is consistent. err = waitForKeyVaultSecretAvailable(ctx, secretsClient, integrationTestResourceGroup, vaultName, integrationTestSecretName) if err != nil { t.Fatalf("Failed waiting for Key Vault secret to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetSecret", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving secret %s from vault %s in subscription %s, resource group %s", integrationTestSecretName, vaultName, subscriptionID, integrationTestResourceGroup) secretWrapper := manual.NewKeyVaultSecret( clients.NewSecretsClient(secretsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := secretWrapper.Scopes()[0] secretAdapter := sources.WrapperToAdapter(secretWrapper, sdpcache.NewNoOpCache()) // Get requires vaultName and secretName as query parts query := vaultName + shared.QuerySeparator + integrationTestSecretName sdpItem, qErr := secretAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Fatalf("Expected unique attribute key to be 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(vaultName, integrationTestSecretName) if uniqueAttrValue != expectedUniqueAttrValue { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } log.Printf("Successfully retrieved secret %s", integrationTestSecretName) }) t.Run("SearchSecrets", func(t *testing.T) { ctx := t.Context() log.Printf("Searching secrets in vault %s", vaultName) secretWrapper := manual.NewKeyVaultSecret( clients.NewSecretsClient(secretsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := secretWrapper.Scopes()[0] secretAdapter := sources.WrapperToAdapter(secretWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := secretAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, vaultName, true) if err != nil { t.Fatalf("Failed to search secrets: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one secret, got %d", len(sdpItems)) } var found bool expectedUniqueAttrValue := shared.CompositeLookupKey(vaultName, integrationTestSecretName) for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUniqueAttrValue { found = true break } } if !found { t.Fatalf("Expected to find secret %s in the search results", integrationTestSecretName) } log.Printf("Found %d secrets in search results", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for secret %s", integrationTestSecretName) secretWrapper := manual.NewKeyVaultSecret( clients.NewSecretsClient(secretsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := secretWrapper.Scopes()[0] secretAdapter := sources.WrapperToAdapter(secretWrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + integrationTestSecretName sdpItem, qErr := secretAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.KeyVaultSecret.String() { t.Errorf("Expected item type %s, got %s", azureshared.KeyVaultSecret, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for secret %s", integrationTestSecretName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for secret %s", integrationTestSecretName) secretWrapper := manual.NewKeyVaultSecret( clients.NewSecretsClient(secretsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := secretWrapper.Scopes()[0] secretAdapter := sources.WrapperToAdapter(secretWrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + integrationTestSecretName sdpItem, qErr := secretAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (Key Vault should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasKeyVaultLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.KeyVaultVault.String() { hasKeyVaultLink = true if liq.GetQuery().GetQuery() != vaultName { t.Errorf("Expected linked query to Key Vault %s, got %s", vaultName, liq.GetQuery().GetQuery()) } break } } if !hasKeyVaultLink { t.Error("Expected linked query to Key Vault, but didn't find one") } log.Printf("Verified %d linked item queries for secret %s", len(linkedQueries), integrationTestSecretName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete secret using Azure CLI err := deleteKeyVaultSecret(ctx, vaultName, integrationTestSecretName) if err != nil { t.Logf("Failed to delete secret: %v", err) } // Note: We don't delete the Key Vault here as it's shared with keyvault-vault_test.go // The Key Vault will be cleaned up by the vault integration test }) } // createKeyVaultSecret creates a Key Vault secret using Azure CLI (idempotent) func createKeyVaultSecret(ctx context.Context, vaultName, secretName string) error { // Check if secret already exists cmd := exec.CommandContext(ctx, "az", "keyvault", "secret", "show", "--vault-name", vaultName, "--name", secretName) err := cmd.Run() if err == nil { log.Printf("Secret %s already exists, skipping creation", secretName) return nil } // Create the secret cmd = exec.CommandContext(ctx, "az", "keyvault", "secret", "set", "--vault-name", vaultName, "--name", secretName, "--value", "test-secret-value") output, err := cmd.CombinedOutput() if err != nil { if strings.Contains(string(output), "ObjectIsDeletedButRecoverable") { log.Printf("Secret %s is deleted but recoverable, attempting recovery", secretName) recoverCmd := exec.CommandContext(ctx, "az", "keyvault", "secret", "recover", "--vault-name", vaultName, "--name", secretName) recoverOutput, recoverErr := recoverCmd.CombinedOutput() if recoverErr != nil { return fmt.Errorf("failed to recover deleted secret: %w, output: %s", recoverErr, string(recoverOutput)) } log.Printf("Secret %s recovered successfully", secretName) return nil } // If the command failed, it might be because the secret already exists // Try to show it to confirm showCmd := exec.CommandContext(ctx, "az", "keyvault", "secret", "show", "--vault-name", vaultName, "--name", secretName) if showCmd.Run() == nil { log.Printf("Secret %s already exists, skipping creation", secretName) return nil } return fmt.Errorf("failed to create secret: %w, output: %s", err, string(output)) } log.Printf("Secret %s created successfully", secretName) return nil } // deleteKeyVaultSecret deletes a Key Vault secret using Azure CLI (idempotent) func deleteKeyVaultSecret(ctx context.Context, vaultName, secretName string) error { // Check if secret exists first showCmd := exec.CommandContext(ctx, "az", "keyvault", "secret", "show", "--vault-name", vaultName, "--name", secretName) showErr := showCmd.Run() if showErr != nil { // Secret doesn't exist, which is fine - nothing to delete // We intentionally ignore showErr here as it indicates the secret doesn't exist log.Printf("Secret %s does not exist, skipping deletion", secretName) return nil //nolint:nilerr // Returning nil is correct when secret doesn't exist } // Secret exists, try to delete it cmd := exec.CommandContext(ctx, "az", "keyvault", "secret", "delete", "--vault-name", vaultName, "--name", secretName) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to delete secret: %w, output: %s", err, string(output)) } log.Printf("Secret %s deleted successfully", secretName) return nil } func waitForKeyVaultSecretAvailable(ctx context.Context, client *armkeyvault.SecretsClient, resourceGroupName, vaultName, secretName string) error { maxAttempts := 20 pollInterval := 3 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { _, err := client.Get(ctx, resourceGroupName, vaultName, secretName, nil) if err == nil { return nil } var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { time.Sleep(pollInterval) continue } return fmt.Errorf("error checking secret availability: %w", err) } return fmt.Errorf("timeout waiting for secret %s in vault %s to be available", secretName, vaultName) } ================================================ FILE: sources/azure/integration-tests/keyvault-vault_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestKeyVaultName = "ovm-integ-test-kv" ) func TestKeyVaultVaultIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients keyVaultClient, err := armkeyvault.NewVaultsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Key Vault client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create Key Vault err = createKeyVault(ctx, keyVaultClient, integrationTestResourceGroup, integrationTestKeyVaultName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create Key Vault: %v", err) } // Wait for Key Vault to be fully available err = waitForKeyVaultAvailable(ctx, keyVaultClient, integrationTestResourceGroup, integrationTestKeyVaultName) if err != nil { t.Fatalf("Failed waiting for Key Vault to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetKeyVault", func(t *testing.T) { ctx := t.Context() // Try to get the test vault, skip if it doesn't exist _, err := keyVaultClient.Get(ctx, integrationTestResourceGroup, integrationTestKeyVaultName, nil) if err != nil { t.Skipf("Key Vault %s does not exist in resource group %s, skipping test. Error: %v", integrationTestKeyVaultName, integrationTestResourceGroup, err) } log.Printf("Retrieving Key Vault %s in subscription %s, resource group %s", integrationTestKeyVaultName, subscriptionID, integrationTestResourceGroup) kvWrapper := manual.NewKeyVaultVault( clients.NewVaultsClient(keyVaultClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := kvWrapper.Scopes()[0] kvAdapter := sources.WrapperToAdapter(kvWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := kvAdapter.Get(ctx, scope, integrationTestKeyVaultName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestKeyVaultName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestKeyVaultName, uniqueAttrValue) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } log.Printf("Successfully retrieved Key Vault %s", integrationTestKeyVaultName) }) t.Run("ListKeyVaults", func(t *testing.T) { ctx := t.Context() log.Printf("Listing Key Vaults in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) kvWrapper := manual.NewKeyVaultVault( clients.NewVaultsClient(keyVaultClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := kvWrapper.Scopes()[0] kvAdapter := sources.WrapperToAdapter(kvWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := kvAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list Key Vaults: %v", err) } // Note: len(sdpItems) can be 0 or more, which is valid if len(sdpItems) == 0 { log.Printf("No Key Vaults found in resource group %s", integrationTestResourceGroup) } // Validate all items for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } } log.Printf("Successfully listed %d Key Vaults", len(sdpItems)) }) }) t.Run("Teardown", func(t *testing.T) { // We intentionally keep the Key Vault by default. // // Key Vault names are globally unique and (by default) soft-deleted on removal. // Deleting the vault in tests frequently causes subsequent runs to fail because the // name is still held by the soft-deleted vault, and recreating requires a purge. // // To opt into cleanup, set CLEANUP_AZURE_INTEGRATION_TESTS=true. if os.Getenv("CLEANUP_AZURE_INTEGRATION_TESTS") == "true" { ctx := t.Context() err := deleteKeyVault(ctx, keyVaultClient, integrationTestResourceGroup, integrationTestKeyVaultName) if err != nil { t.Fatalf("Failed to delete Key Vault: %v", err) } } else { log.Printf("Skipping Key Vault deletion (set CLEANUP_AZURE_INTEGRATION_TESTS=true to enable)") } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createKeyVault creates an Azure Key Vault (idempotent) func createKeyVault(ctx context.Context, client *armkeyvault.VaultsClient, resourceGroupName, vaultName, location string) error { // Check if Key Vault already exists _, err := client.Get(ctx, resourceGroupName, vaultName, nil) if err == nil { log.Printf("Key Vault %s already exists, skipping creation", vaultName) return nil } // Get the tenant ID from environment variable tenantID := os.Getenv("AZURE_TENANT_ID") if tenantID == "" { return fmt.Errorf("AZURE_TENANT_ID environment variable not set, required for Key Vault creation") } // Create a context with timeout for the entire Key Vault creation operation. // Purging soft-deleted vaults can take several minutes in Azure. // Key Vault creation can hang if the Microsoft.KeyVault resource provider is not registered createCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) defer cancel() // Create the Key Vault. // Key Vault names must be globally unique and 3-24 characters // They can only contain alphanumeric characters and hyphens params := armkeyvault.VaultCreateOrUpdateParameters{ Location: new(location), Properties: &armkeyvault.VaultProperties{ TenantID: new(tenantID), SKU: &armkeyvault.SKU{ Family: new(armkeyvault.SKUFamilyA), Name: new(armkeyvault.SKUNameStandard), }, AccessPolicies: []*armkeyvault.AccessPolicyEntry{ // For integration tests, we create with minimal configuration. }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("keyvault-vault"), }, } // We allow one remediation pass for the common failure mode: // the vault was soft-deleted previously, so the name is still held and create returns 409. for attempt := 1; attempt <= 2; attempt++ { poller, err := client.BeginCreateOrUpdate(createCtx, resourceGroupName, vaultName, params, nil) if err != nil { // Check if context timed out if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("timeout starting Key Vault creation (this may indicate the Microsoft.KeyVault resource provider is not registered or the operation is taking too long): %w", err) } var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { // Key Vault uses soft-delete by default; 409 commonly means a deleted vault is still holding the name. // Attempt to purge the deleted vault (if it exists) and retry creation. if attempt == 1 { if purgeErr := purgeSoftDeletedKeyVault(createCtx, client, vaultName, location); purgeErr != nil { return fmt.Errorf("key vault name conflict for %s and purge failed: %w", vaultName, purgeErr) } continue } return fmt.Errorf("key vault name conflict for %s (it may be soft-deleted and require purge before recreate): %w", vaultName, err) } return fmt.Errorf("failed to begin creating Key Vault: %w", err) } // Use the same timeout context for polling resp, err := poller.PollUntilDone(createCtx, nil) if err != nil { // Check if context timed out if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("timeout waiting for Key Vault creation to complete (this may indicate the Microsoft.KeyVault resource provider is not registered): %w", err) } return fmt.Errorf("failed to create Key Vault: %w", err) } // Verify the Key Vault was created successfully if resp.Properties == nil { return fmt.Errorf("Key Vault created but properties are nil") } log.Printf("Key Vault %s created successfully", vaultName) return nil } return fmt.Errorf("failed to create Key Vault %s: exhausted retries", vaultName) } // waitForKeyVaultAvailable waits for a Key Vault to be fully available func waitForKeyVaultAvailable(ctx context.Context, client *armkeyvault.VaultsClient, resourceGroupName, vaultName string) error { maxAttempts := 20 pollInterval := 10 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, vaultName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Key Vault %s not yet available (attempt %d/%d), waiting %v...", vaultName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("failed to get Key Vault: %w", err) } // Key Vaults don't have a provisioning state like other resources // If we can get the vault, it's available if resp.Properties != nil { log.Printf("Key Vault %s is available", vaultName) return nil } log.Printf("Waiting for Key Vault %s to be available (attempt %d/%d)", vaultName, attempt, maxAttempts) time.Sleep(pollInterval) } return fmt.Errorf("Key Vault %s did not become available within the timeout period", vaultName) } func purgeSoftDeletedKeyVault(ctx context.Context, client *armkeyvault.VaultsClient, vaultName, location string) error { // Check if the vault is soft-deleted (this is the usual reason for 409 conflicts). _, err := client.GetDeleted(ctx, vaultName, location, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { // The caller may have passed the wrong location. Try to locate the deleted vault and // determine its original location. pager := client.NewListDeletedPager(nil) for pager.More() { page, pageErr := pager.NextPage(ctx) if pageErr != nil { return fmt.Errorf("failed to list deleted vaults while resolving conflict for %s: %w", vaultName, pageErr) } for _, v := range page.Value { if v == nil || v.Name == nil || *v.Name != vaultName { continue } if v.Properties != nil && v.Properties.Location != nil && *v.Properties.Location != "" { location = *v.Properties.Location log.Printf("Found soft-deleted Key Vault %s in location %s via ListDeleted", vaultName, location) goto purge } // If we can't determine location, we still can't purge with the SDK. return fmt.Errorf("soft-deleted Key Vault %s found but location was empty; cannot purge automatically", vaultName) } } // Not a soft-deleted vault in this subscription (or not visible); the name may be held elsewhere. return fmt.Errorf("vault name %s is not soft-deleted in subscription/location (may be held by another subscription/tenant): %w", vaultName, err) } return fmt.Errorf("failed to check deleted Key Vault %s in %s: %w", vaultName, location, err) } purge: log.Printf("Key Vault %s is soft-deleted in %s; purging to allow recreation", vaultName, location) poller, err := client.BeginPurgeDeleted(ctx, vaultName, location, nil) if err != nil { return fmt.Errorf("failed to begin purging soft-deleted Key Vault %s: %w", vaultName, err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to purge soft-deleted Key Vault %s: %w", vaultName, err) } log.Printf("Soft-deleted Key Vault %s purged successfully", vaultName) return nil } // deleteKeyVault deletes an Azure Key Vault (idempotent) func deleteKeyVault(ctx context.Context, client *armkeyvault.VaultsClient, resourceGroupName, vaultName string) error { // Check if Key Vault exists _, err := client.Get(ctx, resourceGroupName, vaultName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Key Vault %s does not exist, skipping deletion", vaultName) return nil } return fmt.Errorf("failed to check if Key Vault exists: %w", err) } // Delete the Key Vault // Note: Key Vaults may require soft-delete to be disabled first // For integration tests, we'll attempt deletion _, err = client.Delete(ctx, resourceGroupName, vaultName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Key Vault %s does not exist, skipping deletion", vaultName) return nil } return fmt.Errorf("failed to delete Key Vault: %w", err) } log.Printf("Key Vault %s deleted successfully", vaultName) return nil } ================================================ FILE: sources/azure/integration-tests/keyvault_helpers_test.go ================================================ package integrationtests import ( "context" "fmt" "os/exec" "strings" log "github.com/sirupsen/logrus" ) // ensureKeyVaultKey ensures a Key Vault key exists and returns its (versioned) key URL. // // Note: this uses the Azure CLI for data-plane operations to keep integration test setup simple. func ensureKeyVaultKey(ctx context.Context, vaultName, keyName string) (string, error) { // If the key already exists, return its current (versioned) URL. showCmd := exec.CommandContext(ctx, "az", "keyvault", "key", "show", "--vault-name", vaultName, "--name", keyName, "--query", "key.kid", "-o", "tsv", ) if out, err := showCmd.CombinedOutput(); err == nil { keyURL := strings.TrimSpace(string(out)) if keyURL != "" { log.Printf("Key Vault key %s already exists in vault %s", keyName, vaultName) return keyURL, nil } } createCmd := exec.CommandContext(ctx, "az", "keyvault", "key", "create", "--vault-name", vaultName, "--name", keyName, "--kty", "RSA", "--size", "2048", "--query", "key.kid", "-o", "tsv", ) out, err := createCmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to create key vault key: %w, output: %s", err, string(out)) } keyURL := strings.TrimSpace(string(out)) if keyURL == "" { return "", fmt.Errorf("created key but key URL was empty") } log.Printf("Key Vault key %s created in vault %s", keyName, vaultName) return keyURL, nil } // grantKeyVaultCryptoAccess grants an identity access to Key Vault key crypto operations. // // Different vaults may use access policies or RBAC for authorization, so we attempt both. func grantKeyVaultCryptoAccess(ctx context.Context, vaultName, vaultResourceID, principalID string) error { // Try access-policy based authorization. // This is idempotent: if policy exists, it updates. setPolicyCmd := exec.CommandContext(ctx, "az", "keyvault", "set-policy", "--name", vaultName, "--object-id", principalID, "--key-permissions", "get", "wrapKey", "unwrapKey", ) if out, err := setPolicyCmd.CombinedOutput(); err != nil { log.Printf("Key Vault set-policy failed (may be RBAC-enabled vault): %v, output: %s", err, string(out)) } // Try RBAC based authorization. // This can fail if the vault isn't RBAC-enabled for data-plane, but it won't hurt to try. roleCmd := exec.CommandContext(ctx, "az", "role", "assignment", "create", "--assignee-object-id", principalID, "--assignee-principal-type", "ServicePrincipal", "--role", "Key Vault Crypto Service Encryption User", "--scope", vaultResourceID, ) if out, err := roleCmd.CombinedOutput(); err != nil { // If the assignment already exists, treat it as success. if strings.Contains(string(out), "RoleAssignmentExists") || strings.Contains(string(out), "already exists") { return nil } log.Printf("Key Vault role assignment failed (may be access-policy vault): %v, output: %s", err, string(out)) } return nil } ================================================ FILE: sources/azure/integration-tests/main_test.go ================================================ package integrationtests import ( "fmt" "os" "strconv" "testing" ) func TestMain(m *testing.M) { if shouldRunIntegrationTests() { fmt.Println("Running integration tests") os.Exit(m.Run()) } else { fmt.Println("Skipping integration tests, set RUN_AZURE_INTEGRATION_TESTS=true to run them") os.Exit(0) } } func shouldRunIntegrationTests() bool { run, found := os.LookupEnv("RUN_AZURE_INTEGRATION_TESTS") if !found { return false } shouldRun, err := strconv.ParseBool(run) if err != nil { return false } return shouldRun } ================================================ FILE: sources/azure/integration-tests/maintenance-maintenance-configuration_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestMaintenanceConfigName = "ovm-integ-test-maint-config" ) func TestMaintenanceMaintenanceConfigurationIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } configurationsClient, err := armmaintenance.NewConfigurationsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Maintenance Configurations client: %v", err) } configurationsForResourceGroupClient, err := armmaintenance.NewConfigurationsForResourceGroupClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Maintenance Configurations For Resource Group client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createMaintenanceConfig(ctx, configurationsClient, integrationTestResourceGroup, integrationTestMaintenanceConfigName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create maintenance configuration: %v", err) } err = waitForMaintenanceConfigAvailable(ctx, configurationsClient, integrationTestResourceGroup, integrationTestMaintenanceConfigName) if err != nil { t.Fatalf("Failed waiting for maintenance configuration to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetMaintenanceConfiguration", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving maintenance configuration %s in subscription %s, resource group %s", integrationTestMaintenanceConfigName, subscriptionID, integrationTestResourceGroup) wrapper := manual.NewMaintenanceMaintenanceConfiguration( clients.NewMaintenanceConfigurationClient(configurationsClient, configurationsForResourceGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestMaintenanceConfigName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestMaintenanceConfigName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestMaintenanceConfigName, uniqueAttrValue) } log.Printf("Successfully retrieved maintenance configuration %s", integrationTestMaintenanceConfigName) }) t.Run("ListMaintenanceConfigurations", func(t *testing.T) { ctx := t.Context() log.Printf("Listing maintenance configurations in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) wrapper := manual.NewMaintenanceMaintenanceConfiguration( clients.NewMaintenanceConfigurationClient(configurationsClient, configurationsForResourceGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list maintenance configurations: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one maintenance configuration, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestMaintenanceConfigName { found = true break } } if !found { t.Fatalf("Expected to find maintenance configuration %s in the list", integrationTestMaintenanceConfigName) } log.Printf("Found %d maintenance configurations in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for maintenance configuration %s", integrationTestMaintenanceConfigName) wrapper := manual.NewMaintenanceMaintenanceConfiguration( clients.NewMaintenanceConfigurationClient(configurationsClient, configurationsForResourceGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestMaintenanceConfigName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.MaintenanceMaintenanceConfiguration.String() { t.Errorf("Expected item type %s, got %s", azureshared.MaintenanceMaintenanceConfiguration, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for maintenance configuration %s", integrationTestMaintenanceConfigName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for maintenance configuration %s", integrationTestMaintenanceConfigName) wrapper := manual.NewMaintenanceMaintenanceConfiguration( clients.NewMaintenanceConfigurationClient(configurationsClient, configurationsForResourceGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestMaintenanceConfigName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for maintenance configuration %s", len(linkedQueries), integrationTestMaintenanceConfigName) for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() == sdp.QueryMethod_GET || query.GetMethod() == sdp.QueryMethod_SEARCH { // Valid method } else { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteMaintenanceConfig(ctx, configurationsClient, integrationTestResourceGroup, integrationTestMaintenanceConfigName) if err != nil { t.Fatalf("Failed to delete maintenance configuration: %v", err) } }) } func createMaintenanceConfig(ctx context.Context, client *armmaintenance.ConfigurationsClient, resourceGroupName, configName, location string) error { _, err := client.Get(ctx, resourceGroupName, configName, nil) if err == nil { log.Printf("Maintenance configuration %s already exists, skipping creation", configName) return nil } maintenanceScope := armmaintenance.MaintenanceScopeHost visibility := armmaintenance.VisibilityCustom _, err = client.CreateOrUpdate(ctx, resourceGroupName, configName, armmaintenance.Configuration{ Location: &location, Properties: &armmaintenance.ConfigurationProperties{ MaintenanceScope: &maintenanceScope, Visibility: &visibility, MaintenanceWindow: &armmaintenance.Window{ StartDateTime: new("2025-01-01 00:00"), Duration: new("02:00"), TimeZone: new("Pacific Standard Time"), RecurEvery: new("Day"), }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("maintenance-configuration"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Maintenance configuration %s already exists (conflict), skipping creation", configName) return nil } return fmt.Errorf("failed to create maintenance configuration: %w", err) } log.Printf("Maintenance configuration %s created successfully", configName) return nil } func waitForMaintenanceConfigAvailable(ctx context.Context, client *armmaintenance.ConfigurationsClient, resourceGroupName, configName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for maintenance configuration %s to be available via API...", configName) for attempt := 1; attempt <= maxAttempts; attempt++ { _, err := client.Get(ctx, resourceGroupName, configName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Maintenance configuration %s not yet available (attempt %d/%d), waiting %v...", configName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking maintenance configuration availability: %w", err) } log.Printf("Maintenance configuration %s is available", configName) return nil } return fmt.Errorf("timeout waiting for maintenance configuration %s to be available after %d attempts", configName, maxAttempts) } func deleteMaintenanceConfig(ctx context.Context, client *armmaintenance.ConfigurationsClient, resourceGroupName, configName string) error { _, err := client.Delete(ctx, resourceGroupName, configName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Maintenance configuration %s not found, skipping deletion", configName) return nil } return fmt.Errorf("failed to delete maintenance configuration: %w", err) } log.Printf("Maintenance configuration %s deleted successfully", configName) return nil } ================================================ FILE: sources/azure/integration-tests/managedidentity-federated-identity-credential_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestIdentityName = "ovm-integ-test-identity" integrationTestFedCredName = "ovm-integ-test-fed-cred" integrationTestFedCredIssuer = "https://token.actions.githubusercontent.com" integrationTestFedCredSubject = "repo:overmindtech/test-repo:ref:refs/heads/main" integrationTestFedCredAudience = "api://AzureADTokenExchange" ) func TestManagedIdentityFederatedIdentityCredentialIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } uaiClient, err := armmsi.NewUserAssignedIdentitiesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create User Assigned Identities client: %v", err) } ficClient, err := armmsi.NewFederatedIdentityCredentialsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Federated Identity Credentials client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createUserAssignedIdentity(ctx, uaiClient, integrationTestResourceGroup, integrationTestIdentityName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create user assigned identity: %v", err) } err = waitForUserAssignedIdentityAvailable(ctx, uaiClient, integrationTestResourceGroup, integrationTestIdentityName) if err != nil { t.Fatalf("Failed waiting for user assigned identity to be available: %v", err) } err = createFederatedIdentityCredential(ctx, ficClient, integrationTestResourceGroup, integrationTestIdentityName, integrationTestFedCredName) if err != nil { t.Fatalf("Failed to create federated identity credential: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetFederatedIdentityCredential", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving federated identity credential %s for identity %s, subscription %s, resource group %s", integrationTestFedCredName, integrationTestIdentityName, subscriptionID, integrationTestResourceGroup) wrapper := manual.NewManagedIdentityFederatedIdentityCredential( clients.NewFederatedIdentityCredentialsClient(ficClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestIdentityName, integrationTestFedCredName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueValue := shared.CompositeLookupKey(integrationTestIdentityName, integrationTestFedCredName) if uniqueAttrValue != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, uniqueAttrValue) } log.Printf("Successfully retrieved federated identity credential %s", integrationTestFedCredName) }) t.Run("SearchFederatedIdentityCredentials", func(t *testing.T) { ctx := t.Context() log.Printf("Searching federated identity credentials for identity %s", integrationTestIdentityName) wrapper := manual.NewManagedIdentityFederatedIdentityCredential( clients.NewFederatedIdentityCredentialsClient(ficClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestIdentityName, true) if err != nil { t.Fatalf("Failed to search federated identity credentials: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one federated identity credential, got %d", len(sdpItems)) } var found bool expectedValue := shared.CompositeLookupKey(integrationTestIdentityName, integrationTestFedCredName) for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedValue { found = true break } } if !found { t.Fatalf("Expected to find credential %s in the search results", integrationTestFedCredName) } log.Printf("Found %d federated identity credentials in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for federated identity credential %s", integrationTestFedCredName) wrapper := manual.NewManagedIdentityFederatedIdentityCredential( clients.NewFederatedIdentityCredentialsClient(ficClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestIdentityName, integrationTestFedCredName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasIdentityLink bool var hasDNSLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query.GetType() == "" { t.Error("Linked query has empty Type") } if query.GetQuery() == "" { t.Error("Linked query has empty Query") } if query.GetScope() == "" { t.Error("Linked query has empty Scope") } if query.GetType() == azureshared.ManagedIdentityUserAssignedIdentity.String() { hasIdentityLink = true if query.GetQuery() != integrationTestIdentityName { t.Errorf("Expected linked query to identity %s, got %s", integrationTestIdentityName, query.GetQuery()) } } if query.GetType() == "dns" { hasDNSLink = true if query.GetQuery() != "token.actions.githubusercontent.com" { t.Errorf("Expected DNS query to token.actions.githubusercontent.com, got %s", query.GetQuery()) } if query.GetScope() != "global" { t.Errorf("Expected DNS query scope to be global, got %s", query.GetScope()) } } } if !hasIdentityLink { t.Error("Expected linked query to user assigned identity, but didn't find one") } if !hasDNSLink { t.Error("Expected linked query to DNS (from Issuer URL), but didn't find one") } log.Printf("Verified %d linked item queries for federated identity credential %s", len(linkedQueries), integrationTestFedCredName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewManagedIdentityFederatedIdentityCredential( clients.NewFederatedIdentityCredentialsClient(ficClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestIdentityName, integrationTestFedCredName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ManagedIdentityFederatedIdentityCredential.String() { t.Errorf("Expected type %s, got %s", azureshared.ManagedIdentityFederatedIdentityCredential, sdpItem.GetType()) } expectedScope := subscriptionID + "." + integrationTestResourceGroup if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } log.Printf("Verified item attributes for federated identity credential %s", integrationTestFedCredName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteFederatedIdentityCredential(ctx, ficClient, integrationTestResourceGroup, integrationTestIdentityName, integrationTestFedCredName) if err != nil { t.Fatalf("Failed to delete federated identity credential: %v", err) } err = deleteUserAssignedIdentity(ctx, uaiClient, integrationTestResourceGroup, integrationTestIdentityName) if err != nil { t.Fatalf("Failed to delete user assigned identity: %v", err) } }) } func createFederatedIdentityCredential(ctx context.Context, client *armmsi.FederatedIdentityCredentialsClient, resourceGroupName, identityName, credentialName string) error { _, err := client.Get(ctx, resourceGroupName, identityName, credentialName, nil) if err == nil { log.Printf("Federated identity credential %s already exists, skipping creation", credentialName) return nil } _, err = client.CreateOrUpdate(ctx, resourceGroupName, identityName, credentialName, armmsi.FederatedIdentityCredential{ Properties: &armmsi.FederatedIdentityCredentialProperties{ Issuer: new(integrationTestFedCredIssuer), Subject: new(integrationTestFedCredSubject), Audiences: []*string{new(integrationTestFedCredAudience)}, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, identityName, credentialName, nil); getErr == nil { log.Printf("Federated identity credential %s already exists (conflict), skipping creation", credentialName) return nil } return fmt.Errorf("federated identity credential %s conflict but not retrievable: %w", credentialName, err) } return fmt.Errorf("failed to create federated identity credential: %w", err) } log.Printf("Federated identity credential %s created successfully", credentialName) return nil } func deleteFederatedIdentityCredential(ctx context.Context, client *armmsi.FederatedIdentityCredentialsClient, resourceGroupName, identityName, credentialName string) error { _, err := client.Delete(ctx, resourceGroupName, identityName, credentialName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Federated identity credential %s not found, skipping deletion", credentialName) return nil } return fmt.Errorf("failed to delete federated identity credential: %w", err) } log.Printf("Federated identity credential %s deleted successfully", credentialName) return nil } ================================================ FILE: sources/azure/integration-tests/managedidentity-user-assigned-identity_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestUserAssignedIdentityName = "ovm-integ-test-uai" ) func TestManagedIdentityUserAssignedIdentityIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients identityClient, err := armmsi.NewUserAssignedIdentitiesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create User Assigned Identities client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create User Assigned Identity err = createUserAssignedIdentity(ctx, identityClient, integrationTestResourceGroup, integrationTestUserAssignedIdentityName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create User Assigned Identity: %v", err) } // Wait for User Assigned Identity to be fully available err = waitForUserAssignedIdentityAvailable(ctx, identityClient, integrationTestResourceGroup, integrationTestUserAssignedIdentityName) if err != nil { t.Fatalf("Failed waiting for User Assigned Identity to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetUserAssignedIdentity", func(t *testing.T) { ctx := t.Context() // Try to get the test identity, skip if it doesn't exist _, err := identityClient.Get(ctx, integrationTestResourceGroup, integrationTestUserAssignedIdentityName, nil) if err != nil { t.Skipf("User Assigned Identity %s does not exist in resource group %s, skipping test. Error: %v", integrationTestUserAssignedIdentityName, integrationTestResourceGroup, err) } log.Printf("Retrieving User Assigned Identity %s in subscription %s, resource group %s", integrationTestUserAssignedIdentityName, subscriptionID, integrationTestResourceGroup) identityWrapper := manual.NewManagedIdentityUserAssignedIdentity( clients.NewUserAssignedIdentitiesClient(identityClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := identityWrapper.Scopes()[0] identityAdapter := sources.WrapperToAdapter(identityWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := identityAdapter.Get(ctx, scope, integrationTestUserAssignedIdentityName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestUserAssignedIdentityName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestUserAssignedIdentityName, uniqueAttrValue) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } log.Printf("Successfully retrieved User Assigned Identity %s", integrationTestUserAssignedIdentityName) }) t.Run("ListUserAssignedIdentities", func(t *testing.T) { ctx := t.Context() log.Printf("Listing User Assigned Identities in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) identityWrapper := manual.NewManagedIdentityUserAssignedIdentity( clients.NewUserAssignedIdentitiesClient(identityClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := identityWrapper.Scopes()[0] identityAdapter := sources.WrapperToAdapter(identityWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := identityAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list User Assigned Identities: %v", err) } // Note: len(sdpItems) can be 0 or more, which is valid if len(sdpItems) == 0 { log.Printf("No User Assigned Identities found in resource group %s", integrationTestResourceGroup) } // Validate all items for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("SDP item validation failed: %v", err) } } // Verify we can find the test identity in the list var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestUserAssignedIdentityName { found = true break } } if !found { t.Fatalf("Expected to find identity %s in the list of User Assigned Identities", integrationTestUserAssignedIdentityName) } log.Printf("Successfully listed %d User Assigned Identities", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for User Assigned Identity %s", integrationTestUserAssignedIdentityName) identityWrapper := manual.NewManagedIdentityUserAssignedIdentity( clients.NewUserAssignedIdentitiesClient(identityClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := identityWrapper.Scopes()[0] identityAdapter := sources.WrapperToAdapter(identityWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := identityAdapter.Get(ctx, scope, integrationTestUserAssignedIdentityName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (federated identity credentials should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasFederatedCredentialLink bool for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.ManagedIdentityFederatedIdentityCredential.String(): hasFederatedCredentialLink = true // Verify federated credential link properties if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected federated credential link method to be SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() != integrationTestUserAssignedIdentityName { t.Errorf("Expected federated credential link query to be %s, got %s", integrationTestUserAssignedIdentityName, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected federated credential link scope to be %s, got %s", scope, liq.GetQuery().GetScope()) } default: t.Errorf("Unexpected linked item type: %s", liq.GetQuery().GetType()) } } if !hasFederatedCredentialLink { t.Error("Expected linked query to federated identity credentials, but didn't find one") } log.Printf("Verified %d linked item queries for User Assigned Identity %s", len(linkedQueries), integrationTestUserAssignedIdentityName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete User Assigned Identity err := deleteUserAssignedIdentity(ctx, identityClient, integrationTestResourceGroup, integrationTestUserAssignedIdentityName) if err != nil { t.Fatalf("Failed to delete User Assigned Identity: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createUserAssignedIdentity creates an Azure User Assigned Identity (idempotent) func createUserAssignedIdentity(ctx context.Context, client *armmsi.UserAssignedIdentitiesClient, resourceGroupName, identityName, location string) error { // Check if User Assigned Identity already exists _, err := client.Get(ctx, resourceGroupName, identityName, nil) if err == nil { log.Printf("User Assigned Identity %s already exists, skipping creation", identityName) return nil } // Create the User Assigned Identity resp, err := client.CreateOrUpdate(ctx, resourceGroupName, identityName, armmsi.Identity{ Location: new(location), Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("managedidentity-user-assigned-identity"), }, }, nil) if err != nil { // Check if identity already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("User Assigned Identity %s already exists (conflict), skipping creation", identityName) return nil } return fmt.Errorf("failed to create User Assigned Identity: %w", err) } // Verify the identity was created successfully if resp.Properties == nil { return fmt.Errorf("User Assigned Identity created but properties are nil") } log.Printf("User Assigned Identity %s created successfully", identityName) return nil } // waitForUserAssignedIdentityAvailable waits for a User Assigned Identity to be fully available func waitForUserAssignedIdentityAvailable(ctx context.Context, client *armmsi.UserAssignedIdentitiesClient, resourceGroupName, identityName string) error { maxAttempts := 20 pollInterval := 10 * time.Second log.Printf("Waiting for User Assigned Identity %s to be available...", identityName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, identityName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("User Assigned Identity %s not yet available (attempt %d/%d), waiting %v...", identityName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking User Assigned Identity availability: %w", err) } // User Assigned Identities don't have a provisioning state like some other resources // If we can get the identity and it has properties, it's available if resp.Properties != nil { log.Printf("User Assigned Identity %s is available", identityName) return nil } log.Printf("Waiting for User Assigned Identity %s to be available (attempt %d/%d)", identityName, attempt, maxAttempts) time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for User Assigned Identity %s to be available after %d attempts", identityName, maxAttempts) } // deleteUserAssignedIdentity deletes an Azure User Assigned Identity (idempotent) func deleteUserAssignedIdentity(ctx context.Context, client *armmsi.UserAssignedIdentitiesClient, resourceGroupName, identityName string) error { // Check if User Assigned Identity exists _, err := client.Get(ctx, resourceGroupName, identityName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("User Assigned Identity %s does not exist, skipping deletion", identityName) return nil } return fmt.Errorf("failed to check if User Assigned Identity exists: %w", err) } // Delete the User Assigned Identity _, err = client.Delete(ctx, resourceGroupName, identityName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("User Assigned Identity %s does not exist, skipping deletion", identityName) return nil } return fmt.Errorf("failed to delete User Assigned Identity: %w", err) } log.Printf("User Assigned Identity %s deleted successfully", identityName) return nil } ================================================ FILE: sources/azure/integration-tests/network-application-gateway_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestAGName = "ovm-integ-test-ag" integrationTestVNetNameForAG = "ovm-integ-test-vnet-for-ag" integrationTestAGSubnetName = "ag-subnet" integrationTestPublicIPNameForAG = "ovm-integ-test-public-ip-for-ag" ) func TestNetworkApplicationGatewayIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } agClient, err := armnetwork.NewApplicationGatewaysClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Application Gateways client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network for the application gateway // Application Gateway requires a dedicated subnet err = createVirtualNetworkForAG(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForAG, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Create dedicated subnet for Application Gateway err = createAGSubnet(ctx, subnetClient, integrationTestResourceGroup, integrationTestVNetNameForAG, integrationTestAGSubnetName) if err != nil { t.Fatalf("Failed to create Application Gateway subnet: %v", err) } // Create public IP address for the application gateway (needed even if AG exists) err = createPublicIPForAG(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPNameForAG, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP address: %v", err) } // Check if Application Gateway already exists first (quick check) existingAG, err := agClient.Get(ctx, integrationTestResourceGroup, integrationTestAGName, nil) if err == nil { // Application Gateway exists, check if it's ready if existingAG.Properties != nil && existingAG.Properties.ProvisioningState != nil { state := *existingAG.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Application Gateway %s already exists and is ready, skipping creation", integrationTestAGName) } else { log.Printf("Application Gateway %s exists but in state %s, waiting for it to be ready", integrationTestAGName, state) err = waitForApplicationGatewayAvailable(ctx, agClient, integrationTestResourceGroup, integrationTestAGName) if err != nil { t.Fatalf("Failed waiting for existing application gateway to be ready: %v", err) } } } else { log.Printf("Application Gateway %s already exists, verifying availability", integrationTestAGName) err = waitForApplicationGatewayAvailable(ctx, agClient, integrationTestResourceGroup, integrationTestAGName) if err != nil { t.Fatalf("Failed waiting for application gateway to be available: %v", err) } } } else { // Application Gateway doesn't exist // Application Gateway creation takes 15-20 minutes which exceeds test timeout // For integration tests, we require the Application Gateway to already exist log.Printf("Application Gateway %s does not exist", integrationTestAGName) log.Printf("Application Gateway creation takes 15-20 minutes, which exceeds the test timeout of 5 minutes.") log.Printf("Please create the Application Gateway manually or wait for a previous creation to complete.") log.Printf("Required resources should be ready: subnet %s and public IP %s", integrationTestAGSubnetName, integrationTestPublicIPNameForAG) t.Skipf("Application Gateway %s does not exist. Please create it first (takes 15-20 minutes) or ensure it exists in 'Succeeded' state before running integration tests", integrationTestAGName) } log.Printf("Setup completed: Application Gateway %s is available", integrationTestAGName) }) t.Run("Run", func(t *testing.T) { ctx := t.Context() _, checkErr := agClient.Get(ctx, integrationTestResourceGroup, integrationTestAGName, nil) if checkErr != nil { var respErr *azcore.ResponseError if errors.As(checkErr, &respErr) && respErr.StatusCode == http.StatusNotFound { t.Skipf("Application Gateway %s does not exist (Setup may have been skipped). Skipping Run tests.", integrationTestAGName) } t.Fatalf("Failed preflight check for application gateway %s: %v", integrationTestAGName, checkErr) } t.Run("GetApplicationGateway", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving application gateway %s in subscription %s, resource group %s", integrationTestAGName, subscriptionID, integrationTestResourceGroup) agWrapper := manual.NewNetworkApplicationGateway( clients.NewApplicationGatewaysClient(agClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := agWrapper.Scopes()[0] agAdapter := sources.WrapperToAdapter(agWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := agAdapter.Get(ctx, scope, integrationTestAGName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestAGName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestAGName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.NetworkApplicationGateway.String() { t.Fatalf("Expected type %s, got %s", azureshared.NetworkApplicationGateway, sdpItem.GetType()) } log.Printf("Successfully retrieved application gateway %s", integrationTestAGName) }) t.Run("ListApplicationGateways", func(t *testing.T) { ctx := t.Context() log.Printf("Listing application gateways in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) agWrapper := manual.NewNetworkApplicationGateway( clients.NewApplicationGatewaysClient(agClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := agWrapper.Scopes()[0] agAdapter := sources.WrapperToAdapter(agWrapper, sdpcache.NewNoOpCache()) listable, ok := agAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least 1 application gateway, got: %d", len(sdpItems)) } // Find our test application gateway found := false for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestAGName { found = true if item.GetType() != azureshared.NetworkApplicationGateway.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkApplicationGateway, item.GetType()) } break } } if !found { t.Fatalf("Expected to find application gateway %s in list, but didn't", integrationTestAGName) } log.Printf("Successfully listed %d application gateways", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() agWrapper := manual.NewNetworkApplicationGateway( clients.NewApplicationGatewaysClient(agClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := agWrapper.Scopes()[0] agAdapter := sources.WrapperToAdapter(agWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := agAdapter.Get(ctx, scope, integrationTestAGName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.NetworkApplicationGateway.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkApplicationGateway, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for application gateway %s", integrationTestAGName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() agWrapper := manual.NewNetworkApplicationGateway( clients.NewApplicationGatewaysClient(agClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := agWrapper.Scopes()[0] agAdapter := sources.WrapperToAdapter(agWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := agAdapter.Get(ctx, scope, integrationTestAGName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected linked item types for application gateway expectedLinkedTypes := map[string]bool{ azureshared.NetworkApplicationGatewayGatewayIPConfiguration.String(): false, azureshared.NetworkApplicationGatewayFrontendIPConfiguration.String(): false, azureshared.NetworkApplicationGatewayBackendAddressPool.String(): false, azureshared.NetworkApplicationGatewayHTTPListener.String(): false, azureshared.NetworkApplicationGatewayBackendHTTPSettings.String(): false, azureshared.NetworkApplicationGatewayRequestRoutingRule.String(): false, azureshared.NetworkPublicIPAddress.String(): false, azureshared.NetworkSubnet.String(): false, azureshared.NetworkVirtualNetwork.String(): false, } for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } linkedType := query.GetType() if _, exists := expectedLinkedTypes[linkedType]; exists { expectedLinkedTypes[linkedType] = true } // Verify query has required fields if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } } // Verify critical linked types were found criticalTypes := []string{ azureshared.NetworkApplicationGatewayGatewayIPConfiguration.String(), azureshared.NetworkApplicationGatewayFrontendIPConfiguration.String(), azureshared.NetworkApplicationGatewayBackendAddressPool.String(), azureshared.NetworkApplicationGatewayHTTPListener.String(), azureshared.NetworkApplicationGatewayBackendHTTPSettings.String(), azureshared.NetworkApplicationGatewayRequestRoutingRule.String(), } for _, linkedType := range criticalTypes { if !expectedLinkedTypes[linkedType] { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } log.Printf("Verified %d linked item queries for application gateway %s", len(linkedQueries), integrationTestAGName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete application gateway err := deleteApplicationGateway(ctx, agClient, integrationTestResourceGroup, integrationTestAGName) if err != nil { t.Fatalf("Failed to delete application gateway: %v", err) } // Delete public IP address err = deletePublicIPForAG(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPNameForAG) if err != nil { t.Fatalf("Failed to delete public IP address: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForAG(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForAG) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } // createVirtualNetworkForAG creates an Azure virtual network for Application Gateway (idempotent) func createVirtualNetworkForAG(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.3.0.0/16")}, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // createAGSubnet creates a dedicated subnet for Application Gateway (idempotent) // Application Gateway requires a dedicated subnet with at least /24 address space func createAGSubnet(ctx context.Context, client *armnetwork.SubnetsClient, resourceGroupName, vnetName, subnetName string) error { // Check if subnet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, subnetName, nil) if err == nil { log.Printf("Subnet %s already exists, skipping creation", subnetName) return nil } // Create the subnet with /24 address space for Application Gateway poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, subnetName, armnetwork.Subnet{ Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.3.0.0/24"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Subnet %s already exists (conflict), skipping creation", subnetName) return nil } return fmt.Errorf("failed to begin creating subnet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create subnet: %w", err) } log.Printf("Subnet %s created successfully", subnetName) return nil } // deleteVirtualNetworkForAG deletes an Azure virtual network func deleteVirtualNetworkForAG(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } // createPublicIPForAG creates an Azure public IP address for Application Gateway (idempotent) func createPublicIPForAG(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName, location string) error { // Check if public IP already exists _, err := client.Get(ctx, resourceGroupName, publicIPName, nil) if err == nil { log.Printf("Public IP address %s already exists, skipping creation", publicIPName) return nil } // Create the public IP address with Standard SKU (required for Application Gateway v2) poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, publicIPName, armnetwork.PublicIPAddress{ Location: new(location), Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), }, SKU: &armnetwork.PublicIPAddressSKU{ Name: new(armnetwork.PublicIPAddressSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create public IP address: %w", err) } log.Printf("Public IP address %s created successfully", publicIPName) return nil } // deletePublicIPForAG deletes an Azure public IP address func deletePublicIPForAG(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, publicIPName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP address %s not found, skipping deletion", publicIPName) return nil } return fmt.Errorf("failed to begin deleting public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete public IP address: %w", err) } log.Printf("Public IP address %s deleted successfully", publicIPName) return nil } // waitForApplicationGatewayAvailable polls until the application gateway is available via the Get API func waitForApplicationGatewayAvailable(ctx context.Context, client *armnetwork.ApplicationGatewaysClient, resourceGroupName, agName string) error { maxAttempts := 30 // Application Gateways take longer to provision pollInterval := 10 * time.Second log.Printf("Waiting for application gateway %s to be available via API...", agName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, agName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Application Gateway %s not yet available (attempt %d/%d), waiting %v...", agName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking application gateway availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Application Gateway %s is available with provisioning state: %s", agName, state) return nil } if state == "Failed" { return fmt.Errorf("application gateway provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Application Gateway %s provisioning state: %s (attempt %d/%d), waiting...", agName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Application Gateway exists but no provisioning state - consider it available log.Printf("Application Gateway %s is available", agName) return nil } return fmt.Errorf("timeout waiting for application gateway %s to be available after %d attempts", agName, maxAttempts) } // deleteApplicationGateway deletes an Azure Application Gateway func deleteApplicationGateway(ctx context.Context, client *armnetwork.ApplicationGatewaysClient, resourceGroupName, agName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, agName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Application Gateway %s not found, skipping deletion", agName) return nil } return fmt.Errorf("failed to begin deleting application gateway: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete application gateway: %w", err) } log.Printf("Application Gateway %s deleted successfully", agName) return nil } ================================================ FILE: sources/azure/integration-tests/network-dns-virtual-network-link_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestDNSVNetLinkName = "ovm-integ-test-dns-vnet-link" integrationTestPrivateZoneName = "ovm-integ-test.private.zone" integrationTestVNetForDNSName = "ovm-integ-test-vnet-dns" ) func TestNetworkDNSVirtualNetworkLinkIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } privateDNSZonesClient, err := armprivatedns.NewPrivateZonesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Private DNS Zones client: %v", err) } vnetLinksClient, err := armprivatedns.NewVirtualNetworkLinksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Network Links client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createVNetForDNS(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetForDNSName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } err = createPrivateDNSZoneForLink(ctx, privateDNSZonesClient, integrationTestResourceGroup, integrationTestPrivateZoneName) if err != nil { t.Fatalf("Failed to create private DNS zone: %v", err) } err = waitForPrivateDNSZoneAvailable(ctx, privateDNSZonesClient, integrationTestResourceGroup, integrationTestPrivateZoneName) if err != nil { t.Fatalf("Failed waiting for private DNS zone: %v", err) } vnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s", subscriptionID, integrationTestResourceGroup, integrationTestVNetForDNSName) err = createVirtualNetworkLink(ctx, vnetLinksClient, integrationTestResourceGroup, integrationTestPrivateZoneName, integrationTestDNSVNetLinkName, vnetID, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network link: %v", err) } err = waitForVirtualNetworkLinkAvailable(ctx, vnetLinksClient, integrationTestResourceGroup, integrationTestPrivateZoneName, integrationTestDNSVNetLinkName) if err != nil { t.Fatalf("Failed waiting for virtual network link: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetVirtualNetworkLink", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkDNSVirtualNetworkLink( clients.NewVirtualNetworkLinksClient(vnetLinksClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestPrivateZoneName, integrationTestDNSVNetLinkName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUnique := shared.CompositeLookupKey(integrationTestPrivateZoneName, integrationTestDNSVNetLinkName) if uniqueAttrValue != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, uniqueAttrValue) } log.Printf("Successfully retrieved virtual network link %s", integrationTestDNSVNetLinkName) }) t.Run("SearchVirtualNetworkLinks", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkDNSVirtualNetworkLink( clients.NewVirtualNetworkLinksClient(vnetLinksClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestPrivateZoneName, true) if err != nil { t.Fatalf("Failed to search virtual network links: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one virtual network link, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == shared.CompositeLookupKey(integrationTestPrivateZoneName, integrationTestDNSVNetLinkName) { found = true break } } if !found { t.Fatalf("Expected to find link %s in the search results", integrationTestDNSVNetLinkName) } log.Printf("Found %d virtual network links in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkDNSVirtualNetworkLink( clients.NewVirtualNetworkLinksClient(vnetLinksClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestPrivateZoneName, integrationTestDNSVNetLinkName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasPrivateDNSZoneLink, hasVNetLink bool for _, liq := range linkedQueries { q := liq.GetQuery() if q.GetType() == azureshared.NetworkPrivateDNSZone.String() && q.GetQuery() == integrationTestPrivateZoneName { hasPrivateDNSZoneLink = true } if q.GetType() == azureshared.NetworkVirtualNetwork.String() && q.GetQuery() == integrationTestVNetForDNSName { hasVNetLink = true } } if !hasPrivateDNSZoneLink { t.Error("Expected linked query to Private DNS Zone, but didn't find one") } if !hasVNetLink { t.Error("Expected linked query to Virtual Network, but didn't find one") } log.Printf("Verified %d linked item queries for virtual network link %s", len(linkedQueries), integrationTestDNSVNetLinkName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkDNSVirtualNetworkLink( clients.NewVirtualNetworkLinksClient(vnetLinksClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestPrivateZoneName, integrationTestDNSVNetLinkName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkDNSVirtualNetworkLink.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDNSVirtualNetworkLink.String(), sdpItem.GetType()) } expectedScope := subscriptionID + "." + integrationTestResourceGroup if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Expected item to validate, got: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteVirtualNetworkLink(ctx, vnetLinksClient, integrationTestResourceGroup, integrationTestPrivateZoneName, integrationTestDNSVNetLinkName) if err != nil { t.Fatalf("Failed to delete virtual network link: %v", err) } log.Printf("Waiting 30 seconds for VNet link deletion to propagate before deleting DNS zone...") time.Sleep(30 * time.Second) err = deletePrivateDNSZoneForLink(ctx, privateDNSZonesClient, integrationTestResourceGroup, integrationTestPrivateZoneName) if err != nil { t.Fatalf("Failed to delete private DNS zone: %v", err) } err = deleteVNetForDNS(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetForDNSName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } func createVNetForDNS(ctx context.Context, client *armnetwork.VirtualNetworksClient, rg, name, location string) error { _, err := client.Get(ctx, rg, name, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", name) return nil } poller, err := client.BeginCreateOrUpdate(ctx, rg, name, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.100.0.0/16")}, }, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Virtual network %s already exists (conflict), skipping", name) return nil } return fmt.Errorf("failed to create virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", name) return nil } func createPrivateDNSZoneForLink(ctx context.Context, client *armprivatedns.PrivateZonesClient, rg, zoneName string) error { _, err := client.Get(ctx, rg, zoneName, nil) if err == nil { log.Printf("Private DNS zone %s already exists, skipping creation", zoneName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, rg, zoneName, armprivatedns.PrivateZone{ Location: new("global"), }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Private DNS zone %s already exists (conflict), skipping", zoneName) return nil } return fmt.Errorf("failed to create private DNS zone: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create private DNS zone: %w", err) } log.Printf("Private DNS zone %s created successfully", zoneName) return nil } func waitForPrivateDNSZoneAvailable(ctx context.Context, client *armprivatedns.PrivateZonesClient, rg, zoneName string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, rg, zoneName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { time.Sleep(pollInterval) continue } return fmt.Errorf("error checking private DNS zone: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armprivatedns.ProvisioningStateSucceeded { return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for private DNS zone %s", zoneName) } func createVirtualNetworkLink(ctx context.Context, client *armprivatedns.VirtualNetworkLinksClient, rg, zoneName, linkName, vnetID, location string) error { _, err := client.Get(ctx, rg, zoneName, linkName, nil) if err == nil { log.Printf("Virtual network link %s already exists, skipping creation", linkName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, rg, zoneName, linkName, armprivatedns.VirtualNetworkLink{ Location: new("global"), Properties: &armprivatedns.VirtualNetworkLinkProperties{ VirtualNetwork: &armprivatedns.SubResource{ ID: &vnetID, }, RegistrationEnabled: new(false), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Virtual network link %s already exists (conflict), skipping", linkName) return nil } return fmt.Errorf("failed to create virtual network link: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network link: %w", err) } log.Printf("Virtual network link %s created successfully", linkName) return nil } func waitForVirtualNetworkLinkAvailable(ctx context.Context, client *armprivatedns.VirtualNetworkLinksClient, rg, zoneName, linkName string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, rg, zoneName, linkName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { time.Sleep(pollInterval) continue } return fmt.Errorf("error checking virtual network link: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armprivatedns.ProvisioningStateSucceeded { return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for virtual network link %s", linkName) } func deleteVirtualNetworkLink(ctx context.Context, client *armprivatedns.VirtualNetworkLinksClient, rg, zoneName, linkName string) error { poller, err := client.BeginDelete(ctx, rg, zoneName, linkName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network link %s not found, skipping deletion", linkName) return nil } return fmt.Errorf("failed to delete virtual network link: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network link: %w", err) } log.Printf("Virtual network link %s deleted successfully", linkName) return nil } func deletePrivateDNSZoneForLink(ctx context.Context, client *armprivatedns.PrivateZonesClient, rg, zoneName string) error { poller, err := client.BeginDelete(ctx, rg, zoneName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Private DNS zone %s not found, skipping deletion", zoneName) return nil } return fmt.Errorf("failed to delete private DNS zone: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete private DNS zone: %w", err) } log.Printf("Private DNS zone %s deleted successfully", zoneName) return nil } func deleteVNetForDNS(ctx context.Context, client *armnetwork.VirtualNetworksClient, rg, name string) error { poller, err := client.BeginDelete(ctx, rg, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", name) return nil } return fmt.Errorf("failed to delete virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", name) return nil } ================================================ FILE: sources/azure/integration-tests/network-flow-log_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestFlowLogName = "ovm-integ-test-flow-log" integrationTestFlowLogNSGName = "ovm-integ-test-flow-log-nsg" integrationTestFlowLogStorageName = "ovmintegflowlogstor" integrationTestNetworkWatcherName = "NetworkWatcher_westus2" integrationTestNetworkWatcherRG = "NetworkWatcherRG" ) func TestNetworkFlowLogIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } nsgClient, err := armnetwork.NewSecurityGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create NSG client: %v", err) } storageClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } flowLogsSDKClient, err := armnetwork.NewFlowLogsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Flow Logs client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createResourceGroup(ctx, rgClient, integrationTestNetworkWatcherRG, integrationTestLocation) if err != nil { t.Fatalf("Failed to create NetworkWatcherRG: %v", err) } err = createFlowLogNSG(ctx, nsgClient, integrationTestResourceGroup, integrationTestFlowLogNSGName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create NSG: %v", err) } err = createFlowLogStorageAccount(ctx, storageClient, integrationTestResourceGroup, integrationTestFlowLogStorageName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } err = waitForFlowLogStorageAccountAvailable(ctx, storageClient, integrationTestResourceGroup, integrationTestFlowLogStorageName) if err != nil { t.Fatalf("Failed waiting for storage account: %v", err) } nsgID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkSecurityGroups/%s", subscriptionID, integrationTestResourceGroup, integrationTestFlowLogNSGName) storageID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Storage/storageAccounts/%s", subscriptionID, integrationTestResourceGroup, integrationTestFlowLogStorageName) err = createFlowLog(ctx, flowLogsSDKClient, integrationTestNetworkWatcherRG, integrationTestNetworkWatcherName, integrationTestFlowLogName, nsgID, storageID, integrationTestLocation) if err != nil { if strings.Contains(err.Error(), "NsgFlowLogCreationBlocked") { t.Skipf("Skipping: Azure has retired new NSG flow log creation: %v", err) } t.Fatalf("Failed to create flow log: %v", err) } err = waitForFlowLogAvailable(ctx, flowLogsSDKClient, integrationTestNetworkWatcherRG, integrationTestNetworkWatcherName, integrationTestFlowLogName) if err != nil { t.Fatalf("Failed waiting for flow log: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetFlowLog", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkFlowLog( clients.NewFlowLogsClient(flowLogsSDKClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestNetworkWatcherRG)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestNetworkWatcherName, integrationTestFlowLogName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUnique := shared.CompositeLookupKey(integrationTestNetworkWatcherName, integrationTestFlowLogName) if uniqueAttrValue != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, uniqueAttrValue) } log.Printf("Successfully retrieved flow log %s", integrationTestFlowLogName) }) t.Run("SearchFlowLogs", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkFlowLog( clients.NewFlowLogsClient(flowLogsSDKClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestNetworkWatcherRG)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestNetworkWatcherName, true) if err != nil { t.Fatalf("Failed to search flow logs: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one flow log, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == shared.CompositeLookupKey(integrationTestNetworkWatcherName, integrationTestFlowLogName) { found = true break } } if !found { t.Fatalf("Expected to find flow log %s in the search results", integrationTestFlowLogName) } log.Printf("Found %d flow logs in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkFlowLog( clients.NewFlowLogsClient(flowLogsSDKClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestNetworkWatcherRG)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestNetworkWatcherName, integrationTestFlowLogName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } for _, liq := range linkedQueries { q := liq.GetQuery() if q.GetType() == "" { t.Error("Linked item query has empty Type") } if q.GetQuery() == "" { t.Errorf("Linked item query of type %s has empty Query", q.GetType()) } if q.GetScope() == "" { t.Errorf("Linked item query of type %s has empty Scope", q.GetType()) } method := q.GetMethod() if method != 1 && method != 2 { // GET=1, SEARCH=2 t.Errorf("Linked item query of type %s has unexpected Method %d", q.GetType(), method) } } log.Printf("Verified %d linked item queries for flow log %s", len(linkedQueries), integrationTestFlowLogName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkFlowLog( clients.NewFlowLogsClient(flowLogsSDKClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestNetworkWatcherRG)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestNetworkWatcherName, integrationTestFlowLogName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkFlowLog.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkFlowLog.String(), sdpItem.GetType()) } expectedScope := subscriptionID + "." + integrationTestNetworkWatcherRG if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Expected item to validate, got: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteFlowLog(ctx, flowLogsSDKClient, integrationTestNetworkWatcherRG, integrationTestNetworkWatcherName, integrationTestFlowLogName) if err != nil { t.Fatalf("Failed to delete flow log: %v", err) } err = deleteFlowLogStorageAccount(ctx, storageClient, integrationTestResourceGroup, integrationTestFlowLogStorageName) if err != nil { t.Fatalf("Failed to delete storage account: %v", err) } err = deleteFlowLogNSG(ctx, nsgClient, integrationTestResourceGroup, integrationTestFlowLogNSGName) if err != nil { t.Fatalf("Failed to delete NSG: %v", err) } }) } func createFlowLogNSG(ctx context.Context, client *armnetwork.SecurityGroupsClient, rg, name, location string) error { _, err := client.Get(ctx, rg, name, nil) if err == nil { log.Printf("NSG %s already exists, skipping creation", name) return nil } poller, err := client.BeginCreateOrUpdate(ctx, rg, name, armnetwork.SecurityGroup{ Location: &location, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("NSG %s already exists (conflict), skipping", name) return nil } return fmt.Errorf("failed to create NSG: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create NSG: %w", err) } log.Printf("NSG %s created successfully", name) return nil } func createFlowLogStorageAccount(ctx context.Context, client *armstorage.AccountsClient, rg, name, location string) error { _, err := client.GetProperties(ctx, rg, name, nil) if err == nil { log.Printf("Storage account %s already exists, skipping creation", name) return nil } poller, err := client.BeginCreate(ctx, rg, name, armstorage.AccountCreateParameters{ Location: &location, Kind: new(armstorage.KindStorageV2), SKU: &armstorage.SKU{ Name: new(armstorage.SKUNameStandardLRS), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Storage account %s already exists (conflict), skipping", name) return nil } return fmt.Errorf("failed to create storage account: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create storage account: %w", err) } log.Printf("Storage account %s created successfully", name) return nil } func waitForFlowLogStorageAccountAvailable(ctx context.Context, client *armstorage.AccountsClient, rg, name string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.GetProperties(ctx, rg, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { time.Sleep(pollInterval) continue } return fmt.Errorf("error checking storage account: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armstorage.ProvisioningStateSucceeded { return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for storage account %s", name) } func createFlowLog(ctx context.Context, client *armnetwork.FlowLogsClient, rg, networkWatcherName, flowLogName, nsgID, storageID, location string) error { _, err := client.Get(ctx, rg, networkWatcherName, flowLogName, nil) if err == nil { log.Printf("Flow log %s already exists, skipping creation", flowLogName) return nil } enabled := true poller, err := client.BeginCreateOrUpdate(ctx, rg, networkWatcherName, flowLogName, armnetwork.FlowLog{ Location: &location, Properties: &armnetwork.FlowLogPropertiesFormat{ TargetResourceID: &nsgID, StorageID: &storageID, Enabled: &enabled, RetentionPolicy: &armnetwork.RetentionPolicyParameters{ Enabled: &enabled, Days: new(int32(7)), }, }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Flow log %s already exists (conflict), skipping", flowLogName) return nil } return fmt.Errorf("failed to create flow log: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create flow log: %w", err) } log.Printf("Flow log %s created successfully", flowLogName) return nil } func waitForFlowLogAvailable(ctx context.Context, client *armnetwork.FlowLogsClient, rg, networkWatcherName, flowLogName string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, rg, networkWatcherName, flowLogName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { time.Sleep(pollInterval) continue } return fmt.Errorf("error checking flow log: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil && string(*resp.Properties.ProvisioningState) == "Succeeded" { return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for flow log %s", flowLogName) } func deleteFlowLog(ctx context.Context, client *armnetwork.FlowLogsClient, rg, networkWatcherName, flowLogName string) error { poller, err := client.BeginDelete(ctx, rg, networkWatcherName, flowLogName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Flow log %s not found, skipping deletion", flowLogName) return nil } return fmt.Errorf("failed to delete flow log: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete flow log: %w", err) } log.Printf("Flow log %s deleted successfully", flowLogName) return nil } func deleteFlowLogStorageAccount(ctx context.Context, client *armstorage.AccountsClient, rg, name string) error { _, err := client.Delete(ctx, rg, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Storage account %s not found, skipping deletion", name) return nil } return fmt.Errorf("failed to delete storage account: %w", err) } log.Printf("Storage account %s deleted successfully", name) return nil } func deleteFlowLogNSG(ctx context.Context, client *armnetwork.SecurityGroupsClient, rg, name string) error { poller, err := client.BeginDelete(ctx, rg, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("NSG %s not found, skipping deletion", name) return nil } return fmt.Errorf("failed to delete NSG: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete NSG: %w", err) } log.Printf("NSG %s deleted successfully", name) return nil } ================================================ FILE: sources/azure/integration-tests/network-ip-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestIPGroupName = "ovm-integ-test-ip-group" ) func TestNetworkIPGroupIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } ipGroupsClient, err := armnetwork.NewIPGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create IP Groups client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createIPGroup(ctx, ipGroupsClient, integrationTestResourceGroup, integrationTestIPGroupName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create IP group: %v", err) } err = waitForIPGroupAvailable(ctx, ipGroupsClient, integrationTestResourceGroup, integrationTestIPGroupName) if err != nil { t.Fatalf("Failed waiting for IP group to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetIPGroup", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving IP group %s in subscription %s, resource group %s", integrationTestIPGroupName, subscriptionID, integrationTestResourceGroup) ipGroupWrapper := manual.NewNetworkIPGroup( clients.NewIPGroupsClient(ipGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipGroupWrapper.Scopes()[0] ipGroupAdapter := sources.WrapperToAdapter(ipGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := ipGroupAdapter.Get(ctx, scope, integrationTestIPGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestIPGroupName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestIPGroupName, uniqueAttrValue) } log.Printf("Successfully retrieved IP group %s", integrationTestIPGroupName) }) t.Run("ListIPGroups", func(t *testing.T) { ctx := t.Context() log.Printf("Listing IP groups in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) ipGroupWrapper := manual.NewNetworkIPGroup( clients.NewIPGroupsClient(ipGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipGroupWrapper.Scopes()[0] ipGroupAdapter := sources.WrapperToAdapter(ipGroupWrapper, sdpcache.NewNoOpCache()) listable, ok := ipGroupAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list IP groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one IP group, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestIPGroupName { found = true break } } if !found { t.Fatalf("Expected to find IP group %s in the list", integrationTestIPGroupName) } log.Printf("Found %d IP groups in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for IP group %s", integrationTestIPGroupName) ipGroupWrapper := manual.NewNetworkIPGroup( clients.NewIPGroupsClient(ipGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipGroupWrapper.Scopes()[0] ipGroupAdapter := sources.WrapperToAdapter(ipGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := ipGroupAdapter.Get(ctx, scope, integrationTestIPGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkIPGroup.String() { t.Errorf("Expected item type %s, got %s", azureshared.NetworkIPGroup, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for IP group %s", integrationTestIPGroupName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for IP group %s", integrationTestIPGroupName) ipGroupWrapper := manual.NewNetworkIPGroup( clients.NewIPGroupsClient(ipGroupsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipGroupWrapper.Scopes()[0] ipGroupAdapter := sources.WrapperToAdapter(ipGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := ipGroupAdapter.Get(ctx, scope, integrationTestIPGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for IP group %s", len(linkedQueries), integrationTestIPGroupName) for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteIPGroup(ctx, ipGroupsClient, integrationTestResourceGroup, integrationTestIPGroupName) if err != nil { t.Fatalf("Failed to delete IP group: %v", err) } }) } func createIPGroup(ctx context.Context, client *armnetwork.IPGroupsClient, resourceGroupName, ipGroupName, location string) error { existingIPGroup, err := client.Get(ctx, resourceGroupName, ipGroupName, nil) if err == nil { if existingIPGroup.Properties != nil && existingIPGroup.Properties.ProvisioningState != nil { state := *existingIPGroup.Properties.ProvisioningState if state == armnetwork.ProvisioningStateSucceeded { log.Printf("IP group %s already exists with state %s, skipping creation", ipGroupName, state) return nil } log.Printf("IP group %s exists but in state %s, will wait for it", ipGroupName, state) } else { log.Printf("IP group %s already exists, skipping creation", ipGroupName) return nil } } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, ipGroupName, armnetwork.IPGroup{ Location: new(location), Properties: &armnetwork.IPGroupPropertiesFormat{ IPAddresses: []*string{ new("10.0.0.0/24"), new("192.168.1.1"), }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-ip-group"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("IP group %s already exists (conflict), skipping creation", ipGroupName) return nil } return fmt.Errorf("failed to begin creating IP group: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create IP group: %w", err) } if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("IP group created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != armnetwork.ProvisioningStateSucceeded { return fmt.Errorf("IP group provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("IP group %s created successfully with provisioning state: %s", ipGroupName, provisioningState) return nil } func waitForIPGroupAvailable(ctx context.Context, client *armnetwork.IPGroupsClient, resourceGroupName, ipGroupName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for IP group %s to be available via API...", ipGroupName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, ipGroupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("IP group %s not yet available (attempt %d/%d), waiting %v...", ipGroupName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking IP group availability: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == armnetwork.ProvisioningStateSucceeded { log.Printf("IP group %s is available with provisioning state: %s", ipGroupName, state) return nil } if state == armnetwork.ProvisioningStateFailed { return fmt.Errorf("IP group provisioning failed with state: %s", state) } log.Printf("IP group %s provisioning state: %s (attempt %d/%d), waiting...", ipGroupName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } log.Printf("IP group %s is available", ipGroupName) return nil } return fmt.Errorf("timeout waiting for IP group %s to be available after %d attempts", ipGroupName, maxAttempts) } func deleteIPGroup(ctx context.Context, client *armnetwork.IPGroupsClient, resourceGroupName, ipGroupName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, ipGroupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("IP group %s not found, skipping deletion", ipGroupName) return nil } return fmt.Errorf("failed to begin deleting IP group: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete IP group: %w", err) } log.Printf("IP group %s deleted successfully", ipGroupName) return nil } ================================================ FILE: sources/azure/integration-tests/network-load-balancer-backend-address-pool_test.go ================================================ package integrationtests import ( "context" "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestBackendPoolLBName = "ovm-integ-test-lb-for-pool" integrationTestBackendPoolName = "test-backend-pool" integrationTestVNetNameForBackendPool = "ovm-integ-test-vnet-for-pool" integrationTestSubnetNameForBackendPool = "default" integrationTestPublicIPNameForBackendPool = "ovm-integ-test-pip-for-pool" ) func TestNetworkLoadBalancerBackendAddressPoolIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } lbClient, err := armnetwork.NewLoadBalancersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Load Balancers client: %v", err) } backendPoolClient, err := armnetwork.NewLoadBalancerBackendAddressPoolsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Load Balancer Backend Address Pools client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createVirtualNetworkForBackendPool(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForBackendPool, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } err = createPublicIPForBackendPool(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPNameForBackendPool, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP address: %v", err) } publicIPResp, err := publicIPClient.Get(ctx, integrationTestResourceGroup, integrationTestPublicIPNameForBackendPool, nil) if err != nil { t.Fatalf("Failed to get public IP address: %v", err) } err = createLoadBalancerWithBackendPool(ctx, lbClient, subscriptionID, integrationTestResourceGroup, integrationTestBackendPoolLBName, integrationTestLocation, *publicIPResp.ID, integrationTestBackendPoolName) if err != nil { t.Fatalf("Failed to create load balancer: %v", err) } log.Printf("Setup completed: Load balancer %s with backend pool %s created", integrationTestBackendPoolLBName, integrationTestBackendPoolName) setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetBackendAddressPool", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving backend address pool %s from load balancer %s", integrationTestBackendPoolName, integrationTestBackendPoolLBName) wrapper := manual.NewNetworkLoadBalancerBackendAddressPool( clients.NewLoadBalancerBackendAddressPoolsClient(backendPoolClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestBackendPoolLBName, integrationTestBackendPoolName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueValue := shared.CompositeLookupKey(integrationTestBackendPoolLBName, integrationTestBackendPoolName) if uniqueAttrValue != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, uniqueAttrValue) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerBackendAddressPool.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerBackendAddressPool, sdpItem.GetType()) } log.Printf("Successfully retrieved backend address pool %s", integrationTestBackendPoolName) }) t.Run("SearchBackendAddressPools", func(t *testing.T) { ctx := t.Context() log.Printf("Searching backend address pools in load balancer %s", integrationTestBackendPoolLBName) wrapper := manual.NewNetworkLoadBalancerBackendAddressPool( clients.NewLoadBalancerBackendAddressPoolsClient(backendPoolClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestBackendPoolLBName, true) if err != nil { t.Fatalf("Failed to search backend address pools: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one backend address pool, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() expectedValue := shared.CompositeLookupKey(integrationTestBackendPoolLBName, integrationTestBackendPoolName) if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedValue { found = true break } } if !found { t.Fatalf("Expected to find backend pool %s in the search results", integrationTestBackendPoolName) } log.Printf("Found %d backend address pools in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for backend address pool %s", integrationTestBackendPoolName) wrapper := manual.NewNetworkLoadBalancerBackendAddressPool( clients.NewLoadBalancerBackendAddressPoolsClient(backendPoolClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestBackendPoolLBName, integrationTestBackendPoolName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } for _, liq := range linkedQueries { query := liq.GetQuery() if query.GetType() == "" { t.Error("Expected linked query to have a non-empty Type") } if query.GetQuery() == "" { t.Error("Expected linked query to have a non-empty Query") } if query.GetScope() == "" { t.Error("Expected linked query to have a non-empty Scope") } } // Verify parent load balancer link exists var hasLoadBalancerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkLoadBalancer.String() { hasLoadBalancerLink = true if liq.GetQuery().GetQuery() != integrationTestBackendPoolLBName { t.Errorf("Expected linked query to load balancer %s, got %s", integrationTestBackendPoolLBName, liq.GetQuery().GetQuery()) } break } } if !hasLoadBalancerLink { t.Error("Expected linked query to parent load balancer, but didn't find one") } log.Printf("Verified %d linked item queries for backend address pool %s", len(linkedQueries), integrationTestBackendPoolName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkLoadBalancerBackendAddressPool( clients.NewLoadBalancerBackendAddressPoolsClient(backendPoolClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestBackendPoolLBName, integrationTestBackendPoolName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerBackendAddressPool.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerBackendAddressPool, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } log.Printf("Verified item attributes for backend address pool %s", integrationTestBackendPoolName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestBackendPoolLBName) if err != nil { t.Fatalf("Failed to delete load balancer: %v", err) } err = deletePublicIPForBackendPool(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPNameForBackendPool) if err != nil { t.Fatalf("Failed to delete public IP address: %v", err) } err = deleteVirtualNetworkForBackendPool(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForBackendPool) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } func createVirtualNetworkForBackendPool(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.3.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetNameForBackendPool), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.3.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } func deleteVirtualNetworkForBackendPool(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { log.Printf("Virtual network %s delete failed (may already be deleted): %v", vnetName, err) return nil } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } func createPublicIPForBackendPool(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName, location string) error { _, err := client.Get(ctx, resourceGroupName, publicIPName, nil) if err == nil { log.Printf("Public IP address %s already exists, skipping creation", publicIPName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, publicIPName, armnetwork.PublicIPAddress{ Location: new(location), Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), }, SKU: &armnetwork.PublicIPAddressSKU{ Name: new(armnetwork.PublicIPAddressSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create public IP address: %w", err) } log.Printf("Public IP address %s created successfully", publicIPName) return nil } func deletePublicIPForBackendPool(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, publicIPName, nil) if err != nil { log.Printf("Public IP address %s delete failed (may already be deleted): %v", publicIPName, err) return nil } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete public IP address: %w", err) } log.Printf("Public IP address %s deleted successfully", publicIPName) return nil } func createLoadBalancerWithBackendPool(ctx context.Context, client *armnetwork.LoadBalancersClient, subscriptionID, resourceGroupName, lbName, location, publicIPID, backendPoolName string) error { _, err := client.Get(ctx, resourceGroupName, lbName, nil) if err == nil { log.Printf("Load balancer %s already exists, skipping creation", lbName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, lbName, armnetwork.LoadBalancer{ Location: new(location), Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new("frontend-config"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, }, }, }, BackendAddressPools: []*armnetwork.BackendAddressPool{ { Name: new(backendPoolName), }, }, LoadBalancingRules: []*armnetwork.LoadBalancingRule{ { Name: new("lb-rule"), Properties: &armnetwork.LoadBalancingRulePropertiesFormat{ FrontendIPConfiguration: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/frontend-config", subscriptionID, resourceGroupName, lbName)), }, BackendAddressPool: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/%s", subscriptionID, resourceGroupName, lbName, backendPoolName)), }, Protocol: new(armnetwork.TransportProtocolTCP), FrontendPort: new(int32(80)), BackendPort: new(int32(80)), EnableFloatingIP: new(false), IdleTimeoutInMinutes: new(int32(4)), }, }, }, }, SKU: &armnetwork.LoadBalancerSKU{ Name: new(armnetwork.LoadBalancerSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating load balancer: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create load balancer: %w", err) } log.Printf("Load balancer %s with backend pool %s created successfully", lbName, backendPoolName) return nil } ================================================ FILE: sources/azure/integration-tests/network-load-balancer-frontend-ip-configuration_test.go ================================================ package integrationtests import ( "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestFrontendIPLBName = "ovm-integ-test-lb-fip" integrationTestFrontendIPPublicIPName = "ovm-integ-test-pip-fip" integrationTestFrontendIPConfigName = "frontend-ip-config" integrationTestFrontendIPVNetName = "ovm-integ-test-vnet-fip" integrationTestFrontendIPSubnetName = "default" integrationTestFrontendIPInternalLBName = "ovm-integ-test-lb-fip-internal" integrationTestFrontendIPInternalName = "frontend-ip-internal" ) func TestNetworkLoadBalancerFrontendIPConfigurationIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } lbClient, err := armnetwork.NewLoadBalancersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Load Balancers client: %v", err) } frontendIPClient, err := armnetwork.NewLoadBalancerFrontendIPConfigurationsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Frontend IP Configurations client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create public IP for public LB err = createPublicIPForLB(ctx, publicIPClient, integrationTestResourceGroup, integrationTestFrontendIPPublicIPName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP address: %v", err) } publicIPResp, err := publicIPClient.Get(ctx, integrationTestResourceGroup, integrationTestFrontendIPPublicIPName, nil) if err != nil { t.Fatalf("Failed to get public IP address: %v", err) } // Create public LB with frontend IP config err = createPublicLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestFrontendIPLBName, integrationTestLocation, *publicIPResp.ID) if err != nil { t.Fatalf("Failed to create public load balancer: %v", err) } // Create VNet + subnet for internal LB err = createVirtualNetworkForLB(ctx, vnetClient, integrationTestResourceGroup, integrationTestFrontendIPVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestFrontendIPVNetName, integrationTestFrontendIPSubnetName, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create internal LB err = createInternalLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestFrontendIPInternalLBName, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create internal load balancer: %v", err) } log.Printf("Setup completed for frontend IP configuration integration tests") }) t.Run("Run", func(t *testing.T) { t.Run("GetFrontendIPConfiguration", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration( clients.NewLoadBalancerFrontendIPConfigurationsClient(frontendIPClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // The public LB has a frontend IP config named "frontend-ip-config-public" query := shared.CompositeLookupKey(integrationTestFrontendIPLBName, "frontend-ip-config-public") sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerFrontendIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerFrontendIPConfiguration, sdpItem.GetType()) } expectedUniqueValue := shared.CompositeLookupKey(integrationTestFrontendIPLBName, "frontend-ip-config-public") if sdpItem.UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique value %s, got %s", expectedUniqueValue, sdpItem.UniqueAttributeValue()) } log.Printf("Successfully retrieved frontend IP configuration for LB %s", integrationTestFrontendIPLBName) }) t.Run("SearchFrontendIPConfigurations", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration( clients.NewLoadBalancerFrontendIPConfigurationsClient(frontendIPClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestFrontendIPLBName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least 1 frontend IP configuration, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.GetType() != azureshared.NetworkLoadBalancerFrontendIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerFrontendIPConfiguration, item.GetType()) } } log.Printf("Successfully searched %d frontend IP configurations for LB %s", len(sdpItems), integrationTestFrontendIPLBName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration( clients.NewLoadBalancerFrontendIPConfigurationsClient(frontendIPClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Verify public frontend IP config links t.Run("PublicFrontendIP", func(t *testing.T) { query := shared.CompositeLookupKey(integrationTestFrontendIPLBName, "frontend-ip-config-public") sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() for _, liq := range linkedQueries { q := liq.GetQuery() if q.GetType() == "" { t.Error("Linked item query has empty Type") } if q.GetQuery() == "" { t.Errorf("Linked item query of type %s has empty Query", q.GetType()) } if q.GetScope() == "" { t.Errorf("Linked item query of type %s has empty Scope", q.GetType()) } } expectedTypes := map[string]bool{ azureshared.NetworkLoadBalancer.String(): false, azureshared.NetworkPublicIPAddress.String(): false, } for _, liq := range linkedQueries { if _, exists := expectedTypes[liq.GetQuery().GetType()]; exists { expectedTypes[liq.GetQuery().GetType()] = true } } for linkedType, found := range expectedTypes { if !found { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } }) // Verify internal frontend IP config links t.Run("InternalFrontendIP", func(t *testing.T) { query := shared.CompositeLookupKey(integrationTestFrontendIPInternalLBName, "frontend-ip-config-internal") sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() expectedTypes := map[string]bool{ azureshared.NetworkLoadBalancer.String(): false, azureshared.NetworkSubnet.String(): false, "ip": false, } for _, liq := range linkedQueries { if _, exists := expectedTypes[liq.GetQuery().GetType()]; exists { expectedTypes[liq.GetQuery().GetType()] = true } } for linkedType, found := range expectedTypes { if !found { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } }) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration( clients.NewLoadBalancerFrontendIPConfigurationsClient(frontendIPClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestFrontendIPLBName, "frontend-ip-config-public") sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerFrontendIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerFrontendIPConfiguration, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Expected no validation error, got: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete public LB err := deleteLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestFrontendIPLBName) if err != nil { t.Fatalf("Failed to delete public load balancer: %v", err) } // Delete internal LB err = deleteLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestFrontendIPInternalLBName) if err != nil { t.Fatalf("Failed to delete internal load balancer: %v", err) } // Delete public IP err = deletePublicIPForLB(ctx, publicIPClient, integrationTestResourceGroup, integrationTestFrontendIPPublicIPName) if err != nil { t.Fatalf("Failed to delete public IP address: %v", err) } // Delete VNet err = deleteVirtualNetworkForLB(ctx, vnetClient, integrationTestResourceGroup, integrationTestFrontendIPVNetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } ================================================ FILE: sources/azure/integration-tests/network-load-balancer-probe_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestLBForProbeName = "ovm-integ-test-lb-probe" integrationTestVNetForProbeName = "ovm-integ-test-vnet-for-probe" integrationTestSubnetForProbeName = "default" integrationTestPublicIPForProbeLB = "ovm-integ-test-pip-for-probe-lb" integrationTestProbeName = "ovm-integ-test-health-probe" integrationTestProbeHTTPName = "ovm-integ-test-http-probe" ) func TestNetworkLoadBalancerProbeIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } lbClient, err := armnetwork.NewLoadBalancersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Load Balancers client: %v", err) } probesClient, err := armnetwork.NewLoadBalancerProbesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Load Balancer Probes client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createVNetForProbeTest(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetForProbeName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } err = createPublicIPForProbeTest(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPForProbeLB, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP address: %v", err) } publicIPResp, err := publicIPClient.Get(ctx, integrationTestResourceGroup, integrationTestPublicIPForProbeLB, nil) if err != nil { t.Fatalf("Failed to get public IP address: %v", err) } err = createLBWithProbes(ctx, lbClient, subscriptionID, integrationTestResourceGroup, integrationTestLBForProbeName, integrationTestLocation, *publicIPResp.ID) if err != nil { t.Fatalf("Failed to create load balancer with probes: %v", err) } setupCompleted = true log.Printf("Setup completed: Load balancer %s with probes created", integrationTestLBForProbeName) }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetProbe", func(t *testing.T) { ctx := t.Context() probeWrapper := manual.NewNetworkLoadBalancerProbe( clients.NewLoadBalancerProbesClient(probesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := probeWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(probeWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestLBForProbeName, integrationTestProbeName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerProbe.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerProbe, sdpItem.GetType()) } expectedUniqueValue := shared.CompositeLookupKey(integrationTestLBForProbeName, integrationTestProbeName) if sdpItem.UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, sdpItem.UniqueAttributeValue()) } log.Printf("Successfully retrieved probe %s from load balancer %s", integrationTestProbeName, integrationTestLBForProbeName) }) t.Run("SearchProbes", func(t *testing.T) { ctx := t.Context() probeWrapper := manual.NewNetworkLoadBalancerProbe( clients.NewLoadBalancerProbesClient(probesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := probeWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(probeWrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestLBForProbeName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) < 2 { t.Fatalf("Expected at least 2 probes, got: %d", len(sdpItems)) } foundTCP := false foundHTTP := false for _, item := range sdpItems { val := item.UniqueAttributeValue() if val == shared.CompositeLookupKey(integrationTestLBForProbeName, integrationTestProbeName) { foundTCP = true } if val == shared.CompositeLookupKey(integrationTestLBForProbeName, integrationTestProbeHTTPName) { foundHTTP = true } } if !foundTCP { t.Errorf("Expected to find TCP probe %s in search results", integrationTestProbeName) } if !foundHTTP { t.Errorf("Expected to find HTTP probe %s in search results", integrationTestProbeHTTPName) } log.Printf("Successfully searched %d probes for load balancer %s", len(sdpItems), integrationTestLBForProbeName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() probeWrapper := manual.NewNetworkLoadBalancerProbe( clients.NewLoadBalancerProbesClient(probesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := probeWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(probeWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestLBForProbeName, integrationTestProbeName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } for _, liq := range linkedQueries { q := liq.GetQuery() if q.GetType() == "" { t.Error("Linked item query has empty Type") } if q.GetQuery() == "" { t.Error("Linked item query has empty Query") } if q.GetScope() == "" { t.Error("Linked item query has empty Scope") } if q.GetMethod() != sdp.QueryMethod_GET && q.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has invalid Method: %v", q.GetMethod()) } } foundParentLB := false for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkLoadBalancer.String() { foundParentLB = true if liq.GetQuery().GetQuery() != integrationTestLBForProbeName { t.Errorf("Expected parent LB query %s, got %s", integrationTestLBForProbeName, liq.GetQuery().GetQuery()) } break } } if !foundParentLB { t.Error("Expected to find parent Load Balancer linked query") } log.Printf("Verified %d linked item queries for probe %s", len(linkedQueries), integrationTestProbeName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() probeWrapper := manual.NewNetworkLoadBalancerProbe( clients.NewLoadBalancerProbesClient(probesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := probeWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(probeWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestLBForProbeName, integrationTestProbeName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerProbe.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerProbe, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Validation failed: %v", err) } log.Printf("Verified item attributes for probe %s", integrationTestProbeName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteLBForProbeTest(ctx, lbClient, integrationTestResourceGroup, integrationTestLBForProbeName) if err != nil { t.Fatalf("Failed to delete load balancer: %v", err) } err = deletePublicIPForProbeTest(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPForProbeLB) if err != nil { t.Fatalf("Failed to delete public IP address: %v", err) } err = deleteVNetForProbeTest(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetForProbeName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } func createVNetForProbeTest(ctx context.Context, client *armnetwork.VirtualNetworksClient, rg, name, location string) error { _, err := client.Get(ctx, rg, name, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", name) return nil } poller, err := client.BeginCreateOrUpdate(ctx, rg, name, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.3.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetForProbeName), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.3.0.0/24"), }, }, }, }, Tags: map[string]*string{"purpose": new("overmind-integration-tests")}, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, rg, name, nil); getErr == nil { log.Printf("Virtual network %s already exists (conflict), skipping", name) return nil } return fmt.Errorf("virtual network %s conflict but not retrievable: %w", name, err) } return fmt.Errorf("failed to create virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", name) return nil } func deleteVNetForProbeTest(ctx context.Context, client *armnetwork.VirtualNetworksClient, rg, name string) error { poller, err := client.BeginDelete(ctx, rg, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", name) return nil } return fmt.Errorf("failed to delete virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", name) return nil } func createPublicIPForProbeTest(ctx context.Context, client *armnetwork.PublicIPAddressesClient, rg, name, location string) error { _, err := client.Get(ctx, rg, name, nil) if err == nil { log.Printf("Public IP address %s already exists, skipping creation", name) return nil } poller, err := client.BeginCreateOrUpdate(ctx, rg, name, armnetwork.PublicIPAddress{ Location: new(location), Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), }, SKU: &armnetwork.PublicIPAddressSKU{ Name: new(armnetwork.PublicIPAddressSKUNameStandard), }, Tags: map[string]*string{"purpose": new("overmind-integration-tests")}, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, rg, name, nil); getErr == nil { log.Printf("Public IP address %s already exists (conflict), skipping", name) return nil } return fmt.Errorf("public IP %s conflict but not retrievable: %w", name, err) } return fmt.Errorf("failed to create public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create public IP address: %w", err) } log.Printf("Public IP address %s created successfully", name) return nil } func deletePublicIPForProbeTest(ctx context.Context, client *armnetwork.PublicIPAddressesClient, rg, name string) error { poller, err := client.BeginDelete(ctx, rg, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP address %s not found, skipping deletion", name) return nil } return fmt.Errorf("failed to delete public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete public IP address: %w", err) } log.Printf("Public IP address %s deleted successfully", name) return nil } func createLBWithProbes(ctx context.Context, client *armnetwork.LoadBalancersClient, subscriptionID, rg, name, location, publicIPID string) error { _, err := client.Get(ctx, rg, name, nil) if err == nil { log.Printf("Load balancer %s already exists, skipping creation", name) return nil } frontendIPConfigID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/frontend-config", subscriptionID, rg, name) backendPoolID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/backend-pool", subscriptionID, rg, name) tcpProbeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/probes/%s", subscriptionID, rg, name, integrationTestProbeName) port80 := int32(80) port443 := int32(443) intervalInSeconds := int32(15) numberOfProbes := int32(2) poller, err := client.BeginCreateOrUpdate(ctx, rg, name, armnetwork.LoadBalancer{ Location: new(location), SKU: &armnetwork.LoadBalancerSKU{ Name: new(armnetwork.LoadBalancerSKUNameStandard), }, Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new("frontend-config"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, }, }, }, BackendAddressPools: []*armnetwork.BackendAddressPool{ {Name: new("backend-pool")}, }, Probes: []*armnetwork.Probe{ { Name: new(integrationTestProbeName), Properties: &armnetwork.ProbePropertiesFormat{ Protocol: new(armnetwork.ProbeProtocolTCP), Port: &port80, IntervalInSeconds: &intervalInSeconds, NumberOfProbes: &numberOfProbes, }, }, { Name: new(integrationTestProbeHTTPName), Properties: &armnetwork.ProbePropertiesFormat{ Protocol: new(armnetwork.ProbeProtocolHTTP), Port: &port443, IntervalInSeconds: &intervalInSeconds, NumberOfProbes: &numberOfProbes, RequestPath: new("/health"), }, }, }, LoadBalancingRules: []*armnetwork.LoadBalancingRule{ { Name: new("lb-rule-with-probe"), Properties: &armnetwork.LoadBalancingRulePropertiesFormat{ FrontendIPConfiguration: &armnetwork.SubResource{ID: new(frontendIPConfigID)}, BackendAddressPool: &armnetwork.SubResource{ID: new(backendPoolID)}, Probe: &armnetwork.SubResource{ID: new(tcpProbeID)}, Protocol: new(armnetwork.TransportProtocolTCP), FrontendPort: &port80, BackendPort: &port80, EnableFloatingIP: new(false), IdleTimeoutInMinutes: new(int32(4)), }, }, }, }, Tags: map[string]*string{"purpose": new("overmind-integration-tests")}, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, rg, name, nil); getErr == nil { log.Printf("Load balancer %s already exists (conflict), skipping", name) return nil } return fmt.Errorf("load balancer %s conflict but not retrievable: %w", name, err) } return fmt.Errorf("failed to create load balancer: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create load balancer: %w", err) } log.Printf("Load balancer %s with probes created successfully", name) return nil } func deleteLBForProbeTest(ctx context.Context, client *armnetwork.LoadBalancersClient, rg, name string) error { poller, err := client.BeginDelete(ctx, rg, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Load balancer %s not found, skipping deletion", name) return nil } return fmt.Errorf("failed to delete load balancer: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete load balancer: %w", err) } log.Printf("Load balancer %s deleted successfully", name) return nil } ================================================ FILE: sources/azure/integration-tests/network-load-balancer_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestLBName = "ovm-integ-test-lb" integrationTestLBInternalName = "ovm-integ-test-lb-internal" integrationTestVNetNameForLB = "ovm-integ-test-vnet-for-lb" integrationTestSubnetNameForLB = "default" integrationTestPublicIPNameForLB = "ovm-integ-test-public-ip-for-lb" ) func TestNetworkLoadBalancerIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } lbClient, err := armnetwork.NewLoadBalancersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Load Balancers client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network for the load balancer err = createVirtualNetworkForLB(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForLB, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for load balancer creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestVNetNameForLB, integrationTestSubnetNameForLB, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create public IP address for the load balancer err = createPublicIPForLB(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPNameForLB, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP address: %v", err) } // Get public IP ID publicIPResp, err := publicIPClient.Get(ctx, integrationTestResourceGroup, integrationTestPublicIPNameForLB, nil) if err != nil { t.Fatalf("Failed to get public IP address: %v", err) } // Create public load balancer (with PublicIPAddress) err = createPublicLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestLBName, integrationTestLocation, *publicIPResp.ID) if err != nil { t.Fatalf("Failed to create public load balancer: %v", err) } // Create internal load balancer (with Subnet) err = createInternalLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestLBInternalName, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create internal load balancer: %v", err) } log.Printf("Setup completed: Load balancers %s (public) and %s (internal) created", integrationTestLBName, integrationTestLBInternalName) }) t.Run("Run", func(t *testing.T) { t.Run("GetLoadBalancer", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving load balancer %s in subscription %s, resource group %s", integrationTestLBName, subscriptionID, integrationTestResourceGroup) lbWrapper := manual.NewNetworkLoadBalancer( clients.NewLoadBalancersClient(lbClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := lbWrapper.Scopes()[0] lbAdapter := sources.WrapperToAdapter(lbWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := lbAdapter.Get(ctx, scope, integrationTestLBName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestLBName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestLBName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.NetworkLoadBalancer.String() { t.Fatalf("Expected type %s, got %s", azureshared.NetworkLoadBalancer, sdpItem.GetType()) } log.Printf("Successfully retrieved load balancer %s", integrationTestLBName) }) t.Run("ListLoadBalancers", func(t *testing.T) { ctx := t.Context() log.Printf("Listing load balancers in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) lbWrapper := manual.NewNetworkLoadBalancer( clients.NewLoadBalancersClient(lbClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := lbWrapper.Scopes()[0] lbAdapter := sources.WrapperToAdapter(lbWrapper, sdpcache.NewNoOpCache()) listable, ok := lbAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) < 2 { t.Fatalf("Expected at least 2 load balancers, got: %d", len(sdpItems)) } // Find our test load balancers foundPublic := false foundInternal := false for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { switch v { case integrationTestLBName: foundPublic = true if item.GetType() != azureshared.NetworkLoadBalancer.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancer, item.GetType()) } case integrationTestLBInternalName: foundInternal = true if item.GetType() != azureshared.NetworkLoadBalancer.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancer, item.GetType()) } } } } if !foundPublic { t.Fatalf("Expected to find load balancer %s in list, but didn't", integrationTestLBName) } if !foundInternal { t.Fatalf("Expected to find load balancer %s in list, but didn't", integrationTestLBInternalName) } log.Printf("Successfully listed %d load balancers", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() lbWrapper := manual.NewNetworkLoadBalancer( clients.NewLoadBalancersClient(lbClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := lbWrapper.Scopes()[0] lbAdapter := sources.WrapperToAdapter(lbWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := lbAdapter.Get(ctx, scope, integrationTestLBName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.NetworkLoadBalancer.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancer, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } log.Printf("Verified item attributes for load balancer %s", integrationTestLBName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() lbWrapper := manual.NewNetworkLoadBalancer( clients.NewLoadBalancersClient(lbClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := lbWrapper.Scopes()[0] // Test public load balancer (should have PublicIPAddress link) t.Run("PublicLoadBalancer", func(t *testing.T) { lbAdapter := sources.WrapperToAdapter(lbWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := lbAdapter.Get(ctx, scope, integrationTestLBName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected linked item types for public load balancer expectedLinkedTypes := map[string]bool{ azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(): false, azureshared.NetworkPublicIPAddress.String(): false, } for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() if _, exists := expectedLinkedTypes[linkedType]; exists { expectedLinkedTypes[linkedType] = true } } for linkedType, found := range expectedLinkedTypes { if !found { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } log.Printf("Verified %d linked item queries for public load balancer %s", len(linkedQueries), integrationTestLBName) }) // Test internal load balancer (should have Subnet link) t.Run("InternalLoadBalancer", func(t *testing.T) { lbAdapter := sources.WrapperToAdapter(lbWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := lbAdapter.Get(ctx, scope, integrationTestLBInternalName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected linked item types for internal load balancer expectedLinkedTypes := map[string]bool{ azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(): false, azureshared.NetworkSubnet.String(): false, } for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() if _, exists := expectedLinkedTypes[linkedType]; exists { expectedLinkedTypes[linkedType] = true } } for linkedType, found := range expectedLinkedTypes { if !found { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } log.Printf("Verified %d linked item queries for internal load balancer %s", len(linkedQueries), integrationTestLBInternalName) }) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete public load balancer err := deleteLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestLBName) if err != nil { t.Fatalf("Failed to delete public load balancer: %v", err) } // Delete internal load balancer err = deleteLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestLBInternalName) if err != nil { t.Fatalf("Failed to delete internal load balancer: %v", err) } // Delete public IP address err = deletePublicIPForLB(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPNameForLB) if err != nil { t.Fatalf("Failed to delete public IP address: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForLB(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForLB) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } // createVirtualNetworkForLB creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetworkForLB(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.2.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetNameForLB), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.2.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // deleteVirtualNetworkForLB deletes an Azure virtual network func deleteVirtualNetworkForLB(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } // createPublicIPForLB creates an Azure public IP address (idempotent) func createPublicIPForLB(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName, location string) error { // Check if public IP already exists _, err := client.Get(ctx, resourceGroupName, publicIPName, nil) if err == nil { log.Printf("Public IP address %s already exists, skipping creation", publicIPName) return nil } // Create the public IP address poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, publicIPName, armnetwork.PublicIPAddress{ Location: new(location), Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), }, SKU: &armnetwork.PublicIPAddressSKU{ Name: new(armnetwork.PublicIPAddressSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create public IP address: %w", err) } log.Printf("Public IP address %s created successfully", publicIPName) return nil } // deletePublicIPForLB deletes an Azure public IP address func deletePublicIPForLB(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, publicIPName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP address %s not found, skipping deletion", publicIPName) return nil } return fmt.Errorf("failed to begin deleting public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete public IP address: %w", err) } log.Printf("Public IP address %s deleted successfully", publicIPName) return nil } // createPublicLoadBalancer creates an Azure load balancer with public IP (idempotent) func createPublicLoadBalancer(ctx context.Context, client *armnetwork.LoadBalancersClient, resourceGroupName, lbName, location, publicIPID string) error { // Check if load balancer already exists _, err := client.Get(ctx, resourceGroupName, lbName, nil) if err == nil { log.Printf("Load balancer %s already exists, skipping creation", lbName) return nil } // Create the public load balancer poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, lbName, armnetwork.LoadBalancer{ Location: new(location), Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new("frontend-ip-config-public"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, }, }, }, BackendAddressPools: []*armnetwork.BackendAddressPool{ { Name: new("backend-pool"), }, }, LoadBalancingRules: []*armnetwork.LoadBalancingRule{ { Name: new("lb-rule"), Properties: &armnetwork.LoadBalancingRulePropertiesFormat{ FrontendIPConfiguration: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/frontend-ip-config-public", os.Getenv("AZURE_SUBSCRIPTION_ID"), resourceGroupName, lbName)), }, BackendAddressPool: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/backend-pool", os.Getenv("AZURE_SUBSCRIPTION_ID"), resourceGroupName, lbName)), }, Protocol: new(armnetwork.TransportProtocolTCP), FrontendPort: new(int32(80)), BackendPort: new(int32(80)), EnableFloatingIP: new(false), IdleTimeoutInMinutes: new(int32(4)), }, }, }, }, SKU: &armnetwork.LoadBalancerSKU{ Name: new(armnetwork.LoadBalancerSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating load balancer: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create load balancer: %w", err) } log.Printf("Load balancer %s created successfully", lbName) return nil } // createInternalLoadBalancer creates an Azure load balancer with subnet (idempotent) func createInternalLoadBalancer(ctx context.Context, client *armnetwork.LoadBalancersClient, resourceGroupName, lbName, location, subnetID string) error { // Check if load balancer already exists _, err := client.Get(ctx, resourceGroupName, lbName, nil) if err == nil { log.Printf("Load balancer %s already exists, skipping creation", lbName) return nil } // Create the internal load balancer poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, lbName, armnetwork.LoadBalancer{ Location: new(location), Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new("frontend-ip-config-internal"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAddress: new("10.2.0.5"), PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), }, }, }, BackendAddressPools: []*armnetwork.BackendAddressPool{ { Name: new("backend-pool"), }, }, LoadBalancingRules: []*armnetwork.LoadBalancingRule{ { Name: new("lb-rule"), Properties: &armnetwork.LoadBalancingRulePropertiesFormat{ FrontendIPConfiguration: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/frontend-ip-config-internal", os.Getenv("AZURE_SUBSCRIPTION_ID"), resourceGroupName, lbName)), }, BackendAddressPool: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/backend-pool", os.Getenv("AZURE_SUBSCRIPTION_ID"), resourceGroupName, lbName)), }, Protocol: new(armnetwork.TransportProtocolTCP), FrontendPort: new(int32(80)), BackendPort: new(int32(80)), EnableFloatingIP: new(false), IdleTimeoutInMinutes: new(int32(4)), }, }, }, }, SKU: &armnetwork.LoadBalancerSKU{ Name: new(armnetwork.LoadBalancerSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating load balancer: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create load balancer: %w", err) } log.Printf("Load balancer %s created successfully", lbName) return nil } // deleteLoadBalancer deletes an Azure load balancer func deleteLoadBalancer(ctx context.Context, client *armnetwork.LoadBalancersClient, resourceGroupName, lbName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, lbName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Load balancer %s not found, skipping deletion", lbName) return nil } return fmt.Errorf("failed to begin deleting load balancer: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete load balancer: %w", err) } log.Printf("Load balancer %s deleted successfully", lbName) return nil } ================================================ FILE: sources/azure/integration-tests/network-local-network-gateway_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestLocalNetworkGatewayName = "ovm-integ-test-lng" ) func TestNetworkLocalNetworkGatewayIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } localNetworkGatewaysClient, err := armnetwork.NewLocalNetworkGatewaysClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Local Network Gateways client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createLocalNetworkGateway(ctx, localNetworkGatewaysClient, integrationTestResourceGroup, integrationTestLocalNetworkGatewayName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create local network gateway: %v", err) } err = waitForLocalNetworkGatewayAvailable(ctx, localNetworkGatewaysClient, integrationTestResourceGroup, integrationTestLocalNetworkGatewayName) if err != nil { t.Fatalf("Failed waiting for local network gateway to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetLocalNetworkGateway", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving local network gateway %s in subscription %s, resource group %s", integrationTestLocalNetworkGatewayName, subscriptionID, integrationTestResourceGroup) wrapper := manual.NewNetworkLocalNetworkGateway( clients.NewLocalNetworkGatewaysClient(localNetworkGatewaysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestLocalNetworkGatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestLocalNetworkGatewayName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestLocalNetworkGatewayName, uniqueAttrValue) } log.Printf("Successfully retrieved local network gateway %s", integrationTestLocalNetworkGatewayName) }) t.Run("ListLocalNetworkGateways", func(t *testing.T) { ctx := t.Context() log.Printf("Listing local network gateways in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) wrapper := manual.NewNetworkLocalNetworkGateway( clients.NewLocalNetworkGatewaysClient(localNetworkGatewaysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list local network gateways: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one local network gateway, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestLocalNetworkGatewayName { found = true break } } if !found { t.Fatalf("Expected to find local network gateway %s in the list", integrationTestLocalNetworkGatewayName) } log.Printf("Found %d local network gateways in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for local network gateway %s", integrationTestLocalNetworkGatewayName) wrapper := manual.NewNetworkLocalNetworkGateway( clients.NewLocalNetworkGatewaysClient(localNetworkGatewaysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestLocalNetworkGatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLocalNetworkGateway.String() { t.Errorf("Expected item type %s, got %s", azureshared.NetworkLocalNetworkGateway, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for local network gateway %s", integrationTestLocalNetworkGatewayName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for local network gateway %s", integrationTestLocalNetworkGatewayName) wrapper := manual.NewNetworkLocalNetworkGateway( clients.NewLocalNetworkGatewaysClient(localNetworkGatewaysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestLocalNetworkGatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for local network gateway %s", len(linkedQueries), integrationTestLocalNetworkGatewayName) for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteLocalNetworkGateway(ctx, localNetworkGatewaysClient, integrationTestResourceGroup, integrationTestLocalNetworkGatewayName) if err != nil { t.Fatalf("Failed to delete local network gateway: %v", err) } }) } func createLocalNetworkGateway(ctx context.Context, client *armnetwork.LocalNetworkGatewaysClient, resourceGroupName, gatewayName, location string) error { existingGateway, err := client.Get(ctx, resourceGroupName, gatewayName, nil) if err == nil { if existingGateway.Properties != nil && existingGateway.Properties.ProvisioningState != nil { state := string(*existingGateway.Properties.ProvisioningState) if state == "Succeeded" { log.Printf("Local network gateway %s already exists with state %s, skipping creation", gatewayName, state) return nil } log.Printf("Local network gateway %s exists but in state %s, will wait for it", gatewayName, state) } else { log.Printf("Local network gateway %s already exists, skipping creation", gatewayName) return nil } } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, gatewayName, armnetwork.LocalNetworkGateway{ Location: new(location), Properties: &armnetwork.LocalNetworkGatewayPropertiesFormat{ GatewayIPAddress: new("203.0.113.1"), LocalNetworkAddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{ new("10.1.0.0/16"), new("10.2.0.0/16"), }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-local-network-gateway"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroupName, gatewayName, nil); getErr == nil { log.Printf("Local network gateway %s already exists (conflict), skipping creation", gatewayName) return nil } return fmt.Errorf("local network gateway %s conflict but not retrievable: %w", gatewayName, err) } return fmt.Errorf("failed to begin creating local network gateway: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create local network gateway: %w", err) } if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("local network gateway created but provisioning state is unknown") } provisioningState := string(*resp.Properties.ProvisioningState) if provisioningState != "Succeeded" { return fmt.Errorf("local network gateway provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Local network gateway %s created successfully with provisioning state: %s", gatewayName, provisioningState) return nil } func waitForLocalNetworkGatewayAvailable(ctx context.Context, client *armnetwork.LocalNetworkGatewaysClient, resourceGroupName, gatewayName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for local network gateway %s to be available via API...", gatewayName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, gatewayName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Local network gateway %s not yet available (attempt %d/%d), waiting %v...", gatewayName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking local network gateway availability: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := string(*resp.Properties.ProvisioningState) if state == "Succeeded" { log.Printf("Local network gateway %s is available with provisioning state: %s", gatewayName, state) return nil } if state == "Failed" { return fmt.Errorf("local network gateway provisioning failed with state: %s", state) } log.Printf("Local network gateway %s provisioning state: %s (attempt %d/%d), waiting...", gatewayName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } log.Printf("Local network gateway %s is available", gatewayName) return nil } return fmt.Errorf("timeout waiting for local network gateway %s to be available after %d attempts", gatewayName, maxAttempts) } func deleteLocalNetworkGateway(ctx context.Context, client *armnetwork.LocalNetworkGatewaysClient, resourceGroupName, gatewayName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, gatewayName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Local network gateway %s not found, skipping deletion", gatewayName) return nil } return fmt.Errorf("failed to begin deleting local network gateway: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete local network gateway: %w", err) } log.Printf("Local network gateway %s deleted successfully", gatewayName) return nil } ================================================ FILE: sources/azure/integration-tests/network-network-interface-ip-configuration_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestIPConfigNICName = "ovm-integ-test-nic-for-ipconfig" integrationTestIPConfigVNetName = "ovm-integ-test-vnet-for-ipconfig" integrationTestIPConfigSubnetName = "default" integrationTestIPConfigIPConfigName = "ipconfig1" ) func TestNetworkNetworkInterfaceIPConfigurationIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } nicClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Interfaces client: %v", err) } ipConfigClient, err := armnetwork.NewInterfaceIPConfigurationsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Interface IP Configurations client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createVirtualNetworkForIPConfig(ctx, vnetClient, integrationTestResourceGroup, integrationTestIPConfigVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestIPConfigVNetName, integrationTestIPConfigSubnetName, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } err = createNetworkInterfaceForIPConfig(ctx, nicClient, integrationTestResourceGroup, integrationTestIPConfigNICName, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create network interface: %v", err) } setupCompleted = true log.Printf("Setup completed: Network interface %s created with IP configuration %s", integrationTestIPConfigNICName, integrationTestIPConfigIPConfigName) }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetIPConfiguration", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving IP configuration %s from NIC %s in subscription %s, resource group %s", integrationTestIPConfigIPConfigName, integrationTestIPConfigNICName, subscriptionID, integrationTestResourceGroup) ipConfigWrapper := manual.NewNetworkNetworkInterfaceIPConfiguration( clients.NewInterfaceIPConfigurationsClient(ipConfigClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipConfigWrapper.Scopes()[0] ipConfigAdapter := sources.WrapperToAdapter(ipConfigWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestIPConfigNICName, integrationTestIPConfigIPConfigName) sdpItem, qErr := ipConfigAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueValue := shared.CompositeLookupKey(integrationTestIPConfigNICName, integrationTestIPConfigIPConfigName) if uniqueAttrValue != expectedUniqueValue { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedUniqueValue, uniqueAttrValue) } log.Printf("Successfully retrieved IP configuration %s", integrationTestIPConfigIPConfigName) }) t.Run("SearchIPConfigurations", func(t *testing.T) { ctx := t.Context() log.Printf("Searching IP configurations in NIC %s", integrationTestIPConfigNICName) ipConfigWrapper := manual.NewNetworkNetworkInterfaceIPConfiguration( clients.NewInterfaceIPConfigurationsClient(ipConfigClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipConfigWrapper.Scopes()[0] ipConfigAdapter := sources.WrapperToAdapter(ipConfigWrapper, sdpcache.NewNoOpCache()) searchable, ok := ipConfigAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, integrationTestIPConfigNICName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) == 0 { t.Fatalf("Expected at least 1 IP configuration, got: %d", len(sdpItems)) } var found bool expectedUniqueValue := shared.CompositeLookupKey(integrationTestIPConfigNICName, integrationTestIPConfigIPConfigName) for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUniqueValue { found = true break } } if !found { t.Fatalf("Expected to find IP configuration %s in search results", integrationTestIPConfigIPConfigName) } log.Printf("Successfully found %d IP configurations in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for IP configuration %s", integrationTestIPConfigIPConfigName) ipConfigWrapper := manual.NewNetworkNetworkInterfaceIPConfiguration( clients.NewInterfaceIPConfigurationsClient(ipConfigClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipConfigWrapper.Scopes()[0] ipConfigAdapter := sources.WrapperToAdapter(ipConfigWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestIPConfigNICName, integrationTestIPConfigIPConfigName) sdpItem, qErr := ipConfigAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasNICLink bool var hasSubnetLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query.GetType() == "" { t.Error("Linked item query has empty type") } if query.GetQuery() == "" { t.Error("Linked item query has empty query") } if query.GetScope() == "" { t.Error("Linked item query has empty scope") } switch query.GetType() { case azureshared.NetworkNetworkInterface.String(): hasNICLink = true if query.GetQuery() != integrationTestIPConfigNICName { t.Errorf("Expected linked query to NIC %s, got %s", integrationTestIPConfigNICName, query.GetQuery()) } case azureshared.NetworkSubnet.String(): hasSubnetLink = true } } if !hasNICLink { t.Error("Expected linked query to parent network interface, but didn't find one") } if !hasSubnetLink { t.Error("Expected linked query to subnet, but didn't find one") } log.Printf("Verified %d linked item queries for IP configuration %s", len(linkedQueries), integrationTestIPConfigIPConfigName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() ipConfigWrapper := manual.NewNetworkNetworkInterfaceIPConfiguration( clients.NewInterfaceIPConfigurationsClient(ipConfigClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := ipConfigWrapper.Scopes()[0] ipConfigAdapter := sources.WrapperToAdapter(ipConfigWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(integrationTestIPConfigNICName, integrationTestIPConfigIPConfigName) sdpItem, qErr := ipConfigAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkNetworkInterfaceIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNetworkInterfaceIPConfiguration, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } log.Printf("Verified item attributes for IP configuration %s", integrationTestIPConfigIPConfigName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteNetworkInterfaceForIPConfig(ctx, nicClient, integrationTestResourceGroup, integrationTestIPConfigNICName) if err != nil { t.Fatalf("Failed to delete network interface: %v", err) } err = deleteVirtualNetworkForIPConfig(ctx, vnetClient, integrationTestResourceGroup, integrationTestIPConfigVNetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } func createVirtualNetworkForIPConfig(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.2.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestIPConfigSubnetName), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.2.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } func deleteVirtualNetworkForIPConfig(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } func createNetworkInterfaceForIPConfig(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName, location, subnetID string) error { _, err := client.Get(ctx, resourceGroupName, nicName, nil) if err == nil { log.Printf("Network interface %s already exists, skipping creation", nicName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, nicName, armnetwork.Interface{ Location: new(location), Properties: &armnetwork.InterfacePropertiesFormat{ IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new(integrationTestIPConfigIPConfigName), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), Primary: new(true), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create network interface: %w", err) } log.Printf("Network interface %s created successfully", nicName) return nil } func deleteNetworkInterfaceForIPConfig(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, nicName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Network interface %s not found, skipping deletion", nicName) return nil } return fmt.Errorf("failed to begin deleting network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete network interface: %w", err) } log.Printf("Network interface %s deleted successfully", nicName) return nil } ================================================ FILE: sources/azure/integration-tests/network-network-interface_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestNICNameForTest = "ovm-integ-test-nic-standalone" integrationTestVNetNameForNIC = "ovm-integ-test-vnet-for-nic" integrationTestSubnetNameForNIC = "default" ) func TestNetworkNetworkInterfaceIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } nicClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Interfaces client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network for the NIC err = createVirtualNetworkForNIC(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForNIC, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for NIC creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestVNetNameForNIC, integrationTestSubnetNameForNIC, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create network interface err = createNetworkInterface(ctx, nicClient, integrationTestResourceGroup, integrationTestNICNameForTest, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create network interface: %v", err) } log.Printf("Setup completed: Network interface %s created", integrationTestNICNameForTest) }) t.Run("Run", func(t *testing.T) { t.Run("GetNetworkInterface", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving network interface %s in subscription %s, resource group %s", integrationTestNICNameForTest, subscriptionID, integrationTestResourceGroup) nicWrapper := manual.NewNetworkNetworkInterface( clients.NewNetworkInterfacesClient(nicClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := nicWrapper.Scopes()[0] nicAdapter := sources.WrapperToAdapter(nicWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := nicAdapter.Get(ctx, scope, integrationTestNICNameForTest, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestNICNameForTest { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestNICNameForTest, uniqueAttrValue) } log.Printf("Successfully retrieved network interface %s", integrationTestNICNameForTest) }) t.Run("ListNetworkInterfaces", func(t *testing.T) { ctx := t.Context() log.Printf("Listing network interfaces in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) nicWrapper := manual.NewNetworkNetworkInterface( clients.NewNetworkInterfacesClient(nicClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := nicWrapper.Scopes()[0] nicAdapter := sources.WrapperToAdapter(nicWrapper, sdpcache.NewNoOpCache()) listable, ok := nicAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) == 0 { t.Fatalf("Expected at least 1 network interface, got: %d", len(sdpItems)) } // Find our test NIC var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestNICNameForTest { found = true break } } if !found { t.Fatalf("Expected to find network interface %s in list, but didn't", integrationTestNICNameForTest) } log.Printf("Successfully listed %d network interfaces", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() nicWrapper := manual.NewNetworkNetworkInterface( clients.NewNetworkInterfacesClient(nicClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := nicWrapper.Scopes()[0] nicAdapter := sources.WrapperToAdapter(nicWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := nicAdapter.Get(ctx, scope, integrationTestNICNameForTest, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.NetworkNetworkInterface.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNetworkInterface, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify linked item queries exist (IP configuration link should always be present) linkedQueries := sdpItem.GetLinkedItemQueries() hasIPConfigLink := false for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.NetworkNetworkInterfaceIPConfiguration.String(): hasIPConfigLink = true case azureshared.ComputeVirtualMachine.String(): // VM link may or may not be present depending on whether NIC is attached case azureshared.NetworkNetworkSecurityGroup.String(): // NSG link may or may not be present } } // IP configuration link should always be present if !hasIPConfigLink { t.Error("Expected linked query to IP configuration, but didn't find one") } log.Printf("Verified %d linked item queries for NIC %s", len(linkedQueries), integrationTestNICNameForTest) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete network interface err := deleteNetworkInterface(ctx, nicClient, integrationTestResourceGroup, integrationTestNICNameForTest) if err != nil { t.Fatalf("Failed to delete network interface: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForNIC(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForNIC) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } // createVirtualNetworkForNIC creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetworkForNIC(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.1.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetNameForNIC), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.1.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // deleteVirtualNetworkForNIC deletes an Azure virtual network func deleteVirtualNetworkForNIC(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } ================================================ FILE: sources/azure/integration-tests/network-network-security-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestNSGName = "ovm-integ-test-nsg" ) func TestNetworkNetworkSecurityGroupIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients nsgClient, err := armnetwork.NewSecurityGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Security Groups client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create network security group err = createNetworkSecurityGroup(ctx, nsgClient, integrationTestResourceGroup, integrationTestNSGName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create network security group: %v", err) } // Wait for NSG to be fully available err = waitForNSGAvailable(ctx, nsgClient, integrationTestResourceGroup, integrationTestNSGName) if err != nil { t.Fatalf("Failed waiting for network security group to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetNetworkSecurityGroup", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving network security group %s in subscription %s, resource group %s", integrationTestNSGName, subscriptionID, integrationTestResourceGroup) nsgWrapper := manual.NewNetworkNetworkSecurityGroup( clients.NewNetworkSecurityGroupsClient(nsgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := nsgWrapper.Scopes()[0] nsgAdapter := sources.WrapperToAdapter(nsgWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := nsgAdapter.Get(ctx, scope, integrationTestNSGName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestNSGName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestNSGName, uniqueAttrValue) } log.Printf("Successfully retrieved network security group %s", integrationTestNSGName) }) t.Run("ListNetworkSecurityGroups", func(t *testing.T) { ctx := t.Context() log.Printf("Listing network security groups in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) nsgWrapper := manual.NewNetworkNetworkSecurityGroup( clients.NewNetworkSecurityGroupsClient(nsgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := nsgWrapper.Scopes()[0] nsgAdapter := sources.WrapperToAdapter(nsgWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := nsgAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list network security groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one network security group, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestNSGName { found = true break } } if !found { t.Fatalf("Expected to find network security group %s in the list of network security groups", integrationTestNSGName) } log.Printf("Found %d network security groups in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for network security group %s", integrationTestNSGName) nsgWrapper := manual.NewNetworkNetworkSecurityGroup( clients.NewNetworkSecurityGroupsClient(nsgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := nsgWrapper.Scopes()[0] nsgAdapter := sources.WrapperToAdapter(nsgWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := nsgAdapter.Get(ctx, scope, integrationTestNSGName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.NetworkNetworkSecurityGroup.String() { t.Errorf("Expected item type %s, got %s", azureshared.NetworkNetworkSecurityGroup, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for network security group %s", integrationTestNSGName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for network security group %s", integrationTestNSGName) nsgWrapper := manual.NewNetworkNetworkSecurityGroup( clients.NewNetworkSecurityGroupsClient(nsgClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := nsgWrapper.Scopes()[0] nsgAdapter := sources.WrapperToAdapter(nsgWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := nsgAdapter.Get(ctx, scope, integrationTestNSGName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (if any) linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for network security group %s", len(linkedQueries), integrationTestNSGName) // For a newly created NSG, there should be default security rules // Verify the structure is correct if links exist for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } // Verify query has required fields if query.GetType() == "" { t.Error("Linked item query has empty Type") } // Method should be GET or SEARCH (not empty) if query.GetMethod() == sdp.QueryMethod_GET || query.GetMethod() == sdp.QueryMethod_SEARCH { // Valid method } else { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } // Verify that default security rules are linked (they should always exist) var hasDefaultSecurityRuleLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkDefaultSecurityRule.String() { hasDefaultSecurityRuleLink = true break } } if !hasDefaultSecurityRuleLink { t.Error("Expected linked query to default security rules, but didn't find one") } // Verify that custom security rules are linked (we created one named "AllowSSH") var hasSecurityRuleLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkSecurityRule.String() { hasSecurityRuleLink = true // Verify the query contains the NSG name and rule name query := liq.GetQuery().GetQuery() if query == "" { t.Error("Expected security rule query to be non-empty") } break } } if !hasSecurityRuleLink { t.Error("Expected linked query to security rules, but didn't find one") } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete network security group err := deleteNetworkSecurityGroup(ctx, nsgClient, integrationTestResourceGroup, integrationTestNSGName) if err != nil { t.Fatalf("Failed to delete network security group: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createNetworkSecurityGroup creates an Azure network security group (idempotent) func createNetworkSecurityGroup(ctx context.Context, client *armnetwork.SecurityGroupsClient, resourceGroupName, nsgName, location string) error { // Check if NSG already exists existingNSG, err := client.Get(ctx, resourceGroupName, nsgName, nil) if err == nil { // NSG exists, check its provisioning state if existingNSG.Properties != nil && existingNSG.Properties.ProvisioningState != nil { state := *existingNSG.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Network security group %s already exists with state %s, skipping creation", nsgName, state) return nil } log.Printf("Network security group %s exists but in state %s, will wait for it", nsgName, state) } else { log.Printf("Network security group %s already exists, skipping creation", nsgName) return nil } } // Create a basic network security group with a sample security rule // This creates an NSG with a default allow rule for testing poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, nsgName, armnetwork.SecurityGroup{ Location: new(location), Properties: &armnetwork.SecurityGroupPropertiesFormat{ SecurityRules: []*armnetwork.SecurityRule{ { Name: new("AllowSSH"), Properties: &armnetwork.SecurityRulePropertiesFormat{ Protocol: new(armnetwork.SecurityRuleProtocolTCP), SourcePortRange: new("*"), DestinationPortRange: new("22"), SourceAddressPrefix: new("*"), DestinationAddressPrefix: new("*"), Access: new(armnetwork.SecurityRuleAccessAllow), Priority: new(int32(1000)), Direction: new(armnetwork.SecurityRuleDirectionInbound), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-network-security-group"), }, }, nil) if err != nil { // Check if NSG already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Network security group %s already exists (conflict), skipping creation", nsgName) return nil } return fmt.Errorf("failed to begin creating network security group: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create network security group: %w", err) } // Verify the NSG was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("network security group created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("network security group provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Network security group %s created successfully with provisioning state: %s", nsgName, provisioningState) return nil } // waitForNSGAvailable polls until the NSG is available via the Get API // This is needed because even after creation succeeds, there can be a delay before the NSG is queryable func waitForNSGAvailable(ctx context.Context, client *armnetwork.SecurityGroupsClient, resourceGroupName, nsgName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for network security group %s to be available via API...", nsgName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, nsgName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Network security group %s not yet available (attempt %d/%d), waiting %v...", nsgName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking network security group availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Network security group %s is available with provisioning state: %s", nsgName, state) return nil } if state == "Failed" { return fmt.Errorf("network security group provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Network security group %s provisioning state: %s (attempt %d/%d), waiting...", nsgName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // NSG exists but no provisioning state - consider it available log.Printf("Network security group %s is available", nsgName) return nil } return fmt.Errorf("timeout waiting for network security group %s to be available after %d attempts", nsgName, maxAttempts) } // deleteNetworkSecurityGroup deletes an Azure network security group func deleteNetworkSecurityGroup(ctx context.Context, client *armnetwork.SecurityGroupsClient, resourceGroupName, nsgName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, nsgName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Network security group %s not found, skipping deletion", nsgName) return nil } return fmt.Errorf("failed to begin deleting network security group: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete network security group: %w", err) } log.Printf("Network security group %s deleted successfully", nsgName) return nil } ================================================ FILE: sources/azure/integration-tests/network-network-watcher_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( // Azure only allows one Network Watcher per region per subscription. // We create a test Network Watcher in our integration test resource group. integrationTestNetworkWatcherTestName = "ovm-integ-test-nw" ) func TestNetworkNetworkWatcherIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } networkWatchersClient, err := armnetwork.NewWatchersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Watchers client: %v", err) } setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create network watcher - Azure only allows one per region per subscription err = createNetworkWatcher(ctx, networkWatchersClient, integrationTestResourceGroup, integrationTestNetworkWatcherTestName, integrationTestLocation) if err != nil { // If we hit the limit, it means a Network Watcher already exists in another RG if strings.Contains(err.Error(), "NetworkWatcherCountLimitReached") { t.Skipf("Skipping: Azure allows only one Network Watcher per region. One already exists: %v", err) } t.Fatalf("Failed to create network watcher: %v", err) } // Wait for network watcher to be available err = waitForNetworkWatcherAvailable(ctx, networkWatchersClient, integrationTestResourceGroup, integrationTestNetworkWatcherTestName) if err != nil { t.Fatalf("Failed waiting for network watcher: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetNetworkWatcher", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving network watcher %s in subscription %s, resource group %s", integrationTestNetworkWatcherTestName, subscriptionID, integrationTestResourceGroup) wrapper := manual.NewNetworkNetworkWatcher( clients.NewNetworkWatchersClient(networkWatchersClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestNetworkWatcherTestName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestNetworkWatcherTestName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestNetworkWatcherTestName, uniqueAttrValue) } log.Printf("Successfully retrieved network watcher %s", integrationTestNetworkWatcherTestName) }) t.Run("ListNetworkWatchers", func(t *testing.T) { ctx := t.Context() log.Printf("Listing network watchers in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) wrapper := manual.NewNetworkNetworkWatcher( clients.NewNetworkWatchersClient(networkWatchersClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list network watchers: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one network watcher, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestNetworkWatcherTestName { found = true break } } if !found { t.Fatalf("Expected to find network watcher %s in the list", integrationTestNetworkWatcherTestName) } log.Printf("Found %d network watchers in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for network watcher %s", integrationTestNetworkWatcherTestName) wrapper := manual.NewNetworkNetworkWatcher( clients.NewNetworkWatchersClient(networkWatchersClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestNetworkWatcherTestName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() for _, query := range linkedQueries { q := query.GetQuery() if q == nil { t.Error("LinkedItemQuery has nil Query") continue } if q.GetType() == "" { t.Error("LinkedItemQuery has empty Type") } if q.GetMethod() != sdp.QueryMethod_GET && q.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("LinkedItemQuery has invalid Method: %v", q.GetMethod()) } if q.GetQuery() == "" { t.Error("LinkedItemQuery has empty Query") } if q.GetScope() == "" { t.Error("LinkedItemQuery has empty Scope") } } log.Printf("Verified %d linked item queries for network watcher %s", len(linkedQueries), integrationTestNetworkWatcherTestName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for network watcher %s", integrationTestNetworkWatcherTestName) wrapper := manual.NewNetworkNetworkWatcher( clients.NewNetworkWatchersClient(networkWatchersClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestNetworkWatcherTestName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkNetworkWatcher.String() { t.Errorf("Expected item type %s, got %s", azureshared.NetworkNetworkWatcher, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for network watcher %s", integrationTestNetworkWatcherTestName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete the network watcher we created err := deleteNetworkWatcher(ctx, networkWatchersClient, integrationTestResourceGroup, integrationTestNetworkWatcherTestName) if err != nil { t.Logf("Warning: Failed to delete network watcher %s: %v", integrationTestNetworkWatcherTestName, err) } }) } func createNetworkWatcher(ctx context.Context, client *armnetwork.WatchersClient, resourceGroup, name, location string) error { _, err := client.Get(ctx, resourceGroup, name, nil) if err == nil { log.Printf("Network watcher %s already exists, skipping creation", name) return nil } result, err := client.CreateOrUpdate(ctx, resourceGroup, name, armnetwork.Watcher{ Location: &location, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { if _, getErr := client.Get(ctx, resourceGroup, name, nil); getErr == nil { log.Printf("Network watcher %s already exists (conflict), skipping", name) return nil } return fmt.Errorf("network watcher %s conflict but not retrievable: %w", name, err) } return fmt.Errorf("failed to create network watcher: %w", err) } log.Printf("Network watcher %s created: %v", name, result.Watcher.Name) return nil } func waitForNetworkWatcherAvailable(ctx context.Context, client *armnetwork.WatchersClient, resourceGroup, name string) error { maxAttempts := 20 pollInterval := 5 * time.Second maxNotFoundAttempts := 5 notFoundCount := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroup, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("network watcher %s not found after %d attempts", name, notFoundCount) } time.Sleep(pollInterval) continue } return fmt.Errorf("error checking network watcher: %w", err) } notFoundCount = 0 if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armnetwork.ProvisioningStateSucceeded { log.Printf("Network watcher %s is available", name) return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for network watcher %s", name) } func deleteNetworkWatcher(ctx context.Context, client *armnetwork.WatchersClient, resourceGroup, name string) error { poller, err := client.BeginDelete(ctx, resourceGroup, name, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Network watcher %s already deleted", name) return nil } return fmt.Errorf("failed to begin delete network watcher: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete network watcher: %w", err) } log.Printf("Network watcher %s deleted successfully", name) return nil } ================================================ FILE: sources/azure/integration-tests/network-private-link-service_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestPLSName = "ovm-integ-test-pls" integrationTestVNetNameForPLS = "ovm-integ-test-vnet-for-pls" integrationTestSubnetNameForPLS = "pls-subnet" integrationTestLBNameForPLS = "ovm-integ-test-lb-for-pls" integrationTestFrontendIPForPLS = "frontend-ip-config" integrationTestBackendPoolForPLS = "backend-pool" ) func TestNetworkPrivateLinkServiceIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } lbClient, err := armnetwork.NewLoadBalancersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Load Balancers client: %v", err) } plsClient, err := armnetwork.NewPrivateLinkServicesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Private Link Services client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network for private link service (with special subnet settings) err = createVirtualNetworkForPLS(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForPLS, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for load balancer and private link service subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestVNetNameForPLS, integrationTestSubnetNameForPLS, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create internal load balancer for private link service err = createInternalLoadBalancerForPLS(ctx, lbClient, subscriptionID, integrationTestResourceGroup, integrationTestLBNameForPLS, integrationTestLocation, *subnetResp.ID) if err != nil { t.Fatalf("Failed to create internal load balancer: %v", err) } // Get load balancer frontend IP configuration ID lbResp, err := lbClient.Get(ctx, integrationTestResourceGroup, integrationTestLBNameForPLS, nil) if err != nil { t.Fatalf("Failed to get load balancer: %v", err) } var frontendIPConfigID string if lbResp.Properties != nil && len(lbResp.Properties.FrontendIPConfigurations) > 0 { frontendIPConfigID = *lbResp.Properties.FrontendIPConfigurations[0].ID } if frontendIPConfigID == "" { t.Fatalf("Failed to get frontend IP configuration ID") } // Create private link service err = createPrivateLinkService(ctx, plsClient, integrationTestResourceGroup, integrationTestPLSName, integrationTestLocation, *subnetResp.ID, frontendIPConfigID) if err != nil { t.Fatalf("Failed to create private link service: %v", err) } setupCompleted = true log.Printf("Setup completed: Private Link Service %s created", integrationTestPLSName) }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetPrivateLinkService", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving private link service %s in subscription %s, resource group %s", integrationTestPLSName, subscriptionID, integrationTestResourceGroup) plsWrapper := manual.NewNetworkPrivateLinkService( clients.NewPrivateLinkServicesClient(plsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := plsWrapper.Scopes()[0] plsAdapter := sources.WrapperToAdapter(plsWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := plsAdapter.Get(ctx, scope, integrationTestPLSName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestPLSName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestPLSName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.NetworkPrivateLinkService.String() { t.Fatalf("Expected type %s, got %s", azureshared.NetworkPrivateLinkService, sdpItem.GetType()) } log.Printf("Successfully retrieved private link service %s", integrationTestPLSName) }) t.Run("ListPrivateLinkServices", func(t *testing.T) { ctx := t.Context() log.Printf("Listing private link services in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) plsWrapper := manual.NewNetworkPrivateLinkService( clients.NewPrivateLinkServicesClient(plsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := plsWrapper.Scopes()[0] plsAdapter := sources.WrapperToAdapter(plsWrapper, sdpcache.NewNoOpCache()) listable, ok := plsAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least 1 private link service, got: %d", len(sdpItems)) } // Find our test private link service found := false for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { if v == integrationTestPLSName { found = true if item.GetType() != azureshared.NetworkPrivateLinkService.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPrivateLinkService, item.GetType()) } break } } } if !found { t.Fatalf("Expected to find private link service %s in list, but didn't", integrationTestPLSName) } log.Printf("Successfully listed %d private link services", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() plsWrapper := manual.NewNetworkPrivateLinkService( clients.NewPrivateLinkServicesClient(plsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := plsWrapper.Scopes()[0] plsAdapter := sources.WrapperToAdapter(plsWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := plsAdapter.Get(ctx, scope, integrationTestPLSName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.NetworkPrivateLinkService.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPrivateLinkService, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify Validate() passes if err := sdpItem.Validate(); err != nil { t.Errorf("Expected item to validate, got error: %v", err) } log.Printf("Verified item attributes for private link service %s", integrationTestPLSName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() plsWrapper := manual.NewNetworkPrivateLinkService( clients.NewPrivateLinkServicesClient(plsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := plsWrapper.Scopes()[0] plsAdapter := sources.WrapperToAdapter(plsWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := plsAdapter.Get(ctx, scope, integrationTestPLSName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify each linked item query has required fields for i, liq := range linkedQueries { query := liq.GetQuery() if query.GetType() == "" { t.Errorf("Linked query %d has empty Type", i) } if query.GetQuery() == "" { t.Errorf("Linked query %d has empty Query", i) } if query.GetScope() == "" { t.Errorf("Linked query %d has empty Scope", i) } } // Verify expected linked item types expectedLinkedTypes := map[string]bool{ azureshared.NetworkSubnet.String(): false, azureshared.NetworkVirtualNetwork.String(): false, azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(): false, azureshared.NetworkLoadBalancer.String(): false, } for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() if _, exists := expectedLinkedTypes[linkedType]; exists { expectedLinkedTypes[linkedType] = true } } for linkedType, found := range expectedLinkedTypes { if !found { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } log.Printf("Verified %d linked item queries for private link service %s", len(linkedQueries), integrationTestPLSName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete private link service err := deletePrivateLinkService(ctx, plsClient, integrationTestResourceGroup, integrationTestPLSName) if err != nil { t.Logf("Warning: Failed to delete private link service: %v", err) } // Delete load balancer err = deleteLoadBalancer(ctx, lbClient, integrationTestResourceGroup, integrationTestLBNameForPLS) if err != nil { t.Logf("Warning: Failed to delete load balancer: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForPLS(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForPLS) if err != nil { t.Logf("Warning: Failed to delete virtual network: %v", err) } log.Printf("Teardown completed") }) } // createVirtualNetworkForPLS creates an Azure virtual network with a subnet that has privateLinkServiceNetworkPolicies disabled func createVirtualNetworkForPLS(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet with a subnet that has privateLinkServiceNetworkPolicies disabled disabled := armnetwork.VirtualNetworkPrivateLinkServiceNetworkPoliciesDisabled poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.3.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetNameForPLS), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.3.0.0/24"), PrivateLinkServiceNetworkPolicies: &disabled, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // deleteVirtualNetworkForPLS deletes an Azure virtual network func deleteVirtualNetworkForPLS(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } // createInternalLoadBalancerForPLS creates an Azure internal load balancer for private link service func createInternalLoadBalancerForPLS(ctx context.Context, client *armnetwork.LoadBalancersClient, subscriptionID, resourceGroupName, lbName, location, subnetID string) error { // Check if load balancer already exists _, err := client.Get(ctx, resourceGroupName, lbName, nil) if err == nil { log.Printf("Load balancer %s already exists, skipping creation", lbName) return nil } // Create the internal load balancer poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, lbName, armnetwork.LoadBalancer{ Location: new(location), Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new(integrationTestFrontendIPForPLS), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), }, }, }, BackendAddressPools: []*armnetwork.BackendAddressPool{ { Name: new(integrationTestBackendPoolForPLS), }, }, LoadBalancingRules: []*armnetwork.LoadBalancingRule{ { Name: new("lb-rule"), Properties: &armnetwork.LoadBalancingRulePropertiesFormat{ FrontendIPConfiguration: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/%s", subscriptionID, resourceGroupName, lbName, integrationTestFrontendIPForPLS)), }, BackendAddressPool: &armnetwork.SubResource{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/%s", subscriptionID, resourceGroupName, lbName, integrationTestBackendPoolForPLS)), }, Protocol: new(armnetwork.TransportProtocolTCP), FrontendPort: new(int32(80)), BackendPort: new(int32(80)), EnableFloatingIP: new(false), IdleTimeoutInMinutes: new(int32(4)), }, }, }, }, SKU: &armnetwork.LoadBalancerSKU{ Name: new(armnetwork.LoadBalancerSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating load balancer: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create load balancer: %w", err) } log.Printf("Load balancer %s created successfully", lbName) return nil } // createPrivateLinkService creates an Azure Private Link Service func createPrivateLinkService(ctx context.Context, client *armnetwork.PrivateLinkServicesClient, resourceGroupName, plsName, location, subnetID, frontendIPConfigID string) error { // Check if private link service already exists _, err := client.Get(ctx, resourceGroupName, plsName, nil) if err == nil { log.Printf("Private link service %s already exists, skipping creation", plsName) return nil } // Create the private link service poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, plsName, armnetwork.PrivateLinkService{ Location: new(location), Properties: &armnetwork.PrivateLinkServiceProperties{ LoadBalancerFrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { ID: new(frontendIPConfigID), }, }, IPConfigurations: []*armnetwork.PrivateLinkServiceIPConfiguration{ { Name: new("pls-ip-config"), Properties: &armnetwork.PrivateLinkServiceIPConfigurationProperties{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), Primary: new(true), }, }, }, EnableProxyProtocol: new(false), Fqdns: []*string{ new("test-pls.example.com"), }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { // Verify the resource actually exists before treating conflict as success if _, getErr := client.Get(ctx, resourceGroupName, plsName, nil); getErr == nil { log.Printf("Private link service %s already exists (conflict), skipping creation", plsName) return nil } return fmt.Errorf("private link service %s conflict but not retrievable: %w", plsName, err) } return fmt.Errorf("failed to begin creating private link service: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create private link service: %w", err) } log.Printf("Private link service %s created successfully", plsName) return nil } // deletePrivateLinkService deletes an Azure Private Link Service func deletePrivateLinkService(ctx context.Context, client *armnetwork.PrivateLinkServicesClient, resourceGroupName, plsName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, plsName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Private link service %s not found, skipping deletion", plsName) return nil } return fmt.Errorf("failed to begin deleting private link service: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete private link service: %w", err) } log.Printf("Private link service %s deleted successfully", plsName) return nil } ================================================ FILE: sources/azure/integration-tests/network-public-ip-address_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestPublicIPName = "ovm-integ-test-public-ip" integrationTestNICNameForPIP = "ovm-integ-test-nic-for-pip" integrationTestVNetNameForPIP = "ovm-integ-test-vnet-for-pip" integrationTestSubnetNameForPIP = "default" ) func TestNetworkPublicIPAddressIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } nicClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Network Interfaces client: %v", err) } publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network for the NIC err = createVirtualNetworkForPIP(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForPIP, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } // Get subnet ID for NIC creation subnetResp, err := subnetClient.Get(ctx, integrationTestResourceGroup, integrationTestVNetNameForPIP, integrationTestSubnetNameForPIP, nil) if err != nil { t.Fatalf("Failed to get subnet: %v", err) } // Create public IP address first (needed for NIC) err = createPublicIPAddress(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP address: %v", err) } // Wait for public IP to be available err = waitForPublicIPAvailable(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPName) if err != nil { t.Fatalf("Failed waiting for public IP to be available: %v", err) } // Get public IP ID for NIC creation publicIPResp, err := publicIPClient.Get(ctx, integrationTestResourceGroup, integrationTestPublicIPName, nil) if err != nil { t.Fatalf("Failed to get public IP address: %v", err) } // Create network interface with public IP err = createNetworkInterfaceWithPublicIP(ctx, nicClient, integrationTestResourceGroup, integrationTestNICNameForPIP, integrationTestLocation, *subnetResp.ID, *publicIPResp.ID) if err != nil { t.Fatalf("Failed to create network interface: %v", err) } log.Printf("Setup completed: Public IP address %s and network interface %s created", integrationTestPublicIPName, integrationTestNICNameForPIP) }) t.Run("Run", func(t *testing.T) { t.Run("GetPublicIPAddress", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving public IP address %s in subscription %s, resource group %s", integrationTestPublicIPName, subscriptionID, integrationTestResourceGroup) publicIPWrapper := manual.NewNetworkPublicIPAddress( clients.NewPublicIPAddressesClient(publicIPClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := publicIPWrapper.Scopes()[0] publicIPAdapter := sources.WrapperToAdapter(publicIPWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := publicIPAdapter.Get(ctx, scope, integrationTestPublicIPName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.NetworkPublicIPAddress.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPublicIPAddress, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "name" { t.Errorf("Expected unique attribute 'name', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestPublicIPName { t.Errorf("Expected unique attribute value %s, got %s", integrationTestPublicIPName, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved public IP address %s", integrationTestPublicIPName) }) t.Run("ListPublicIPAddresses", func(t *testing.T) { ctx := t.Context() log.Printf("Listing public IP addresses in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) publicIPWrapper := manual.NewNetworkPublicIPAddress( clients.NewPublicIPAddressesClient(publicIPClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := publicIPWrapper.Scopes()[0] publicIPAdapter := sources.WrapperToAdapter(publicIPWrapper, sdpcache.NewNoOpCache()) listable, ok := publicIPAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one public IP address, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { if v == integrationTestPublicIPName { found = true break } } } if !found { t.Fatalf("Expected to find public IP address %s in the list results", integrationTestPublicIPName) } log.Printf("Found %d public IP addresses in list results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for public IP address %s", integrationTestPublicIPName) publicIPWrapper := manual.NewNetworkPublicIPAddress( clients.NewPublicIPAddressesClient(publicIPClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := publicIPWrapper.Scopes()[0] publicIPAdapter := sources.WrapperToAdapter(publicIPWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := publicIPAdapter.Get(ctx, scope, integrationTestPublicIPName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (Network Interface should be linked via IPConfiguration) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasNetworkInterfaceLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkNetworkInterface.String() { hasNetworkInterfaceLink = true if liq.GetQuery().GetQuery() != integrationTestNICNameForPIP { t.Errorf("Expected linked query to network interface %s, got %s", integrationTestNICNameForPIP, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } break } } if !hasNetworkInterfaceLink { t.Error("Expected linked query to Network Interface, but didn't find one") } log.Printf("Verified %d linked item queries for public IP address %s", len(linkedQueries), integrationTestPublicIPName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete network interface first (it must be deleted before public IP can be deleted if associated) err := deleteNetworkInterface(ctx, nicClient, integrationTestResourceGroup, integrationTestNICNameForPIP) if err != nil { t.Fatalf("Failed to delete network interface: %v", err) } // Delete public IP address err = deletePublicIPAddress(ctx, publicIPClient, integrationTestResourceGroup, integrationTestPublicIPName) if err != nil { t.Fatalf("Failed to delete public IP address: %v", err) } // Delete VNet (this also deletes the subnet) err = deleteVirtualNetworkForPIP(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetNameForPIP) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } }) } // createVirtualNetworkForPIP creates an Azure virtual network with a default subnet (idempotent) func createVirtualNetworkForPIP(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { // Check if VNet already exists _, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil { log.Printf("Virtual network %s already exists, skipping creation", vnetName) return nil } // Create the VNet poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: new(location), Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.2.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new(integrationTestSubnetNameForPIP), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.2.0.0/24"), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), }, }, nil) if err != nil { return fmt.Errorf("failed to begin creating virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create virtual network: %w", err) } log.Printf("Virtual network %s created successfully", vnetName) return nil } // deleteVirtualNetworkForPIP deletes an Azure virtual network func deleteVirtualNetworkForPIP(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Virtual network %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting virtual network: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete virtual network: %w", err) } log.Printf("Virtual network %s deleted successfully", vnetName) return nil } // createPublicIPAddress creates an Azure public IP address (idempotent) func createPublicIPAddress(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName, location string) error { // Check if public IP already exists _, err := client.Get(ctx, resourceGroupName, publicIPName, nil) if err == nil { log.Printf("Public IP address %s already exists, skipping creation", publicIPName) return nil } // Create the public IP address poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, publicIPName, armnetwork.PublicIPAddress{ Location: new(location), Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), }, SKU: &armnetwork.PublicIPAddressSKU{ Name: new(armnetwork.PublicIPAddressSKUNameStandard), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-public-ip-address"), }, }, nil) if err != nil { // Check if public IP already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Public IP address %s already exists, skipping creation", publicIPName) return nil } return fmt.Errorf("failed to begin creating public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create public IP address: %w", err) } log.Printf("Public IP address %s created successfully", publicIPName) return nil } // waitForPublicIPAvailable waits for a public IP address to be fully available func waitForPublicIPAvailable(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for public IP address %s to be available via API...", publicIPName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, publicIPName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP address %s not yet available (attempt %d/%d), waiting %v...", publicIPName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking public IP address availability: %w", err) } // If we can get the public IP and it has an IP address assigned, it's available if resp.Properties != nil && resp.Properties.IPAddress != nil && *resp.Properties.IPAddress != "" { log.Printf("Public IP address %s is available with IP: %s", publicIPName, *resp.Properties.IPAddress) return nil } // Still provisioning, wait and retry log.Printf("Public IP address %s still provisioning (attempt %d/%d), waiting...", publicIPName, attempt, maxAttempts) time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for public IP address %s to be available after %d attempts", publicIPName, maxAttempts) } // deletePublicIPAddress deletes an Azure public IP address func deletePublicIPAddress(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, publicIPName string) error { // Check if public IP exists _, err := client.Get(ctx, resourceGroupName, publicIPName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP address %s does not exist, skipping deletion", publicIPName) return nil } return fmt.Errorf("error checking public IP address existence: %w", err) } // Delete the public IP address poller, err := client.BeginDelete(ctx, resourceGroupName, publicIPName, nil) if err != nil { return fmt.Errorf("failed to begin deleting public IP address: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete public IP address: %w", err) } log.Printf("Public IP address %s deleted successfully", publicIPName) return nil } // createNetworkInterfaceWithPublicIP creates an Azure network interface with a public IP address (idempotent) func createNetworkInterfaceWithPublicIP(ctx context.Context, client *armnetwork.InterfacesClient, resourceGroupName, nicName, location, subnetID, publicIPID string) error { // Check if NIC already exists _, err := client.Get(ctx, resourceGroupName, nicName, nil) if err == nil { log.Printf("Network interface %s already exists, skipping creation", nicName) return nil } // Create the NIC poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, nicName, armnetwork.Interface{ Location: new(location), Properties: &armnetwork.InterfacePropertiesFormat{ IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new("ipconfig1"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-public-ip-address"), }, }, nil) if err != nil { // Check if NIC already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Network interface %s already exists, skipping creation", nicName) return nil } return fmt.Errorf("failed to begin creating network interface: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create network interface: %w", err) } log.Printf("Network interface %s created successfully", nicName) return nil } ================================================ FILE: sources/azure/integration-tests/network-route-table_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestRouteTableName = "ovm-integ-test-route-table" integrationTestRouteName = "ovm-integ-test-route" ) func TestNetworkRouteTableIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients routeTableClient, err := armnetwork.NewRouteTablesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Route Tables client: %v", err) } routesClient, err := armnetwork.NewRoutesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Routes client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create route table err = createRouteTable(ctx, routeTableClient, integrationTestResourceGroup, integrationTestRouteTableName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create route table: %v", err) } // Wait for route table to be fully available err = waitForRouteTableAvailable(ctx, routeTableClient, integrationTestResourceGroup, integrationTestRouteTableName) if err != nil { t.Fatalf("Failed waiting for route table to be available: %v", err) } // Create a route in the route table err = createRoute(ctx, routesClient, integrationTestResourceGroup, integrationTestRouteTableName, integrationTestRouteName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create route: %v", err) } // Wait for route to be available err = waitForRouteAvailable(ctx, routesClient, integrationTestResourceGroup, integrationTestRouteTableName, integrationTestRouteName) if err != nil { t.Fatalf("Failed waiting for route to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetRouteTable", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving route table %s in subscription %s, resource group %s", integrationTestRouteTableName, subscriptionID, integrationTestResourceGroup) routeTableWrapper := manual.NewNetworkRouteTable( clients.NewRouteTablesClient(routeTableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := routeTableWrapper.Scopes()[0] routeTableAdapter := sources.WrapperToAdapter(routeTableWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := routeTableAdapter.Get(ctx, scope, integrationTestRouteTableName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestRouteTableName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestRouteTableName, uniqueAttrValue) } log.Printf("Successfully retrieved route table %s", integrationTestRouteTableName) }) t.Run("ListRouteTables", func(t *testing.T) { ctx := t.Context() log.Printf("Listing route tables in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) routeTableWrapper := manual.NewNetworkRouteTable( clients.NewRouteTablesClient(routeTableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := routeTableWrapper.Scopes()[0] routeTableAdapter := sources.WrapperToAdapter(routeTableWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := routeTableAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list route tables: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one route table, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestRouteTableName { found = true break } } if !found { t.Fatalf("Expected to find route table %s in the list of route tables", integrationTestRouteTableName) } log.Printf("Found %d route tables in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for route table %s", integrationTestRouteTableName) routeTableWrapper := manual.NewNetworkRouteTable( clients.NewRouteTablesClient(routeTableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := routeTableWrapper.Scopes()[0] routeTableAdapter := sources.WrapperToAdapter(routeTableWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := routeTableAdapter.Get(ctx, scope, integrationTestRouteTableName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.NetworkRouteTable.String() { t.Errorf("Expected item type %s, got %s", azureshared.NetworkRouteTable, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for route table %s", integrationTestRouteTableName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for route table %s", integrationTestRouteTableName) routeTableWrapper := manual.NewNetworkRouteTable( clients.NewRouteTablesClient(routeTableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := routeTableWrapper.Scopes()[0] routeTableAdapter := sources.WrapperToAdapter(routeTableWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := routeTableAdapter.Get(ctx, scope, integrationTestRouteTableName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (if any) linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for route table %s", len(linkedQueries), integrationTestRouteTableName) // Verify the structure is correct if links exist for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } // Verify query has required fields if query.GetType() == "" { t.Error("Linked item query has empty Type") } // Method should be GET or SEARCH (not empty) if query.GetMethod() == sdp.QueryMethod_GET || query.GetMethod() == sdp.QueryMethod_SEARCH { // Valid method } else { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } // Verify that routes are linked (we created one named integrationTestRouteName) var hasRouteLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkRoute.String() { hasRouteLink = true // Verify the query contains the route table name and route name query := liq.GetQuery().GetQuery() if query == "" { t.Error("Expected route query to be non-empty") } log.Printf("Found route link with query: %s", query) break } } if !hasRouteLink { t.Error("Expected linked query to routes, but didn't find one") } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete route err := deleteRoute(ctx, routesClient, integrationTestResourceGroup, integrationTestRouteTableName, integrationTestRouteName) if err != nil { t.Fatalf("Failed to delete route: %v", err) } // Delete route table err = deleteRouteTable(ctx, routeTableClient, integrationTestResourceGroup, integrationTestRouteTableName) if err != nil { t.Fatalf("Failed to delete route table: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createRouteTable creates an Azure route table (idempotent) func createRouteTable(ctx context.Context, client *armnetwork.RouteTablesClient, resourceGroupName, routeTableName, location string) error { // Check if route table already exists existingRouteTable, err := client.Get(ctx, resourceGroupName, routeTableName, nil) if err == nil { // Route table exists, check its provisioning state if existingRouteTable.Properties != nil && existingRouteTable.Properties.ProvisioningState != nil { state := *existingRouteTable.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Route table %s already exists with state %s, skipping creation", routeTableName, state) return nil } log.Printf("Route table %s exists but in state %s, will wait for it", routeTableName, state) } else { log.Printf("Route table %s already exists, skipping creation", routeTableName) return nil } } // Create a basic route table poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, routeTableName, armnetwork.RouteTable{ Location: new(location), Properties: &armnetwork.RouteTablePropertiesFormat{ // Routes will be added separately as child resources }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-route-table"), }, }, nil) if err != nil { // Check if route table already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Route table %s already exists (conflict), skipping creation", routeTableName) return nil } return fmt.Errorf("failed to begin creating route table: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create route table: %w", err) } // Verify the route table was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("route table created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("route table provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Route table %s created successfully with provisioning state: %s", routeTableName, provisioningState) return nil } // waitForRouteTableAvailable polls until the route table is available via the Get API // This is needed because even after creation succeeds, there can be a delay before the route table is queryable func waitForRouteTableAvailable(ctx context.Context, client *armnetwork.RouteTablesClient, resourceGroupName, routeTableName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for route table %s to be available via API...", routeTableName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, routeTableName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Route table %s not yet available (attempt %d/%d), waiting %v...", routeTableName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking route table availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Route table %s is available with provisioning state: %s", routeTableName, state) return nil } if state == "Failed" { return fmt.Errorf("route table provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Route table %s provisioning state: %s (attempt %d/%d), waiting...", routeTableName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Route table exists but no provisioning state - consider it available log.Printf("Route table %s is available", routeTableName) return nil } return fmt.Errorf("timeout waiting for route table %s to be available after %d attempts", routeTableName, maxAttempts) } // createRoute creates a route in a route table (idempotent) func createRoute(ctx context.Context, client *armnetwork.RoutesClient, resourceGroupName, routeTableName, routeName, location string) error { // Check if route already exists existingRoute, err := client.Get(ctx, resourceGroupName, routeTableName, routeName, nil) if err == nil { // Route exists, check its provisioning state if existingRoute.Properties != nil && existingRoute.Properties.ProvisioningState != nil { state := *existingRoute.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Route %s already exists with state %s, skipping creation", routeName, state) return nil } log.Printf("Route %s exists but in state %s, will wait for it", routeName, state) } else { log.Printf("Route %s already exists, skipping creation", routeName) return nil } } // Create a route with VirtualAppliance next hop type and a sample IP address // This creates a route that will link to a NetworkIP poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, routeTableName, routeName, armnetwork.Route{ Properties: &armnetwork.RoutePropertiesFormat{ AddressPrefix: new("10.0.0.0/8"), NextHopType: new(armnetwork.RouteNextHopTypeVirtualAppliance), NextHopIPAddress: new("10.0.0.1"), // This will create a link to stdlib.NetworkIP }, }, nil) if err != nil { // Check if route already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Route %s already exists (conflict), skipping creation", routeName) return nil } return fmt.Errorf("failed to begin creating route: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create route: %w", err) } // Verify the route was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("route created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != "Succeeded" { return fmt.Errorf("route provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Route %s created successfully with provisioning state: %s", routeName, provisioningState) return nil } // waitForRouteAvailable polls until the route is available via the Get API func waitForRouteAvailable(ctx context.Context, client *armnetwork.RoutesClient, resourceGroupName, routeTableName, routeName string) error { maxAttempts := 20 pollInterval := 5 * time.Second log.Printf("Waiting for route %s to be available via API...", routeName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, routeTableName, routeName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Route %s not yet available (attempt %d/%d), waiting %v...", routeName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking route availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == "Succeeded" { log.Printf("Route %s is available with provisioning state: %s", routeName, state) return nil } if state == "Failed" { return fmt.Errorf("route provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Route %s provisioning state: %s (attempt %d/%d), waiting...", routeName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Route exists but no provisioning state - consider it available log.Printf("Route %s is available", routeName) return nil } return fmt.Errorf("timeout waiting for route %s to be available after %d attempts", routeName, maxAttempts) } // deleteRoute deletes a route from a route table func deleteRoute(ctx context.Context, client *armnetwork.RoutesClient, resourceGroupName, routeTableName, routeName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, routeTableName, routeName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Route %s not found, skipping deletion", routeName) return nil } return fmt.Errorf("failed to begin deleting route: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete route: %w", err) } log.Printf("Route %s deleted successfully", routeName) return nil } // deleteRouteTable deletes an Azure route table func deleteRouteTable(ctx context.Context, client *armnetwork.RouteTablesClient, resourceGroupName, routeTableName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, routeTableName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Route table %s not found, skipping deletion", routeTableName) return nil } return fmt.Errorf("failed to begin deleting route table: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete route table: %w", err) } log.Printf("Route table %s deleted successfully", routeTableName) return nil } ================================================ FILE: sources/azure/integration-tests/network-virtual-network-gateway-connection_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestVPNConnectionName = "ovm-integ-test-vpn-conn" integrationTestVPNVNetName = "ovm-integ-test-vpn-vnet" integrationTestVPNSubnetName = "GatewaySubnet" integrationTestVPNGatewayName = "ovm-integ-test-vpn-gw" integrationTestVPNPublicIPName = "ovm-integ-test-vpn-pip" integrationTestVPNLocalGWName = "ovm-integ-test-vpn-lgw" ) func TestNetworkVirtualNetworkGatewayConnectionIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } vpnConnectionsClient, err := armnetwork.NewVirtualNetworkGatewayConnectionsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create VPN Connections client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } subnetClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Subnets client: %v", err) } publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Public IP Addresses client: %v", err) } vpnGatewayClient, err := armnetwork.NewVirtualNetworkGatewaysClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create VPN Gateways client: %v", err) } localGatewayClient, err := armnetwork.NewLocalNetworkGatewaysClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Local Network Gateways client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 55*time.Minute) defer cancel() err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } err = createVPNTestVNet(ctx, vnetClient, integrationTestResourceGroup, integrationTestVPNVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create VNet: %v", err) } err = createVPNGatewaySubnet(ctx, subnetClient, integrationTestResourceGroup, integrationTestVPNVNetName, integrationTestVPNSubnetName) if err != nil { t.Fatalf("Failed to create GatewaySubnet: %v", err) } err = createVPNPublicIP(ctx, publicIPClient, integrationTestResourceGroup, integrationTestVPNPublicIPName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create public IP: %v", err) } err = waitForVPNPublicIPAvailable(ctx, publicIPClient, integrationTestResourceGroup, integrationTestVPNPublicIPName) if err != nil { t.Fatalf("Failed waiting for public IP: %v", err) } err = createVPNLocalGateway(ctx, localGatewayClient, integrationTestResourceGroup, integrationTestVPNLocalGWName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create local network gateway: %v", err) } err = waitForVPNLocalGatewayAvailable(ctx, localGatewayClient, integrationTestResourceGroup, integrationTestVPNLocalGWName) if err != nil { t.Fatalf("Failed waiting for local gateway: %v", err) } err = createVPNGateway(ctx, vpnGatewayClient, subscriptionID, integrationTestResourceGroup, integrationTestVPNGatewayName, integrationTestVPNVNetName, integrationTestVPNSubnetName, integrationTestVPNPublicIPName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create VPN gateway: %v", err) } err = waitForVPNGatewayAvailable(ctx, vpnGatewayClient, integrationTestResourceGroup, integrationTestVPNGatewayName) if err != nil { t.Fatalf("Failed waiting for VPN gateway: %v", err) } err = createVPNConnection(ctx, vpnConnectionsClient, subscriptionID, integrationTestResourceGroup, integrationTestVPNConnectionName, integrationTestVPNGatewayName, integrationTestVPNLocalGWName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create VPN connection: %v", err) } err = waitForVPNConnectionAvailable(ctx, vpnConnectionsClient, integrationTestResourceGroup, integrationTestVPNConnectionName) if err != nil { t.Fatalf("Failed waiting for VPN connection: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetVPNConnection", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving VPN connection %s in subscription %s, resource group %s", integrationTestVPNConnectionName, subscriptionID, integrationTestResourceGroup) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection( clients.NewVirtualNetworkGatewayConnectionsClient(vpnConnectionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestVPNConnectionName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestVPNConnectionName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestVPNConnectionName, uniqueAttrValue) } log.Printf("Successfully retrieved VPN connection %s", integrationTestVPNConnectionName) }) t.Run("ListVPNConnections", func(t *testing.T) { ctx := t.Context() log.Printf("Listing VPN connections in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection( clients.NewVirtualNetworkGatewayConnectionsClient(vpnConnectionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list VPN connections: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one VPN connection, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestVPNConnectionName { found = true break } } if !found { t.Fatalf("Expected to find VPN connection %s in the list", integrationTestVPNConnectionName) } log.Printf("Found %d VPN connections in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for VPN connection %s", integrationTestVPNConnectionName) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection( clients.NewVirtualNetworkGatewayConnectionsClient(vpnConnectionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestVPNConnectionName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkVirtualNetworkGatewayConnection.String() { t.Errorf("Expected item type %s, got %s", azureshared.NetworkVirtualNetworkGatewayConnection, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for VPN connection %s", integrationTestVPNConnectionName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for VPN connection %s", integrationTestVPNConnectionName) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection( clients.NewVirtualNetworkGatewayConnectionsClient(vpnConnectionsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, integrationTestVPNConnectionName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for VPN connection %s", len(linkedQueries), integrationTestVPNConnectionName) if len(linkedQueries) < 1 { t.Error("Expected at least one linked item query (VirtualNetworkGateway1)") } var hasVNGLink, hasLNGLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } if query.GetType() == "" { t.Error("Linked item query has empty Type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } if query.GetType() == azureshared.NetworkVirtualNetworkGateway.String() { hasVNGLink = true } if query.GetType() == azureshared.NetworkLocalNetworkGateway.String() { hasLNGLink = true } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } if !hasVNGLink { t.Error("Expected a linked item query for VirtualNetworkGateway") } if !hasLNGLink { t.Error("Expected a linked item query for LocalNetworkGateway") } }) }) t.Run("Teardown", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 30*time.Minute) defer cancel() err := deleteVPNConnection(ctx, vpnConnectionsClient, integrationTestResourceGroup, integrationTestVPNConnectionName) if err != nil { log.Printf("Warning: Failed to delete VPN connection: %v", err) } err = deleteVPNGateway(ctx, vpnGatewayClient, integrationTestResourceGroup, integrationTestVPNGatewayName) if err != nil { log.Printf("Warning: Failed to delete VPN gateway: %v", err) } err = deleteVPNLocalGateway(ctx, localGatewayClient, integrationTestResourceGroup, integrationTestVPNLocalGWName) if err != nil { log.Printf("Warning: Failed to delete local gateway: %v", err) } err = deleteVPNPublicIP(ctx, publicIPClient, integrationTestResourceGroup, integrationTestVPNPublicIPName) if err != nil { log.Printf("Warning: Failed to delete public IP: %v", err) } err = deleteVPNVNet(ctx, vnetClient, integrationTestResourceGroup, integrationTestVPNVNetName) if err != nil { log.Printf("Warning: Failed to delete VNet: %v", err) } }) } func createVPNTestVNet(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName, location string) error { existingVNet, err := client.Get(ctx, resourceGroupName, vnetName, nil) if err == nil && existingVNet.Properties != nil { log.Printf("VNet %s already exists, skipping creation", vnetName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, armnetwork.VirtualNetwork{ Location: &location, Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.200.0.0/16")}, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-virtual-network-gateway-connection"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("VNet %s already exists (conflict), skipping creation", vnetName) return nil } return fmt.Errorf("failed to begin creating VNet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create VNet: %w", err) } log.Printf("VNet %s created successfully", vnetName) return nil } func createVPNGatewaySubnet(ctx context.Context, client *armnetwork.SubnetsClient, resourceGroupName, vnetName, subnetName string) error { existingSubnet, err := client.Get(ctx, resourceGroupName, vnetName, subnetName, nil) if err == nil && existingSubnet.Properties != nil { log.Printf("Subnet %s already exists, skipping creation", subnetName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, vnetName, subnetName, armnetwork.Subnet{ Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.200.255.0/27"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Subnet %s already exists (conflict), skipping creation", subnetName) return nil } return fmt.Errorf("failed to begin creating subnet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create subnet: %w", err) } log.Printf("Subnet %s created successfully", subnetName) return nil } func createVPNPublicIP(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, pipName, location string) error { existingPIP, err := client.Get(ctx, resourceGroupName, pipName, nil) if err == nil && existingPIP.Properties != nil { log.Printf("Public IP %s already exists, skipping creation", pipName) return nil } allocMethodStatic := armnetwork.IPAllocationMethodStatic skuNameStandard := armnetwork.PublicIPAddressSKUNameStandard poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, pipName, armnetwork.PublicIPAddress{ Location: &location, Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAllocationMethod: &allocMethodStatic, }, SKU: &armnetwork.PublicIPAddressSKU{ Name: &skuNameStandard, }, Zones: []*string{new("1"), new("2"), new("3")}, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-virtual-network-gateway-connection"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Public IP %s already exists (conflict), skipping creation", pipName) return nil } return fmt.Errorf("failed to begin creating public IP: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create public IP: %w", err) } log.Printf("Public IP %s created successfully", pipName) return nil } func waitForVPNPublicIPAvailable(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, pipName string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, pipName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP %s not yet available (attempt %d/%d)", pipName, attempt, maxAttempts) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking public IP: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armnetwork.ProvisioningStateSucceeded { log.Printf("Public IP %s is available", pipName) return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for public IP %s", pipName) } func createVPNLocalGateway(ctx context.Context, client *armnetwork.LocalNetworkGatewaysClient, resourceGroupName, gatewayName, location string) error { existingGW, err := client.Get(ctx, resourceGroupName, gatewayName, nil) if err == nil && existingGW.Properties != nil { log.Printf("Local network gateway %s already exists, skipping creation", gatewayName) return nil } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, gatewayName, armnetwork.LocalNetworkGateway{ Location: &location, Properties: &armnetwork.LocalNetworkGatewayPropertiesFormat{ GatewayIPAddress: new("203.0.113.1"), LocalNetworkAddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.100.0.0/16")}, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-virtual-network-gateway-connection"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Local network gateway %s already exists (conflict), skipping creation", gatewayName) return nil } return fmt.Errorf("failed to begin creating local network gateway: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create local network gateway: %w", err) } log.Printf("Local network gateway %s created successfully", gatewayName) return nil } func waitForVPNLocalGatewayAvailable(ctx context.Context, client *armnetwork.LocalNetworkGatewaysClient, resourceGroupName, gatewayName string) error { maxAttempts := 20 pollInterval := 5 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, gatewayName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Local network gateway %s not yet available (attempt %d/%d)", gatewayName, attempt, maxAttempts) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking local network gateway: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armnetwork.ProvisioningStateSucceeded { log.Printf("Local network gateway %s is available", gatewayName) return nil } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for local network gateway %s", gatewayName) } func createVPNGateway(ctx context.Context, client *armnetwork.VirtualNetworkGatewaysClient, subscriptionID, resourceGroupName, gatewayName, vnetName, subnetName, pipName, location string) error { existingGW, err := client.Get(ctx, resourceGroupName, gatewayName, nil) if err == nil && existingGW.Properties != nil { log.Printf("VPN gateway %s already exists, skipping creation", gatewayName) return nil } subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s/subnets/%s", subscriptionID, resourceGroupName, vnetName, subnetName) pipID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/publicIPAddresses/%s", subscriptionID, resourceGroupName, pipName) gatewayTypeVPN := armnetwork.VirtualNetworkGatewayTypeVPN vpnTypeRouteBased := armnetwork.VPNTypeRouteBased skuNameVPNGw1AZ := armnetwork.VirtualNetworkGatewaySKUNameVPNGw1AZ skuTierVPNGw1AZ := armnetwork.VirtualNetworkGatewaySKUTierVPNGw1AZ allocMethodDynamic := armnetwork.IPAllocationMethodDynamic poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, gatewayName, armnetwork.VirtualNetworkGateway{ Location: &location, Properties: &armnetwork.VirtualNetworkGatewayPropertiesFormat{ GatewayType: &gatewayTypeVPN, VPNType: &vpnTypeRouteBased, SKU: &armnetwork.VirtualNetworkGatewaySKU{ Name: &skuNameVPNGw1AZ, Tier: &skuTierVPNGw1AZ, }, IPConfigurations: []*armnetwork.VirtualNetworkGatewayIPConfiguration{ { Name: new("default"), Properties: &armnetwork.VirtualNetworkGatewayIPConfigurationPropertiesFormat{ PrivateIPAllocationMethod: &allocMethodDynamic, Subnet: &armnetwork.SubResource{ ID: &subnetID, }, PublicIPAddress: &armnetwork.SubResource{ ID: &pipID, }, }, }, }, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-virtual-network-gateway-connection"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("VPN gateway %s already exists (conflict), skipping creation", gatewayName) return nil } return fmt.Errorf("failed to begin creating VPN gateway: %w", err) } log.Printf("VPN gateway %s creation started, this may take 20-45 minutes...", gatewayName) _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create VPN gateway: %w", err) } log.Printf("VPN gateway %s created successfully", gatewayName) return nil } func waitForVPNGatewayAvailable(ctx context.Context, client *armnetwork.VirtualNetworkGatewaysClient, resourceGroupName, gatewayName string) error { maxAttempts := 60 pollInterval := 30 * time.Second log.Printf("Waiting for VPN gateway %s to be available (this may take 20-45 minutes)...", gatewayName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, gatewayName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("VPN gateway %s not yet available (attempt %d/%d)", gatewayName, attempt, maxAttempts) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking VPN gateway: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == armnetwork.ProvisioningStateSucceeded { log.Printf("VPN gateway %s is available", gatewayName) return nil } if state == armnetwork.ProvisioningStateFailed { return fmt.Errorf("VPN gateway %s provisioning failed", gatewayName) } log.Printf("VPN gateway %s state: %s (attempt %d/%d)", gatewayName, state, attempt, maxAttempts) } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for VPN gateway %s", gatewayName) } func createVPNConnection(ctx context.Context, client *armnetwork.VirtualNetworkGatewayConnectionsClient, subscriptionID, resourceGroupName, connectionName, gatewayName, localGatewayName, location string) error { existingConn, err := client.Get(ctx, resourceGroupName, connectionName, nil) if err == nil && existingConn.Properties != nil { log.Printf("VPN connection %s already exists, skipping creation", connectionName) return nil } vpnGatewayID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworkGateways/%s", subscriptionID, resourceGroupName, gatewayName) localGatewayID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/localNetworkGateways/%s", subscriptionID, resourceGroupName, localGatewayName) connTypeIPsec := armnetwork.VirtualNetworkGatewayConnectionTypeIPsec poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, connectionName, armnetwork.VirtualNetworkGatewayConnection{ Location: &location, Properties: &armnetwork.VirtualNetworkGatewayConnectionPropertiesFormat{ ConnectionType: &connTypeIPsec, VirtualNetworkGateway1: &armnetwork.VirtualNetworkGateway{ ID: &vpnGatewayID, }, LocalNetworkGateway2: &armnetwork.LocalNetworkGateway{ ID: &localGatewayID, }, SharedKey: new("overmind-test-key-12345"), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("network-virtual-network-gateway-connection"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("VPN connection %s already exists (conflict), skipping creation", connectionName) return nil } return fmt.Errorf("failed to begin creating VPN connection: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create VPN connection: %w", err) } log.Printf("VPN connection %s created successfully", connectionName) return nil } func waitForVPNConnectionAvailable(ctx context.Context, client *armnetwork.VirtualNetworkGatewayConnectionsClient, resourceGroupName, connectionName string) error { maxAttempts := 30 pollInterval := 10 * time.Second for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, connectionName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("VPN connection %s not yet available (attempt %d/%d)", connectionName, attempt, maxAttempts) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking VPN connection: %w", err) } if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == armnetwork.ProvisioningStateSucceeded { log.Printf("VPN connection %s is available", connectionName) return nil } if state == armnetwork.ProvisioningStateFailed { return fmt.Errorf("VPN connection %s provisioning failed", connectionName) } log.Printf("VPN connection %s state: %s (attempt %d/%d)", connectionName, state, attempt, maxAttempts) } time.Sleep(pollInterval) } return fmt.Errorf("timeout waiting for VPN connection %s", connectionName) } func deleteVPNConnection(ctx context.Context, client *armnetwork.VirtualNetworkGatewayConnectionsClient, resourceGroupName, connectionName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, connectionName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("VPN connection %s not found, skipping deletion", connectionName) return nil } return fmt.Errorf("failed to begin deleting VPN connection: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete VPN connection: %w", err) } log.Printf("VPN connection %s deleted successfully", connectionName) return nil } func deleteVPNGateway(ctx context.Context, client *armnetwork.VirtualNetworkGatewaysClient, resourceGroupName, gatewayName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, gatewayName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("VPN gateway %s not found, skipping deletion", gatewayName) return nil } return fmt.Errorf("failed to begin deleting VPN gateway: %w", err) } log.Printf("VPN gateway %s deletion started, this may take several minutes...", gatewayName) _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete VPN gateway: %w", err) } log.Printf("VPN gateway %s deleted successfully", gatewayName) return nil } func deleteVPNLocalGateway(ctx context.Context, client *armnetwork.LocalNetworkGatewaysClient, resourceGroupName, gatewayName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, gatewayName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Local network gateway %s not found, skipping deletion", gatewayName) return nil } return fmt.Errorf("failed to begin deleting local network gateway: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete local network gateway: %w", err) } log.Printf("Local network gateway %s deleted successfully", gatewayName) return nil } func deleteVPNPublicIP(ctx context.Context, client *armnetwork.PublicIPAddressesClient, resourceGroupName, pipName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, pipName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Public IP %s not found, skipping deletion", pipName) return nil } return fmt.Errorf("failed to begin deleting public IP: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete public IP: %w", err) } log.Printf("Public IP %s deleted successfully", pipName) return nil } func deleteVPNVNet(ctx context.Context, client *armnetwork.VirtualNetworksClient, resourceGroupName, vnetName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, vnetName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("VNet %s not found, skipping deletion", vnetName) return nil } return fmt.Errorf("failed to begin deleting VNet: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete VNet: %w", err) } log.Printf("VNet %s deleted successfully", vnetName) return nil } ================================================ FILE: sources/azure/integration-tests/network-virtual-network_test.go ================================================ package integrationtests import ( "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) func TestNetworkVirtualNetworkIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } vnetClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Virtual Networks client: %v", err) } t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create virtual network err = createVirtualNetwork(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create virtual network: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetVirtualNetwork", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving virtual network %s in subscription %s, resource group %s", integrationTestVNetName, subscriptionID, integrationTestResourceGroup) vnetWrapper := manual.NewNetworkVirtualNetwork( clients.NewVirtualNetworksClient(vnetClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vnetWrapper.Scopes()[0] vnetAdapter := sources.WrapperToAdapter(vnetWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := vnetAdapter.Get(ctx, scope, integrationTestVNetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestVNetName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestVNetName, uniqueAttrValue) } log.Printf("Successfully retrieved virtual network %s", integrationTestVNetName) }) t.Run("ListVirtualNetworks", func(t *testing.T) { ctx := t.Context() log.Printf("Listing virtual networks in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) vnetWrapper := manual.NewNetworkVirtualNetwork( clients.NewVirtualNetworksClient(vnetClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vnetWrapper.Scopes()[0] vnetAdapter := sources.WrapperToAdapter(vnetWrapper, sdpcache.NewNoOpCache()) listable, ok := vnetAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) == 0 { t.Fatalf("Expected at least 1 virtual network, got: %d", len(sdpItems)) } // Find our test VNet var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestVNetName { found = true break } } if !found { t.Fatalf("Expected to find virtual network %s in list, but didn't", integrationTestVNetName) } log.Printf("Successfully listed %d virtual networks", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() vnetWrapper := manual.NewNetworkVirtualNetwork( clients.NewVirtualNetworksClient(vnetClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := vnetWrapper.Scopes()[0] vnetAdapter := sources.WrapperToAdapter(vnetWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := vnetAdapter.Get(ctx, scope, integrationTestVNetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify type if sdpItem.GetType() != azureshared.NetworkVirtualNetwork.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkVirtualNetwork.String(), sdpItem.GetType()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify linked item queries linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected at least one linked item query, got: %d", len(linkedQueries)) } // Verify subnet link var hasSubnetLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkSubnet.String() { hasSubnetLink = true if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected subnet link method to be SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() != integrationTestVNetName { t.Errorf("Expected subnet link query to be %s, got %s", integrationTestVNetName, liq.GetQuery().GetQuery()) } break } } if !hasSubnetLink { t.Error("Expected linked query to subnet, but didn't find one") } // Verify peering link var hasPeeringLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.NetworkVirtualNetworkPeering.String() { hasPeeringLink = true if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected peering link method to be SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() != integrationTestVNetName { t.Errorf("Expected peering link query to be %s, got %s", integrationTestVNetName, liq.GetQuery().GetQuery()) } break } } if !hasPeeringLink { t.Error("Expected linked query to virtual network peering, but didn't find one") } log.Printf("Verified %d linked item queries for VNet %s", len(linkedQueries), integrationTestVNetName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete VNet (this also deletes the subnet) // Note: deleteVirtualNetwork is already defined in compute-virtual-machine_test.go err := deleteVirtualNetwork(ctx, vnetClient, integrationTestResourceGroup, integrationTestVNetName) if err != nil { t.Fatalf("Failed to delete virtual network: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } ================================================ FILE: sources/azure/integration-tests/network-zone_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestZoneName = "ovm-integ-test-zone.com" ) func TestNetworkZoneIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients zonesClient, err := armdns.NewZonesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create DNS Zones client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique zone name (DNS zone names must be globally unique) zoneName := generateZoneName(integrationTestZoneName) t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create DNS zone err = createDNSZone(ctx, zonesClient, integrationTestResourceGroup, zoneName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create DNS zone: %v", err) } // Wait for DNS zone to be available err = waitForDNSZoneAvailable(ctx, zonesClient, integrationTestResourceGroup, zoneName) if err != nil { t.Fatalf("Failed waiting for DNS zone to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetDNSZone", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving DNS zone %s in subscription %s, resource group %s", zoneName, subscriptionID, integrationTestResourceGroup) zoneWrapper := manual.NewNetworkZone( clients.NewZonesClient(zonesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := zoneWrapper.Scopes()[0] zoneAdapter := sources.WrapperToAdapter(zoneWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := zoneAdapter.Get(ctx, scope, zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.NetworkZone.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkZone.String(), sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "name" { t.Errorf("Expected unique attribute 'name', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != zoneName { t.Errorf("Expected unique attribute value %s, got %s", zoneName, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved DNS zone %s", zoneName) }) t.Run("ListDNSZones", func(t *testing.T) { ctx := t.Context() log.Printf("Listing DNS zones in resource group %s", integrationTestResourceGroup) zoneWrapper := manual.NewNetworkZone( clients.NewZonesClient(zonesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := zoneWrapper.Scopes()[0] zoneAdapter := sources.WrapperToAdapter(zoneWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list listable, ok := zoneAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list DNS zones: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one DNS zone, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { if v == zoneName { found = true break } } } if !found { t.Fatalf("Expected to find DNS zone %s in the list results", zoneName) } log.Printf("Found %d DNS zones in list results", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for DNS zone %s", zoneName) zoneWrapper := manual.NewNetworkZone( clients.NewZonesClient(zonesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := zoneWrapper.Scopes()[0] zoneAdapter := sources.WrapperToAdapter(zoneWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := zoneAdapter.Get(ctx, scope, zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.NetworkZone.String() { t.Errorf("Expected item type %s, got %s", azureshared.NetworkZone.String(), sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for DNS zone %s", zoneName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for DNS zone %s", zoneName) zoneWrapper := manual.NewNetworkZone( clients.NewZonesClient(zonesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := zoneWrapper.Scopes()[0] zoneAdapter := sources.WrapperToAdapter(zoneWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := zoneAdapter.Get(ctx, scope, zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected child resource links exist expectedChildResources := map[string]bool{ azureshared.NetworkDNSRecordSet.String(): false, } // Track found resources var hasDNSRecordSetLink bool var hasNameServerLinks bool for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() query := liq.GetQuery().GetQuery() method := liq.GetQuery().GetMethod() linkedScope := liq.GetQuery().GetScope() // Verify DNS Record Set link (child resource) if linkedType == azureshared.NetworkDNSRecordSet.String() { hasDNSRecordSetLink = true if expectedChildResources[linkedType] { t.Errorf("Found duplicate linked query for type %s", linkedType) } expectedChildResources[linkedType] = true if method != sdp.QueryMethod_SEARCH { t.Errorf("Expected linked query method SEARCH for %s, got %s", linkedType, method) } if query != zoneName { t.Errorf("Expected linked query to use zone name %s, got %s", zoneName, query) } if linkedScope != scope { t.Errorf("Expected linked query scope %s, got %s", scope, linkedScope) } } // Verify DNS name server links (standard library) if linkedType == "dns" { hasNameServerLinks = true if method != sdp.QueryMethod_SEARCH { t.Errorf("Expected linked query method SEARCH for DNS name server, got %s", method) } if linkedScope != "global" { t.Errorf("Expected linked query scope 'global' for DNS name server, got %s", linkedScope) } } // Verify Virtual Network links (if present) if linkedType == azureshared.NetworkVirtualNetwork.String() { if method != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET for Virtual Network, got %s", method) } } } // Check that all expected child resources are linked if !hasDNSRecordSetLink { t.Error("Expected linked query to DNS Record Set, but didn't find one") } // Name servers should be present (Azure automatically assigns them) if !hasNameServerLinks { t.Error("Expected linked queries to DNS name servers, but didn't find any") } log.Printf("Verified %d linked item queries for DNS zone %s", len(linkedQueries), zoneName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete DNS zone err := deleteDNSZone(ctx, zonesClient, integrationTestResourceGroup, zoneName) if err != nil { t.Fatalf("Failed to delete DNS zone: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // generateZoneName generates a unique DNS zone name by appending a timestamp // DNS zone names must be globally unique func generateZoneName(baseName string) string { return fmt.Sprintf("%s-%d", baseName, time.Now().Unix()) } // createDNSZone creates an Azure DNS zone (idempotent) func createDNSZone(ctx context.Context, client *armdns.ZonesClient, resourceGroupName, zoneName, location string) error { // Check if zone already exists _, err := client.Get(ctx, resourceGroupName, zoneName, nil) if err == nil { log.Printf("DNS zone %s already exists, skipping creation", zoneName) return nil } // Create the DNS zone resp, err := client.CreateOrUpdate(ctx, resourceGroupName, zoneName, armdns.Zone{ Location: new(location), Properties: &armdns.ZoneProperties{ ZoneType: new(armdns.ZoneTypePublic), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { // Check if zone already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("DNS zone %s already exists (conflict), skipping creation", zoneName) return nil } return fmt.Errorf("failed to create DNS zone: %w", err) } // Verify the zone was created successfully if resp.ID == nil { return fmt.Errorf("DNS zone created but ID is unknown") } log.Printf("DNS zone %s created successfully", zoneName) return nil } // waitForDNSZoneAvailable waits for a DNS zone to be available func waitForDNSZoneAvailable(ctx context.Context, client *armdns.ZonesClient, resourceGroupName, zoneName string) error { maxAttempts := 10 pollInterval := 5 * time.Second for i := range maxAttempts { resp, err := client.Get(ctx, resourceGroupName, zoneName, nil) if err == nil { // DNS zones don't have a provisioning state, so if we can get it, it's available if resp.ID != nil { log.Printf("DNS zone %s is available", zoneName) return nil } } if i < maxAttempts-1 { time.Sleep(pollInterval) } } return fmt.Errorf("DNS zone %s did not become available within the timeout period", zoneName) } // deleteDNSZone deletes an Azure DNS zone func deleteDNSZone(ctx context.Context, client *armdns.ZonesClient, resourceGroupName, zoneName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, zoneName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("DNS zone %s not found, skipping deletion", zoneName) return nil } return fmt.Errorf("failed to begin deletion of DNS zone: %w", err) } // Wait for deletion to complete _, err = poller.PollUntilDone(ctx, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("DNS zone %s not found during deletion, assuming already deleted", zoneName) return nil } return fmt.Errorf("failed to delete DNS zone: %w", err) } log.Printf("DNS zone %s deleted successfully", zoneName) return nil } ================================================ FILE: sources/azure/integration-tests/operational-insights-workspace_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) const ( integrationTestWorkspaceName = "ovm-integ-test-workspace" ) // errOperationalInsightsAuthorizationFailed is a sentinel error for authorization failures var errOperationalInsightsAuthorizationFailed = errors.New("authorization failed for Operational Insights resource provider") func TestOperationalInsightsWorkspaceIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients workspacesClient, err := armoperationalinsights.NewWorkspacesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Workspaces client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create workspace err = createOperationalInsightsWorkspace(ctx, workspacesClient, integrationTestResourceGroup, integrationTestWorkspaceName, integrationTestLocation) if err != nil { if errors.Is(err, errOperationalInsightsAuthorizationFailed) { t.Skipf("Skipping test: %v (service principal lacks permission to register Microsoft.OperationalInsights resource provider)", err) } t.Fatalf("Failed to create workspace: %v", err) } // Wait for workspace to be fully available err = waitForOperationalInsightsWorkspaceAvailable(ctx, workspacesClient, integrationTestResourceGroup, integrationTestWorkspaceName) if err != nil { t.Fatalf("Failed waiting for workspace to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetWorkspace", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving workspace %s in subscription %s, resource group %s", integrationTestWorkspaceName, subscriptionID, integrationTestResourceGroup) workspaceWrapper := manual.NewOperationalInsightsWorkspace( clients.NewOperationalInsightsWorkspaceClient(workspacesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := workspaceWrapper.Scopes()[0] workspaceAdapter := sources.WrapperToAdapter(workspaceWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := workspaceAdapter.Get(ctx, scope, integrationTestWorkspaceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != integrationTestWorkspaceName { t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestWorkspaceName, uniqueAttrValue) } log.Printf("Successfully retrieved workspace %s", integrationTestWorkspaceName) }) t.Run("ListWorkspaces", func(t *testing.T) { ctx := t.Context() log.Printf("Listing workspaces in subscription %s, resource group %s", subscriptionID, integrationTestResourceGroup) workspaceWrapper := manual.NewOperationalInsightsWorkspace( clients.NewOperationalInsightsWorkspaceClient(workspacesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := workspaceWrapper.Scopes()[0] workspaceAdapter := sources.WrapperToAdapter(workspaceWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := workspaceAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list workspaces: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one workspace, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestWorkspaceName { found = true break } } if !found { t.Fatalf("Expected to find workspace %s in the list of workspaces", integrationTestWorkspaceName) } log.Printf("Found %d workspaces in resource group %s", len(sdpItems), integrationTestResourceGroup) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for workspace %s", integrationTestWorkspaceName) workspaceWrapper := manual.NewOperationalInsightsWorkspace( clients.NewOperationalInsightsWorkspaceClient(workspacesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := workspaceWrapper.Scopes()[0] workspaceAdapter := sources.WrapperToAdapter(workspaceWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := workspaceAdapter.Get(ctx, scope, integrationTestWorkspaceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.OperationalInsightsWorkspace.String() { t.Errorf("Expected item type %s, got %s", azureshared.OperationalInsightsWorkspace, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for workspace %s", integrationTestWorkspaceName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for workspace %s", integrationTestWorkspaceName) workspaceWrapper := manual.NewOperationalInsightsWorkspace( clients.NewOperationalInsightsWorkspaceClient(workspacesClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := workspaceWrapper.Scopes()[0] workspaceAdapter := sources.WrapperToAdapter(workspaceWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := workspaceAdapter.Get(ctx, scope, integrationTestWorkspaceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (if any) linkedQueries := sdpItem.GetLinkedItemQueries() log.Printf("Found %d linked item queries for workspace %s", len(linkedQueries), integrationTestWorkspaceName) // For a standalone workspace without private link, there may not be any linked items // But we should verify the structure is correct if links exist for _, liq := range linkedQueries { query := liq.GetQuery() if query == nil { t.Error("Linked item query has nil Query") continue } // Verify query has required fields if query.GetType() == "" { t.Error("Linked item query has empty Type") } // Method should be GET or SEARCH (not empty) if query.GetMethod() == sdp.QueryMethod_GET || query.GetMethod() == sdp.QueryMethod_SEARCH { // Valid method } else { t.Errorf("Linked item query has unexpected Method: %v", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Linked item query has empty Query") } if query.GetScope() == "" { t.Error("Linked item query has empty Scope") } log.Printf("Verified linked item query: Type=%s, Method=%s, Query=%s, Scope=%s", query.GetType(), query.GetMethod(), query.GetQuery(), query.GetScope()) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete workspace err := deleteOperationalInsightsWorkspace(ctx, workspacesClient, integrationTestResourceGroup, integrationTestWorkspaceName) if err != nil { t.Fatalf("Failed to delete workspace: %v", err) } // Note: We keep the resource group for faster subsequent test runs }) } // createOperationalInsightsWorkspace creates an Azure Log Analytics workspace (idempotent) func createOperationalInsightsWorkspace(ctx context.Context, client *armoperationalinsights.WorkspacesClient, resourceGroupName, workspaceName, location string) error { // Check if workspace already exists existingWorkspace, err := client.Get(ctx, resourceGroupName, workspaceName, nil) if err == nil { // Workspace exists, check its state if existingWorkspace.Properties != nil && existingWorkspace.Properties.ProvisioningState != nil { state := *existingWorkspace.Properties.ProvisioningState if state == armoperationalinsights.WorkspaceEntityStatusSucceeded { log.Printf("Workspace %s already exists with state %s, skipping creation", workspaceName, state) return nil } log.Printf("Workspace %s exists but in state %s, will wait for it", workspaceName, state) } else { log.Printf("Workspace %s already exists, skipping creation", workspaceName) return nil } } // Create the workspace retentionDays := int32(30) poller, err := client.BeginCreateOrUpdate(ctx, resourceGroupName, workspaceName, armoperationalinsights.Workspace{ Location: new(location), Properties: &armoperationalinsights.WorkspaceProperties{ RetentionInDays: &retentionDays, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("operational-insights-workspace"), }, }, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) { // Check for authorization failure (resource provider not registered) if respErr.StatusCode == http.StatusForbidden && respErr.ErrorCode == "AuthorizationFailed" { return fmt.Errorf("%w: %s", errOperationalInsightsAuthorizationFailed, respErr.Error()) } // Check for missing resource provider registration if strings.Contains(respErr.Error(), "register/action") { return fmt.Errorf("%w: %s", errOperationalInsightsAuthorizationFailed, respErr.Error()) } // Check if workspace already exists (conflict) if respErr.StatusCode == http.StatusConflict { // Verify conflict is real before treating it as success. if _, getErr := client.Get(ctx, resourceGroupName, workspaceName, nil); getErr == nil { log.Printf("Workspace %s already exists (conflict), skipping", workspaceName) return nil } return fmt.Errorf("workspace %s conflict but not retrievable: %w", workspaceName, err) } } return fmt.Errorf("failed to begin creating workspace: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create workspace: %w", err) } // Verify the workspace was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("workspace created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != armoperationalinsights.WorkspaceEntityStatusSucceeded { return fmt.Errorf("workspace provisioning state is %s, expected Succeeded", provisioningState) } log.Printf("Workspace %s created successfully with provisioning state: %s", workspaceName, provisioningState) return nil } // waitForOperationalInsightsWorkspaceAvailable polls until the workspace is available via the Get API func waitForOperationalInsightsWorkspaceAvailable(ctx context.Context, client *armoperationalinsights.WorkspacesClient, resourceGroupName, workspaceName string) error { maxAttempts := 20 pollInterval := 5 * time.Second maxNotFoundAttempts := 5 notFoundCount := 0 log.Printf("Waiting for workspace %s to be available via API...", workspaceName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.Get(ctx, resourceGroupName, workspaceName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { notFoundCount++ if notFoundCount >= maxNotFoundAttempts { return fmt.Errorf("workspace %s not found after %d attempts", workspaceName, notFoundCount) } log.Printf("Workspace %s not yet available (attempt %d/%d), waiting %v...", workspaceName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking workspace availability: %w", err) } notFoundCount = 0 // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == armoperationalinsights.WorkspaceEntityStatusSucceeded { log.Printf("Workspace %s is available with provisioning state: %s", workspaceName, state) return nil } if state == armoperationalinsights.WorkspaceEntityStatusFailed { return fmt.Errorf("workspace provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Workspace %s provisioning state: %s (attempt %d/%d), waiting...", workspaceName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Workspace exists but no provisioning state - consider it available log.Printf("Workspace %s is available", workspaceName) return nil } return fmt.Errorf("timeout waiting for workspace %s to be available after %d attempts", workspaceName, maxAttempts) } // deleteOperationalInsightsWorkspace deletes an Azure Log Analytics workspace func deleteOperationalInsightsWorkspace(ctx context.Context, client *armoperationalinsights.WorkspacesClient, resourceGroupName, workspaceName string) error { poller, err := client.BeginDelete(ctx, resourceGroupName, workspaceName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Workspace %s not found, skipping deletion", workspaceName) return nil } return fmt.Errorf("failed to begin deleting workspace: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete workspace: %w", err) } log.Printf("Workspace %s deleted successfully", workspaceName) return nil } ================================================ FILE: sources/azure/integration-tests/sql-database-schema_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "math/rand" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestSQLSchemaServerName = "ovm-integ-test-schema-svr" integrationTestSQLSchemaDatabaseName = "ovm-integ-test-schema-db" ) func TestSQLDatabaseSchemaIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients sqlServerClient, err := armsql.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Servers client: %v", err) } sqlDatabaseClient, err := armsql.NewDatabasesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Databases client: %v", err) } sqlDatabaseSchemasClient, err := armsql.NewDatabaseSchemasClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Database Schemas client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique SQL server name (must be globally unique, lowercase, no special chars) sqlServerName := generateSQLServerNameForSchemaTest(integrationTestSQLSchemaServerName) // Track if setup completed successfully setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create SQL server err = createSQLServerForSchemaTest(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName, integrationTestLocation) if err != nil { if errors.Is(err, errMissingSQLCredentials) { t.Skip("Skipping: SQL server admin credentials not configured") } t.Fatalf("Failed to create SQL server: %v", err) } // Wait for SQL server to be available err = waitForSQLServerAvailableForSchemaTest(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Fatalf("Failed waiting for SQL server to be available: %v", err) } // Create SQL database err = createSQLDatabaseForSchemaTest(ctx, sqlDatabaseClient, integrationTestResourceGroup, sqlServerName, integrationTestSQLSchemaDatabaseName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create SQL database: %v", err) } // Wait for SQL database to be available err = waitForSQLDatabaseAvailableForSchemaTest(ctx, sqlDatabaseClient, integrationTestResourceGroup, sqlServerName, integrationTestSQLSchemaDatabaseName) if err != nil { t.Fatalf("Failed waiting for SQL database to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } // First discover available schemas from the database (schemas are auto-created like dbo, sys, etc.) var testSchemaName string t.Run("DiscoverSchemas", func(t *testing.T) { ctx := t.Context() // List schemas to find an available one (dbo is standard in SQL Server databases) pager := sqlDatabaseSchemasClient.NewListByDatabasePager(integrationTestResourceGroup, sqlServerName, integrationTestSQLSchemaDatabaseName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { t.Fatalf("Failed to list schemas: %v", err) } if len(page.Value) > 0 && page.Value[0].Name != nil { testSchemaName = *page.Value[0].Name log.Printf("Discovered schema: %s", testSchemaName) break } } if testSchemaName == "" { t.Fatalf("No schemas found in database %s", integrationTestSQLSchemaDatabaseName) } }) t.Run("GetSQLDatabaseSchema", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving SQL database schema %s in database %s, server %s", testSchemaName, integrationTestSQLSchemaDatabaseName, sqlServerName) schemaWrapper := manual.NewSqlDatabaseSchema( clients.NewSqlDatabaseSchemasClient(sqlDatabaseSchemasClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := schemaWrapper.Scopes()[0] schemaAdapter := sources.WrapperToAdapter(schemaWrapper, sdpcache.NewNoOpCache()) // Get requires serverName, databaseName, and schemaName as query parts query := shared.CompositeLookupKey(sqlServerName, integrationTestSQLSchemaDatabaseName, testSchemaName) sdpItem, qErr := schemaAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.SQLDatabaseSchema.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLDatabaseSchema, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(sqlServerName, integrationTestSQLSchemaDatabaseName, testSchemaName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved SQL database schema %s", testSchemaName) }) t.Run("SearchSQLDatabaseSchemas", func(t *testing.T) { ctx := t.Context() log.Printf("Searching SQL database schemas in database %s", integrationTestSQLSchemaDatabaseName) schemaWrapper := manual.NewSqlDatabaseSchema( clients.NewSqlDatabaseSchemasClient(sqlDatabaseSchemasClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := schemaWrapper.Scopes()[0] schemaAdapter := sources.WrapperToAdapter(schemaWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := schemaAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, shared.CompositeLookupKey(sqlServerName, integrationTestSQLSchemaDatabaseName), true) if err != nil { t.Fatalf("Failed to search SQL database schemas: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one SQL database schema, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { expectedValue := shared.CompositeLookupKey(sqlServerName, integrationTestSQLSchemaDatabaseName, testSchemaName) if v == expectedValue { found = true break } } } if !found { t.Fatalf("Expected to find schema %s in the search results", testSchemaName) } log.Printf("Found %d SQL database schemas in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for SQL database schema %s", testSchemaName) schemaWrapper := manual.NewSqlDatabaseSchema( clients.NewSqlDatabaseSchemasClient(sqlDatabaseSchemasClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := schemaWrapper.Scopes()[0] schemaAdapter := sources.WrapperToAdapter(schemaWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(sqlServerName, integrationTestSQLSchemaDatabaseName, testSchemaName) sdpItem, qErr := schemaAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (SQL database should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasSQLDatabaseLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() != "" { // Verify query structure if liq.GetQuery().GetQuery() == "" { t.Errorf("LinkedItemQuery has empty query") } if liq.GetQuery().GetScope() == "" { t.Errorf("LinkedItemQuery has empty scope") } } if liq.GetQuery().GetType() == azureshared.SQLDatabase.String() { hasSQLDatabaseLink = true expectedQuery := shared.CompositeLookupKey(sqlServerName, integrationTestSQLSchemaDatabaseName) if liq.GetQuery().GetQuery() != expectedQuery { t.Errorf("Expected linked query to SQL database %s, got %s", expectedQuery, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } } } if !hasSQLDatabaseLink { t.Error("Expected linked query to SQL database, but didn't find one") } log.Printf("Verified %d linked item queries for SQL database schema %s", len(linkedQueries), testSchemaName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() schemaWrapper := manual.NewSqlDatabaseSchema( clients.NewSqlDatabaseSchemasClient(sqlDatabaseSchemasClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := schemaWrapper.Scopes()[0] schemaAdapter := sources.WrapperToAdapter(schemaWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(sqlServerName, integrationTestSQLSchemaDatabaseName, testSchemaName) sdpItem, qErr := schemaAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.SQLDatabaseSchema.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLDatabaseSchema.String(), sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Errorf("Item validation failed: %v", err) } log.Printf("Verified item attributes for SQL database schema %s", testSchemaName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete SQL database err := deleteSQLDatabaseForSchemaTest(ctx, sqlDatabaseClient, integrationTestResourceGroup, sqlServerName, integrationTestSQLSchemaDatabaseName) if err != nil { t.Logf("Warning: Failed to delete SQL database: %v", err) } // Delete SQL server err = deleteSQLServerForSchemaTest(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Logf("Warning: Failed to delete SQL server: %v", err) } }) } // errMissingSQLCredentials is a sentinel error for missing SQL credentials var errMissingSQLCredentials = errors.New("AZURE_SQL_SERVER_ADMIN_LOGIN and AZURE_SQL_SERVER_ADMIN_PASSWORD environment variables must be set for integration tests") // createSQLServerForSchemaTest creates an Azure SQL server for schema tests func createSQLServerForSchemaTest(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName, location string) error { // Check if SQL server already exists _, err := client.Get(ctx, resourceGroup, serverName, nil) if err == nil { log.Printf("SQL server %s already exists, skipping creation", serverName) return nil } var respErr *azcore.ResponseError if !errors.As(err, &respErr) { return fmt.Errorf("failed to check if SQL server exists: %w", err) } if respErr != nil && respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check if SQL server exists: %w", err) } // Get credentials from environment adminLogin := os.Getenv("AZURE_SQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_SQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { return errMissingSQLCredentials } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroup, serverName, armsql.Server{ Location: new(location), Properties: &armsql.ServerProperties{ AdministratorLogin: new(adminLogin), AdministratorLoginPassword: new(adminPassword), Version: new("12.0"), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to start SQL server creation: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create SQL server: %w", err) } log.Printf("SQL server %s created successfully in location %s", serverName, location) return nil } // waitForSQLServerAvailableForSchemaTest waits for a SQL server to be available func waitForSQLServerAvailableForSchemaTest(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName string) error { maxAttempts := 30 for range maxAttempts { server, err := client.Get(ctx, resourceGroup, serverName, nil) if err == nil { if server.Properties != nil && server.Properties.State != nil && *server.Properties.State == "Ready" { return nil } } time.Sleep(5 * time.Second) } return fmt.Errorf("SQL server %s did not become available within expected time", serverName) } // createSQLDatabaseForSchemaTest creates an Azure SQL database for schema tests func createSQLDatabaseForSchemaTest(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName, location string) error { // Check if SQL database already exists _, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err == nil { log.Printf("SQL database %s already exists, skipping creation", databaseName) return nil } var respErr *azcore.ResponseError if !errors.As(err, &respErr) { return fmt.Errorf("failed to check if SQL database exists: %w", err) } if respErr != nil && respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check if SQL database exists: %w", err) } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroup, serverName, databaseName, armsql.Database{ Location: new(location), Properties: &armsql.DatabaseProperties{ RequestedServiceObjectiveName: new("Basic"), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to start SQL database creation: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create SQL database: %w", err) } log.Printf("SQL database %s created successfully in server %s", databaseName, serverName) return nil } // waitForSQLDatabaseAvailableForSchemaTest waits for a SQL database to be available func waitForSQLDatabaseAvailableForSchemaTest(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName string) error { maxAttempts := 30 for range maxAttempts { database, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err == nil { if database.Properties != nil && database.Properties.Status != nil && *database.Properties.Status == armsql.DatabaseStatusOnline { return nil } } time.Sleep(5 * time.Second) } return fmt.Errorf("SQL database %s did not become available within expected time", databaseName) } // deleteSQLDatabaseForSchemaTest deletes an Azure SQL database func deleteSQLDatabaseForSchemaTest(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName string) error { _, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("SQL database %s does not exist, skipping deletion", databaseName) return nil } return fmt.Errorf("failed to check if SQL database exists: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroup, serverName, databaseName, nil) if err != nil { return fmt.Errorf("failed to start SQL database deletion: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete SQL database: %w", err) } log.Printf("SQL database %s deleted successfully", databaseName) return nil } // deleteSQLServerForSchemaTest deletes an Azure SQL server func deleteSQLServerForSchemaTest(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName string) error { _, err := client.Get(ctx, resourceGroup, serverName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("SQL server %s does not exist, skipping deletion", serverName) return nil } return fmt.Errorf("failed to check if SQL server exists: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroup, serverName, nil) if err != nil { return fmt.Errorf("failed to start SQL server deletion: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete SQL server: %w", err) } log.Printf("SQL server %s deleted successfully", serverName) return nil } // generateSQLServerNameForSchemaTest generates a unique SQL server name // SQL server names must be globally unique, 1-63 characters, lowercase letters, numbers, and hyphens func generateSQLServerNameForSchemaTest(baseName string) string { baseName = strings.ToLower(baseName) baseName = strings.ReplaceAll(baseName, "_", "-") baseName = strings.ReplaceAll(baseName, " ", "-") rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(os.Getpid()))) suffix := rng.Intn(10000) return fmt.Sprintf("%s-%04d", baseName, suffix) } ================================================ FILE: sources/azure/integration-tests/sql-database_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "math/rand" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestSQLServerName = "ovm-integ-test-sql-server" integrationTestSQLDatabaseName = "ovm-integ-test-database" ) func TestSQLDatabaseIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients sqlServerClient, err := armsql.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Servers client: %v", err) } sqlDatabaseClient, err := armsql.NewDatabasesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Databases client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique SQL server name (must be globally unique, lowercase, no special chars) sqlServerName := generateSQLServerName(integrationTestSQLServerName) setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create SQL server err = createSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create SQL server: %v", err) } // Wait for SQL server to be available err = waitForSQLServerAvailable(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Fatalf("Failed waiting for SQL server to be available: %v", err) } // Create SQL database err = createSQLDatabase(ctx, sqlDatabaseClient, integrationTestResourceGroup, sqlServerName, integrationTestSQLDatabaseName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create SQL database: %v", err) } // Wait for SQL database to be available err = waitForSQLDatabaseAvailable(ctx, sqlDatabaseClient, integrationTestResourceGroup, sqlServerName, integrationTestSQLDatabaseName) if err != nil { t.Fatalf("Failed waiting for SQL database to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetSQLDatabase", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving SQL database %s in SQL server %s, subscription %s, resource group %s", integrationTestSQLDatabaseName, sqlServerName, subscriptionID, integrationTestResourceGroup) sqlDbWrapper := manual.NewSqlDatabase( clients.NewSqlDatabasesClient(sqlDatabaseClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := sqlDbWrapper.Scopes()[0] sqlDbAdapter := sources.WrapperToAdapter(sqlDbWrapper, sdpcache.NewNoOpCache()) // Get requires serverName and databaseName as query parts query := sqlServerName + shared.QuerySeparator + integrationTestSQLDatabaseName sdpItem, qErr := sqlDbAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.SQLDatabase.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLDatabase, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(sqlServerName, integrationTestSQLDatabaseName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved SQL database %s", integrationTestSQLDatabaseName) }) t.Run("SearchSQLDatabases", func(t *testing.T) { ctx := t.Context() log.Printf("Searching SQL databases in SQL server %s", sqlServerName) sqlDbWrapper := manual.NewSqlDatabase( clients.NewSqlDatabasesClient(sqlDatabaseClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := sqlDbWrapper.Scopes()[0] sqlDbAdapter := sources.WrapperToAdapter(sqlDbWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := sqlDbAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, sqlServerName, true) if err != nil { t.Fatalf("Failed to search SQL databases: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one SQL database, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { expectedValue := shared.CompositeLookupKey(sqlServerName, integrationTestSQLDatabaseName) if v == expectedValue { found = true break } } } if !found { t.Fatalf("Expected to find database %s in the search results", integrationTestSQLDatabaseName) } log.Printf("Found %d SQL databases in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for SQL database %s", integrationTestSQLDatabaseName) sqlDbWrapper := manual.NewSqlDatabase( clients.NewSqlDatabasesClient(sqlDatabaseClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := sqlDbWrapper.Scopes()[0] sqlDbAdapter := sources.WrapperToAdapter(sqlDbWrapper, sdpcache.NewNoOpCache()) query := sqlServerName + shared.QuerySeparator + integrationTestSQLDatabaseName sdpItem, qErr := sqlDbAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (SQL server should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasSQLServerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.SQLServer.String() { hasSQLServerLink = true if liq.GetQuery().GetQuery() != sqlServerName { t.Errorf("Expected linked query to SQL server %s, got %s", sqlServerName, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } break } } if !hasSQLServerLink { t.Error("Expected linked query to SQL server, but didn't find one") } log.Printf("Verified %d linked item queries for SQL database %s", len(linkedQueries), integrationTestSQLDatabaseName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete SQL database err := deleteSQLDatabase(ctx, sqlDatabaseClient, integrationTestResourceGroup, sqlServerName, integrationTestSQLDatabaseName) if err != nil { t.Fatalf("Failed to delete SQL database: %v", err) } // Delete SQL server err = deleteSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Fatalf("Failed to delete SQL server: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // generateSQLServerName generates a unique SQL server name // SQL server names must be globally unique, 1-63 characters, lowercase letters, numbers, and hyphens func generateSQLServerName(baseName string) string { // Ensure base name is lowercase and valid baseName = strings.ToLower(baseName) // Remove any invalid characters (only alphanumeric and hyphens allowed) baseName = strings.ReplaceAll(baseName, "_", "-") // Remove any invalid characters baseName = strings.ReplaceAll(baseName, " ", "-") // Add random suffix for uniqueness (4 characters) rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(os.Getpid()))) suffix := rng.Intn(10000) return fmt.Sprintf("%s-%04d", baseName, suffix) } // createSQLServer creates an Azure SQL server func createSQLServer(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName, location string) error { // Check if SQL server already exists _, err := client.Get(ctx, resourceGroup, serverName, nil) if err == nil { log.Printf("SQL server %s already exists, skipping creation", serverName) return nil } var respErr *azcore.ResponseError if !errors.As(err, &respErr) { // Some other error occurred return fmt.Errorf("failed to check if SQL server exists: %w", err) } if respErr != nil && respErr.StatusCode != http.StatusNotFound { // Server exists or other error if respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check if SQL server exists: %w", err) } } // Create the SQL server // Note: SQL servers require administrator login credentials // Credentials are read from environment variables to avoid committing secrets to source control adminLogin := os.Getenv("AZURE_SQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_SQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { return fmt.Errorf("AZURE_SQL_SERVER_ADMIN_LOGIN and AZURE_SQL_SERVER_ADMIN_PASSWORD environment variables must be set for integration tests") } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroup, serverName, armsql.Server{ Location: new(location), Properties: &armsql.ServerProperties{ AdministratorLogin: new(adminLogin), AdministratorLoginPassword: new(adminPassword), Version: new("12.0"), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to start SQL server creation: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create SQL server: %w", err) } log.Printf("SQL server %s created successfully in location %s", serverName, location) return nil } // waitForSQLServerAvailable waits for a SQL server to be available func waitForSQLServerAvailable(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName string) error { maxAttempts := 30 for range maxAttempts { server, err := client.Get(ctx, resourceGroup, serverName, nil) if err == nil { // Server exists, check if it's ready (state should be "Ready") if server.Properties != nil && server.Properties.State != nil && *server.Properties.State == "Ready" { return nil } } time.Sleep(5 * time.Second) } return fmt.Errorf("SQL server %s did not become available within expected time", serverName) } // createSQLDatabase creates an Azure SQL database func createSQLDatabase(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName, location string) error { // Check if SQL database already exists _, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err == nil { log.Printf("SQL database %s already exists, skipping creation", databaseName) return nil } var respErr *azcore.ResponseError if !errors.As(err, &respErr) { // Some other error occurred return fmt.Errorf("failed to check if SQL database exists: %w", err) } if respErr != nil && respErr.StatusCode != http.StatusNotFound { // Database exists or other error if respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check if SQL database exists: %w", err) } } // Create the SQL database // Using Basic tier for integration tests (cheaper) poller, err := client.BeginCreateOrUpdate(ctx, resourceGroup, serverName, databaseName, armsql.Database{ Location: new(location), Properties: &armsql.DatabaseProperties{ RequestedServiceObjectiveName: new("Basic"), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to start SQL database creation: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create SQL database: %w", err) } log.Printf("SQL database %s created successfully in server %s", databaseName, serverName) return nil } // waitForSQLDatabaseAvailable waits for a SQL database to be available func waitForSQLDatabaseAvailable(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName string) error { maxAttempts := 30 for range maxAttempts { database, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err == nil { // Database exists, check if it's ready (status should be "Online") if database.Properties != nil && database.Properties.Status != nil && *database.Properties.Status == armsql.DatabaseStatusOnline { return nil } } time.Sleep(5 * time.Second) } return fmt.Errorf("SQL database %s did not become available within expected time", databaseName) } // deleteSQLDatabase deletes an Azure SQL database func deleteSQLDatabase(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName string) error { // Check if database exists before attempting to delete _, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("SQL database %s does not exist, skipping deletion", databaseName) return nil } return fmt.Errorf("failed to check if SQL database exists: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroup, serverName, databaseName, nil) if err != nil { return fmt.Errorf("failed to start SQL database deletion: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete SQL database: %w", err) } log.Printf("SQL database %s deleted successfully", databaseName) return nil } // deleteSQLServer deletes an Azure SQL server func deleteSQLServer(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName string) error { // Check if server exists before attempting to delete _, err := client.Get(ctx, resourceGroup, serverName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("SQL server %s does not exist, skipping deletion", serverName) return nil } return fmt.Errorf("failed to check if SQL server exists: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroup, serverName, nil) if err != nil { return fmt.Errorf("failed to start SQL server deletion: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete SQL server: %w", err) } log.Printf("SQL server %s deleted successfully", serverName) return nil } ================================================ FILE: sources/azure/integration-tests/sql-server-failover-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "math/rand" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestFailoverGroupName = "ovm-integ-test-failover-group" integrationTestPrimaryServerName = "ovm-integ-test-primary-server" integrationTestSecondaryServerName = "ovm-integ-test-secondary-server" integrationTestPrimaryLocation = "westus2" integrationTestSecondaryLocation = "centralus" integrationTestFailoverGroupDBName = "ovm-integ-test-fg-database" ) func TestSQLServerFailoverGroupIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // SQL server admin credentials are required for creating SQL servers adminLogin := os.Getenv("AZURE_SQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_SQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { t.Skip("AZURE_SQL_SERVER_ADMIN_LOGIN and AZURE_SQL_SERVER_ADMIN_PASSWORD environment variables must be set for SQL failover group integration tests") } cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients sqlServerClient, err := armsql.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Servers client: %v", err) } sqlDatabaseClient, err := armsql.NewDatabasesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Databases client: %v", err) } sqlFailoverGroupClient, err := armsql.NewFailoverGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Failover Groups client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique names for SQL servers (must be globally unique) primaryServerName := generateFailoverGroupServerName(integrationTestPrimaryServerName) secondaryServerName := generateFailoverGroupServerName(integrationTestSecondaryServerName) var setupCompleted bool t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create primary SQL server err = createFailoverGroupSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, primaryServerName, integrationTestPrimaryLocation) if err != nil { t.Fatalf("Failed to create primary SQL server: %v", err) } // Wait for primary SQL server to be available err = waitForFailoverGroupSQLServerAvailable(ctx, sqlServerClient, integrationTestResourceGroup, primaryServerName) if err != nil { t.Fatalf("Failed waiting for primary SQL server to be available: %v", err) } // Create secondary SQL server (in a different region) err = createFailoverGroupSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, secondaryServerName, integrationTestSecondaryLocation) if err != nil { t.Fatalf("Failed to create secondary SQL server: %v", err) } // Wait for secondary SQL server to be available err = waitForFailoverGroupSQLServerAvailable(ctx, sqlServerClient, integrationTestResourceGroup, secondaryServerName) if err != nil { t.Fatalf("Failed waiting for secondary SQL server to be available: %v", err) } // Create a database on the primary server (failover groups need at least one database) err = createFailoverGroupDatabase(ctx, sqlDatabaseClient, integrationTestResourceGroup, primaryServerName, integrationTestFailoverGroupDBName, integrationTestPrimaryLocation) if err != nil { t.Fatalf("Failed to create database: %v", err) } // Wait for database to be available err = waitForFailoverGroupDatabaseAvailable(ctx, sqlDatabaseClient, integrationTestResourceGroup, primaryServerName, integrationTestFailoverGroupDBName) if err != nil { t.Fatalf("Failed waiting for database to be available: %v", err) } // Create the failover group err = createFailoverGroup(ctx, sqlFailoverGroupClient, integrationTestResourceGroup, primaryServerName, secondaryServerName, integrationTestFailoverGroupName, subscriptionID) if err != nil { t.Fatalf("Failed to create failover group: %v", err) } // Wait for the failover group to be available err = waitForFailoverGroupAvailable(ctx, sqlFailoverGroupClient, integrationTestResourceGroup, primaryServerName, integrationTestFailoverGroupName) if err != nil { t.Fatalf("Failed waiting for failover group to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetSQLServerFailoverGroup", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving failover group %s in SQL server %s, subscription %s, resource group %s", integrationTestFailoverGroupName, primaryServerName, subscriptionID, integrationTestResourceGroup) wrapper := manual.NewSqlServerFailoverGroup( clients.NewSqlFailoverGroupsClient(sqlFailoverGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(primaryServerName, integrationTestFailoverGroupName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.SQLServerFailoverGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerFailoverGroup, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(primaryServerName, integrationTestFailoverGroupName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved failover group %s", integrationTestFailoverGroupName) }) t.Run("SearchSQLServerFailoverGroups", func(t *testing.T) { ctx := t.Context() log.Printf("Searching failover groups in SQL server %s", primaryServerName) wrapper := manual.NewSqlServerFailoverGroup( clients.NewSqlFailoverGroupsClient(sqlFailoverGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, primaryServerName, true) if err != nil { t.Fatalf("Failed to search failover groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one failover group, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { expectedValue := shared.CompositeLookupKey(primaryServerName, integrationTestFailoverGroupName) if v == expectedValue { found = true break } } } if !found { t.Fatalf("Expected to find failover group %s in the search results", integrationTestFailoverGroupName) } log.Printf("Found %d failover groups in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for failover group %s", integrationTestFailoverGroupName) wrapper := manual.NewSqlServerFailoverGroup( clients.NewSqlFailoverGroupsClient(sqlFailoverGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(primaryServerName, integrationTestFailoverGroupName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasPrimaryServerLink bool var hasPartnerServerLink bool for _, liq := range linkedQueries { query := liq.GetQuery() if query.GetType() == "" { t.Error("Found linked query with empty type") } if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Found linked query with invalid method: %s", query.GetMethod()) } if query.GetQuery() == "" { t.Error("Found linked query with empty query") } if query.GetScope() == "" { t.Error("Found linked query with empty scope") } if query.GetType() == azureshared.SQLServer.String() { if query.GetQuery() == primaryServerName { hasPrimaryServerLink = true } if query.GetQuery() == secondaryServerName { hasPartnerServerLink = true } } } if !hasPrimaryServerLink { t.Error("Expected linked query to primary SQL server, but didn't find one") } if !hasPartnerServerLink { t.Error("Expected linked query to partner (secondary) SQL server, but didn't find one") } log.Printf("Verified %d linked item queries for failover group %s", len(linkedQueries), integrationTestFailoverGroupName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() wrapper := manual.NewSqlServerFailoverGroup( clients.NewSqlFailoverGroupsClient(sqlFailoverGroupClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := wrapper.Scopes()[0] adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(primaryServerName, integrationTestFailoverGroupName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLServerFailoverGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerFailoverGroup, sdpItem.GetType()) } expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete the failover group first err := deleteFailoverGroup(ctx, sqlFailoverGroupClient, integrationTestResourceGroup, primaryServerName, integrationTestFailoverGroupName) if err != nil { t.Logf("Warning: Failed to delete failover group: %v", err) } // Delete the database err = deleteFailoverGroupDatabase(ctx, sqlDatabaseClient, integrationTestResourceGroup, primaryServerName, integrationTestFailoverGroupDBName) if err != nil { t.Logf("Warning: Failed to delete database: %v", err) } // Delete secondary SQL server first (since failover group is deleted) err = deleteFailoverGroupSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, secondaryServerName) if err != nil { t.Logf("Warning: Failed to delete secondary SQL server: %v", err) } // Delete primary SQL server err = deleteFailoverGroupSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, primaryServerName) if err != nil { t.Logf("Warning: Failed to delete primary SQL server: %v", err) } }) } // generateFailoverGroupServerName generates a unique SQL server name for failover group tests func generateFailoverGroupServerName(baseName string) string { baseName = strings.ToLower(baseName) baseName = strings.ReplaceAll(baseName, "_", "-") baseName = strings.ReplaceAll(baseName, " ", "-") rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(os.Getpid()))) suffix := rng.Intn(10000) return fmt.Sprintf("%s-%04d", baseName, suffix) } // createFailoverGroupSQLServer creates an Azure SQL server for failover group testing func createFailoverGroupSQLServer(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName, location string) error { _, err := client.Get(ctx, resourceGroup, serverName, nil) if err == nil { log.Printf("SQL server %s already exists, skipping creation", serverName) return nil } var respErr *azcore.ResponseError if !errors.As(err, &respErr) { return fmt.Errorf("failed to check if SQL server exists: %w", err) } if respErr != nil && respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check if SQL server exists: %w", err) } adminLogin := os.Getenv("AZURE_SQL_SERVER_ADMIN_LOGIN") adminPassword := os.Getenv("AZURE_SQL_SERVER_ADMIN_PASSWORD") if adminLogin == "" || adminPassword == "" { return fmt.Errorf("AZURE_SQL_SERVER_ADMIN_LOGIN and AZURE_SQL_SERVER_ADMIN_PASSWORD environment variables must be set for integration tests") } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroup, serverName, armsql.Server{ Location: &location, Properties: &armsql.ServerProperties{ AdministratorLogin: &adminLogin, AdministratorLoginPassword: &adminPassword, Version: new("12.0"), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to start SQL server creation: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create SQL server: %w", err) } log.Printf("SQL server %s created successfully in location %s", serverName, location) return nil } // waitForFailoverGroupSQLServerAvailable waits for a SQL server to be available func waitForFailoverGroupSQLServerAvailable(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName string) error { maxAttempts := 60 // Longer timeout for failover group tests for range maxAttempts { server, err := client.Get(ctx, resourceGroup, serverName, nil) if err == nil { if server.Properties != nil && server.Properties.State != nil && *server.Properties.State == "Ready" { return nil } } time.Sleep(5 * time.Second) } return fmt.Errorf("SQL server %s did not become available within expected time", serverName) } // createFailoverGroupDatabase creates an Azure SQL database for failover group func createFailoverGroupDatabase(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName, location string) error { _, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err == nil { log.Printf("SQL database %s already exists, skipping creation", databaseName) return nil } var respErr *azcore.ResponseError if !errors.As(err, &respErr) { return fmt.Errorf("failed to check if SQL database exists: %w", err) } if respErr != nil && respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check if SQL database exists: %w", err) } poller, err := client.BeginCreateOrUpdate(ctx, resourceGroup, serverName, databaseName, armsql.Database{ Location: &location, Properties: &armsql.DatabaseProperties{ RequestedServiceObjectiveName: new("Basic"), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to start SQL database creation: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create SQL database: %w", err) } log.Printf("SQL database %s created successfully in server %s", databaseName, serverName) return nil } // waitForFailoverGroupDatabaseAvailable waits for a SQL database to be available func waitForFailoverGroupDatabaseAvailable(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName string) error { maxAttempts := 60 for range maxAttempts { database, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err == nil { if database.Properties != nil && database.Properties.Status != nil && *database.Properties.Status == armsql.DatabaseStatusOnline { return nil } } time.Sleep(5 * time.Second) } return fmt.Errorf("SQL database %s did not become available within expected time", databaseName) } // createFailoverGroup creates an Azure SQL Failover Group func createFailoverGroup(ctx context.Context, client *armsql.FailoverGroupsClient, resourceGroup, primaryServerName, secondaryServerName, failoverGroupName, subscriptionID string) error { _, err := client.Get(ctx, resourceGroup, primaryServerName, failoverGroupName, nil) if err == nil { log.Printf("Failover group %s already exists, skipping creation", failoverGroupName) return nil } var respErr *azcore.ResponseError if !errors.As(err, &respErr) { return fmt.Errorf("failed to check if failover group exists: %w", err) } if respErr != nil && respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check if failover group exists: %w", err) } secondaryServerID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Sql/servers/%s", subscriptionID, resourceGroup, secondaryServerName) poller, err := client.BeginCreateOrUpdate(ctx, resourceGroup, primaryServerName, failoverGroupName, armsql.FailoverGroup{ Properties: &armsql.FailoverGroupProperties{ PartnerServers: []*armsql.PartnerInfo{ { ID: &secondaryServerID, }, }, ReadWriteEndpoint: &armsql.FailoverGroupReadWriteEndpoint{ FailoverPolicy: new(armsql.ReadWriteEndpointFailoverPolicyAutomatic), FailoverWithDataLossGracePeriodMinutes: new(int32(60)), }, ReadOnlyEndpoint: &armsql.FailoverGroupReadOnlyEndpoint{ FailoverPolicy: new(armsql.ReadOnlyEndpointFailoverPolicyDisabled), }, Databases: []*string{}, }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "managed": new("true"), }, }, nil) if err != nil { return fmt.Errorf("failed to start failover group creation: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create failover group: %w", err) } log.Printf("Failover group %s created successfully", failoverGroupName) return nil } // waitForFailoverGroupAvailable waits for a failover group to be available func waitForFailoverGroupAvailable(ctx context.Context, client *armsql.FailoverGroupsClient, resourceGroup, serverName, failoverGroupName string) error { maxAttempts := 60 for range maxAttempts { fg, err := client.Get(ctx, resourceGroup, serverName, failoverGroupName, nil) if err == nil { // Replication state can be empty string (ready), "CATCH_UP", "PENDING", "SEEDING", "SUSPENDED" if fg.Properties != nil && fg.Properties.ReplicationState != nil { state := *fg.Properties.ReplicationState if state == "" || state == "CATCH_UP" { // Empty string or CATCH_UP indicates the failover group is functional return nil } } else if fg.Properties != nil { // ReplicationState is nil, check if properties exist (group created) return nil } } time.Sleep(5 * time.Second) } return fmt.Errorf("failover group %s did not become available within expected time", failoverGroupName) } // deleteFailoverGroup deletes an Azure SQL Failover Group func deleteFailoverGroup(ctx context.Context, client *armsql.FailoverGroupsClient, resourceGroup, serverName, failoverGroupName string) error { _, err := client.Get(ctx, resourceGroup, serverName, failoverGroupName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Failover group %s does not exist, skipping deletion", failoverGroupName) return nil } return fmt.Errorf("failed to check if failover group exists: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroup, serverName, failoverGroupName, nil) if err != nil { return fmt.Errorf("failed to start failover group deletion: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete failover group: %w", err) } log.Printf("Failover group %s deleted successfully", failoverGroupName) return nil } // deleteFailoverGroupDatabase deletes an Azure SQL database func deleteFailoverGroupDatabase(ctx context.Context, client *armsql.DatabasesClient, resourceGroup, serverName, databaseName string) error { _, err := client.Get(ctx, resourceGroup, serverName, databaseName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("SQL database %s does not exist, skipping deletion", databaseName) return nil } return fmt.Errorf("failed to check if SQL database exists: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroup, serverName, databaseName, nil) if err != nil { return fmt.Errorf("failed to start SQL database deletion: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete SQL database: %w", err) } log.Printf("SQL database %s deleted successfully", databaseName) return nil } // deleteFailoverGroupSQLServer deletes an Azure SQL server func deleteFailoverGroupSQLServer(ctx context.Context, client *armsql.ServersClient, resourceGroup, serverName string) error { _, err := client.Get(ctx, resourceGroup, serverName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("SQL server %s does not exist, skipping deletion", serverName) return nil } return fmt.Errorf("failed to check if SQL server exists: %w", err) } poller, err := client.BeginDelete(ctx, resourceGroup, serverName, nil) if err != nil { return fmt.Errorf("failed to start SQL server deletion: %w", err) } _, err = poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to delete SQL server: %w", err) } log.Printf("SQL server %s deleted successfully", serverName) return nil } ================================================ FILE: sources/azure/integration-tests/sql-server-key_test.go ================================================ package integrationtests import ( "context" "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) // findExistingSQLServer searches for an existing SQL server in the resource group // Returns the server name if found, empty string otherwise func findExistingSQLServer(ctx context.Context, client *armsql.ServersClient, resourceGroup string) string { pager := client.NewListByResourceGroupPager(resourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { log.Printf("Failed to list SQL servers: %v", err) return "" } for _, server := range page.Value { if server.Name != nil && *server.Name != "" { log.Printf("Found existing SQL server: %s", *server.Name) return *server.Name } } } return "" } func TestSQLServerKeyIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients sqlServerClient, err := armsql.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Servers client: %v", err) } serverKeysClient, err := armsql.NewServerKeysClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Server Keys client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Track setup completion for skipping Run if Setup fails setupCompleted := false // Track if we created the server (for cleanup) serverCreated := false // SQL server name - will be set in Setup var sqlServerName string // The ServiceManaged key name is always "ServiceManaged" const serviceManagedKeyName = "ServiceManaged" t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // First, try to find an existing SQL server to reuse // This helps when admin credentials are not available sqlServerName = findExistingSQLServer(ctx, sqlServerClient, integrationTestResourceGroup) if sqlServerName == "" { // No existing server found, try to create one sqlServerName = generateSQLServerName(integrationTestSQLServerName) err = createSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName, integrationTestLocation) if err != nil { t.Skipf("Skipping test: Failed to create SQL server (admin credentials may be missing): %v", err) } serverCreated = true // Wait for SQL server to be available err = waitForSQLServerAvailable(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Fatalf("Failed waiting for SQL server to be available: %v", err) } } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetSQLServerKey", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving SQL server key %s for server %s in subscription %s, resource group %s", serviceManagedKeyName, sqlServerName, subscriptionID, integrationTestResourceGroup) serverKeyWrapper := manual.NewSqlServerKey( clients.NewSqlServerKeysClient(serverKeysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := serverKeyWrapper.Scopes()[0] serverKeyAdapter := sources.WrapperToAdapter(serverKeyWrapper, sdpcache.NewNoOpCache()) // Get requires serverName and keyName as query parts query := shared.CompositeLookupKey(sqlServerName, serviceManagedKeyName) sdpItem, qErr := serverKeyAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.SQLServerKey.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerKey, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueAttrValue := shared.CompositeLookupKey(sqlServerName, serviceManagedKeyName) if uniqueAttrValue != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved SQL server key %s", serviceManagedKeyName) }) t.Run("SearchSQLServerKeys", func(t *testing.T) { ctx := t.Context() log.Printf("Searching SQL server keys for server %s", sqlServerName) serverKeyWrapper := manual.NewSqlServerKey( clients.NewSqlServerKeysClient(serverKeysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := serverKeyWrapper.Scopes()[0] serverKeyAdapter := sources.WrapperToAdapter(serverKeyWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := serverKeyAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, sqlServerName, true) if err != nil { t.Fatalf("Failed to search SQL server keys: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one SQL server key, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { expectedValue := shared.CompositeLookupKey(sqlServerName, serviceManagedKeyName) if v == expectedValue { found = true break } } } if !found { t.Fatalf("Expected to find key %s in the search results", serviceManagedKeyName) } log.Printf("Found %d SQL server keys in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for SQL server key %s", serviceManagedKeyName) serverKeyWrapper := manual.NewSqlServerKey( clients.NewSqlServerKeysClient(serverKeysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := serverKeyWrapper.Scopes()[0] serverKeyAdapter := sources.WrapperToAdapter(serverKeyWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(sqlServerName, serviceManagedKeyName) sdpItem, qErr := serverKeyAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (SQL server should be linked as parent) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify each linked item query has required fields for _, liq := range linkedQueries { if liq.GetQuery().GetType() == "" { t.Error("Linked item query has empty Type") } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET && liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linked item query has invalid Method: %v", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetQuery() == "" { t.Error("Linked item query has empty Query") } if liq.GetQuery().GetScope() == "" { t.Error("Linked item query has empty Scope") } } // Verify parent SQL Server link exists var hasSQLServerLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.SQLServer.String() { hasSQLServerLink = true if liq.GetQuery().GetQuery() != sqlServerName { t.Errorf("Expected linked query to SQL server %s, got %s", sqlServerName, liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET for SQL server, got %v", liq.GetQuery().GetMethod()) } break } } if !hasSQLServerLink { t.Error("Expected linked query to parent SQL server, but didn't find one") } log.Printf("Verified %d linked item queries for SQL server key %s", len(linkedQueries), serviceManagedKeyName) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for SQL server key %s", serviceManagedKeyName) serverKeyWrapper := manual.NewSqlServerKey( clients.NewSqlServerKeysClient(serverKeysClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := serverKeyWrapper.Scopes()[0] serverKeyAdapter := sources.WrapperToAdapter(serverKeyWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(sqlServerName, serviceManagedKeyName) sdpItem, qErr := serverKeyAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify GetType returns the expected item type if sdpItem.GetType() != azureshared.SQLServerKey.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerKey, sdpItem.GetType()) } // Verify GetScope returns the expected scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify GetUniqueAttribute returns the correct attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } // Verify Validate passes if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for SQL server key %s", serviceManagedKeyName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Only delete the SQL server if we created it if serverCreated && sqlServerName != "" { err := deleteSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Fatalf("Failed to delete SQL server: %v", err) } } else { log.Printf("Skipping SQL server deletion (using pre-existing server)") } // We don't delete the resource group to allow faster subsequent test runs }) } ================================================ FILE: sources/azure/integration-tests/sql-server_test.go ================================================ package integrationtests import ( "fmt" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) func TestSQLServerIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients sqlServerClient, err := armsql.NewServersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create SQL Servers client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique SQL server name (must be globally unique, lowercase, no special chars) sqlServerName := generateSQLServerName(integrationTestSQLServerName) setupCompleted := false t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create SQL server err = createSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create SQL server: %v", err) } // Wait for SQL server to be available err = waitForSQLServerAvailable(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Fatalf("Failed waiting for SQL server to be available: %v", err) } setupCompleted = true }) t.Run("Run", func(t *testing.T) { if !setupCompleted { t.Skip("Skipping Run: Setup did not complete successfully") } t.Run("GetSQLServer", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving SQL server %s in subscription %s, resource group %s", sqlServerName, subscriptionID, integrationTestResourceGroup) sqlServerWrapper := manual.NewSqlServer( clients.NewSqlServersClient(sqlServerClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := sqlServerWrapper.Scopes()[0] sqlServerAdapter := sources.WrapperToAdapter(sqlServerWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := sqlServerAdapter.Get(ctx, scope, sqlServerName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } if sdpItem.GetType() != azureshared.SQLServer.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServer, sdpItem.GetType()) } uniqueAttrKey := sdpItem.GetUniqueAttribute() if uniqueAttrKey != "name" { t.Errorf("Expected unique attribute 'name', got %s", uniqueAttrKey) } uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != sqlServerName { t.Errorf("Expected unique attribute value %s, got %s", sqlServerName, uniqueAttrValue) } if sdpItem.GetScope() != fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) { t.Errorf("Expected scope %s.%s, got %s", subscriptionID, integrationTestResourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Successfully retrieved SQL server %s", sqlServerName) }) t.Run("ListSQLServers", func(t *testing.T) { ctx := t.Context() log.Printf("Listing SQL servers in resource group %s", integrationTestResourceGroup) sqlServerWrapper := manual.NewSqlServer( clients.NewSqlServersClient(sqlServerClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := sqlServerWrapper.Scopes()[0] sqlServerAdapter := sources.WrapperToAdapter(sqlServerWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list listable, ok := sqlServerAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list SQL servers: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one SQL server, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil { if v == sqlServerName { found = true break } } } if !found { t.Fatalf("Expected to find SQL server %s in the list results", sqlServerName) } log.Printf("Found %d SQL servers in list results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for SQL server %s", sqlServerName) sqlServerWrapper := manual.NewSqlServer( clients.NewSqlServersClient(sqlServerClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := sqlServerWrapper.Scopes()[0] sqlServerAdapter := sources.WrapperToAdapter(sqlServerWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := sqlServerAdapter.Get(ctx, scope, sqlServerName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (SQL server has many child resources) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected child resource links exist expectedChildResources := map[string]bool{ azureshared.SQLDatabase.String(): false, azureshared.SQLElasticPool.String(): false, azureshared.SQLServerFirewallRule.String(): false, azureshared.SQLServerVirtualNetworkRule.String(): false, azureshared.SQLServerKey.String(): false, azureshared.SQLServerFailoverGroup.String(): false, azureshared.SQLServerAdministrator.String(): false, azureshared.SQLServerSyncGroup.String(): false, azureshared.SQLServerSyncAgent.String(): false, azureshared.SQLServerPrivateEndpointConnection.String(): false, azureshared.SQLServerAuditingSetting.String(): false, azureshared.SQLServerSecurityAlertPolicy.String(): false, azureshared.SQLServerVulnerabilityAssessment.String(): false, azureshared.SQLServerEncryptionProtector.String(): false, azureshared.SQLServerBlobAuditingPolicy.String(): false, azureshared.SQLServerAutomaticTuning.String(): false, azureshared.SQLServerAdvancedThreatProtectionSetting.String(): false, azureshared.SQLServerDnsAlias.String(): false, azureshared.SQLServerUsage.String(): false, azureshared.SQLServerOperation.String(): false, azureshared.SQLServerAdvisor.String(): false, azureshared.SQLServerBackupLongTermRetentionPolicy.String(): false, azureshared.SQLServerDevOpsAuditSetting.String(): false, azureshared.SQLServerTrustGroup.String(): false, azureshared.SQLServerOutboundFirewallRule.String(): false, azureshared.SQLServerPrivateLinkResource.String(): false, } for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() if expectedChildResources[linkedType] { t.Errorf("Found duplicate linked query for type %s", linkedType) } if _, exists := expectedChildResources[linkedType]; exists { expectedChildResources[linkedType] = true // Verify query method is SEARCH for child resources if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected linked query method SEARCH for %s, got %s", linkedType, liq.GetQuery().GetMethod()) } // Verify query is the server name if liq.GetQuery().GetQuery() != sqlServerName { t.Errorf("Expected linked query to use server name %s, got %s", sqlServerName, liq.GetQuery().GetQuery()) } // Verify scope matches if liq.GetQuery().GetScope() != scope { t.Errorf("Expected linked query scope %s, got %s", scope, liq.GetQuery().GetScope()) } } } // Check that all expected child resources are linked for resourceType, found := range expectedChildResources { if !found { t.Errorf("Expected linked query to %s, but didn't find one", resourceType) } } log.Printf("Verified %d linked item queries for SQL server %s", len(linkedQueries), sqlServerName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete SQL server err := deleteSQLServer(ctx, sqlServerClient, integrationTestResourceGroup, sqlServerName) if err != nil { t.Fatalf("Failed to delete SQL server: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } ================================================ FILE: sources/azure/integration-tests/storage-account_test.go ================================================ package integrationtests import ( "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) // Note: integrationTestSAName is already declared in storage-blob-container_test.go // Reusing it here since both tests are in the same package func TestStorageAccountIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique storage account name (must be globally unique, lowercase, 3-24 chars) storageAccountName := generateStorageAccountName(integrationTestSAName) t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create storage account err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } // Wait for storage account to be fully available err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account to be available: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetStorageAccount", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving storage account %s, subscription %s, resource group %s", storageAccountName, subscriptionID, integrationTestResourceGroup) saWrapper := manual.NewStorageAccount( clients.NewStorageAccountsClient(saClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := saWrapper.Scopes()[0] saAdapter := sources.WrapperToAdapter(saWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := saAdapter.Get(ctx, scope, storageAccountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != storageAccountName { t.Fatalf("Expected unique attribute value to be %s, got %s", storageAccountName, uniqueAttrValue) } if sdpItem.GetType() != azureshared.StorageAccount.String() { t.Fatalf("Expected type %s, got %s", azureshared.StorageAccount, sdpItem.GetType()) } log.Printf("Successfully retrieved storage account %s", storageAccountName) }) t.Run("ListStorageAccounts", func(t *testing.T) { ctx := t.Context() log.Printf("Listing storage accounts in resource group %s", integrationTestResourceGroup) saWrapper := manual.NewStorageAccount( clients.NewStorageAccountsClient(saClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := saWrapper.Scopes()[0] saAdapter := sources.WrapperToAdapter(saWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list listable, ok := saAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list storage accounts: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one storage account, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == storageAccountName { found = true if item.GetType() != azureshared.StorageAccount.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageAccount, item.GetType()) } break } } if !found { t.Fatalf("Expected to find storage account %s in the list results", storageAccountName) } log.Printf("Found %d storage accounts in list results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for storage account %s", storageAccountName) saWrapper := manual.NewStorageAccount( clients.NewStorageAccountsClient(saClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := saWrapper.Scopes()[0] saAdapter := sources.WrapperToAdapter(saWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := saAdapter.Get(ctx, scope, storageAccountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } // Verify expected linked item types expectedLinkedTypes := map[string]bool{ azureshared.StorageBlobContainer.String(): false, azureshared.StorageFileShare.String(): false, azureshared.StorageTable.String(): false, azureshared.StorageQueue.String(): false, } for _, liq := range linkedQueries { linkedType := liq.GetQuery().GetType() if _, exists := expectedLinkedTypes[linkedType]; exists { expectedLinkedTypes[linkedType] = true // Verify the query uses the storage account name if liq.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query to use storage account name %s, got %s", storageAccountName, liq.GetQuery().GetQuery()) } // Verify the query method is SEARCH (since we're linking to child resources) if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected linked query method to be SEARCH, got %s", liq.GetQuery().GetMethod()) } } } // Verify all expected linked types were found for linkedType, found := range expectedLinkedTypes { if !found { t.Errorf("Expected linked query to %s, but didn't find one", linkedType) } } log.Printf("Verified %d linked item queries for storage account %s", len(linkedQueries), storageAccountName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete storage account err := deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed to delete storage account: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } ================================================ FILE: sources/azure/integration-tests/storage-blob-container_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "math/rand" "net/http" "os" "strings" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestSAName = "ovm-integ-test-sa" integrationTestContainerName = "ovm-integ-test-container" ) func TestStorageBlobContainerIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } bcClient, err := armstorage.NewBlobContainersClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Blob Containers client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique storage account name (must be globally unique, lowercase, 3-24 chars) storageAccountName := generateStorageAccountName(integrationTestSAName) t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create storage account err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } // Wait for storage account to be fully available err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account to be available: %v", err) } // Create blob container err = createBlobContainer(ctx, bcClient, integrationTestResourceGroup, storageAccountName, integrationTestContainerName) if err != nil { t.Fatalf("Failed to create blob container: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetBlobContainer", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving blob container %s in storage account %s, subscription %s, resource group %s", integrationTestContainerName, storageAccountName, subscriptionID, integrationTestResourceGroup) bcWrapper := manual.NewStorageBlobContainer( clients.NewBlobContainersClient(bcClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := bcWrapper.Scopes()[0] bcAdapter := sources.WrapperToAdapter(bcWrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and containerName as query parts query := storageAccountName + shared.QuerySeparator + integrationTestContainerName sdpItem, qErr := bcAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedUniqueValue := shared.CompositeLookupKey(storageAccountName, integrationTestContainerName) if uniqueAttrValue != expectedUniqueValue { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedUniqueValue, uniqueAttrValue) } log.Printf("Successfully retrieved blob container %s", integrationTestContainerName) }) t.Run("SearchBlobContainers", func(t *testing.T) { ctx := t.Context() log.Printf("Searching blob containers in storage account %s", storageAccountName) bcWrapper := manual.NewStorageBlobContainer( clients.NewBlobContainersClient(bcClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := bcWrapper.Scopes()[0] bcAdapter := sources.WrapperToAdapter(bcWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := bcAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, storageAccountName, true) if err != nil { t.Fatalf("Failed to search blob containers: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one blob container, got %d", len(sdpItems)) } expectedUniqueValue := shared.CompositeLookupKey(storageAccountName, integrationTestContainerName) var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUniqueValue { found = true break } } if !found { t.Fatalf("Expected to find container %s in the search results", integrationTestContainerName) } log.Printf("Found %d blob containers in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for blob container %s", integrationTestContainerName) bcWrapper := manual.NewStorageBlobContainer( clients.NewBlobContainersClient(bcClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := bcWrapper.Scopes()[0] bcAdapter := sources.WrapperToAdapter(bcWrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + integrationTestContainerName sdpItem, qErr := bcAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (storage account should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasStorageAccountLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.StorageAccount.String() { hasStorageAccountLink = true if liq.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query to storage account %s, got %s", storageAccountName, liq.GetQuery().GetQuery()) } break } } if !hasStorageAccountLink { t.Error("Expected linked query to storage account, but didn't find one") } log.Printf("Verified %d linked item queries for blob container %s", len(linkedQueries), integrationTestContainerName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete blob container err := deleteBlobContainer(ctx, bcClient, integrationTestResourceGroup, storageAccountName, integrationTestContainerName) if err != nil { t.Fatalf("Failed to delete blob container: %v", err) } // Delete storage account err = deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed to delete storage account: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // generateStorageAccountName generates a unique storage account name // Storage account names must be globally unique, 3-24 characters, lowercase letters and numbers only func generateStorageAccountName(baseName string) string { // Ensure base name is lowercase and valid baseName = strings.ToLower(baseName) baseName = strings.ReplaceAll(baseName, "-", "") // Add random suffix to ensure uniqueness rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(os.Getpid()))) suffix := fmt.Sprintf("%04d", rng.Intn(10000)) name := baseName + suffix // Ensure length is within limits (3-24 chars) if len(name) > 24 { name = name[:24] } if len(name) < 3 { name = name + "000" } return name } // createStorageAccount creates an Azure storage account (idempotent) func createStorageAccount(ctx context.Context, client *armstorage.AccountsClient, resourceGroupName, accountName, location string) error { // Check if storage account already exists _, err := client.GetProperties(ctx, resourceGroupName, accountName, nil) if err == nil { log.Printf("Storage account %s already exists, skipping creation", accountName) return nil } // Create the storage account poller, err := client.BeginCreate(ctx, resourceGroupName, accountName, armstorage.AccountCreateParameters{ Location: new(location), Kind: new(armstorage.KindStorageV2), SKU: &armstorage.SKU{ Name: new(armstorage.SKUNameStandardLRS), }, Properties: &armstorage.AccountPropertiesCreateParameters{ AccessTier: new(armstorage.AccessTierHot), }, Tags: map[string]*string{ "purpose": new("overmind-integration-tests"), "test": new("storage-blob-container"), }, }, nil) if err != nil { // Check if storage account already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Storage account %s already exists (conflict), skipping creation", accountName) return nil } return fmt.Errorf("failed to begin creating storage account: %w", err) } resp, err := poller.PollUntilDone(ctx, nil) if err != nil { return fmt.Errorf("failed to create storage account: %w", err) } // Verify the storage account was created successfully if resp.Properties == nil || resp.Properties.ProvisioningState == nil { return fmt.Errorf("storage account created but provisioning state is unknown") } provisioningState := *resp.Properties.ProvisioningState if provisioningState != armstorage.ProvisioningStateSucceeded { return fmt.Errorf("storage account provisioning state is %s, expected %s", provisioningState, armstorage.ProvisioningStateSucceeded) } log.Printf("Storage account %s created successfully with provisioning state: %s", accountName, provisioningState) return nil } // waitForStorageAccountAvailable polls until the storage account is available via the Get API func waitForStorageAccountAvailable(ctx context.Context, client *armstorage.AccountsClient, resourceGroupName, accountName string) error { maxAttempts := 20 pollInterval := 10 * time.Second log.Printf("Waiting for storage account %s to be available via API...", accountName) for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := client.GetProperties(ctx, resourceGroupName, accountName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Storage account %s not yet available (attempt %d/%d), waiting %v...", accountName, attempt, maxAttempts, pollInterval) time.Sleep(pollInterval) continue } return fmt.Errorf("error checking storage account availability: %w", err) } // Check provisioning state if resp.Properties != nil && resp.Properties.ProvisioningState != nil { state := *resp.Properties.ProvisioningState if state == armstorage.ProvisioningStateSucceeded { log.Printf("Storage account %s is available with provisioning state: %s", accountName, state) return nil } if state == "Failed" { return fmt.Errorf("storage account provisioning failed with state: %s", state) } // Still provisioning, wait and retry log.Printf("Storage account %s provisioning state: %s (attempt %d/%d), waiting...", accountName, state, attempt, maxAttempts) time.Sleep(pollInterval) continue } // Storage account exists but no provisioning state - consider it available log.Printf("Storage account %s is available", accountName) return nil } return fmt.Errorf("timeout waiting for storage account %s to be available after %d attempts", accountName, maxAttempts) } // createBlobContainer creates an Azure blob container (idempotent) func createBlobContainer(ctx context.Context, client *armstorage.BlobContainersClient, resourceGroupName, accountName, containerName string) error { // Check if container already exists _, err := client.Get(ctx, resourceGroupName, accountName, containerName, nil) if err == nil { log.Printf("Blob container %s already exists, skipping creation", containerName) return nil } // Create the blob container resp, err := client.Create(ctx, resourceGroupName, accountName, containerName, armstorage.BlobContainer{ ContainerProperties: &armstorage.ContainerProperties{ PublicAccess: new(armstorage.PublicAccessNone), }, }, nil) if err != nil { // Check if container already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Blob container %s already exists (conflict), skipping creation", containerName) return nil } return fmt.Errorf("failed to create blob container: %w", err) } // Verify the container was created successfully if resp.ID == nil { return fmt.Errorf("blob container created but ID is unknown") } log.Printf("Blob container %s created successfully", containerName) return nil } // deleteBlobContainer deletes an Azure blob container func deleteBlobContainer(ctx context.Context, client *armstorage.BlobContainersClient, resourceGroupName, accountName, containerName string) error { _, err := client.Delete(ctx, resourceGroupName, accountName, containerName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Blob container %s not found, skipping deletion", containerName) return nil } return fmt.Errorf("failed to delete blob container: %w", err) } log.Printf("Blob container %s deleted successfully", containerName) return nil } // deleteStorageAccount deletes an Azure storage account func deleteStorageAccount(ctx context.Context, client *armstorage.AccountsClient, resourceGroupName, accountName string) error { _, err := client.Delete(ctx, resourceGroupName, accountName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Storage account %s not found, skipping deletion", accountName) return nil } return fmt.Errorf("failed to delete storage account: %w", err) } log.Printf("Storage account %s deleted successfully", accountName) // Poll to verify the storage account is actually deleted and Azure has released associated resources. // Azure may take some time to fully delete the storage account and release its globally unique name. // This ensures subsequent test runs can reuse the same storage account name without conflicts. // The polling approach is more efficient than a fixed sleep as it returns as soon as deletion is confirmed. err = waitForStorageAccountDeleted(ctx, client, resourceGroupName, accountName) if err != nil { // Log the error but don't fail - deletion was initiated successfully // The polling failure might be due to timeout, but the resource should still be deleted log.Printf("Warning: Could not confirm storage account deletion via polling: %v", err) } return nil } // waitForStorageAccountDeleted polls until the storage account is confirmed deleted // This ensures Azure has released the storage account name and associated resources. // The wait duration can be configured via AZURE_RESOURCE_DELETE_WAIT_SECONDS environment variable // (default: 30 seconds max wait time with 2-second polling intervals). func waitForStorageAccountDeleted(ctx context.Context, client *armstorage.AccountsClient, resourceGroupName, accountName string) error { // Allow configuration via environment variable, default to 30 seconds maxWaitSeconds := 30 if envWait := os.Getenv("AZURE_RESOURCE_DELETE_WAIT_SECONDS"); envWait != "" { if parsed, err := time.ParseDuration(envWait + "s"); err == nil { maxWaitSeconds = int(parsed.Seconds()) } } maxAttempts := maxWaitSeconds / 2 // Poll every 2 seconds pollInterval := 2 * time.Second log.Printf("Verifying storage account %s is deleted (max wait: %d seconds)...", accountName, maxWaitSeconds) for attempt := 1; attempt <= maxAttempts; attempt++ { _, err := client.GetProperties(ctx, resourceGroupName, accountName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Storage account %s confirmed deleted", accountName) return nil } // Unexpected error - log but continue polling log.Printf("Unexpected error checking storage account deletion status: %v (attempt %d/%d)", err, attempt, maxAttempts) } else { // Storage account still exists log.Printf("Storage account %s still exists (attempt %d/%d), waiting %v...", accountName, attempt, maxAttempts, pollInterval) } if attempt < maxAttempts { time.Sleep(pollInterval) } } return fmt.Errorf("timeout waiting for storage account %s to be confirmed deleted after %d attempts", accountName, maxAttempts) } ================================================ FILE: sources/azure/integration-tests/storage-fileshare_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestShareName = "ovm-integ-test-share" ) func TestStorageFileShareIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } fsClient, err := armstorage.NewFileSharesClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create File Shares client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique storage account name (must be globally unique, lowercase, 3-24 chars) storageAccountName := generateStorageAccountName(integrationTestSAName) t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create storage account err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } // Wait for storage account to be fully available err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account to be available: %v", err) } // Create file share err = createFileShare(ctx, fsClient, integrationTestResourceGroup, storageAccountName, integrationTestShareName) if err != nil { t.Fatalf("Failed to create file share: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetFileShare", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving file share %s in storage account %s, subscription %s, resource group %s", integrationTestShareName, storageAccountName, subscriptionID, integrationTestResourceGroup) fsWrapper := manual.NewStorageFileShare( clients.NewFileSharesClient(fsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := fsWrapper.Scopes()[0] fsAdapter := sources.WrapperToAdapter(fsWrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and shareName as query parts query := storageAccountName + shared.QuerySeparator + integrationTestShareName sdpItem, qErr := fsAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != shared.CompositeLookupKey(storageAccountName, integrationTestShareName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(storageAccountName, integrationTestShareName), uniqueAttrValue) } log.Printf("Successfully retrieved file share %s", integrationTestShareName) }) t.Run("SearchFileShares", func(t *testing.T) { ctx := t.Context() log.Printf("Searching file shares in storage account %s", storageAccountName) fsWrapper := manual.NewStorageFileShare( clients.NewFileSharesClient(fsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := fsWrapper.Scopes()[0] fsAdapter := sources.WrapperToAdapter(fsWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := fsAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, storageAccountName, true) if err != nil { t.Fatalf("Failed to search file shares: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one file share, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == shared.CompositeLookupKey(storageAccountName, integrationTestShareName) { found = true break } } if !found { t.Fatalf("Expected to find share %s in the search results", integrationTestShareName) } log.Printf("Found %d file shares in search results", len(sdpItems)) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for file share %s", integrationTestShareName) fsWrapper := manual.NewStorageFileShare( clients.NewFileSharesClient(fsClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := fsWrapper.Scopes()[0] fsAdapter := sources.WrapperToAdapter(fsWrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + integrationTestShareName sdpItem, qErr := fsAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (storage account should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasStorageAccountLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.StorageAccount.String() { hasStorageAccountLink = true if liq.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query to storage account %s, got %s", storageAccountName, liq.GetQuery().GetQuery()) } break } } if !hasStorageAccountLink { t.Error("Expected linked query to storage account, but didn't find one") } log.Printf("Verified %d linked item queries for file share %s", len(linkedQueries), integrationTestShareName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete file share err := deleteFileShare(ctx, fsClient, integrationTestResourceGroup, storageAccountName, integrationTestShareName) if err != nil { t.Fatalf("Failed to delete file share: %v", err) } // Delete storage account err = deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed to delete storage account: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createFileShare creates an Azure file share (idempotent) func createFileShare(ctx context.Context, client *armstorage.FileSharesClient, resourceGroupName, accountName, shareName string) error { // Check if file share already exists _, err := client.Get(ctx, resourceGroupName, accountName, shareName, nil) if err == nil { log.Printf("File share %s already exists, skipping creation", shareName) return nil } // Create the file share // File shares require a quota (size in GB) resp, err := client.Create(ctx, resourceGroupName, accountName, shareName, armstorage.FileShare{ FileShareProperties: &armstorage.FileShareProperties{ ShareQuota: new(int32(1)), // 1GB minimum quota }, }, nil) if err != nil { // Check if file share already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("File share %s already exists (conflict), skipping creation", shareName) return nil } return fmt.Errorf("failed to create file share: %w", err) } // Verify the file share was created successfully if resp.ID == nil { return fmt.Errorf("file share created but ID is unknown") } log.Printf("File share %s created successfully", shareName) return nil } // deleteFileShare deletes an Azure file share func deleteFileShare(ctx context.Context, client *armstorage.FileSharesClient, resourceGroupName, accountName, shareName string) error { _, err := client.Delete(ctx, resourceGroupName, accountName, shareName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("File share %s not found, skipping deletion", shareName) return nil } return fmt.Errorf("failed to delete file share: %w", err) } log.Printf("File share %s deleted successfully", shareName) return nil } ================================================ FILE: sources/azure/integration-tests/storage-queues_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestQueueName = "ovm-integ-test-queue" ) func TestStorageQueuesIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } queueClient, err := armstorage.NewQueueClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Queue client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique storage account name (must be globally unique, lowercase, 3-24 chars) storageAccountName := generateStorageAccountName(integrationTestSAName) t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create storage account err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } // Wait for storage account to be fully available err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account to be available: %v", err) } // Create queue err = createQueue(ctx, queueClient, integrationTestResourceGroup, storageAccountName, integrationTestQueueName) if err != nil { t.Fatalf("Failed to create queue: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetQueue", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving queue %s in storage account %s, subscription %s, resource group %s", integrationTestQueueName, storageAccountName, subscriptionID, integrationTestResourceGroup) queueWrapper := manual.NewStorageQueues( clients.NewQueuesClient(queueClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := queueWrapper.Scopes()[0] queueAdapter := sources.WrapperToAdapter(queueWrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and queueName as query parts query := shared.CompositeLookupKey(storageAccountName, integrationTestQueueName) sdpItem, qErr := queueAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedID := shared.CompositeLookupKey(storageAccountName, integrationTestQueueName) if uniqueAttrValue != expectedID { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedID, uniqueAttrValue) } log.Printf("Successfully retrieved queue %s", integrationTestQueueName) }) t.Run("SearchQueues", func(t *testing.T) { ctx := t.Context() log.Printf("Searching queues in storage account %s", storageAccountName) queueWrapper := manual.NewStorageQueues( clients.NewQueuesClient(queueClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := queueWrapper.Scopes()[0] queueAdapter := sources.WrapperToAdapter(queueWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := queueAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, storageAccountName, true) if err != nil { t.Fatalf("Failed to search queues: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one queue, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() expectedID := shared.CompositeLookupKey(storageAccountName, integrationTestQueueName) if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedID { found = true break } } if !found { t.Fatalf("Expected to find queue %s in the search results", integrationTestQueueName) } log.Printf("Found %d queues in search results", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for queue %s", integrationTestQueueName) queueWrapper := manual.NewStorageQueues( clients.NewQueuesClient(queueClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := queueWrapper.Scopes()[0] queueAdapter := sources.WrapperToAdapter(queueWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(storageAccountName, integrationTestQueueName) sdpItem, qErr := queueAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.StorageQueue.String() { t.Errorf("Expected item type %s, got %s", azureshared.StorageQueue, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for queue %s", integrationTestQueueName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for queue %s", integrationTestQueueName) queueWrapper := manual.NewStorageQueues( clients.NewQueuesClient(queueClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := queueWrapper.Scopes()[0] queueAdapter := sources.WrapperToAdapter(queueWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(storageAccountName, integrationTestQueueName) sdpItem, qErr := queueAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (storage account should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasStorageAccountLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.StorageAccount.String() { hasStorageAccountLink = true if liq.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query to storage account %s, got %s", storageAccountName, liq.GetQuery().GetQuery()) } break } } if !hasStorageAccountLink { t.Error("Expected linked query to storage account, but didn't find one") } log.Printf("Verified %d linked item queries for queue %s", len(linkedQueries), integrationTestQueueName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete queue err := deleteQueue(ctx, queueClient, integrationTestResourceGroup, storageAccountName, integrationTestQueueName) if err != nil { t.Fatalf("Failed to delete queue: %v", err) } // Delete storage account err = deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed to delete storage account: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createQueue creates an Azure storage queue (idempotent) func createQueue(ctx context.Context, client *armstorage.QueueClient, resourceGroupName, accountName, queueName string) error { // Check if queue already exists _, err := client.Get(ctx, resourceGroupName, accountName, queueName, nil) if err == nil { log.Printf("Queue %s already exists, skipping creation", queueName) return nil } // Create the queue // Queues don't require any properties, they can be created with an empty QueueProperties resp, err := client.Create(ctx, resourceGroupName, accountName, queueName, armstorage.Queue{ QueueProperties: &armstorage.QueueProperties{ // Metadata is optional, can be nil }, }, nil) if err != nil { // Check if queue already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Queue %s already exists (conflict), skipping creation", queueName) return nil } return fmt.Errorf("failed to create queue: %w", err) } // Verify the queue was created successfully if resp.ID == nil { return fmt.Errorf("queue created but ID is unknown") } log.Printf("Queue %s created successfully", queueName) return nil } // deleteQueue deletes an Azure storage queue func deleteQueue(ctx context.Context, client *armstorage.QueueClient, resourceGroupName, accountName, queueName string) error { _, err := client.Delete(ctx, resourceGroupName, accountName, queueName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Queue %s not found, skipping deletion", queueName) return nil } return fmt.Errorf("failed to delete queue: %w", err) } log.Printf("Queue %s deleted successfully", queueName) return nil } ================================================ FILE: sources/azure/integration-tests/storage-table_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) const ( integrationTestTableName = "ovmintegtesttable" ) func TestStorageTableIntegration(t *testing.T) { subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") if subscriptionID == "" { t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set") } // Initialize Azure credentials using DefaultAzureCredential cred, err := azureshared.NewAzureCredential(t.Context()) if err != nil { t.Fatalf("Failed to create Azure credential: %v", err) } // Create Azure SDK clients saClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Storage Accounts client: %v", err) } tableClient, err := armstorage.NewTableClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Table client: %v", err) } rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { t.Fatalf("Failed to create Resource Groups client: %v", err) } // Generate unique storage account name (must be globally unique, lowercase, 3-24 chars) storageAccountName := generateStorageAccountName(integrationTestSAName) t.Run("Setup", func(t *testing.T) { ctx := t.Context() // Create resource group if it doesn't exist err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation) if err != nil { t.Fatalf("Failed to create resource group: %v", err) } // Create storage account err = createStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName, integrationTestLocation) if err != nil { t.Fatalf("Failed to create storage account: %v", err) } // Wait for storage account to be fully available err = waitForStorageAccountAvailable(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed waiting for storage account to be available: %v", err) } // Create table err = createTable(ctx, tableClient, integrationTestResourceGroup, storageAccountName, integrationTestTableName) if err != nil { t.Fatalf("Failed to create table: %v", err) } }) t.Run("Run", func(t *testing.T) { t.Run("GetTable", func(t *testing.T) { ctx := t.Context() log.Printf("Retrieving table %s in storage account %s, subscription %s, resource group %s", integrationTestTableName, storageAccountName, subscriptionID, integrationTestResourceGroup) tableWrapper := manual.NewStorageTable( clients.NewTablesClient(tableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := tableWrapper.Scopes()[0] tableAdapter := sources.WrapperToAdapter(tableWrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and tableName as query parts query := shared.CompositeLookupKey(storageAccountName, integrationTestTableName) sdpItem, qErr := tableAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } expectedID := shared.CompositeLookupKey(storageAccountName, integrationTestTableName) if uniqueAttrValue != expectedID { t.Fatalf("Expected unique attribute value to be %s, got %s", expectedID, uniqueAttrValue) } log.Printf("Successfully retrieved table %s", integrationTestTableName) }) t.Run("SearchTables", func(t *testing.T) { ctx := t.Context() log.Printf("Searching tables in storage account %s", storageAccountName) tableWrapper := manual.NewStorageTable( clients.NewTablesClient(tableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := tableWrapper.Scopes()[0] tableAdapter := sources.WrapperToAdapter(tableWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports search searchable, ok := tableAdapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, storageAccountName, true) if err != nil { t.Fatalf("Failed to search tables: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one table, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() expectedID := shared.CompositeLookupKey(storageAccountName, integrationTestTableName) if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedID { found = true break } } if !found { t.Fatalf("Expected to find table %s in the search results", integrationTestTableName) } log.Printf("Found %d tables in search results", len(sdpItems)) }) t.Run("VerifyItemAttributes", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying item attributes for table %s", integrationTestTableName) tableWrapper := manual.NewStorageTable( clients.NewTablesClient(tableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := tableWrapper.Scopes()[0] tableAdapter := sources.WrapperToAdapter(tableWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(storageAccountName, integrationTestTableName) sdpItem, qErr := tableAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify item type if sdpItem.GetType() != azureshared.StorageTable.String() { t.Errorf("Expected item type %s, got %s", azureshared.StorageTable, sdpItem.GetType()) } // Verify scope expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope()) } // Verify unique attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } // Verify item validation if err := sdpItem.Validate(); err != nil { t.Fatalf("Item validation failed: %v", err) } log.Printf("Verified item attributes for table %s", integrationTestTableName) }) t.Run("VerifyLinkedItems", func(t *testing.T) { ctx := t.Context() log.Printf("Verifying linked items for table %s", integrationTestTableName) tableWrapper := manual.NewStorageTable( clients.NewTablesClient(tableClient), []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)}, ) scope := tableWrapper.Scopes()[0] tableAdapter := sources.WrapperToAdapter(tableWrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(storageAccountName, integrationTestTableName) sdpItem, qErr := tableAdapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked items exist (storage account should be linked) linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatalf("Expected linked item queries, but got none") } var hasStorageAccountLink bool for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.StorageAccount.String() { hasStorageAccountLink = true if liq.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query to storage account %s, got %s", storageAccountName, liq.GetQuery().GetQuery()) } break } } if !hasStorageAccountLink { t.Error("Expected linked query to storage account, but didn't find one") } log.Printf("Verified %d linked item queries for table %s", len(linkedQueries), integrationTestTableName) }) }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() // Delete table err := deleteTable(ctx, tableClient, integrationTestResourceGroup, storageAccountName, integrationTestTableName) if err != nil { t.Fatalf("Failed to delete table: %v", err) } // Delete storage account err = deleteStorageAccount(ctx, saClient, integrationTestResourceGroup, storageAccountName) if err != nil { t.Fatalf("Failed to delete storage account: %v", err) } // Optionally delete the resource group // Note: We keep the resource group for faster subsequent test runs // Uncomment the following if you want to clean up completely: // err = deleteResourceGroup(ctx, rgClient, integrationTestResourceGroup) // if err != nil { // t.Fatalf("Failed to delete resource group: %v", err) // } }) } // createTable creates an Azure storage table (idempotent) func createTable(ctx context.Context, client *armstorage.TableClient, resourceGroupName, accountName, tableName string) error { // Check if table already exists _, err := client.Get(ctx, resourceGroupName, accountName, tableName, nil) if err == nil { log.Printf("Table %s already exists, skipping creation", tableName) return nil } // Create the table // Tables don't require any properties resp, err := client.Create(ctx, resourceGroupName, accountName, tableName, &armstorage.TableClientCreateOptions{ Parameters: &armstorage.Table{ TableProperties: &armstorage.TableProperties{}, }, }) if err != nil { // Check if table already exists (conflict) var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict { log.Printf("Table %s already exists (conflict), skipping creation", tableName) return nil } return fmt.Errorf("failed to create table: %w", err) } // Verify the table was created successfully if resp.ID == nil { return fmt.Errorf("table created but ID is unknown") } log.Printf("Table %s created successfully", tableName) return nil } // deleteTable deletes an Azure storage table func deleteTable(ctx context.Context, client *armstorage.TableClient, resourceGroupName, accountName, tableName string) error { _, err := client.Delete(ctx, resourceGroupName, accountName, tableName, nil) if err != nil { var respErr *azcore.ResponseError if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound { log.Printf("Table %s not found, skipping deletion", tableName) return nil } return fmt.Errorf("failed to delete table: %w", err) } log.Printf("Table %s deleted successfully", tableName) return nil } ================================================ FILE: sources/azure/main.go ================================================ package main import ( _ "go.uber.org/automaxprocs" "github.com/overmindtech/cli/sources/azure/cmd" ) func main() { cmd.Execute() } ================================================ FILE: sources/azure/manual/README.md ================================================ # Azure Manual Adapters This directory contains manually implemented Azure adapters that cannot be generated using the dynamic adapter framework due to their complex API response patterns or resource relationships. ## When to Use Manual Adapters **Prefer Dynamic Adapters**: Always use the [dynamic adapter framework](../../dynamic/adapters/README.md) when possible. Dynamic adapters can leverage the [Azure Resource List API](https://learn.microsoft.com/en-us/rest/api/resources/resources/list?view=rest-resources-2021-04-01) which lists all resources in a subscription, similar to how GCP dynamic adapters work. This makes dynamic adapters easier to maintain and automatically generated from Azure API specifications. **Create Manual Adapters Only When**: 1. **Non-standard API Response Format**: The Azure API response doesn't follow the general pattern where resource names or attributes reference different types of resources that require manual handling for linked item queries. 2. **Complex Resource Relationships**: The adapter needs to manually parse and link to multiple different resource types based on the API response content. ## Examples of Manual Adapter Use Cases ### Non-standard API Response Format **Compute Virtual Machine** (`compute-virtual-machine.go`): - Complex resource ID parsing from Azure resource manager format - Requires manual extraction of resource names from full resource IDs (`/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/virtualMachines/{vmName}`) - Multiple disk and network interface references need manual parsing ### Attributes Referencing Different Resource Types **Virtual Machine with Multiple Linked Resources**: - The `Properties` field contains references to multiple different resource types: - Managed Disks: `/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/disks/{diskName}` - Network Interfaces: `/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Network/networkInterfaces/{nicName}` - Availability Sets: `/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/availabilitySets/{availabilitySetName}` - Public IP Addresses: Referenced through network interfaces - Network Security Groups: Referenced through network interfaces - Requires manual parsing and conditional linking based on the resource ID format and provider namespace **Network Private DNS Zone** (`network-private-dns-zone.go`): - Discovers Azure Private DNS Zones via `armprivatedns`; uses `MultiResourceGroupBase` and list-by-resource-group pager - Links zone name to stdlib DNS for resolution; health from provisioning state ## Implementation Guidelines ### For Detailed Implementation Rules Refer to the [cursor rules](.cursor/rules/azure-manual-adapter-creation.mdc) for comprehensive implementation patterns, examples, and best practices. ### Key Implementation Requirements 1. **Follow Naming Conventions**: - File names: `{api}-{resource}.go` (e.g., `compute-virtual-machine.go`, `network-virtual-network.go`) - Struct names: `{resourceName}Wrapper` (e.g., `computeVirtualMachineWrapper`, `networkVirtualNetworkWrapper`) - Constructor: `New{ResourceName}` (e.g., `NewComputeVirtualMachine`, `NewNetworkVirtualNetwork`) 2. **Implement Required Methods**: - `IAMPermissions()` - List specific Azure RBAC permissions (e.g., `Microsoft.Compute/virtualMachines/read`) - `PredefinedRole()` - Most restrictive Azure built-in role (e.g., `Reader`, `Virtual Machine Contributor`) - `PotentialLinks()` - All possible linked resource types - `TerraformMappings()` - Terraform registry mappings (using `azurerm_` provider) - `GetLookups()` / `SearchLookups()` - Query parameter definitions 3. **Handle Complex Resource Linking**: - Parse Azure resource IDs to extract resource names and types - Extract resource identifiers from Azure resource manager format - Create appropriate linked item queries 4. **Include Comprehensive Tests**: - Unit tests for all methods - Static tests for linked item queries - Mock-based testing with gomock - Interface compliance tests ## Code Review Checklist When reviewing PRs for manual adapters, ensure: ### ✅ Fundamentals Coverage - [ ] Unit tests cover all adapter methods (Get, List, Search if applicable) - [ ] Static tests validate linked item queries using `shared.RunStaticTests` - [ ] Mock expectations are properly set up with gomock - [ ] Interface compliance is tested (ListableWrapper, SearchableWrapper, etc.) ### ✅ Terraform Integration - [ ] Terraform mappings reference official Terraform registry URLs - [ ] Terraform method (GET vs SEARCH) matches adapter capabilities - [ ] Terraform query map uses correct resource attribute names ### ✅ Naming and Structure - [ ] File name follows `{api}-{resource}.go` convention (e.g., `compute-subnetwork.go`) - [ ] Struct and function names follow Go conventions - [ ] Package imports are properly organized ### ✅ Linked Item Queries - [ ] Example values in tests match actual Azure resource formats - [ ] Scopes for linked item queries are correct (verify with linked resource documentation) - [ ] Linked item queries are appropriately defined - [ ] All possible resource references are handled (no missing cases) ### ✅ Documentation and References - [ ] Azure REST API documentation URLs are included in comments - [ ] Resource relationship explanations are documented - [ ] Complex parsing logic is well-commented - [ ] Official Azure reference links are provided for linked resources ### ✅ Error Handling - [ ] Proper error wrapping with `azureshared.QueryError` - [ ] Input validation for parsed values - [ ] Graceful handling of malformed API responses ## Testing Examples ### Static Tests for Linked Item Queries ```go t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, // ... more test cases } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) ``` ### Mock Setup for Complex APIs ```go mockClient := mocks.NewMockVirtualMachinesClient(ctrl) vm := createAzureVirtualMachine("test-vm", "Succeeded") mockClient.EXPECT().Get(ctx, resourceGroup, vmName, nil).Return( armcompute.VirtualMachinesClientGetResponse{VirtualMachine: *vm}, nil) ``` ## Common Patterns ### Parsing Azure Resource IDs ```go // Azure resource ID format: /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/disks/{diskName} diskName := azureshared.ExtractResourceName(*vm.Properties.StorageProfile.OSDisk.ManagedDisk.ID) if diskName == "" { return nil, azureshared.QueryError(fmt.Errorf("invalid disk resource ID: %s", *vm.Properties.StorageProfile.OSDisk.ManagedDisk.ID), scope, c.Type()) } ``` ### Conditional Resource Linking ```go if vm.Properties.NetworkProfile != nil && len(vm.Properties.NetworkProfile.NetworkInterfaces) > 0 { for _, nicRef := range vm.Properties.NetworkProfile.NetworkInterfaces { if nicRef.ID != nil { nicName := azureshared.ExtractResourceName(*nicRef.ID) // Determine resource type from provider namespace in ID if strings.Contains(*nicRef.ID, "Microsoft.Network/networkInterfaces") { // Handle network interface linking } } } } ``` ### Resource ID Extraction ```go // Extract resource name from Azure resource ID // ID: /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/virtualMachines/{vmName} resourceName := azureshared.ExtractResourceName(resourceID) if resourceName != "" { // Use extracted resource name for linking } ``` ## Getting Help - **Implementation Details**: See [cursor rules](.cursor/rules/azure-manual-adapter-creation.mdc) - **Dynamic Adapters**: See [dynamic adapter README](../../dynamic/adapters/README.md) - Note: Azure dynamic adapters can leverage the [Azure Resource List API](https://learn.microsoft.com/en-us/rest/api/resources/resources/list?view=rest-resources-2021-04-01) to list all resources in a subscription - **General Source Adapters**: See [sources README](../../README.md) - **Azure API Documentation**: Always reference official Azure REST API documentation for API specifics ## Related Files - **Cursor Rules**: `.cursor/rules/azure-manual-adapter-creation.mdc` - Comprehensive implementation guide - **Shared Utilities**: `../../shared/` - Common utilities and patterns - **Azure Shared**: `../shared/` - Azure-specific utilities and base structs - **Test Utilities**: `../../shared/testing.go` - Testing helpers and patterns ================================================ FILE: sources/azure/manual/adapters.go ================================================ package manual import ( "context" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) // Adapters returns a slice of discovery.Adapter instances for Azure Source. // It initializes Azure clients if initAzureClients is true, and creates adapters for the specified subscription ID and regions. // Otherwise, it uses nil clients, which is useful for enumerating adapters for documentation purposes. func Adapters(ctx context.Context, subscriptionID string, regions []string, cred *azidentity.DefaultAzureCredential, initAzureClients bool, cache sdpcache.Cache) ([]discovery.Adapter, error) { var adapters []discovery.Adapter if initAzureClients { if cred == nil { return nil, fmt.Errorf("credentials are required when initAzureClients is true") } log.WithFields(log.Fields{ "ovm.source.subscription_id": subscriptionID, "ovm.source.regions": regions, }).Info("Initializing Azure clients and discovering resource groups") // Create resource groups client to discover all resource groups in the subscription rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create resource groups client: %w", err) } // Discover resource groups in the subscription resourceGroups := make([]string, 0) pager := rgClient.NewListPager(nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, fmt.Errorf("failed to list resource groups: %w", err) } for _, rg := range page.Value { if rg.Name != nil { resourceGroups = append(resourceGroups, *rg.Name) } } } log.WithFields(log.Fields{ "ovm.source.subscription_id": subscriptionID, "ovm.source.resource_group_count": len(resourceGroups), }).Info("Discovered resource groups") // Build resource group scopes for multi-scope adapters resourceGroupScopes := make([]azureshared.ResourceGroupScope, 0, len(resourceGroups)) for _, rg := range resourceGroups { resourceGroupScopes = append(resourceGroupScopes, azureshared.NewResourceGroupScope(subscriptionID, rg)) } // Initialize Azure SDK clients vmClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual machines client: %w", err) } storageAccountsClient, err := armstorage.NewAccountsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create storage accounts client: %w", err) } blobContainersClient, err := armstorage.NewBlobContainersClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create blob containers client: %w", err) } fileSharesClient, err := armstorage.NewFileSharesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create file shares client: %w", err) } queuesClient, err := armstorage.NewQueueClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create queues client: %w", err) } tablesClient, err := armstorage.NewTableClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create tables client: %w", err) } encryptionScopesClient, err := armstorage.NewEncryptionScopesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create encryption scopes client: %w", err) } privateEndpointConnectionsClient, err := armstorage.NewPrivateEndpointConnectionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create private endpoint connections client: %w", err) } virtualNetworksClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual networks client: %w", err) } subnetsClient, err := armnetwork.NewSubnetsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create subnets client: %w", err) } virtualNetworkPeeringsClient, err := armnetwork.NewVirtualNetworkPeeringsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual network peerings client: %w", err) } networkInterfacesClient, err := armnetwork.NewInterfacesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create network interfaces client: %w", err) } interfaceIPConfigurationsClient, err := armnetwork.NewInterfaceIPConfigurationsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create interface IP configurations client: %w", err) } sqlDatabasesClient, err := armsql.NewDatabasesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql databases client: %w", err) } sqlDatabaseSchemasClient, err := armsql.NewDatabaseSchemasClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql database schemas client: %w", err) } documentDBDatabaseAccountsClient, err := armcosmos.NewDatabaseAccountsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create document db database accounts client: %w", err) } documentDBPrivateEndpointConnectionsClient, err := armcosmos.NewPrivateEndpointConnectionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create document db private endpoint connections client: %w", err) } keyVaultsClient, err := armkeyvault.NewVaultsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create key vaults client: %w", err) } postgreSQLDatabasesClient, err := armpostgresqlflexibleservers.NewDatabasesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgreSQL databases client: %w", err) } publicIPAddressesClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create public ip addresses client: %w", err) } publicIPPrefixesClient, err := armnetwork.NewPublicIPPrefixesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create public ip prefixes client: %w", err) } ddosProtectionPlansClient, err := armnetwork.NewDdosProtectionPlansClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create DDoS protection plans client: %w", err) } loadBalancersClient, err := armnetwork.NewLoadBalancersClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create load balancers client: %w", err) } loadBalancerFrontendIPConfigurationsClient, err := armnetwork.NewLoadBalancerFrontendIPConfigurationsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create load balancer frontend IP configurations client: %w", err) } loadBalancerBackendAddressPoolsClient, err := armnetwork.NewLoadBalancerBackendAddressPoolsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create load balancer backend address pools client: %w", err) } loadBalancerProbesClient, err := armnetwork.NewLoadBalancerProbesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create load balancer probes client: %w", err) } privateEndpointsClient, err := armnetwork.NewPrivateEndpointsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create private endpoints client: %w", err) } privateLinkServicesClient, err := armnetwork.NewPrivateLinkServicesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create private link services client: %w", err) } batchAccountsClient, err := armbatch.NewAccountClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create batch accounts client: %w", err) } batchApplicationClient, err := armbatch.NewApplicationClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create batch application client: %w", err) } batchPoolClient, err := armbatch.NewPoolClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create batch pool client: %w", err) } batchApplicationPackageClient, err := armbatch.NewApplicationPackageClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create batch application package client: %w", err) } batchPrivateEndpointConnectionClient, err := armbatch.NewPrivateEndpointConnectionClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create batch private endpoint connection client: %w", err) } virtualMachineScaleSetsClient, err := armcompute.NewVirtualMachineScaleSetsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual machine scale sets client: %w", err) } availabilitySetsClient, err := armcompute.NewAvailabilitySetsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create availability sets client: %w", err) } disksClient, err := armcompute.NewDisksClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create disks client: %w", err) } networkSecurityGroupsClient, err := armnetwork.NewSecurityGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create network security groups client: %w", err) } routeTablesClient, err := armnetwork.NewRouteTablesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create route tables client: %w", err) } routesClient, err := armnetwork.NewRoutesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create routes client: %w", err) } securityRulesClient, err := armnetwork.NewSecurityRulesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create security rules client: %w", err) } defaultSecurityRulesClient, err := armnetwork.NewDefaultSecurityRulesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create default security rules client: %w", err) } applicationGatewaysClient, err := armnetwork.NewApplicationGatewaysClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create application gateways client: %w", err) } applicationSecurityGroupsClient, err := armnetwork.NewApplicationSecurityGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create application security groups client: %w", err) } ipGroupsClient, err := armnetwork.NewIPGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create IP groups client: %w", err) } virtualNetworkGatewaysClient, err := armnetwork.NewVirtualNetworkGatewaysClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual network gateways client: %w", err) } localNetworkGatewaysClient, err := armnetwork.NewLocalNetworkGatewaysClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create local network gateways client: %w", err) } virtualNetworkGatewayConnectionsClient, err := armnetwork.NewVirtualNetworkGatewayConnectionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual network gateway connections client: %w", err) } natGatewaysClient, err := armnetwork.NewNatGatewaysClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create nat gateways client: %w", err) } flowLogsClient, err := armnetwork.NewFlowLogsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create flow logs client: %w", err) } networkWatchersClient, err := armnetwork.NewWatchersClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create network watchers client: %w", err) } managedHSMsClient, err := armkeyvault.NewManagedHsmsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create managed hsms client: %w", err) } mhsmPrivateEndpointConnectionsClient, err := armkeyvault.NewMHSMPrivateEndpointConnectionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create MHSM private endpoint connections client: %w", err) } sqlServersClient, err := armsql.NewServersClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql servers client: %w", err) } sqlFirewallRulesClient, err := armsql.NewFirewallRulesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql firewall rules client: %w", err) } sqlVirtualNetworkRulesClient, err := armsql.NewVirtualNetworkRulesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql virtual network rules client: %w", err) } sqlElasticPoolsClient, err := armsql.NewElasticPoolsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql elastic pools client: %w", err) } sqlPrivateEndpointConnectionsClient, err := armsql.NewPrivateEndpointConnectionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql private endpoint connections client: %w", err) } sqlFailoverGroupsClient, err := armsql.NewFailoverGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql failover groups client: %w", err) } sqlServerKeysClient, err := armsql.NewServerKeysClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create sql server keys client: %w", err) } postgresqlFlexibleServersClient, err := armpostgresqlflexibleservers.NewServersClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql flexible servers client: %w", err) } postgresqlFirewallRulesClient, err := armpostgresqlflexibleservers.NewFirewallRulesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql firewall rules client: %w", err) } postgresqlPrivateEndpointConnectionsClient, err := armpostgresqlflexibleservers.NewPrivateEndpointConnectionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql flexible server private endpoint connections client: %w", err) } postgresqlBackupsClient, err := armpostgresqlflexibleservers.NewBackupsAutomaticAndOnDemandClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql flexible server backups client: %w", err) } postgresqlReplicasClient, err := armpostgresqlflexibleservers.NewReplicasClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql flexible server replicas client: %w", err) } postgresqlConfigurationsClient, err := armpostgresqlflexibleservers.NewConfigurationsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql flexible server configurations client: %w", err) } postgresqlVirtualEndpointsClient, err := armpostgresqlflexibleservers.NewVirtualEndpointsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql flexible server virtual endpoints client: %w", err) } postgresqlAdministratorsClient, err := armpostgresqlflexibleservers.NewAdministratorsMicrosoftEntraClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create postgresql flexible server administrators client: %w", err) } secretsClient, err := armkeyvault.NewSecretsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create secrets client: %w", err) } keysClient, err := armkeyvault.NewKeysClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create keys client: %w", err) } userAssignedIdentitiesClient, err := armmsi.NewUserAssignedIdentitiesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create user assigned identities client: %w", err) } federatedIdentityCredentialsClient, err := armmsi.NewFederatedIdentityCredentialsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create federated identity credentials client: %w", err) } roleAssignmentsClient, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create role assignments client: %w", err) } roleDefinitionsClient, err := armauthorization.NewRoleDefinitionsClient(cred, nil) if err != nil { return nil, fmt.Errorf("failed to create role definitions client: %w", err) } diskEncryptionSetsClient, err := armcompute.NewDiskEncryptionSetsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create disk encryption sets client: %w", err) } imagesClient, err := armcompute.NewImagesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create images client: %w", err) } virtualMachineRunCommandsClient, err := armcompute.NewVirtualMachineRunCommandsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual machine run commands client: %w", err) } virtualMachineExtensionsClient, err := armcompute.NewVirtualMachineExtensionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual machine extensions client: %w", err) } proximityPlacementGroupsClient, err := armcompute.NewProximityPlacementGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create proximity placement groups client: %w", err) } zonesClient, err := armdns.NewZonesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create zones client: %w", err) } recordSetsClient, err := armdns.NewRecordSetsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create record sets client: %w", err) } privateDNSZonesClient, err := armprivatedns.NewPrivateZonesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create private DNS zones client: %w", err) } virtualNetworkLinksClient, err := armprivatedns.NewVirtualNetworkLinksClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create virtual network links client: %w", err) } diskAccessesClient, err := armcompute.NewDiskAccessesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create disk accesses client: %w", err) } dedicatedHostGroupsClient, err := armcompute.NewDedicatedHostGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create dedicated host groups client: %w", err) } dedicatedHostsClient, err := armcompute.NewDedicatedHostsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create dedicated hosts client: %w", err) } capacityReservationGroupsClient, err := armcompute.NewCapacityReservationGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create capacity reservation groups client: %w", err) } capacityReservationsClient, err := armcompute.NewCapacityReservationsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create capacity reservations client: %w", err) } galleryApplicationVersionsClient, err := armcompute.NewGalleryApplicationVersionsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create gallery application versions client: %w", err) } galleryApplicationsClient, err := armcompute.NewGalleryApplicationsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create gallery applications client: %w", err) } galleryImagesClient, err := armcompute.NewGalleryImagesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create gallery images client: %w", err) } galleriesClient, err := armcompute.NewGalleriesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create galleries client: %w", err) } snapshotsClient, err := armcompute.NewSnapshotsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create snapshots client: %w", err) } elasticSansClient, err := armelasticsan.NewElasticSansClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create elastic sans client: %w", err) } elasticSanVolumeSnapshotsClient, err := armelasticsan.NewVolumeSnapshotsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create elastic san volume snapshots client: %w", err) } elasticSanVolumeGroupsClient, err := armelasticsan.NewVolumeGroupsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create elastic san volume groups client: %w", err) } elasticSanVolumesClient, err := armelasticsan.NewVolumesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create elastic san volumes client: %w", err) } sharedGalleryImagesClient, err := armcompute.NewSharedGalleryImagesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create shared gallery images client: %w", err) } maintenanceConfigurationsClient, err := armmaintenance.NewConfigurationsClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create maintenance configurations client: %w", err) } maintenanceConfigurationsForResourceGroupClient, err := armmaintenance.NewConfigurationsForResourceGroupClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create maintenance configurations for resource group client: %w", err) } operationalInsightsWorkspacesClient, err := armoperationalinsights.NewWorkspacesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create operational insights workspaces client: %w", err) } // Multi-scope resource group adapters (one adapter per type handling all resource groups) if len(resourceGroupScopes) > 0 { adapters = append(adapters, sources.WrapperToAdapter(NewComputeVirtualMachine( clients.NewVirtualMachinesClient(vmClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewStorageAccount( clients.NewStorageAccountsClient(storageAccountsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewStorageBlobContainer( clients.NewBlobContainersClient(blobContainersClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewStorageFileShare( clients.NewFileSharesClient(fileSharesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewStorageQueues( clients.NewQueuesClient(queuesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewStorageTable( clients.NewTablesClient(tablesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewStorageEncryptionScope( clients.NewEncryptionScopesClient(encryptionScopesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewStoragePrivateEndpointConnection( clients.NewStoragePrivateEndpointConnectionsClient(privateEndpointConnectionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkVirtualNetwork( clients.NewVirtualNetworksClient(virtualNetworksClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkSubnet( clients.NewSubnetsClient(subnetsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkVirtualNetworkPeering( clients.NewVirtualNetworkPeeringsClient(virtualNetworkPeeringsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkNetworkInterface( clients.NewNetworkInterfacesClient(networkInterfacesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkNetworkInterfaceIPConfiguration( clients.NewInterfaceIPConfigurationsClient(interfaceIPConfigurationsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlDatabase( clients.NewSqlDatabasesClient(sqlDatabasesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlDatabaseSchema( clients.NewSqlDatabaseSchemasClient(sqlDatabaseSchemasClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlElasticPool( clients.NewSqlElasticPoolClient(sqlElasticPoolsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlServerFirewallRule( clients.NewSqlServerFirewallRuleClient(sqlFirewallRulesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlServerVirtualNetworkRule( clients.NewSqlServerVirtualNetworkRuleClient(sqlVirtualNetworkRulesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSQLServerPrivateEndpointConnection( clients.NewSQLServerPrivateEndpointConnectionsClient(sqlPrivateEndpointConnectionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlServerFailoverGroup( clients.NewSqlFailoverGroupsClient(sqlFailoverGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlServerKey( clients.NewSqlServerKeysClient(sqlServerKeysClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDocumentDBDatabaseAccounts( clients.NewDocumentDBDatabaseAccountsClient(documentDBDatabaseAccountsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDocumentDBPrivateEndpointConnection( clients.NewDocumentDBPrivateEndpointConnectionsClient(documentDBPrivateEndpointConnectionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewKeyVaultVault( clients.NewVaultsClient(keyVaultsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewKeyVaultManagedHSM( clients.NewManagedHSMsClient(managedHSMsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewKeyVaultManagedHSMPrivateEndpointConnection( clients.NewKeyVaultManagedHSMPrivateEndpointConnectionsClient(mhsmPrivateEndpointConnectionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLDatabase( clients.NewPostgreSQLDatabasesClient(postgreSQLDatabasesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkPublicIPAddress( clients.NewPublicIPAddressesClient(publicIPAddressesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkPublicIPPrefix( clients.NewPublicIPPrefixesClient(publicIPPrefixesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkDdosProtectionPlan( clients.NewDdosProtectionPlansClient(ddosProtectionPlansClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkLoadBalancer( clients.NewLoadBalancersClient(loadBalancersClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkLoadBalancerFrontendIPConfiguration( clients.NewLoadBalancerFrontendIPConfigurationsClient(loadBalancerFrontendIPConfigurationsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkLoadBalancerBackendAddressPool( clients.NewLoadBalancerBackendAddressPoolsClient(loadBalancerBackendAddressPoolsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkLoadBalancerProbe( clients.NewLoadBalancerProbesClient(loadBalancerProbesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkPrivateEndpoint( clients.NewPrivateEndpointsClient(privateEndpointsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkPrivateLinkService( clients.NewPrivateLinkServicesClient(privateLinkServicesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkZone( clients.NewZonesClient(zonesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkPrivateDNSZone( clients.NewPrivateDNSZonesClient(privateDNSZonesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkDNSRecordSet( clients.NewRecordSetsClient(recordSetsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkDNSVirtualNetworkLink( clients.NewVirtualNetworkLinksClient(virtualNetworkLinksClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewBatchAccount( clients.NewBatchAccountsClient(batchAccountsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewBatchBatchApplication( clients.NewBatchApplicationsClient(batchApplicationClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewBatchBatchPool( clients.NewBatchPoolsClient(batchPoolClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewBatchBatchApplicationPackage( clients.NewBatchApplicationPackagesClient(batchApplicationPackageClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewBatchPrivateEndpointConnection( clients.NewBatchPrivateEndpointConnectionClient(batchPrivateEndpointConnectionClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeVirtualMachineScaleSet( clients.NewVirtualMachineScaleSetsClient(virtualMachineScaleSetsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeAvailabilitySet( clients.NewAvailabilitySetsClient(availabilitySetsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeDisk( clients.NewDisksClient(disksClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkNetworkSecurityGroup( clients.NewNetworkSecurityGroupsClient(networkSecurityGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkApplicationSecurityGroup( clients.NewApplicationSecurityGroupsClient(applicationSecurityGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkIPGroup( clients.NewIPGroupsClient(ipGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkRouteTable( clients.NewRouteTablesClient(routeTablesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkRoute( clients.NewRoutesClient(routesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkSecurityRule( clients.NewSecurityRulesClient(securityRulesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkDefaultSecurityRule( clients.NewDefaultSecurityRulesClient(defaultSecurityRulesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkApplicationGateway( clients.NewApplicationGatewaysClient(applicationGatewaysClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkVirtualNetworkGateway( clients.NewVirtualNetworkGatewaysClient(virtualNetworkGatewaysClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkLocalNetworkGateway( clients.NewLocalNetworkGatewaysClient(localNetworkGatewaysClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkVirtualNetworkGatewayConnection( clients.NewVirtualNetworkGatewayConnectionsClient(virtualNetworkGatewayConnectionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkNatGateway( clients.NewNatGatewaysClient(natGatewaysClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkFlowLog( clients.NewFlowLogsClient(flowLogsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewNetworkNetworkWatcher( clients.NewNetworkWatchersClient(networkWatchersClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewSqlServer( clients.NewSqlServersClient(sqlServersClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServer( clients.NewPostgreSQLFlexibleServersClient(postgresqlFlexibleServersClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerFirewallRule( clients.NewPostgreSQLFlexibleServerFirewallRuleClient(postgresqlFirewallRulesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection( clients.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(postgresqlPrivateEndpointConnectionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerBackup( clients.NewDBforPostgreSQLFlexibleServerBackupClient(postgresqlBackupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerReplica( clients.NewDBforPostgreSQLFlexibleServerReplicaClient(postgresqlReplicasClient, postgresqlFlexibleServersClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerConfiguration( clients.NewPostgreSQLConfigurationsClient(postgresqlConfigurationsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerVirtualEndpoint( clients.NewDBforPostgreSQLFlexibleServerVirtualEndpointClient(postgresqlVirtualEndpointsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerAdministrator( clients.NewDBforPostgreSQLFlexibleServerAdministratorClient(postgresqlAdministratorsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewKeyVaultSecret( clients.NewSecretsClient(secretsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewKeyVaultKey( clients.NewKeysClient(keysClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewManagedIdentityUserAssignedIdentity( clients.NewUserAssignedIdentitiesClient(userAssignedIdentitiesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewManagedIdentityFederatedIdentityCredential( clients.NewFederatedIdentityCredentialsClient(federatedIdentityCredentialsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewAuthorizationRoleAssignment( clients.NewRoleAssignmentsClient(roleAssignmentsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeDiskEncryptionSet( clients.NewDiskEncryptionSetsClient(diskEncryptionSetsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeImage( clients.NewImagesClient(imagesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeVirtualMachineRunCommand( clients.NewVirtualMachineRunCommandsClient(virtualMachineRunCommandsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeVirtualMachineExtension( clients.NewVirtualMachineExtensionsClient(virtualMachineExtensionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeProximityPlacementGroup( clients.NewProximityPlacementGroupsClient(proximityPlacementGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeDiskAccess( clients.NewDiskAccessesClient(diskAccessesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeDiskAccessPrivateEndpointConnection( clients.NewComputeDiskAccessPrivateEndpointConnectionsClient(diskAccessesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeDedicatedHostGroup( clients.NewDedicatedHostGroupsClient(dedicatedHostGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeDedicatedHost( clients.NewDedicatedHostsClient(dedicatedHostsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeCapacityReservationGroup( clients.NewCapacityReservationGroupsClient(capacityReservationGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeCapacityReservation( clients.NewCapacityReservationsClient(capacityReservationsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeGalleryApplicationVersion( clients.NewGalleryApplicationVersionsClient(galleryApplicationVersionsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeGalleryApplication( clients.NewGalleryApplicationsClient(galleryApplicationsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeGallery( clients.NewGalleriesClient(galleriesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeGalleryImage( clients.NewGalleryImagesClient(galleryImagesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewComputeSnapshot( clients.NewSnapshotsClient(snapshotsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewElasticSan( clients.NewElasticSanClient(elasticSansClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewElasticSanVolumeSnapshot( clients.NewElasticSanVolumeSnapshotClient(elasticSanVolumeSnapshotsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewElasticSanVolumeGroup( clients.NewElasticSanVolumeGroupClient(elasticSanVolumeGroupsClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewElasticSanVolume( clients.NewElasticSanVolumeClient(elasticSanVolumesClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewMaintenanceMaintenanceConfiguration( clients.NewMaintenanceConfigurationClient(maintenanceConfigurationsClient, maintenanceConfigurationsForResourceGroupClient), resourceGroupScopes, ), cache), sources.WrapperToAdapter(NewOperationalInsightsWorkspace( clients.NewOperationalInsightsWorkspaceClient(operationalInsightsWorkspacesClient), resourceGroupScopes, ), cache), ) } // Subscription-scoped adapters (not resource-group-scoped) adapters = append(adapters, sources.WrapperToAdapter(NewComputeSharedGalleryImage( clients.NewSharedGalleryImagesClient(sharedGalleryImagesClient), subscriptionID, ), cache), sources.WrapperToAdapter(NewAuthorizationRoleDefinition( clients.NewRoleDefinitionsClient(roleDefinitionsClient), subscriptionID, ), cache), ) log.WithFields(log.Fields{ "ovm.source.subscription_id": subscriptionID, "ovm.source.adapter_count": len(adapters), }).Info("Initialized Azure adapters") } else { // For metadata registration only - no actual clients needed // This is used to enumerate available adapter types for documentation // Create placeholder adapters with nil clients and one placeholder scope placeholderResourceGroupScopes := []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, "placeholder-resource-group")} noOpCache := sdpcache.NewNoOpCache() adapters = append(adapters, sources.WrapperToAdapter(NewComputeVirtualMachine(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewStorageAccount(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewStorageBlobContainer(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewStorageFileShare(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewStorageQueues(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewStorageTable(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewStorageEncryptionScope(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewStoragePrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkVirtualNetwork(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkSubnet(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkVirtualNetworkPeering(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkNetworkInterface(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkNetworkInterfaceIPConfiguration(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSqlDatabase(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSqlDatabaseSchema(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSqlServerFirewallRule(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSqlServerVirtualNetworkRule(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSQLServerPrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSqlServerFailoverGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSqlServerKey(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDocumentDBDatabaseAccounts(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDocumentDBPrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewKeyVaultVault(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewKeyVaultManagedHSM(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewKeyVaultManagedHSMPrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLDatabase(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkPublicIPAddress(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkPublicIPPrefix(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkDdosProtectionPlan(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkLoadBalancer(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkLoadBalancerFrontendIPConfiguration(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkLoadBalancerBackendAddressPool(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkLoadBalancerProbe(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkZone(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkPrivateDNSZone(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkDNSRecordSet(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkDNSVirtualNetworkLink(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewBatchAccount(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewBatchBatchApplication(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewBatchBatchPool(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewBatchBatchApplicationPackage(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewBatchPrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeVirtualMachineScaleSet(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeAvailabilitySet(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDisk(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkNetworkSecurityGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkApplicationSecurityGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkIPGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkSecurityRule(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkDefaultSecurityRule(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkRouteTable(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkApplicationGateway(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkVirtualNetworkGateway(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkLocalNetworkGateway(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkVirtualNetworkGatewayConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkNatGateway(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkFlowLog(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkNetworkWatcher(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewSqlServer(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServer(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerFirewallRule(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerBackup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerReplica(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerConfiguration(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerVirtualEndpoint(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewDBforPostgreSQLFlexibleServerAdministrator(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewKeyVaultSecret(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewKeyVaultKey(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewManagedIdentityUserAssignedIdentity(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewManagedIdentityFederatedIdentityCredential(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewAuthorizationRoleAssignment(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDiskEncryptionSet(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeImage(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeVirtualMachineRunCommand(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeVirtualMachineExtension(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeProximityPlacementGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDiskAccess(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDiskAccessPrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDedicatedHostGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDedicatedHost(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeCapacityReservationGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeCapacityReservation(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeGalleryApplicationVersion(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeGalleryApplication(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeGallery(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeGalleryImage(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeSnapshot(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewElasticSan(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewElasticSanVolumeSnapshot(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewElasticSanVolumeGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewElasticSanVolume(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewMaintenanceMaintenanceConfiguration(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeSharedGalleryImage(nil, subscriptionID), noOpCache), sources.WrapperToAdapter(NewAuthorizationRoleDefinition(nil, subscriptionID), noOpCache), sources.WrapperToAdapter(NewNetworkPrivateEndpoint(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkPrivateLinkService(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewOperationalInsightsWorkspace(nil, placeholderResourceGroupScopes), noOpCache), ) _ = regions } return adapters, nil } ================================================ FILE: sources/azure/manual/authorization-role-assignment.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var AuthorizationRoleAssignmentLookupByName = shared.NewItemTypeLookup("name", azureshared.AuthorizationRoleAssignment) type authorizationRoleAssignmentWrapper struct { client clients.RoleAssignmentsClient *azureshared.MultiResourceGroupBase } func NewAuthorizationRoleAssignment(client clients.RoleAssignmentsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &authorizationRoleAssignmentWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.AuthorizationRoleAssignment, ), } } func (a authorizationRoleAssignmentWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { if scope == "" { return nil, azureshared.QueryError(errors.New("scope cannot be empty"), scope, a.Type()) } rgScope, err := a.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, a.Type()) } pager := a.client.ListForResourceGroup(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, a.Type()) } for _, roleAssignment := range page.Value { item, sdpErr := a.azureRoleAssignmentToSDPItem(roleAssignment, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (a authorizationRoleAssignmentWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := a.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, a.Type())) return } pager := a.client.ListForResourceGroup(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, a.Type())) return } for _, roleAssignment := range page.Value { item, sdpErr := a.azureRoleAssignmentToSDPItem(roleAssignment, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (a authorizationRoleAssignmentWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if scope == "" { return nil, azureshared.QueryError(errors.New("scope cannot be empty"), scope, a.Type()) } if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("Get requires 1 query part: roleAssignmentName"), scope, a.Type()) } roleAssignmentName := queryParts[0] if roleAssignmentName == "" { return nil, azureshared.QueryError(errors.New("roleAssignmentName cannot be empty"), scope, a.Type()) } rgScope, err := a.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, a.Type()) } // Construct the Azure scope path from either subscription ID or resource group name azureScope := azureshared.ConstructRoleAssignmentScope(scope, rgScope.SubscriptionID) if azureScope == "" { return nil, azureshared.QueryError(errors.New("failed to construct Azure scope path"), scope, a.Type()) } resp, err := a.client.Get(ctx, azureScope, roleAssignmentName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, a.Type()) } return a.azureRoleAssignmentToSDPItem(&resp.RoleAssignment, scope) } func (a authorizationRoleAssignmentWrapper) azureRoleAssignmentToSDPItem(roleAssignment *armauthorization.RoleAssignment, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(roleAssignment) if err != nil { return nil, azureshared.QueryError(err, scope, a.Type()) } // Extract role assignment name var roleAssignmentName string if roleAssignment.Name != nil { roleAssignmentName = *roleAssignment.Name } if roleAssignmentName == "" { return nil, azureshared.QueryError(errors.New("role assignment name cannot be empty"), scope, a.Type()) } rgScope, err := a.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, a.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(rgScope.ResourceGroup, roleAssignmentName)) if err != nil { return nil, azureshared.QueryError(err, scope, a.Type()) } sdpItem := &sdp.Item{ Type: azureshared.AuthorizationRoleAssignment.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to Delegated Managed Identity (external resource) if present // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} if roleAssignment.Properties != nil && roleAssignment.Properties.DelegatedManagedIdentityResourceID != nil && *roleAssignment.Properties.DelegatedManagedIdentityResourceID != "" { identityResourceID := *roleAssignment.Properties.DelegatedManagedIdentityResourceID identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } // Link to Role Definition (external resource) // Reference: https://learn.microsoft.com/en-us/rest/api/authorization/role-definitions/get // GET /{scope}/providers/Microsoft.Authorization/roleDefinitions/{roleDefinitionId} // Role definitions are subscription-level resources if roleAssignment.Properties != nil && roleAssignment.Properties.RoleDefinitionID != nil && *roleAssignment.Properties.RoleDefinitionID != "" { roleDefinitionID := *roleAssignment.Properties.RoleDefinitionID // Extract the role definition ID (GUID) from the full resource ID path // Format: /subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/{roleDefinitionId} roleDefinitionGUID := azureshared.ExtractResourceName(roleDefinitionID) if roleDefinitionGUID != "" { // Extract subscription ID from the role definition ID path for scope // Role definitions are subscription-level, not resource group scoped linkedScope := azureshared.ExtractSubscriptionIDFromResourceID(roleDefinitionID) // Fallback: extract subscription ID from current scope if extraction failed if linkedScope == "" { scopeParts := strings.Split(scope, ".") if len(scopeParts) > 0 { linkedScope = scopeParts[0] } } if linkedScope != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.AuthorizationRoleDefinition.String(), Method: sdp.QueryMethod_GET, Query: roleDefinitionGUID, Scope: linkedScope, }, }) } } } return sdpItem, nil } func (a authorizationRoleAssignmentWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ AuthorizationRoleAssignmentLookupByName, } } // SearchLookups defines how the source can be searched (e.g. by role assignment name within a scope). // Used when TerraformMethod is SEARCH (azurerm_role_assignment.id). func (a authorizationRoleAssignmentWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { AuthorizationRoleAssignmentLookupByName, }, } } // Search resolves a role assignment by name within the given scope. // Supports Terraform SEARCH resolution when the query is the role assignment name (or extracted from Azure resource ID by the transformer). func (a authorizationRoleAssignmentWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("Search requires 1 query part: roleAssignmentName"), scope, a.Type()) } roleAssignmentName := queryParts[0] if roleAssignmentName == "" { return nil, azureshared.QueryError(errors.New("roleAssignmentName cannot be empty"), scope, a.Type()) } item, qErr := a.Get(ctx, scope, roleAssignmentName) if qErr != nil { return nil, qErr } return []*sdp.Item{item}, nil } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment func (a authorizationRoleAssignmentWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment // Terraform uses: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Authorization/roleAssignments/{roleAssignmentName} // Or: /subscriptions/{sub}/providers/Microsoft.Authorization/roleAssignments/{roleAssignmentName} TerraformQueryMap: "azurerm_role_assignment.id", }, } } func (a authorizationRoleAssignmentWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ManagedIdentityUserAssignedIdentity, azureshared.AuthorizationRoleDefinition, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/management-and-governance#microsoftauthorization func (a authorizationRoleAssignmentWrapper) IAMPermissions() []string { return []string{ "Microsoft.Authorization/roleAssignments/read", } } func (a authorizationRoleAssignmentWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/authorization-role-assignment_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestAuthorizationRoleAssignment(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { roleAssignmentName := "test-role-assignment" roleAssignment := createAzureRoleAssignment(roleAssignmentName, "/subscriptions/test-subscription/resourceGroups/test-rg") mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) azureScope := "/subscriptions/test-subscription/resourceGroups/test-rg" mockClient.EXPECT().Get(ctx, azureScope, roleAssignmentName, nil).Return( armauthorization.RoleAssignmentsClientGetResponse{ RoleAssignment: *roleAssignment, }, nil) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, roleAssignmentName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.AuthorizationRoleAssignment.String() { t.Errorf("Expected type %s, got %s", azureshared.AuthorizationRoleAssignment.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(resourceGroup, roleAssignmentName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != scope { t.Errorf("Expected scope %s, got %s", scope, sdpItem.GetScope()) } // Verify linked item queries t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Role Definition link ExpectedType: azureshared.AuthorizationRoleDefinition.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "b24988ac-6180-42a0-ab88-20f7382dd24c", ExpectedScope: subscriptionID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyScope", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, "", "test-role-assignment", true) if qErr == nil { t.Error("Expected error when getting role assignment with empty scope, but got nil") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty query (adapter rejects before calling wrapper) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting role assignment with empty name, but got nil") } // Note: "too many" query parts are coalesced by the standard adapter into a single part, // so the wrapper would receive one part and call the client. We only test empty here. }) t.Run("Get_EmptyRoleAssignmentName", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting role assignment with empty name, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { roleAssignmentName := "test-role-assignment" expectedError := errors.New("client error") mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) azureScope := "/subscriptions/test-subscription/resourceGroups/test-rg" mockClient.EXPECT().Get(ctx, azureScope, roleAssignmentName, nil).Return( armauthorization.RoleAssignmentsClientGetResponse{}, expectedError) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, roleAssignmentName, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Get_NilName", func(t *testing.T) { roleAssignment := &armauthorization.RoleAssignment{ Name: nil, // Role assignment with nil name should cause error Properties: &armauthorization.RoleAssignmentProperties{ Scope: new("/subscriptions/test-subscription/resourceGroups/test-rg"), }, } mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) azureScope := "/subscriptions/test-subscription/resourceGroups/test-rg" roleAssignmentName := "test-role-assignment" mockClient.EXPECT().Get(ctx, azureScope, roleAssignmentName, nil).Return( armauthorization.RoleAssignmentsClientGetResponse{ RoleAssignment: *roleAssignment, }, nil) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, roleAssignmentName, true) if qErr == nil { t.Error("Expected error when role assignment has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { roleAssignment1 := createAzureRoleAssignment("test-role-assignment-1", "/subscriptions/test-subscription/resourceGroups/test-rg") roleAssignment2 := createAzureRoleAssignment("test-role-assignment-2", "/subscriptions/test-subscription/resourceGroups/test-rg") mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) mockPager := NewMockRoleAssignmentsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armauthorization.RoleAssignmentsClientListForResourceGroupResponse{ RoleAssignmentListResult: armauthorization.RoleAssignmentListResult{ Value: []*armauthorization.RoleAssignment{roleAssignment1, roleAssignment2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().ListForResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for i, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.AuthorizationRoleAssignment.String() { t.Fatalf("Expected type %s, got: %s", azureshared.AuthorizationRoleAssignment.String(), item.GetType()) } expectedName := "test-role-assignment-" + string(rune(i+1+'0')) expectedUniqueAttrValue := shared.CompositeLookupKey(resourceGroup, expectedName) if item.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got: %s", expectedUniqueAttrValue, item.UniqueAttributeValue()) } } }) t.Run("List_EmptyScope", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, "", true) if err == nil { t.Error("Expected error when listing role assignments with empty scope, but got nil") } }) t.Run("List_PagerError", func(t *testing.T) { expectedError := errors.New("pager error") mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) mockPager := NewMockRoleAssignmentsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armauthorization.RoleAssignmentsClientListForResourceGroupResponse{}, expectedError), ) mockClient.EXPECT().ListForResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, scope, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("List_WithNilName", func(t *testing.T) { // Create role assignment with nil name to test error handling roleAssignment1 := createAzureRoleAssignment("test-role-assignment-1", "/subscriptions/test-subscription/resourceGroups/test-rg") roleAssignment2 := &armauthorization.RoleAssignment{ Name: nil, // Role assignment with nil name should cause error Properties: &armauthorization.RoleAssignmentProperties{ Scope: new("/subscriptions/test-subscription/resourceGroups/test-rg"), }, } mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) mockPager := NewMockRoleAssignmentsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armauthorization.RoleAssignmentsClientListForResourceGroupResponse{ RoleAssignmentListResult: armauthorization.RoleAssignmentListResult{ Value: []*armauthorization.RoleAssignment{roleAssignment1, roleAssignment2}, }, }, nil), ) mockClient.EXPECT().ListForResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, scope, true) if err == nil { t.Error("Expected error when listing role assignments with nil name, but got nil") } }) t.Run("GetLookups", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) != 1 { t.Errorf("Expected 1 lookup, got: %d", len(lookups)) } foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.AuthorizationRoleAssignment { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include AuthorizationRoleAssignment") } }) t.Run("Search", func(t *testing.T) { roleAssignmentName := "test-role-assignment" roleAssignment := createAzureRoleAssignment(roleAssignmentName, "/subscriptions/test-subscription/resourceGroups/test-rg") mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) azureScope := "/subscriptions/test-subscription/resourceGroups/test-rg" mockClient.EXPECT().Get(ctx, azureScope, roleAssignmentName, nil).Return( armauthorization.RoleAssignmentsClientGetResponse{ RoleAssignment: *roleAssignment, }, nil) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not implement SearchableAdapter") } items, err := searchable.Search(ctx, scope, roleAssignmentName, true) if err != nil { t.Fatalf("Search failed: %v", err) } if len(items) != 1 { t.Errorf("Expected 1 item, got %d", len(items)) } if len(items) > 0 && items[0].GetType() != azureshared.AuthorizationRoleAssignment.String() { t.Errorf("Expected type %s, got %s", azureshared.AuthorizationRoleAssignment.String(), items[0].GetType()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) searchableWrapper := wrapper.(sources.SearchableWrapper) _, qErr := searchableWrapper.Search(ctx, scope, "name1", "name2") if qErr == nil { t.Error("Expected error for too many query parts, got nil") } }) t.Run("Search_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) searchableWrapper := wrapper.(sources.SearchableWrapper) _, qErr := searchableWrapper.Search(ctx, scope, "") if qErr == nil { t.Error("Expected error for empty role assignment name, got nil") } }) t.Run("SearchLookups", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) searchableWrapper := wrapper.(sources.SearchableWrapper) searchLookups := searchableWrapper.SearchLookups() if len(searchLookups) != 1 { t.Errorf("Expected 1 search lookup group, got %d", len(searchLookups)) } if len(searchLookups) > 0 && len(searchLookups[0]) != 1 { t.Errorf("Expected 1 lookup in first group, got %d", len(searchLookups[0])) } if len(searchLookups) > 0 && len(searchLookups[0]) > 0 && searchLookups[0][0].ItemType != azureshared.AuthorizationRoleAssignment { t.Errorf("Expected SearchLookups to include AuthorizationRoleAssignment, got %v", searchLookups[0][0].ItemType) } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_role_assignment.id" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected TerraformMethod to be SEARCH, got: %v", mapping.GetTerraformMethod()) } break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_role_assignment.id' mapping") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to include at least one link type") } if !potentialLinks[azureshared.ManagedIdentityUserAssignedIdentity] { t.Error("Expected PotentialLinks to include ManagedIdentityUserAssignedIdentity") } if !potentialLinks[azureshared.AuthorizationRoleDefinition] { t.Error("Expected PotentialLinks to include AuthorizationRoleDefinition") } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() if len(permissions) != 1 { t.Errorf("Expected 1 permission, got: %d", len(permissions)) } expectedPermission := "Microsoft.Authorization/roleAssignments/read" if permissions[0] != expectedPermission { t.Errorf("Expected permission %s, got: %s", expectedPermission, permissions[0]) } }) t.Run("PredefinedRole", func(t *testing.T) { mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Use interface assertion to access PredefinedRole method if roleInterface, ok := any(wrapper).(interface{ PredefinedRole() string }); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } else { t.Error("Wrapper does not implement PredefinedRole method") } }) t.Run("Get_WithDelegatedManagedIdentity", func(t *testing.T) { roleAssignmentName := "test-role-assignment-with-identity" roleAssignment := createAzureRoleAssignment(roleAssignmentName, "/subscriptions/test-subscription/resourceGroups/test-rg") // Add delegated managed identity resource ID delegatedIdentityID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" roleAssignment.Properties.DelegatedManagedIdentityResourceID = new(delegatedIdentityID) mockClient := mocks.NewMockRoleAssignmentsClient(ctrl) azureScope := "/subscriptions/test-subscription/resourceGroups/test-rg" mockClient.EXPECT().Get(ctx, azureScope, roleAssignmentName, nil).Return( armauthorization.RoleAssignmentsClientGetResponse{ RoleAssignment: *roleAssignment, }, nil) wrapper := manual.NewAuthorizationRoleAssignment(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, roleAssignmentName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify linked item queries include both role definition and managed identity t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Role Definition link ExpectedType: azureshared.AuthorizationRoleDefinition.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "b24988ac-6180-42a0-ab88-20f7382dd24c", ExpectedScope: subscriptionID, }, { // Delegated Managed Identity link ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) } // MockRoleAssignmentsPager is a mock for RoleAssignmentsPager type MockRoleAssignmentsPager struct { ctrl *gomock.Controller recorder *MockRoleAssignmentsPagerMockRecorder } type MockRoleAssignmentsPagerMockRecorder struct { mock *MockRoleAssignmentsPager } func NewMockRoleAssignmentsPager(ctrl *gomock.Controller) *MockRoleAssignmentsPager { mock := &MockRoleAssignmentsPager{ctrl: ctrl} mock.recorder = &MockRoleAssignmentsPagerMockRecorder{mock} return mock } func (m *MockRoleAssignmentsPager) EXPECT() *MockRoleAssignmentsPagerMockRecorder { return m.recorder } func (m *MockRoleAssignmentsPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockRoleAssignmentsPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockRoleAssignmentsPager) NextPage(ctx context.Context) (armauthorization.RoleAssignmentsClientListForResourceGroupResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armauthorization.RoleAssignmentsClientListForResourceGroupResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockRoleAssignmentsPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armauthorization.RoleAssignmentsClientListForResourceGroupResponse, error)](), ctx) } // createAzureRoleAssignment creates a mock Azure role assignment for testing func createAzureRoleAssignment(roleAssignmentName, scope string) *armauthorization.RoleAssignment { return &armauthorization.RoleAssignment{ Name: new(roleAssignmentName), Type: new("Microsoft.Authorization/roleAssignments"), ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Authorization/roleAssignments/" + roleAssignmentName), Properties: &armauthorization.RoleAssignmentProperties{ Scope: new(scope), RoleDefinitionID: new("/subscriptions/test-subscription/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"), PrincipalID: new("00000000-0000-0000-0000-000000000000"), }, } } ================================================ FILE: sources/azure/manual/authorization-role-definition.go ================================================ package manual import ( "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var AuthorizationRoleDefinitionLookupByName = shared.NewItemTypeLookup("name", azureshared.AuthorizationRoleDefinition) type authorizationRoleDefinitionWrapper struct { client clients.RoleDefinitionsClient *azureshared.SubscriptionBase } func NewAuthorizationRoleDefinition(client clients.RoleDefinitionsClient, subscriptionID string) sources.ListableWrapper { return &authorizationRoleDefinitionWrapper{ client: client, SubscriptionBase: azureshared.NewSubscriptionBase( subscriptionID, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.AuthorizationRoleDefinition, ), } } // List retrieves all role definitions within the subscription scope. // ref: https://learn.microsoft.com/en-us/rest/api/authorization/role-definitions/list func (c authorizationRoleDefinitionWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { if scope == "" { return nil, azureshared.QueryError(errors.New("scope cannot be empty"), scope, c.Type()) } azureScope := fmt.Sprintf("/subscriptions/%s", c.SubscriptionID()) pager := c.client.NewListPager(azureScope, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, roleDefinition := range page.Value { if roleDefinition == nil || roleDefinition.Name == nil { continue } item, sdpErr := c.azureRoleDefinitionToSDPItem(roleDefinition, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } // ListStream streams all role definitions within the subscription scope. func (c authorizationRoleDefinitionWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { if scope == "" { stream.SendError(azureshared.QueryError(errors.New("scope cannot be empty"), scope, c.Type())) return } azureScope := fmt.Sprintf("/subscriptions/%s", c.SubscriptionID()) pager := c.client.NewListPager(azureScope, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, roleDefinition := range page.Value { if roleDefinition == nil || roleDefinition.Name == nil { continue } item, sdpErr := c.azureRoleDefinitionToSDPItem(roleDefinition, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // Get retrieves a role definition by its ID (GUID). // ref: https://learn.microsoft.com/en-us/rest/api/authorization/role-definitions/get func (c authorizationRoleDefinitionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if scope == "" { return nil, azureshared.QueryError(errors.New("scope cannot be empty"), scope, c.Type()) } if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("Get requires 1 query part: roleDefinitionID"), scope, c.Type()) } roleDefinitionID := queryParts[0] if roleDefinitionID == "" { return nil, azureshared.QueryError(errors.New("roleDefinitionID cannot be empty"), scope, c.Type()) } azureScope := fmt.Sprintf("/subscriptions/%s", c.SubscriptionID()) resp, err := c.client.Get(ctx, azureScope, roleDefinitionID, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureRoleDefinitionToSDPItem(&resp.RoleDefinition, scope) } func (c authorizationRoleDefinitionWrapper) azureRoleDefinitionToSDPItem(roleDefinition *armauthorization.RoleDefinition, scope string) (*sdp.Item, *sdp.QueryError) { if roleDefinition.Name == nil { return nil, azureshared.QueryError(errors.New("role definition name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(roleDefinition) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.AuthorizationRoleDefinition.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, } // Link to AssignableScopes (subscriptions and resource groups) if roleDefinition.Properties != nil && roleDefinition.Properties.AssignableScopes != nil { for _, assignableScope := range roleDefinition.Properties.AssignableScopes { if assignableScope == nil || *assignableScope == "" { continue } scopePath := *assignableScope // Determine if this is a subscription or resource group scope // Format: /subscriptions/{subscriptionId} or /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName} if rgName := azureshared.ExtractResourceGroupFromResourceID(scopePath); rgName != "" { // Resource group scope subscriptionID := azureshared.ExtractSubscriptionIDFromResourceID(scopePath) if subscriptionID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ResourcesResourceGroup.String(), Method: sdp.QueryMethod_GET, Query: rgName, Scope: subscriptionID, }, }) } } else if subscriptionID := azureshared.ExtractSubscriptionIDFromResourceID(scopePath); subscriptionID != "" { // Subscription scope only sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ResourcesSubscription.String(), Method: sdp.QueryMethod_GET, Query: subscriptionID, Scope: "global", }, }) } } } return sdpItem, nil } func (c authorizationRoleDefinitionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ AuthorizationRoleDefinitionLookupByName, } } // PotentialLinks returns all resource types this adapter can link to. func (c authorizationRoleDefinitionWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ResourcesSubscription, azureshared.ResourcesResourceGroup, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/management-and-governance#microsoftauthorization func (c authorizationRoleDefinitionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Authorization/roleDefinitions/read", } } func (c authorizationRoleDefinitionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/authorization-role-definition_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestAuthorizationRoleDefinition(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" scope := subscriptionID azureScope := "/subscriptions/" + subscriptionID t.Run("Get", func(t *testing.T) { roleDefinitionID := "b24988ac-6180-42a0-ab88-20f7382dd24c" roleDefinition := createAzureRoleDefinition(roleDefinitionID, "Reader") mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) mockClient.EXPECT().Get(ctx, azureScope, roleDefinitionID, nil).Return( armauthorization.RoleDefinitionsClientGetResponse{ RoleDefinition: *roleDefinition, }, nil) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, roleDefinitionID, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.AuthorizationRoleDefinition.String() { t.Errorf("Expected type %s, got %s", azureshared.AuthorizationRoleDefinition.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != roleDefinitionID { t.Errorf("Expected unique attribute value %s, got %s", roleDefinitionID, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != scope { t.Errorf("Expected scope %s, got %s", scope, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } // Verify linked item queries for AssignableScopes t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Subscription scope link ExpectedType: azureshared.ResourcesSubscription.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: subscriptionID, ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyScope", func(t *testing.T) { mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, "", "test-role-definition", true) if qErr == nil { t.Error("Expected error when getting role definition with empty scope, but got nil") } }) t.Run("Get_EmptyRoleDefinitionID", func(t *testing.T) { mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting role definition with empty ID, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { roleDefinitionID := "test-role-definition" expectedError := errors.New("client error") mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) mockClient.EXPECT().Get(ctx, azureScope, roleDefinitionID, nil).Return( armauthorization.RoleDefinitionsClientGetResponse{}, expectedError) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, roleDefinitionID, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Get_NilName", func(t *testing.T) { roleDefinition := &armauthorization.RoleDefinition{ Name: nil, Properties: &armauthorization.RoleDefinitionProperties{ RoleName: new("Reader"), }, } mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) roleDefinitionID := "test-role-definition" mockClient.EXPECT().Get(ctx, azureScope, roleDefinitionID, nil).Return( armauthorization.RoleDefinitionsClientGetResponse{ RoleDefinition: *roleDefinition, }, nil) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, roleDefinitionID, true) if qErr == nil { t.Error("Expected error when role definition has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { roleDefinition1 := createAzureRoleDefinition("guid-1", "Reader") roleDefinition2 := createAzureRoleDefinition("guid-2", "Contributor") mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) mockPager := NewMockRoleDefinitionsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armauthorization.RoleDefinitionsClientListResponse{ RoleDefinitionListResult: armauthorization.RoleDefinitionListResult{ Value: []*armauthorization.RoleDefinition{roleDefinition1, roleDefinition2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(azureScope, nil).Return(mockPager) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.AuthorizationRoleDefinition.String() { t.Fatalf("Expected type %s, got: %s", azureshared.AuthorizationRoleDefinition.String(), item.GetType()) } } }) t.Run("List_EmptyScope", func(t *testing.T) { mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, "", true) if err == nil { t.Error("Expected error when listing role definitions with empty scope, but got nil") } }) t.Run("List_PagerError", func(t *testing.T) { expectedError := errors.New("pager error") mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) mockPager := NewMockRoleDefinitionsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armauthorization.RoleDefinitionsClientListResponse{}, expectedError), ) mockClient.EXPECT().NewListPager(azureScope, nil).Return(mockPager) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, scope, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("List_WithNilName", func(t *testing.T) { roleDefinition1 := createAzureRoleDefinition("guid-1", "Reader") roleDefinition2 := &armauthorization.RoleDefinition{ Name: nil, Properties: &armauthorization.RoleDefinitionProperties{ RoleName: new("Contributor"), }, } mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) mockPager := NewMockRoleDefinitionsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armauthorization.RoleDefinitionsClientListResponse{ RoleDefinitionListResult: armauthorization.RoleDefinitionListResult{ Value: []*armauthorization.RoleDefinition{roleDefinition1, roleDefinition2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(azureScope, nil).Return(mockPager) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should skip nil name items if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name should be skipped), got: %d", len(sdpItems)) } }) t.Run("ListStream", func(t *testing.T) { roleDefinition1 := createAzureRoleDefinition("guid-1", "Reader") roleDefinition2 := createAzureRoleDefinition("guid-2", "Contributor") mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) mockPager := NewMockRoleDefinitionsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armauthorization.RoleDefinitionsClientListResponse{ RoleDefinitionListResult: armauthorization.RoleDefinitionListResult{ Value: []*armauthorization.RoleDefinition{roleDefinition1, roleDefinition2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(azureScope, nil).Return(mockPager) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable.ListStream(ctx, scope, true, stream) wg.Wait() if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } if len(errs) != 0 { t.Fatalf("Expected no errors, got: %d", len(errs)) } }) t.Run("GetLookups", func(t *testing.T) { mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) lookups := wrapper.GetLookups() if len(lookups) != 1 { t.Errorf("Expected 1 lookup, got: %d", len(lookups)) } foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.AuthorizationRoleDefinition { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include AuthorizationRoleDefinition") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) potentialLinks := wrapper.PotentialLinks() if len(potentialLinks) != 2 { t.Errorf("Expected 2 potential links, got: %d", len(potentialLinks)) } if !potentialLinks[azureshared.ResourcesSubscription] { t.Error("Expected PotentialLinks to include ResourcesSubscription") } if !potentialLinks[azureshared.ResourcesResourceGroup] { t.Error("Expected PotentialLinks to include ResourcesResourceGroup") } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) permissions := wrapper.IAMPermissions() if len(permissions) != 1 { t.Errorf("Expected 1 permission, got: %d", len(permissions)) } expectedPermission := "Microsoft.Authorization/roleDefinitions/read" if permissions[0] != expectedPermission { t.Errorf("Expected permission %s, got: %s", expectedPermission, permissions[0]) } }) t.Run("PredefinedRole", func(t *testing.T) { mockClient := mocks.NewMockRoleDefinitionsClient(ctrl) wrapper := manual.NewAuthorizationRoleDefinition(mockClient, subscriptionID) if roleInterface, ok := any(wrapper).(interface{ PredefinedRole() string }); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } else { t.Error("Wrapper does not implement PredefinedRole method") } }) } // MockRoleDefinitionsPager is a mock for RoleDefinitionsPager type MockRoleDefinitionsPager struct { ctrl *gomock.Controller recorder *MockRoleDefinitionsPagerMockRecorder } type MockRoleDefinitionsPagerMockRecorder struct { mock *MockRoleDefinitionsPager } func NewMockRoleDefinitionsPager(ctrl *gomock.Controller) *MockRoleDefinitionsPager { mock := &MockRoleDefinitionsPager{ctrl: ctrl} mock.recorder = &MockRoleDefinitionsPagerMockRecorder{mock} return mock } func (m *MockRoleDefinitionsPager) EXPECT() *MockRoleDefinitionsPagerMockRecorder { return m.recorder } func (m *MockRoleDefinitionsPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockRoleDefinitionsPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockRoleDefinitionsPager) NextPage(ctx context.Context) (armauthorization.RoleDefinitionsClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armauthorization.RoleDefinitionsClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockRoleDefinitionsPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armauthorization.RoleDefinitionsClientListResponse, error)](), ctx) } // createAzureRoleDefinition creates a mock Azure role definition for testing func createAzureRoleDefinition(roleDefinitionID, roleName string) *armauthorization.RoleDefinition { return &armauthorization.RoleDefinition{ Name: new(roleDefinitionID), Type: new("Microsoft.Authorization/roleDefinitions"), ID: new("/subscriptions/test-subscription/providers/Microsoft.Authorization/roleDefinitions/" + roleDefinitionID), Properties: &armauthorization.RoleDefinitionProperties{ RoleName: new(roleName), RoleType: new("BuiltInRole"), Description: new("Test role definition for " + roleName), AssignableScopes: []*string{ new("/subscriptions/test-subscription"), }, Permissions: []*armauthorization.Permission{ { Actions: []*string{ new("*/read"), }, }, }, }, } } ================================================ FILE: sources/azure/manual/batch-batch-accounts.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var BatchAccountLookupByName = shared.NewItemTypeLookup("name", azureshared.BatchBatchAccount) type batchAccountWrapper struct { client clients.BatchAccountsClient *azureshared.MultiResourceGroupBase } func NewBatchAccount(client clients.BatchAccountsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &batchAccountWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.BatchBatchAccount, ), } } func (b batchAccountWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } pager := b.client.ListByResourceGroup(ctx, rgScope.ResourceGroup) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } for _, account := range page.Value { if account.Name == nil { continue } item, sdpErr := b.azureBatchAccountToSDPItem(account, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (b batchAccountWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } pager := b.client.ListByResourceGroup(ctx, rgScope.ResourceGroup) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } for _, account := range page.Value { if account.Name == nil { continue } item, sdpErr := b.azureBatchAccountToSDPItem(account, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (b batchAccountWrapper) azureBatchAccountToSDPItem(account *armbatch.Account, scope string) (*sdp.Item, *sdp.QueryError) { if account.Name == nil { return nil, azureshared.QueryError(errors.New("name is nil"), scope, b.Type()) } attributes, err := shared.ToAttributesWithExclude(account, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } sdpItem := &sdp.Item{ Type: azureshared.BatchBatchAccount.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(account.Tags), } accountName := *account.Name // Link to Storage Account (external resource) // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/storage-accounts/get-properties if account.Properties != nil && account.Properties.AutoStorage != nil && account.Properties.AutoStorage.StorageAccountID != nil { storageAccountID := *account.Properties.AutoStorage.StorageAccountID storageAccountName := azureshared.ExtractResourceName(storageAccountID) if storageAccountName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(storageAccountID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: linkedScope, }, }) } } // Link to Key Vault (external resource) from KeyVaultReference // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01&tabs=HTTP if account.Properties != nil && account.Properties.KeyVaultReference != nil && account.Properties.KeyVaultReference.ID != nil { keyVaultID := *account.Properties.KeyVaultReference.ID keyVaultName := azureshared.ExtractResourceName(keyVaultID) if keyVaultName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(keyVaultID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: keyVaultName, Scope: linkedScope, }, }) } } // Link to Key Vault (external resource) from Encryption KeyVaultProperties // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName} // // NOTE: Key Vaults can be in a different resource group than the Batch account. However, the Key Vault URI // format (https://{vaultName}.vault.azure.net/keys/{keyName}/{version}) does not contain resource group information. // Key Vault names are globally unique within a subscription, so we use the batch account's scope as a best-effort // approach. If the Key Vault is in a different resource group, the query may fail and would need to be manually corrected // or the Key Vault adapter would need to support subscription-level search. if account.Properties != nil && account.Properties.Encryption != nil && account.Properties.Encryption.KeyVaultProperties != nil { if account.Properties.Encryption.KeyVaultProperties.KeyIdentifier != nil { keyIdentifier := *account.Properties.Encryption.KeyVaultProperties.KeyIdentifier // Key Vault URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} vaultName := azureshared.ExtractVaultNameFromURI(keyIdentifier) if vaultName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } } } // Link to Private Endpoints (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get if account.Properties != nil && account.Properties.PrivateEndpointConnections != nil { for _, peConnection := range account.Properties.PrivateEndpointConnections { if peConnection.Properties != nil && peConnection.Properties.PrivateEndpoint != nil && peConnection.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *peConnection.Properties.PrivateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: linkedScope, }, }) } } } } // Link to User Assigned Managed Identities (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if account.Identity != nil && account.Identity.UserAssignedIdentities != nil { for identityResourceID := range account.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Link to Node Identity Reference (external resource) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if account.Properties != nil && account.Properties.AutoStorage != nil && account.Properties.AutoStorage.NodeIdentityReference != nil && account.Properties.AutoStorage.NodeIdentityReference.ResourceID != nil { nodeIdentityID := *account.Properties.AutoStorage.NodeIdentityReference.ResourceID nodeIdentityName := azureshared.ExtractResourceName(nodeIdentityID) if nodeIdentityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(nodeIdentityID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: nodeIdentityName, Scope: linkedScope, }, }) } } // Link to Applications (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/batchmanagement/application/list?view=rest-batchmanagement-2024-07-01&tabs=HTTP // Applications can be listed using the batch account name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchApplication.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to Pools (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/batchmanagement/pool/list-by-batch-account?view=rest-batchmanagement-2024-07-01&tabs=HTTP // Pools can be listed using the batch account name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchPool.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to Certificates (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/batchmanagement/certificate/list-by-batch-account?view=rest-batchmanagement-2024-07-01&tabs=HTTP // Certificates can be listed using the batch account name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchCertificate.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to Private Endpoint Connections (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/batchmanagement/private-endpoint-connection/list-by-batch-account?view=rest-batchmanagement-2024-07-01&tabs=HTTP // Private endpoint connections can be listed using the batch account name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchPrivateEndpointConnection.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to Private Link Resources (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/batchmanagement/private-link-resource/list-by-batch-account?view=rest-batchmanagement-2024-07-01&tabs=HTTP // Private link resources can be listed using the batch account name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchPrivateLinkResource.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to Detectors (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/batchmanagement/batch-account/list-detectors?view=rest-batchmanagement-2024-07-01&tabs=HTTP // Detectors can be listed using the batch account name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchDetector.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to DNS name (standard library) if AccountEndpoint is configured if account.Properties != nil && account.Properties.AccountEndpoint != nil && *account.Properties.AccountEndpoint != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *account.Properties.AccountEndpoint, Scope: "global", }, }) } // Link to IP addresses (standard library) from NetworkProfile AccountAccess IPRules if account.Properties != nil && account.Properties.NetworkProfile != nil && account.Properties.NetworkProfile.AccountAccess != nil { if account.Properties.NetworkProfile.AccountAccess.IPRules != nil { for _, ipRule := range account.Properties.NetworkProfile.AccountAccess.IPRules { if ipRule != nil && ipRule.Value != nil && *ipRule.Value != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipRule.Value, Scope: "global", }, }) } } } } // Link to IP addresses (standard library) from NetworkProfile NodeManagementAccess IPRules if account.Properties != nil && account.Properties.NetworkProfile != nil && account.Properties.NetworkProfile.NodeManagementAccess != nil { if account.Properties.NetworkProfile.NodeManagementAccess.IPRules != nil { for _, ipRule := range account.Properties.NetworkProfile.NodeManagementAccess.IPRules { if ipRule != nil && ipRule.Value != nil && *ipRule.Value != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipRule.Value, Scope: "global", }, }) } } } } return sdpItem, nil } func (b batchAccountWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 1 query part: accountName", Scope: scope, ItemType: b.Type(), } } accountName := queryParts[0] if accountName == "" { return nil, azureshared.QueryError(errors.New("accountName is empty"), scope, b.Type()) } rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } resp, err := b.client.Get(ctx, rgScope.ResourceGroup, accountName) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } return b.azureBatchAccountToSDPItem(&resp.Account, scope) } func (b batchAccountWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BatchAccountLookupByName, } } func (b batchAccountWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ // External resources azureshared.StorageAccount: true, azureshared.KeyVaultVault: true, azureshared.NetworkPrivateEndpoint: true, azureshared.ManagedIdentityUserAssignedIdentity: true, // Child resources azureshared.BatchBatchApplication: true, azureshared.BatchBatchPool: true, azureshared.BatchBatchCertificate: true, azureshared.BatchBatchPrivateEndpointConnection: true, azureshared.BatchBatchPrivateLinkResource: true, azureshared.BatchBatchDetector: true, // DNS stdlib.NetworkDNS: true, // IP stdlib.NetworkIP: true, } } func (b batchAccountWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/batch_account TerraformQueryMap: "azurerm_batch_account.name", }, } } // ref : https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute func (b batchAccountWrapper) IAMPermissions() []string { return []string{ "Microsoft.Batch/batchAccounts/read", } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/compute#azure-batch-account-reader func (b batchAccountWrapper) PredefinedRole() string { return "Azure Batch Account Reader" } ================================================ FILE: sources/azure/manual/batch-batch-accounts_test.go ================================================ package manual_test import ( "context" "errors" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockBatchAccountsPager is a simple mock implementation of BatchAccountsPager type mockBatchAccountsPager struct { ctrl *gomock.Controller more bool response armbatch.AccountClientListByResourceGroupResponse err error } func (m *mockBatchAccountsPager) More() bool { return m.more } func (m *mockBatchAccountsPager) NextPage(ctx context.Context) (armbatch.AccountClientListByResourceGroupResponse, error) { m.more = false // After NextPage, More() should return false return m.response, m.err } func TestBatchAccount(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { accountName := "test-batch-account" account := createAzureBatchAccount(accountName, "Succeeded", subscriptionID, resourceGroup) mockClient := mocks.NewMockBatchAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armbatch.AccountClientGetResponse{ Account: *account, }, nil) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.BatchBatchAccount.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchAccount, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != accountName { t.Errorf("Expected unique attribute value %s, got %s", accountName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Storage Account link ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-storage-account", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Key Vault link ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-keyvault", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint link ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // User Assigned Managed Identity link ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Node Identity Reference link ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-node-identity", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Applications (child resource) ExpectedType: azureshared.BatchBatchApplication.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Pools (child resource) ExpectedType: azureshared.BatchBatchPool.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Certificates (child resource) ExpectedType: azureshared.BatchBatchCertificate.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint Connections (child resource) ExpectedType: azureshared.BatchBatchPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Link Resources (child resource) ExpectedType: azureshared.BatchBatchPrivateLinkResource.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Detectors (child resource) ExpectedType: azureshared.BatchBatchDetector.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyAccountName", func(t *testing.T) { mockClient := mocks.NewMockBatchAccountsClient(ctrl) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when account name is empty, but got nil") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchAccountsClient(ctrl) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with no query parts _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when no query parts provided, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { accountName := "test-batch-account" expectedErr := errors.New("batch account not found") mockClient := mocks.NewMockBatchAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armbatch.AccountClientGetResponse{}, expectedErr) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("List", func(t *testing.T) { account1 := createAzureBatchAccount("test-batch-account-1", "Succeeded", subscriptionID, resourceGroup) account2 := createAzureBatchAccount("test-batch-account-2", "Succeeded", subscriptionID, resourceGroup) mockClient := mocks.NewMockBatchAccountsClient(ctrl) mockPager := &mockBatchAccountsPager{ ctrl: ctrl, more: true, response: armbatch.AccountClientListByResourceGroupResponse{ AccountListResult: armbatch.AccountListResult{ Value: []*armbatch.Account{account1, account2}, }, }, } mockClient.EXPECT().ListByResourceGroup(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("List_WithNilName", func(t *testing.T) { account1 := createAzureBatchAccount("test-batch-account-1", "Succeeded", subscriptionID, resourceGroup) account2NilName := createAzureBatchAccount("test-batch-account-2", "Succeeded", subscriptionID, resourceGroup) account2NilName.Name = nil // Set name to nil to test filtering mockClient := mocks.NewMockBatchAccountsClient(ctrl) mockPager := &mockBatchAccountsPager{ ctrl: ctrl, more: true, response: armbatch.AccountClientListByResourceGroupResponse{ AccountListResult: armbatch.AccountListResult{ Value: []*armbatch.Account{account1, account2NilName}, }, }, } mockClient.EXPECT().ListByResourceGroup(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item since account2 has nil name if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (filtered out nil name), got: %d", len(sdpItems)) } }) t.Run("List_PagerError", func(t *testing.T) { expectedErr := errors.New("pager error") mockClient := mocks.NewMockBatchAccountsClient(ctrl) mockPager := &mockBatchAccountsPager{ ctrl: ctrl, more: true, err: expectedErr, } mockClient.EXPECT().ListByResourceGroup(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("GetLookups", func(t *testing.T) { mockClient := mocks.NewMockBatchAccountsClient(ctrl) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) != 1 { t.Fatalf("Expected 1 lookup, got: %d", len(lookups)) } if lookups[0].ItemType != azureshared.BatchBatchAccount { t.Errorf("Expected lookup item type %s, got %s", azureshared.BatchBatchAccount, lookups[0].ItemType) } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockBatchAccountsClient(ctrl) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() expectedLinks := []shared.ItemType{ azureshared.StorageAccount, azureshared.KeyVaultVault, azureshared.NetworkPrivateEndpoint, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.BatchBatchApplication, azureshared.BatchBatchPool, azureshared.BatchBatchCertificate, azureshared.BatchBatchPrivateEndpointConnection, azureshared.BatchBatchPrivateLinkResource, azureshared.BatchBatchDetector, } for _, expectedLink := range expectedLinks { if !potentialLinks[expectedLink] { t.Errorf("Expected potential link %s to be true, got false", expectedLink) } } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockBatchAccountsClient(ctrl) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) != 1 { t.Fatalf("Expected 1 terraform mapping, got: %d", len(mappings)) } if mappings[0].GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected terraform method GET, got: %s", mappings[0].GetTerraformMethod()) } if mappings[0].GetTerraformQueryMap() != "azurerm_batch_account.name" { t.Errorf("Expected terraform query map 'azurerm_batch_account.name', got: %s", mappings[0].GetTerraformQueryMap()) } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockBatchAccountsClient(ctrl) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() expectedPermissions := []string{ "Microsoft.Batch/batchAccounts/read", } if len(permissions) != len(expectedPermissions) { t.Fatalf("Expected %d permissions, got: %d", len(expectedPermissions), len(permissions)) } for i, expected := range expectedPermissions { if permissions[i] != expected { t.Errorf("Expected permission %s, got: %s", expected, permissions[i]) } } }) t.Run("PredefinedRole", func(t *testing.T) { mockClient := mocks.NewMockBatchAccountsClient(ctrl) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // PredefinedRole is available on the wrapper, not the adapter role := wrapper.(interface{ PredefinedRole() string }).PredefinedRole() expectedRole := "Azure Batch Account Reader" if role != expectedRole { t.Errorf("Expected role %s, got: %s", expectedRole, role) } }) t.Run("CrossResourceGroupScope", func(t *testing.T) { // Test that resources in different resource groups use the correct scope otherSubscriptionID := "other-subscription" otherResourceGroup := "other-rg" accountName := "test-batch-account" account := createAzureBatchAccountWithCrossRGResources( accountName, "Succeeded", subscriptionID, resourceGroup, otherSubscriptionID, otherResourceGroup, ) mockClient := mocks.NewMockBatchAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armbatch.AccountClientGetResponse{ Account: *account, }, nil) wrapper := manual.NewBatchAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Find the storage account link (which is in a different resource group) foundCrossRGStorage := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.StorageAccount.String() { expectedScope := otherSubscriptionID + "." + otherResourceGroup if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected storage account scope %s, got: %s", expectedScope, linkedQuery.GetQuery().GetScope()) } foundCrossRGStorage = true } } if !foundCrossRGStorage { t.Error("Expected to find storage account link with cross-resource-group scope") } }) } // createAzureBatchAccount creates a mock Azure Batch Account for testing func createAzureBatchAccount(accountName, provisioningState, subscriptionID, resourceGroup string) *armbatch.Account { storageAccountID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Storage/storageAccounts/test-storage-account" keyVaultID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-keyvault" privateEndpointID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-private-endpoint" identityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" nodeIdentityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-node-identity" return &armbatch.Account{ Name: new(accountName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armbatch.AccountProperties{ ProvisioningState: (*armbatch.ProvisioningState)(new(provisioningState)), AutoStorage: &armbatch.AutoStorageProperties{ StorageAccountID: new(storageAccountID), LastKeySync: new(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), NodeIdentityReference: &armbatch.ComputeNodeIdentityReference{ ResourceID: new(nodeIdentityID), }, }, KeyVaultReference: &armbatch.KeyVaultReference{ ID: new(keyVaultID), URL: new("https://test-keyvault.vault.azure.net/"), }, PrivateEndpointConnections: []*armbatch.PrivateEndpointConnection{ { Properties: &armbatch.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armbatch.PrivateEndpoint{ ID: new(privateEndpointID), }, }, }, }, }, Identity: &armbatch.AccountIdentity{ Type: (*armbatch.ResourceIdentityType)(new(armbatch.ResourceIdentityTypeUserAssigned)), UserAssignedIdentities: map[string]*armbatch.UserAssignedIdentities{ identityID: {}, }, }, } } // createAzureBatchAccountWithCrossRGResources creates a mock Azure Batch Account with resources in different resource groups func createAzureBatchAccountWithCrossRGResources( accountName, provisioningState, subscriptionID, resourceGroup, otherSubscriptionID, otherResourceGroup string, ) *armbatch.Account { // Storage account is in a different resource group storageAccountID := "/subscriptions/" + otherSubscriptionID + "/resourceGroups/" + otherResourceGroup + "/providers/Microsoft.Storage/storageAccounts/test-storage-account" return &armbatch.Account{ Name: new(accountName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armbatch.AccountProperties{ ProvisioningState: (*armbatch.ProvisioningState)(new(provisioningState)), AutoStorage: &armbatch.AutoStorageProperties{ StorageAccountID: new(storageAccountID), LastKeySync: new(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), }, }, } } ================================================ FILE: sources/azure/manual/batch-batch-application-package.go ================================================ package manual import ( "context" "errors" "net/url" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var BatchBatchApplicationPackageLookupByName = shared.NewItemTypeLookup("name", azureshared.BatchBatchApplicationPackage) type batchBatchApplicationPackageWrapper struct { client clients.BatchApplicationPackagesClient *azureshared.MultiResourceGroupBase } // NewBatchBatchApplicationPackage returns a SearchableWrapper for Azure Batch application packages // (child of Batch application, grandchild of Batch account). func NewBatchBatchApplicationPackage(client clients.BatchApplicationPackagesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &batchBatchApplicationPackageWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.BatchBatchApplicationPackage, ), } } func (c batchBatchApplicationPackageWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 3 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 3 query parts: accountName, applicationName, and versionName", Scope: scope, ItemType: c.Type(), } } accountName := queryParts[0] applicationName := queryParts[1] versionName := queryParts[2] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, accountName, applicationName, versionName) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureApplicationPackageToSDPItem(&resp.ApplicationPackage, accountName, applicationName, versionName, scope) } func (c batchBatchApplicationPackageWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BatchAccountLookupByName, BatchBatchApplicationLookupByName, BatchBatchApplicationPackageLookupByName, } } func (c batchBatchApplicationPackageWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 2 query parts: accountName and applicationName", Scope: scope, ItemType: c.Type(), } } accountName := queryParts[0] applicationName := queryParts[1] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.List(ctx, rgScope.ResourceGroup, accountName, applicationName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, pkg := range page.Value { if pkg == nil || pkg.Name == nil { continue } item, sdpErr := c.azureApplicationPackageToSDPItem(pkg, accountName, applicationName, *pkg.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c batchBatchApplicationPackageWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 2 { stream.SendError(azureshared.QueryError(errors.New("Search requires 2 query parts: accountName and applicationName"), scope, c.Type())) return } accountName := queryParts[0] applicationName := queryParts[1] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.List(ctx, rgScope.ResourceGroup, accountName, applicationName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, pkg := range page.Value { if pkg == nil || pkg.Name == nil { continue } item, sdpErr := c.azureApplicationPackageToSDPItem(pkg, accountName, applicationName, *pkg.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c batchBatchApplicationPackageWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { BatchAccountLookupByName, BatchBatchApplicationLookupByName, }, } } func (c batchBatchApplicationPackageWrapper) azureApplicationPackageToSDPItem(pkg *armbatch.ApplicationPackage, accountName, applicationName, versionName, scope string) (*sdp.Item, *sdp.QueryError) { if pkg.Name == nil { return nil, azureshared.QueryError(errors.New("application package name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(pkg, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if err := attributes.Set("uniqueAttr", shared.CompositeLookupKey(accountName, applicationName, versionName)); err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.BatchBatchApplicationPackage.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(pkg.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Health status from package state if pkg.Properties != nil && pkg.Properties.State != nil { switch *pkg.Properties.State { case armbatch.PackageStateActive: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armbatch.PackageStatePending: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Batch Application sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchApplication.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(accountName, applicationName), Scope: scope, }, }) // Link to parent Batch Account sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchAccount.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) // Link to StorageURL DNS name (Azure Storage blob endpoint hosting the package) if pkg.Properties != nil && pkg.Properties.StorageURL != nil && *pkg.Properties.StorageURL != "" { u, parseErr := url.Parse(*pkg.Properties.StorageURL) if parseErr == nil && u.Hostname() != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: u.Hostname(), Scope: "global", }, }) } } return sdpItem, nil } func (c batchBatchApplicationPackageWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.BatchBatchApplication: true, azureshared.BatchBatchAccount: true, stdlib.NetworkDNS: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftbatch func (c batchBatchApplicationPackageWrapper) IAMPermissions() []string { return []string{ "Microsoft.Batch/batchAccounts/applications/versions/read", } } func (c batchBatchApplicationPackageWrapper) PredefinedRole() string { return "Azure Batch Account Reader" } ================================================ FILE: sources/azure/manual/batch-batch-application-package_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockBatchApplicationPackagesPager struct { pages []armbatch.ApplicationPackageClientListResponse index int } func (m *mockBatchApplicationPackagesPager) More() bool { return m.index < len(m.pages) } func (m *mockBatchApplicationPackagesPager) NextPage(ctx context.Context) (armbatch.ApplicationPackageClientListResponse, error) { if m.index >= len(m.pages) { return armbatch.ApplicationPackageClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorBatchApplicationPackagesPager struct{} func (e *errorBatchApplicationPackagesPager) More() bool { return true } func (e *errorBatchApplicationPackagesPager) NextPage(ctx context.Context) (armbatch.ApplicationPackageClientListResponse, error) { return armbatch.ApplicationPackageClientListResponse{}, errors.New("pager error") } type testBatchApplicationPackagesClient struct { *mocks.MockBatchApplicationPackagesClient pager clients.BatchApplicationPackagesPager } func (t *testBatchApplicationPackagesClient) List(ctx context.Context, resourceGroupName, accountName, applicationName string) clients.BatchApplicationPackagesPager { if t.pager != nil { return t.pager } return t.MockBatchApplicationPackagesClient.List(ctx, resourceGroupName, accountName, applicationName) } func createAzureBatchApplicationPackage(versionName string) *armbatch.ApplicationPackage { state := armbatch.PackageStateActive return &armbatch.ApplicationPackage{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Batch/batchAccounts/acc/applications/app/versions/" + versionName), Name: new(versionName), Type: new("Microsoft.Batch/batchAccounts/applications/versions"), Properties: &armbatch.ApplicationPackageProperties{ State: &state, Format: new("zip"), StorageURL: new("https://teststorage.blob.core.windows.net/packages/" + versionName + ".zip"), }, Tags: map[string]*string{"env": new("test")}, } } func TestBatchBatchApplicationPackage(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup accountName := "test-batch-account" applicationName := "test-app" versionName := "1.0" t.Run("Get", func(t *testing.T) { pkg := createAzureBatchApplicationPackage(versionName) mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, applicationName, versionName).Return( armbatch.ApplicationPackageClientGetResponse{ ApplicationPackage: *pkg, }, nil) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, applicationName, versionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.BatchBatchApplicationPackage.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchApplicationPackage.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(accountName, applicationName, versionName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != scope { t.Errorf("Expected scope %s, got %s", scope, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected valid item, got: %v", err) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK for active package, got %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.BatchBatchApplication.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(accountName, applicationName), ExpectedScope: scope}, {ExpectedType: azureshared.BatchBatchAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: accountName, ExpectedScope: scope}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "teststorage.blob.core.windows.net", ExpectedScope: "global"}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Only 2 parts instead of 3 query := shared.CompositeLookupKey(accountName, applicationName) _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when Get with insufficient query parts, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("application package not found") mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, applicationName, "nonexistent").Return( armbatch.ApplicationPackageClientGetResponse{}, expectedErr) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, applicationName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { pkg1 := createAzureBatchApplicationPackage("1.0") pkg2 := createAzureBatchApplicationPackage("2.0") mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) pages := []armbatch.ApplicationPackageClientListResponse{ { ListApplicationPackagesResult: armbatch.ListApplicationPackagesResult{ Value: []*armbatch.ApplicationPackage{pkg1, pkg2}, }, }, } mockPager := &mockBatchApplicationPackagesPager{pages: pages} testClient := &testBatchApplicationPackagesClient{ MockBatchApplicationPackagesClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchBatchApplicationPackage(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, shared.CompositeLookupKey(accountName, applicationName), true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("SearchStream", func(t *testing.T) { pkg1 := createAzureBatchApplicationPackage("1.0") pkg2 := createAzureBatchApplicationPackage("2.0") mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) pages := []armbatch.ApplicationPackageClientListResponse{ { ListApplicationPackagesResult: armbatch.ListApplicationPackagesResult{ Value: []*armbatch.ApplicationPackage{pkg1, pkg2}, }, }, } mockPager := &mockBatchApplicationPackagesPager{pages: pages} testClient := &testBatchApplicationPackagesClient{ MockBatchApplicationPackagesClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchBatchApplicationPackage(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } var items []*sdp.Item var errs []error stream := discovery.NewQueryResultStream( func(item *sdp.Item) { items = append(items, item) }, func(err error) { errs = append(errs, err) }, ) searchStreamable.SearchStream(ctx, scope, shared.CompositeLookupKey(accountName, applicationName), true, stream) if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // No query parts _, qErr := wrapper.Search(ctx, scope) if qErr == nil { t.Error("Expected error when Search with no query parts, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) errorPager := &errorBatchApplicationPackagesPager{} testClient := &testBatchApplicationPackagesClient{ MockBatchApplicationPackagesClient: mockClient, pager: errorPager, } wrapper := manual.NewBatchBatchApplicationPackage(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, accountName, applicationName) if qErr == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validPkg := createAzureBatchApplicationPackage("1.0") nilNamePkg := &armbatch.ApplicationPackage{ Name: nil, } mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) pages := []armbatch.ApplicationPackageClientListResponse{ { ListApplicationPackagesResult: armbatch.ListApplicationPackagesResult{ Value: []*armbatch.ApplicationPackage{nilNamePkg, validPkg}, }, }, } mockPager := &mockBatchApplicationPackagesPager{pages: pages} testClient := &testBatchApplicationPackagesClient{ MockBatchApplicationPackagesClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchBatchApplicationPackage(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) items, qErr := wrapper.Search(ctx, scope, accountName, applicationName) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 1 { t.Fatalf("Expected 1 item (nil-name skipped), got: %d", len(items)) } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.BatchBatchApplication] { t.Error("PotentialLinks() should include BatchBatchApplication") } if !links[azureshared.BatchBatchAccount] { t.Error("PotentialLinks() should include BatchBatchAccount") } if !links[stdlib.NetworkDNS] { t.Error("PotentialLinks() should include stdlib.NetworkDNS") } }) t.Run("HealthPending", func(t *testing.T) { pkg := createAzureBatchApplicationPackage(versionName) state := armbatch.PackageStatePending pkg.Properties.State = &state mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, applicationName, versionName).Return( armbatch.ApplicationPackageClientGetResponse{ ApplicationPackage: *pkg, }, nil) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, applicationName, versionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != sdp.Health_HEALTH_PENDING { t.Errorf("Expected health PENDING for pending package, got %s", sdpItem.GetHealth()) } }) t.Run("GetWithoutStorageURL", func(t *testing.T) { pkg := createAzureBatchApplicationPackage(versionName) pkg.Properties.StorageURL = nil mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, applicationName, versionName).Return( armbatch.ApplicationPackageClientGetResponse{ ApplicationPackage: *pkg, }, nil) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, applicationName, versionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should have 2 linked queries (application + account) but no DNS link linkedQueries := sdpItem.GetLinkedItemQueries() for _, liq := range linkedQueries { if liq.GetQuery().GetType() == stdlib.NetworkDNS.String() { t.Error("Expected no DNS linked query when StorageURL is nil") } } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationPackagesClient(ctrl) wrapper := manual.NewBatchBatchApplicationPackage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/batch-batch-application.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var BatchBatchApplicationLookupByName = shared.NewItemTypeLookup("name", azureshared.BatchBatchApplication) type batchBatchApplicationWrapper struct { client clients.BatchApplicationsClient *azureshared.MultiResourceGroupBase } // NewBatchBatchApplication returns a SearchableWrapper for Azure Batch applications (child of Batch account). func NewBatchBatchApplication(client clients.BatchApplicationsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &batchBatchApplicationWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.BatchBatchApplication, ), } } func (b batchBatchApplicationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: accountName and applicationName", Scope: scope, ItemType: b.Type(), } } accountName := queryParts[0] applicationName := queryParts[1] rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } resp, err := b.client.Get(ctx, rgScope.ResourceGroup, accountName, applicationName) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } return b.azureApplicationToSDPItem(&resp.Application, accountName, applicationName, scope) } func (b batchBatchApplicationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BatchAccountLookupByName, BatchBatchApplicationLookupByName, } } func (b batchBatchApplicationWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: accountName", Scope: scope, ItemType: b.Type(), } } accountName := queryParts[0] rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } pager := b.client.List(ctx, rgScope.ResourceGroup, accountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } for _, app := range page.Value { if app == nil || app.Name == nil { continue } item, sdpErr := b.azureApplicationToSDPItem(app, accountName, *app.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (b batchBatchApplicationWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: accountName"), scope, b.Type())) return } accountName := queryParts[0] rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } pager := b.client.List(ctx, rgScope.ResourceGroup, accountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } for _, app := range page.Value { if app == nil || app.Name == nil { continue } item, sdpErr := b.azureApplicationToSDPItem(app, accountName, *app.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (b batchBatchApplicationWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { BatchAccountLookupByName, }, } } func (b batchBatchApplicationWrapper) azureApplicationToSDPItem(app *armbatch.Application, accountName, applicationName, scope string) (*sdp.Item, *sdp.QueryError) { if app.Name == nil { return nil, azureshared.QueryError(errors.New("application name is nil"), scope, b.Type()) } attributes, err := shared.ToAttributesWithExclude(app, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } if err := attributes.Set("uniqueAttr", shared.CompositeLookupKey(accountName, applicationName)); err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } sdpItem := &sdp.Item{ Type: azureshared.BatchBatchApplication.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(app.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to parent Batch Account sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchAccount.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) // Link to Application Packages (child resource under this application) // Packages are listed under /batchAccounts/{account}/applications/{app}/versions sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchApplicationPackage.String(), Method: sdp.QueryMethod_SEARCH, Query: shared.CompositeLookupKey(accountName, applicationName), Scope: scope, }, }) // Link to default version application package when set (GET to specific child resource) if app.Properties != nil && app.Properties.DefaultVersion != nil && *app.Properties.DefaultVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchApplicationPackage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(accountName, applicationName, *app.Properties.DefaultVersion), Scope: scope, }, }) } return sdpItem, nil } func (b batchBatchApplicationWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.BatchBatchAccount: true, azureshared.BatchBatchApplicationPackage: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/batch_application func (b batchBatchApplicationWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_batch_application.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute func (b batchBatchApplicationWrapper) IAMPermissions() []string { return []string{ "Microsoft.Batch/batchAccounts/applications/read", } } func (b batchBatchApplicationWrapper) PredefinedRole() string { return "Azure Batch Account Reader" } ================================================ FILE: sources/azure/manual/batch-batch-application_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockBatchApplicationsPager is a mock implementation of BatchApplicationsPager. type mockBatchApplicationsPager struct { pages []armbatch.ApplicationClientListResponse index int } func (m *mockBatchApplicationsPager) More() bool { return m.index < len(m.pages) } func (m *mockBatchApplicationsPager) NextPage(ctx context.Context) (armbatch.ApplicationClientListResponse, error) { if m.index >= len(m.pages) { return armbatch.ApplicationClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorBatchApplicationsPager is a mock pager that always returns an error. type errorBatchApplicationsPager struct{} func (e *errorBatchApplicationsPager) More() bool { return true } func (e *errorBatchApplicationsPager) NextPage(ctx context.Context) (armbatch.ApplicationClientListResponse, error) { return armbatch.ApplicationClientListResponse{}, errors.New("pager error") } // testBatchApplicationsClient wraps the mock and injects a pager from List(). type testBatchApplicationsClient struct { *mocks.MockBatchApplicationsClient pager clients.BatchApplicationsPager } func (t *testBatchApplicationsClient) List(ctx context.Context, resourceGroupName, accountName string) clients.BatchApplicationsPager { if t.pager != nil { return t.pager } return t.MockBatchApplicationsClient.List(ctx, resourceGroupName, accountName) } func createAzureBatchApplication(name string) *armbatch.Application { allowUpdates := true return &armbatch.Application{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Batch/batchAccounts/acc/applications/" + name), Name: new(name), Type: new("Microsoft.Batch/batchAccounts/applications"), Properties: &armbatch.ApplicationProperties{ DisplayName: new("Test application " + name), AllowUpdates: &allowUpdates, }, Tags: map[string]*string{"env": new("test")}, } } func TestBatchBatchApplication(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup accountName := "test-batch-account" applicationName := "test-app" t.Run("Get", func(t *testing.T) { app := createAzureBatchApplication(applicationName) mockClient := mocks.NewMockBatchApplicationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, applicationName).Return( armbatch.ApplicationClientGetResponse{ Application: *app, }, nil) wrapper := manual.NewBatchBatchApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, applicationName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.BatchBatchApplication.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchApplication.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(accountName, applicationName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != scope { t.Errorf("Expected scope %s, got %s", scope, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected valid item, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.BatchBatchAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: accountName, ExpectedScope: scope}, {ExpectedType: azureshared.BatchBatchApplicationPackage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(accountName, applicationName), ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationsClient(ctrl) wrapper := manual.NewBatchBatchApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, accountName, true) if qErr == nil { t.Error("Expected error when Get with insufficient query parts, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("application not found") mockClient := mocks.NewMockBatchApplicationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, "nonexistent").Return( armbatch.ApplicationClientGetResponse{}, expectedErr) wrapper := manual.NewBatchBatchApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { app1 := createAzureBatchApplication("app-1") app2 := createAzureBatchApplication("app-2") mockClient := mocks.NewMockBatchApplicationsClient(ctrl) pages := []armbatch.ApplicationClientListResponse{ { ListApplicationsResult: armbatch.ListApplicationsResult{ Value: []*armbatch.Application{app1, app2}, }, }, } mockPager := &mockBatchApplicationsPager{pages: pages} testClient := &testBatchApplicationsClient{ MockBatchApplicationsClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchBatchApplication(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationsClient(ctrl) wrapper := manual.NewBatchBatchApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope) if qErr == nil { t.Error("Expected error when Search with no query parts, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationsClient(ctrl) errorPager := &errorBatchApplicationsPager{} testClient := &testBatchApplicationsClient{ MockBatchApplicationsClient: mockClient, pager: errorPager, } wrapper := manual.NewBatchBatchApplication(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, accountName) if qErr == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationsClient(ctrl) wrapper := manual.NewBatchBatchApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.BatchBatchAccount] { t.Error("PotentialLinks() should include BatchBatchAccount") } if !links[azureshared.BatchBatchApplicationPackage] { t.Error("PotentialLinks() should include BatchBatchApplicationPackage") } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockBatchApplicationsClient(ctrl) wrapper := manual.NewBatchBatchApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/batch-batch-pool.go ================================================ package manual import ( "context" "errors" "fmt" "net" "net/url" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var BatchBatchPoolLookupByName = shared.NewItemTypeLookup("name", azureshared.BatchBatchPool) type batchBatchPoolWrapper struct { client clients.BatchPoolsClient *azureshared.MultiResourceGroupBase } // NewBatchBatchPool returns a SearchableWrapper for Azure Batch pools (child of Batch account). func NewBatchBatchPool(client clients.BatchPoolsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &batchBatchPoolWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.BatchBatchPool, ), } } func (b batchBatchPoolWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: accountName and poolName", Scope: scope, ItemType: b.Type(), } } accountName := queryParts[0] poolName := queryParts[1] rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } resp, err := b.client.Get(ctx, rgScope.ResourceGroup, accountName, poolName) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } return b.azurePoolToSDPItem(&resp.Pool, accountName, poolName, scope) } func (b batchBatchPoolWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BatchAccountLookupByName, BatchBatchPoolLookupByName, } } func (b batchBatchPoolWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: accountName", Scope: scope, ItemType: b.Type(), } } accountName := queryParts[0] rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } pager := b.client.ListByBatchAccount(ctx, rgScope.ResourceGroup, accountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } for _, pool := range page.Value { if pool == nil || pool.Name == nil { continue } item, sdpErr := b.azurePoolToSDPItem(pool, accountName, *pool.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (b batchBatchPoolWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: accountName"), scope, b.Type())) return } accountName := queryParts[0] rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } pager := b.client.ListByBatchAccount(ctx, rgScope.ResourceGroup, accountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } for _, pool := range page.Value { if pool == nil || pool.Name == nil { continue } item, sdpErr := b.azurePoolToSDPItem(pool, accountName, *pool.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (b batchBatchPoolWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { BatchAccountLookupByName, }, } } func (b batchBatchPoolWrapper) azurePoolToSDPItem(pool *armbatch.Pool, accountName, poolName, scope string) (*sdp.Item, *sdp.QueryError) { if pool.Name == nil { return nil, azureshared.QueryError(errors.New("pool name is nil"), scope, b.Type()) } attributes, err := shared.ToAttributesWithExclude(pool, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } if err := attributes.Set("uniqueAttr", shared.CompositeLookupKey(accountName, poolName)); err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } sdpItem := &sdp.Item{ Type: azureshared.BatchBatchPool.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(pool.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to parent Batch Account sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchAccount.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) // Link to public IPs when NetworkConfiguration.PublicIPAddressConfiguration.IPAddressIDs is set if pool.Properties != nil && pool.Properties.NetworkConfiguration != nil && pool.Properties.NetworkConfiguration.PublicIPAddressConfiguration != nil { for _, ipIDPtr := range pool.Properties.NetworkConfiguration.PublicIPAddressConfiguration.IPAddressIDs { if ipIDPtr == nil || *ipIDPtr == "" { continue } ipName := azureshared.ExtractResourceName(*ipIDPtr) if ipName == "" { continue } linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*ipIDPtr); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: ipName, Scope: linkedScope, }, }) } } // Link to Subnet when NetworkConfiguration.SubnetID is set if pool.Properties != nil && pool.Properties.NetworkConfiguration != nil && pool.Properties.NetworkConfiguration.SubnetID != nil { subnetID := *pool.Properties.NetworkConfiguration.SubnetID scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subnetScope := fmt.Sprintf("%s.%s", scopeParams[0], scopeParams[1]) vnetName := subnetParams[0] subnetName := subnetParams[1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: subnetScope, }, }) } } // Link to user-assigned managed identities from Identity.UserAssignedIdentities map keys (resource IDs) if pool.Identity != nil && pool.Identity.UserAssignedIdentities != nil { for identityResourceID := range pool.Identity.UserAssignedIdentities { if identityResourceID == "" { continue } identityName := azureshared.ExtractResourceName(identityResourceID) if identityName == "" { continue } linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } // Link to application packages referenced by the pool (Properties.ApplicationPackages) // ID can be .../batchAccounts/{account}/applications/{app}/versions/{version} (specific version) // or .../applications/{app} (default version); when default, use pkgRef.Version as fallback. if pool.Properties != nil && pool.Properties.ApplicationPackages != nil { for _, pkgRef := range pool.Properties.ApplicationPackages { if pkgRef == nil || pkgRef.ID == nil || *pkgRef.ID == "" { continue } var pkgAccountName, appName, version string params := azureshared.ExtractPathParamsFromResourceID(*pkgRef.ID, []string{"batchAccounts", "applications", "versions"}) if len(params) >= 3 { pkgAccountName, appName, version = params[0], params[1], params[2] } else { paramsApp := azureshared.ExtractPathParamsFromResourceID(*pkgRef.ID, []string{"batchAccounts", "applications"}) if len(paramsApp) < 2 { continue } pkgAccountName, appName = paramsApp[0], paramsApp[1] if pkgRef.Version != nil && *pkgRef.Version != "" { version = *pkgRef.Version } else { // Default version reference with no Version field: cannot form GET (adapter needs account|app|version) continue } } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchApplicationPackage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(pkgAccountName, appName, version), Scope: scope, }, }) } } // Note: armbatch v4 removed Certificates from PoolProperties; certificate refs are no longer linked from pools. // Link to storage accounts and IP/DNS from MountConfiguration seenIPs := make(map[string]struct{}) seenDNS := make(map[string]struct{}) if pool.Properties != nil && pool.Properties.MountConfiguration != nil { for _, mount := range pool.Properties.MountConfiguration { if mount == nil { continue } if mount.AzureBlobFileSystemConfiguration != nil { blobCfg := mount.AzureBlobFileSystemConfiguration if blobCfg.AccountName != nil && *blobCfg.AccountName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: *blobCfg.AccountName, Scope: scope, }, }) } if blobCfg.AccountName != nil && *blobCfg.AccountName != "" && blobCfg.ContainerName != nil && *blobCfg.ContainerName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageBlobContainer.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(*blobCfg.AccountName, *blobCfg.ContainerName), Scope: scope, }, }) } if blobCfg.IdentityReference != nil && blobCfg.IdentityReference.ResourceID != nil && *blobCfg.IdentityReference.ResourceID != "" { identityResourceID := *blobCfg.IdentityReference.ResourceID identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } if mount.AzureFileShareConfiguration != nil { if mount.AzureFileShareConfiguration.AccountName != nil && *mount.AzureFileShareConfiguration.AccountName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: *mount.AzureFileShareConfiguration.AccountName, Scope: scope, }, }) } if mount.AzureFileShareConfiguration.AzureFileURL != nil && *mount.AzureFileShareConfiguration.AzureFileURL != "" { AppendURILinks(&sdpItem.LinkedItemQueries, *mount.AzureFileShareConfiguration.AzureFileURL, seenDNS, seenIPs) } } if mount.CifsMountConfiguration != nil && mount.CifsMountConfiguration.Source != nil && *mount.CifsMountConfiguration.Source != "" { appendMountSourceHostLink(&sdpItem.LinkedItemQueries, *mount.CifsMountConfiguration.Source, seenIPs, seenDNS) } if mount.NfsMountConfiguration != nil && mount.NfsMountConfiguration.Source != nil && *mount.NfsMountConfiguration.Source != "" { appendMountSourceHostLink(&sdpItem.LinkedItemQueries, *mount.NfsMountConfiguration.Source, seenIPs, seenDNS) } } } // Link to image reference from DeploymentConfiguration.VirtualMachineConfiguration.ImageReference // (custom image, shared gallery image, or community gallery image) if pool.Properties != nil && pool.Properties.DeploymentConfiguration != nil && pool.Properties.DeploymentConfiguration.VirtualMachineConfiguration != nil { imageRef := pool.Properties.DeploymentConfiguration.VirtualMachineConfiguration.ImageReference if imageRef != nil { // ImageReference.ID: custom image or gallery image version path if imageRef.ID != nil && *imageRef.ID != "" { imageID := *imageRef.ID if strings.Contains(imageID, "/galleries/") && strings.Contains(imageID, "/images/") && strings.Contains(imageID, "/versions/") { params := azureshared.ExtractPathParamsFromResourceID(imageID, []string{"galleries", "images", "versions"}) if len(params) == 3 { galleryName, imageName, versionName := params[0], params[1], params[2] linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(imageID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, versionName), Scope: linkScope, }, }) } } else if strings.Contains(imageID, "/images/") { imageName := azureshared.ExtractResourceName(imageID) if imageName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(imageID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: imageName, Scope: linkScope, }, }) } } } // SharedGalleryImageID (path: .../sharedGalleries/{name}/images/{name}/versions/{name}) if imageRef.SharedGalleryImageID != nil && *imageRef.SharedGalleryImageID != "" { sharedGalleryImageID := *imageRef.SharedGalleryImageID parts := azureshared.ExtractPathParamsFromResourceID(sharedGalleryImageID, []string{"sharedGalleries", "images", "versions"}) if len(parts) >= 3 { galleryName, imageName, version := parts[0], parts[1], parts[2] linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(sharedGalleryImageID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, version), Scope: linkScope, }, }) } } // CommunityGalleryImageID if imageRef.CommunityGalleryImageID != nil && *imageRef.CommunityGalleryImageID != "" { communityGalleryImageID := *imageRef.CommunityGalleryImageID parts := azureshared.ExtractPathParamsFromResourceID(communityGalleryImageID, []string{"CommunityGalleries", "Images", "Versions"}) if len(parts) >= 3 { communityGalleryName, imageName, version := parts[0], parts[1], parts[2] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCommunityGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(communityGalleryName, imageName, version), Scope: scope, }, }) } } } // Container registries (RegistryServer → DNS link; IdentityReference → managed identity link) vmConfig := pool.Properties.DeploymentConfiguration.VirtualMachineConfiguration if vmConfig.ContainerConfiguration != nil && vmConfig.ContainerConfiguration.ContainerRegistries != nil { for _, reg := range vmConfig.ContainerConfiguration.ContainerRegistries { if reg == nil { continue } if reg.RegistryServer != nil && *reg.RegistryServer != "" { host := strings.TrimSpace(*reg.RegistryServer) if host != "" { if net.ParseIP(host) != nil { if _, seen := seenIPs[host]; !seen { seenIPs[host] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: host, Scope: "global", }, }) } } else { if _, seen := seenDNS[host]; !seen { seenDNS[host] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: host, Scope: "global", }, }) } } } } if reg.IdentityReference != nil && reg.IdentityReference.ResourceID != nil && *reg.IdentityReference.ResourceID != "" { identityResourceID := *reg.IdentityReference.ResourceID identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } } } // StartTask: ResourceFiles (HTTPUrl, StorageContainerURL → URI links; IdentityReference → managed identity), ContainerSettings.Registry (RegistryServer → DNS; IdentityReference → managed identity) if pool.Properties != nil && pool.Properties.StartTask != nil { startTask := pool.Properties.StartTask if startTask.ResourceFiles != nil { for _, rf := range startTask.ResourceFiles { if rf == nil { continue } if rf.HTTPURL != nil && *rf.HTTPURL != "" { AppendURILinks(&sdpItem.LinkedItemQueries, *rf.HTTPURL, seenDNS, seenIPs) } if rf.StorageContainerURL != nil && *rf.StorageContainerURL != "" { AppendURILinks(&sdpItem.LinkedItemQueries, *rf.StorageContainerURL, seenDNS, seenIPs) } if rf.IdentityReference != nil && rf.IdentityReference.ResourceID != nil && *rf.IdentityReference.ResourceID != "" { identityResourceID := *rf.IdentityReference.ResourceID identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } } if startTask.ContainerSettings != nil && startTask.ContainerSettings.Registry != nil { reg := startTask.ContainerSettings.Registry if reg.RegistryServer != nil && *reg.RegistryServer != "" { host := strings.TrimSpace(*reg.RegistryServer) if host != "" { if net.ParseIP(host) != nil { if _, seen := seenIPs[host]; !seen { seenIPs[host] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: host, Scope: "global", }, }) } } else { if _, seen := seenDNS[host]; !seen { seenDNS[host] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: host, Scope: "global", }, }) } } } } if reg.IdentityReference != nil && reg.IdentityReference.ResourceID != nil && *reg.IdentityReference.ResourceID != "" { identityResourceID := *reg.IdentityReference.ResourceID identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } } // Map provisioning state to health if pool.Properties != nil && pool.Properties.ProvisioningState != nil { switch *pool.Properties.ProvisioningState { case armbatch.PoolProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armbatch.PoolProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return sdpItem, nil } func (b batchBatchPoolWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.BatchBatchAccount: true, azureshared.NetworkSubnet: true, azureshared.NetworkPublicIPAddress: true, azureshared.ManagedIdentityUserAssignedIdentity: true, azureshared.BatchBatchApplicationPackage: true, azureshared.StorageAccount: true, azureshared.StorageBlobContainer: true, azureshared.ComputeImage: true, azureshared.ComputeSharedGalleryImage: true, azureshared.ComputeCommunityGalleryImage: true, stdlib.NetworkIP: true, stdlib.NetworkDNS: true, stdlib.NetworkHTTP: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/batch_pool func (b batchBatchPoolWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_batch_pool.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute func (b batchBatchPoolWrapper) IAMPermissions() []string { return []string{ "Microsoft.Batch/batchAccounts/pools/read", } } func (b batchBatchPoolWrapper) PredefinedRole() string { return "Azure Batch Account Reader" } // appendMountSourceHostLink extracts a host from a CIFS or NFS mount source (e.g. "\\server\share", "nfs://host/path", or "192.168.1.1") and appends a NetworkIP or NetworkDNS linked query with deduplication. func appendMountSourceHostLink(queries *[]*sdp.LinkedItemQuery, source string, seenIPs, seenDNS map[string]struct{}) { if source == "" { return } var host string if after, ok := strings.CutPrefix(source, "\\\\"); ok { // UNC path: \\server\share rest := after if before, _, ok := strings.Cut(rest, "\\"); ok { host = before } else { host = rest } } else if strings.Contains(source, "://") { u, err := url.Parse(source) if err != nil || u.Host == "" { return } host = u.Hostname() } else { // NFS format: host:/path (e.g. 192.168.1.1:/vol1) — split on ":/" so host has no trailing colon if before, _, ok0 := strings.Cut(source, ":/"); ok0 { host = before } else if idx := strings.IndexAny(source, "/\\"); idx >= 0 { host = source[:idx] } else { host = source } } host = strings.TrimSpace(host) if host == "" { return } if net.ParseIP(host) != nil { if _, seen := seenIPs[host]; !seen { seenIPs[host] = struct{}{} *queries = append(*queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: host, Scope: "global", }, }) } } else { if _, seen := seenDNS[host]; !seen { seenDNS[host] = struct{}{} *queries = append(*queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: host, Scope: "global", }, }) } } } ================================================ FILE: sources/azure/manual/batch-batch-pool_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockBatchPoolsPager struct { pages []armbatch.PoolClientListByBatchAccountResponse index int } func (m *mockBatchPoolsPager) More() bool { return m.index < len(m.pages) } func (m *mockBatchPoolsPager) NextPage(ctx context.Context) (armbatch.PoolClientListByBatchAccountResponse, error) { if m.index >= len(m.pages) { return armbatch.PoolClientListByBatchAccountResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorBatchPoolsPager struct{} func (e *errorBatchPoolsPager) More() bool { return true } func (e *errorBatchPoolsPager) NextPage(ctx context.Context) (armbatch.PoolClientListByBatchAccountResponse, error) { return armbatch.PoolClientListByBatchAccountResponse{}, errors.New("pager error") } type testBatchPoolsClient struct { *mocks.MockBatchPoolsClient pager clients.BatchPoolsPager } func (t *testBatchPoolsClient) ListByBatchAccount(ctx context.Context, resourceGroupName, accountName string) clients.BatchPoolsPager { if t.pager != nil { return t.pager } return t.MockBatchPoolsClient.ListByBatchAccount(ctx, resourceGroupName, accountName) } func createAzureBatchPool(name string) *armbatch.Pool { state := armbatch.PoolProvisioningStateSucceeded return &armbatch.Pool{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Batch/batchAccounts/acc/pools/" + name), Name: new(name), Type: new("Microsoft.Batch/batchAccounts/pools"), Properties: &armbatch.PoolProperties{ VMSize: new("Standard_D2s_v3"), ProvisioningState: &state, }, Tags: map[string]*string{"env": new("test")}, } } func TestBatchBatchPool(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup accountName := "test-batch-account" poolName := "test-pool" t.Run("Get", func(t *testing.T) { pool := createAzureBatchPool(poolName) mockClient := mocks.NewMockBatchPoolsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, poolName).Return( armbatch.PoolClientGetResponse{ Pool: *pool, }, nil) wrapper := manual.NewBatchBatchPool(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, poolName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.BatchBatchPool.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchPool.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(accountName, poolName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != scope { t.Errorf("Expected scope %s, got %s", scope, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected valid item, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.BatchBatchAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: accountName, ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchPoolsClient(ctrl) wrapper := manual.NewBatchBatchPool(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, accountName, true) if qErr == nil { t.Error("Expected error when Get with insufficient query parts, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("pool not found") mockClient := mocks.NewMockBatchPoolsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, "nonexistent").Return( armbatch.PoolClientGetResponse{}, expectedErr) wrapper := manual.NewBatchBatchPool(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { pool1 := createAzureBatchPool("pool-1") pool2 := createAzureBatchPool("pool-2") mockClient := mocks.NewMockBatchPoolsClient(ctrl) pages := []armbatch.PoolClientListByBatchAccountResponse{ { ListPoolsResult: armbatch.ListPoolsResult{ Value: []*armbatch.Pool{pool1, pool2}, }, }, } mockPager := &mockBatchPoolsPager{pages: pages} testClient := &testBatchPoolsClient{ MockBatchPoolsClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchBatchPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchPoolsClient(ctrl) wrapper := manual.NewBatchBatchPool(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope) if qErr == nil { t.Error("Expected error when Search with no query parts, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockBatchPoolsClient(ctrl) errorPager := &errorBatchPoolsPager{} testClient := &testBatchPoolsClient{ MockBatchPoolsClient: mockClient, pager: errorPager, } wrapper := manual.NewBatchBatchPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, accountName) if qErr == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockBatchPoolsClient(ctrl) wrapper := manual.NewBatchBatchPool(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.BatchBatchAccount] { t.Error("PotentialLinks() should include BatchBatchAccount") } if !links[azureshared.NetworkSubnet] { t.Error("PotentialLinks() should include NetworkSubnet") } if !links[azureshared.ManagedIdentityUserAssignedIdentity] { t.Error("PotentialLinks() should include ManagedIdentityUserAssignedIdentity") } if !links[azureshared.BatchBatchApplicationPackage] { t.Error("PotentialLinks() should include BatchBatchApplicationPackage") } if !links[azureshared.NetworkPublicIPAddress] { t.Error("PotentialLinks() should include NetworkPublicIPAddress") } if !links[azureshared.StorageAccount] { t.Error("PotentialLinks() should include StorageAccount") } if !links[stdlib.NetworkIP] { t.Error("PotentialLinks() should include stdlib.NetworkIP") } if !links[stdlib.NetworkDNS] { t.Error("PotentialLinks() should include stdlib.NetworkDNS") } if !links[stdlib.NetworkHTTP] { t.Error("PotentialLinks() should include stdlib.NetworkHTTP") } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockBatchPoolsClient(ctrl) wrapper := manual.NewBatchBatchPool(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/batch-private-endpoint-connection.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var BatchPrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.BatchBatchPrivateEndpointConnection) type batchPrivateEndpointConnectionWrapper struct { client clients.BatchPrivateEndpointConnectionClient *azureshared.MultiResourceGroupBase } // NewBatchPrivateEndpointConnection returns a SearchableWrapper for Azure Batch private endpoint connections. func NewBatchPrivateEndpointConnection(client clients.BatchPrivateEndpointConnectionClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &batchPrivateEndpointConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.BatchBatchPrivateEndpointConnection, ), } } func (b batchPrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: accountName and privateEndpointConnectionName", Scope: scope, ItemType: b.Type(), } } accountName := queryParts[0] connectionName := queryParts[1] if accountName == "" { return nil, azureshared.QueryError(errors.New("accountName cannot be empty"), scope, b.Type()) } if connectionName == "" { return nil, azureshared.QueryError(errors.New("privateEndpointConnectionName cannot be empty"), scope, b.Type()) } rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } resp, err := b.client.Get(ctx, rgScope.ResourceGroup, accountName, connectionName) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } item, sdpErr := b.azurePrivateEndpointConnectionToSDPItem(&resp.PrivateEndpointConnection, accountName, connectionName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (b batchPrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BatchAccountLookupByName, BatchPrivateEndpointConnectionLookupByName, } } func (b batchPrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: accountName", Scope: scope, ItemType: b.Type(), } } accountName := queryParts[0] if accountName == "" { return nil, azureshared.QueryError(errors.New("accountName cannot be empty"), scope, b.Type()) } rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } pager := b.client.ListByBatchAccount(ctx, rgScope.ResourceGroup, accountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := b.azurePrivateEndpointConnectionToSDPItem(conn, accountName, *conn.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (b batchPrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: accountName"), scope, b.Type())) return } accountName := queryParts[0] if accountName == "" { stream.SendError(azureshared.QueryError(errors.New("accountName cannot be empty"), scope, b.Type())) return } rgScope, err := b.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } pager := b.client.ListByBatchAccount(ctx, rgScope.ResourceGroup, accountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, b.Type())) return } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := b.azurePrivateEndpointConnectionToSDPItem(conn, accountName, *conn.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (b batchPrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { BatchAccountLookupByName, }, } } func (b batchPrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.BatchBatchAccount: true, azureshared.NetworkPrivateEndpoint: true, } } func (b batchPrivateEndpointConnectionWrapper) azurePrivateEndpointConnectionToSDPItem(conn *armbatch.PrivateEndpointConnection, accountName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(conn, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(accountName, connectionName)) if err != nil { return nil, azureshared.QueryError(err, scope, b.Type()) } sdpItem := &sdp.Item{ Type: azureshared.BatchBatchPrivateEndpointConnection.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(conn.Tags), } // Health from provisioning state if conn.Properties != nil && conn.Properties.ProvisioningState != nil { switch *conn.Properties.ProvisioningState { case armbatch.PrivateEndpointConnectionProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armbatch.PrivateEndpointConnectionProvisioningStateCreating, armbatch.PrivateEndpointConnectionProvisioningStateUpdating, armbatch.PrivateEndpointConnectionProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armbatch.PrivateEndpointConnectionProvisioningStateFailed, armbatch.PrivateEndpointConnectionProvisioningStateCancelled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Batch Account sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.BatchBatchAccount.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) // Link to Network Private Endpoint when present (may be in different resource group) if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { peID := *conn.Properties.PrivateEndpoint.ID peName := azureshared.ExtractResourceName(peID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } return sdpItem, nil } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftbatch func (b batchPrivateEndpointConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Batch/batchAccounts/privateEndpointConnections/read", } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/compute#azure-batch-account-reader func (b batchPrivateEndpointConnectionWrapper) PredefinedRole() string { return "Azure Batch Account Reader" } ================================================ FILE: sources/azure/manual/batch-private-endpoint-connection_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockBatchPrivateEndpointConnectionPager struct { pages []armbatch.PrivateEndpointConnectionClientListByBatchAccountResponse index int } func (m *mockBatchPrivateEndpointConnectionPager) More() bool { return m.index < len(m.pages) } func (m *mockBatchPrivateEndpointConnectionPager) NextPage(ctx context.Context) (armbatch.PrivateEndpointConnectionClientListByBatchAccountResponse, error) { if m.index >= len(m.pages) { return armbatch.PrivateEndpointConnectionClientListByBatchAccountResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type testBatchPrivateEndpointConnectionClient struct { *mocks.MockBatchPrivateEndpointConnectionClient pager clients.BatchPrivateEndpointConnectionPager } func (t *testBatchPrivateEndpointConnectionClient) ListByBatchAccount(ctx context.Context, resourceGroupName, accountName string) clients.BatchPrivateEndpointConnectionPager { return t.pager } func TestBatchPrivateEndpointConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" accountName := "test-batch-account" connectionName := "test-pec" t.Run("Get", func(t *testing.T) { conn := createAzureBatchPrivateEndpointConnection(connectionName, "") mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, connectionName).Return( armbatch.PrivateEndpointConnectionClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.BatchBatchPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchPrivateEndpointConnection, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(accountName, connectionName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(accountName, connectionName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) < 1 { t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) } foundBatchAccount := false for _, lq := range linkedQueries { if lq.GetQuery().GetType() == azureshared.BatchBatchAccount.String() { foundBatchAccount = true if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected BatchAccount link method GET, got %v", lq.GetQuery().GetMethod()) } if lq.GetQuery().GetQuery() != accountName { t.Errorf("Expected BatchAccount query %s, got %s", accountName, lq.GetQuery().GetQuery()) } } } if !foundBatchAccount { t.Error("Expected linked query to BatchAccount") } }) }) t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { peID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe" conn := createAzureBatchPrivateEndpointConnection(connectionName, peID) mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, connectionName).Return( armbatch.PrivateEndpointConnectionClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundPrivateEndpoint := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { foundPrivateEndpoint = true if lq.GetQuery().GetQuery() != "test-pe" { t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) } break } } if !foundPrivateEndpoint { t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyAccountName", func(t *testing.T) { mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", connectionName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when accountName is empty, but got nil") } }) t.Run("GetWithEmptyConnectionName", func(t *testing.T) { mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when connectionName is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { conn1 := createAzureBatchPrivateEndpointConnection("pec-1", "") conn2 := createAzureBatchPrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) mockPager := &mockBatchPrivateEndpointConnectionPager{ pages: []armbatch.PrivateEndpointConnectionClientListByBatchAccountResponse{ { ListPrivateEndpointConnectionsResult: armbatch.ListPrivateEndpointConnectionsResult{ Value: []*armbatch.PrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testBatchPrivateEndpointConnectionClient{ MockBatchPrivateEndpointConnectionClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.BatchBatchPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.BatchBatchPrivateEndpointConnection, item.GetType()) } } }) t.Run("SearchStream", func(t *testing.T) { conn1 := createAzureBatchPrivateEndpointConnection("pec-1", "") conn2 := createAzureBatchPrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) mockPager := &mockBatchPrivateEndpointConnectionPager{ pages: []armbatch.PrivateEndpointConnectionClientListByBatchAccountResponse{ { ListPrivateEndpointConnectionsResult: armbatch.ListPrivateEndpointConnectionsResult{ Value: []*armbatch.PrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testBatchPrivateEndpointConnectionClient{ MockBatchPrivateEndpointConnectionClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item) } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], accountName, true, stream) if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validConn := createAzureBatchPrivateEndpointConnection("valid-pec", "") mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) mockPager := &mockBatchPrivateEndpointConnectionPager{ pages: []armbatch.PrivateEndpointConnectionClientListByBatchAccountResponse{ { ListPrivateEndpointConnectionsResult: armbatch.ListPrivateEndpointConnectionsResult{ Value: []*armbatch.PrivateEndpointConnection{ {Name: nil}, validConn, }, }, }, }, } testClient := &testBatchPrivateEndpointConnectionClient{ MockBatchPrivateEndpointConnectionClient: mockClient, pager: mockPager, } wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(accountName, "valid-pec") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(accountName, "valid-pec"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("SearchWithEmptyAccountName", func(t *testing.T) { mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when accountName is empty, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("private endpoint connection not found") mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, "nonexistent-pec").Return( armbatch.PrivateEndpointConnectionClientGetResponse{}, expectedErr) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, "nonexistent-pec") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent private endpoint connection, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewBatchPrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.BatchBatchAccount] { t.Error("Expected BatchAccount in PotentialLinks") } if !links[azureshared.NetworkPrivateEndpoint] { t.Error("Expected NetworkPrivateEndpoint in PotentialLinks") } }) t.Run("HealthMapping", func(t *testing.T) { tests := []struct { name string state armbatch.PrivateEndpointConnectionProvisioningState expectedHeath sdp.Health }{ {"Succeeded", armbatch.PrivateEndpointConnectionProvisioningStateSucceeded, sdp.Health_HEALTH_OK}, {"Creating", armbatch.PrivateEndpointConnectionProvisioningStateCreating, sdp.Health_HEALTH_PENDING}, {"Updating", armbatch.PrivateEndpointConnectionProvisioningStateUpdating, sdp.Health_HEALTH_PENDING}, {"Deleting", armbatch.PrivateEndpointConnectionProvisioningStateDeleting, sdp.Health_HEALTH_PENDING}, {"Failed", armbatch.PrivateEndpointConnectionProvisioningStateFailed, sdp.Health_HEALTH_ERROR}, {"Cancelled", armbatch.PrivateEndpointConnectionProvisioningStateCancelled, sdp.Health_HEALTH_ERROR}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { conn := createAzureBatchPrivateEndpointConnectionWithState(connectionName, tt.state) mockClient := mocks.NewMockBatchPrivateEndpointConnectionClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, connectionName).Return( armbatch.PrivateEndpointConnectionClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testBatchPrivateEndpointConnectionClient{MockBatchPrivateEndpointConnectionClient: mockClient} wrapper := manual.NewBatchPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tt.expectedHeath { t.Errorf("Expected health %v, got %v", tt.expectedHeath, sdpItem.GetHealth()) } }) } }) } func createAzureBatchPrivateEndpointConnection(connectionName, privateEndpointID string) *armbatch.PrivateEndpointConnection { succeeded := armbatch.PrivateEndpointConnectionProvisioningStateSucceeded conn := &armbatch.PrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Batch/batchAccounts/test-batch-account/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.Batch/batchAccounts/privateEndpointConnections"), Properties: &armbatch.PrivateEndpointConnectionProperties{ ProvisioningState: &succeeded, PrivateLinkServiceConnectionState: &armbatch.PrivateLinkServiceConnectionState{ Status: new(armbatch.PrivateLinkServiceConnectionStatusApproved), }, }, Tags: map[string]*string{ "env": new("test"), }, } if privateEndpointID != "" { conn.Properties.PrivateEndpoint = &armbatch.PrivateEndpoint{ ID: new(privateEndpointID), } } return conn } func createAzureBatchPrivateEndpointConnectionWithState(connectionName string, state armbatch.PrivateEndpointConnectionProvisioningState) *armbatch.PrivateEndpointConnection { conn := &armbatch.PrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Batch/batchAccounts/test-batch-account/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.Batch/batchAccounts/privateEndpointConnections"), Properties: &armbatch.PrivateEndpointConnectionProperties{ ProvisioningState: &state, }, Tags: map[string]*string{ "env": new("test"), }, } return conn } ================================================ FILE: sources/azure/manual/compute-availability-set.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeAvailabilitySetLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeAvailabilitySet) type computeAvailabilitySetWrapper struct { client clients.AvailabilitySetsClient *azureshared.MultiResourceGroupBase } func NewComputeAvailabilitySet(client clients.AvailabilitySetsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeAvailabilitySetWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeAvailabilitySet, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/availability-sets/list?view=rest-compute-2025-04-01&tabs=HTTP func (c computeAvailabilitySetWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, availabilitySet := range page.Value { if availabilitySet.Name == nil { continue } item, sdpErr := c.azureAvailabilitySetToSDPItem(availabilitySet, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeAvailabilitySetWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, availabilitySet := range page.Value { if availabilitySet.Name == nil { continue } var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = c.azureAvailabilitySetToSDPItem(availabilitySet, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref : https://learn.microsoft.com/en-us/rest/api/compute/availability-sets/get?view=rest-compute-2025-04-01&tabs=HTTP func (c computeAvailabilitySetWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the availability set name"), scope, c.Type()) } availabilitySetName := queryParts[0] if availabilitySetName == "" { return nil, azureshared.QueryError(errors.New("availabilitySetName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } availabilitySet, err := c.client.Get(ctx, rgScope.ResourceGroup, availabilitySetName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureAvailabilitySetToSDPItem(&availabilitySet.AvailabilitySet, scope) } func (c computeAvailabilitySetWrapper) azureAvailabilitySetToSDPItem(availabilitySet *armcompute.AvailabilitySet, scope string) (*sdp.Item, *sdp.QueryError) { if availabilitySet.Name == nil { return nil, azureshared.QueryError(errors.New("availabilitySetName is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(availabilitySet, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeAvailabilitySet.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(availabilitySet.Tags), } // Link to Proximity Placement Group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/proximity-placement-groups/get if availabilitySet.Properties != nil && availabilitySet.Properties.ProximityPlacementGroup != nil && availabilitySet.Properties.ProximityPlacementGroup.ID != nil { ppgName := azureshared.ExtractResourceName(*availabilitySet.Properties.ProximityPlacementGroup.ID) if ppgName != "" { linkedScope := scope // Check if Proximity Placement Group is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*availabilitySet.Properties.ProximityPlacementGroup.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeProximityPlacementGroup.String(), Method: sdp.QueryMethod_GET, Query: ppgName, Scope: linkedScope, }, }) } } // Link to Virtual Machines // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get if availabilitySet.Properties != nil && availabilitySet.Properties.VirtualMachines != nil { for _, vmRef := range availabilitySet.Properties.VirtualMachines { if vmRef != nil && vmRef.ID != nil { vmName := azureshared.ExtractResourceName(*vmRef.ID) if vmName != "" { linkedScope := scope // Check if Virtual Machine is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*vmRef.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: linkedScope, }, }) } } } } return sdpItem, nil } func (c computeAvailabilitySetWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeAvailabilitySetLookupByName, } } func (c computeAvailabilitySetWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeProximityPlacementGroup, azureshared.ComputeVirtualMachine, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/availability_set func (c computeAvailabilitySetWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_availability_set.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeAvailabilitySetWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/availabilitySets/read", } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/compute func (c computeAvailabilitySetWrapper) PredefinedRole() string { return "Reader" // there is no predefined role for availability sets, so we use the most restrictive role (Reader) } ================================================ FILE: sources/azure/manual/compute-availability-set_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeAvailabilitySet(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { availabilitySetName := "test-avset" avSet := createAzureAvailabilitySet(availabilitySetName) mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, availabilitySetName, nil).Return( armcompute.AvailabilitySetsClientGetResponse{ AvailabilitySet: *avSet, }, nil) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], availabilitySetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeAvailabilitySet.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeAvailabilitySet, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != availabilitySetName { t.Errorf("Expected unique attribute value %s, got %s", availabilitySetName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Properties.ProximityPlacementGroup.ID ExpectedType: azureshared.ComputeProximityPlacementGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-ppg", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.VirtualMachines[0].ID ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm-1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.VirtualMachines[1].ID ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm-2", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithCrossResourceGroupLinks", func(t *testing.T) { availabilitySetName := "test-avset-cross-rg" avSet := createAzureAvailabilitySetWithCrossResourceGroupLinks(availabilitySetName, subscriptionID) mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, availabilitySetName, nil).Return( armcompute.AvailabilitySetsClientGetResponse{ AvailabilitySet: *avSet, }, nil) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], availabilitySetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that links use the correct scope from different resource groups foundPPGLink := false foundVMLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.ComputeProximityPlacementGroup.String() { foundPPGLink = true expectedScope := subscriptionID + ".other-rg" if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected PPG scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } if link.GetQuery().GetType() == azureshared.ComputeVirtualMachine.String() { foundVMLink = true expectedScope := subscriptionID + ".vm-rg" if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected VM scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } } if !foundPPGLink { t.Error("Expected to find Proximity Placement Group link") } if !foundVMLink { t.Error("Expected to find Virtual Machine link") } }) t.Run("GetWithoutLinks", func(t *testing.T) { availabilitySetName := "test-avset-no-links" avSet := createAzureAvailabilitySetWithoutLinks(availabilitySetName) mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, availabilitySetName, nil).Return( armcompute.AvailabilitySetsClientGetResponse{ AvailabilitySet: *avSet, }, nil) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], availabilitySetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("List", func(t *testing.T) { avSet1 := createAzureAvailabilitySet("test-avset-1") avSet2 := createAzureAvailabilitySet("test-avset-2") mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) mockPager := newMockAvailabilitySetsPager(ctrl, []*armcompute.AvailabilitySet{avSet1, avSet2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { avSet1 := createAzureAvailabilitySet("test-avset-1") avSet2 := createAzureAvailabilitySet("test-avset-2") mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) mockPager := newMockAvailabilitySetsPager(ctrl, []*armcompute.AvailabilitySet{avSet1, avSet2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListWithNilName", func(t *testing.T) { avSet1 := createAzureAvailabilitySet("test-avset-1") avSetNilName := &armcompute.AvailabilitySet{ Name: nil, // nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) mockPager := newMockAvailabilitySetsPager(ctrl, []*armcompute.AvailabilitySet{avSet1, avSetNilName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("availability set not found") mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-avset", nil).Return( armcompute.AvailabilitySetsClientGetResponse{}, expectedErr) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-avset", true) if qErr == nil { t.Error("Expected error when getting non-existent availability set, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting availability set with empty name, but got nil") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockAvailabilitySetsClient(ctrl) wrapper := manual.NewComputeAvailabilitySet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test the wrapper's Get method directly with insufficient query parts _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when getting availability set with insufficient query parts, but got nil") } }) } // createAzureAvailabilitySet creates a mock Azure Availability Set for testing func createAzureAvailabilitySet(avSetName string) *armcompute.AvailabilitySet { return &armcompute.AvailabilitySet{ Name: new(avSetName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.AvailabilitySetProperties{ PlatformFaultDomainCount: new(int32(2)), PlatformUpdateDomainCount: new(int32(5)), ProximityPlacementGroup: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/proximityPlacementGroups/test-ppg"), }, VirtualMachines: []*armcompute.SubResource{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm-1"), }, { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm-2"), }, }, }, } } // createAzureAvailabilitySetWithCrossResourceGroupLinks creates a mock Availability Set // with links to resources in different resource groups func createAzureAvailabilitySetWithCrossResourceGroupLinks(avSetName, subscriptionID string) *armcompute.AvailabilitySet { return &armcompute.AvailabilitySet{ Name: new(avSetName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.AvailabilitySetProperties{ PlatformFaultDomainCount: new(int32(2)), PlatformUpdateDomainCount: new(int32(5)), ProximityPlacementGroup: &armcompute.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/Microsoft.Compute/proximityPlacementGroups/test-ppg"), }, VirtualMachines: []*armcompute.SubResource{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/vm-rg/providers/Microsoft.Compute/virtualMachines/test-vm"), }, }, }, } } // createAzureAvailabilitySetWithoutLinks creates a mock Availability Set without any linked resources func createAzureAvailabilitySetWithoutLinks(avSetName string) *armcompute.AvailabilitySet { return &armcompute.AvailabilitySet{ Name: new(avSetName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.AvailabilitySetProperties{ PlatformFaultDomainCount: new(int32(2)), PlatformUpdateDomainCount: new(int32(5)), // No ProximityPlacementGroup // No VirtualMachines }, } } // mockAvailabilitySetsPager is a simple mock implementation of the Pager interface for testing type mockAvailabilitySetsPager struct { ctrl *gomock.Controller items []*armcompute.AvailabilitySet index int more bool } func newMockAvailabilitySetsPager(ctrl *gomock.Controller, items []*armcompute.AvailabilitySet) clients.AvailabilitySetsPager { return &mockAvailabilitySetsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockAvailabilitySetsPager) More() bool { return m.more } func (m *mockAvailabilitySetsPager) NextPage(ctx context.Context) (armcompute.AvailabilitySetsClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.AvailabilitySetsClientListResponse{ AvailabilitySetListResult: armcompute.AvailabilitySetListResult{ Value: []*armcompute.AvailabilitySet{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.AvailabilitySetsClientListResponse{ AvailabilitySetListResult: armcompute.AvailabilitySetListResult{ Value: []*armcompute.AvailabilitySet{item}, }, }, nil } ================================================ FILE: sources/azure/manual/compute-capacity-reservation-group.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeCapacityReservationGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeCapacityReservationGroup) type computeCapacityReservationGroupWrapper struct { client clients.CapacityReservationGroupsClient *azureshared.MultiResourceGroupBase } // NewComputeCapacityReservationGroup creates a new computeCapacityReservationGroupWrapper instance. func NewComputeCapacityReservationGroup(client clients.CapacityReservationGroupsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeCapacityReservationGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeCapacityReservationGroup, ), } } func capacityReservationGroupGetOptions() *armcompute.CapacityReservationGroupsClientGetOptions { return nil } func capacityReservationGroupListOptions() *armcompute.CapacityReservationGroupsClientListByResourceGroupOptions { expand := armcompute.ExpandTypesForGetCapacityReservationGroupsVirtualMachinesRef return &armcompute.CapacityReservationGroupsClientListByResourceGroupOptions{ Expand: &expand, } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/capacity-reservation-groups/get?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeCapacityReservationGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the capacity reservation group name"), scope, c.Type()) } capacityReservationGroupName := queryParts[0] if capacityReservationGroupName == "" { return nil, azureshared.QueryError(errors.New("capacity reservation group name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } capacityReservationGroup, err := c.client.Get(ctx, rgScope.ResourceGroup, capacityReservationGroupName, capacityReservationGroupGetOptions()) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureCapacityReservationGroupToSDPItem(&capacityReservationGroup.CapacityReservationGroup, scope) } // ref:https://learn.microsoft.com/en-us/rest/api/compute/capacity-reservation-groups/list-by-resource-group?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeCapacityReservationGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, capacityReservationGroupListOptions()) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, capacityReservationGroup := range page.Value { if capacityReservationGroup.Name == nil { continue } item, sdpErr := c.azureCapacityReservationGroupToSDPItem(capacityReservationGroup, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c *computeCapacityReservationGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, capacityReservationGroupListOptions()) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, capacityReservationGroup := range page.Value { if capacityReservationGroup.Name == nil { continue } item, sdpErr := c.azureCapacityReservationGroupToSDPItem(capacityReservationGroup, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c *computeCapacityReservationGroupWrapper) azureCapacityReservationGroupToSDPItem(capacityReservationGroup *armcompute.CapacityReservationGroup, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(capacityReservationGroup, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) if capacityReservationGroup.Properties != nil { groupName := "" if capacityReservationGroup.Name != nil { groupName = *capacityReservationGroup.Name } // Child resource: capacity reservations in this group (have their own GET/LIST endpoints) if capacityReservationGroup.Properties.CapacityReservations != nil && groupName != "" { for _, ref := range capacityReservationGroup.Properties.CapacityReservations { if ref == nil || ref.ID == nil || *ref.ID == "" { continue } reservationName := azureshared.ExtractResourceName(*ref.ID) if reservationName == "" { continue } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCapacityReservation.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(groupName, reservationName), Scope: scope, }, }) } } // External resource: VMs associated with this capacity reservation group if capacityReservationGroup.Properties.VirtualMachinesAssociated != nil { for _, ref := range capacityReservationGroup.Properties.VirtualMachinesAssociated { if ref == nil || ref.ID == nil || *ref.ID == "" { continue } vmName := azureshared.ExtractResourceName(*ref.ID) if vmName == "" { continue } linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*ref.ID); extractedScope != "" { linkScope = extractedScope } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: linkScope, }, }) } } } sdpItem := &sdp.Item{ Type: azureshared.ComputeCapacityReservationGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(capacityReservationGroup.Tags), LinkedItemQueries: linkedItemQueries, } return sdpItem, nil } func (c *computeCapacityReservationGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeCapacityReservationGroupLookupByName, } } func (c *computeCapacityReservationGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeCapacityReservation: true, azureshared.ComputeVirtualMachine: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/capacity_reservation_group func (c *computeCapacityReservationGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_capacity_reservation_group.name", }, } } func (c *computeCapacityReservationGroupWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/capacityReservationGroups/read", } } func (c *computeCapacityReservationGroupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-capacity-reservation-group_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeCapacityReservationGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { groupName := "test-crg" crg := createAzureCapacityReservationGroup(groupName) mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, groupName, gomock.Eq(capacityReservationGroupGetOptions())).Return( armcompute.CapacityReservationGroupsClientGetResponse{ CapacityReservationGroup: *crg, }, nil) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, groupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeCapacityReservationGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeCapacityReservationGroup.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != groupName { t.Errorf("Expected unique attribute value %s, got %s", groupName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithLinkedResources", func(t *testing.T) { groupName := "test-crg-with-links" crg := createAzureCapacityReservationGroupWithLinks(groupName, subscriptionID, resourceGroup, []string{"res-1", "res-2"}, []string{"vm-1", "vm-2"}) mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, groupName, gomock.Eq(capacityReservationGroupGetOptions())).Return( armcompute.CapacityReservationGroupsClientGetResponse{ CapacityReservationGroup: *crg, }, nil) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, groupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.ComputeCapacityReservation.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(groupName, "res-1"), ExpectedScope: scope, }, { ExpectedType: azureshared.ComputeCapacityReservation.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(groupName, "res-2"), ExpectedScope: scope, }, { ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vm-1", ExpectedScope: scope, }, { ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vm-2", ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Get(ctx, scope) if qErr == nil { t.Error("Expected error when getting with no query parts, but got nil") } }) t.Run("Get_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting with empty name, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("capacity reservation group not found") mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", gomock.Eq(capacityReservationGroupGetOptions())).Return( armcompute.CapacityReservationGroupsClientGetResponse{}, expectedErr) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "nonexistent", true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("List", func(t *testing.T) { crg1 := createAzureCapacityReservationGroup("test-crg-1") crg2 := createAzureCapacityReservationGroup("test-crg-2") mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) mockPager := newMockCapacityReservationGroupsPager(ctrl, []*armcompute.CapacityReservationGroup{crg1, crg2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, gomock.Eq(capacityReservationGroupListOptions())).Return(mockPager) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { crg1 := createAzureCapacityReservationGroup("test-crg-1") crg2 := createAzureCapacityReservationGroup("test-crg-2") mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) mockPager := newMockCapacityReservationGroupsPager(ctrl, []*armcompute.CapacityReservationGroup{crg1, crg2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, gomock.Eq(capacityReservationGroupListOptions())).Return(mockPager) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListWithNilName", func(t *testing.T) { crg1 := createAzureCapacityReservationGroup("test-crg-1") crgNilName := &armcompute.CapacityReservationGroup{ Name: nil, Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.CapacityReservationGroupProperties{}, } mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) mockPager := newMockCapacityReservationGroupsPager(ctrl, []*armcompute.CapacityReservationGroup{crg1, crgNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, gomock.Eq(capacityReservationGroupListOptions())).Return(mockPager) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ListWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) errorPager := newErrorCapacityReservationGroupsPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, gomock.Eq(capacityReservationGroupListOptions())).Return(errorPager) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, scope, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("ListStreamWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationGroupsClient(ctrl) errorPager := newErrorCapacityReservationGroupsPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, gomock.Eq(capacityReservationGroupListOptions())).Return(errorPager) wrapper := manual.NewComputeCapacityReservationGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(item *sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) if len(errs) == 0 { t.Error("Expected error when pager returns error, but got none") } }) } func capacityReservationGroupGetOptions() *armcompute.CapacityReservationGroupsClientGetOptions { return nil } func capacityReservationGroupListOptions() *armcompute.CapacityReservationGroupsClientListByResourceGroupOptions { expand := armcompute.ExpandTypesForGetCapacityReservationGroupsVirtualMachinesRef return &armcompute.CapacityReservationGroupsClientListByResourceGroupOptions{ Expand: &expand, } } // createAzureCapacityReservationGroup creates a mock Azure Capacity Reservation Group for testing. func createAzureCapacityReservationGroup(groupName string) *armcompute.CapacityReservationGroup { return &armcompute.CapacityReservationGroup{ Name: new(groupName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.CapacityReservationGroupProperties{}, } } // createAzureCapacityReservationGroupWithLinks creates a mock group with capacity reservation and VM links. func createAzureCapacityReservationGroupWithLinks(groupName, subscriptionID, resourceGroup string, reservationNames, vmNames []string) *armcompute.CapacityReservationGroup { reservations := make([]*armcompute.SubResourceReadOnly, 0, len(reservationNames)) for _, name := range reservationNames { reservations = append(reservations, &armcompute.SubResourceReadOnly{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/capacityReservationGroups/" + groupName + "/capacityReservations/" + name), }) } vms := make([]*armcompute.SubResourceReadOnly, 0, len(vmNames)) for _, name := range vmNames { vms = append(vms, &armcompute.SubResourceReadOnly{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/virtualMachines/" + name), }) } return &armcompute.CapacityReservationGroup{ Name: new(groupName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.CapacityReservationGroupProperties{ CapacityReservations: reservations, VirtualMachinesAssociated: vms, }, } } // mockCapacityReservationGroupsPager is a mock pager for CapacityReservationGroupsClientListByResourceGroupResponse. type mockCapacityReservationGroupsPager struct { ctrl *gomock.Controller items []*armcompute.CapacityReservationGroup index int more bool } func newMockCapacityReservationGroupsPager(ctrl *gomock.Controller, items []*armcompute.CapacityReservationGroup) clients.CapacityReservationGroupsPager { return &mockCapacityReservationGroupsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockCapacityReservationGroupsPager) More() bool { return m.more } func (m *mockCapacityReservationGroupsPager) NextPage(ctx context.Context) (armcompute.CapacityReservationGroupsClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.CapacityReservationGroupsClientListByResourceGroupResponse{ CapacityReservationGroupListResult: armcompute.CapacityReservationGroupListResult{ Value: []*armcompute.CapacityReservationGroup{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.CapacityReservationGroupsClientListByResourceGroupResponse{ CapacityReservationGroupListResult: armcompute.CapacityReservationGroupListResult{ Value: []*armcompute.CapacityReservationGroup{item}, }, }, nil } // errorCapacityReservationGroupsPager is a mock pager that always returns an error. type errorCapacityReservationGroupsPager struct { ctrl *gomock.Controller } func newErrorCapacityReservationGroupsPager(ctrl *gomock.Controller) clients.CapacityReservationGroupsPager { return &errorCapacityReservationGroupsPager{ctrl: ctrl} } func (e *errorCapacityReservationGroupsPager) More() bool { return true } func (e *errorCapacityReservationGroupsPager) NextPage(ctx context.Context) (armcompute.CapacityReservationGroupsClientListByResourceGroupResponse, error) { return armcompute.CapacityReservationGroupsClientListByResourceGroupResponse{}, errors.New("pager error") } ================================================ FILE: sources/azure/manual/compute-capacity-reservation.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeCapacityReservationLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeCapacityReservation) type computeCapacityReservationWrapper struct { client clients.CapacityReservationsClient *azureshared.MultiResourceGroupBase } func NewComputeCapacityReservation(client clients.CapacityReservationsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeCapacityReservationWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeCapacityReservation, ), } } func capacityReservationGetOptions() *armcompute.CapacityReservationsClientGetOptions { expand := armcompute.CapacityReservationInstanceViewTypesInstanceView return &armcompute.CapacityReservationsClientGetOptions{ Expand: &expand, } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/capacity-reservations/get?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeCapacityReservationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 2 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 2: capacity reservation group name and capacity reservation name"), scope, c.Type()) } groupName := queryParts[0] if groupName == "" { return nil, azureshared.QueryError(errors.New("capacity reservation group name cannot be empty"), scope, c.Type()) } reservationName := queryParts[1] if reservationName == "" { return nil, azureshared.QueryError(errors.New("capacity reservation name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, groupName, reservationName, capacityReservationGetOptions()) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureCapacityReservationToSDPItem(&resp.CapacityReservation, groupName, scope) } // ref: https://learn.microsoft.com/en-us/rest/api/compute/capacity-reservations/list-by-capacity-reservation-group?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeCapacityReservationWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1: capacity reservation group name"), scope, c.Type()) } groupName := queryParts[0] if groupName == "" { return nil, azureshared.QueryError(errors.New("capacity reservation group name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByCapacityReservationGroupPager(rgScope.ResourceGroup, groupName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, res := range page.Value { if res == nil || res.Name == nil { continue } item, sdpErr := c.azureCapacityReservationToSDPItem(res, groupName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c *computeCapacityReservationWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(errors.New("queryParts must be exactly 1: capacity reservation group name"), scope, c.Type())) return } groupName := queryParts[0] if groupName == "" { stream.SendError(azureshared.QueryError(errors.New("capacity reservation group name cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByCapacityReservationGroupPager(rgScope.ResourceGroup, groupName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, res := range page.Value { if res == nil || res.Name == nil { continue } item, sdpErr := c.azureCapacityReservationToSDPItem(res, groupName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c *computeCapacityReservationWrapper) azureCapacityReservationToSDPItem(res *armcompute.CapacityReservation, groupName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(res, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if res.Name == nil { return nil, azureshared.QueryError(errors.New("capacity reservation name is nil"), scope, c.Type()) } reservationName := *res.Name if reservationName == "" { return nil, azureshared.QueryError(errors.New("capacity reservation name cannot be empty"), scope, c.Type()) } if err := attributes.Set("uniqueAttr", shared.CompositeLookupKey(groupName, reservationName)); err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) // Parent: capacity reservation group linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCapacityReservationGroup.String(), Method: sdp.QueryMethod_GET, Query: groupName, Scope: scope, }, }) // VMs associated with this capacity reservation if res.Properties != nil && res.Properties.VirtualMachinesAssociated != nil { for _, vmRef := range res.Properties.VirtualMachinesAssociated { if vmRef == nil || vmRef.ID == nil || *vmRef.ID == "" { continue } vmName := azureshared.ExtractResourceName(*vmRef.ID) if vmName == "" { continue } vmScope := scope if linkScope := azureshared.ExtractScopeFromResourceID(*vmRef.ID); linkScope != "" { vmScope = linkScope } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: vmScope, }, }) } } // VMs physically allocated to this capacity reservation (from instance view; only populated when Get uses $expand=instanceView) if res.Properties != nil && res.Properties.InstanceView != nil && res.Properties.InstanceView.UtilizationInfo != nil && res.Properties.InstanceView.UtilizationInfo.VirtualMachinesAllocated != nil { for _, vmRef := range res.Properties.InstanceView.UtilizationInfo.VirtualMachinesAllocated { if vmRef == nil || vmRef.ID == nil || *vmRef.ID == "" { continue } vmName := azureshared.ExtractResourceName(*vmRef.ID) if vmName == "" { continue } vmScope := scope if linkScope := azureshared.ExtractScopeFromResourceID(*vmRef.ID); linkScope != "" { vmScope = linkScope } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: vmScope, }, }) } } sdpItem := &sdp.Item{ Type: azureshared.ComputeCapacityReservation.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(res.Tags), LinkedItemQueries: linkedItemQueries, } // Health status from ProvisioningState if res.Properties != nil && res.Properties.ProvisioningState != nil { state := strings.ToLower(*res.Properties.ProvisioningState) switch state { case "succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "creating", "updating", "deleting": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "failed", "canceled": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } } return sdpItem, nil } func (c *computeCapacityReservationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeCapacityReservationGroupLookupByName, ComputeCapacityReservationLookupByName, } } func (c *computeCapacityReservationWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeCapacityReservationGroupLookupByName, }, } } func (c *computeCapacityReservationWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeCapacityReservationGroup: true, azureshared.ComputeVirtualMachine: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/capacity_reservation func (c *computeCapacityReservationWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_capacity_reservation.id", }, } } func (c *computeCapacityReservationWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/capacityReservationGroups/capacityReservations/read", } } func (c *computeCapacityReservationWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-capacity-reservation_test.go ================================================ package manual import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func createAzureCapacityReservation(reservationName, groupName string) *armcompute.CapacityReservation { return &armcompute.CapacityReservation{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/capacityReservationGroups/" + groupName + "/capacityReservations/" + reservationName), Name: new(reservationName), Type: new("Microsoft.Compute/capacityReservationGroups/capacityReservations"), Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, SKU: &armcompute.SKU{ Name: new("Standard_D2s_v3"), Capacity: new(int64(1)), }, Properties: &armcompute.CapacityReservationProperties{ ProvisioningState: new("Succeeded"), }, } } func createAzureCapacityReservationWithVMs(reservationName, groupName, subscriptionID, resourceGroup string, vmNames ...string) *armcompute.CapacityReservation { vms := make([]*armcompute.SubResourceReadOnly, 0, len(vmNames)) for _, vmName := range vmNames { vms = append(vms, &armcompute.SubResourceReadOnly{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/virtualMachines/" + vmName), }) } return &armcompute.CapacityReservation{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/capacityReservationGroups/" + groupName + "/capacityReservations/" + reservationName), Name: new(reservationName), Type: new("Microsoft.Compute/capacityReservationGroups/capacityReservations"), Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, SKU: &armcompute.SKU{ Name: new("Standard_D2s_v3"), Capacity: new(int64(1)), }, Properties: &armcompute.CapacityReservationProperties{ ProvisioningState: new("Succeeded"), VirtualMachinesAssociated: vms, }, } } type mockCapacityReservationsPager struct { items []*armcompute.CapacityReservation index int } func (m *mockCapacityReservationsPager) More() bool { return m.index < len(m.items) } func (m *mockCapacityReservationsPager) NextPage(ctx context.Context) (armcompute.CapacityReservationsClientListByCapacityReservationGroupResponse, error) { if m.index >= len(m.items) { return armcompute.CapacityReservationsClientListByCapacityReservationGroupResponse{ CapacityReservationListResult: armcompute.CapacityReservationListResult{ Value: []*armcompute.CapacityReservation{}, }, }, nil } item := m.items[m.index] m.index++ return armcompute.CapacityReservationsClientListByCapacityReservationGroupResponse{ CapacityReservationListResult: armcompute.CapacityReservationListResult{ Value: []*armcompute.CapacityReservation{item}, }, }, nil } type errorCapacityReservationsPager struct{} func (e *errorCapacityReservationsPager) More() bool { return true } func (e *errorCapacityReservationsPager) NextPage(ctx context.Context) (armcompute.CapacityReservationsClientListByCapacityReservationGroupResponse, error) { return armcompute.CapacityReservationsClientListByCapacityReservationGroupResponse{}, errors.New("pager error") } type testCapacityReservationsClient struct { *mocks.MockCapacityReservationsClient pager clients.CapacityReservationsPager } func (t *testCapacityReservationsClient) NewListByCapacityReservationGroupPager(resourceGroupName string, capacityReservationGroupName string, options *armcompute.CapacityReservationsClientListByCapacityReservationGroupOptions) clients.CapacityReservationsPager { if t.pager != nil { return t.pager } return t.MockCapacityReservationsClient.NewListByCapacityReservationGroupPager(resourceGroupName, capacityReservationGroupName, options) } func TestComputeCapacityReservation(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup groupName := "test-crg" reservationName := "test-reservation" t.Run("Get", func(t *testing.T) { res := createAzureCapacityReservation(reservationName, groupName) mockClient := mocks.NewMockCapacityReservationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, groupName, reservationName, gomock.Eq(capacityReservationGetOptions())).Return( armcompute.CapacityReservationsClientGetResponse{ CapacityReservation: *res, }, nil) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(groupName, reservationName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeCapacityReservation.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeCapacityReservation.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(groupName, reservationName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag env=test, got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeCapacityReservationGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: groupName, ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithVMLinks", func(t *testing.T) { res := createAzureCapacityReservationWithVMs(reservationName, groupName, subscriptionID, resourceGroup, "vm-1", "vm-2") mockClient := mocks.NewMockCapacityReservationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, groupName, reservationName, gomock.Eq(capacityReservationGetOptions())).Return( armcompute.CapacityReservationsClientGetResponse{ CapacityReservation: *res, }, nil) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(groupName, reservationName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeCapacityReservationGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: groupName, ExpectedScope: scope}, {ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vm-1", ExpectedScope: scope}, {ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vm-2", ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, groupName, true) if qErr == nil { t.Error("Expected error when Get with wrong number of query parts, but got nil") } }) t.Run("Get_EmptyGroupName", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", reservationName) _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when capacity reservation group name is empty, but got nil") } }) t.Run("Get_EmptyReservationName", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(groupName, "") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when capacity reservation name is empty, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("reservation not found") mockClient := mocks.NewMockCapacityReservationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, groupName, "nonexistent", gomock.Eq(capacityReservationGetOptions())).Return( armcompute.CapacityReservationsClientGetResponse{}, expectedErr) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(groupName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { res1 := createAzureCapacityReservation("res-1", groupName) res2 := createAzureCapacityReservation("res-2", groupName) mockClient := mocks.NewMockCapacityReservationsClient(ctrl) pager := &mockCapacityReservationsPager{ items: []*armcompute.CapacityReservation{res1, res2}, } testClient := &testCapacityReservationsClient{ MockCapacityReservationsClient: mockClient, pager: pager, } wrapper := NewComputeCapacityReservation(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, groupName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, groupName, reservationName) if qErr == nil { t.Error("Expected error when Search with wrong number of query parts, but got nil") } }) t.Run("Search_EmptyGroupName", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, "") if qErr == nil { t.Error("Expected error when capacity reservation group name is empty, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) errorPager := &errorCapacityReservationsPager{} testClient := &testCapacityReservationsClient{ MockCapacityReservationsClient: mockClient, pager: errorPager, } wrapper := NewComputeCapacityReservation(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, scope, groupName, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() expected := map[shared.ItemType]bool{ azureshared.ComputeCapacityReservationGroup: true, azureshared.ComputeVirtualMachine: true, } for itemType, want := range expected { if got := links[itemType]; got != want { t.Errorf("PotentialLinks()[%v] = %v, want %v", itemType, got, want) } } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockCapacityReservationsClient(ctrl) wrapper := NewComputeCapacityReservation(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/compute-dedicated-host-group.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeDedicatedHostGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeDedicatedHostGroup) type computeDedicatedHostGroupWrapper struct { client clients.DedicatedHostGroupsClient *azureshared.MultiResourceGroupBase } func NewComputeDedicatedHostGroup(client clients.DedicatedHostGroupsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeDedicatedHostGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeDedicatedHostGroup, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/dedicated-host-groups/get?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeDedicatedHostGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the dedicated host group name"), scope, c.Type()) } dedicatedHostGroupName := queryParts[0] if dedicatedHostGroupName == "" { return nil, azureshared.QueryError(errors.New("dedicated host group name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } dedicatedHostGroup, err := c.client.Get(ctx, rgScope.ResourceGroup, dedicatedHostGroupName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureDedicatedHostGroupToSDPItem(&dedicatedHostGroup.DedicatedHostGroup, scope) } // ref: https://learn.microsoft.com/en-us/rest/api/compute/dedicated-host-groups/list-by-resource-group?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeDedicatedHostGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, dedicatedHostGroup := range page.Value { if dedicatedHostGroup.Name == nil { continue } item, sdpErr := c.azureDedicatedHostGroupToSDPItem(dedicatedHostGroup, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c *computeDedicatedHostGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, dedicatedHostGroup := range page.Value { if dedicatedHostGroup.Name == nil { continue } item, sdpErr := c.azureDedicatedHostGroupToSDPItem(dedicatedHostGroup, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c *computeDedicatedHostGroupWrapper) azureDedicatedHostGroupToSDPItem(dedicatedHostGroup *armcompute.DedicatedHostGroup, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(dedicatedHostGroup, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) if dedicatedHostGroup.Properties != nil && dedicatedHostGroup.Properties.Hosts != nil && dedicatedHostGroup.Name != nil { hostGroupName := *dedicatedHostGroup.Name for _, hostRef := range dedicatedHostGroup.Properties.Hosts { if hostRef == nil || hostRef.ID == nil || *hostRef.ID == "" { continue } hostName := azureshared.ExtractResourceName(*hostRef.ID) if hostName == "" { continue } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDedicatedHost.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(hostGroupName, hostName), Scope: scope, }, }) } } sdpItem := &sdp.Item{ Type: azureshared.ComputeDedicatedHostGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(dedicatedHostGroup.Tags), LinkedItemQueries: linkedItemQueries, } return sdpItem, nil } func (c *computeDedicatedHostGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeDedicatedHostGroupLookupByName, } } func (c *computeDedicatedHostGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeDedicatedHost: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/dedicated_host_group func (c *computeDedicatedHostGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_dedicated_host_group.name", }, } } func (c *computeDedicatedHostGroupWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/hostGroups/read", } } func (c *computeDedicatedHostGroupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-dedicated-host-group_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeDedicatedHostGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { hostGroupName := "test-host-group" dedicatedHostGroup := createAzureDedicatedHostGroup(hostGroupName) mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hostGroupName, nil).Return( armcompute.DedicatedHostGroupsClientGetResponse{ DedicatedHostGroup: *dedicatedHostGroup, }, nil) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, hostGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDedicatedHostGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeDedicatedHostGroup.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != hostGroupName { t.Errorf("Expected unique attribute value %s, got %s", hostGroupName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithHosts", func(t *testing.T) { hostGroupName := "test-host-group-with-hosts" dedicatedHostGroup := createAzureDedicatedHostGroupWithHosts(hostGroupName, subscriptionID, resourceGroup, "host-1", "host-2") mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hostGroupName, nil).Return( armcompute.DedicatedHostGroupsClientGetResponse{ DedicatedHostGroup: *dedicatedHostGroup, }, nil) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, hostGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.ComputeDedicatedHost.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(hostGroupName, "host-1"), ExpectedScope: scope, }, { ExpectedType: azureshared.ComputeDedicatedHost.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(hostGroupName, "host-2"), ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Get(ctx, scope) if qErr == nil { t.Error("Expected error when getting with no query parts, but got nil") } }) t.Run("Get_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting with empty name, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("dedicated host group not found") mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", nil).Return( armcompute.DedicatedHostGroupsClientGetResponse{}, expectedErr) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "nonexistent", true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("List", func(t *testing.T) { hostGroup1 := createAzureDedicatedHostGroup("test-host-group-1") hostGroup2 := createAzureDedicatedHostGroup("test-host-group-2") mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) mockPager := newMockDedicatedHostGroupsPager(ctrl, []*armcompute.DedicatedHostGroup{hostGroup1, hostGroup2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { hostGroup1 := createAzureDedicatedHostGroup("test-host-group-1") hostGroup2 := createAzureDedicatedHostGroup("test-host-group-2") mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) mockPager := newMockDedicatedHostGroupsPager(ctrl, []*armcompute.DedicatedHostGroup{hostGroup1, hostGroup2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListWithNilName", func(t *testing.T) { hostGroup1 := createAzureDedicatedHostGroup("test-host-group-1") hostGroupNilName := &armcompute.DedicatedHostGroup{ Name: nil, Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.DedicatedHostGroupProperties{ PlatformFaultDomainCount: new(int32(2)), }, } mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) mockPager := newMockDedicatedHostGroupsPager(ctrl, []*armcompute.DedicatedHostGroup{hostGroup1, hostGroupNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ListWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) errorPager := newErrorDedicatedHostGroupsPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, scope, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("ListStreamWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostGroupsClient(ctrl) errorPager := newErrorDedicatedHostGroupsPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeDedicatedHostGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(item *sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) if len(errs) == 0 { t.Error("Expected error when pager returns error, but got none") } }) } // createAzureDedicatedHostGroup creates a mock Azure Dedicated Host Group for testing. func createAzureDedicatedHostGroup(hostGroupName string) *armcompute.DedicatedHostGroup { return &armcompute.DedicatedHostGroup{ Name: new(hostGroupName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.DedicatedHostGroupProperties{ PlatformFaultDomainCount: new(int32(2)), SupportAutomaticPlacement: new(false), AdditionalCapabilities: nil, Hosts: nil, InstanceView: nil, }, } } // createAzureDedicatedHostGroupWithHosts creates a mock Azure Dedicated Host Group with host references. func createAzureDedicatedHostGroupWithHosts(hostGroupName, subscriptionID, resourceGroup string, hostNames ...string) *armcompute.DedicatedHostGroup { hosts := make([]*armcompute.SubResourceReadOnly, 0, len(hostNames)) for _, name := range hostNames { hosts = append(hosts, &armcompute.SubResourceReadOnly{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/hostGroups/" + hostGroupName + "/hosts/" + name), }) } return &armcompute.DedicatedHostGroup{ Name: new(hostGroupName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.DedicatedHostGroupProperties{ PlatformFaultDomainCount: new(int32(2)), Hosts: hosts, }, } } // mockDedicatedHostGroupsPager is a mock pager for DedicatedHostGroupsClientListByResourceGroupResponse. type mockDedicatedHostGroupsPager struct { ctrl *gomock.Controller items []*armcompute.DedicatedHostGroup index int more bool } func newMockDedicatedHostGroupsPager(ctrl *gomock.Controller, items []*armcompute.DedicatedHostGroup) clients.DedicatedHostGroupsPager { return &mockDedicatedHostGroupsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockDedicatedHostGroupsPager) More() bool { return m.more } func (m *mockDedicatedHostGroupsPager) NextPage(ctx context.Context) (armcompute.DedicatedHostGroupsClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.DedicatedHostGroupsClientListByResourceGroupResponse{ DedicatedHostGroupListResult: armcompute.DedicatedHostGroupListResult{ Value: []*armcompute.DedicatedHostGroup{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.DedicatedHostGroupsClientListByResourceGroupResponse{ DedicatedHostGroupListResult: armcompute.DedicatedHostGroupListResult{ Value: []*armcompute.DedicatedHostGroup{item}, }, }, nil } // errorDedicatedHostGroupsPager is a mock pager that always returns an error. type errorDedicatedHostGroupsPager struct { ctrl *gomock.Controller } func newErrorDedicatedHostGroupsPager(ctrl *gomock.Controller) clients.DedicatedHostGroupsPager { return &errorDedicatedHostGroupsPager{ctrl: ctrl} } func (e *errorDedicatedHostGroupsPager) More() bool { return true } func (e *errorDedicatedHostGroupsPager) NextPage(ctx context.Context) (armcompute.DedicatedHostGroupsClientListByResourceGroupResponse, error) { return armcompute.DedicatedHostGroupsClientListByResourceGroupResponse{}, errors.New("pager error") } ================================================ FILE: sources/azure/manual/compute-dedicated-host.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeDedicatedHostLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeDedicatedHost) type computeDedicatedHostWrapper struct { client clients.DedicatedHostsClient *azureshared.MultiResourceGroupBase } func NewComputeDedicatedHost(client clients.DedicatedHostsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeDedicatedHostWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeDedicatedHost, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/dedicated-hosts/get?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeDedicatedHostWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 2 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 2: dedicated host group name and dedicated host name"), scope, c.Type()) } hostGroupName := queryParts[0] if hostGroupName == "" { return nil, azureshared.QueryError(errors.New("dedicated host group name cannot be empty"), scope, c.Type()) } hostName := queryParts[1] if hostName == "" { return nil, azureshared.QueryError(errors.New("dedicated host name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, hostGroupName, hostName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureDedicatedHostToSDPItem(&resp.DedicatedHost, hostGroupName, scope) } // ref: https://learn.microsoft.com/en-us/rest/api/compute/dedicated-hosts/list-by-host-group?view=rest-compute-2025-04-01&tabs=HTTP func (c *computeDedicatedHostWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1: dedicated host group name"), scope, c.Type()) } hostGroupName := queryParts[0] if hostGroupName == "" { return nil, azureshared.QueryError(errors.New("dedicated host group name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByHostGroupPager(rgScope.ResourceGroup, hostGroupName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, host := range page.Value { if host == nil || host.Name == nil { continue } item, sdpErr := c.azureDedicatedHostToSDPItem(host, hostGroupName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c *computeDedicatedHostWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(errors.New("queryParts must be exactly 1: dedicated host group name"), scope, c.Type())) return } hostGroupName := queryParts[0] if hostGroupName == "" { stream.SendError(azureshared.QueryError(errors.New("dedicated host group name cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByHostGroupPager(rgScope.ResourceGroup, hostGroupName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, host := range page.Value { if host == nil || host.Name == nil { continue } item, sdpErr := c.azureDedicatedHostToSDPItem(host, hostGroupName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c *computeDedicatedHostWrapper) azureDedicatedHostToSDPItem(host *armcompute.DedicatedHost, hostGroupName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(host, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if host.Name == nil { return nil, azureshared.QueryError(errors.New("dedicated host name is nil"), scope, c.Type()) } hostName := *host.Name if hostName == "" { return nil, azureshared.QueryError(errors.New("dedicated host name cannot be empty"), scope, c.Type()) } if err := attributes.Set("uniqueAttr", shared.CompositeLookupKey(hostGroupName, hostName)); err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) // Parent: dedicated host group linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDedicatedHostGroup.String(), Method: sdp.QueryMethod_GET, Query: hostGroupName, Scope: scope, }, }) // VMs deployed on this dedicated host if host.Properties != nil && host.Properties.VirtualMachines != nil { for _, vmRef := range host.Properties.VirtualMachines { if vmRef == nil || vmRef.ID == nil || *vmRef.ID == "" { continue } vmName := azureshared.ExtractResourceName(*vmRef.ID) if vmName == "" { continue } vmScope := scope if linkScope := azureshared.ExtractScopeFromResourceID(*vmRef.ID); linkScope != "" { vmScope = linkScope } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: vmScope, }, }) } } sdpItem := &sdp.Item{ Type: azureshared.ComputeDedicatedHost.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(host.Tags), LinkedItemQueries: linkedItemQueries, } // Health status from ProvisioningState if host.Properties != nil && host.Properties.ProvisioningState != nil { state := strings.ToLower(*host.Properties.ProvisioningState) switch state { case "succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "creating", "updating", "deleting": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "failed", "canceled": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } } return sdpItem, nil } func (c *computeDedicatedHostWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeDedicatedHostGroupLookupByName, ComputeDedicatedHostLookupByName, } } func (c *computeDedicatedHostWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeDedicatedHostGroupLookupByName, }, } } func (c *computeDedicatedHostWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeDedicatedHostGroup: true, azureshared.ComputeVirtualMachine: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/dedicated_host func (c *computeDedicatedHostWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_dedicated_host.id", }, } } func (c *computeDedicatedHostWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/hostGroups/hosts/read", } } func (c *computeDedicatedHostWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-dedicated-host_test.go ================================================ package manual import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func createAzureDedicatedHost(hostName, hostGroupName string) *armcompute.DedicatedHost { return &armcompute.DedicatedHost{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/hostGroups/" + hostGroupName + "/hosts/" + hostName), Name: new(hostName), Type: new("Microsoft.Compute/hostGroups/hosts"), Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, SKU: &armcompute.SKU{ Name: new("DSv3-Type1"), }, Properties: &armcompute.DedicatedHostProperties{ PlatformFaultDomain: new(int32(0)), ProvisioningState: new("Succeeded"), }, } } func createAzureDedicatedHostWithVMs(hostName, hostGroupName, subscriptionID, resourceGroup string, vmNames ...string) *armcompute.DedicatedHost { vms := make([]*armcompute.SubResourceReadOnly, 0, len(vmNames)) for _, vmName := range vmNames { vms = append(vms, &armcompute.SubResourceReadOnly{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/virtualMachines/" + vmName), }) } return &armcompute.DedicatedHost{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/hostGroups/" + hostGroupName + "/hosts/" + hostName), Name: new(hostName), Type: new("Microsoft.Compute/hostGroups/hosts"), Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, SKU: &armcompute.SKU{ Name: new("DSv3-Type1"), }, Properties: &armcompute.DedicatedHostProperties{ PlatformFaultDomain: new(int32(0)), ProvisioningState: new("Succeeded"), VirtualMachines: vms, }, } } type mockDedicatedHostsPager struct { items []*armcompute.DedicatedHost index int } func (m *mockDedicatedHostsPager) More() bool { return m.index < len(m.items) } func (m *mockDedicatedHostsPager) NextPage(ctx context.Context) (armcompute.DedicatedHostsClientListByHostGroupResponse, error) { if m.index >= len(m.items) { return armcompute.DedicatedHostsClientListByHostGroupResponse{ DedicatedHostListResult: armcompute.DedicatedHostListResult{ Value: []*armcompute.DedicatedHost{}, }, }, nil } item := m.items[m.index] m.index++ return armcompute.DedicatedHostsClientListByHostGroupResponse{ DedicatedHostListResult: armcompute.DedicatedHostListResult{ Value: []*armcompute.DedicatedHost{item}, }, }, nil } type errorDedicatedHostsPager struct{} func (e *errorDedicatedHostsPager) More() bool { return true } func (e *errorDedicatedHostsPager) NextPage(ctx context.Context) (armcompute.DedicatedHostsClientListByHostGroupResponse, error) { return armcompute.DedicatedHostsClientListByHostGroupResponse{}, errors.New("pager error") } type testDedicatedHostsClient struct { *mocks.MockDedicatedHostsClient pager clients.DedicatedHostsPager } func (t *testDedicatedHostsClient) NewListByHostGroupPager(resourceGroupName string, hostGroupName string, options *armcompute.DedicatedHostsClientListByHostGroupOptions) clients.DedicatedHostsPager { if t.pager != nil { return t.pager } return t.MockDedicatedHostsClient.NewListByHostGroupPager(resourceGroupName, hostGroupName, options) } func TestComputeDedicatedHost(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup hostGroupName := "test-host-group" hostName := "test-host" t.Run("Get", func(t *testing.T) { host := createAzureDedicatedHost(hostName, hostGroupName) mockClient := mocks.NewMockDedicatedHostsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hostGroupName, hostName, nil).Return( armcompute.DedicatedHostsClientGetResponse{ DedicatedHost: *host, }, nil) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hostGroupName, hostName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDedicatedHost.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeDedicatedHost.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(hostGroupName, hostName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag env=test, got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeDedicatedHostGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: hostGroupName, ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithVMLinks", func(t *testing.T) { host := createAzureDedicatedHostWithVMs(hostName, hostGroupName, subscriptionID, resourceGroup, "vm-1", "vm-2") mockClient := mocks.NewMockDedicatedHostsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hostGroupName, hostName, nil).Return( armcompute.DedicatedHostsClientGetResponse{ DedicatedHost: *host, }, nil) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hostGroupName, hostName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeDedicatedHostGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: hostGroupName, ExpectedScope: scope}, {ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vm-1", ExpectedScope: scope}, {ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "vm-2", ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, hostGroupName, true) if qErr == nil { t.Error("Expected error when Get with wrong number of query parts, but got nil") } }) t.Run("Get_EmptyHostGroupName", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", hostName) _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when host group name is empty, but got nil") } }) t.Run("Get_EmptyHostName", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hostGroupName, "") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when host name is empty, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("host not found") mockClient := mocks.NewMockDedicatedHostsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hostGroupName, "nonexistent", nil).Return( armcompute.DedicatedHostsClientGetResponse{}, expectedErr) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hostGroupName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { host1 := createAzureDedicatedHost("host-1", hostGroupName) host2 := createAzureDedicatedHost("host-2", hostGroupName) mockClient := mocks.NewMockDedicatedHostsClient(ctrl) pager := &mockDedicatedHostsPager{ items: []*armcompute.DedicatedHost{host1, host2}, } testClient := &testDedicatedHostsClient{ MockDedicatedHostsClient: mockClient, pager: pager, } wrapper := NewComputeDedicatedHost(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, hostGroupName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, hostGroupName, hostName) if qErr == nil { t.Error("Expected error when Search with wrong number of query parts, but got nil") } }) t.Run("Search_EmptyHostGroupName", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, "") if qErr == nil { t.Error("Expected error when host group name is empty, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) errorPager := &errorDedicatedHostsPager{} testClient := &testDedicatedHostsClient{ MockDedicatedHostsClient: mockClient, pager: errorPager, } wrapper := NewComputeDedicatedHost(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, scope, hostGroupName, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() expected := map[shared.ItemType]bool{ azureshared.ComputeDedicatedHostGroup: true, azureshared.ComputeVirtualMachine: true, } for itemType, want := range expected { if got := links[itemType]; got != want { t.Errorf("PotentialLinks()[%v] = %v, want %v", itemType, got, want) } } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockDedicatedHostsClient(ctrl) wrapper := NewComputeDedicatedHost(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/compute-disk-access-private-endpoint-connection.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeDiskAccessPrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeDiskAccessPrivateEndpointConnection) type computeDiskAccessPrivateEndpointConnectionWrapper struct { client clients.ComputeDiskAccessPrivateEndpointConnectionsClient *azureshared.MultiResourceGroupBase } // NewComputeDiskAccessPrivateEndpointConnection returns a SearchableWrapper for Azure disk access private endpoint connections. func NewComputeDiskAccessPrivateEndpointConnection(client clients.ComputeDiskAccessPrivateEndpointConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeDiskAccessPrivateEndpointConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ComputeDiskAccessPrivateEndpointConnection, ), } } func (s computeDiskAccessPrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: diskAccessName and privateEndpointConnectionName", Scope: scope, ItemType: s.Type(), } } diskAccessName := queryParts[0] connectionName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, diskAccessName, connectionName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(&resp.PrivateEndpointConnection, diskAccessName, connectionName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s computeDiskAccessPrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeDiskAccessLookupByName, ComputeDiskAccessPrivateEndpointConnectionLookupByName, } } func (s computeDiskAccessPrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: diskAccessName", Scope: scope, ItemType: s.Type(), } } diskAccessName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.NewListPrivateEndpointConnectionsPager(rgScope.ResourceGroup, diskAccessName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, diskAccessName, *conn.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s computeDiskAccessPrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: diskAccessName"), scope, s.Type())) return } diskAccessName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.NewListPrivateEndpointConnectionsPager(rgScope.ResourceGroup, diskAccessName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, diskAccessName, *conn.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s computeDiskAccessPrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeDiskAccessLookupByName, }, } } func (s computeDiskAccessPrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeDiskAccess: true, azureshared.NetworkPrivateEndpoint: true, } } func (s computeDiskAccessPrivateEndpointConnectionWrapper) azurePrivateEndpointConnectionToSDPItem(conn *armcompute.PrivateEndpointConnection, diskAccessName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(conn) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(diskAccessName, connectionName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health from provisioning state if conn.Properties != nil && conn.Properties.ProvisioningState != nil { switch *conn.Properties.ProvisioningState { case armcompute.PrivateEndpointConnectionProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armcompute.PrivateEndpointConnectionProvisioningStateCreating, armcompute.PrivateEndpointConnectionProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armcompute.PrivateEndpointConnectionProvisioningStateFailed: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Disk Access sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskAccess.String(), Method: sdp.QueryMethod_GET, Query: diskAccessName, Scope: scope, }, }) // Link to Network Private Endpoint when present (may be in different resource group) if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { peID := *conn.Properties.PrivateEndpoint.ID peName := azureshared.ExtractResourceName(peID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (s computeDiskAccessPrivateEndpointConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/diskAccesses/privateEndpointConnections/read", } } func (s computeDiskAccessPrivateEndpointConnectionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-disk-access-private-endpoint-connection_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockComputeDiskAccessPrivateEndpointConnectionsPager struct { pages []armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse index int } func (m *mockComputeDiskAccessPrivateEndpointConnectionsPager) More() bool { return m.index < len(m.pages) } func (m *mockComputeDiskAccessPrivateEndpointConnectionsPager) NextPage(ctx context.Context) (armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse, error) { if m.index >= len(m.pages) { return armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type testComputeDiskAccessPrivateEndpointConnectionsClient struct { *mocks.MockComputeDiskAccessPrivateEndpointConnectionsClient pager clients.ComputeDiskAccessPrivateEndpointConnectionsPager } func (t *testComputeDiskAccessPrivateEndpointConnectionsClient) NewListPrivateEndpointConnectionsPager(resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) clients.ComputeDiskAccessPrivateEndpointConnectionsPager { return t.pager } func TestComputeDiskAccessPrivateEndpointConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" diskAccessName := "test-disk-access" connectionName := "test-pec" t.Run("Get", func(t *testing.T) { conn := createAzureComputeDiskAccessPrivateEndpointConnection(connectionName, "") mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, connectionName).Return( armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(diskAccessName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDiskAccessPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(diskAccessName, connectionName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(diskAccessName, connectionName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) < 1 { t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) } foundDiskAccess := false for _, lq := range linkedQueries { if lq.GetQuery().GetType() == azureshared.ComputeDiskAccess.String() { foundDiskAccess = true if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected ComputeDiskAccess link method GET, got %v", lq.GetQuery().GetMethod()) } if lq.GetQuery().GetQuery() != diskAccessName { t.Errorf("Expected ComputeDiskAccess query %s, got %s", diskAccessName, lq.GetQuery().GetQuery()) } } } if !foundDiskAccess { t.Error("Expected linked query to ComputeDiskAccess") } }) }) t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { peID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe" conn := createAzureComputeDiskAccessPrivateEndpointConnection(connectionName, peID) mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, connectionName).Return( armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(diskAccessName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundPrivateEndpoint := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { foundPrivateEndpoint = true if lq.GetQuery().GetQuery() != "test-pe" { t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) } break } } if !foundPrivateEndpoint { t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], diskAccessName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { conn1 := createAzureComputeDiskAccessPrivateEndpointConnection("pec-1", "") conn2 := createAzureComputeDiskAccessPrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) mockPager := &mockComputeDiskAccessPrivateEndpointConnectionsPager{ pages: []armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse{ { PrivateEndpointConnectionListResult: armcompute.PrivateEndpointConnectionListResult{ Value: []*armcompute.PrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{ MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], diskAccessName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.ComputeDiskAccessPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), item.GetType()) } } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validConn := createAzureComputeDiskAccessPrivateEndpointConnection("valid-pec", "") mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) mockPager := &mockComputeDiskAccessPrivateEndpointConnectionsPager{ pages: []armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse{ { PrivateEndpointConnectionListResult: armcompute.PrivateEndpointConnectionListResult{ Value: []*armcompute.PrivateEndpointConnection{ {Name: nil}, validConn, }, }, }, }, } testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{ MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], diskAccessName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(diskAccessName, "valid-pec") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(diskAccessName, "valid-pec"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("private endpoint connection not found") mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, "nonexistent-pec").Return( armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse{}, expectedErr) testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(diskAccessName, "nonexistent-pec") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent private endpoint connection, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.ComputeDiskAccess] { t.Error("Expected ComputeDiskAccess in PotentialLinks") } if !links[azureshared.NetworkPrivateEndpoint] { t.Error("Expected NetworkPrivateEndpoint in PotentialLinks") } }) } func createAzureComputeDiskAccessPrivateEndpointConnection(connectionName, privateEndpointID string) *armcompute.PrivateEndpointConnection { state := armcompute.PrivateEndpointConnectionProvisioningStateSucceeded status := armcompute.PrivateEndpointServiceConnectionStatusApproved conn := &armcompute.PrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/diskAccesses/test-disk-access/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.Compute/diskAccesses/privateEndpointConnections"), Properties: &armcompute.PrivateEndpointConnectionProperties{ ProvisioningState: &state, PrivateLinkServiceConnectionState: &armcompute.PrivateLinkServiceConnectionState{ Status: &status, }, }, } if privateEndpointID != "" { conn.Properties.PrivateEndpoint = &armcompute.PrivateEndpoint{ ID: new(privateEndpointID), } } return conn } ================================================ FILE: sources/azure/manual/compute-disk-access.go ================================================ package manual import ( "context" "errors" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" discovery "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" sdpcache "github.com/overmindtech/cli/go/sdpcache" sources "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeDiskAccessLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeDiskAccess) type computeDiskAccessWrapper struct { client clients.DiskAccessesClient *azureshared.MultiResourceGroupBase } func NewComputeDiskAccess(client clients.DiskAccessesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeDiskAccessWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ComputeDiskAccess, ), } } func (c *computeDiskAccessWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the disk access name"), scope, c.Type()) } diskAccessName := queryParts[0] if diskAccessName == "" { return nil, azureshared.QueryError(errors.New("disk access name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } diskAccess, err := c.client.Get(ctx, rgScope.ResourceGroup, diskAccessName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureDiskAccessToSDPItem(&diskAccess.DiskAccess, scope) } func (c *computeDiskAccessWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, diskAccess := range page.Value { if diskAccess.Name == nil { continue } item, sdpErr := c.azureDiskAccessToSDPItem(diskAccess, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c *computeDiskAccessWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, diskAccess := range page.Value { if diskAccess.Name == nil { continue } item, sdpErr := c.azureDiskAccessToSDPItem(diskAccess, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c *computeDiskAccessWrapper) azureDiskAccessToSDPItem(diskAccess *armcompute.DiskAccess, scope string) (*sdp.Item, *sdp.QueryError) { if diskAccess.Name == nil { return nil, azureshared.QueryError(errors.New("name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(diskAccess, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeDiskAccess.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(diskAccess.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to Private Endpoint Connections (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-accesses/list-private-endpoint-connections // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/diskAccesses/{diskAccessName}/privateEndpointConnections sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), Method: sdp.QueryMethod_SEARCH, Query: *diskAccess.Name, Scope: scope, }, }) // Link to Network Private Endpoints (external resources) from PrivateEndpointConnections // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get if diskAccess.Properties != nil && diskAccess.Properties.PrivateEndpointConnections != nil { for _, peConnection := range diskAccess.Properties.PrivateEndpointConnections { if peConnection.Properties != nil && peConnection.Properties.PrivateEndpoint != nil && peConnection.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *peConnection.Properties.PrivateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: extractedScope, }, }) } } } } return sdpItem, nil } func (c *computeDiskAccessWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeDiskAccessLookupByName, } } func (c *computeDiskAccessWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeDiskAccessPrivateEndpointConnection: true, azureshared.NetworkPrivateEndpoint: true, } } func (c *computeDiskAccessWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_disk_access.name", }, } } ================================================ FILE: sources/azure/manual/compute-disk-access_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeDiskAccess(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { diskAccessName := "test-disk-access" diskAccess := createAzureDiskAccess(diskAccessName) mockClient := mocks.NewMockDiskAccessesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, nil).Return( armcompute.DiskAccessesClientGetResponse{ DiskAccess: *diskAccess, }, nil) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, diskAccessName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDiskAccess.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeDiskAccess.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != diskAccessName { t.Errorf("Expected unique attribute value %s, got %s", diskAccessName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Child resource: Private Endpoint Connections ExpectedType: azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: diskAccessName, ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithPrivateEndpointConnections", func(t *testing.T) { diskAccessName := "test-disk-access-with-pe" diskAccess := createAzureDiskAccessWithPrivateEndpointConnections(diskAccessName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDiskAccessesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, nil).Return( armcompute.DiskAccessesClientGetResponse{ DiskAccess: *diskAccess, }, nil) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, diskAccessName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: diskAccessName, ExpectedScope: scope, }, { // Network Private Endpoint (same resource group) ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint", ExpectedScope: scope, }, { // Network Private Endpoint (different resource group) ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint-other-rg", ExpectedScope: subscriptionID + ".other-rg", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDiskAccessesClient(ctrl) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Get(ctx, scope) if qErr == nil { t.Error("Expected error when getting with no query parts, but got nil") } }) t.Run("Get_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockDiskAccessesClient(ctrl) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting with empty name, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("disk access not found") mockClient := mocks.NewMockDiskAccessesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", nil).Return( armcompute.DiskAccessesClientGetResponse{}, expectedErr) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "nonexistent", true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("List", func(t *testing.T) { diskAccess1 := createAzureDiskAccess("test-disk-access-1") diskAccess2 := createAzureDiskAccess("test-disk-access-2") mockClient := mocks.NewMockDiskAccessesClient(ctrl) mockPager := newMockDiskAccessesPager(ctrl, []*armcompute.DiskAccess{diskAccess1, diskAccess2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { diskAccess1 := createAzureDiskAccess("test-disk-access-1") diskAccess2 := createAzureDiskAccess("test-disk-access-2") mockClient := mocks.NewMockDiskAccessesClient(ctrl) mockPager := newMockDiskAccessesPager(ctrl, []*armcompute.DiskAccess{diskAccess1, diskAccess2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListWithNilName", func(t *testing.T) { diskAccess1 := createAzureDiskAccess("test-disk-access-1") diskAccessNilName := &armcompute.DiskAccess{ Name: nil, Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockDiskAccessesClient(ctrl) mockPager := newMockDiskAccessesPager(ctrl, []*armcompute.DiskAccess{diskAccess1, diskAccessNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ListWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockDiskAccessesClient(ctrl) errorPager := newErrorDiskAccessesPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, scope, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("ListStreamWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockDiskAccessesClient(ctrl) errorPager := newErrorDiskAccessesPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeDiskAccess(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(item *sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) if len(errs) == 0 { t.Error("Expected error when pager returns error, but got none") } }) } // createAzureDiskAccess creates a mock Azure Disk Access for testing. func createAzureDiskAccess(diskAccessName string) *armcompute.DiskAccess { return &armcompute.DiskAccess{ Name: new(diskAccessName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.DiskAccessProperties{ ProvisioningState: new("Succeeded"), }, } } // createAzureDiskAccessWithPrivateEndpointConnections creates a mock Azure Disk Access with private endpoint connections. func createAzureDiskAccessWithPrivateEndpointConnections(diskAccessName, subscriptionID, resourceGroup string) *armcompute.DiskAccess { return &armcompute.DiskAccess{ Name: new(diskAccessName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.DiskAccessProperties{ ProvisioningState: new("Succeeded"), PrivateEndpointConnections: []*armcompute.PrivateEndpointConnection{ { Name: new("pe-connection-1"), Properties: &armcompute.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armcompute.PrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-private-endpoint"), }, }, }, { Name: new("pe-connection-2"), Properties: &armcompute.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armcompute.PrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/Microsoft.Network/privateEndpoints/test-private-endpoint-other-rg"), }, }, }, }, }, } } // mockDiskAccessesPager is a mock pager for DiskAccessesClientListByResourceGroupResponse. type mockDiskAccessesPager struct { ctrl *gomock.Controller items []*armcompute.DiskAccess index int more bool } func newMockDiskAccessesPager(ctrl *gomock.Controller, items []*armcompute.DiskAccess) clients.DiskAccessesPager { return &mockDiskAccessesPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockDiskAccessesPager) More() bool { return m.more } func (m *mockDiskAccessesPager) NextPage(ctx context.Context) (armcompute.DiskAccessesClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.DiskAccessesClientListByResourceGroupResponse{ DiskAccessList: armcompute.DiskAccessList{ Value: []*armcompute.DiskAccess{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.DiskAccessesClientListByResourceGroupResponse{ DiskAccessList: armcompute.DiskAccessList{ Value: []*armcompute.DiskAccess{item}, }, }, nil } // errorDiskAccessesPager is a mock pager that always returns an error. type errorDiskAccessesPager struct { ctrl *gomock.Controller } func newErrorDiskAccessesPager(ctrl *gomock.Controller) clients.DiskAccessesPager { return &errorDiskAccessesPager{ctrl: ctrl} } func (e *errorDiskAccessesPager) More() bool { return true } func (e *errorDiskAccessesPager) NextPage(ctx context.Context) (armcompute.DiskAccessesClientListByResourceGroupResponse, error) { return armcompute.DiskAccessesClientListByResourceGroupResponse{}, errors.New("pager error") } ================================================ FILE: sources/azure/manual/compute-disk-encryption-set.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeDiskEncryptionSetLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeDiskEncryptionSet) type computeDiskEncryptionSetWrapper struct { client clients.DiskEncryptionSetsClient *azureshared.MultiResourceGroupBase } func NewComputeDiskEncryptionSet(client clients.DiskEncryptionSetsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeDiskEncryptionSetWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ComputeDiskEncryptionSet, ), } } func (c computeDiskEncryptionSetWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, diskEncryptionSet := range page.Value { if diskEncryptionSet.Name == nil { continue } item, sdpErr := c.azureDiskEncryptionSetToSDPItem(diskEncryptionSet, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeDiskEncryptionSetWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, diskEncryptionSet := range page.Value { if diskEncryptionSet.Name == nil { continue } item, sdpErr := c.azureDiskEncryptionSetToSDPItem(diskEncryptionSet, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } stream.SendItem(item) cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) } } } func (c computeDiskEncryptionSetWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the disk encryption set name"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } diskEncryptionSetName := queryParts[0] if diskEncryptionSetName == "" { return nil, azureshared.QueryError(errors.New("diskEncryptionSetName cannot be empty"), scope, c.Type()) } diskEncryptionSet, err := c.client.Get(ctx, rgScope.ResourceGroup, diskEncryptionSetName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureDiskEncryptionSetToSDPItem(&diskEncryptionSet.DiskEncryptionSet, scope) } func (c computeDiskEncryptionSetWrapper) azureDiskEncryptionSetToSDPItem(diskEncryptionSet *armcompute.DiskEncryptionSet, scope string) (*sdp.Item, *sdp.QueryError) { if diskEncryptionSet.Name == nil { return nil, azureshared.QueryError(errors.New("name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(diskEncryptionSet, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeDiskEncryptionSet.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(diskEncryptionSet.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } hasLinkedQuery := func(itemType string, method sdp.QueryMethod, query string) bool { for _, liq := range sdpItem.GetLinkedItemQueries() { q := liq.GetQuery() if q == nil { continue } if q.GetType() == itemType && q.GetMethod() == method && q.GetQuery() == query { return true } } return false } // Link to Key Vault from Properties.ActiveKey.SourceVault.ID // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01 if diskEncryptionSet.Properties != nil && diskEncryptionSet.Properties.ActiveKey != nil && diskEncryptionSet.Properties.ActiveKey.SourceVault != nil && diskEncryptionSet.Properties.ActiveKey.SourceVault.ID != nil && *diskEncryptionSet.Properties.ActiveKey.SourceVault.ID != "" { vaultID := *diskEncryptionSet.Properties.ActiveKey.SourceVault.ID vaultName := azureshared.ExtractResourceName(vaultID) if vaultName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(vaultID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } // Link to Key Vault(s) from Properties.PreviousKeys[].SourceVault.ID // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01 if diskEncryptionSet.Properties != nil && len(diskEncryptionSet.Properties.PreviousKeys) > 0 { for _, prevKey := range diskEncryptionSet.Properties.PreviousKeys { if prevKey == nil { continue } // Link to Key Vault Vault from PreviousKeys[].SourceVault.ID if prevKey.SourceVault != nil && prevKey.SourceVault.ID != nil && *prevKey.SourceVault.ID != "" { vaultID := *prevKey.SourceVault.ID vaultName := azureshared.ExtractResourceName(vaultID) if vaultName != "" { // Deduplicate by (type, method, query). QueryTests uses type+query uniqueness. if !hasLinkedQuery(azureshared.KeyVaultVault.String(), sdp.QueryMethod_GET, vaultName) { extractedScope := azureshared.ExtractScopeFromResourceID(vaultID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } } // Link to Key Vault Key + DNS from PreviousKeys[].KeyURL (mirrors ActiveKey.KeyURL behavior) if prevKey.KeyURL != nil && *prevKey.KeyURL != "" { prevKeyURL := *prevKey.KeyURL vaultName := azureshared.ExtractVaultNameFromURI(prevKeyURL) keyName := azureshared.ExtractKeyNameFromURI(prevKeyURL) if vaultName != "" && keyName != "" { keyQuery := shared.CompositeLookupKey(vaultName, keyName) if !hasLinkedQuery(azureshared.KeyVaultKey.String(), sdp.QueryMethod_GET, keyQuery) { // Key Vault URI doesn't contain resource group, use DES scope as best effort sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: keyQuery, Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } } dnsName := azureshared.ExtractDNSFromURL(prevKeyURL) if dnsName != "" && !hasLinkedQuery("dns", sdp.QueryMethod_SEARCH, dnsName) { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } // Link to Key Vault Key from Properties.ActiveKey.KeyURL // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keys/get-key/get-key?view=rest-keyvault-keys-2016-10-01 if diskEncryptionSet.Properties != nil && diskEncryptionSet.Properties.ActiveKey != nil && diskEncryptionSet.Properties.ActiveKey.KeyURL != nil && *diskEncryptionSet.Properties.ActiveKey.KeyURL != "" { keyURL := *diskEncryptionSet.Properties.ActiveKey.KeyURL vaultName := azureshared.ExtractVaultNameFromURI(keyURL) keyName := azureshared.ExtractKeyNameFromURI(keyURL) if vaultName != "" && keyName != "" { keyQuery := shared.CompositeLookupKey(vaultName, keyName) // Key Vault URI doesn't contain resource group, use DES scope as best effort if !hasLinkedQuery(azureshared.KeyVaultKey.String(), sdp.QueryMethod_GET, keyQuery) { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: keyQuery, Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } } // Link to DNS name (standard library) from KeyURL dnsName := azureshared.ExtractDNSFromURL(keyURL) if dnsName != "" && !hasLinkedQuery("dns", sdp.QueryMethod_SEARCH, dnsName) { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } // Link to user-assigned managed identities from Identity.UserAssignedIdentities map keys (resource IDs) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30 if diskEncryptionSet.Identity != nil && diskEncryptionSet.Identity.UserAssignedIdentities != nil { for identityID := range diskEncryptionSet.Identity.UserAssignedIdentities { if identityID == "" { continue } identityName := azureshared.ExtractResourceName(identityID) if identityName == "" { continue } extractedScope := azureshared.ExtractScopeFromResourceID(identityID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: extractedScope, }, }) } } return sdpItem, nil } func (c computeDiskEncryptionSetWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeDiskEncryptionSetLookupByName, } } func (c computeDiskEncryptionSetWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeDisk, azureshared.KeyVaultVault, azureshared.KeyVaultKey, azureshared.ManagedIdentityUserAssignedIdentity, stdlib.NetworkDNS, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/disk_encryption_set func (c computeDiskEncryptionSetWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_disk_encryption_set.name", }, } } ================================================ FILE: sources/azure/manual/compute-disk-encryption-set_test.go ================================================ package manual_test import ( "context" "errors" "strings" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeDiskEncryptionSet(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { desName := "test-des" des := createAzureDiskEncryptionSet(desName) mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, desName, nil).Return( armcompute.DiskEncryptionSetsClientGetResponse{DiskEncryptionSet: *des}, nil, ) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], desName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDiskEncryptionSet.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeDiskEncryptionSet.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != desName { t.Errorf("Expected unique attribute value %s, got %s", desName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } }) t.Run("GetWithAllLinkedResources", func(t *testing.T) { desName := "test-des" des := createAzureDiskEncryptionSetWithAllLinks(desName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, desName, nil).Return( armcompute.DiskEncryptionSetsClientGetResponse{DiskEncryptionSet: *des}, nil, ) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], desName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { // Properties.ActiveKey.SourceVault.ID - Key Vault Vault ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vault", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.ActiveKey.KeyURL - Key Vault Key ExpectedType: azureshared.KeyVaultKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vault", "test-key"), ExpectedScope: subscriptionID + "." + resourceGroup, // Key Vault URI doesn't contain resource group, use DES scope }, { // Properties.ActiveKey.KeyURL - DNS name ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-vault.vault.azure.net", ExpectedScope: "global", }, { // Identity.UserAssignedIdentities[{id}] - User Assigned Identity ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("GetWithPreviousKeysLinks", func(t *testing.T) { desName := "test-des" des := createAzureDiskEncryptionSetWithPreviousKeys(desName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, desName, nil).Return( armcompute.DiskEncryptionSetsClientGetResponse{DiskEncryptionSet: *des}, nil, ) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], desName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { // Properties.ActiveKey.SourceVault.ID - Key Vault Vault ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vault", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.ActiveKey.KeyURL - Key Vault Key ExpectedType: azureshared.KeyVaultKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vault", "test-key"), ExpectedScope: subscriptionID + "." + resourceGroup, // Key Vault URI doesn't contain resource group, use DES scope }, { // Properties.ActiveKey.KeyURL - DNS name ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-vault.vault.azure.net", ExpectedScope: "global", }, { // Identity.UserAssignedIdentities[{id}] - User Assigned Identity ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.PreviousKeys[].SourceVault.ID - Key Vault Vault ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-old-vault", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.PreviousKeys[].KeyURL - Key Vault Key ExpectedType: azureshared.KeyVaultKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-old-vault", "test-old-key"), ExpectedScope: subscriptionID + "." + resourceGroup, // Key Vault URI doesn't contain resource group, use DES scope }, { // Properties.PreviousKeys[].KeyURL - DNS name ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-old-vault.vault.azure.net", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("Get_DeduplicatesActiveKeyLinksWhenPreviousKeyMatches", func(t *testing.T) { desName := "test-des" des := createAzureDiskEncryptionSetWithPreviousKeysSameVault(desName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, desName, nil).Return( armcompute.DiskEncryptionSetsClientGetResponse{DiskEncryptionSet: *des}, nil, ) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], desName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } var keyCount, dnsCount int for _, liq := range sdpItem.GetLinkedItemQueries() { q := liq.GetQuery() if q == nil { continue } if q.GetType() == azureshared.KeyVaultKey.String() && q.GetMethod() == sdp.QueryMethod_GET && q.GetQuery() == shared.CompositeLookupKey("test-vault", "test-key") { keyCount++ } if q.GetType() == "dns" && q.GetMethod() == sdp.QueryMethod_SEARCH && q.GetQuery() == "test-vault.vault.azure.net" { dnsCount++ } } if keyCount != 1 { t.Fatalf("Expected exactly 1 KeyVaultKey link for ActiveKey/PreviousKeys overlap, got %d", keyCount) } if dnsCount != 1 { t.Fatalf("Expected exactly 1 dns link for ActiveKey/PreviousKeys overlap, got %d", dnsCount) } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting disk encryption set with empty name, but got nil") } }) t.Run("WrapperGet_MissingQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) defer func() { if r := recover(); r != nil { t.Fatalf("Expected no panic, but got: %v", r) } }() _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when queryParts is empty, but got nil") } }) t.Run("Get_NoName", func(t *testing.T) { desName := "test-des" des := &armcompute.DiskEncryptionSet{ Name: nil, Location: new("eastus"), } mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, desName, nil).Return( armcompute.DiskEncryptionSetsClientGetResponse{DiskEncryptionSet: *des}, nil, ) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], desName, true) if qErr == nil { t.Error("Expected error when disk encryption set has no name, but got nil") } }) t.Run("List", func(t *testing.T) { des1 := createAzureDiskEncryptionSet("test-des-1") des2 := createAzureDiskEncryptionSet("test-des-2") mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockPager := newMockDiskEncryptionSetsPager(ctrl, []*armcompute.DiskEncryptionSet{des1, des2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } }) t.Run("List_WithNilName", func(t *testing.T) { des1 := createAzureDiskEncryptionSet("test-des-1") desNil := &armcompute.DiskEncryptionSet{ Name: nil, // Should be skipped Location: new("eastus"), } mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockPager := newMockDiskEncryptionSetsPager(ctrl, []*armcompute.DiskEncryptionSet{des1, desNil}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } }) t.Run("List_PagerError", func(t *testing.T) { mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockPager := newErrorDiskEncryptionSetsPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Fatalf("Expected error, got nil") } if !strings.Contains(err.Error(), "pager error") { t.Fatalf("Expected error to contain 'pager error', got: %v", err) } }) t.Run("ListStream", func(t *testing.T) { des1 := createAzureDiskEncryptionSet("test-des-1") des2 := createAzureDiskEncryptionSet("test-des-2") mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockPager := newMockDiskEncryptionSetsPager(ctrl, []*armcompute.DiskEncryptionSet{des1, des2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item stream := discovery.NewQueryResultStream( func(item *sdp.Item) { items = append(items, item) wg.Done() }, func(err error) {}, ) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListStream_PagerError", func(t *testing.T) { mockClient := mocks.NewMockDiskEncryptionSetsClient(ctrl) mockPager := newErrorDiskEncryptionSetsPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDiskEncryptionSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) errCh := make(chan error, 1) stream := discovery.NewQueryResultStream( func(item *sdp.Item) {}, func(err error) { errCh <- err }, ) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) select { case err := <-errCh: if err == nil { t.Fatalf("Expected error, got nil") } if !strings.Contains(err.Error(), "pager error") { t.Fatalf("Expected error to contain 'pager error', got: %v", err) } default: t.Fatalf("Expected an error to be sent to the stream, got none") } }) } func createAzureDiskEncryptionSet(name string) *armcompute.DiskEncryptionSet { return &armcompute.DiskEncryptionSet{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.EncryptionSetProperties{ ProvisioningState: new("Succeeded"), }, } } func createAzureDiskEncryptionSetWithAllLinks(name, subscriptionID, resourceGroup string) *armcompute.DiskEncryptionSet { return &armcompute.DiskEncryptionSet{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.EncryptionSetProperties{ ProvisioningState: new("Succeeded"), ActiveKey: &armcompute.KeyForDiskEncryptionSet{ KeyURL: new("https://test-vault.vault.azure.net/keys/test-key/00000000000000000000000000000000"), SourceVault: &armcompute.SourceVault{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-vault"), }, }, }, Identity: &armcompute.EncryptionSetIdentity{ UserAssignedIdentities: map[string]*armcompute.UserAssignedIdentitiesValue{ "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity": {}, }, }, } } func createAzureDiskEncryptionSetWithPreviousKeys(name, subscriptionID, resourceGroup string) *armcompute.DiskEncryptionSet { des := createAzureDiskEncryptionSetWithAllLinks(name, subscriptionID, resourceGroup) des.Properties.PreviousKeys = []*armcompute.KeyForDiskEncryptionSet{ { KeyURL: new("https://test-old-vault.vault.azure.net/keys/test-old-key/00000000000000000000000000000000"), SourceVault: &armcompute.SourceVault{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-old-vault"), }, }, } return des } func createAzureDiskEncryptionSetWithPreviousKeysSameVault(name, subscriptionID, resourceGroup string) *armcompute.DiskEncryptionSet { des := createAzureDiskEncryptionSetWithAllLinks(name, subscriptionID, resourceGroup) des.Properties.PreviousKeys = []*armcompute.KeyForDiskEncryptionSet{ { // Same vault + key as ActiveKey.KeyURL to ensure links are deduplicated. KeyURL: new("https://test-vault.vault.azure.net/keys/test-key/00000000000000000000000000000000"), SourceVault: &armcompute.SourceVault{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-vault"), }, }, } return des } type mockDiskEncryptionSetsPager struct { ctrl *gomock.Controller items []*armcompute.DiskEncryptionSet index int more bool } func newMockDiskEncryptionSetsPager(ctrl *gomock.Controller, items []*armcompute.DiskEncryptionSet) clients.DiskEncryptionSetsPager { return &mockDiskEncryptionSetsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockDiskEncryptionSetsPager) More() bool { return m.more } func (m *mockDiskEncryptionSetsPager) NextPage(ctx context.Context) (armcompute.DiskEncryptionSetsClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.DiskEncryptionSetsClientListByResourceGroupResponse{ DiskEncryptionSetList: armcompute.DiskEncryptionSetList{Value: []*armcompute.DiskEncryptionSet{}}, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.DiskEncryptionSetsClientListByResourceGroupResponse{ DiskEncryptionSetList: armcompute.DiskEncryptionSetList{Value: []*armcompute.DiskEncryptionSet{item}}, }, nil } type errorDiskEncryptionSetsPager struct { ctrl *gomock.Controller } func newErrorDiskEncryptionSetsPager(ctrl *gomock.Controller) clients.DiskEncryptionSetsPager { return &errorDiskEncryptionSetsPager{ctrl: ctrl} } func (e *errorDiskEncryptionSetsPager) More() bool { return true } func (e *errorDiskEncryptionSetsPager) NextPage(ctx context.Context) (armcompute.DiskEncryptionSetsClientListByResourceGroupResponse, error) { return armcompute.DiskEncryptionSetsClientListByResourceGroupResponse{}, errors.New("pager error") } ================================================ FILE: sources/azure/manual/compute-disk.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeDiskLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeDisk) type computeDiskWrapper struct { client clients.DisksClient *azureshared.MultiResourceGroupBase } func NewComputeDisk(client clients.DisksClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeDiskWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ComputeDisk, ), } } func (c computeDiskWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, disk := range page.Value { if disk.Name == nil { continue } item, sdpErr := c.azureDiskToSDPItem(disk, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeDiskWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, disk := range page.Value { if disk.Name == nil { continue } item, sdpErr := c.azureDiskToSDPItem(disk, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c computeDiskWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the disk name"), scope, c.Type()) } diskName := queryParts[0] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } disk, err := c.client.Get(ctx, rgScope.ResourceGroup, diskName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureDiskToSDPItem(&disk.Disk, scope) } func (c computeDiskWrapper) azureDiskToSDPItem(disk *armcompute.Disk, scope string) (*sdp.Item, *sdp.QueryError) { if disk.Name == nil { return nil, azureshared.QueryError(errors.New("name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(disk, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeDisk.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(disk.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to Virtual Machine from ManagedBy // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get if disk.ManagedBy != nil && *disk.ManagedBy != "" { vmName := azureshared.ExtractResourceName(*disk.ManagedBy) if vmName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*disk.ManagedBy) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: extractedScope, }, }) } } // Link to Virtual Machines from ManagedByExtended (for shared disks) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get if disk.ManagedByExtended != nil { for _, vmID := range disk.ManagedByExtended { if vmID != nil && *vmID != "" { vmName := azureshared.ExtractResourceName(*vmID) if vmName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*vmID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: extractedScope, }, }) } } } } // Link to Virtual Machines from ShareInfo // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get if disk.Properties != nil && disk.Properties.ShareInfo != nil { for _, shareInfo := range disk.Properties.ShareInfo { if shareInfo != nil && shareInfo.VMURI != nil && *shareInfo.VMURI != "" { vmName := azureshared.ExtractResourceName(*shareInfo.VMURI) if vmName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*shareInfo.VMURI) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: extractedScope, }, }) } } } } // Link to Disk Access from Properties.DiskAccessID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/diskaccesses/get if disk.Properties != nil && disk.Properties.DiskAccessID != nil && *disk.Properties.DiskAccessID != "" { diskAccessName := azureshared.ExtractResourceName(*disk.Properties.DiskAccessID) if diskAccessName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*disk.Properties.DiskAccessID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskAccess.String(), Method: sdp.QueryMethod_GET, Query: diskAccessName, Scope: extractedScope, }, }) } } // Link to Disk Encryption Set from Properties.Encryption.DiskEncryptionSetID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if disk.Properties != nil && disk.Properties.Encryption != nil && disk.Properties.Encryption.DiskEncryptionSetID != nil && *disk.Properties.Encryption.DiskEncryptionSetID != "" { encryptionSetName := azureshared.ExtractResourceName(*disk.Properties.Encryption.DiskEncryptionSetID) if encryptionSetName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*disk.Properties.Encryption.DiskEncryptionSetID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } // Link to Disk Encryption Set from Properties.SecurityProfile.SecureVMDiskEncryptionSetID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if disk.Properties != nil && disk.Properties.SecurityProfile != nil && disk.Properties.SecurityProfile.SecureVMDiskEncryptionSetID != nil && *disk.Properties.SecurityProfile.SecureVMDiskEncryptionSetID != "" { encryptionSetName := azureshared.ExtractResourceName(*disk.Properties.SecurityProfile.SecureVMDiskEncryptionSetID) if encryptionSetName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*disk.Properties.SecurityProfile.SecureVMDiskEncryptionSetID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } // Link to source resources from Properties.CreationData if disk.Properties != nil && disk.Properties.CreationData != nil { creationData := disk.Properties.CreationData // Link to source Disk or Snapshot from SourceResourceID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disks/get?view=rest-compute-2025-04-01&tabs=HTTP // Reference: https://learn.microsoft.com/en-us/rest/api/compute/snapshots/get?view=rest-compute-2025-04-01&tabs=HTTP if creationData.SourceResourceID != nil && *creationData.SourceResourceID != "" { sourceResourceID := *creationData.SourceResourceID // Determine if it's a disk or snapshot based on the resource type in the ID if strings.Contains(sourceResourceID, "/disks/") { diskName := azureshared.ExtractResourceName(sourceResourceID) if diskName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(sourceResourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: extractedScope, }, }) } } else if strings.Contains(sourceResourceID, "/snapshots/") { snapshotName := azureshared.ExtractResourceName(sourceResourceID) if snapshotName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(sourceResourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: extractedScope, }, }) } } } // Link to Storage Account from StorageAccountID // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/storage-accounts/get-properties if creationData.StorageAccountID != nil && *creationData.StorageAccountID != "" { storageAccountName := azureshared.ExtractResourceName(*creationData.StorageAccountID) if storageAccountName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*creationData.StorageAccountID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: extractedScope, }, }) } } // Link to Image from ImageReference.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/images/get if creationData.ImageReference != nil && creationData.ImageReference.ID != nil && *creationData.ImageReference.ID != "" { imageID := *creationData.ImageReference.ID // Check if it's a regular image or gallery image if strings.Contains(imageID, "/images/") && !strings.Contains(imageID, "/galleries/") { imageName := azureshared.ExtractResourceName(imageID) if imageName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(imageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: imageName, Scope: extractedScope, }, }) } } } // Link to Gallery Image from GalleryImageReference // Reference: https://learn.microsoft.com/en-us/rest/api/compute/gallery-images/get if creationData.GalleryImageReference != nil { // Link from ID (shared gallery image) if creationData.GalleryImageReference.ID != nil && *creationData.GalleryImageReference.ID != "" { galleryImageID := *creationData.GalleryImageReference.ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/images/{imageName}/versions/{version} parts := azureshared.ExtractPathParamsFromResourceID(galleryImageID, []string{"galleries", "images", "versions"}) if len(parts) >= 3 { galleryName := parts[0] imageName := parts[1] version := parts[2] extractedScope := azureshared.ExtractScopeFromResourceID(galleryImageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, version), Scope: extractedScope, }, }) } } // Link from SharedGalleryImageID if creationData.GalleryImageReference.SharedGalleryImageID != nil && *creationData.GalleryImageReference.SharedGalleryImageID != "" { sharedGalleryImageID := *creationData.GalleryImageReference.SharedGalleryImageID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/images/{imageName}/versions/{version} parts := azureshared.ExtractPathParamsFromResourceID(sharedGalleryImageID, []string{"galleries", "images", "versions"}) if len(parts) >= 3 { galleryName := parts[0] imageName := parts[1] version := parts[2] extractedScope := azureshared.ExtractScopeFromResourceID(sharedGalleryImageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, version), Scope: extractedScope, }, }) } } // Link from CommunityGalleryImageID if creationData.GalleryImageReference.CommunityGalleryImageID != nil && *creationData.GalleryImageReference.CommunityGalleryImageID != "" { communityGalleryImageID := *creationData.GalleryImageReference.CommunityGalleryImageID // Format: /CommunityGalleries/{communityGalleryName}/Images/{imageName}/Versions/{version} // Note: Community gallery images may not have subscription/resource group in the ID parts := azureshared.ExtractPathParamsFromResourceID(communityGalleryImageID, []string{"Images", "Versions"}) if len(parts) >= 2 { imageName := parts[0] version := parts[1] // Extract community gallery name (before "Images") allParts := strings.Split(strings.Trim(communityGalleryImageID, "/"), "/") communityGalleryName := "" for i, part := range allParts { if strings.EqualFold(part, "CommunityGalleries") && i+1 < len(allParts) { communityGalleryName = allParts[i+1] break } } if communityGalleryName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(communityGalleryImageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCommunityGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(communityGalleryName, imageName, version), Scope: extractedScope, }, }) } } } } // Link to Elastic SAN Volume Snapshot from ElasticSanResourceID // Reference: https://learn.microsoft.com/en-us/rest/api/elasticsan/volume-snapshots/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ElasticSan/elasticSans/{elasticSanName}/volumegroups/{volumeGroupName}/snapshots/{snapshotName} if creationData.ElasticSanResourceID != nil && *creationData.ElasticSanResourceID != "" { elasticSanResourceID := *creationData.ElasticSanResourceID // Elastic SAN snapshot IDs follow format: // /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ElasticSan/elasticSans/{elasticSanName}/volumegroups/{volumeGroupName}/snapshots/{snapshotName} parts := azureshared.ExtractPathParamsFromResourceID(elasticSanResourceID, []string{"elasticSans", "volumegroups", "snapshots"}) if len(parts) >= 3 { elasticSanName := parts[0] volumeGroupName := parts[1] snapshotName := parts[2] extractedScope := azureshared.ExtractScopeFromResourceID(elasticSanResourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolumeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName, snapshotName), Scope: extractedScope, }, }) } } } // Link to Key Vault resources from EncryptionSettingsCollection // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secret // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keys/get-key if disk.Properties != nil && disk.Properties.EncryptionSettingsCollection != nil && disk.Properties.EncryptionSettingsCollection.EncryptionSettings != nil { for _, encryptionSetting := range disk.Properties.EncryptionSettingsCollection.EncryptionSettings { if encryptionSetting == nil { continue } // Link to Key Vault from DiskEncryptionKey.SourceVault.ID if encryptionSetting.DiskEncryptionKey != nil && encryptionSetting.DiskEncryptionKey.SourceVault != nil && encryptionSetting.DiskEncryptionKey.SourceVault.ID != nil && *encryptionSetting.DiskEncryptionKey.SourceVault.ID != "" { vaultName := azureshared.ExtractResourceName(*encryptionSetting.DiskEncryptionKey.SourceVault.ID) if vaultName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*encryptionSetting.DiskEncryptionKey.SourceVault.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } // Link to Key Vault Secret from DiskEncryptionKey.SecretURL if encryptionSetting.DiskEncryptionKey != nil && encryptionSetting.DiskEncryptionKey.SecretURL != nil && *encryptionSetting.DiskEncryptionKey.SecretURL != "" { secretURL := *encryptionSetting.DiskEncryptionKey.SecretURL vaultName := azureshared.ExtractVaultNameFromURI(secretURL) secretName := azureshared.ExtractSecretNameFromURI(secretURL) if vaultName != "" && secretName != "" { // Key Vault URI doesn't contain resource group, use disk's scope as best effort sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultSecret.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, secretName), Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } // Link to DNS name (standard library) from SecretURL dnsName := azureshared.ExtractDNSFromURL(secretURL) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } // Link to Key Vault from KeyEncryptionKey.SourceVault.ID if encryptionSetting.KeyEncryptionKey != nil && encryptionSetting.KeyEncryptionKey.SourceVault != nil && encryptionSetting.KeyEncryptionKey.SourceVault.ID != nil && *encryptionSetting.KeyEncryptionKey.SourceVault.ID != "" { vaultName := azureshared.ExtractResourceName(*encryptionSetting.KeyEncryptionKey.SourceVault.ID) if vaultName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*encryptionSetting.KeyEncryptionKey.SourceVault.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } // Link to Key Vault Key from KeyEncryptionKey.KeyURL if encryptionSetting.KeyEncryptionKey != nil && encryptionSetting.KeyEncryptionKey.KeyURL != nil && *encryptionSetting.KeyEncryptionKey.KeyURL != "" { keyURL := *encryptionSetting.KeyEncryptionKey.KeyURL vaultName := azureshared.ExtractVaultNameFromURI(keyURL) keyName := azureshared.ExtractKeyNameFromURI(keyURL) if vaultName != "" && keyName != "" { // Key Vault URI doesn't contain resource group, use disk's scope as best effort sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, keyName), Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } // Link to DNS name (standard library) from KeyURL dnsName := azureshared.ExtractDNSFromURL(keyURL) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } return sdpItem, nil } func (c computeDiskWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeDiskLookupByName, } } func (c computeDiskWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeVirtualMachine, azureshared.ComputeDisk, azureshared.ComputeSnapshot, azureshared.ComputeDiskAccess, azureshared.ComputeDiskEncryptionSet, azureshared.ComputeImage, azureshared.ComputeSharedGalleryImage, azureshared.ComputeCommunityGalleryImage, azureshared.StorageAccount, azureshared.ElasticSanVolumeSnapshot, azureshared.KeyVaultVault, azureshared.KeyVaultSecret, azureshared.KeyVaultKey, stdlib.NetworkDNS, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/managed_disk func (c computeDiskWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_managed_disk.name", }, } } ================================================ FILE: sources/azure/manual/compute-disk_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeDisk(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { diskName := "test-disk" disk := createAzureDisk(diskName, "Succeeded") mockClient := mocks.NewMockDisksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskName, nil).Return( armcompute.DisksClientGetResponse{ Disk: *disk, }, nil) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], diskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeDisk.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeDisk, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != diskName { t.Errorf("Expected unique attribute value %s, got %s", diskName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } }) t.Run("GetWithAllLinkedResources", func(t *testing.T) { diskName := "test-disk" disk := createAzureDiskWithAllLinks(diskName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDisksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskName, nil).Return( armcompute.DisksClientGetResponse{ Disk: *disk, }, nil) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], diskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // ManagedBy - Virtual Machine ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // ManagedByExtended[0] - Virtual Machine ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm-2", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // ShareInfo[0].VMURI - Virtual Machine ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm-3", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.DiskAccessID - Disk Access ExpectedType: azureshared.ComputeDiskAccess.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk-access", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.Encryption.DiskEncryptionSetID - Disk Encryption Set ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk-encryption-set", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.SecurityProfile.SecureVMDiskEncryptionSetID - Disk Encryption Set ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-secure-vm-disk-encryption-set", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.SourceResourceID (Disk) - Source Disk ExpectedType: azureshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-disk", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.StorageAccountID - Storage Account ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-storage-account", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.ImageReference.ID - Image ExpectedType: azureshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-image", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.GalleryImageReference.ID - Shared Gallery Image ExpectedType: azureshared.ComputeSharedGalleryImage.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-gallery", "test-gallery-image", "1.0.0"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.GalleryImageReference.SharedGalleryImageID - Shared Gallery Image ExpectedType: azureshared.ComputeSharedGalleryImage.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-gallery-2", "test-gallery-image-2", "2.0.0"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.GalleryImageReference.CommunityGalleryImageID - Community Gallery Image ExpectedType: azureshared.ComputeCommunityGalleryImage.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-community-gallery", "test-community-image", "1.0.0"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.ElasticSanResourceID - Elastic SAN Volume Snapshot ExpectedType: azureshared.ElasticSanVolumeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-elastic-san", "test-volume-group", "test-snapshot"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.EncryptionSettingsCollection.EncryptionSettings[0].DiskEncryptionKey.SourceVault.ID - Key Vault ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-keyvault", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.EncryptionSettingsCollection.EncryptionSettings[0].DiskEncryptionKey.SecretURL - Key Vault Secret ExpectedType: azureshared.KeyVaultSecret.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-keyvault", "test-secret"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.EncryptionSettingsCollection.EncryptionSettings[0].DiskEncryptionKey.SecretURL - DNS name ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-keyvault.vault.azure.net", ExpectedScope: "global", }, { // Properties.EncryptionSettingsCollection.EncryptionSettings[0].KeyEncryptionKey.SourceVault.ID - Key Vault ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-keyvault-2", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.EncryptionSettingsCollection.EncryptionSettings[0].KeyEncryptionKey.KeyURL - Key Vault Key ExpectedType: azureshared.KeyVaultKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-keyvault-2", "test-key"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.EncryptionSettingsCollection.EncryptionSettings[0].KeyEncryptionKey.KeyURL - DNS name ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-keyvault-2.vault.azure.net", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithSnapshotSource", func(t *testing.T) { diskName := "test-disk-from-snapshot" disk := createAzureDiskFromSnapshot(diskName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDisksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskName, nil).Return( armcompute.DisksClientGetResponse{ Disk: *disk, }, nil) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], diskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify snapshot link foundSnapshotLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.ComputeSnapshot.String() && linkedQuery.GetQuery().GetQuery() == "test-snapshot" { foundSnapshotLink = true break } } if !foundSnapshotLink { t.Error("Expected snapshot link not found") } }) t.Run("GetWithCrossResourceGroupLinks", func(t *testing.T) { diskName := "test-disk-cross-rg" disk := createAzureDiskWithCrossResourceGroupLinks(diskName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDisksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, diskName, nil).Return( armcompute.DisksClientGetResponse{ Disk: *disk, }, nil) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], diskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that links to resources in different resource groups use the correct scope foundCrossRGLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.ComputeVirtualMachine.String() && linkedQuery.GetQuery().GetQuery() == "test-vm-other-rg" { foundCrossRGLink = true expectedScope := subscriptionID + ".other-rg" if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected scope %s for cross-RG link, got %s", expectedScope, linkedQuery.GetQuery().GetScope()) } break } } if !foundCrossRGLink { t.Error("Expected cross-resource-group link not found") } }) t.Run("List", func(t *testing.T) { disk1 := createAzureDisk("test-disk-1", "Succeeded") disk2 := createAzureDisk("test-disk-2", "Succeeded") mockClient := mocks.NewMockDisksClient(ctrl) mockPager := newMockDisksPager(ctrl, []*armcompute.Disk{disk1, disk2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { disk1 := createAzureDisk("test-disk-1", "Succeeded") disk2 := createAzureDisk("test-disk-2", "Succeeded") mockClient := mocks.NewMockDisksClient(ctrl) mockPager := newMockDisksPager(ctrl, []*armcompute.Disk{disk1, disk2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListWithNilName", func(t *testing.T) { disk1 := createAzureDisk("test-disk-1", "Succeeded") diskNilName := &armcompute.Disk{ Name: nil, // nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockDisksClient(ctrl) mockPager := newMockDisksPager(ctrl, []*armcompute.Disk{disk1, diskNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("disk not found") mockClient := mocks.NewMockDisksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-disk", nil).Return( armcompute.DisksClientGetResponse{}, expectedErr) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-disk", true) if qErr == nil { t.Error("Expected error when getting non-existent disk, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { expectedErr := errors.New("disk name cannot be empty") mockClient := mocks.NewMockDisksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "", nil).Return( armcompute.DisksClientGetResponse{}, expectedErr) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting disk with empty name, but got nil") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDisksClient(ctrl) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test the wrapper's Get method directly with insufficient query parts _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when getting disk with insufficient query parts, but got nil") } }) t.Run("ListWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockDisksClient(ctrl) errorPager := newErrorDisksPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("ListStreamWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockDisksClient(ctrl) errorPager := newErrorDisksPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeDisk(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(item *sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) if len(errs) == 0 { t.Error("Expected error when pager returns error, but got none") } }) } // createAzureDisk creates a mock Azure Disk for testing func createAzureDisk(diskName, provisioningState string) *armcompute.Disk { return &armcompute.Disk{ Name: new(diskName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.DiskProperties{ ProvisioningState: new(provisioningState), DiskSizeGB: new(int32(128)), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionEmpty), }, }, } } // createAzureDiskWithAllLinks creates a mock Azure Disk with all possible linked resources func createAzureDiskWithAllLinks(diskName, subscriptionID, resourceGroup string) *armcompute.Disk { return &armcompute.Disk{ Name: new(diskName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, ManagedBy: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/virtualMachines/test-vm"), ManagedByExtended: []*string{ new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/virtualMachines/test-vm-2"), }, Properties: &armcompute.DiskProperties{ ProvisioningState: new("Succeeded"), DiskSizeGB: new(int32(128)), DiskAccessID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskAccesses/test-disk-access"), Encryption: &armcompute.Encryption{ DiskEncryptionSetID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskEncryptionSets/test-disk-encryption-set"), }, SecurityProfile: &armcompute.DiskSecurityProfile{ SecureVMDiskEncryptionSetID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskEncryptionSets/test-secure-vm-disk-encryption-set"), }, ShareInfo: []*armcompute.ShareInfoElement{ { VMURI: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/virtualMachines/test-vm-3"), }, }, CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionCopy), SourceResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/disks/source-disk"), StorageAccountID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Storage/storageAccounts/test-storage-account"), ImageReference: &armcompute.ImageDiskReference{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/images/test-image"), }, GalleryImageReference: &armcompute.ImageDiskReference{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/galleries/test-gallery/images/test-gallery-image/versions/1.0.0"), SharedGalleryImageID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/galleries/test-gallery-2/images/test-gallery-image-2/versions/2.0.0"), CommunityGalleryImageID: new("/CommunityGalleries/test-community-gallery/Images/test-community-image/Versions/1.0.0"), }, ElasticSanResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ElasticSan/elasticSans/test-elastic-san/volumegroups/test-volume-group/snapshots/test-snapshot"), }, EncryptionSettingsCollection: &armcompute.EncryptionSettingsCollection{ Enabled: new(true), EncryptionSettings: []*armcompute.EncryptionSettingsElement{ { DiskEncryptionKey: &armcompute.KeyVaultAndSecretReference{ SourceVault: &armcompute.SourceVault{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-keyvault"), }, SecretURL: new("https://test-keyvault.vault.azure.net/secrets/test-secret/version"), }, KeyEncryptionKey: &armcompute.KeyVaultAndKeyReference{ SourceVault: &armcompute.SourceVault{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-keyvault-2"), }, KeyURL: new("https://test-keyvault-2.vault.azure.net/keys/test-key/version"), }, }, }, }, }, } } // createAzureDiskFromSnapshot creates a mock Azure Disk created from a snapshot func createAzureDiskFromSnapshot(diskName, subscriptionID, resourceGroup string) *armcompute.Disk { return &armcompute.Disk{ Name: new(diskName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.DiskProperties{ ProvisioningState: new("Succeeded"), DiskSizeGB: new(int32(128)), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionCopy), SourceResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/snapshots/test-snapshot"), }, }, } } // createAzureDiskWithCrossResourceGroupLinks creates a mock Azure Disk with links to resources in different resource groups func createAzureDiskWithCrossResourceGroupLinks(diskName, subscriptionID, resourceGroup string) *armcompute.Disk { return &armcompute.Disk{ Name: new(diskName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, ManagedBy: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/Microsoft.Compute/virtualMachines/test-vm-other-rg"), Properties: &armcompute.DiskProperties{ ProvisioningState: new("Succeeded"), DiskSizeGB: new(int32(128)), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionEmpty), }, }, } } // mockDisksPager is a simple mock implementation of the Pager interface for testing type mockDisksPager struct { ctrl *gomock.Controller items []*armcompute.Disk index int more bool } func newMockDisksPager(ctrl *gomock.Controller, items []*armcompute.Disk) clients.DisksPager { return &mockDisksPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockDisksPager) More() bool { return m.more } func (m *mockDisksPager) NextPage(ctx context.Context) (armcompute.DisksClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.DisksClientListByResourceGroupResponse{ DiskList: armcompute.DiskList{ Value: []*armcompute.Disk{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.DisksClientListByResourceGroupResponse{ DiskList: armcompute.DiskList{ Value: []*armcompute.Disk{item}, }, }, nil } // errorDisksPager is a mock pager that always returns an error type errorDisksPager struct { ctrl *gomock.Controller } func newErrorDisksPager(ctrl *gomock.Controller) clients.DisksPager { return &errorDisksPager{ctrl: ctrl} } func (e *errorDisksPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorDisksPager) NextPage(ctx context.Context) (armcompute.DisksClientListByResourceGroupResponse, error) { return armcompute.DisksClientListByResourceGroupResponse{}, errors.New("pager error") } ================================================ FILE: sources/azure/manual/compute-gallery-application-version.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeGalleryApplicationVersionLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeGalleryApplicationVersion) type computeGalleryApplicationVersionWrapper struct { client clients.GalleryApplicationVersionsClient *azureshared.MultiResourceGroupBase } func NewComputeGalleryApplicationVersion(client clients.GalleryApplicationVersionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeGalleryApplicationVersionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeGalleryApplicationVersion, ), } } func (c computeGalleryApplicationVersionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 3 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 3 and be the gallery name, gallery application name, and gallery application version name"), scope, c.Type()) } galleryName := queryParts[0] if galleryName == "" { return nil, azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type()) } galleryApplicationName := queryParts[1] if galleryApplicationName == "" { return nil, azureshared.QueryError(errors.New("gallery application name cannot be empty"), scope, c.Type()) } galleryApplicationVersionName := queryParts[2] if galleryApplicationVersionName == "" { return nil, azureshared.QueryError(errors.New("gallery application version name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, galleryName, galleryApplicationName, galleryApplicationVersionName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureGalleryApplicationVersionToSDPItem(&resp.GalleryApplicationVersion, galleryName, galleryApplicationName, scope) } func (c computeGalleryApplicationVersionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 2 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 2 and be the gallery name and gallery application name"), scope, c.Type()) } galleryName := queryParts[0] if galleryName == "" { return nil, azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type()) } galleryApplicationName := queryParts[1] if galleryApplicationName == "" { return nil, azureshared.QueryError(errors.New("gallery application name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByGalleryApplicationPager(rgScope.ResourceGroup, galleryName, galleryApplicationName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, galleryApplicationVersion := range page.Value { if galleryApplicationVersion == nil || galleryApplicationVersion.Name == nil { continue } item, sdpErr := c.azureGalleryApplicationVersionToSDPItem(galleryApplicationVersion, galleryName, galleryApplicationName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeGalleryApplicationVersionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 2 { stream.SendError(azureshared.QueryError(errors.New("queryParts must be exactly 2 and be the gallery name and gallery application name"), scope, c.Type())) return } galleryName := queryParts[0] if galleryName == "" { stream.SendError(azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type())) return } galleryApplicationName := queryParts[1] if galleryApplicationName == "" { stream.SendError(azureshared.QueryError(errors.New("gallery application name cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByGalleryApplicationPager(rgScope.ResourceGroup, galleryName, galleryApplicationName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, galleryApplicationVersion := range page.Value { if galleryApplicationVersion == nil || galleryApplicationVersion.Name == nil { continue } item, sdpErr := c.azureGalleryApplicationVersionToSDPItem(galleryApplicationVersion, galleryName, galleryApplicationName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c computeGalleryApplicationVersionWrapper) azureGalleryApplicationVersionToSDPItem( galleryApplicationVersion *armcompute.GalleryApplicationVersion, galleryName, galleryApplicationName, scope string, ) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(galleryApplicationVersion, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if galleryApplicationVersion.Name == nil { return nil, azureshared.QueryError(errors.New("gallery application version name is nil"), scope, c.Type()) } galleryApplicationVersionName := *galleryApplicationVersion.Name if galleryApplicationVersionName == "" { return nil, azureshared.QueryError(errors.New("gallery application version name cannot be empty"), scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(galleryName, galleryApplicationName, galleryApplicationVersionName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) // Parent Gallery: version depends on gallery; deleting version does not delete gallery linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGallery.String(), Method: sdp.QueryMethod_GET, Query: galleryName, Scope: scope, }, }) // Parent Gallery Application: version depends on application; deleting version does not delete application linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGalleryApplication.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, galleryApplicationName), Scope: scope, }, }) // MediaLink and DefaultConfigurationLink: add stdlib.NetworkHTTP, stdlib.NetworkDNS (hostname), stdlib.NetworkIP (when host is IP), azureshared.StorageAccount and azureshared.StorageBlobContainer (when Azure Blob) links. // Dedupe DNS by hostname, IP by address, StorageAccount by account name, and StorageBlobContainer by (account, container) so the same resource is not linked twice. linkedDNSHostnames := make(map[string]struct{}) seenIPs := make(map[string]struct{}) seenStorageAccounts := make(map[string]struct{}) seenBlobContainers := make(map[string]struct{}) if galleryApplicationVersion.Properties != nil && galleryApplicationVersion.Properties.PublishingProfile != nil && galleryApplicationVersion.Properties.PublishingProfile.Source != nil { src := galleryApplicationVersion.Properties.PublishingProfile.Source addBlobLinks := func(link string) { if link == "" || (!strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://")) { return } AppendURILinks(&linkedItemQueries, link, linkedDNSHostnames, seenIPs) if accountName := azureshared.ExtractStorageAccountNameFromBlobURI(link); accountName != "" { if _, seen := seenStorageAccounts[accountName]; !seen { seenStorageAccounts[accountName] = struct{}{} linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) } containerName := azureshared.ExtractContainerNameFromBlobURI(link) if containerName != "" { containerKey := shared.CompositeLookupKey(accountName, containerName) if _, seen := seenBlobContainers[containerKey]; !seen { seenBlobContainers[containerKey] = struct{}{} linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageBlobContainer.String(), Method: sdp.QueryMethod_GET, Query: containerKey, Scope: scope, }, }) } } } } if src.MediaLink != nil && *src.MediaLink != "" { addBlobLinks(*src.MediaLink) } if src.DefaultConfigurationLink != nil && *src.DefaultConfigurationLink != "" { defaultConfigLink := *src.DefaultConfigurationLink if strings.HasPrefix(defaultConfigLink, "http://") || strings.HasPrefix(defaultConfigLink, "https://") { sameAsMedia := src.MediaLink != nil && *src.MediaLink == defaultConfigLink if !sameAsMedia { addBlobLinks(defaultConfigLink) } } } } // Disk encryption sets from TargetRegions[].Encryption (OS and data disk); dedupe by ID seenEncryptionSetIDs := make(map[string]struct{}) if galleryApplicationVersion.Properties != nil && galleryApplicationVersion.Properties.PublishingProfile != nil && galleryApplicationVersion.Properties.PublishingProfile.TargetRegions != nil { for _, tr := range galleryApplicationVersion.Properties.PublishingProfile.TargetRegions { if tr == nil || tr.Encryption == nil { continue } if tr.Encryption.OSDiskImage != nil && tr.Encryption.OSDiskImage.DiskEncryptionSetID != nil && *tr.Encryption.OSDiskImage.DiskEncryptionSetID != "" { id := *tr.Encryption.OSDiskImage.DiskEncryptionSetID if _, seen := seenEncryptionSetIDs[id]; !seen { seenEncryptionSetIDs[id] = struct{}{} name := azureshared.ExtractResourceName(id) if name != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(id); extractedScope != "" { linkScope = extractedScope } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: linkScope, }, }) } } } if tr.Encryption.OSDiskImage != nil && tr.Encryption.OSDiskImage.SecurityProfile != nil && tr.Encryption.OSDiskImage.SecurityProfile.SecureVMDiskEncryptionSetID != nil && *tr.Encryption.OSDiskImage.SecurityProfile.SecureVMDiskEncryptionSetID != "" { id := *tr.Encryption.OSDiskImage.SecurityProfile.SecureVMDiskEncryptionSetID if _, seen := seenEncryptionSetIDs[id]; !seen { seenEncryptionSetIDs[id] = struct{}{} name := azureshared.ExtractResourceName(id) if name != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(id); extractedScope != "" { linkScope = extractedScope } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: linkScope, }, }) } } } if tr.Encryption.DataDiskImages != nil { for _, ddi := range tr.Encryption.DataDiskImages { if ddi != nil && ddi.DiskEncryptionSetID != nil && *ddi.DiskEncryptionSetID != "" { id := *ddi.DiskEncryptionSetID if _, seen := seenEncryptionSetIDs[id]; !seen { seenEncryptionSetIDs[id] = struct{}{} name := azureshared.ExtractResourceName(id) if name != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(id); extractedScope != "" { linkScope = extractedScope } linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: linkScope, }, }) } } } } } } } sdpItem := &sdp.Item{ Type: azureshared.ComputeGalleryApplicationVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(galleryApplicationVersion.Tags), LinkedItemQueries: linkedItemQueries, } return sdpItem, nil } func (c computeGalleryApplicationVersionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeGalleryLookupByName, ComputeGalleryApplicationLookupByName, ComputeGalleryApplicationVersionLookupByName, } } func (c computeGalleryApplicationVersionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeGalleryLookupByName, ComputeGalleryApplicationLookupByName, }, } } func (c computeGalleryApplicationVersionWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeGallery, azureshared.ComputeGalleryApplication, azureshared.ComputeDiskEncryptionSet, azureshared.StorageAccount, azureshared.StorageBlobContainer, stdlib.NetworkDNS, stdlib.NetworkHTTP, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/gallery_application_version func (c computeGalleryApplicationVersionWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // example id: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Compute/galleries/gallery1/applications/galleryApplication1/versions/galleryApplicationVersion1 TerraformQueryMap: "azurerm_gallery_application_version.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeGalleryApplicationVersionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/galleries/applications/versions/read", } } func (c computeGalleryApplicationVersionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-gallery-application-version_test.go ================================================ package manual import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockGalleryApplicationVersionsPager is a mock pager for ListByGalleryApplication. type mockGalleryApplicationVersionsPager struct { pages []armcompute.GalleryApplicationVersionsClientListByGalleryApplicationResponse index int } func (m *mockGalleryApplicationVersionsPager) More() bool { return m.index < len(m.pages) } func (m *mockGalleryApplicationVersionsPager) NextPage(ctx context.Context) (armcompute.GalleryApplicationVersionsClientListByGalleryApplicationResponse, error) { if m.index >= len(m.pages) { return armcompute.GalleryApplicationVersionsClientListByGalleryApplicationResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorGalleryApplicationVersionsPager is a mock pager that always returns an error. type errorGalleryApplicationVersionsPager struct{} func (e *errorGalleryApplicationVersionsPager) More() bool { return true } func (e *errorGalleryApplicationVersionsPager) NextPage(ctx context.Context) (armcompute.GalleryApplicationVersionsClientListByGalleryApplicationResponse, error) { return armcompute.GalleryApplicationVersionsClientListByGalleryApplicationResponse{}, errors.New("pager error") } // testGalleryApplicationVersionsClient wraps the mock and returns a pager from NewListByGalleryApplicationPager. type testGalleryApplicationVersionsClient struct { *mocks.MockGalleryApplicationVersionsClient pager clients.GalleryApplicationVersionsPager } // NewListByGalleryApplicationPager returns the test pager so we don't need to mock this call. func (t *testGalleryApplicationVersionsClient) NewListByGalleryApplicationPager(resourceGroupName, galleryName, galleryApplicationName string, options *armcompute.GalleryApplicationVersionsClientListByGalleryApplicationOptions) clients.GalleryApplicationVersionsPager { if t.pager != nil { return t.pager } return t.MockGalleryApplicationVersionsClient.NewListByGalleryApplicationPager(resourceGroupName, galleryName, galleryApplicationName, options) } func createAzureGalleryApplicationVersion(versionName string) *armcompute.GalleryApplicationVersion { return &armcompute.GalleryApplicationVersion{ Name: new(versionName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.GalleryApplicationVersionProperties{ PublishingProfile: &armcompute.GalleryApplicationVersionPublishingProfile{ Source: &armcompute.UserArtifactSource{ MediaLink: new("https://mystorageaccount.blob.core.windows.net/packages/app.zip"), }, }, }, } } func createAzureGalleryApplicationVersionWithLinks(versionName, subscriptionID, resourceGroup string) *armcompute.GalleryApplicationVersion { v := createAzureGalleryApplicationVersion(versionName) v.Properties.PublishingProfile.Source.DefaultConfigurationLink = new("https://mystorageaccount.blob.core.windows.net/config/default.json") desID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskEncryptionSets/test-des" v.Properties.PublishingProfile.TargetRegions = []*armcompute.TargetRegion{ { Name: new("eastus"), Encryption: &armcompute.EncryptionImages{ OSDiskImage: &armcompute.OSDiskImageEncryption{ DiskEncryptionSetID: new(desID), }, }, }, } return v } func TestComputeGalleryApplicationVersion(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup galleryName := "test-gallery" galleryApplicationName := "test-app" galleryApplicationVersionName := "1.0.0" t.Run("Get", func(t *testing.T) { version := createAzureGalleryApplicationVersion(galleryApplicationVersionName) mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryApplicationName, galleryApplicationVersionName, nil).Return( armcompute.GalleryApplicationVersionsClientGetResponse{ GalleryApplicationVersion: *version, }, nil) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryApplicationName, galleryApplicationVersionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeGalleryApplicationVersion.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeGalleryApplicationVersion.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(galleryName, galleryApplicationName, galleryApplicationVersionName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag env=test, got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeGallery.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: galleryName, ExpectedScope: scope}, {ExpectedType: azureshared.ComputeGalleryApplication.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(galleryName, galleryApplicationName), ExpectedScope: scope}, {ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "mystorageaccount", ExpectedScope: scope}, {ExpectedType: azureshared.StorageBlobContainer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("mystorageaccount", "packages"), ExpectedScope: scope}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://mystorageaccount.blob.core.windows.net/packages/app.zip", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "mystorageaccount.blob.core.windows.net", ExpectedScope: "global"}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithLinkedResources", func(t *testing.T) { version := createAzureGalleryApplicationVersionWithLinks(galleryApplicationVersionName, subscriptionID, resourceGroup) mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryApplicationName, galleryApplicationVersionName, nil).Return( armcompute.GalleryApplicationVersionsClientGetResponse{ GalleryApplicationVersion: *version, }, nil) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryApplicationName, galleryApplicationVersionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeGallery.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: galleryName, ExpectedScope: scope}, {ExpectedType: azureshared.ComputeGalleryApplication.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(galleryName, galleryApplicationName), ExpectedScope: scope}, {ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "mystorageaccount", ExpectedScope: scope}, {ExpectedType: azureshared.StorageBlobContainer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("mystorageaccount", "packages"), ExpectedScope: scope}, {ExpectedType: azureshared.StorageBlobContainer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("mystorageaccount", "config"), ExpectedScope: scope}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://mystorageaccount.blob.core.windows.net/packages/app.zip", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "mystorageaccount.blob.core.windows.net", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://mystorageaccount.blob.core.windows.net/config/default.json", ExpectedScope: "global"}, {ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-des", ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Adapter expects query to split into 3 parts (gallery, application, version); single part is invalid _, qErr := adapter.Get(ctx, scope, galleryName, true) if qErr == nil { t.Error("Expected error when Get with wrong number of query parts, but got nil") } }) t.Run("Get_EmptyGalleryName", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", galleryApplicationName, galleryApplicationVersionName) _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when gallery name is empty, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("version not found") mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryApplicationName, "nonexistent", nil).Return( armcompute.GalleryApplicationVersionsClientGetResponse{}, expectedErr) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryApplicationName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Get_NonBlobURL_NoStorageLinks", func(t *testing.T) { // MediaLink that is not Azure Blob Storage must not create StorageAccount/StorageBlobContainer links. version := createAzureGalleryApplicationVersion(galleryApplicationVersionName) version.Properties.PublishingProfile.Source.MediaLink = new("https://example.com/artifacts/app.zip") mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryApplicationName, galleryApplicationVersionName, nil).Return( armcompute.GalleryApplicationVersionsClientGetResponse{ GalleryApplicationVersion: *version, }, nil) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryApplicationName, galleryApplicationVersionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } for _, q := range sdpItem.GetLinkedItemQueries() { query := q.GetQuery() if query == nil { continue } typ := query.GetType() if typ == azureshared.StorageAccount.String() || typ == azureshared.StorageBlobContainer.String() { t.Errorf("Non-blob URL must not create storage links; found linked query type %s with query %s", typ, query.GetQuery()) } } // Should still have NetworkHTTP and NetworkDNS for the URL hasHTTP := false hasDNS := false for _, q := range sdpItem.GetLinkedItemQueries() { query := q.GetQuery() if query != nil { if query.GetType() == stdlib.NetworkHTTP.String() { hasHTTP = true } if query.GetType() == stdlib.NetworkDNS.String() { hasDNS = true } } } if !hasHTTP { t.Error("Expected NetworkHTTP linked query for the media URL") } if !hasDNS { t.Error("Expected NetworkDNS linked query for the media URL hostname") } }) t.Run("Get_IPHost_EmitsIPLink", func(t *testing.T) { // When MediaLink or DefaultConfigurationLink has a literal IP host, emit stdlib.NetworkIP link (GET, global), not DNS. version := createAzureGalleryApplicationVersion(galleryApplicationVersionName) version.Properties.PublishingProfile.Source.MediaLink = new("https://192.168.1.10:8443/artifacts/app.zip") mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryApplicationName, galleryApplicationVersionName, nil).Return( armcompute.GalleryApplicationVersionsClientGetResponse{ GalleryApplicationVersion: *version, }, nil) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryApplicationName, galleryApplicationVersionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } hasIP := false for _, q := range sdpItem.GetLinkedItemQueries() { query := q.GetQuery() if query != nil && query.GetType() == stdlib.NetworkIP.String() { hasIP = true if query.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected NetworkIP link to use GET, got %v", query.GetMethod()) } if query.GetScope() != "global" { t.Errorf("Expected NetworkIP link scope global, got %s", query.GetScope()) } if query.GetQuery() != "192.168.1.10" { t.Errorf("Expected NetworkIP link query 192.168.1.10, got %s", query.GetQuery()) } break } } if !hasIP { t.Error("Expected NetworkIP linked query when MediaLink host is an IP address") } }) t.Run("Search", func(t *testing.T) { v1 := createAzureGalleryApplicationVersion("1.0.0") v2 := createAzureGalleryApplicationVersion("1.0.1") mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) pages := []armcompute.GalleryApplicationVersionsClientListByGalleryApplicationResponse{ { GalleryApplicationVersionList: armcompute.GalleryApplicationVersionList{ Value: []*armcompute.GalleryApplicationVersion{v1, v2}, }, }, } mockPager := &mockGalleryApplicationVersionsPager{pages: pages} testClient := &testGalleryApplicationVersionsClient{ MockGalleryApplicationVersionsClient: mockClient, pager: mockPager, } wrapper := NewComputeGalleryApplicationVersion(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := shared.CompositeLookupKey(galleryName, galleryApplicationName) sdpItems, err := searchable.Search(ctx, scope, searchQuery, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, scope, galleryName, true) if err == nil { t.Error("Expected error when Search with wrong number of query parts, but got nil") } }) t.Run("Search_EmptyGalleryName", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, "", galleryApplicationName) if qErr == nil { t.Error("Expected error when gallery name is empty, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) errorPager := &errorGalleryApplicationVersionsPager{} testClient := &testGalleryApplicationVersionsClient{ MockGalleryApplicationVersionsClient: mockClient, pager: errorPager, } wrapper := NewComputeGalleryApplicationVersion(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := shared.CompositeLookupKey(galleryName, galleryApplicationName) _, err := searchable.Search(ctx, scope, searchQuery, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() expected := map[shared.ItemType]bool{ azureshared.ComputeGallery: true, azureshared.ComputeGalleryApplication: true, azureshared.ComputeDiskEncryptionSet: true, azureshared.StorageAccount: true, azureshared.StorageBlobContainer: true, stdlib.NetworkDNS: true, stdlib.NetworkHTTP: true, stdlib.NetworkIP: true, } for itemType, want := range expected { if got := links[itemType]; got != want { t.Errorf("PotentialLinks()[%v] = %v, want %v", itemType, got, want) } } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationVersionsClient(ctrl) wrapper := NewComputeGalleryApplicationVersion(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/compute-gallery-application.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeGalleryApplicationLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeGalleryApplication) type computeGalleryApplicationWrapper struct { client clients.GalleryApplicationsClient *azureshared.MultiResourceGroupBase } func NewComputeGalleryApplication(client clients.GalleryApplicationsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeGalleryApplicationWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeGalleryApplication, ), } } func (c computeGalleryApplicationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 2 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 2 and be the gallery name and gallery application name"), scope, c.Type()) } galleryName := queryParts[0] if galleryName == "" { return nil, azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type()) } galleryApplicationName := queryParts[1] if galleryApplicationName == "" { return nil, azureshared.QueryError(errors.New("gallery application name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, galleryName, galleryApplicationName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureGalleryApplicationToSDPItem(&resp.GalleryApplication, galleryName, scope) } func (c computeGalleryApplicationWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the gallery name"), scope, c.Type()) } galleryName := queryParts[0] if galleryName == "" { return nil, azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByGalleryPager(rgScope.ResourceGroup, galleryName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, galleryApplication := range page.Value { if galleryApplication == nil || galleryApplication.Name == nil { continue } item, sdpErr := c.azureGalleryApplicationToSDPItem(galleryApplication, galleryName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeGalleryApplicationWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the gallery name"), scope, c.Type())) return } galleryName := queryParts[0] if galleryName == "" { stream.SendError(azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByGalleryPager(rgScope.ResourceGroup, galleryName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, galleryApplication := range page.Value { if galleryApplication == nil || galleryApplication.Name == nil { continue } item, sdpErr := c.azureGalleryApplicationToSDPItem(galleryApplication, galleryName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c computeGalleryApplicationWrapper) azureGalleryApplicationToSDPItem( galleryApplication *armcompute.GalleryApplication, galleryName, scope string, ) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(galleryApplication, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if galleryApplication.Name == nil { return nil, azureshared.QueryError(errors.New("gallery application name is nil"), scope, c.Type()) } galleryApplicationName := *galleryApplication.Name if galleryApplicationName == "" { return nil, azureshared.QueryError(errors.New("gallery application name cannot be empty"), scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(galleryName, galleryApplicationName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) // Parent Gallery: application depends on gallery linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGallery.String(), Method: sdp.QueryMethod_GET, Query: galleryName, Scope: scope, }, }) // Child: list gallery application versions under this application (Search by gallery name + application name) linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGalleryApplicationVersion.String(), Method: sdp.QueryMethod_SEARCH, Query: shared.CompositeLookupKey(galleryName, galleryApplicationName), Scope: scope, }, }) // URI-based links: Eula, PrivacyStatementURI, ReleaseNoteURI linkedDNSHostnames := make(map[string]struct{}) seenIPs := make(map[string]struct{}) if galleryApplication.Properties != nil { if galleryApplication.Properties.Eula != nil && *galleryApplication.Properties.Eula != "" { AppendURILinks(&linkedItemQueries, *galleryApplication.Properties.Eula, linkedDNSHostnames, seenIPs) } if galleryApplication.Properties.PrivacyStatementURI != nil && *galleryApplication.Properties.PrivacyStatementURI != "" { AppendURILinks(&linkedItemQueries, *galleryApplication.Properties.PrivacyStatementURI, linkedDNSHostnames, seenIPs) } if galleryApplication.Properties.ReleaseNoteURI != nil && *galleryApplication.Properties.ReleaseNoteURI != "" { AppendURILinks(&linkedItemQueries, *galleryApplication.Properties.ReleaseNoteURI, linkedDNSHostnames, seenIPs) } } sdpItem := &sdp.Item{ Type: azureshared.ComputeGalleryApplication.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(galleryApplication.Tags), LinkedItemQueries: linkedItemQueries, } return sdpItem, nil } func (c computeGalleryApplicationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeGalleryLookupByName, ComputeGalleryApplicationLookupByName, } } func (c computeGalleryApplicationWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeGalleryLookupByName, }, } } func (c computeGalleryApplicationWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeGallery, azureshared.ComputeGalleryApplicationVersion, stdlib.NetworkDNS, stdlib.NetworkHTTP, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/gallery_application func (c computeGalleryApplicationWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_gallery_application.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeGalleryApplicationWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/galleries/applications/read", } } func (c computeGalleryApplicationWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-gallery-application_test.go ================================================ package manual import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockGalleryApplicationsPager is a mock pager for ListByGallery. type mockGalleryApplicationsPager struct { pages []armcompute.GalleryApplicationsClientListByGalleryResponse index int } func (m *mockGalleryApplicationsPager) More() bool { return m.index < len(m.pages) } func (m *mockGalleryApplicationsPager) NextPage(ctx context.Context) (armcompute.GalleryApplicationsClientListByGalleryResponse, error) { if m.index >= len(m.pages) { return armcompute.GalleryApplicationsClientListByGalleryResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorGalleryApplicationsPager is a mock pager that always returns an error. type errorGalleryApplicationsPager struct{} func (e *errorGalleryApplicationsPager) More() bool { return true } func (e *errorGalleryApplicationsPager) NextPage(ctx context.Context) (armcompute.GalleryApplicationsClientListByGalleryResponse, error) { return armcompute.GalleryApplicationsClientListByGalleryResponse{}, errors.New("pager error") } // testGalleryApplicationsClient wraps the mock and returns a pager from NewListByGalleryPager. type testGalleryApplicationsClient struct { *mocks.MockGalleryApplicationsClient pager clients.GalleryApplicationsPager } func (t *testGalleryApplicationsClient) NewListByGalleryPager(resourceGroupName, galleryName string, options *armcompute.GalleryApplicationsClientListByGalleryOptions) clients.GalleryApplicationsPager { if t.pager != nil { return t.pager } return t.MockGalleryApplicationsClient.NewListByGalleryPager(resourceGroupName, galleryName, options) } func createAzureGalleryApplication(applicationName string) *armcompute.GalleryApplication { return &armcompute.GalleryApplication{ Name: new(applicationName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.GalleryApplicationProperties{ SupportedOSType: to.Ptr(armcompute.OperatingSystemTypesWindows), Description: new("Test gallery application"), }, } } func TestComputeGalleryApplication(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup galleryName := "test-gallery" galleryApplicationName := "test-application" t.Run("Get", func(t *testing.T) { app := createAzureGalleryApplication(galleryApplicationName) mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryApplicationName, nil).Return( armcompute.GalleryApplicationsClientGetResponse{ GalleryApplication: *app, }, nil) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryApplicationName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeGalleryApplication.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeGalleryApplication.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(galleryName, galleryApplicationName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag env=test, got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeGallery.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: galleryName, ExpectedScope: scope}, {ExpectedType: azureshared.ComputeGalleryApplicationVersion.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(galleryName, galleryApplicationName), ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, galleryName, true) if qErr == nil { t.Error("Expected error when Get with wrong number of query parts, but got nil") } }) t.Run("Get_EmptyGalleryName", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", galleryApplicationName) _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when gallery name is empty, but got nil") } }) t.Run("Get_EmptyApplicationName", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, "") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when gallery application name is empty, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("application not found") mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, "nonexistent", nil).Return( armcompute.GalleryApplicationsClientGetResponse{}, expectedErr) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { app1 := createAzureGalleryApplication("app-1") app2 := createAzureGalleryApplication("app-2") mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) pages := []armcompute.GalleryApplicationsClientListByGalleryResponse{ { GalleryApplicationList: armcompute.GalleryApplicationList{ Value: []*armcompute.GalleryApplication{app1, app2}, }, }, } mockPager := &mockGalleryApplicationsPager{pages: pages} testClient := &testGalleryApplicationsClient{ MockGalleryApplicationsClient: mockClient, pager: mockPager, } wrapper := NewComputeGalleryApplication(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, galleryName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := shared.CompositeLookupKey(galleryName, galleryApplicationName) _, err := searchable.Search(ctx, scope, searchQuery, true) if err == nil { t.Error("Expected error when Search with wrong number of query parts, but got nil") } }) t.Run("Search_EmptyGalleryName", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, "") if qErr == nil { t.Error("Expected error when gallery name is empty, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) errorPager := &errorGalleryApplicationsPager{} testClient := &testGalleryApplicationsClient{ MockGalleryApplicationsClient: mockClient, pager: errorPager, } wrapper := NewComputeGalleryApplication(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, scope, galleryName, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() expected := map[shared.ItemType]bool{ azureshared.ComputeGallery: true, azureshared.ComputeGalleryApplicationVersion: true, stdlib.NetworkDNS: true, stdlib.NetworkHTTP: true, stdlib.NetworkIP: true, } for itemType, want := range expected { if got := links[itemType]; got != want { t.Errorf("PotentialLinks()[%v] = %v, want %v", itemType, got, want) } } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockGalleryApplicationsClient(ctrl) wrapper := NewComputeGalleryApplication(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/compute-gallery-image.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeGalleryImageLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeGalleryImage) type computeGalleryImageWrapper struct { client clients.GalleryImagesClient *azureshared.MultiResourceGroupBase } func NewComputeGalleryImage(client clients.GalleryImagesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeGalleryImageWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeGalleryImage, ), } } func (c computeGalleryImageWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 2 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 2 and be the gallery name and gallery image name"), scope, c.Type()) } galleryName := queryParts[0] if galleryName == "" { return nil, azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type()) } galleryImageName := queryParts[1] if galleryImageName == "" { return nil, azureshared.QueryError(errors.New("gallery image name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, galleryName, galleryImageName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureGalleryImageToSDPItem(&resp.GalleryImage, galleryName, scope) } func (c computeGalleryImageWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the gallery name"), scope, c.Type()) } galleryName := queryParts[0] if galleryName == "" { return nil, azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByGalleryPager(rgScope.ResourceGroup, galleryName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, galleryImage := range page.Value { if galleryImage == nil || galleryImage.Name == nil { continue } item, sdpErr := c.azureGalleryImageToSDPItem(galleryImage, galleryName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeGalleryImageWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the gallery name"), scope, c.Type())) return } galleryName := queryParts[0] if galleryName == "" { stream.SendError(azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByGalleryPager(rgScope.ResourceGroup, galleryName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, galleryImage := range page.Value { if galleryImage == nil || galleryImage.Name == nil { continue } item, sdpErr := c.azureGalleryImageToSDPItem(galleryImage, galleryName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c computeGalleryImageWrapper) azureGalleryImageToSDPItem( galleryImage *armcompute.GalleryImage, galleryName, scope string, ) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(galleryImage, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if galleryImage.Name == nil { return nil, azureshared.QueryError(errors.New("gallery image name is nil"), scope, c.Type()) } galleryImageName := *galleryImage.Name if galleryImageName == "" { return nil, azureshared.QueryError(errors.New("gallery image name cannot be empty"), scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(galleryName, galleryImageName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) // Parent Gallery: image definition depends on gallery linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGallery.String(), Method: sdp.QueryMethod_GET, Query: galleryName, Scope: scope, }, }) // URI-based links: Eula, PrivacyStatementURI, ReleaseNoteURI linkedDNSHostnames := make(map[string]struct{}) seenIPs := make(map[string]struct{}) if galleryImage.Properties != nil { if galleryImage.Properties.Eula != nil { AppendURILinks(&linkedItemQueries, *galleryImage.Properties.Eula, linkedDNSHostnames, seenIPs) } if galleryImage.Properties.PrivacyStatementURI != nil { AppendURILinks(&linkedItemQueries, *galleryImage.Properties.PrivacyStatementURI, linkedDNSHostnames, seenIPs) } if galleryImage.Properties.ReleaseNoteURI != nil { AppendURILinks(&linkedItemQueries, *galleryImage.Properties.ReleaseNoteURI, linkedDNSHostnames, seenIPs) } } sdpItem := &sdp.Item{ Type: azureshared.ComputeGalleryImage.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(galleryImage.Tags), LinkedItemQueries: linkedItemQueries, } return sdpItem, nil } func (c computeGalleryImageWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeGalleryLookupByName, ComputeGalleryImageLookupByName, } } func (c computeGalleryImageWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeGalleryLookupByName, }, } } func (c computeGalleryImageWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeGallery, stdlib.NetworkDNS, stdlib.NetworkHTTP, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/shared_image func (c computeGalleryImageWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // example id: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Compute/galleries/gallery1/images/image1 TerraformQueryMap: "azurerm_shared_image.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeGalleryImageWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/galleries/images/read", } } func (c computeGalleryImageWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-gallery-image_test.go ================================================ package manual import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockGalleryImagesPager is a mock pager for ListByGallery. type mockGalleryImagesPager struct { pages []armcompute.GalleryImagesClientListByGalleryResponse index int } func (m *mockGalleryImagesPager) More() bool { return m.index < len(m.pages) } func (m *mockGalleryImagesPager) NextPage(ctx context.Context) (armcompute.GalleryImagesClientListByGalleryResponse, error) { if m.index >= len(m.pages) { return armcompute.GalleryImagesClientListByGalleryResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorGalleryImagesPager is a mock pager that always returns an error. type errorGalleryImagesPager struct{} func (e *errorGalleryImagesPager) More() bool { return true } func (e *errorGalleryImagesPager) NextPage(ctx context.Context) (armcompute.GalleryImagesClientListByGalleryResponse, error) { return armcompute.GalleryImagesClientListByGalleryResponse{}, errors.New("pager error") } // testGalleryImagesClient wraps the mock and returns a pager from NewListByGalleryPager. type testGalleryImagesClient struct { *mocks.MockGalleryImagesClient pager clients.GalleryImagesPager } // NewListByGalleryPager returns the test pager so we don't need to mock this call. func (t *testGalleryImagesClient) NewListByGalleryPager(resourceGroupName, galleryName string, options *armcompute.GalleryImagesClientListByGalleryOptions) clients.GalleryImagesPager { if t.pager != nil { return t.pager } return t.MockGalleryImagesClient.NewListByGalleryPager(resourceGroupName, galleryName, options) } func createAzureGalleryImage(imageName string) *armcompute.GalleryImage { return &armcompute.GalleryImage{ Name: new(imageName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.GalleryImageProperties{ Identifier: &armcompute.GalleryImageIdentifier{ Publisher: new("test-publisher"), Offer: new("test-offer"), SKU: new("test-sku"), }, OSType: new(armcompute.OperatingSystemTypesLinux), OSState: new(armcompute.OperatingSystemStateTypesGeneralized), }, } } func createAzureGalleryImageWithURIs(imageName string) *armcompute.GalleryImage { img := createAzureGalleryImage(imageName) img.Properties.Eula = new("https://eula.example.com/terms") img.Properties.PrivacyStatementURI = new("https://example.com/privacy") img.Properties.ReleaseNoteURI = new("https://releases.example.com/notes") return img } func TestComputeGalleryImage(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup galleryName := "test-gallery" galleryImageName := "test-image" t.Run("Get", func(t *testing.T) { image := createAzureGalleryImage(galleryImageName) mockClient := mocks.NewMockGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryImageName, nil).Return( armcompute.GalleryImagesClientGetResponse{ GalleryImage: *image, }, nil) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryImageName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeGalleryImage.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeGalleryImage.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(galleryName, galleryImageName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag env=test, got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeGallery.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: galleryName, ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithURIs", func(t *testing.T) { image := createAzureGalleryImageWithURIs(galleryImageName) mockClient := mocks.NewMockGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryImageName, nil).Return( armcompute.GalleryImagesClientGetResponse{ GalleryImage: *image, }, nil) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryImageName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeGallery.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: galleryName, ExpectedScope: scope}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://eula.example.com/terms", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "eula.example.com", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://example.com/privacy", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "example.com", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://releases.example.com/notes", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "releases.example.com", ExpectedScope: "global"}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_PlainTextEula_NoLinks", func(t *testing.T) { image := createAzureGalleryImage(galleryImageName) image.Properties.Eula = new("This software is provided as-is. No warranty.") mockClient := mocks.NewMockGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryImageName, nil).Return( armcompute.GalleryImagesClientGetResponse{ GalleryImage: *image, }, nil) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryImageName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Plain-text Eula should not generate HTTP/DNS/IP links for _, q := range sdpItem.GetLinkedItemQueries() { lq := q.GetQuery() if lq == nil { continue } typ := lq.GetType() if typ == stdlib.NetworkHTTP.String() || typ == stdlib.NetworkDNS.String() || typ == stdlib.NetworkIP.String() { t.Errorf("Plain-text Eula must not create network links; found linked query type %s with query %s", typ, lq.GetQuery()) } } }) t.Run("Get_SameHostDeduplication", func(t *testing.T) { image := createAzureGalleryImage(galleryImageName) image.Properties.PrivacyStatementURI = new("https://example.com/privacy") image.Properties.ReleaseNoteURI = new("https://example.com/release-notes") mockClient := mocks.NewMockGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryImageName, nil).Return( armcompute.GalleryImagesClientGetResponse{ GalleryImage: *image, }, nil) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryImageName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should have 2 HTTP links (one per URI) but only 1 DNS link (same hostname) httpCount := 0 dnsCount := 0 for _, q := range sdpItem.GetLinkedItemQueries() { query := q.GetQuery() if query != nil { if query.GetType() == stdlib.NetworkHTTP.String() { httpCount++ } if query.GetType() == stdlib.NetworkDNS.String() { dnsCount++ } } } if httpCount != 2 { t.Errorf("Expected 2 HTTP links, got %d", httpCount) } if dnsCount != 1 { t.Errorf("Expected 1 DNS link (deduped), got %d", dnsCount) } }) t.Run("Get_IPHost_EmitsIPLink", func(t *testing.T) { image := createAzureGalleryImage(galleryImageName) image.Properties.PrivacyStatementURI = new("https://192.168.1.10:8443/privacy") mockClient := mocks.NewMockGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, galleryImageName, nil).Return( armcompute.GalleryImagesClientGetResponse{ GalleryImage: *image, }, nil) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, galleryImageName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } hasIP := false for _, q := range sdpItem.GetLinkedItemQueries() { query := q.GetQuery() if query != nil && query.GetType() == stdlib.NetworkIP.String() { hasIP = true if query.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected NetworkIP link to use GET, got %v", query.GetMethod()) } if query.GetScope() != "global" { t.Errorf("Expected NetworkIP link scope global, got %s", query.GetScope()) } if query.GetQuery() != "192.168.1.10" { t.Errorf("Expected NetworkIP link query 192.168.1.10, got %s", query.GetQuery()) } break } } if !hasIP { t.Error("Expected NetworkIP linked query when PrivacyStatementURI host is an IP address") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Adapter expects query to split into 2 parts (gallery, image); single part is invalid _, qErr := adapter.Get(ctx, scope, galleryName, true) if qErr == nil { t.Error("Expected error when Get with wrong number of query parts, but got nil") } }) t.Run("Get_EmptyGalleryName", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", galleryImageName) _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when gallery name is empty, but got nil") } }) t.Run("Get_EmptyImageName", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, "") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when image name is empty, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("image not found") mockClient := mocks.NewMockGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, "nonexistent", nil).Return( armcompute.GalleryImagesClientGetResponse{}, expectedErr) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(galleryName, "nonexistent") _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { img1 := createAzureGalleryImage("image-1") img2 := createAzureGalleryImage("image-2") mockClient := mocks.NewMockGalleryImagesClient(ctrl) pages := []armcompute.GalleryImagesClientListByGalleryResponse{ { GalleryImageList: armcompute.GalleryImageList{ Value: []*armcompute.GalleryImage{img1, img2}, }, }, } mockPager := &mockGalleryImagesPager{pages: pages} testClient := &testGalleryImagesClient{ MockGalleryImagesClient: mockClient, pager: mockPager, } wrapper := NewComputeGalleryImage(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, scope, galleryName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } // Search expects exactly 1 query part; giving 2 is invalid searchQuery := shared.CompositeLookupKey(galleryName, galleryImageName) _, err := searchable.Search(ctx, scope, searchQuery, true) if err == nil { t.Error("Expected error when Search with wrong number of query parts, but got nil") } }) t.Run("Search_EmptyGalleryName", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, scope, "") if qErr == nil { t.Error("Expected error when gallery name is empty, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) errorPager := &errorGalleryImagesPager{} testClient := &testGalleryImagesClient{ MockGalleryImagesClient: mockClient, pager: errorPager, } wrapper := NewComputeGalleryImage(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, scope, galleryName, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() expected := map[shared.ItemType]bool{ azureshared.ComputeGallery: true, stdlib.NetworkDNS: true, stdlib.NetworkHTTP: true, stdlib.NetworkIP: true, } for itemType, want := range expected { if got := links[itemType]; got != want { t.Errorf("PotentialLinks()[%v] = %v, want %v", itemType, got, want) } } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockGalleryImagesClient(ctrl) wrapper := NewComputeGalleryImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } ================================================ FILE: sources/azure/manual/compute-gallery.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeGalleryLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeGallery) type computeGalleryWrapper struct { client clients.GalleriesClient *azureshared.MultiResourceGroupBase } func NewComputeGallery(client clients.GalleriesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeGalleryWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeGallery, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/galleries/list-by-resource-group func (c computeGalleryWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, gallery := range page.Value { if gallery == nil || gallery.Name == nil { continue } item, sdpErr := c.azureGalleryToSDPItem(gallery, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeGalleryWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, gallery := range page.Value { if gallery == nil || gallery.Name == nil { continue } item, sdpErr := c.azureGalleryToSDPItem(gallery, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/galleries/get func (c computeGalleryWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the gallery name"), scope, c.Type()) } galleryName := queryParts[0] if galleryName == "" { return nil, azureshared.QueryError(errors.New("gallery name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, galleryName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureGalleryToSDPItem(&resp.Gallery, scope) } func (c computeGalleryWrapper) azureGalleryToSDPItem(gallery *armcompute.Gallery, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(gallery, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if gallery.Name == nil { return nil, azureshared.QueryError(errors.New("gallery name is nil"), scope, c.Type()) } galleryName := *gallery.Name linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) // Child resources: list gallery images under this gallery (Search by gallery name) linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGalleryImage.String(), Method: sdp.QueryMethod_SEARCH, Query: galleryName, Scope: scope, }, }) // Child resources: list gallery applications under this gallery (Search by gallery name) linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGalleryApplication.String(), Method: sdp.QueryMethod_SEARCH, Query: galleryName, Scope: scope, }, }) // URI-based links from community gallery info: PublisherURI, Eula linkedDNSHostnames := make(map[string]struct{}) seenIPs := make(map[string]struct{}) if gallery.Properties != nil && gallery.Properties.SharingProfile != nil && gallery.Properties.SharingProfile.CommunityGalleryInfo != nil { info := gallery.Properties.SharingProfile.CommunityGalleryInfo if info.PublisherURI != nil { AppendURILinks(&linkedItemQueries, *info.PublisherURI, linkedDNSHostnames, seenIPs) } if info.Eula != nil { AppendURILinks(&linkedItemQueries, *info.Eula, linkedDNSHostnames, seenIPs) } } sdpItem := &sdp.Item{ Type: azureshared.ComputeGallery.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(gallery.Tags), LinkedItemQueries: linkedItemQueries, } // Health status from ProvisioningState if gallery.Properties != nil && gallery.Properties.ProvisioningState != nil { switch *gallery.Properties.ProvisioningState { case armcompute.GalleryProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armcompute.GalleryProvisioningStateCreating, armcompute.GalleryProvisioningStateUpdating, armcompute.GalleryProvisioningStateDeleting, armcompute.GalleryProvisioningStateMigrating: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armcompute.GalleryProvisioningStateFailed: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return sdpItem, nil } func (c computeGalleryWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeGalleryLookupByName, } } func (c computeGalleryWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeGalleryImage, azureshared.ComputeGalleryApplication, stdlib.NetworkDNS, stdlib.NetworkHTTP, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/shared_image_gallery func (c computeGalleryWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_shared_image_gallery.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeGalleryWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/galleries/read", } } func (c computeGalleryWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-gallery_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeGallery(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { galleryName := "test-gallery" gallery := createAzureGallery(galleryName) mockClient := mocks.NewMockGalleriesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, galleryName, nil).Return( armcompute.GalleriesClientGetResponse{ Gallery: *gallery, }, nil) wrapper := manual.NewComputeGallery(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, galleryName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeGallery.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeGallery.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != galleryName { t.Errorf("Expected unique attribute value %s, got %s", galleryName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.ComputeGalleryImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: galleryName, ExpectedScope: scope, }, { ExpectedType: azureshared.ComputeGalleryApplication.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: galleryName, ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { gallery1 := createAzureGallery("test-gallery-1") gallery2 := createAzureGallery("test-gallery-2") mockClient := mocks.NewMockGalleriesClient(ctrl) mockPager := newMockGalleriesPager(ctrl, []*armcompute.Gallery{gallery1, gallery2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeGallery(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { gallery1 := createAzureGallery("test-gallery-1") gallery2 := createAzureGallery("test-gallery-2") mockClient := mocks.NewMockGalleriesClient(ctrl) mockPager := newMockGalleriesPager(ctrl, []*armcompute.Gallery{gallery1, gallery2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeGallery(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListWithNilName", func(t *testing.T) { gallery1 := createAzureGallery("test-gallery-1") galleryNilName := &armcompute.Gallery{ Name: nil, Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockGalleriesClient(ctrl) mockPager := newMockGalleriesPager(ctrl, []*armcompute.Gallery{gallery1, galleryNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeGallery(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("gallery not found") mockClient := mocks.NewMockGalleriesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-gallery", nil).Return( armcompute.GalleriesClientGetResponse{}, expectedErr) wrapper := manual.NewComputeGallery(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "nonexistent-gallery", true) if qErr == nil { t.Error("Expected error when getting non-existent gallery, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockGalleriesClient(ctrl) wrapper := manual.NewComputeGallery(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting gallery with empty name, but got nil") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockGalleriesClient(ctrl) wrapper := manual.NewComputeGallery(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Get(ctx, scope) if qErr == nil { t.Error("Expected error when getting gallery with insufficient query parts, but got nil") } }) } func createAzureGallery(galleryName string) *armcompute.Gallery { return &armcompute.Gallery{ Name: new(galleryName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.GalleryProperties{ Description: new("Test shared image gallery"), Identifier: &armcompute.GalleryIdentifier{ UniqueName: new("unique-" + galleryName), }, ProvisioningState: new(armcompute.GalleryProvisioningStateSucceeded), }, } } type mockGalleriesPager struct { ctrl *gomock.Controller items []*armcompute.Gallery index int more bool } func newMockGalleriesPager(ctrl *gomock.Controller, items []*armcompute.Gallery) clients.GalleriesPager { return &mockGalleriesPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockGalleriesPager) More() bool { return m.more } func (m *mockGalleriesPager) NextPage(ctx context.Context) (armcompute.GalleriesClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.GalleriesClientListByResourceGroupResponse{ GalleryList: armcompute.GalleryList{ Value: []*armcompute.Gallery{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.GalleriesClientListByResourceGroupResponse{ GalleryList: armcompute.GalleryList{ Value: []*armcompute.Gallery{item}, }, }, nil } ================================================ FILE: sources/azure/manual/compute-image.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeImageLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeImage) type computeImageWrapper struct { client clients.ImagesClient *azureshared.MultiResourceGroupBase } func NewComputeImage(client clients.ImagesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListStreamableWrapper { return &computeImageWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeImage, ), } } func (c computeImageWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, image := range page.Value { if image.Name == nil { continue } item, sdpErr := c.azureImageToSDPItem(image, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeImageWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, image := range page.Value { if image.Name == nil { continue } item, sdpErr := c.azureImageToSDPItem(image, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c computeImageWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 1 and be the image name"), scope, c.Type()) } imageName := queryParts[0] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } image, err := c.client.Get(ctx, rgScope.ResourceGroup, imageName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureImageToSDPItem(&image.Image, scope) } func (c computeImageWrapper) azureImageToSDPItem(image *armcompute.Image, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(image, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeImage.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(image.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link resources from StorageProfile if image.Properties != nil && image.Properties.StorageProfile != nil { storageProfile := image.Properties.StorageProfile // Link to OS Disk resources if storageProfile.OSDisk != nil { osDisk := storageProfile.OSDisk // Link to Managed Disk from OSDisk.ManagedDisk.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disks/get if osDisk.ManagedDisk != nil && osDisk.ManagedDisk.ID != nil && *osDisk.ManagedDisk.ID != "" { diskName := azureshared.ExtractResourceName(*osDisk.ManagedDisk.ID) if diskName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*osDisk.ManagedDisk.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: extractedScope, }, }) } } // Link to Snapshot from OSDisk.Snapshot.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/snapshots/get if osDisk.Snapshot != nil && osDisk.Snapshot.ID != nil && *osDisk.Snapshot.ID != "" { snapshotName := azureshared.ExtractResourceName(*osDisk.Snapshot.ID) if snapshotName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*osDisk.Snapshot.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: extractedScope, }, }) } } // Link to Storage Account from OSDisk.BlobUri // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/storage-accounts/get-properties if osDisk.BlobURI != nil && *osDisk.BlobURI != "" { blobURI := *osDisk.BlobURI storageAccountName := azureshared.ExtractStorageAccountNameFromBlobURI(blobURI) if storageAccountName != "" { // For blob URIs, we use the current scope since storage accounts are typically in the same subscription // If the blob URI contains resource ID information, we could extract it, but blob URIs typically don't sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) } // Link to stdlib.NetworkHTTP if blob URI is HTTP/HTTPS if strings.HasPrefix(blobURI, "http://") || strings.HasPrefix(blobURI, "https://") { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: blobURI, Scope: "global", }, }) } // Link to DNS name (standard library) from BlobURI dnsName := azureshared.ExtractDNSFromURL(blobURI) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } // Link to Disk Encryption Set from OSDisk.DiskEncryptionSet.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if osDisk.DiskEncryptionSet != nil && osDisk.DiskEncryptionSet.ID != nil && *osDisk.DiskEncryptionSet.ID != "" { encryptionSetName := azureshared.ExtractResourceName(*osDisk.DiskEncryptionSet.ID) if encryptionSetName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*osDisk.DiskEncryptionSet.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } } // Link to Data Disk resources if storageProfile.DataDisks != nil { for _, dataDisk := range storageProfile.DataDisks { if dataDisk == nil { continue } // Link to Managed Disk from DataDisk.ManagedDisk.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disks/get if dataDisk.ManagedDisk != nil && dataDisk.ManagedDisk.ID != nil && *dataDisk.ManagedDisk.ID != "" { diskName := azureshared.ExtractResourceName(*dataDisk.ManagedDisk.ID) if diskName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*dataDisk.ManagedDisk.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: extractedScope, }, }) } } // Link to Snapshot from DataDisk.Snapshot.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/snapshots/get if dataDisk.Snapshot != nil && dataDisk.Snapshot.ID != nil && *dataDisk.Snapshot.ID != "" { snapshotName := azureshared.ExtractResourceName(*dataDisk.Snapshot.ID) if snapshotName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*dataDisk.Snapshot.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: extractedScope, }, }) } } // Link to Storage Account from DataDisk.BlobUri // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/storage-accounts/get-properties if dataDisk.BlobURI != nil && *dataDisk.BlobURI != "" { blobURI := *dataDisk.BlobURI storageAccountName := azureshared.ExtractStorageAccountNameFromBlobURI(blobURI) if storageAccountName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) } // Link to stdlib.NetworkHTTP if blob URI is HTTP/HTTPS if strings.HasPrefix(blobURI, "http://") || strings.HasPrefix(blobURI, "https://") { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: blobURI, Scope: "global", }, }) } // Link to DNS name (standard library) from BlobURI dnsName := azureshared.ExtractDNSFromURL(blobURI) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } // Link to Disk Encryption Set from DataDisk.DiskEncryptionSet.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if dataDisk.DiskEncryptionSet != nil && dataDisk.DiskEncryptionSet.ID != nil && *dataDisk.DiskEncryptionSet.ID != "" { encryptionSetName := azureshared.ExtractResourceName(*dataDisk.DiskEncryptionSet.ID) if encryptionSetName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*dataDisk.DiskEncryptionSet.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } } } } // Link to Source Virtual Machine from Properties.SourceVirtualMachine.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get if image.Properties != nil && image.Properties.SourceVirtualMachine != nil && image.Properties.SourceVirtualMachine.ID != nil && *image.Properties.SourceVirtualMachine.ID != "" { vmName := azureshared.ExtractResourceName(*image.Properties.SourceVirtualMachine.ID) if vmName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*image.Properties.SourceVirtualMachine.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: extractedScope, }, }) } } return sdpItem, nil } func (c computeImageWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeImageLookupByName, } } func (c computeImageWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeDisk, azureshared.ComputeSnapshot, azureshared.ComputeDiskEncryptionSet, azureshared.ComputeVirtualMachine, azureshared.StorageAccount, stdlib.NetworkHTTP, stdlib.NetworkDNS, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/image func (c computeImageWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_image.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeImageWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/images/read", } } func (c computeImageWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-image_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeImage(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { imageName := "test-image" image := createAzureImage(imageName) mockClient := mocks.NewMockImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, imageName, nil).Return( armcompute.ImagesClientGetResponse{ Image: *image, }, nil) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], imageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeImage.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeImage, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != imageName { t.Errorf("Expected unique attribute value %s, got %s", imageName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } }) t.Run("GetWithAllLinkedResources", func(t *testing.T) { imageName := "test-image" image := createAzureImageWithAllLinks(imageName, subscriptionID, resourceGroup) mockClient := mocks.NewMockImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, imageName, nil).Return( armcompute.ImagesClientGetResponse{ Image: *image, }, nil) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], imageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // OSDisk.ManagedDisk.ID - Compute Disk ExpectedType: azureshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-os-disk", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // OSDisk.Snapshot.ID - Compute Snapshot ExpectedType: azureshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-os-snapshot", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // OSDisk.BlobURI - Storage Account ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "teststorageaccount", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // OSDisk.BlobURI - NetworkHTTP ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://teststorageaccount.blob.core.windows.net/vhds/osdisk.vhd", ExpectedScope: "global", }, { // OSDisk.BlobURI - NetworkDNS ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "teststorageaccount.blob.core.windows.net", ExpectedScope: "global", }, { // OSDisk.DiskEncryptionSet.ID - Disk Encryption Set ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-os-disk-encryption-set", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DataDisks[0].ManagedDisk.ID - Compute Disk ExpectedType: azureshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-data-disk-1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DataDisks[0].Snapshot.ID - Compute Snapshot ExpectedType: azureshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-data-snapshot-1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DataDisks[0].BlobURI - Storage Account ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "teststorageaccount2", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DataDisks[0].BlobURI - NetworkHTTP ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://teststorageaccount2.blob.core.windows.net/vhds/datadisk1.vhd", ExpectedScope: "global", }, { // DataDisks[0].BlobURI - NetworkDNS ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "teststorageaccount2.blob.core.windows.net", ExpectedScope: "global", }, { // DataDisks[0].DiskEncryptionSet.ID - Disk Encryption Set ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-data-disk-encryption-set", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // SourceVirtualMachine.ID - Virtual Machine ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-source-vm", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithCrossResourceGroupLinks", func(t *testing.T) { imageName := "test-image-cross-rg" image := createAzureImageWithCrossResourceGroupLinks(imageName, subscriptionID, resourceGroup) mockClient := mocks.NewMockImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, imageName, nil).Return( armcompute.ImagesClientGetResponse{ Image: *image, }, nil) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], imageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that links to resources in different resource groups use the correct scope foundCrossRGLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.ComputeDisk.String() && linkedQuery.GetQuery().GetQuery() == "test-disk-other-rg" { foundCrossRGLink = true expectedScope := subscriptionID + ".other-rg" if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected scope %s for cross-RG link, got %s", expectedScope, linkedQuery.GetQuery().GetScope()) } break } } if !foundCrossRGLink { t.Error("Expected cross-resource-group link not found") } }) t.Run("List", func(t *testing.T) { image1 := createAzureImage("test-image-1") image2 := createAzureImage("test-image-2") mockClient := mocks.NewMockImagesClient(ctrl) mockPager := newMockImagesPager(ctrl, []*armcompute.Image{image1, image2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { image1 := createAzureImage("test-image-1") image2 := createAzureImage("test-image-2") mockClient := mocks.NewMockImagesClient(ctrl) mockPager := newMockImagesPager(ctrl, []*armcompute.Image{image1, image2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListWithNilName", func(t *testing.T) { image1 := createAzureImage("test-image-1") imageNilName := &armcompute.Image{ Name: nil, // nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockImagesClient(ctrl) mockPager := newMockImagesPager(ctrl, []*armcompute.Image{image1, imageNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("image not found") mockClient := mocks.NewMockImagesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-image", nil).Return( armcompute.ImagesClientGetResponse{}, expectedErr) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-image", true) if qErr == nil { t.Error("Expected error when getting non-existent image, but got nil") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test the wrapper's Get method directly with insufficient query parts _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when getting image with insufficient query parts, but got nil") } }) t.Run("ListWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) errorPager := newErrorImagesPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("ListStreamWithPagerError", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) errorPager := newErrorImagesPager(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(item *sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) if len(errs) == 0 { t.Error("Expected error when pager returns error, but got none") } }) t.Run("GetLookups", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) != 1 { t.Errorf("Expected 1 lookup, got %d", len(lookups)) } // Verify the lookup is for name if lookups[0].By != "name" { t.Errorf("Expected lookup attribute 'name', got %s", lookups[0].By) } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() expectedLinks := []shared.ItemType{ azureshared.ComputeDisk, azureshared.ComputeSnapshot, azureshared.ComputeDiskEncryptionSet, azureshared.ComputeVirtualMachine, azureshared.StorageAccount, stdlib.NetworkHTTP, stdlib.NetworkDNS, } for _, expectedLink := range expectedLinks { if !potentialLinks[expectedLink] { t.Errorf("Expected potential link %s to be true", expectedLink) } } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) != 1 { t.Errorf("Expected 1 terraform mapping, got %d", len(mappings)) } if mappings[0].GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected terraform method GET, got %v", mappings[0].GetTerraformMethod()) } if mappings[0].GetTerraformQueryMap() != "azurerm_image.name" { t.Errorf("Expected terraform query map 'azurerm_image.name', got %s", mappings[0].GetTerraformQueryMap()) } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() expectedPermissions := []string{ "Microsoft.Compute/images/read", } if len(permissions) != len(expectedPermissions) { t.Errorf("Expected %d permissions, got %d", len(expectedPermissions), len(permissions)) } for i, expected := range expectedPermissions { if permissions[i] != expected { t.Errorf("Expected permission %s, got %s", expected, permissions[i]) } } }) t.Run("PredefinedRole", func(t *testing.T) { mockClient := mocks.NewMockImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // PredefinedRole is available on the wrapper, not the adapter if roleInterface, ok := any(wrapper).(interface{ PredefinedRole() string }); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected predefined role 'Reader', got %s", role) } } else { t.Error("Wrapper does not implement PredefinedRole method") } }) } // createAzureImage creates a mock Azure Image for testing func createAzureImage(imageName string) *armcompute.Image { return &armcompute.Image{ Name: new(imageName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.ImageProperties{ ProvisioningState: new("Succeeded"), }, } } // createAzureImageWithAllLinks creates a mock Azure Image with all possible linked resources func createAzureImageWithAllLinks(imageName, subscriptionID, resourceGroup string) *armcompute.Image { osDiskBlobURI := "https://teststorageaccount.blob.core.windows.net/vhds/osdisk.vhd" dataDiskBlobURI := "https://teststorageaccount2.blob.core.windows.net/vhds/datadisk1.vhd" return &armcompute.Image{ Name: new(imageName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.ImageProperties{ ProvisioningState: new("Succeeded"), StorageProfile: &armcompute.ImageStorageProfile{ OSDisk: &armcompute.ImageOSDisk{ OSType: new(armcompute.OperatingSystemTypesLinux), OSState: new(armcompute.OperatingSystemStateTypesGeneralized), ManagedDisk: &armcompute.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/disks/test-os-disk"), }, Snapshot: &armcompute.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/snapshots/test-os-snapshot"), }, BlobURI: new(osDiskBlobURI), DiskEncryptionSet: &armcompute.DiskEncryptionSetParameters{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskEncryptionSets/test-os-disk-encryption-set"), }, }, DataDisks: []*armcompute.ImageDataDisk{ { Lun: new(int32(0)), ManagedDisk: &armcompute.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/disks/test-data-disk-1"), }, Snapshot: &armcompute.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/snapshots/test-data-snapshot-1"), }, BlobURI: new(dataDiskBlobURI), DiskEncryptionSet: &armcompute.DiskEncryptionSetParameters{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskEncryptionSets/test-data-disk-encryption-set"), }, }, }, }, SourceVirtualMachine: &armcompute.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/virtualMachines/test-source-vm"), }, }, } } // createAzureImageWithCrossResourceGroupLinks creates a mock Azure Image with links to resources in different resource groups func createAzureImageWithCrossResourceGroupLinks(imageName, subscriptionID, resourceGroup string) *armcompute.Image { return &armcompute.Image{ Name: new(imageName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.ImageProperties{ ProvisioningState: new("Succeeded"), StorageProfile: &armcompute.ImageStorageProfile{ OSDisk: &armcompute.ImageOSDisk{ OSType: new(armcompute.OperatingSystemTypesLinux), OSState: new(armcompute.OperatingSystemStateTypesGeneralized), ManagedDisk: &armcompute.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/Microsoft.Compute/disks/test-disk-other-rg"), }, }, }, }, } } // mockImagesPager is a simple mock implementation of the Pager interface for testing type mockImagesPager struct { items []*armcompute.Image index int more bool } func newMockImagesPager(ctrl *gomock.Controller, items []*armcompute.Image) clients.ImagesPager { return &mockImagesPager{ items: items, index: 0, more: len(items) > 0, } } func (m *mockImagesPager) More() bool { return m.more } func (m *mockImagesPager) NextPage(ctx context.Context) (armcompute.ImagesClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.ImagesClientListByResourceGroupResponse{ ImageListResult: armcompute.ImageListResult{ Value: []*armcompute.Image{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.ImagesClientListByResourceGroupResponse{ ImageListResult: armcompute.ImageListResult{ Value: []*armcompute.Image{item}, }, }, nil } // errorImagesPager is a mock pager that always returns an error type errorImagesPager struct{} func newErrorImagesPager(ctrl *gomock.Controller) clients.ImagesPager { return &errorImagesPager{} } func (e *errorImagesPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorImagesPager) NextPage(ctx context.Context) (armcompute.ImagesClientListByResourceGroupResponse, error) { return armcompute.ImagesClientListByResourceGroupResponse{}, errors.New("pager error") } ================================================ FILE: sources/azure/manual/compute-proximity-placement-group.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeProximityPlacementGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeProximityPlacementGroup) type computeProximityPlacementGroupWrapper struct { client clients.ProximityPlacementGroupsClient *azureshared.MultiResourceGroupBase } func NewComputeProximityPlacementGroup(client clients.ProximityPlacementGroupsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeProximityPlacementGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeProximityPlacementGroup, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/proximity-placement-groups/list-by-resource-group?view=rest-compute-2025-04-01&tabs=HTTP func (c computeProximityPlacementGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.ListByResourceGroup(ctx, rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, proximityPlacementGroup := range page.Value { if proximityPlacementGroup.Name == nil { continue } item, sdpErr := c.azureProximityPlacementGroupToSDPItem(proximityPlacementGroup, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeProximityPlacementGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.ListByResourceGroup(ctx, rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, proximityPlacementGroup := range page.Value { if proximityPlacementGroup.Name == nil { continue } item, sdpErr := c.azureProximityPlacementGroupToSDPItem(proximityPlacementGroup, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/proximity-placement-groups/get?view=rest-compute-2025-04-01&tabs=HTTP func (c computeProximityPlacementGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the proximity placement group name"), scope, c.Type()) } proximityPlacementGroupName := queryParts[0] resp, err := c.client.Get(ctx, rgScope.ResourceGroup, proximityPlacementGroupName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureProximityPlacementGroupToSDPItem(&resp.ProximityPlacementGroup, scope) } func (c computeProximityPlacementGroupWrapper) azureProximityPlacementGroupToSDPItem(proximityPlacementGroup *armcompute.ProximityPlacementGroup, scope string) (*sdp.Item, *sdp.QueryError) { if proximityPlacementGroup.Name == nil { return nil, azureshared.QueryError(errors.New("proximityPlacementGroupName is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(proximityPlacementGroup, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeProximityPlacementGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(proximityPlacementGroup.Tags), } // Link to Virtual Machines in the proximity placement group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get if proximityPlacementGroup.Properties != nil && proximityPlacementGroup.Properties.VirtualMachines != nil { for _, ref := range proximityPlacementGroup.Properties.VirtualMachines { if ref != nil && ref.ID != nil { vmName := azureshared.ExtractResourceName(*ref.ID) if vmName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*ref.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: linkedScope, }, }) } } } } // Link to Availability Sets in the proximity placement group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/availability-sets/get if proximityPlacementGroup.Properties != nil && proximityPlacementGroup.Properties.AvailabilitySets != nil { for _, ref := range proximityPlacementGroup.Properties.AvailabilitySets { if ref != nil && ref.ID != nil { avSetName := azureshared.ExtractResourceName(*ref.ID) if avSetName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*ref.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeAvailabilitySet.String(), Method: sdp.QueryMethod_GET, Query: avSetName, Scope: linkedScope, }, }) } } } } // Link to Virtual Machine Scale Sets in the proximity placement group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-scale-sets/get if proximityPlacementGroup.Properties != nil && proximityPlacementGroup.Properties.VirtualMachineScaleSets != nil { for _, ref := range proximityPlacementGroup.Properties.VirtualMachineScaleSets { if ref != nil && ref.ID != nil { vmssName := azureshared.ExtractResourceName(*ref.ID) if vmssName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*ref.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachineScaleSet.String(), Method: sdp.QueryMethod_GET, Query: vmssName, Scope: linkedScope, }, }) } } } } return sdpItem, nil } func (c computeProximityPlacementGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeVirtualMachine: true, azureshared.ComputeAvailabilitySet: true, azureshared.ComputeVirtualMachineScaleSet: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/proximity_placement_group func (c computeProximityPlacementGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_proximity_placement_group.name", }, } } func (c computeProximityPlacementGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeProximityPlacementGroupLookupByName, } } ================================================ FILE: sources/azure/manual/compute-proximity-placement-group_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeProximityPlacementGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { ppgName := "test-ppg" ppg := createAzureProximityPlacementGroup(ppgName, subscriptionID, resourceGroup) mockClient := mocks.NewMockProximityPlacementGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, ppgName, nil).Return( armcompute.ProximityPlacementGroupsClientGetResponse{ ProximityPlacementGroup: *ppg, }, nil) wrapper := manual.NewComputeProximityPlacementGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, ppgName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeProximityPlacementGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeProximityPlacementGroup.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != ppgName { t.Errorf("Expected unique attribute value %s, got %s", ppgName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm", ExpectedScope: scope, }, { ExpectedType: azureshared.ComputeAvailabilitySet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-avset", ExpectedScope: scope, }, { ExpectedType: azureshared.ComputeVirtualMachineScaleSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vmss", ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithCrossResourceGroupLinks", func(t *testing.T) { ppgName := "test-ppg-cross-rg" ppg := createAzureProximityPlacementGroupWithCrossResourceGroupLinks(ppgName, subscriptionID) mockClient := mocks.NewMockProximityPlacementGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, ppgName, nil).Return( armcompute.ProximityPlacementGroupsClientGetResponse{ ProximityPlacementGroup: *ppg, }, nil) wrapper := manual.NewComputeProximityPlacementGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, ppgName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } expectedVMScope := subscriptionID + ".vm-rg" expectedAVSetScope := subscriptionID + ".avset-rg" expectedVMSSScope := subscriptionID + ".vmss-rg" for _, link := range sdpItem.GetLinkedItemQueries() { q := link.GetQuery() switch q.GetType() { case azureshared.ComputeVirtualMachine.String(): if q.GetScope() != expectedVMScope { t.Errorf("Expected VM scope %s, got %s", expectedVMScope, q.GetScope()) } case azureshared.ComputeAvailabilitySet.String(): if q.GetScope() != expectedAVSetScope { t.Errorf("Expected Availability Set scope %s, got %s", expectedAVSetScope, q.GetScope()) } case azureshared.ComputeVirtualMachineScaleSet.String(): if q.GetScope() != expectedVMSSScope { t.Errorf("Expected VMSS scope %s, got %s", expectedVMSSScope, q.GetScope()) } } } }) t.Run("GetWithoutLinks", func(t *testing.T) { ppgName := "test-ppg-no-links" ppg := createAzureProximityPlacementGroupWithoutLinks(ppgName) mockClient := mocks.NewMockProximityPlacementGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, ppgName, nil).Return( armcompute.ProximityPlacementGroupsClientGetResponse{ ProximityPlacementGroup: *ppg, }, nil) wrapper := manual.NewComputeProximityPlacementGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, ppgName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("List", func(t *testing.T) { ppg1 := createAzureProximityPlacementGroup("test-ppg-1", subscriptionID, resourceGroup) ppg2 := createAzureProximityPlacementGroup("test-ppg-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockProximityPlacementGroupsClient(ctrl) mockPager := newMockProximityPlacementGroupsPager(ctrl, []*armcompute.ProximityPlacementGroup{ppg1, ppg2}) mockClient.EXPECT().ListByResourceGroup(ctx, resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeProximityPlacementGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) // ListStream is not implemented for the proximity placement group adapter // (wrapper does not implement ListStreamableWrapper), so no ListStream test. t.Run("ListWithNilName", func(t *testing.T) { ppg1 := createAzureProximityPlacementGroup("test-ppg-1", subscriptionID, resourceGroup) ppgNilName := &armcompute.ProximityPlacementGroup{ Name: nil, Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockProximityPlacementGroupsClient(ctrl) mockPager := newMockProximityPlacementGroupsPager(ctrl, []*armcompute.ProximityPlacementGroup{ppg1, ppgNilName}) mockClient.EXPECT().ListByResourceGroup(ctx, resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeProximityPlacementGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("GetError", func(t *testing.T) { expectedErr := errors.New("proximity placement group not found") mockClient := mocks.NewMockProximityPlacementGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-ppg", nil).Return( armcompute.ProximityPlacementGroupsClientGetResponse{}, expectedErr) wrapper := manual.NewComputeProximityPlacementGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "nonexistent-ppg", true) if qErr == nil { t.Error("Expected error when getting non-existent proximity placement group, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockProximityPlacementGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "", nil).Return( armcompute.ProximityPlacementGroupsClientGetResponse{}, errors.New("proximity placement group name is required")) wrapper := manual.NewComputeProximityPlacementGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting proximity placement group with empty name, but got nil") } }) } func createAzureProximityPlacementGroup(ppgName, subscriptionID, resourceGroup string) *armcompute.ProximityPlacementGroup { baseID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute" return &armcompute.ProximityPlacementGroup{ Name: new(ppgName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.ProximityPlacementGroupProperties{ ProximityPlacementGroupType: new(armcompute.ProximityPlacementGroupTypeStandard), VirtualMachines: []*armcompute.SubResourceWithColocationStatus{ {ID: new(baseID + "/virtualMachines/test-vm")}, }, AvailabilitySets: []*armcompute.SubResourceWithColocationStatus{ {ID: new(baseID + "/availabilitySets/test-avset")}, }, VirtualMachineScaleSets: []*armcompute.SubResourceWithColocationStatus{ {ID: new(baseID + "/virtualMachineScaleSets/test-vmss")}, }, }, Zones: []*string{new("1")}, } } func createAzureProximityPlacementGroupWithCrossResourceGroupLinks(ppgName, subscriptionID string) *armcompute.ProximityPlacementGroup { return &armcompute.ProximityPlacementGroup{ Name: new(ppgName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.ProximityPlacementGroupProperties{ ProximityPlacementGroupType: new(armcompute.ProximityPlacementGroupTypeStandard), VirtualMachines: []*armcompute.SubResourceWithColocationStatus{ {ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/vm-rg/providers/Microsoft.Compute/virtualMachines/test-vm")}, }, AvailabilitySets: []*armcompute.SubResourceWithColocationStatus{ {ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/avset-rg/providers/Microsoft.Compute/availabilitySets/test-avset")}, }, VirtualMachineScaleSets: []*armcompute.SubResourceWithColocationStatus{ {ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/vmss-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss")}, }, }, } } func createAzureProximityPlacementGroupWithoutLinks(ppgName string) *armcompute.ProximityPlacementGroup { return &armcompute.ProximityPlacementGroup{ Name: new(ppgName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.ProximityPlacementGroupProperties{ ProximityPlacementGroupType: new(armcompute.ProximityPlacementGroupTypeStandard), }, } } type mockProximityPlacementGroupsPager struct { ctrl *gomock.Controller items []*armcompute.ProximityPlacementGroup index int more bool } func newMockProximityPlacementGroupsPager(ctrl *gomock.Controller, items []*armcompute.ProximityPlacementGroup) clients.ProximityPlacementGroupsPager { return &mockProximityPlacementGroupsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockProximityPlacementGroupsPager) More() bool { return m.more } func (m *mockProximityPlacementGroupsPager) NextPage(ctx context.Context) (armcompute.ProximityPlacementGroupsClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.ProximityPlacementGroupsClientListByResourceGroupResponse{ ProximityPlacementGroupListResult: armcompute.ProximityPlacementGroupListResult{ Value: []*armcompute.ProximityPlacementGroup{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.ProximityPlacementGroupsClientListByResourceGroupResponse{ ProximityPlacementGroupListResult: armcompute.ProximityPlacementGroupListResult{ Value: []*armcompute.ProximityPlacementGroup{item}, }, }, nil } ================================================ FILE: sources/azure/manual/compute-shared-gallery-image.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ( ComputeSharedGalleryImageLookupByLocation = shared.NewItemTypeLookup("location", azureshared.ComputeSharedGalleryImage) ComputeSharedGalleryImageLookupByGalleryUniqueName = shared.NewItemTypeLookup("galleryUniqueName", azureshared.ComputeSharedGalleryImage) ComputeSharedGalleryImageLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeSharedGalleryImage) ) type computeSharedGalleryImageWrapper struct { client clients.SharedGalleryImagesClient *azureshared.SubscriptionBase } func NewComputeSharedGalleryImage(client clients.SharedGalleryImagesClient, subscriptionID string) sources.SearchableWrapper { return &computeSharedGalleryImageWrapper{ client: client, SubscriptionBase: azureshared.NewSubscriptionBase( subscriptionID, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeSharedGalleryImage, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/shared-gallery-images/get func (c computeSharedGalleryImageWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 3 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 3: location, gallery unique name, and image name"), scope, c.Type()) } location := queryParts[0] if location == "" { return nil, azureshared.QueryError(errors.New("location cannot be empty"), scope, c.Type()) } galleryUniqueName := queryParts[1] if galleryUniqueName == "" { return nil, azureshared.QueryError(errors.New("gallery unique name cannot be empty"), scope, c.Type()) } galleryImageName := queryParts[2] if galleryImageName == "" { return nil, azureshared.QueryError(errors.New("gallery image name cannot be empty"), scope, c.Type()) } resp, err := c.client.Get(ctx, location, galleryUniqueName, galleryImageName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureSharedGalleryImageToSDPItem(&resp.SharedGalleryImage, location, galleryUniqueName, scope) } // ref: https://learn.microsoft.com/en-us/rest/api/compute/shared-gallery-images/list func (c computeSharedGalleryImageWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 2 { return nil, azureshared.QueryError(errors.New("queryParts must be exactly 2: location and gallery unique name"), scope, c.Type()) } location := queryParts[0] if location == "" { return nil, azureshared.QueryError(errors.New("location cannot be empty"), scope, c.Type()) } galleryUniqueName := queryParts[1] if galleryUniqueName == "" { return nil, azureshared.QueryError(errors.New("gallery unique name cannot be empty"), scope, c.Type()) } pager := c.client.NewListPager(location, galleryUniqueName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, image := range page.Value { if image == nil || image.Name == nil { continue } item, sdpErr := c.azureSharedGalleryImageToSDPItem(image, location, galleryUniqueName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeSharedGalleryImageWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 2 { stream.SendError(azureshared.QueryError(errors.New("queryParts must be exactly 2: location and gallery unique name"), scope, c.Type())) return } location := queryParts[0] if location == "" { stream.SendError(azureshared.QueryError(errors.New("location cannot be empty"), scope, c.Type())) return } galleryUniqueName := queryParts[1] if galleryUniqueName == "" { stream.SendError(azureshared.QueryError(errors.New("gallery unique name cannot be empty"), scope, c.Type())) return } pager := c.client.NewListPager(location, galleryUniqueName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, image := range page.Value { if image == nil || image.Name == nil { continue } item, sdpErr := c.azureSharedGalleryImageToSDPItem(image, location, galleryUniqueName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c computeSharedGalleryImageWrapper) azureSharedGalleryImageToSDPItem( image *armcompute.SharedGalleryImage, location, galleryUniqueName, scope string, ) (*sdp.Item, *sdp.QueryError) { if image.Name == nil { return nil, azureshared.QueryError(errors.New("shared gallery image name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(image) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } imageName := *image.Name err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(location, galleryUniqueName, imageName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } linkedItemQueries := make([]*sdp.LinkedItemQuery, 0) // Parent Shared Gallery: image definition depends on shared gallery (Microsoft.Compute/locations/sharedGalleries) linkedItemQueries = append(linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGallery.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(location, galleryUniqueName), Scope: scope, }, }) // URI-based links. Note: armcompute.SharedGalleryImageProperties has no ReleaseNoteURI field (unlike GalleryImage). linkedDNSHostnames := make(map[string]struct{}) seenIPs := make(map[string]struct{}) if image.Properties != nil { if image.Properties.Eula != nil { AppendURILinks(&linkedItemQueries, *image.Properties.Eula, linkedDNSHostnames, seenIPs) } if image.Properties.PrivacyStatementURI != nil { AppendURILinks(&linkedItemQueries, *image.Properties.PrivacyStatementURI, linkedDNSHostnames, seenIPs) } } sdpItem := &sdp.Item{ Type: azureshared.ComputeSharedGalleryImage.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, LinkedItemQueries: linkedItemQueries, } return sdpItem, nil } func (c computeSharedGalleryImageWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeSharedGalleryImageLookupByLocation, ComputeSharedGalleryImageLookupByGalleryUniqueName, ComputeSharedGalleryImageLookupByName, } } func (c computeSharedGalleryImageWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeSharedGalleryImageLookupByLocation, ComputeSharedGalleryImageLookupByGalleryUniqueName, }, } } func (c computeSharedGalleryImageWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeSharedGallery, stdlib.NetworkDNS, stdlib.NetworkHTTP, stdlib.NetworkIP, ) } // Shared gallery images are read-only views with no direct Terraform resource mapping. func (c computeSharedGalleryImageWrapper) TerraformMappings() []*sdp.TerraformMapping { return nil } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeSharedGalleryImageWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/locations/sharedGalleries/images/read", } } func (c computeSharedGalleryImageWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-shared-gallery-image_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeSharedGalleryImage(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" location := "eastus" galleryUniqueName := "test-gallery-unique-name" imageName := "test-image" t.Run("Get", func(t *testing.T) { image := createSharedGalleryImage(imageName) mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, location, galleryUniqueName, imageName, nil).Return( armcompute.SharedGalleryImagesClientGetResponse{ SharedGalleryImage: *image, }, nil) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, galleryUniqueName, imageName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeSharedGalleryImage.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeSharedGalleryImage.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(location, galleryUniqueName, imageName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeSharedGallery.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, galleryUniqueName), ExpectedScope: subscriptionID}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithURIs", func(t *testing.T) { image := createSharedGalleryImageWithURIs(imageName) mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, location, galleryUniqueName, imageName, nil).Return( armcompute.SharedGalleryImagesClientGetResponse{ SharedGalleryImage: *image, }, nil) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, galleryUniqueName, imageName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ {ExpectedType: azureshared.ComputeSharedGallery.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, galleryUniqueName), ExpectedScope: subscriptionID}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://eula.example.com/terms", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "eula.example.com", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://example.com/privacy", ExpectedScope: "global"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "example.com", ExpectedScope: "global"}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_PlainTextEula_NoLinks", func(t *testing.T) { image := createSharedGalleryImage(imageName) image.Properties.Eula = new("This software is provided as-is. No warranty.") mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, location, galleryUniqueName, imageName, nil).Return( armcompute.SharedGalleryImagesClientGetResponse{ SharedGalleryImage: *image, }, nil) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, galleryUniqueName, imageName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } for _, q := range sdpItem.GetLinkedItemQueries() { lq := q.GetQuery() if lq == nil { continue } typ := lq.GetType() if typ == stdlib.NetworkHTTP.String() || typ == stdlib.NetworkDNS.String() || typ == stdlib.NetworkIP.String() { t.Errorf("Plain-text Eula must not create network links; found linked query type %s with query %s", typ, lq.GetQuery()) } } }) t.Run("Get_SameHostDeduplication", func(t *testing.T) { image := createSharedGalleryImage(imageName) image.Properties.Eula = new("https://example.com/eula") image.Properties.PrivacyStatementURI = new("https://example.com/privacy") mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, location, galleryUniqueName, imageName, nil).Return( armcompute.SharedGalleryImagesClientGetResponse{ SharedGalleryImage: *image, }, nil) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, galleryUniqueName, imageName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } httpCount := 0 dnsCount := 0 for _, q := range sdpItem.GetLinkedItemQueries() { lq := q.GetQuery() if lq != nil { if lq.GetType() == stdlib.NetworkHTTP.String() { httpCount++ } if lq.GetType() == stdlib.NetworkDNS.String() { dnsCount++ } } } if httpCount != 2 { t.Errorf("Expected 2 HTTP links, got %d", httpCount) } if dnsCount != 1 { t.Errorf("Expected 1 DNS link (deduped), got %d", dnsCount) } }) t.Run("Get_IPHost_EmitsIPLink", func(t *testing.T) { image := createSharedGalleryImage(imageName) image.Properties.PrivacyStatementURI = new("https://192.168.1.10:8443/privacy") mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, location, galleryUniqueName, imageName, nil).Return( armcompute.SharedGalleryImagesClientGetResponse{ SharedGalleryImage: *image, }, nil) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, galleryUniqueName, imageName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } hasIP := false for _, q := range sdpItem.GetLinkedItemQueries() { lq := q.GetQuery() if lq != nil && lq.GetType() == stdlib.NetworkIP.String() { hasIP = true if lq.GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected NetworkIP link to use GET, got %v", lq.GetMethod()) } if lq.GetScope() != "global" { t.Errorf("Expected NetworkIP link scope global, got %s", lq.GetScope()) } if lq.GetQuery() != "192.168.1.10" { t.Errorf("Expected NetworkIP link query 192.168.1.10, got %s", lq.GetQuery()) } break } } if !hasIP { t.Error("Expected NetworkIP linked query when PrivacyStatementURI host is an IP address") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], location, true) if qErr == nil { t.Error("Expected error when Get with wrong number of query parts, but got nil") } }) t.Run("Get_EmptyLocation", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", galleryUniqueName, imageName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when location is empty, but got nil") } }) t.Run("Get_EmptyGalleryUniqueName", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, "", imageName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when gallery unique name is empty, but got nil") } }) t.Run("Get_EmptyImageName", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, galleryUniqueName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when image name is empty, but got nil") } }) t.Run("Get_ClientError", func(t *testing.T) { expectedErr := errors.New("image not found") mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) mockClient.EXPECT().Get(ctx, location, galleryUniqueName, "nonexistent", nil).Return( armcompute.SharedGalleryImagesClientGetResponse{}, expectedErr) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(location, galleryUniqueName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("Search", func(t *testing.T) { img1 := createSharedGalleryImage("image-1") img2 := createSharedGalleryImage("image-2") mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) mockPager := newMockSharedGalleryImagesPager([]*armcompute.SharedGalleryImage{img1, img2}) mockClient.EXPECT().NewListPager(location, galleryUniqueName, nil).Return(mockPager) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := shared.CompositeLookupKey(location, galleryUniqueName) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], searchQuery, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Errorf("Expected valid item, got: %v", err) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := shared.CompositeLookupKey(location, galleryUniqueName, imageName) _, err := searchable.Search(ctx, wrapper.Scopes()[0], searchQuery, true) if err == nil { t.Error("Expected error when Search with wrong number of query parts, but got nil") } }) t.Run("Search_EmptyLocation", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "", galleryUniqueName) if qErr == nil { t.Error("Expected error when location is empty, but got nil") } }) t.Run("Search_EmptyGalleryUniqueName", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], location, "") if qErr == nil { t.Error("Expected error when gallery unique name is empty, but got nil") } }) t.Run("Search_PagerError", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) errorPager := &errorSharedGalleryImagesPager{} mockClient.EXPECT().NewListPager(location, galleryUniqueName, nil).Return(errorPager) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } searchQuery := shared.CompositeLookupKey(location, galleryUniqueName) _, err := searchable.Search(ctx, wrapper.Scopes()[0], searchQuery, true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) links := wrapper.PotentialLinks() expected := map[shared.ItemType]bool{ azureshared.ComputeSharedGallery: true, stdlib.NetworkDNS: true, stdlib.NetworkHTTP: true, stdlib.NetworkIP: true, } for itemType, want := range expected { if got := links[itemType]; got != want { t.Errorf("PotentialLinks()[%v] = %v, want %v", itemType, got, want) } } }) t.Run("ImplementsSearchableAdapter", func(t *testing.T) { mockClient := mocks.NewMockSharedGalleryImagesClient(ctrl) wrapper := manual.NewComputeSharedGalleryImage(mockClient, subscriptionID) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) } func createSharedGalleryImage(name string) *armcompute.SharedGalleryImage { return &armcompute.SharedGalleryImage{ Name: new(name), Location: new("eastus"), Identifier: &armcompute.SharedGalleryIdentifier{ UniqueID: new("/SharedGalleries/test-gallery-unique-name"), }, Properties: &armcompute.SharedGalleryImageProperties{ Identifier: &armcompute.GalleryImageIdentifier{ Publisher: new("test-publisher"), Offer: new("test-offer"), SKU: new("test-sku"), }, OSType: new(armcompute.OperatingSystemTypesLinux), OSState: new(armcompute.OperatingSystemStateTypesGeneralized), }, } } func createSharedGalleryImageWithURIs(name string) *armcompute.SharedGalleryImage { img := createSharedGalleryImage(name) img.Properties.Eula = new("https://eula.example.com/terms") img.Properties.PrivacyStatementURI = new("https://example.com/privacy") return img } type mockSharedGalleryImagesPager struct { pages []armcompute.SharedGalleryImagesClientListResponse index int } func newMockSharedGalleryImagesPager(items []*armcompute.SharedGalleryImage) clients.SharedGalleryImagesPager { return &mockSharedGalleryImagesPager{ pages: []armcompute.SharedGalleryImagesClientListResponse{ { SharedGalleryImageList: armcompute.SharedGalleryImageList{ Value: items, }, }, }, index: 0, } } func (m *mockSharedGalleryImagesPager) More() bool { return m.index < len(m.pages) } func (m *mockSharedGalleryImagesPager) NextPage(ctx context.Context) (armcompute.SharedGalleryImagesClientListResponse, error) { if m.index >= len(m.pages) { return armcompute.SharedGalleryImagesClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorSharedGalleryImagesPager struct{} func (e *errorSharedGalleryImagesPager) More() bool { return true } func (e *errorSharedGalleryImagesPager) NextPage(ctx context.Context) (armcompute.SharedGalleryImagesClientListResponse, error) { return armcompute.SharedGalleryImagesClientListResponse{}, errors.New("pager error") } ================================================ FILE: sources/azure/manual/compute-snapshot.go ================================================ package manual import ( "context" "errors" "net" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeSnapshotLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeSnapshot) type computeSnapshotWrapper struct { client clients.SnapshotsClient *azureshared.MultiResourceGroupBase } func NewComputeSnapshot(client clients.SnapshotsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeSnapshotWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ComputeSnapshot, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/snapshots/list-by-resource-group func (c computeSnapshotWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, snapshot := range page.Value { if snapshot.Name == nil { continue } item, sdpErr := c.azureSnapshotToSDPItem(snapshot, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeSnapshotWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, snapshot := range page.Value { if snapshot.Name == nil { continue } item, sdpErr := c.azureSnapshotToSDPItem(snapshot, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/snapshots/get func (c computeSnapshotWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the snapshot name"), scope, c.Type()) } snapshotName := queryParts[0] if snapshotName == "" { return nil, azureshared.QueryError(errors.New("snapshotName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } result, err := c.client.Get(ctx, rgScope.ResourceGroup, snapshotName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureSnapshotToSDPItem(&result.Snapshot, scope) } func (c computeSnapshotWrapper) azureSnapshotToSDPItem(snapshot *armcompute.Snapshot, scope string) (*sdp.Item, *sdp.QueryError) { if snapshot.Name == nil { return nil, azureshared.QueryError(errors.New("snapshot name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(snapshot, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeSnapshot.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(snapshot.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Health status from ProvisioningState if snapshot.Properties != nil && snapshot.Properties.ProvisioningState != nil { switch *snapshot.Properties.ProvisioningState { case "Succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "Creating", "Updating", "Deleting": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "Failed", "Canceled": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to Disk Access from Properties.DiskAccessID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-accesses/get if snapshot.Properties != nil && snapshot.Properties.DiskAccessID != nil && *snapshot.Properties.DiskAccessID != "" { diskAccessName := azureshared.ExtractResourceName(*snapshot.Properties.DiskAccessID) if diskAccessName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*snapshot.Properties.DiskAccessID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskAccess.String(), Method: sdp.QueryMethod_GET, Query: diskAccessName, Scope: extractedScope, }, }) } } // Link to Disk Encryption Set from Properties.Encryption.DiskEncryptionSetID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if snapshot.Properties != nil && snapshot.Properties.Encryption != nil && snapshot.Properties.Encryption.DiskEncryptionSetID != nil && *snapshot.Properties.Encryption.DiskEncryptionSetID != "" { encryptionSetName := azureshared.ExtractResourceName(*snapshot.Properties.Encryption.DiskEncryptionSetID) if encryptionSetName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*snapshot.Properties.Encryption.DiskEncryptionSetID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } // Link to Disk Encryption Set from Properties.SecurityProfile.SecureVMDiskEncryptionSetID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if snapshot.Properties != nil && snapshot.Properties.SecurityProfile != nil && snapshot.Properties.SecurityProfile.SecureVMDiskEncryptionSetID != nil && *snapshot.Properties.SecurityProfile.SecureVMDiskEncryptionSetID != "" { encryptionSetName := azureshared.ExtractResourceName(*snapshot.Properties.SecurityProfile.SecureVMDiskEncryptionSetID) if encryptionSetName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*snapshot.Properties.SecurityProfile.SecureVMDiskEncryptionSetID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } // Link to source resources from Properties.CreationData if snapshot.Properties != nil && snapshot.Properties.CreationData != nil { creationData := snapshot.Properties.CreationData // Link to source Disk or Snapshot from SourceResourceID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disks/get // Reference: https://learn.microsoft.com/en-us/rest/api/compute/snapshots/get if creationData.SourceResourceID != nil && *creationData.SourceResourceID != "" { sourceResourceID := *creationData.SourceResourceID sourceResourceIDLower := strings.ToLower(sourceResourceID) if strings.Contains(sourceResourceIDLower, "/disks/") { diskName := azureshared.ExtractResourceName(sourceResourceID) if diskName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(sourceResourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: extractedScope, }, }) } } else if strings.Contains(sourceResourceIDLower, "/snapshots/") { snapshotName := azureshared.ExtractResourceName(sourceResourceID) if snapshotName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(sourceResourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: extractedScope, }, }) } } } // Link to Storage Account from StorageAccountID // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/storage-accounts/get-properties if creationData.StorageAccountID != nil && *creationData.StorageAccountID != "" { storageAccountName := azureshared.ExtractResourceName(*creationData.StorageAccountID) if storageAccountName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*creationData.StorageAccountID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: extractedScope, }, }) } } // Link to Storage Account and DNS from SourceURI (blob URI used for Import) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/snapshots/create-or-update if creationData.SourceURI != nil && *creationData.SourceURI != "" { sourceURI := *creationData.SourceURI storageAccountName := azureshared.ExtractStorageAccountNameFromBlobURI(sourceURI) if storageAccountName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) containerName := azureshared.ExtractContainerNameFromBlobURI(sourceURI) if containerName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageBlobContainer.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(storageAccountName, containerName), Scope: scope, }, }) } } if strings.HasPrefix(sourceURI, "http://") || strings.HasPrefix(sourceURI, "https://") { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: sourceURI, Scope: "global", }, }) } host := azureshared.ExtractDNSFromURL(sourceURI) if host != "" { if net.ParseIP(host) != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: host, Scope: "global", }, }) } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: host, Scope: "global", }, }) } } } // Link to Image from ImageReference.ID // Reference: https://learn.microsoft.com/en-us/rest/api/compute/images/get if creationData.ImageReference != nil && creationData.ImageReference.ID != nil && *creationData.ImageReference.ID != "" { imageID := *creationData.ImageReference.ID imageIDLower := strings.ToLower(imageID) if strings.Contains(imageIDLower, "/images/") && !strings.Contains(imageIDLower, "/galleries/") { imageName := azureshared.ExtractResourceName(imageID) if imageName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(imageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: imageName, Scope: extractedScope, }, }) } } } // Link to Gallery Image from GalleryImageReference // Reference: https://learn.microsoft.com/en-us/rest/api/compute/gallery-images/get if creationData.GalleryImageReference != nil { if creationData.GalleryImageReference.ID != nil && *creationData.GalleryImageReference.ID != "" { galleryImageID := *creationData.GalleryImageReference.ID parts := azureshared.ExtractPathParamsFromResourceID(galleryImageID, []string{"galleries", "images", "versions"}) if len(parts) >= 3 { galleryName := parts[0] imageName := parts[1] version := parts[2] extractedScope := azureshared.ExtractScopeFromResourceID(galleryImageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, version), Scope: extractedScope, }, }) } } if creationData.GalleryImageReference.SharedGalleryImageID != nil && *creationData.GalleryImageReference.SharedGalleryImageID != "" { sharedGalleryImageID := *creationData.GalleryImageReference.SharedGalleryImageID parts := azureshared.ExtractPathParamsFromResourceID(sharedGalleryImageID, []string{"galleries", "images", "versions"}) if len(parts) >= 3 { galleryName := parts[0] imageName := parts[1] version := parts[2] extractedScope := azureshared.ExtractScopeFromResourceID(sharedGalleryImageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, version), Scope: extractedScope, }, }) } } if creationData.GalleryImageReference.CommunityGalleryImageID != nil && *creationData.GalleryImageReference.CommunityGalleryImageID != "" { communityGalleryImageID := *creationData.GalleryImageReference.CommunityGalleryImageID parts := azureshared.ExtractPathParamsFromResourceID(communityGalleryImageID, []string{"Images", "Versions"}) if len(parts) >= 2 { imageName := parts[0] version := parts[1] allParts := strings.Split(strings.Trim(communityGalleryImageID, "/"), "/") communityGalleryName := "" for i, part := range allParts { if strings.EqualFold(part, "CommunityGalleries") && i+1 < len(allParts) { communityGalleryName = allParts[i+1] break } } if communityGalleryName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(communityGalleryImageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCommunityGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(communityGalleryName, imageName, version), Scope: extractedScope, }, }) } } } } // Link to Elastic SAN Volume Snapshot from ElasticSanResourceID // Reference: https://learn.microsoft.com/en-us/rest/api/elasticsan/volume-snapshots/get if creationData.ElasticSanResourceID != nil && *creationData.ElasticSanResourceID != "" { elasticSanResourceID := *creationData.ElasticSanResourceID parts := azureshared.ExtractPathParamsFromResourceID(elasticSanResourceID, []string{"elasticSans", "volumegroups", "snapshots"}) if len(parts) >= 3 { elasticSanName := parts[0] volumeGroupName := parts[1] esSnapshotName := parts[2] extractedScope := azureshared.ExtractScopeFromResourceID(elasticSanResourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolumeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName, esSnapshotName), Scope: extractedScope, }, }) } } } // Link to Key Vault resources from EncryptionSettingsCollection // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get if snapshot.Properties != nil && snapshot.Properties.EncryptionSettingsCollection != nil && snapshot.Properties.EncryptionSettingsCollection.EncryptionSettings != nil { for _, encryptionSetting := range snapshot.Properties.EncryptionSettingsCollection.EncryptionSettings { if encryptionSetting == nil { continue } // Link to Key Vault from DiskEncryptionKey.SourceVault.ID if encryptionSetting.DiskEncryptionKey != nil && encryptionSetting.DiskEncryptionKey.SourceVault != nil && encryptionSetting.DiskEncryptionKey.SourceVault.ID != nil && *encryptionSetting.DiskEncryptionKey.SourceVault.ID != "" { vaultName := azureshared.ExtractResourceName(*encryptionSetting.DiskEncryptionKey.SourceVault.ID) if vaultName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*encryptionSetting.DiskEncryptionKey.SourceVault.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } // Link to Key Vault Secret from DiskEncryptionKey.SecretURL if encryptionSetting.DiskEncryptionKey != nil && encryptionSetting.DiskEncryptionKey.SecretURL != nil && *encryptionSetting.DiskEncryptionKey.SecretURL != "" { secretURL := *encryptionSetting.DiskEncryptionKey.SecretURL vaultName := azureshared.ExtractVaultNameFromURI(secretURL) secretName := azureshared.ExtractSecretNameFromURI(secretURL) if vaultName != "" && secretName != "" { // Derive scope from the DiskEncryptionKey's SourceVault when available secretScope := scope if encryptionSetting.DiskEncryptionKey.SourceVault != nil && encryptionSetting.DiskEncryptionKey.SourceVault.ID != nil { if extracted := azureshared.ExtractScopeFromResourceID(*encryptionSetting.DiskEncryptionKey.SourceVault.ID); extracted != "" { secretScope = extracted } } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultSecret.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, secretName), Scope: secretScope, }, }) } secretHost := azureshared.ExtractDNSFromURL(secretURL) if secretHost != "" { if net.ParseIP(secretHost) != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: secretHost, Scope: "global", }, }) } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: secretHost, Scope: "global", }, }) } } } // Link to Key Vault from KeyEncryptionKey.SourceVault.ID if encryptionSetting.KeyEncryptionKey != nil && encryptionSetting.KeyEncryptionKey.SourceVault != nil && encryptionSetting.KeyEncryptionKey.SourceVault.ID != nil && *encryptionSetting.KeyEncryptionKey.SourceVault.ID != "" { vaultName := azureshared.ExtractResourceName(*encryptionSetting.KeyEncryptionKey.SourceVault.ID) if vaultName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*encryptionSetting.KeyEncryptionKey.SourceVault.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } // Link to Key Vault Key from KeyEncryptionKey.KeyURL if encryptionSetting.KeyEncryptionKey != nil && encryptionSetting.KeyEncryptionKey.KeyURL != nil && *encryptionSetting.KeyEncryptionKey.KeyURL != "" { keyURL := *encryptionSetting.KeyEncryptionKey.KeyURL vaultName := azureshared.ExtractVaultNameFromURI(keyURL) keyName := azureshared.ExtractKeyNameFromURI(keyURL) if vaultName != "" && keyName != "" { // Derive scope from the KeyEncryptionKey's SourceVault when available keyScope := scope if encryptionSetting.KeyEncryptionKey.SourceVault != nil && encryptionSetting.KeyEncryptionKey.SourceVault.ID != nil { if extracted := azureshared.ExtractScopeFromResourceID(*encryptionSetting.KeyEncryptionKey.SourceVault.ID); extracted != "" { keyScope = extracted } } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, keyName), Scope: keyScope, }, }) } keyHost := azureshared.ExtractDNSFromURL(keyURL) if keyHost != "" { if net.ParseIP(keyHost) != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: keyHost, Scope: "global", }, }) } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: keyHost, Scope: "global", }, }) } } } } } return sdpItem, nil } func (c computeSnapshotWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeSnapshotLookupByName, } } func (c computeSnapshotWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeDisk, azureshared.ComputeSnapshot, azureshared.ComputeDiskAccess, azureshared.ComputeDiskEncryptionSet, azureshared.ComputeImage, azureshared.ComputeSharedGalleryImage, azureshared.ComputeCommunityGalleryImage, azureshared.StorageAccount, azureshared.StorageBlobContainer, azureshared.ElasticSanVolumeSnapshot, azureshared.KeyVaultVault, azureshared.KeyVaultSecret, azureshared.KeyVaultKey, stdlib.NetworkDNS, stdlib.NetworkHTTP, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/snapshot func (c computeSnapshotWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_snapshot.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (c computeSnapshotWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/snapshots/read", } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/compute func (c computeSnapshotWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-snapshot_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeSnapshot(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { snapshotName := "test-snapshot" snapshot := createAzureSnapshot(snapshotName, subscriptionID, resourceGroup) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, snapshotName, nil).Return( armcompute.SnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeSnapshot.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeSnapshot, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != snapshotName { t.Errorf("Expected unique attribute value %s, got %s", snapshotName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Properties.DiskAccessID ExpectedType: azureshared.ComputeDiskAccess.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk-access", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.Encryption.DiskEncryptionSetID ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-des", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.SourceResourceID (disk) ExpectedType: azureshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-disk", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithSnapshotSource", func(t *testing.T) { snapshotName := "test-snapshot-from-snapshot" snapshot := createAzureSnapshotFromSnapshot(snapshotName, subscriptionID, resourceGroup) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, snapshotName, nil).Return( armcompute.SnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Properties.CreationData.SourceResourceID (snapshot) ExpectedType: azureshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-snapshot", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithSourceURI", func(t *testing.T) { snapshotName := "test-snapshot-from-blob" snapshot := createAzureSnapshotFromBlobURI(snapshotName) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, snapshotName, nil).Return( armcompute.SnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Properties.CreationData.SourceURI → Storage Account ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "teststorageaccount", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.SourceURI → Blob Container ExpectedType: azureshared.StorageBlobContainer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("teststorageaccount", "vhds"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.CreationData.SourceURI → HTTP ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://teststorageaccount.blob.core.windows.net/vhds/my-disk.vhd", ExpectedScope: "global", }, { // Properties.CreationData.SourceURI → DNS ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "teststorageaccount.blob.core.windows.net", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithSourceURIUsingIPHost", func(t *testing.T) { snapshotName := "test-snapshot-from-ip-blob" snapshot := createAzureSnapshotFromIPBlobURI(snapshotName) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, snapshotName, nil).Return( armcompute.SnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Properties.CreationData.SourceURI → HTTP ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://10.0.0.1/vhds/my-disk.vhd", ExpectedScope: "global", }, { // Properties.CreationData.SourceURI → IP (host is IP address) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) // Verify no DNS link was emitted for the IP host for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == stdlib.NetworkDNS.String() { t.Error("Expected no DNS link when SourceURI host is an IP address") } } }) t.Run("GetWithEncryptionIPHosts", func(t *testing.T) { snapshotName := "test-snapshot-encryption-ip" snapshot := createAzureSnapshotWithEncryptionIPHosts(snapshotName, subscriptionID, resourceGroup) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, snapshotName, nil).Return( armcompute.SnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundSecretIPLink := false foundKeyIPLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == stdlib.NetworkIP.String() { if link.GetQuery().GetQuery() == "10.0.0.2" { foundSecretIPLink = true } if link.GetQuery().GetQuery() == "10.0.0.3" { foundKeyIPLink = true } if link.GetQuery().GetScope() != "global" { t.Errorf("Expected IP scope 'global', got %s", link.GetQuery().GetScope()) } if link.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected IP method GET, got %s", link.GetQuery().GetMethod()) } } if link.GetQuery().GetType() == stdlib.NetworkDNS.String() { t.Error("Expected no DNS link when SecretURL/KeyURL hosts are IP addresses") } } if !foundSecretIPLink { t.Error("Expected to find IP link for SecretURL host 10.0.0.2") } if !foundKeyIPLink { t.Error("Expected to find IP link for KeyURL host 10.0.0.3") } }) t.Run("GetWithCrossResourceGroupLinks", func(t *testing.T) { snapshotName := "test-snapshot-cross-rg" snapshot := createAzureSnapshotWithCrossResourceGroupLinks(snapshotName, subscriptionID) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, snapshotName, nil).Return( armcompute.SnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundDiskAccessLink := false foundDiskLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.ComputeDiskAccess.String() { foundDiskAccessLink = true expectedScope := subscriptionID + ".other-rg" if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected DiskAccess scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } if link.GetQuery().GetType() == azureshared.ComputeDisk.String() { foundDiskLink = true expectedScope := subscriptionID + ".disk-rg" if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected Disk scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } } if !foundDiskAccessLink { t.Error("Expected to find Disk Access link") } if !foundDiskLink { t.Error("Expected to find Disk link") } }) t.Run("GetWithoutLinks", func(t *testing.T) { snapshotName := "test-snapshot-no-links" snapshot := createAzureSnapshotWithoutLinks(snapshotName) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, snapshotName, nil).Return( armcompute.SnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("List", func(t *testing.T) { snapshot1 := createAzureSnapshot("test-snapshot-1", subscriptionID, resourceGroup) snapshot2 := createAzureSnapshot("test-snapshot-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockPager := newMockSnapshotsPager(ctrl, []*armcompute.Snapshot{snapshot1, snapshot2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { snapshot1 := createAzureSnapshot("test-snapshot-1", subscriptionID, resourceGroup) snapshot2 := createAzureSnapshot("test-snapshot-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockSnapshotsClient(ctrl) mockPager := newMockSnapshotsPager(ctrl, []*armcompute.Snapshot{snapshot1, snapshot2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListWithNilName", func(t *testing.T) { snapshot1 := createAzureSnapshot("test-snapshot-1", subscriptionID, resourceGroup) snapshotNilName := &armcompute.Snapshot{ Name: nil, Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockSnapshotsClient(ctrl) mockPager := newMockSnapshotsPager(ctrl, []*armcompute.Snapshot{snapshot1, snapshotNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("snapshot not found") mockClient := mocks.NewMockSnapshotsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-snapshot", nil).Return( armcompute.SnapshotsClientGetResponse{}, expectedErr) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-snapshot", true) if qErr == nil { t.Error("Expected error when getting non-existent snapshot, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockSnapshotsClient(ctrl) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting snapshot with empty name, but got nil") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSnapshotsClient(ctrl) wrapper := manual.NewComputeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when getting snapshot with insufficient query parts, but got nil") } }) } // createAzureSnapshot creates a mock Azure Snapshot with linked resources for testing func createAzureSnapshot(name, subscriptionID, resourceGroup string) *armcompute.Snapshot { return &armcompute.Snapshot{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.SnapshotProperties{ ProvisioningState: new("Succeeded"), DiskAccessID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskAccesses/test-disk-access"), Encryption: &armcompute.Encryption{ DiskEncryptionSetID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/diskEncryptionSets/test-des"), }, CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionCopy), SourceResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/disks/source-disk"), }, }, } } // createAzureSnapshotFromSnapshot creates a mock Snapshot that was copied from another snapshot func createAzureSnapshotFromSnapshot(name, subscriptionID, resourceGroup string) *armcompute.Snapshot { return &armcompute.Snapshot{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.SnapshotProperties{ ProvisioningState: new("Succeeded"), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionCopy), SourceResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Compute/snapshots/source-snapshot"), }, }, } } // createAzureSnapshotFromBlobURI creates a mock Snapshot imported from a blob URI func createAzureSnapshotFromBlobURI(name string) *armcompute.Snapshot { return &armcompute.Snapshot{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.SnapshotProperties{ ProvisioningState: new("Succeeded"), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionImport), SourceURI: new("https://teststorageaccount.blob.core.windows.net/vhds/my-disk.vhd"), }, }, } } // createAzureSnapshotFromIPBlobURI creates a mock Snapshot imported from a blob URI with an IP address host func createAzureSnapshotFromIPBlobURI(name string) *armcompute.Snapshot { return &armcompute.Snapshot{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.SnapshotProperties{ ProvisioningState: new("Succeeded"), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionImport), SourceURI: new("https://10.0.0.1/vhds/my-disk.vhd"), }, }, } } // createAzureSnapshotWithEncryptionIPHosts creates a mock Snapshot with encryption settings using IP-based SecretURL and KeyURL func createAzureSnapshotWithEncryptionIPHosts(name, subscriptionID, resourceGroup string) *armcompute.Snapshot { return &armcompute.Snapshot{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.SnapshotProperties{ ProvisioningState: new("Succeeded"), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionEmpty), }, EncryptionSettingsCollection: &armcompute.EncryptionSettingsCollection{ Enabled: new(true), EncryptionSettings: []*armcompute.EncryptionSettingsElement{ { DiskEncryptionKey: &armcompute.KeyVaultAndSecretReference{ SourceVault: &armcompute.SourceVault{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-vault"), }, SecretURL: new("https://10.0.0.2/secrets/my-secret/version1"), }, KeyEncryptionKey: &armcompute.KeyVaultAndKeyReference{ SourceVault: &armcompute.SourceVault{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/vaults/test-vault"), }, KeyURL: new("https://10.0.0.3/keys/my-key/version1"), }, }, }, }, }, } } // createAzureSnapshotWithCrossResourceGroupLinks creates a mock Snapshot with links to resources in different resource groups func createAzureSnapshotWithCrossResourceGroupLinks(name, subscriptionID string) *armcompute.Snapshot { return &armcompute.Snapshot{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.SnapshotProperties{ ProvisioningState: new("Succeeded"), DiskAccessID: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/Microsoft.Compute/diskAccesses/test-disk-access"), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionCopy), SourceResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/disk-rg/providers/Microsoft.Compute/disks/source-disk"), }, }, } } // createAzureSnapshotWithoutLinks creates a mock Snapshot without any linked resources func createAzureSnapshotWithoutLinks(name string) *armcompute.Snapshot { return &armcompute.Snapshot{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcompute.SnapshotProperties{ ProvisioningState: new("Succeeded"), CreationData: &armcompute.CreationData{ CreateOption: new(armcompute.DiskCreateOptionEmpty), }, }, } } // mockSnapshotsPager is a simple mock implementation of the Pager interface for testing type mockSnapshotsPager struct { ctrl *gomock.Controller items []*armcompute.Snapshot index int more bool } func newMockSnapshotsPager(ctrl *gomock.Controller, items []*armcompute.Snapshot) clients.SnapshotsPager { return &mockSnapshotsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockSnapshotsPager) More() bool { return m.more } func (m *mockSnapshotsPager) NextPage(ctx context.Context) (armcompute.SnapshotsClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armcompute.SnapshotsClientListByResourceGroupResponse{ SnapshotList: armcompute.SnapshotList{ Value: []*armcompute.Snapshot{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armcompute.SnapshotsClientListByResourceGroupResponse{ SnapshotList: armcompute.SnapshotList{ Value: []*armcompute.Snapshot{item}, }, }, nil } ================================================ FILE: sources/azure/manual/compute-virtual-machine-extension.go ================================================ package manual import ( "context" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeVirtualMachineExtensionLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeVirtualMachineExtension) type computeVirtualMachineExtensionWrapper struct { client clients.VirtualMachineExtensionsClient *azureshared.MultiResourceGroupBase } func NewComputeVirtualMachineExtension(client clients.VirtualMachineExtensionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeVirtualMachineExtensionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeVirtualMachineExtension, ), } } func (c computeVirtualMachineExtensionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 2 { return nil, azureshared.QueryError(fmt.Errorf("queryParts must be 2 query parts: virtualMachineName and extensionName, got %d", len(queryParts)), scope, c.Type()) } virtualMachineName := queryParts[0] extensionName := queryParts[1] if virtualMachineName == "" { return nil, azureshared.QueryError(fmt.Errorf("virtualMachineName cannot be empty"), scope, c.Type()) } if extensionName == "" { return nil, azureshared.QueryError(fmt.Errorf("extensionName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, virtualMachineName, extensionName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureVirtualMachineExtensionToSDPItem(&resp.VirtualMachineExtension, virtualMachineName, extensionName, scope) } func (c computeVirtualMachineExtensionWrapper) azureVirtualMachineExtensionToSDPItem(extension *armcompute.VirtualMachineExtension, virtualMachineName, extensionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(extension, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } uniqueAttr := shared.CompositeLookupKey(virtualMachineName, extensionName) err = attributes.Set("uniqueAttr", uniqueAttr) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeVirtualMachineExtension.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(extension.Tags), } // Link to Virtual Machine (parent resource) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get?view=rest-compute-2025-04-01 // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}?api-version=2025-04-01 if virtualMachineName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: virtualMachineName, Scope: scope, }, }) } // Link to Key Vault for extension protected settings // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01 // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}?api-version=2024-11-01 if extension.Properties != nil && extension.Properties.ProtectedSettingsFromKeyVault != nil && extension.Properties.ProtectedSettingsFromKeyVault.SourceVault != nil && extension.Properties.ProtectedSettingsFromKeyVault.SourceVault.ID != nil { vaultName := azureshared.ExtractResourceName(*extension.Properties.ProtectedSettingsFromKeyVault.SourceVault.ID) if vaultName != "" { // Check if Key Vault is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*extension.Properties.ProtectedSettingsFromKeyVault.SourceVault.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } // Link to DNS name (standard library) from SecretURL // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/secrets/get-secret?view=rest-keyvault-keyvault-2024-11-01 // SecretURL format: https://{vault}.vault.azure.net/secrets/{secret}/{version} if extension.Properties != nil && extension.Properties.ProtectedSettingsFromKeyVault != nil && extension.Properties.ProtectedSettingsFromKeyVault.SecretURL != nil && *extension.Properties.ProtectedSettingsFromKeyVault.SecretURL != "" { secretURL := *extension.Properties.ProtectedSettingsFromKeyVault.SecretURL dnsName := azureshared.ExtractDNSFromURL(secretURL) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } // Extract links from settings JSON (may contain URLs, DNS names, or IP addresses) // Extension settings are extension-specific JSON that may contain resource references if extension.Properties != nil && extension.Properties.Settings != nil { settingsLinks, err := sdp.ExtractLinksFrom(extension.Properties.Settings) if err == nil && settingsLinks != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, settingsLinks...) // Also extract DNS links from HTTP URLs for _, link := range settingsLinks { if link.GetQuery().GetType() == stdlib.NetworkHTTP.String() { dnsName := azureshared.ExtractDNSFromURL(link.GetQuery().GetQuery()) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } } // Extract links from protectedSettings JSON (may contain URLs, DNS names, or IP addresses) // Protected settings are encrypted but may still contain resource references if extension.Properties != nil && extension.Properties.ProtectedSettings != nil { protectedSettingsLinks, err := sdp.ExtractLinksFrom(extension.Properties.ProtectedSettings) if err == nil && protectedSettingsLinks != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, protectedSettingsLinks...) // Also extract DNS links from HTTP URLs for _, link := range protectedSettingsLinks { if link.GetQuery().GetType() == stdlib.NetworkHTTP.String() { dnsName := azureshared.ExtractDNSFromURL(link.GetQuery().GetQuery()) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } } return sdpItem, nil } func (c computeVirtualMachineExtensionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeVirtualMachineLookupByName, ComputeVirtualMachineExtensionLookupByName, } } func (c computeVirtualMachineExtensionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(fmt.Errorf("queryParts must be 1 query part: virtualMachineName, got %d", len(queryParts)), scope, c.Type()) } virtualMachineName := queryParts[0] if virtualMachineName == "" { return nil, azureshared.QueryError(fmt.Errorf("virtualMachineName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.List(ctx, rgScope.ResourceGroup, virtualMachineName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } items := make([]*sdp.Item, 0) for _, extension := range resp.Value { if extension.Name == nil { continue } item, err := c.azureVirtualMachineExtensionToSDPItem(extension, virtualMachineName, *extension.Name, scope) if err != nil { return nil, err } items = append(items, item) } return items, nil } func (c computeVirtualMachineExtensionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(fmt.Errorf("queryParts must be 1 query part: virtualMachineName, got %d", len(queryParts)), scope, c.Type())) return } virtualMachineName := queryParts[0] if virtualMachineName == "" { stream.SendError(azureshared.QueryError(fmt.Errorf("virtualMachineName cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } resp, err := c.client.List(ctx, rgScope.ResourceGroup, virtualMachineName, nil) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, extension := range resp.Value { if extension.Name == nil { continue } item, sdpErr := c.azureVirtualMachineExtensionToSDPItem(extension, virtualMachineName, *extension.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } func (c computeVirtualMachineExtensionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeVirtualMachineLookupByName, }, } } func (c computeVirtualMachineExtensionWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeVirtualMachine, azureshared.KeyVaultVault, stdlib.NetworkHTTP, stdlib.NetworkDNS, stdlib.NetworkIP, ) } func (c computeVirtualMachineExtensionWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_virtual_machine_extension.id", }, } } func (c computeVirtualMachineExtensionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/virtualMachines/extensions/read", } } func (c computeVirtualMachineExtensionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-virtual-machine-extension_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeVirtualMachineExtension(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" vmName := "test-vm" extensionName := "test-extension" t.Run("Get", func(t *testing.T) { extension := createAzureVirtualMachineExtension(extensionName, vmName) mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{ VirtualMachineExtension: *extension, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeVirtualMachineExtension.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeVirtualMachineExtension, sdpItem.GetType()) } expectedUniqueAttr := shared.CompositeLookupKey(vmName, extensionName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttr { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttr, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Virtual Machine (parent resource) ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: vmName, ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithKeyVault", func(t *testing.T) { extension := createAzureVirtualMachineExtensionWithKeyVault(extensionName, vmName) mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{ VirtualMachineExtension: *extension, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatal("Expected linked queries, but got none") } hasKeyVaultLink := false hasVMLink := false for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.KeyVaultVault.String(): hasKeyVaultLink = true if liq.GetQuery().GetQuery() != "test-keyvault" { t.Errorf("Expected Key Vault name 'test-keyvault', got %s", liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != scope { t.Errorf("Expected scope %s, got %s", scope, liq.GetQuery().GetScope()) } case azureshared.ComputeVirtualMachine.String(): hasVMLink = true } } if !hasKeyVaultLink { t.Error("Expected Key Vault link, but didn't find one") } if !hasVMLink { t.Error("Expected VM link, but didn't find one") } }) t.Run("Get_WithKeyVault_DifferentResourceGroup", func(t *testing.T) { extension := createAzureVirtualMachineExtension(extensionName, vmName) extension.Properties.ProtectedSettingsFromKeyVault = &armcompute.KeyVaultSecretReference{ SourceVault: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/different-rg/providers/Microsoft.KeyVault/vaults/test-keyvault"), }, SecretURL: new("https://test-keyvault.vault.azure.net/secrets/test-secret/version"), } mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{ VirtualMachineExtension: *extension, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() hasKeyVaultLink := false hasDNSLink := false expectedScope := subscriptionID + ".different-rg" expectedDNSName := "test-keyvault.vault.azure.net" for _, liq := range linkedQueries { if liq.GetQuery().GetType() == azureshared.KeyVaultVault.String() { hasKeyVaultLink = true if liq.GetQuery().GetScope() != expectedScope { t.Errorf("Expected scope %s for Key Vault in different resource group, got %s", expectedScope, liq.GetQuery().GetScope()) } } if liq.GetQuery().GetType() == stdlib.NetworkDNS.String() { if liq.GetQuery().GetQuery() == expectedDNSName { hasDNSLink = true if liq.GetQuery().GetScope() != "global" { t.Errorf("Expected scope 'global' for DNS link, got %s", liq.GetQuery().GetScope()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected method SEARCH for DNS link, got %v", liq.GetQuery().GetMethod()) } } } } if !hasKeyVaultLink { t.Error("Expected Key Vault link, but didn't find one") } if !hasDNSLink { t.Error("Expected DNS link from SecretURL, but didn't find one") } }) t.Run("Get_WithSettingsURL", func(t *testing.T) { extension := createAzureVirtualMachineExtensionWithSettingsURL(extensionName, vmName) mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{ VirtualMachineExtension: *extension, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() hasHTTPLink := false hasDNSLink := false for _, liq := range linkedQueries { if liq.GetQuery().GetType() == stdlib.NetworkHTTP.String() { hasHTTPLink = true if liq.GetQuery().GetQuery() != "https://example.com/scripts/script.sh" { t.Errorf("Expected HTTP link query 'https://example.com/scripts/script.sh', got %s", liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected HTTP method SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != "global" { t.Errorf("Expected HTTP scope 'global', got %s", liq.GetQuery().GetScope()) } } if liq.GetQuery().GetType() == stdlib.NetworkDNS.String() { hasDNSLink = true if liq.GetQuery().GetQuery() != "example.com" { t.Errorf("Expected DNS link query 'example.com', got %s", liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected DNS method SEARCH, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != "global" { t.Errorf("Expected DNS scope 'global', got %s", liq.GetQuery().GetScope()) } } } if !hasHTTPLink { t.Error("Expected HTTP link from settings URL, but didn't find one") } if !hasDNSLink { t.Error("Expected DNS link from settings URL, but didn't find one") } }) t.Run("Get_WithSettingsIP", func(t *testing.T) { extension := createAzureVirtualMachineExtensionWithSettingsIP(extensionName, vmName) mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{ VirtualMachineExtension: *extension, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() hasIPLink := false for _, liq := range linkedQueries { if liq.GetQuery().GetType() == stdlib.NetworkIP.String() { hasIPLink = true if liq.GetQuery().GetQuery() != "10.0.0.1" { t.Errorf("Expected IP link query '10.0.0.1', got %s", liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected IP method GET, got %s", liq.GetQuery().GetMethod()) } if liq.GetQuery().GetScope() != "global" { t.Errorf("Expected IP scope 'global', got %s", liq.GetQuery().GetScope()) } } } if !hasIPLink { t.Error("Expected IP link from settings, but didn't find one") } }) t.Run("Get_WithProtectedSettings", func(t *testing.T) { extension := createAzureVirtualMachineExtensionWithProtectedSettings(extensionName, vmName) mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{ VirtualMachineExtension: *extension, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() hasHTTPLink := false hasDNSLink := false for _, liq := range linkedQueries { if liq.GetQuery().GetType() == stdlib.NetworkHTTP.String() { hasHTTPLink = true if liq.GetQuery().GetQuery() != "https://api.example.com/v1" { t.Errorf("Expected HTTP link query 'https://api.example.com/v1', got %s", liq.GetQuery().GetQuery()) } } if liq.GetQuery().GetType() == stdlib.NetworkDNS.String() { hasDNSLink = true if liq.GetQuery().GetQuery() != "api.example.com" { t.Errorf("Expected DNS link query 'api.example.com', got %s", liq.GetQuery().GetQuery()) } } } if !hasHTTPLink { t.Error("Expected HTTP link from protected settings, but didn't find one") } if !hasDNSLink { t.Error("Expected DNS link from protected settings, but didn't find one") } }) t.Run("Get_WithAllLinks", func(t *testing.T) { extension := createAzureVirtualMachineExtensionWithAllLinks(extensionName, vmName) mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{ VirtualMachineExtension: *extension, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatal("Expected linked queries, but got none") } // Should have multiple links: VM, Key Vault, HTTP, DNS, IP if len(linkedQueries) < 5 { t.Errorf("Expected at least 5 linked queries, got %d", len(linkedQueries)) } linkTypes := make(map[string]int) for _, liq := range linkedQueries { linkTypes[liq.GetQuery().GetType()]++ } if linkTypes[azureshared.ComputeVirtualMachine.String()] != 1 { t.Errorf("Expected 1 VM link, got %d", linkTypes[azureshared.ComputeVirtualMachine.String()]) } if linkTypes[azureshared.KeyVaultVault.String()] != 1 { t.Errorf("Expected 1 Key Vault link, got %d", linkTypes[azureshared.KeyVaultVault.String()]) } }) t.Run("Get_ErrorHandling", func(t *testing.T) { t.Run("InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup // Test with too few query parts (single segment - adapter rejects before calling wrapper) _, qErr := adapter.Get(ctx, scope, "only-vm-name", true) if qErr == nil { t.Error("Expected error for invalid query parts, got nil") } // Note: "too many" query parts are coalesced by the standard adapter (trailing segments // merged into the last part), so the wrapper always receives exactly 2 parts and would // call the client. We only test "too few" here to avoid requiring a mock Get expectation. }) t.Run("EmptyVirtualMachineName", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup _, qErr := adapter.Get(ctx, scope, shared.CompositeLookupKey("", extensionName), true) if qErr == nil { t.Error("Expected error for empty virtual machine name, got nil") } }) t.Run("EmptyExtensionName", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup _, qErr := adapter.Get(ctx, scope, shared.CompositeLookupKey(vmName, ""), true) if qErr == nil { t.Error("Expected error for empty extension name, got nil") } }) t.Run("ClientError", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, extensionName, nil).Return( armcompute.VirtualMachineExtensionsClientGetResponse{}, errors.New("client error")) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, extensionName) _, qErr := adapter.Get(ctx, scope, query, true) if qErr == nil { t.Error("Expected error from client, got nil") } }) }) t.Run("Search", func(t *testing.T) { extension1 := createAzureVirtualMachineExtension("extension-1", vmName) extension2 := createAzureVirtualMachineExtension("extension-2", vmName) mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().List(ctx, resourceGroup, vmName, nil).Return( armcompute.VirtualMachineExtensionsClientListResponse{ VirtualMachineExtensionsListResult: armcompute.VirtualMachineExtensionsListResult{ Value: []*armcompute.VirtualMachineExtension{ extension1, extension2, }, }, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup items, qErr := searchable.Search(ctx, scope, vmName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items, got %d", len(items)) } for _, item := range items { if item.GetType() != azureshared.ComputeVirtualMachineExtension.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeVirtualMachineExtension, item.GetType()) } } }) t.Run("Search_ErrorHandling", func(t *testing.T) { t.Run("InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup // Test with too many query parts - Search takes a single query string, // so we test this at the wrapper level by calling Search directly _, qErr := wrapper.Search(ctx, scope, vmName, "extra") if qErr == nil { t.Error("Expected error for too many query parts, got nil") } // Test with empty VM name _, err := searchable.Search(ctx, scope, "", true) if err == nil { t.Error("Expected error for empty VM name, got nil") } }) t.Run("ClientError", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().List(ctx, resourceGroup, vmName, nil).Return( armcompute.VirtualMachineExtensionsClientListResponse{}, errors.New("client error")) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup _, err := searchable.Search(ctx, scope, vmName, true) if err == nil { t.Error("Expected error from client, got nil") } }) t.Run("ExtensionWithoutName", func(t *testing.T) { extension := createAzureVirtualMachineExtension(extensionName, vmName) extension.Name = nil // Extension without name should be skipped mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) mockClient.EXPECT().List(ctx, resourceGroup, vmName, nil).Return( armcompute.VirtualMachineExtensionsClientListResponse{ VirtualMachineExtensionsListResult: armcompute.VirtualMachineExtensionsListResult{ Value: []*armcompute.VirtualMachineExtension{ extension, }, }, }, nil) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup items, qErr := searchable.Search(ctx, scope, vmName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Extension without name should be skipped if len(items) != 0 { t.Errorf("Expected 0 items (extension without name should be skipped), got %d", len(items)) } }) }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() expectedLinks := map[shared.ItemType]bool{ azureshared.ComputeVirtualMachine: true, azureshared.KeyVaultVault: true, stdlib.NetworkHTTP: true, stdlib.NetworkDNS: true, stdlib.NetworkIP: true, } for expectedType, expectedValue := range expectedLinks { if links[expectedType] != expectedValue { t.Errorf("Expected PotentialLinks[%s] = %v, got %v", expectedType, expectedValue, links[expectedType]) } } }) t.Run("GetLookups", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) != 2 { t.Errorf("Expected 2 lookups, got %d", len(lookups)) } // Verify the first lookup is for the virtual machine name if lookups[0].ItemType.String() != azureshared.ComputeVirtualMachine.String() { t.Errorf("Expected first lookup item type %s, got %s", azureshared.ComputeVirtualMachine, lookups[0].ItemType) } // Verify the second lookup is for the extension name if lookups[1].ItemType.String() != azureshared.ComputeVirtualMachineExtension.String() { t.Errorf("Expected second lookup item type %s, got %s", azureshared.ComputeVirtualMachineExtension, lookups[1].ItemType) } }) t.Run("SearchLookups", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) searchLookups := wrapper.SearchLookups() if len(searchLookups) != 1 { t.Errorf("Expected 1 search lookup, got %d", len(searchLookups)) } if len(searchLookups[0]) != 1 { t.Errorf("Expected 1 lookup in search lookups, got %d", len(searchLookups[0])) } // Verify the lookup is for the virtual machine name if searchLookups[0][0].ItemType.String() != azureshared.ComputeVirtualMachine.String() { t.Errorf("Expected search lookup item type %s, got %s", azureshared.ComputeVirtualMachine, searchLookups[0][0].ItemType) } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) != 1 { t.Errorf("Expected 1 Terraform mapping, got %d", len(mappings)) } if mappings[0].GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected Terraform method SEARCH, got %s", mappings[0].GetTerraformMethod()) } if mappings[0].GetTerraformQueryMap() != "azurerm_virtual_machine_extension.id" { t.Errorf("Expected Terraform query map 'azurerm_virtual_machine_extension.id', got %s", mappings[0].GetTerraformQueryMap()) } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() if len(permissions) != 1 { t.Errorf("Expected 1 IAM permission, got %d", len(permissions)) } expectedPermission := "Microsoft.Compute/virtualMachines/extensions/read" if permissions[0] != expectedPermission { t.Errorf("Expected IAM permission '%s', got '%s'", expectedPermission, permissions[0]) } }) t.Run("PredefinedRole", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineExtensionsClient(ctrl) wrapper := manual.NewComputeVirtualMachineExtension(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // PredefinedRole is available on the wrapper, not the adapter role := wrapper.(interface{ PredefinedRole() string }).PredefinedRole() expectedRole := "Reader" if role != expectedRole { t.Errorf("Expected predefined role '%s', got '%s'", expectedRole, role) } }) } func createAzureVirtualMachineExtension(extensionName, vmName string) *armcompute.VirtualMachineExtension { return &armcompute.VirtualMachineExtension{ Name: new(extensionName), Location: new("eastus"), Type: new("Microsoft.Compute/virtualMachines/extensions"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.VirtualMachineExtensionProperties{ Publisher: new("Microsoft.Compute"), Type: new("CustomScriptExtension"), TypeHandlerVersion: new("1.10"), ProvisioningState: new("Succeeded"), }, } } func createAzureVirtualMachineExtensionWithKeyVault(extensionName, vmName string) *armcompute.VirtualMachineExtension { extension := createAzureVirtualMachineExtension(extensionName, vmName) extension.Properties.ProtectedSettingsFromKeyVault = &armcompute.KeyVaultSecretReference{ SourceVault: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-keyvault"), }, } return extension } func createAzureVirtualMachineExtensionWithSettingsURL(extensionName, vmName string) *armcompute.VirtualMachineExtension { extension := createAzureVirtualMachineExtension(extensionName, vmName) extension.Properties.Settings = map[string]any{ "fileUris": []any{ "https://example.com/scripts/script.sh", }, "commandToExecute": "bash script.sh", } return extension } func createAzureVirtualMachineExtensionWithSettingsIP(extensionName, vmName string) *armcompute.VirtualMachineExtension { extension := createAzureVirtualMachineExtension(extensionName, vmName) extension.Properties.Settings = map[string]any{ "serverIP": "10.0.0.1", "port": 8080, } return extension } func createAzureVirtualMachineExtensionWithProtectedSettings(extensionName, vmName string) *armcompute.VirtualMachineExtension { extension := createAzureVirtualMachineExtension(extensionName, vmName) extension.Properties.ProtectedSettings = map[string]any{ "storageAccountName": "mystorageaccount", "storageAccountKey": "secret-key", "endpoint": "https://api.example.com/v1", } return extension } func createAzureVirtualMachineExtensionWithAllLinks(extensionName, vmName string) *armcompute.VirtualMachineExtension { extension := createAzureVirtualMachineExtension(extensionName, vmName) extension.Properties.ProtectedSettingsFromKeyVault = &armcompute.KeyVaultSecretReference{ SourceVault: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-keyvault"), }, } extension.Properties.Settings = map[string]any{ "fileUris": []any{ "https://example.com/scripts/script.sh", }, "serverIP": "10.0.0.1", } extension.Properties.ProtectedSettings = map[string]any{ "endpoint": "https://api.example.com/v1", } return extension } ================================================ FILE: sources/azure/manual/compute-virtual-machine-run-command.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeVirtualMachineRunCommandLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeVirtualMachineRunCommand) type computeVirtualMachineRunCommandWrapper struct { client clients.VirtualMachineRunCommandsClient *azureshared.MultiResourceGroupBase } func NewComputeVirtualMachineRunCommand(client clients.VirtualMachineRunCommandsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &computeVirtualMachineRunCommandWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeVirtualMachineRunCommand, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-run-commands/get-by-virtual-machine?view=rest-compute-2025-04-01&tabs=HTTP func (s computeVirtualMachineRunCommandWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if scope == "" { return nil, azureshared.QueryError(errors.New("scope cannot be empty"), scope, s.Type()) } if len(queryParts) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires exactly 2 query parts: virtualMachineName and runCommandName", Scope: scope, ItemType: s.Type(), } } virtualMachineName := queryParts[0] runCommandName := queryParts[1] if virtualMachineName == "" { return nil, azureshared.QueryError(errors.New("virtualMachineName cannot be empty"), scope, s.Type()) } if runCommandName == "" { return nil, azureshared.QueryError(errors.New("runCommandName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.GetByVirtualMachine(ctx, rgScope.ResourceGroup, virtualMachineName, runCommandName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureVirtualMachineRunCommandToSDPItem(&resp.VirtualMachineRunCommand, virtualMachineName, scope) } func (s computeVirtualMachineRunCommandWrapper) azureVirtualMachineRunCommandToSDPItem(runCommand *armcompute.VirtualMachineRunCommand, virtualMachineName, scope string) (*sdp.Item, *sdp.QueryError) { if runCommand == nil { return nil, azureshared.QueryError(errors.New("runCommand is nil"), scope, s.Type()) } if runCommand.Name == nil { return nil, azureshared.QueryError(errors.New("runCommand name is nil"), scope, s.Type()) } attributes, err := shared.ToAttributesWithExclude(runCommand, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(virtualMachineName, *runCommand.Name)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: s.Type(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(runCommand.Tags), } // Link to Virtual Machine (parent resource) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get?view=rest-compute-2025-04-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}?api-version=2025-04-01 if virtualMachineName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: virtualMachineName, Scope: scope, }, }) } // Process properties for blob URIs and script URIs if runCommand.Properties != nil { // Helper function to process managed identity and create links to User Assigned Managed Identity // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} processManagedIdentity := func(managedIdentity *armcompute.RunCommandManagedIdentity) { if managedIdentity == nil { return } // Managed identity can be referenced by ClientID or ObjectID // Since we don't have the resource name, we use SEARCH method with ClientID/ObjectID var identityQuery string if managedIdentity.ClientID != nil && *managedIdentity.ClientID != "" { identityQuery = *managedIdentity.ClientID } else if managedIdentity.ObjectID != nil && *managedIdentity.ObjectID != "" { identityQuery = *managedIdentity.ObjectID } else { // System-assigned identity (empty object) - no link needed return } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_SEARCH, Query: identityQuery, Scope: scope, }, }) } // Helper function to process blob URI and create links to Storage Account and Blob Container processBlobURI := func(blobURI *string) { if blobURI == nil || *blobURI == "" { return } uri := *blobURI storageAccountName := azureshared.ExtractStorageAccountNameFromBlobURI(uri) if storageAccountName != "" { // Link to Storage Account // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/storage-accounts/get-properties?view=rest-storagerp-2025-06-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{accountName}?api-version=2025-06-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) // Extract container name and link to Blob Container containerName := azureshared.ExtractContainerNameFromBlobURI(uri) if containerName != "" { // Link to Blob Container // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/blob-containers/get?view=rest-storagerp-2025-06-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{accountName}/blobServices/default/containers/{containerName}?api-version=2025-06-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageBlobContainer.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(storageAccountName, containerName), Scope: scope, }, }) } } // Link to stdlib.NetworkHTTP and DNS only for non-blob URIs // For blob URIs, the StorageBlobContainer already has these links if storageAccountName == "" && (strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://")) { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: uri, Scope: "global", }, }) // Link to DNS name (standard library) from URI dnsName := azureshared.ExtractDNSFromURL(uri) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } // Link to Storage Account and Blob Container from outputBlobUri // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-run-commands/get-by-virtual-machine?view=rest-compute-2025-04-01&tabs=HTTP if runCommand.Properties.OutputBlobURI != nil { processBlobURI(runCommand.Properties.OutputBlobURI) } // Link to Managed Identity from outputBlobManagedIdentity if runCommand.Properties.OutputBlobManagedIdentity != nil { processManagedIdentity(runCommand.Properties.OutputBlobManagedIdentity) } // Link to Storage Account and Blob Container from errorBlobUri if runCommand.Properties.ErrorBlobURI != nil { processBlobURI(runCommand.Properties.ErrorBlobURI) } // Link to Managed Identity from errorBlobManagedIdentity if runCommand.Properties.ErrorBlobManagedIdentity != nil { processManagedIdentity(runCommand.Properties.ErrorBlobManagedIdentity) } // Link to Storage Account, Blob Container, HTTP, and DNS from source.scriptUri if runCommand.Properties.Source != nil && runCommand.Properties.Source.ScriptURI != nil { processBlobURI(runCommand.Properties.Source.ScriptURI) } // Link to Managed Identity from source.scriptUriManagedIdentity if runCommand.Properties.Source != nil && runCommand.Properties.Source.ScriptURIManagedIdentity != nil { processManagedIdentity(runCommand.Properties.Source.ScriptURIManagedIdentity) } } return sdpItem, nil } func (s computeVirtualMachineRunCommandWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeVirtualMachineLookupByName, ComputeVirtualMachineRunCommandLookupByName, } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-run-commands/list-by-virtual-machine?view=rest-compute-2025-04-01&tabs=HTTP func (s computeVirtualMachineRunCommandWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("search requires exactly 1 query part: virtualMachineName"), scope, s.Type()) } virtualMachineName := queryParts[0] if virtualMachineName == "" { return nil, azureshared.QueryError(errors.New("virtualMachineName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.NewListByVirtualMachinePager(rgScope.ResourceGroup, virtualMachineName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, runCommand := range page.Value { if runCommand.Name == nil { continue } item, sdpErr := s.azureVirtualMachineRunCommandToSDPItem(runCommand, virtualMachineName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s computeVirtualMachineRunCommandWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(errors.New("search requires exactly 1 query part: virtualMachineName"), scope, s.Type())) return } virtualMachineName := queryParts[0] if virtualMachineName == "" { stream.SendError(azureshared.QueryError(errors.New("virtualMachineName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.NewListByVirtualMachinePager(rgScope.ResourceGroup, virtualMachineName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, runCommand := range page.Value { if runCommand.Name == nil { continue } item, sdpErr := s.azureVirtualMachineRunCommandToSDPItem(runCommand, virtualMachineName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s computeVirtualMachineRunCommandWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeVirtualMachineLookupByName, }, } } func (s computeVirtualMachineRunCommandWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ComputeVirtualMachine: true, azureshared.StorageAccount: true, azureshared.StorageBlobContainer: true, azureshared.ManagedIdentityUserAssignedIdentity: true, stdlib.NetworkHTTP: true, stdlib.NetworkDNS: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_machine_run_command#attributes-reference func (s computeVirtualMachineRunCommandWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_virtual_machine_run_command.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute#microsoftcompute func (s computeVirtualMachineRunCommandWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/virtualMachines/runCommands/read", } } func (s computeVirtualMachineRunCommandWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/compute-virtual-machine-run-command_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockVirtualMachineRunCommandsPager is a simple mock implementation of VirtualMachineRunCommandsPager type mockVirtualMachineRunCommandsPager struct { pages []armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse index int } func (m *mockVirtualMachineRunCommandsPager) More() bool { return m.index < len(m.pages) } func (m *mockVirtualMachineRunCommandsPager) NextPage(ctx context.Context) (armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse, error) { if m.index >= len(m.pages) { return armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorVirtualMachineRunCommandsPager is a mock pager that always returns an error type errorVirtualMachineRunCommandsPager struct{} func (e *errorVirtualMachineRunCommandsPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorVirtualMachineRunCommandsPager) NextPage(ctx context.Context) (armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse, error) { return armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse{}, errors.New("pager error") } // testVirtualMachineRunCommandsClient wraps the mock to implement the correct interface type testVirtualMachineRunCommandsClient struct { *mocks.MockVirtualMachineRunCommandsClient pager clients.VirtualMachineRunCommandsPager } func (t *testVirtualMachineRunCommandsClient) NewListByVirtualMachinePager(resourceGroupName, virtualMachineName string, options *armcompute.VirtualMachineRunCommandsClientListByVirtualMachineOptions) clients.VirtualMachineRunCommandsPager { return t.pager } func createAzureVirtualMachineRunCommand(runCommandName, vmName string) *armcompute.VirtualMachineRunCommand { return &armcompute.VirtualMachineRunCommand{ Name: new(runCommandName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.VirtualMachineRunCommandProperties{ ProvisioningState: new("Succeeded"), }, } } func createAzureVirtualMachineRunCommandWithBlobURIs(runCommandName, vmName string) *armcompute.VirtualMachineRunCommand { runCommand := createAzureVirtualMachineRunCommand(runCommandName, vmName) runCommand.Properties.OutputBlobURI = new("https://mystorageaccount.blob.core.windows.net/outputcontainer/output.log") runCommand.Properties.ErrorBlobURI = new("https://mystorageaccount.blob.core.windows.net/errorcontainer/error.log") return runCommand } func createAzureVirtualMachineRunCommandWithHTTPScriptURI(runCommandName, vmName string) *armcompute.VirtualMachineRunCommand { runCommand := createAzureVirtualMachineRunCommand(runCommandName, vmName) runCommand.Properties.Source = &armcompute.VirtualMachineRunCommandScriptSource{ ScriptURI: new("https://example.com/scripts/script.sh"), } return runCommand } func createAzureVirtualMachineRunCommandWithAllLinks(runCommandName, vmName string) *armcompute.VirtualMachineRunCommand { runCommand := createAzureVirtualMachineRunCommand(runCommandName, vmName) runCommand.Properties.OutputBlobURI = new("https://mystorageaccount.blob.core.windows.net/outputcontainer/output.log") runCommand.Properties.ErrorBlobURI = new("https://mystorageaccount.blob.core.windows.net/errorcontainer/error.log") runCommand.Properties.Source = &armcompute.VirtualMachineRunCommandScriptSource{ ScriptURI: new("https://mystorageaccount.blob.core.windows.net/scripts/script.sh"), } return runCommand } func TestComputeVirtualMachineRunCommand(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" vmName := "test-vm" runCommandName := "test-run-command" t.Run("Get", func(t *testing.T) { runCommand := createAzureVirtualMachineRunCommand(runCommandName, vmName) mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) mockClient.EXPECT().GetByVirtualMachine(ctx, resourceGroup, vmName, runCommandName, nil).Return( armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse{ VirtualMachineRunCommand: *runCommand, }, nil) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, runCommandName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeVirtualMachineRunCommand.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeVirtualMachineRunCommand, sdpItem.GetType()) } expectedUniqueAttr := shared.CompositeLookupKey(vmName, runCommandName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttr { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttr, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Virtual Machine (parent resource) ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: vmName, ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithBlobURIs", func(t *testing.T) { runCommand := createAzureVirtualMachineRunCommandWithBlobURIs(runCommandName, vmName) mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) mockClient.EXPECT().GetByVirtualMachine(ctx, resourceGroup, vmName, runCommandName, nil).Return( armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse{ VirtualMachineRunCommand: *runCommand, }, nil) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, runCommandName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify linked queries linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatal("Expected linked queries, but got none") } // Check for Storage Account links (from outputBlobUri and errorBlobUri) storageAccountLinks := 0 blobContainerLinks := 0 dnsLinks := 0 httpLinks := 0 vmLinks := 0 for _, liq := range linkedQueries { switch liq.GetQuery().GetType() { case azureshared.StorageAccount.String(): storageAccountLinks++ if liq.GetQuery().GetQuery() != "mystorageaccount" { t.Errorf("Expected storage account name 'mystorageaccount', got %s", liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected method GET, got %s", liq.GetQuery().GetMethod()) } case azureshared.StorageBlobContainer.String(): blobContainerLinks++ expectedQuery := shared.CompositeLookupKey("mystorageaccount", "outputcontainer") if liq.GetQuery().GetQuery() != expectedQuery && liq.GetQuery().GetQuery() != shared.CompositeLookupKey("mystorageaccount", "errorcontainer") { t.Errorf("Expected blob container query to contain 'mystorageaccount' and container name, got %s", liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected method GET, got %s", liq.GetQuery().GetMethod()) } case stdlib.NetworkDNS.String(): dnsLinks++ if liq.GetQuery().GetScope() != "global" { t.Errorf("Expected DNS scope 'global', got %s", liq.GetQuery().GetScope()) } case stdlib.NetworkHTTP.String(): httpLinks++ if liq.GetQuery().GetScope() != "global" { t.Errorf("Expected HTTP scope 'global', got %s", liq.GetQuery().GetScope()) } case azureshared.ComputeVirtualMachine.String(): vmLinks++ } } // We should have at least 2 Storage Account links (from outputBlobUri and errorBlobUri) if storageAccountLinks < 2 { t.Errorf("Expected at least 2 Storage Account links, got %d", storageAccountLinks) } // We should have at least 2 Blob Container links if blobContainerLinks < 2 { t.Errorf("Expected at least 2 Blob Container links, got %d", blobContainerLinks) } // DNS and HTTP links should NOT be present for blob URIs // The StorageBlobContainer would have those links instead if dnsLinks > 0 { t.Errorf("Expected no DNS links for blob URIs (StorageBlobContainer has them), got %d", dnsLinks) } if httpLinks > 0 { t.Errorf("Expected no HTTP links for blob URIs (StorageBlobContainer has them), got %d", httpLinks) } // We should have 1 VM link if vmLinks != 1 { t.Errorf("Expected 1 VM link, got %d", vmLinks) } }) t.Run("Get_WithHTTPScriptURI", func(t *testing.T) { runCommand := createAzureVirtualMachineRunCommandWithHTTPScriptURI(runCommandName, vmName) mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) mockClient.EXPECT().GetByVirtualMachine(ctx, resourceGroup, vmName, runCommandName, nil).Return( armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse{ VirtualMachineRunCommand: *runCommand, }, nil) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, runCommandName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() hasHTTPLink := false hasDNSLink := false for _, liq := range linkedQueries { if liq.GetQuery().GetType() == stdlib.NetworkHTTP.String() { hasHTTPLink = true if liq.GetQuery().GetQuery() != "https://example.com/scripts/script.sh" { t.Errorf("Expected HTTP link query 'https://example.com/scripts/script.sh', got %s", liq.GetQuery().GetQuery()) } if liq.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected HTTP method SEARCH, got %s", liq.GetQuery().GetMethod()) } } if liq.GetQuery().GetType() == stdlib.NetworkDNS.String() { hasDNSLink = true if liq.GetQuery().GetQuery() != "example.com" { t.Errorf("Expected DNS link query 'example.com', got %s", liq.GetQuery().GetQuery()) } } } if !hasHTTPLink { t.Error("Expected HTTP link from script URI, but didn't find one") } if !hasDNSLink { t.Error("Expected DNS link from script URI, but didn't find one") } }) t.Run("Get_WithAllLinks", func(t *testing.T) { runCommand := createAzureVirtualMachineRunCommandWithAllLinks(runCommandName, vmName) mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) mockClient.EXPECT().GetByVirtualMachine(ctx, resourceGroup, vmName, runCommandName, nil).Return( armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse{ VirtualMachineRunCommand: *runCommand, }, nil) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup query := shared.CompositeLookupKey(vmName, runCommandName) sdpItem, qErr := adapter.Get(ctx, scope, query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) == 0 { t.Fatal("Expected linked queries, but got none") } // Should have multiple links: VM, Storage Accounts, Blob Containers, DNS, HTTP // The exact count depends on how many unique resources are linked if len(linkedQueries) < 5 { t.Errorf("Expected at least 5 linked queries, got %d", len(linkedQueries)) } }) t.Run("Get_ErrorHandling", func(t *testing.T) { t.Run("EmptyScope", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, "", shared.CompositeLookupKey(vmName, runCommandName), true) if qErr == nil { t.Error("Expected error for empty scope, got nil") } }) t.Run("WrongQueryPartsCount", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup _, qErr := adapter.Get(ctx, scope, vmName, true) if qErr == nil { t.Error("Expected error for wrong query parts count, got nil") } }) t.Run("EmptyVirtualMachineName", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup _, qErr := adapter.Get(ctx, scope, shared.CompositeLookupKey("", runCommandName), true) if qErr == nil { t.Error("Expected error for empty virtual machine name, got nil") } }) t.Run("EmptyRunCommandName", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup _, qErr := adapter.Get(ctx, scope, shared.CompositeLookupKey(vmName, ""), true) if qErr == nil { t.Error("Expected error for empty run command name, got nil") } }) t.Run("ClientError", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) mockClient.EXPECT().GetByVirtualMachine(ctx, resourceGroup, vmName, runCommandName, nil).Return( armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse{}, errors.New("client error")) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := subscriptionID + "." + resourceGroup _, qErr := adapter.Get(ctx, scope, shared.CompositeLookupKey(vmName, runCommandName), true) if qErr == nil { t.Error("Expected error from client, got nil") } }) }) t.Run("Search", func(t *testing.T) { runCommand1 := createAzureVirtualMachineRunCommand("run-command-1", vmName) runCommand2 := createAzureVirtualMachineRunCommand("run-command-2", vmName) mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) mockPager := &mockVirtualMachineRunCommandsPager{ pages: []armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse{ { VirtualMachineRunCommandsListResult: armcompute.VirtualMachineRunCommandsListResult{ Value: []*armcompute.VirtualMachineRunCommand{runCommand1, runCommand2}, }, }, }, index: 0, } testClient := &testVirtualMachineRunCommandsClient{ MockVirtualMachineRunCommandsClient: mockClient, pager: mockPager, } wrapper := manual.NewComputeVirtualMachineRunCommand(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup sdpItems, err := searchable.Search(ctx, scope, vmName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } // Verify items have correct types for _, item := range sdpItems { if item.GetType() != azureshared.ComputeVirtualMachineRunCommand.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeVirtualMachineRunCommand, item.GetType()) } } }) t.Run("Search_ErrorHandling", func(t *testing.T) { t.Run("WrongQueryPartsCount", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup _, err := searchable.Search(ctx, scope, shared.CompositeLookupKey(vmName, runCommandName), true) if err == nil { t.Error("Expected error for wrong query parts count, got nil") } }) t.Run("EmptyVirtualMachineName", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup _, err := searchable.Search(ctx, scope, "", true) if err == nil { t.Error("Expected error for empty virtual machine name, got nil") } }) t.Run("PagerError", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) errorPager := &errorVirtualMachineRunCommandsPager{} testClient := &testVirtualMachineRunCommandsClient{ MockVirtualMachineRunCommandsClient: mockClient, pager: errorPager, } wrapper := manual.NewComputeVirtualMachineRunCommand(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup _, err := searchable.Search(ctx, scope, vmName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("SkipItemsWithoutName", func(t *testing.T) { runCommandWithName := createAzureVirtualMachineRunCommand("run-command-1", vmName) runCommandWithoutName := &armcompute.VirtualMachineRunCommand{ Location: new("eastus"), Properties: &armcompute.VirtualMachineRunCommandProperties{ ProvisioningState: new("Succeeded"), }, } mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) mockPager := &mockVirtualMachineRunCommandsPager{ pages: []armcompute.VirtualMachineRunCommandsClientListByVirtualMachineResponse{ { VirtualMachineRunCommandsListResult: armcompute.VirtualMachineRunCommandsListResult{ Value: []*armcompute.VirtualMachineRunCommand{runCommandWithName, runCommandWithoutName}, }, }, }, index: 0, } testClient := &testVirtualMachineRunCommandsClient{ MockVirtualMachineRunCommandsClient: mockClient, pager: mockPager, } wrapper := manual.NewComputeVirtualMachineRunCommand(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } scope := subscriptionID + "." + resourceGroup sdpItems, err := searchable.Search(ctx, scope, vmName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (skipping item without name), got: %d", len(sdpItems)) } }) }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() expectedLinks := map[shared.ItemType]bool{ azureshared.ComputeVirtualMachine: true, azureshared.StorageAccount: true, azureshared.StorageBlobContainer: true, azureshared.ManagedIdentityUserAssignedIdentity: true, stdlib.NetworkHTTP: true, stdlib.NetworkDNS: true, } for expectedType, expectedValue := range expectedLinks { if potentialLinks[expectedType] != expectedValue { t.Errorf("Expected PotentialLinks[%s] = %v, got %v", expectedType, expectedValue, potentialLinks[expectedType]) } } // Verify all expected links are present for expectedType := range expectedLinks { if _, exists := potentialLinks[expectedType]; !exists { t.Errorf("Expected PotentialLinks to include %s, but it's missing", expectedType) } } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() expectedPermission := "Microsoft.Compute/virtualMachines/runCommands/read" if len(permissions) != 1 { t.Fatalf("Expected 1 permission, got: %d", len(permissions)) } if permissions[0] != expectedPermission { t.Errorf("Expected permission '%s', got '%s'", expectedPermission, permissions[0]) } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) != 1 { t.Fatalf("Expected 1 Terraform mapping, got: %d", len(mappings)) } if mappings[0].GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected Terraform method SEARCH, got %s", mappings[0].GetTerraformMethod()) } if mappings[0].GetTerraformQueryMap() != "azurerm_virtual_machine_run_command.id" { t.Errorf("Expected Terraform query map 'azurerm_virtual_machine_run_command.id', got '%s'", mappings[0].GetTerraformQueryMap()) } }) t.Run("GetLookups", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) != 2 { t.Fatalf("Expected 2 lookups, got: %d", len(lookups)) } }) t.Run("SearchLookups", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineRunCommandsClient(ctrl) wrapper := manual.NewComputeVirtualMachineRunCommand(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) searchLookups := wrapper.SearchLookups() if len(searchLookups) != 1 { t.Fatalf("Expected 1 search lookup set, got: %d", len(searchLookups)) } if len(searchLookups[0]) != 1 { t.Fatalf("Expected 1 lookup in search lookup set, got: %d", len(searchLookups[0])) } }) } ================================================ FILE: sources/azure/manual/compute-virtual-machine-scale-set.go ================================================ package manual import ( "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeVirtualMachineScaleSetLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeVirtualMachineScaleSet) type computeVirtualMachineScaleSetWrapper struct { client clients.VirtualMachineScaleSetsClient *azureshared.MultiResourceGroupBase } func NewComputeVirtualMachineScaleSet(client clients.VirtualMachineScaleSetsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeVirtualMachineScaleSetWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeVirtualMachineScaleSet, ), } } // ref: https://linear.app/overmind/issue/ENG-2114/create-microsoftcomputevirtualmachinescalesets-adapter func (c computeVirtualMachineScaleSetWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, scaleSet := range page.Value { item, sdpErr := c.azureVirtualMachineScaleSetToSDPItem(scaleSet, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeVirtualMachineScaleSetWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, scaleSet := range page.Value { item, sdpErr := c.azureVirtualMachineScaleSetToSDPItem(scaleSet, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-scale-sets/get?view=rest-compute-2025-04-01&tabs=HTTP func (c computeVirtualMachineScaleSetWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1"), scope, c.Type()) } scaleSetName := queryParts[0] if scaleSetName == "" { return nil, azureshared.QueryError(errors.New("scaleSetName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } scaleSet, err := c.client.Get(ctx, rgScope.ResourceGroup, scaleSetName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureVirtualMachineScaleSetToSDPItem(&scaleSet.VirtualMachineScaleSet, scope) } func (c computeVirtualMachineScaleSetWrapper) azureVirtualMachineScaleSetToSDPItem(scaleSet *armcompute.VirtualMachineScaleSet, scope string) (*sdp.Item, *sdp.QueryError) { if scaleSet.Name == nil { return nil, azureshared.QueryError(errors.New("scaleSetName is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(scaleSet, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ComputeVirtualMachineScaleSet.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(scaleSet.Tags), } scaleSetName := *scaleSet.Name // Track added links to prevent duplicates (key: type:query:scope) addedLinks := make(map[string]bool) addLink := func(link *sdp.LinkedItemQuery) { key := fmt.Sprintf("%s:%s:%s", link.GetQuery().GetType(), link.GetQuery().GetQuery(), link.GetQuery().GetScope()) if !addedLinks[key] { addedLinks[key] = true sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, link) } } // Link to extensions (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-scale-set-extensions/get?view=rest-compute-2025-04-01&tabs=HTTP if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.ExtensionProfile != nil && scaleSet.Properties.VirtualMachineProfile.ExtensionProfile.Extensions != nil { for _, extension := range scaleSet.Properties.VirtualMachineProfile.ExtensionProfile.Extensions { if extension.Name != nil && scaleSetName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachineExtension.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(scaleSetName, *extension.Name), Scope: scope, }, }) } } } // Link to VM instances (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-scale-set-vms/list?view=rest-compute-2025-04-01&tabs=HTTP // Note: VM instances are listed via SEARCH method since we can list all instances for a VMSS if scaleSetName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_SEARCH, Query: scaleSetName, Scope: scope, }, }) } // Link to network resources if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.NetworkProfile != nil && scaleSet.Properties.VirtualMachineProfile.NetworkProfile.NetworkInterfaceConfigurations != nil { for _, nicConfig := range scaleSet.Properties.VirtualMachineProfile.NetworkProfile.NetworkInterfaceConfigurations { if nicConfig.Properties != nil { // Link to Network Security Group // Reference: https://learn.microsoft.com/en-us/rest/api/virtual-network/network-security-groups/get if nicConfig.Properties.NetworkSecurityGroup != nil && nicConfig.Properties.NetworkSecurityGroup.ID != nil { nsgName := azureshared.ExtractResourceName(*nicConfig.Properties.NetworkSecurityGroup.ID) if nsgName != "" { // Check if NSG is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*nicConfig.Properties.NetworkSecurityGroup.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: nsgName, Scope: extractedScope, }, }) } } // Link to IP configurations if nicConfig.Properties.IPConfigurations != nil { for _, ipConfig := range nicConfig.Properties.IPConfigurations { if ipConfig.Properties != nil { // Link to Subnet // Reference: https://learn.microsoft.com/en-us/rest/api/virtual-network/subnets/get if ipConfig.Properties.Subnet != nil && ipConfig.Properties.Subnet.ID != nil { subnetID := *ipConfig.Properties.Subnet.ID // Extract virtual network name and subnet name from ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName} parts := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(parts) >= 2 { vnetName := parts[0] subnetName := parts[1] // Check if subnet is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(subnetID) if extractedScope == "" { extractedScope = scope } // Link to Virtual Network // Reference: https://learn.microsoft.com/en-us/rest/api/virtual-network/virtual-networks/get sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: extractedScope, }, }) // Link to Subnet sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: extractedScope, }, }) } } // Link to Public IP Address Configuration // Reference: https://learn.microsoft.com/en-us/rest/api/virtual-network/public-ip-addresses/get if ipConfig.Properties.PublicIPAddressConfiguration != nil && ipConfig.Properties.PublicIPAddressConfiguration.Properties != nil && ipConfig.Properties.PublicIPAddressConfiguration.Properties.PublicIPPrefix != nil && ipConfig.Properties.PublicIPAddressConfiguration.Properties.PublicIPPrefix.ID != nil { publicIPPrefixName := azureshared.ExtractResourceName(*ipConfig.Properties.PublicIPAddressConfiguration.Properties.PublicIPPrefix.ID) if publicIPPrefixName != "" { // Check if Public IP Prefix is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*ipConfig.Properties.PublicIPAddressConfiguration.Properties.PublicIPPrefix.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPPrefix.String(), Method: sdp.QueryMethod_GET, Query: publicIPPrefixName, Scope: extractedScope, }, }) } } // Link to Load Balancer Backend Address Pools // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/backend-address-pools/get if ipConfig.Properties.LoadBalancerBackendAddressPools != nil { for _, poolRef := range ipConfig.Properties.LoadBalancerBackendAddressPools { if poolRef.ID != nil { poolID := *poolRef.ID // Extract load balancer name and pool name from ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/loadBalancers/{lbName}/backendAddressPools/{poolName} parts := azureshared.ExtractPathParamsFromResourceID(poolID, []string{"loadBalancers", "backendAddressPools"}) if len(parts) >= 2 { lbName := parts[0] poolName := parts[1] // Check if Load Balancer is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(poolID) if extractedScope == "" { extractedScope = scope } // Link to Load Balancer (deduplicated - same LB may be referenced by multiple child resources) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancers/get addLink(&sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: lbName, Scope: extractedScope, }, }) // Link to Backend Address Pool sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(lbName, poolName), Scope: extractedScope, }, }) } } } } // Link to Load Balancer Inbound NAT Pools // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/inbound-nat-pools/get if ipConfig.Properties.LoadBalancerInboundNatPools != nil { for _, natPoolRef := range ipConfig.Properties.LoadBalancerInboundNatPools { if natPoolRef.ID != nil { natPoolID := *natPoolRef.ID // Extract load balancer name and NAT pool name from ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/loadBalancers/{lbName}/inboundNatPools/{poolName} parts := azureshared.ExtractPathParamsFromResourceID(natPoolID, []string{"loadBalancers", "inboundNatPools"}) if len(parts) >= 2 { lbName := parts[0] poolName := parts[1] // Check if Load Balancer is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(natPoolID) if extractedScope == "" { extractedScope = scope } // Link to Load Balancer (deduplicated - same LB may be referenced by multiple child resources) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancers/get addLink(&sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: lbName, Scope: extractedScope, }, }) // Link to Inbound NAT Pool sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(lbName, poolName), Scope: extractedScope, }, }) } } } } // Link to Application Gateway Backend Address Pools // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/backend-address-pools/get if ipConfig.Properties.ApplicationGatewayBackendAddressPools != nil { for _, agPoolRef := range ipConfig.Properties.ApplicationGatewayBackendAddressPools { if agPoolRef.ID != nil { agPoolID := *agPoolRef.ID // Extract application gateway name and pool name from ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/applicationGateways/{agName}/backendAddressPools/{poolName} parts := azureshared.ExtractPathParamsFromResourceID(agPoolID, []string{"applicationGateways", "backendAddressPools"}) if len(parts) >= 2 { agName := parts[0] poolName := parts[1] // Check if Application Gateway is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(agPoolID) if extractedScope == "" { extractedScope = scope } // Link to Application Gateway (deduplicated - same AG may be referenced by multiple child resources) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateways/get addLink(&sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGateway.String(), Method: sdp.QueryMethod_GET, Query: agName, Scope: extractedScope, }, }) // Link to Backend Address Pool sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(agName, poolName), Scope: extractedScope, }, }) } } } } // Link to Application Security Groups // Reference: https://learn.microsoft.com/en-us/rest/api/virtual-network/application-security-groups/get if ipConfig.Properties.ApplicationSecurityGroups != nil { for _, asgRef := range ipConfig.Properties.ApplicationSecurityGroups { if asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { // Check if Application Security Group is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: extractedScope, }, }) } } } } } } } } } } // Link to Load Balancer Health Probe // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancer-probes/get // Note: Health probe is at NetworkProfile level and doesn't require NetworkInterfaceConfigurations if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.NetworkProfile != nil && scaleSet.Properties.VirtualMachineProfile.NetworkProfile.HealthProbe != nil && scaleSet.Properties.VirtualMachineProfile.NetworkProfile.HealthProbe.ID != nil { probeID := *scaleSet.Properties.VirtualMachineProfile.NetworkProfile.HealthProbe.ID // Extract load balancer name and probe name from ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/loadBalancers/{lbName}/probes/{probeName} parts := azureshared.ExtractPathParamsFromResourceID(probeID, []string{"loadBalancers", "probes"}) if len(parts) >= 2 { lbName := parts[0] probeName := parts[1] // Check if Load Balancer is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(probeID) if extractedScope == "" { extractedScope = scope } // Link to Load Balancer (deduplicated - same LB may be referenced by multiple child resources) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancers/get addLink(&sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: lbName, Scope: extractedScope, }, }) // Link to Health Probe sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerProbe.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(lbName, probeName), Scope: extractedScope, }, }) } } // Link to storage resources if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.StorageProfile != nil { // Link to OS Disk Encryption Set // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if scaleSet.Properties.VirtualMachineProfile.StorageProfile.OSDisk != nil && scaleSet.Properties.VirtualMachineProfile.StorageProfile.OSDisk.ManagedDisk != nil && scaleSet.Properties.VirtualMachineProfile.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet != nil && scaleSet.Properties.VirtualMachineProfile.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet.ID != nil { encryptionSetName := azureshared.ExtractResourceName(*scaleSet.Properties.VirtualMachineProfile.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet.ID) if encryptionSetName != "" { // Check if Disk Encryption Set is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*scaleSet.Properties.VirtualMachineProfile.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } // Link to Data Disk Encryption Sets if scaleSet.Properties.VirtualMachineProfile.StorageProfile.DataDisks != nil { for _, dataDisk := range scaleSet.Properties.VirtualMachineProfile.StorageProfile.DataDisks { if dataDisk.ManagedDisk != nil && dataDisk.ManagedDisk.DiskEncryptionSet != nil && dataDisk.ManagedDisk.DiskEncryptionSet.ID != nil { encryptionSetName := azureshared.ExtractResourceName(*dataDisk.ManagedDisk.DiskEncryptionSet.ID) if encryptionSetName != "" { // Check if Disk Encryption Set is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*dataDisk.ManagedDisk.DiskEncryptionSet.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: encryptionSetName, Scope: extractedScope, }, }) } } } } // Link to Image (if custom image with ID) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/images/get if scaleSet.Properties.VirtualMachineProfile.StorageProfile.ImageReference != nil { imageRef := scaleSet.Properties.VirtualMachineProfile.StorageProfile.ImageReference // ImageReference can have: // 1. ID field for custom images: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/{imageName} // 2. SharedGalleryImageID for shared gallery images: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/images/{imageName}/versions/{version} // 3. CommunityGalleryImageID for community gallery images: /CommunityGalleries/{communityGalleryName}/Images/{imageName}/Versions/{version} // 4. Publisher/Offer/Sku for marketplace images (no ID, so we can't link to them) // Link to custom image if imageRef.ID != nil { imageID := *imageRef.ID imageName := azureshared.ExtractResourceName(imageID) if imageName != "" { // Check if Image is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(imageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: imageName, Scope: extractedScope, }, }) } } // Link to Shared Gallery Image // Reference: https://learn.microsoft.com/en-us/rest/api/compute/shared-gallery-images/get if imageRef.SharedGalleryImageID != nil { sharedGalleryImageID := *imageRef.SharedGalleryImageID if sharedGalleryImageID != "" { // Extract gallery name, image name, and version from the ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/images/{imageName}/versions/{version} parts := azureshared.ExtractPathParamsFromResourceID(sharedGalleryImageID, []string{"galleries", "images", "versions"}) if len(parts) >= 3 { galleryName := parts[0] imageName := parts[1] version := parts[2] // Check if gallery is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(sharedGalleryImageID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, version), Scope: extractedScope, }, }) } } } // Link to Community Gallery Image // Reference: https://learn.microsoft.com/en-us/rest/api/compute/community-gallery-images/get if imageRef.CommunityGalleryImageID != nil { communityGalleryImageID := *imageRef.CommunityGalleryImageID if communityGalleryImageID != "" { // Extract community gallery name, image name, and version from the ID // Format: /CommunityGalleries/{communityGalleryName}/Images/{imageName}/Versions/{version} // Note: Community gallery IDs don't follow standard Azure resource ID format parts := azureshared.ExtractPathParamsFromResourceID(communityGalleryImageID, []string{"CommunityGalleries", "Images", "Versions"}) if len(parts) >= 3 { communityGalleryName := parts[0] imageName := parts[1] version := parts[2] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCommunityGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(communityGalleryName, imageName, version), Scope: scope, // Community galleries are subscription-level }, }) } } } } } // Link to Gallery Application Versions // Reference: https://learn.microsoft.com/en-us/rest/api/compute/gallery-application-versions/get if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.ApplicationProfile != nil && scaleSet.Properties.VirtualMachineProfile.ApplicationProfile.GalleryApplications != nil { for _, galleryApp := range scaleSet.Properties.VirtualMachineProfile.ApplicationProfile.GalleryApplications { if galleryApp.PackageReferenceID != nil { packageRefID := *galleryApp.PackageReferenceID if packageRefID != "" { // Extract gallery name, application name, and version from the ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/applications/{application}/versions/{version} parts := azureshared.ExtractPathParamsFromResourceID(packageRefID, []string{"galleries", "applications", "versions"}) if len(parts) >= 3 { galleryName := parts[0] applicationName := parts[1] version := parts[2] // Check if gallery is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(packageRefID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGalleryApplicationVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, applicationName, version), Scope: extractedScope, }, }) } } } } } // Link to compute resources if scaleSet.Properties != nil { // Link to Proximity Placement Group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/proximity-placement-groups/get if scaleSet.Properties.ProximityPlacementGroup != nil && scaleSet.Properties.ProximityPlacementGroup.ID != nil { ppgName := azureshared.ExtractResourceName(*scaleSet.Properties.ProximityPlacementGroup.ID) if ppgName != "" { // Check if Proximity Placement Group is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*scaleSet.Properties.ProximityPlacementGroup.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeProximityPlacementGroup.String(), Method: sdp.QueryMethod_GET, Query: ppgName, Scope: extractedScope, }, }) } } // Link to Dedicated Host Group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/dedicated-host-groups/get if scaleSet.Properties.HostGroup != nil && scaleSet.Properties.HostGroup.ID != nil { hostGroupName := azureshared.ExtractResourceName(*scaleSet.Properties.HostGroup.ID) if hostGroupName != "" { // Check if Dedicated Host Group is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*scaleSet.Properties.HostGroup.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDedicatedHostGroup.String(), Method: sdp.QueryMethod_GET, Query: hostGroupName, Scope: extractedScope, }, }) } } } // Link to Capacity Reservation Group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/capacity-reservation-groups/get if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.CapacityReservation != nil && scaleSet.Properties.VirtualMachineProfile.CapacityReservation.CapacityReservationGroup != nil && scaleSet.Properties.VirtualMachineProfile.CapacityReservation.CapacityReservationGroup.ID != nil { capacityReservationGroupName := azureshared.ExtractResourceName(*scaleSet.Properties.VirtualMachineProfile.CapacityReservation.CapacityReservationGroup.ID) if capacityReservationGroupName != "" { // Check if Capacity Reservation Group is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*scaleSet.Properties.VirtualMachineProfile.CapacityReservation.CapacityReservationGroup.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCapacityReservationGroup.String(), Method: sdp.QueryMethod_GET, Query: capacityReservationGroupName, Scope: extractedScope, }, }) } } // Link to identity resources // Reference: https://learn.microsoft.com/en-us/rest/api/msi/user-assigned-identities/get if scaleSet.Identity != nil && scaleSet.Identity.UserAssignedIdentities != nil { for identityID := range scaleSet.Identity.UserAssignedIdentities { if identityID != "" { identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { // Check if identity is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(identityID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: extractedScope, }, }) } } } } // Link to storage account for boot diagnostics // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/storage-accounts/get-properties if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.DiagnosticsProfile != nil && scaleSet.Properties.VirtualMachineProfile.DiagnosticsProfile.BootDiagnostics != nil && scaleSet.Properties.VirtualMachineProfile.DiagnosticsProfile.BootDiagnostics.StorageURI != nil { storageURI := *scaleSet.Properties.VirtualMachineProfile.DiagnosticsProfile.BootDiagnostics.StorageURI // Extract storage account name from URI // Format: https://{accountName}.blob.core.windows.net/ if storageURI != "" { // Parse the storage account name from the URI // The URI format is: https://{accountName}.blob.core.windows.net/ dnsName := azureshared.ExtractDNSFromURL(storageURI) if dnsName != "" { // Link to DNS name (standard library) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) // Extract account name (everything before the first dot) // dnsName format: accountname.blob.core.windows.net accountName := "" for i := range len(dnsName) { if dnsName[i] == '.' { accountName = dnsName[:i] break } } if accountName != "" { // Storage accounts are typically in the same resource group, but we use DefaultScope sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) } } } } // Link to Key Vault for secrets // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.OSProfile != nil && scaleSet.Properties.VirtualMachineProfile.OSProfile.Secrets != nil { for _, secret := range scaleSet.Properties.VirtualMachineProfile.OSProfile.Secrets { if secret.SourceVault != nil && secret.SourceVault.ID != nil { vaultName := azureshared.ExtractResourceName(*secret.SourceVault.ID) if vaultName != "" { // Check if Key Vault is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*secret.SourceVault.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } } } // Link to Key Vault for extension protected settings if scaleSet.Properties != nil && scaleSet.Properties.VirtualMachineProfile != nil && scaleSet.Properties.VirtualMachineProfile.ExtensionProfile != nil && scaleSet.Properties.VirtualMachineProfile.ExtensionProfile.Extensions != nil { for _, extension := range scaleSet.Properties.VirtualMachineProfile.ExtensionProfile.Extensions { if extension.Properties != nil && extension.Properties.ProtectedSettingsFromKeyVault != nil && extension.Properties.ProtectedSettingsFromKeyVault.SourceVault != nil && extension.Properties.ProtectedSettingsFromKeyVault.SourceVault.ID != nil { vaultName := azureshared.ExtractResourceName(*extension.Properties.ProtectedSettingsFromKeyVault.SourceVault.ID) if vaultName != "" { // Check if Key Vault is in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(*extension.Properties.ProtectedSettingsFromKeyVault.SourceVault.ID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: extractedScope, }, }) } } } } // Map provisioning state to health status if scaleSet.Properties != nil && scaleSet.Properties.ProvisioningState != nil { switch *scaleSet.Properties.ProvisioningState { case "Succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "Creating", "Updating", "Migrating": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "Failed", "Deleting": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } } return sdpItem, nil } func (c computeVirtualMachineScaleSetWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeVirtualMachineScaleSetLookupByName, } } func (c computeVirtualMachineScaleSetWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( // Child resources azureshared.ComputeVirtualMachineExtension, azureshared.ComputeVirtualMachine, // Network resources azureshared.NetworkVirtualNetwork, azureshared.NetworkSubnet, azureshared.NetworkPublicIPPrefix, azureshared.NetworkNetworkSecurityGroup, azureshared.NetworkLoadBalancer, azureshared.NetworkLoadBalancerBackendAddressPool, azureshared.NetworkLoadBalancerInboundNatPool, azureshared.NetworkLoadBalancerProbe, azureshared.NetworkApplicationGateway, azureshared.NetworkApplicationGatewayBackendAddressPool, azureshared.NetworkApplicationSecurityGroup, // Compute resources azureshared.ComputeDiskEncryptionSet, azureshared.ComputeProximityPlacementGroup, azureshared.ComputeDedicatedHostGroup, azureshared.ComputeCapacityReservationGroup, azureshared.ComputeImage, azureshared.ComputeSharedGalleryImage, azureshared.ComputeCommunityGalleryImage, azureshared.ComputeGalleryApplicationVersion, // Storage resources azureshared.StorageAccount, // Identity resources azureshared.ManagedIdentityUserAssignedIdentity, // Key Vault resources azureshared.KeyVaultVault, // Standard library types stdlib.NetworkDNS, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_machine_scale_set func (c computeVirtualMachineScaleSetWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_virtual_machine_scale_set.name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_linux_virtual_machine_scale_set.name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_windows_virtual_machine_scale_set.name", }, } } ================================================ FILE: sources/azure/manual/compute-virtual-machine-scale-set_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeVirtualMachineScaleSet(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { scaleSetName := "test-vmss" scaleSet := createAzureVirtualMachineScaleSet(scaleSetName, "Succeeded") mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, scaleSetName, nil).Return( armcompute.VirtualMachineScaleSetsClientGetResponse{ VirtualMachineScaleSet: *scaleSet, }, nil) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], scaleSetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeVirtualMachineScaleSet.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeVirtualMachineScaleSet, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != scaleSetName { t.Errorf("Expected unique attribute value %s, got %s", scaleSetName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got: %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Extension link - uses composite lookup key ExpectedType: azureshared.ComputeVirtualMachineExtension.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(scaleSetName, "CustomScriptExtension"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // VM instances - always linked via SEARCH ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: scaleSetName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Network Security Group ExpectedType: azureshared.NetworkNetworkSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nsg", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Virtual Network ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Subnet - uses composite lookup key ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "default"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Public IP Prefix ExpectedType: azureshared.NetworkPublicIPPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pip-prefix", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Load Balancer ExpectedType: azureshared.NetworkLoadBalancer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-lb", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Load Balancer Backend Address Pool - uses composite lookup key ExpectedType: azureshared.NetworkLoadBalancerBackendAddressPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-lb", "test-backend-pool"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Load Balancer Inbound NAT Pool - uses composite lookup key ExpectedType: azureshared.NetworkLoadBalancerInboundNatPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-lb", "test-nat-pool"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Application Gateway ExpectedType: azureshared.NetworkApplicationGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-ag", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Application Gateway Backend Address Pool - uses composite lookup key ExpectedType: azureshared.NetworkApplicationGatewayBackendAddressPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-ag", "test-ag-pool"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Application Security Group ExpectedType: azureshared.NetworkApplicationSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-asg", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Load Balancer Health Probe - uses composite lookup key ExpectedType: azureshared.NetworkLoadBalancerProbe.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-lb", "test-probe"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Disk Encryption Set (OS Disk) ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk-encryption-set", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Disk Encryption Set (Data Disk) ExpectedType: azureshared.ComputeDiskEncryptionSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk-encryption-set-data", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Image (custom image) ExpectedType: azureshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-image", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Proximity Placement Group ExpectedType: azureshared.ComputeProximityPlacementGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-ppg", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Dedicated Host Group ExpectedType: azureshared.ComputeDedicatedHostGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-host-group", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // User Assigned Identity ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DNS name (boot diagnostics storage URI) ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "teststorageaccount.blob.core.windows.net", ExpectedScope: "global", }, { // Storage Account (boot diagnostics) ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "teststorageaccount", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Key Vault (OS profile secrets) ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-keyvault", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Key Vault (extension protected settings) ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-keyvault-ext", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty string name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting scale set with empty name, but got nil") } }) t.Run("Get_NilName", func(t *testing.T) { scaleSet := createAzureVirtualMachineScaleSet("", "Succeeded") scaleSet.Name = nil // Explicitly set to nil mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-vmss", nil).Return( armcompute.VirtualMachineScaleSetsClientGetResponse{ VirtualMachineScaleSet: *scaleSet, }, nil) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-vmss", true) if qErr == nil { t.Error("Expected error when scale set name is nil, but got nil") } }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string provisioningState string expectedHealth sdp.Health } testCases := []testCase{ { name: "Succeeded", provisioningState: "Succeeded", expectedHealth: sdp.Health_HEALTH_OK, }, { name: "Creating", provisioningState: "Creating", expectedHealth: sdp.Health_HEALTH_PENDING, }, { name: "Updating", provisioningState: "Updating", expectedHealth: sdp.Health_HEALTH_PENDING, }, { name: "Migrating", provisioningState: "Migrating", expectedHealth: sdp.Health_HEALTH_PENDING, }, { name: "Failed", provisioningState: "Failed", expectedHealth: sdp.Health_HEALTH_ERROR, }, { name: "Deleting", provisioningState: "Deleting", expectedHealth: sdp.Health_HEALTH_ERROR, }, { name: "Unknown", provisioningState: "Unknown", expectedHealth: sdp.Health_HEALTH_UNKNOWN, }, } mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { scaleSet := createAzureVirtualMachineScaleSet("test-vmss", tc.provisioningState) mockClient.EXPECT().Get(ctx, resourceGroup, "test-vmss", nil).Return( armcompute.VirtualMachineScaleSetsClientGetResponse{ VirtualMachineScaleSet: *scaleSet, }, nil) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-vmss", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expectedHealth { t.Errorf("Expected health %s, got: %s", tc.expectedHealth, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { scaleSet1 := createAzureVirtualMachineScaleSet("test-vmss-1", "Succeeded") scaleSet2 := createAzureVirtualMachineScaleSet("test-vmss-2", "Succeeded") mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) mockPager := NewMockVirtualMachineScaleSetsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armcompute.VirtualMachineScaleSetsClientListResponse{ VirtualMachineScaleSetListResult: armcompute.VirtualMachineScaleSetListResult{ Value: []*armcompute.VirtualMachineScaleSet{scaleSet1, scaleSet2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { scaleSet1 := createAzureVirtualMachineScaleSet("test-vmss-1", "Succeeded") scaleSet2 := createAzureVirtualMachineScaleSet("test-vmss-2", "Succeeded") mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) mockPager := NewMockVirtualMachineScaleSetsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armcompute.VirtualMachineScaleSetsClientListResponse{ VirtualMachineScaleSetListResult: armcompute.VirtualMachineScaleSetListResult{ Value: []*armcompute.VirtualMachineScaleSet{scaleSet1, scaleSet2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("VMSS not found") mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-vmss", nil).Return( armcompute.VirtualMachineScaleSetsClientGetResponse{}, expectedErr) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-vmss", true) if qErr == nil { t.Error("Expected error when getting non-existent VMSS, but got nil") } }) t.Run("ListErrorHandling", func(t *testing.T) { expectedErr := errors.New("list error") mockClient := mocks.NewMockVirtualMachineScaleSetsClient(ctrl) mockPager := NewMockVirtualMachineScaleSetsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armcompute.VirtualMachineScaleSetsClientListResponse{}, expectedErr), ) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeVirtualMachineScaleSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing fails, but got nil") } }) } // MockVirtualMachineScaleSetsPager is a mock implementation of VirtualMachineScaleSetsPager type MockVirtualMachineScaleSetsPager struct { ctrl *gomock.Controller recorder *MockVirtualMachineScaleSetsPagerMockRecorder } type MockVirtualMachineScaleSetsPagerMockRecorder struct { mock *MockVirtualMachineScaleSetsPager } func NewMockVirtualMachineScaleSetsPager(ctrl *gomock.Controller) *MockVirtualMachineScaleSetsPager { mock := &MockVirtualMachineScaleSetsPager{ctrl: ctrl} mock.recorder = &MockVirtualMachineScaleSetsPagerMockRecorder{mock} return mock } func (m *MockVirtualMachineScaleSetsPager) EXPECT() *MockVirtualMachineScaleSetsPagerMockRecorder { return m.recorder } func (m *MockVirtualMachineScaleSetsPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockVirtualMachineScaleSetsPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockVirtualMachineScaleSetsPager) NextPage(ctx context.Context) (armcompute.VirtualMachineScaleSetsClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armcompute.VirtualMachineScaleSetsClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockVirtualMachineScaleSetsPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armcompute.VirtualMachineScaleSetsClientListResponse, error)](), ctx) } // createAzureVirtualMachineScaleSet creates a mock Azure Virtual Machine Scale Set for testing func createAzureVirtualMachineScaleSet(scaleSetName, provisioningState string) *armcompute.VirtualMachineScaleSet { return &armcompute.VirtualMachineScaleSet{ Name: new(scaleSetName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.VirtualMachineScaleSetProperties{ ProvisioningState: new(provisioningState), VirtualMachineProfile: &armcompute.VirtualMachineScaleSetVMProfile{ ExtensionProfile: &armcompute.VirtualMachineScaleSetExtensionProfile{ Extensions: []*armcompute.VirtualMachineScaleSetExtension{ { Name: new("CustomScriptExtension"), Properties: &armcompute.VirtualMachineScaleSetExtensionProperties{ Type: new("CustomScriptExtension"), Publisher: new("Microsoft.Compute"), TypeHandlerVersion: new("1.10"), ProtectedSettingsFromKeyVault: &armcompute.KeyVaultSecretReference{ SourceVault: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-keyvault-ext"), }, }, }, }, }, }, NetworkProfile: &armcompute.VirtualMachineScaleSetNetworkProfile{ HealthProbe: &armcompute.APIEntityReference{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/loadBalancers/test-lb/probes/test-probe"), }, NetworkInterfaceConfigurations: []*armcompute.VirtualMachineScaleSetNetworkConfiguration{ { Name: new("nic-config"), Properties: &armcompute.VirtualMachineScaleSetNetworkConfigurationProperties{ NetworkSecurityGroup: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/test-nsg"), }, IPConfigurations: []*armcompute.VirtualMachineScaleSetIPConfiguration{ { Name: new("ip-config"), Properties: &armcompute.VirtualMachineScaleSetIPConfigurationProperties{ Subnet: &armcompute.APIEntityReference{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/default"), }, PublicIPAddressConfiguration: &armcompute.VirtualMachineScaleSetPublicIPAddressConfiguration{ Name: new("public-ip-config"), Properties: &armcompute.VirtualMachineScaleSetPublicIPAddressConfigurationProperties{ PublicIPPrefix: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/publicIPPrefixes/test-pip-prefix"), }, }, }, LoadBalancerBackendAddressPools: []*armcompute.SubResource{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/loadBalancers/test-lb/backendAddressPools/test-backend-pool"), }, }, LoadBalancerInboundNatPools: []*armcompute.SubResource{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/loadBalancers/test-lb/inboundNatPools/test-nat-pool"), }, }, ApplicationGatewayBackendAddressPools: []*armcompute.SubResource{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/applicationGateways/test-ag/backendAddressPools/test-ag-pool"), }, }, ApplicationSecurityGroups: []*armcompute.SubResource{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/applicationSecurityGroups/test-asg"), }, }, }, }, }, }, }, }, }, StorageProfile: &armcompute.VirtualMachineScaleSetStorageProfile{ ImageReference: &armcompute.ImageReference{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image"), }, OSDisk: &armcompute.VirtualMachineScaleSetOSDisk{ Name: new("os-disk"), ManagedDisk: &armcompute.VirtualMachineScaleSetManagedDiskParameters{ DiskEncryptionSet: &armcompute.DiskEncryptionSetParameters{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/diskEncryptionSets/test-disk-encryption-set"), }, }, }, DataDisks: []*armcompute.VirtualMachineScaleSetDataDisk{ { Name: new("data-disk-1"), ManagedDisk: &armcompute.VirtualMachineScaleSetManagedDiskParameters{ DiskEncryptionSet: &armcompute.DiskEncryptionSetParameters{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/diskEncryptionSets/test-disk-encryption-set-data"), }, }, }, }, }, OSProfile: &armcompute.VirtualMachineScaleSetOSProfile{ Secrets: []*armcompute.VaultSecretGroup{ { SourceVault: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-keyvault"), }, }, }, }, DiagnosticsProfile: &armcompute.DiagnosticsProfile{ BootDiagnostics: &armcompute.BootDiagnostics{ StorageURI: new("https://teststorageaccount.blob.core.windows.net/"), }, }, }, ProximityPlacementGroup: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/proximityPlacementGroups/test-ppg"), }, HostGroup: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/hostGroups/test-host-group"), }, }, Identity: &armcompute.VirtualMachineScaleSetIdentity{ Type: new(armcompute.ResourceIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armcompute.UserAssignedIdentitiesValue{ "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity": {}, }, }, } } ================================================ FILE: sources/azure/manual/compute-virtual-machine.go ================================================ package manual import ( "context" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeVirtualMachineLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeVirtualMachine) type computeVirtualMachineWrapper struct { client clients.VirtualMachinesClient *azureshared.MultiResourceGroupBase } // NewComputeVirtualMachine creates a new computeVirtualMachineWrapper instance func NewComputeVirtualMachine(client clients.VirtualMachinesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &computeVirtualMachineWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, azureshared.ComputeVirtualMachine, ), } } // IAMPermissions returns the IAM permissions required for this adapter // Reference: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/compute func (c computeVirtualMachineWrapper) IAMPermissions() []string { return []string{ "Microsoft.Compute/virtualMachines/read", } } // PotentialLinks returns the potential links for the virtual machine wrapper func (c computeVirtualMachineWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ComputeDisk, azureshared.ComputeDiskEncryptionSet, azureshared.NetworkNetworkInterface, azureshared.NetworkPublicIPAddress, azureshared.NetworkNetworkSecurityGroup, azureshared.ComputeAvailabilitySet, azureshared.ComputeProximityPlacementGroup, azureshared.ComputeDedicatedHostGroup, azureshared.ComputeCapacityReservationGroup, azureshared.ComputeVirtualMachineScaleSet, azureshared.ComputeImage, azureshared.ComputeSharedGalleryImage, azureshared.ComputeGalleryApplicationVersion, azureshared.ComputeVirtualMachineExtension, azureshared.ComputeVirtualMachineRunCommand, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.KeyVaultVault, stdlib.NetworkHTTP, stdlib.NetworkDNS, ) } // TerraformMappings returns the Terraform mappings for the virtual machine wrapper func (c computeVirtualMachineWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_machine TerraformQueryMap: "azurerm_virtual_machine.name", }, { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_virtual_machine TerraformQueryMap: "azurerm_linux_virtual_machine.name", }, { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/windows_virtual_machine TerraformQueryMap: "azurerm_windows_virtual_machine.name", }, } } // GetLookups returns the lookups for the virtual machine wrapper // This defines how the source can be queried for specific item // In this case, it will be: azure-compute-virtual-machine-name func (c computeVirtualMachineWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeVirtualMachineLookupByName, } } // Get retrieves a virtual machine by its name // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/get func (c computeVirtualMachineWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { vmName := queryParts[0] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, vmName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = c.azureVirtualMachineToSDPItem(&resp.VirtualMachine, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } // List lists virtual machines in the resource group and converts them to sdp.Items. // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/list func (c computeVirtualMachineWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, vm := range page.Value { var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = c.azureVirtualMachineToSDPItem(vm, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c computeVirtualMachineWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, vm := range page.Value { var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = c.azureVirtualMachineToSDPItem(vm, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c computeVirtualMachineWrapper) azureVirtualMachineToSDPItem(vm *armcompute.VirtualMachine, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(vm, "tags") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: azureshared.ComputeVirtualMachine.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(vm.Tags), } // TODO: This adapter is demon purposes only. // The linked items must be reviewed before using in production. // Link to OS disk // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disks/get if vm.Properties != nil && vm.Properties.StorageProfile != nil && vm.Properties.StorageProfile.OSDisk != nil { if vm.Properties.StorageProfile.OSDisk.ManagedDisk != nil && vm.Properties.StorageProfile.OSDisk.ManagedDisk.ID != nil { diskName := azureshared.ExtractResourceName(*vm.Properties.StorageProfile.OSDisk.ManagedDisk.ID) if diskName != "" { linkScope := scope // Check if disk is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.StorageProfile.OSDisk.ManagedDisk.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: linkScope, }, }) } // Link to disk encryption set for OS disk // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if vm.Properties.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet != nil && vm.Properties.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet.ID != nil { diskEncryptionSetName := azureshared.ExtractResourceName(*vm.Properties.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet.ID) if diskEncryptionSetName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.StorageProfile.OSDisk.ManagedDisk.DiskEncryptionSet.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: diskEncryptionSetName, Scope: linkScope, }, }) } } } } // Link to data disks if vm.Properties != nil && vm.Properties.StorageProfile != nil && vm.Properties.StorageProfile.DataDisks != nil { for _, dataDisk := range vm.Properties.StorageProfile.DataDisks { if dataDisk.ManagedDisk != nil && dataDisk.ManagedDisk.ID != nil { diskName := azureshared.ExtractResourceName(*dataDisk.ManagedDisk.ID) if diskName != "" { linkScope := scope // Check if disk is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*dataDisk.ManagedDisk.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: linkScope, }, }) } // Link to disk encryption set for data disk // Reference: https://learn.microsoft.com/en-us/rest/api/compute/disk-encryption-sets/get if dataDisk.ManagedDisk.DiskEncryptionSet != nil && dataDisk.ManagedDisk.DiskEncryptionSet.ID != nil { diskEncryptionSetName := azureshared.ExtractResourceName(*dataDisk.ManagedDisk.DiskEncryptionSet.ID) if diskEncryptionSetName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*dataDisk.ManagedDisk.DiskEncryptionSet.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDiskEncryptionSet.String(), Method: sdp.QueryMethod_GET, Query: diskEncryptionSetName, Scope: linkScope, }, }) } } } } } // Link to network interfaces // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-interfaces/get if vm.Properties != nil && vm.Properties.NetworkProfile != nil && vm.Properties.NetworkProfile.NetworkInterfaces != nil { for _, nic := range vm.Properties.NetworkProfile.NetworkInterfaces { if nic.ID != nil { nicName := azureshared.ExtractResourceName(*nic.ID) if nicName != "" { linkScope := scope // Check if NIC is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*nic.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: nicName, Scope: linkScope, }, }) } } } } // Link to availability set // Reference: https://learn.microsoft.com/en-us/rest/api/compute/availability-sets/get if vm.Properties != nil && vm.Properties.AvailabilitySet != nil && vm.Properties.AvailabilitySet.ID != nil { availabilitySetName := azureshared.ExtractResourceName(*vm.Properties.AvailabilitySet.ID) if availabilitySetName != "" { linkScope := scope // Check if availability set is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.AvailabilitySet.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeAvailabilitySet.String(), Method: sdp.QueryMethod_GET, Query: availabilitySetName, Scope: linkScope, }, }) } } // Link to proximity placement group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/proximity-placement-groups/get if vm.Properties != nil && vm.Properties.ProximityPlacementGroup != nil && vm.Properties.ProximityPlacementGroup.ID != nil { proximityPlacementGroupName := azureshared.ExtractResourceName(*vm.Properties.ProximityPlacementGroup.ID) if proximityPlacementGroupName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.ProximityPlacementGroup.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeProximityPlacementGroup.String(), Method: sdp.QueryMethod_GET, Query: proximityPlacementGroupName, Scope: linkScope, }, }) } } // Link to dedicated host group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/dedicated-host-groups/get if vm.Properties != nil && vm.Properties.HostGroup != nil && vm.Properties.HostGroup.ID != nil { hostGroupName := azureshared.ExtractResourceName(*vm.Properties.HostGroup.ID) if hostGroupName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.HostGroup.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeDedicatedHostGroup.String(), Method: sdp.QueryMethod_GET, Query: hostGroupName, Scope: linkScope, }, }) } } // Link to capacity reservation group // Reference: https://learn.microsoft.com/en-us/rest/api/compute/capacity-reservation-groups/get if vm.Properties != nil && vm.Properties.CapacityReservation != nil && vm.Properties.CapacityReservation.CapacityReservationGroup != nil && vm.Properties.CapacityReservation.CapacityReservationGroup.ID != nil { capacityReservationGroupName := azureshared.ExtractResourceName(*vm.Properties.CapacityReservation.CapacityReservationGroup.ID) if capacityReservationGroupName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.CapacityReservation.CapacityReservationGroup.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeCapacityReservationGroup.String(), Method: sdp.QueryMethod_GET, Query: capacityReservationGroupName, Scope: linkScope, }, }) } } // Link to virtual machine scale set // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-scale-sets/get if vm.Properties != nil && vm.Properties.VirtualMachineScaleSet != nil && vm.Properties.VirtualMachineScaleSet.ID != nil { vmssName := azureshared.ExtractResourceName(*vm.Properties.VirtualMachineScaleSet.ID) if vmssName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.VirtualMachineScaleSet.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachineScaleSet.String(), Method: sdp.QueryMethod_GET, Query: vmssName, Scope: linkScope, }, }) } } // Link to managed by resource (typically VMSS) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-scale-sets/get if vm.ManagedBy != nil && *vm.ManagedBy != "" { // Check if managedBy is a VMSS if strings.Contains(*vm.ManagedBy, "/virtualMachineScaleSets/") { vmssName := azureshared.ExtractPathParamsFromResourceID(*vm.ManagedBy, []string{"virtualMachineScaleSets"}) if len(vmssName) > 0 && vmssName[0] != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.ManagedBy); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachineScaleSet.String(), Method: sdp.QueryMethod_GET, Query: vmssName[0], Scope: linkScope, }, }) } } } // Link to image reference (custom image or gallery image) // Reference: https://learn.microsoft.com/en-us/rest/api/compute/images/get // or https://learn.microsoft.com/en-us/rest/api/compute/gallery-image-versions/get if vm.Properties != nil && vm.Properties.StorageProfile != nil && vm.Properties.StorageProfile.ImageReference != nil { if vm.Properties.StorageProfile.ImageReference.ID != nil && *vm.Properties.StorageProfile.ImageReference.ID != "" { imageID := *vm.Properties.StorageProfile.ImageReference.ID // Check if it's a gallery image or custom image if strings.Contains(imageID, "/galleries/") && strings.Contains(imageID, "/images/") && strings.Contains(imageID, "/versions/") { // Shared Gallery Image Version params := azureshared.ExtractPathParamsFromResourceID(imageID, []string{"galleries", "images", "versions"}) if len(params) == 3 { galleryName := params[0] imageName := params[1] versionName := params[2] linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(imageID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeSharedGalleryImage.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, imageName, versionName), Scope: linkScope, }, }) } } else if strings.Contains(imageID, "/images/") { // Custom Image imageName := azureshared.ExtractResourceName(imageID) if imageName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(imageID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: imageName, Scope: linkScope, }, }) } } } } // Link to user assigned managed identities // Reference: https://learn.microsoft.com/en-us/rest/api/msi/user-assigned-identities/get if vm.Identity != nil && vm.Identity.UserAssignedIdentities != nil { for identityID := range vm.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkScope, }, }) } } } // Link to Key Vault from OS profile secrets // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get if vm.Properties != nil && vm.Properties.OSProfile != nil && vm.Properties.OSProfile.Secrets != nil { for _, secret := range vm.Properties.OSProfile.Secrets { if secret.SourceVault != nil && secret.SourceVault.ID != nil { vaultName := azureshared.ExtractResourceName(*secret.SourceVault.ID) if vaultName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*secret.SourceVault.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: linkScope, }, }) } } } } // Link to Key Vault from disk encryption settings // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get if vm.Properties != nil && vm.Properties.StorageProfile != nil && vm.Properties.StorageProfile.OSDisk != nil { if vm.Properties.StorageProfile.OSDisk.EncryptionSettings != nil { // Link to Key Vault from DiskEncryptionKey.SourceVault.ID // DiskEncryptionKey is required for Azure Disk Encryption, while KeyEncryptionKey is optional if vm.Properties.StorageProfile.OSDisk.EncryptionSettings.DiskEncryptionKey != nil && vm.Properties.StorageProfile.OSDisk.EncryptionSettings.DiskEncryptionKey.SourceVault != nil && vm.Properties.StorageProfile.OSDisk.EncryptionSettings.DiskEncryptionKey.SourceVault.ID != nil { vaultName := azureshared.ExtractResourceName(*vm.Properties.StorageProfile.OSDisk.EncryptionSettings.DiskEncryptionKey.SourceVault.ID) if vaultName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.StorageProfile.OSDisk.EncryptionSettings.DiskEncryptionKey.SourceVault.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: linkScope, }, }) } } // Link to Key Vault from KeyEncryptionKey.SourceVault.ID if vm.Properties.StorageProfile.OSDisk.EncryptionSettings.KeyEncryptionKey != nil && vm.Properties.StorageProfile.OSDisk.EncryptionSettings.KeyEncryptionKey.SourceVault != nil && vm.Properties.StorageProfile.OSDisk.EncryptionSettings.KeyEncryptionKey.SourceVault.ID != nil { vaultName := azureshared.ExtractResourceName(*vm.Properties.StorageProfile.OSDisk.EncryptionSettings.KeyEncryptionKey.SourceVault.ID) if vaultName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*vm.Properties.StorageProfile.OSDisk.EncryptionSettings.KeyEncryptionKey.SourceVault.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: linkScope, }, }) } } } } // Link to gallery application versions // Reference: https://learn.microsoft.com/en-us/rest/api/compute/gallery-application-versions/get if vm.Properties != nil && vm.Properties.ApplicationProfile != nil && vm.Properties.ApplicationProfile.GalleryApplications != nil { for _, galleryApp := range vm.Properties.ApplicationProfile.GalleryApplications { if galleryApp.PackageReferenceID != nil && *galleryApp.PackageReferenceID != "" { packageRefID := *galleryApp.PackageReferenceID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/applications/{appName}/versions/{versionName} params := azureshared.ExtractPathParamsFromResourceID(packageRefID, []string{"galleries", "applications", "versions"}) if len(params) == 3 { galleryName := params[0] appName := params[1] versionName := params[2] linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(packageRefID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeGalleryApplicationVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(galleryName, appName, versionName), Scope: linkScope, }, }) } } } } // Link to boot diagnostics storage URI (standard library HTTP and DNS) // Reference: Boot diagnostics storage is accessed via HTTP/HTTPS if vm.Properties != nil && vm.Properties.DiagnosticsProfile != nil && vm.Properties.DiagnosticsProfile.BootDiagnostics != nil && vm.Properties.DiagnosticsProfile.BootDiagnostics.StorageURI != nil && *vm.Properties.DiagnosticsProfile.BootDiagnostics.StorageURI != "" { storageURI := *vm.Properties.DiagnosticsProfile.BootDiagnostics.StorageURI // Extract the HTTP/HTTPS URL for standard library if strings.HasPrefix(storageURI, "http://") || strings.HasPrefix(storageURI, "https://") { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_GET, Query: storageURI, Scope: "global", }, }) // Extract DNS name from URL and create DNS link // Reference: Any attribute containing a DNS name must create a LinkedItemQuery for dns type dnsName := azureshared.ExtractDNSFromURL(storageURI) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } // Link to extensions // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-extensions/list if vm.Resources != nil { for _, extension := range vm.Resources { if extension.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachineExtension.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(*vm.Name, *extension.Name), Scope: scope, }, }) } } } // Link to run commands // Reference: https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-run-commands/list-by-virtual-machine?view=rest-compute-2025-04-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}/runCommands?api-version=2025-04-01 if vm.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachineRunCommand.String(), Method: sdp.QueryMethod_SEARCH, Query: *vm.Name, Scope: scope, }, }) } // Map provisioning state to health status // Reference: https://learn.microsoft.com/en-us/azure/virtual-machines/states-billing if vm.Properties != nil && vm.Properties.ProvisioningState != nil { switch *vm.Properties.ProvisioningState { case "Succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "Creating", "Updating", "Migrating": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "Failed", "Deleting": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } } return sdpItem, nil } ================================================ FILE: sources/azure/manual/compute-virtual-machine_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeVirtualMachine(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { vmName := "test-vm" vm := createAzureVirtualMachine(vmName, "Succeeded") mockClient := mocks.NewMockVirtualMachinesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vmName, nil).Return( armcompute.VirtualMachinesClientGetResponse{ VirtualMachine: *vm, }, nil) wrapper := manual.NewComputeVirtualMachine(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vmName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ComputeVirtualMachine.String() { t.Errorf("Expected type %s, got %s", azureshared.ComputeVirtualMachine, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != vmName { t.Errorf("Expected unique attribute value %s, got %s", vmName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got: %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // osDisk.managedDisk.id ExpectedType: azureshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "os-disk", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // dataDisks[0].managedDisk.id ExpectedType: azureshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "data-disk-1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // networkInterfaces[0].id ExpectedType: azureshared.NetworkNetworkInterface.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nic", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // availabilitySet.id ExpectedType: azureshared.ComputeAvailabilitySet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-avset", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Resources[0] (VM Extension) - uses composite lookup key ExpectedType: azureshared.ComputeVirtualMachineExtension.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(vmName, "CustomScriptExtension"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Run commands - always linked via SEARCH ExpectedType: azureshared.ComputeVirtualMachineRunCommand.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vmName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string provisioningState string expectedHealth sdp.Health } testCases := []testCase{ { name: "Succeeded", provisioningState: "Succeeded", expectedHealth: sdp.Health_HEALTH_OK, }, { name: "Creating", provisioningState: "Creating", expectedHealth: sdp.Health_HEALTH_PENDING, }, { name: "Updating", provisioningState: "Updating", expectedHealth: sdp.Health_HEALTH_PENDING, }, { name: "Migrating", provisioningState: "Migrating", expectedHealth: sdp.Health_HEALTH_PENDING, }, { name: "Failed", provisioningState: "Failed", expectedHealth: sdp.Health_HEALTH_ERROR, }, { name: "Deleting", provisioningState: "Deleting", expectedHealth: sdp.Health_HEALTH_ERROR, }, { name: "Unknown", provisioningState: "Unknown", expectedHealth: sdp.Health_HEALTH_UNKNOWN, }, } mockClient := mocks.NewMockVirtualMachinesClient(ctrl) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { vm := createAzureVirtualMachine("test-vm", tc.provisioningState) mockClient.EXPECT().Get(ctx, resourceGroup, "test-vm", nil).Return( armcompute.VirtualMachinesClientGetResponse{ VirtualMachine: *vm, }, nil) wrapper := manual.NewComputeVirtualMachine(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-vm", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expectedHealth { t.Fatalf("Expected health %s, got: %s", tc.expectedHealth, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { vm1 := createAzureVirtualMachine("test-vm-1", "Succeeded") vm2 := createAzureVirtualMachine("test-vm-2", "Succeeded") mockClient := mocks.NewMockVirtualMachinesClient(ctrl) mockPager := mocks.NewMockVirtualMachinesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armcompute.VirtualMachinesClientListResponse{ VirtualMachineListResult: armcompute.VirtualMachineListResult{ Value: []*armcompute.VirtualMachine{vm1, vm2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeVirtualMachine(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { vm1 := createAzureVirtualMachine("test-vm-1", "Succeeded") vm2 := createAzureVirtualMachine("test-vm-2", "Succeeded") mockClient := mocks.NewMockVirtualMachinesClient(ctrl) mockPager := mocks.NewMockVirtualMachinesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armcompute.VirtualMachinesClientListResponse{ VirtualMachineListResult: armcompute.VirtualMachineListResult{ Value: []*armcompute.VirtualMachine{vm1, vm2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewComputeVirtualMachine(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("VM not found") mockClient := mocks.NewMockVirtualMachinesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-vm", nil).Return( armcompute.VirtualMachinesClientGetResponse{}, expectedErr) wrapper := manual.NewComputeVirtualMachine(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-vm", true) if qErr == nil { t.Error("Expected error when getting non-existent VM, but got nil") } }) } // createAzureVirtualMachine creates a mock Azure VM for testing func createAzureVirtualMachine(vmName, provisioningState string) *armcompute.VirtualMachine { return &armcompute.VirtualMachine{ Name: new(vmName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcompute.VirtualMachineProperties{ ProvisioningState: new(provisioningState), StorageProfile: &armcompute.StorageProfile{ OSDisk: &armcompute.OSDisk{ Name: new("os-disk"), ManagedDisk: &armcompute.ManagedDiskParameters{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/disks/os-disk"), }, }, DataDisks: []*armcompute.DataDisk{ { Name: new("data-disk-1"), ManagedDisk: &armcompute.ManagedDiskParameters{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/disks/data-disk-1"), }, }, }, }, NetworkProfile: &armcompute.NetworkProfile{ NetworkInterfaces: []*armcompute.NetworkInterfaceReference{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-nic"), }, }, }, AvailabilitySet: &armcompute.SubResource{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/availabilitySets/test-avset"), }, }, // Add VM extensions to Resources Resources: []*armcompute.VirtualMachineExtension{ { Name: new("CustomScriptExtension"), Type: new("Microsoft.Compute/virtualMachines/extensions"), }, }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-database.go ================================================ package manual import ( "context" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var DBforPostgreSQLDatabaseLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLDatabase) type dbforPostgreSQLDatabaseWrapper struct { client clients.PostgreSQLDatabasesClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLDatabase(client clients.PostgreSQLDatabasesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforPostgreSQLDatabaseWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLDatabase, ), } } // reference : https://learn.microsoft.com/en-us/rest/api/postgresql/databases/get?view=rest-postgresql-2025-08-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/databases/{databaseName}?api-version=2025-08-01 func (s dbforPostgreSQLDatabaseWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and databaseName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] databaseName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, databaseName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureDBforPostgreSQLDatabaseToSDPItem(&resp.Database, serverName, scope) } func (s dbforPostgreSQLDatabaseWrapper) azureDBforPostgreSQLDatabaseToSDPItem(database *armpostgresqlflexibleservers.Database, serverName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(database) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } if database.Name == nil { return nil, azureshared.QueryError(fmt.Errorf("database name is nil"), scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, *database.Name)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DBforPostgreSQLDatabase.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to PostgreSQL Flexible Server (parent resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/databases/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/databases?api-version=2025-08-01 // // The database is a child resource of the server, so the server is always in the same resource group. // We use the serverName that's already available from the query parameters. sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, // Server is in the same resource group as the database }, }) return sdpItem, nil } // reference : https://learn.microsoft.com/en-us/rest/api/postgresql/databases/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP#security // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/databases?api-version=2025-08-01 func (s dbforPostgreSQLDatabaseWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, database := range page.Value { if database.Name == nil { continue } item, sdpErr := s.azureDBforPostgreSQLDatabaseToSDPItem(database, serverName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforPostgreSQLDatabaseWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(fmt.Errorf("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, database := range page.Value { if database.Name == nil { continue } item, sdpErr := s.azureDBforPostgreSQLDatabaseToSDPItem(database, serverName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // reference: GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/databases/{databaseName}?api-version=2025-08-01 func (s dbforPostgreSQLDatabaseWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLDatabaseLookupByName, } } // reference: GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/databases?api-version=2025-08-01 func (s dbforPostgreSQLDatabaseWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (s dbforPostgreSQLDatabaseWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.DBforPostgreSQLFlexibleServer: true, // Linked to parent PostgreSQL Flexible Server } } // reference : https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/postgresql_flexible_server_database func (s dbforPostgreSQLDatabaseWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_postgresql_flexible_server_database.id", }, } } // reference : https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/databases#microsoftdbforpostgresql func (s dbforPostgreSQLDatabaseWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/databases/read", } } func (s dbforPostgreSQLDatabaseWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-database_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockPostgreSQLDatabasesPager is a simple mock implementation of PostgreSQLDatabasesPager type mockPostgreSQLDatabasesPager struct { pages []armpostgresqlflexibleservers.DatabasesClientListByServerResponse index int } func (m *mockPostgreSQLDatabasesPager) More() bool { return m.index < len(m.pages) } func (m *mockPostgreSQLDatabasesPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.DatabasesClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.DatabasesClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorPostgreSQLDatabasesPager is a mock pager that always returns an error type errorPostgreSQLDatabasesPager struct{} func (e *errorPostgreSQLDatabasesPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorPostgreSQLDatabasesPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.DatabasesClientListByServerResponse, error) { return armpostgresqlflexibleservers.DatabasesClientListByServerResponse{}, errors.New("pager error") } // testPostgreSQLDatabasesClient wraps the mock to implement the correct interface type testPostgreSQLDatabasesClient struct { *mocks.MockPostgreSQLDatabasesClient pager clients.PostgreSQLDatabasesPager } func (t *testPostgreSQLDatabasesClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.PostgreSQLDatabasesPager { return t.pager } func TestDBforPostgreSQLDatabase(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" databaseName := "test-database" t.Run("Get", func(t *testing.T) { database := createAzurePostgreSQLDatabase(serverName, databaseName) mockClient := mocks.NewMockPostgreSQLDatabasesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, databaseName).Return( armpostgresqlflexibleservers.DatabasesClientGetResponse{ Database: *database, }, nil) testClient := &testPostgreSQLDatabasesClient{MockPostgreSQLDatabasesClient: mockClient} wrapper := manual.NewDBforPostgreSQLDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires serverName and databaseName as query parts query := shared.CompositeLookupKey(serverName, databaseName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLDatabase.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLDatabase, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, databaseName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // PostgreSQL Flexible Server link ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLDatabasesClient(ctrl) testClient := &testPostgreSQLDatabasesClient{MockPostgreSQLDatabasesClient: mockClient} wrapper := manual.NewDBforPostgreSQLDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only server name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { database1 := createAzurePostgreSQLDatabase(serverName, "database-1") database2 := createAzurePostgreSQLDatabase(serverName, "database-2") mockClient := mocks.NewMockPostgreSQLDatabasesClient(ctrl) mockPager := &mockPostgreSQLDatabasesPager{ pages: []armpostgresqlflexibleservers.DatabasesClientListByServerResponse{ { DatabaseList: armpostgresqlflexibleservers.DatabaseList{ Value: []*armpostgresqlflexibleservers.Database{database1, database2}, }, }, }, } testClient := &testPostgreSQLDatabasesClient{ MockPostgreSQLDatabasesClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.DBforPostgreSQLDatabase.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLDatabase, item.GetType()) } } }) t.Run("Search_WithNilName", func(t *testing.T) { database1 := createAzurePostgreSQLDatabase(serverName, "database-1") database2 := &armpostgresqlflexibleservers.Database{ Name: nil, // Database with nil name should be skipped Properties: &armpostgresqlflexibleservers.DatabaseProperties{ Charset: new("UTF8"), Collation: new("en_US.utf8"), }, } mockClient := mocks.NewMockPostgreSQLDatabasesClient(ctrl) mockPager := &mockPostgreSQLDatabasesPager{ pages: []armpostgresqlflexibleservers.DatabasesClientListByServerResponse{ { DatabaseList: armpostgresqlflexibleservers.DatabaseList{ Value: []*armpostgresqlflexibleservers.Database{database1, database2}, }, }, }, } testClient := &testPostgreSQLDatabasesClient{ MockPostgreSQLDatabasesClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (database with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(serverName, "database-1") { t.Fatalf("Expected database name 'database-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLDatabasesClient(ctrl) testClient := &testPostgreSQLDatabasesClient{MockPostgreSQLDatabasesClient: mockClient} wrapper := manual.NewDBforPostgreSQLDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling ListByServer _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("database not found") mockClient := mocks.NewMockPostgreSQLDatabasesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-database").Return( armpostgresqlflexibleservers.DatabasesClientGetResponse{}, expectedErr) testClient := &testPostgreSQLDatabasesClient{MockPostgreSQLDatabasesClient: mockClient} wrapper := manual.NewDBforPostgreSQLDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-database") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent database, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLDatabasesClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorPostgreSQLDatabasesPager{} testClient := &testPostgreSQLDatabasesClient{ MockPostgreSQLDatabasesClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) // The Search implementation should return an error when pager.NextPage returns an error if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) } // createAzurePostgreSQLDatabase creates a mock Azure PostgreSQL Database for testing func createAzurePostgreSQLDatabase(serverName, databaseName string) *armpostgresqlflexibleservers.Database { databaseID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/" + serverName + "/databases/" + databaseName return &armpostgresqlflexibleservers.Database{ Name: new(databaseName), ID: new(databaseID), Properties: &armpostgresqlflexibleservers.DatabaseProperties{ Charset: new("UTF8"), Collation: new("en_US.utf8"), }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-administrator.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var DBforPostgreSQLFlexibleServerAdministratorLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServerAdministrator) type dbforPostgreSQLFlexibleServerAdministratorWrapper struct { client clients.DBforPostgreSQLFlexibleServerAdministratorClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLFlexibleServerAdministrator(client clients.DBforPostgreSQLFlexibleServerAdministratorClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforPostgreSQLFlexibleServerAdministratorWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServerAdministrator, ), } } // Get retrieves a single administrator by server name and object ID // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/administrators-microsoft-entra/get func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and objectId", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] objectID := queryParts[1] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: s.Type(), } } if objectID == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "objectId cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, objectID) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureAdministratorToSDPItem(&resp.AdministratorMicrosoftEntra, serverName, scope) } // Search retrieves all administrators for a given server // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/administrators-microsoft-entra/list-by-server func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] if serverName == "" { return nil, azureshared.QueryError(errors.New("serverName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, admin := range page.Value { if admin.Name == nil { continue } item, sdpErr := s.azureAdministratorToSDPItem(admin, serverName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] if serverName == "" { stream.SendError(azureshared.QueryError(errors.New("serverName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, admin := range page.Value { if admin.Name == nil { continue } item, sdpErr := s.azureAdministratorToSDPItem(admin, serverName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLFlexibleServerAdministratorLookupByName, } } func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) azureAdministratorToSDPItem(admin *armpostgresqlflexibleservers.AdministratorMicrosoftEntra, serverName, scope string) (*sdp.Item, *sdp.QueryError) { if admin.Name == nil { return nil, azureshared.QueryError(errors.New("administrator name (objectId) is nil"), scope, s.Type()) } attributes, err := shared.ToAttributesWithExclude(admin) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } objectID := *admin.Name err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, objectID)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: s.Type(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to the parent PostgreSQL Flexible Server sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) return sdpItem, nil } func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.DBforPostgreSQLFlexibleServer, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/databases#microsoftdbforpostgresql func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/administrators/read", } } func (s dbforPostgreSQLFlexibleServerAdministratorWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-administrator_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockAdministratorPager is a simple mock implementation of DBforPostgreSQLFlexibleServerAdministratorPager type mockAdministratorPager struct { pages []armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse index int } func (m *mockAdministratorPager) More() bool { return m.index < len(m.pages) } func (m *mockAdministratorPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorAdministratorPager is a mock pager that always returns an error type errorAdministratorPager struct{} func (e *errorAdministratorPager) More() bool { return true } func (e *errorAdministratorPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse, error) { return armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse{}, errors.New("pager error") } // testAdministratorClient wraps the mock to implement the correct interface type testAdministratorClient struct { *mocks.MockDBforPostgreSQLFlexibleServerAdministratorClient pager clients.DBforPostgreSQLFlexibleServerAdministratorPager } func (t *testAdministratorClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerAdministratorPager { return t.pager } func TestDBforPostgreSQLFlexibleServerAdministrator(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" objectID := "00000000-0000-0000-0000-000000000001" t.Run("Get", func(t *testing.T) { admin := createAzureAdministrator(objectID) mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, objectID).Return( armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientGetResponse{ AdministratorMicrosoftEntra: *admin, }, nil) testClient := &testAdministratorClient{MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, objectID) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerAdministrator.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerAdministrator, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueValue := shared.CompositeLookupKey(serverName, objectID) if sdpItem.UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Fatalf("Expected 1 linked query, got: %d", len(linkedQueries)) } queryTests := shared.QueryTests{ { ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) testClient := &testAdministratorClient{MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) testClient := &testAdministratorClient{MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", objectID) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("GetWithEmptyObjectId", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) testClient := &testAdministratorClient{MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when providing empty objectId, but got nil") } }) t.Run("Search", func(t *testing.T) { admin1 := createAzureAdministrator("00000000-0000-0000-0000-000000000001") admin2 := createAzureAdministrator("00000000-0000-0000-0000-000000000002") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) mockPager := &mockAdministratorPager{ pages: []armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse{ { AdministratorMicrosoftEntraList: armpostgresqlflexibleservers.AdministratorMicrosoftEntraList{ Value: []*armpostgresqlflexibleservers.AdministratorMicrosoftEntra{admin1, admin2}, }, }, }, } testClient := &testAdministratorClient{ MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.DBforPostgreSQLFlexibleServerAdministrator.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerAdministrator, item.GetType()) } } }) t.Run("SearchWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) testClient := &testAdministratorClient{MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("SearchWithNoQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) testClient := &testAdministratorClient{MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("SearchStream", func(t *testing.T) { admin1 := createAzureAdministrator("00000000-0000-0000-0000-000000000001") admin2 := createAzureAdministrator("00000000-0000-0000-0000-000000000002") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) mockPager := &mockAdministratorPager{ pages: []armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse{ { AdministratorMicrosoftEntraList: armpostgresqlflexibleservers.AdministratorMicrosoftEntraList{ Value: []*armpostgresqlflexibleservers.AdministratorMicrosoftEntra{admin1, admin2}, }, }, }, } testClient := &testAdministratorClient{ MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("administrator not found") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent").Return( armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientGetResponse{}, expectedErr) testClient := &testAdministratorClient{MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent administrator, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) errorPager := &errorAdministratorPager{} testClient := &testAdministratorClient{ MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("Search_AdminWithNilName", func(t *testing.T) { validAdmin := createAzureAdministrator("00000000-0000-0000-0000-000000000001") nilNameAdmin := &armpostgresqlflexibleservers.AdministratorMicrosoftEntra{ Name: nil, } mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl) mockPager := &mockAdministratorPager{ pages: []armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientListByServerResponse{ { AdministratorMicrosoftEntraList: armpostgresqlflexibleservers.AdministratorMicrosoftEntraList{ Value: []*armpostgresqlflexibleservers.AdministratorMicrosoftEntra{nilNameAdmin, validAdmin}, }, }, }, } testClient := &testAdministratorClient{ MockDBforPostgreSQLFlexibleServerAdministratorClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerAdministrator(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name should be skipped), got: %d", len(sdpItems)) } expectedUniqueValue := shared.CompositeLookupKey(serverName, "00000000-0000-0000-0000-000000000001") if sdpItems[0].UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique value %s, got %s", expectedUniqueValue, sdpItems[0].UniqueAttributeValue()) } }) } // createAzureAdministrator creates a mock Azure administrator for testing func createAzureAdministrator(objectID string) *armpostgresqlflexibleservers.AdministratorMicrosoftEntra { principalType := armpostgresqlflexibleservers.PrincipalTypeUser return &armpostgresqlflexibleservers.AdministratorMicrosoftEntra{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/administrators/" + objectID), Name: new(objectID), Type: new("Microsoft.DBforPostgreSQL/flexibleServers/administrators"), Properties: &armpostgresqlflexibleservers.AdministratorMicrosoftEntraProperties{ ObjectID: new(objectID), PrincipalName: new("admin@example.com"), PrincipalType: &principalType, TenantID: new("tenant-id"), }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-backup.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var DBforPostgreSQLFlexibleServerBackupLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServerBackup) type dbforPostgreSQLFlexibleServerBackupWrapper struct { client clients.DBforPostgreSQLFlexibleServerBackupClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLFlexibleServerBackup(client clients.DBforPostgreSQLFlexibleServerBackupClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforPostgreSQLFlexibleServerBackupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServerBackup, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/backups-automatic-and-on-demand/get?view=rest-postgresql-2025-08-01 func (s dbforPostgreSQLFlexibleServerBackupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and backupName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] backupName := queryParts[1] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: s.Type(), } } if backupName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "backupName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, backupName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureBackupToSDPItem(&resp.BackupAutomaticAndOnDemand, serverName, backupName, scope) } func (s dbforPostgreSQLFlexibleServerBackupWrapper) azureBackupToSDPItem(backup *armpostgresqlflexibleservers.BackupAutomaticAndOnDemand, serverName, backupName, scope string) (*sdp.Item, *sdp.QueryError) { if backup.Name == nil { return nil, azureshared.QueryError(errors.New("backup name is nil"), scope, s.Type()) } attributes, err := shared.ToAttributesWithExclude(backup, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, backupName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DBforPostgreSQLFlexibleServerBackup.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: nil, } // Link to parent PostgreSQL Flexible Server if backup.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*backup.ID, []string{"flexibleServers"}) if len(params) > 0 { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: params[0], Scope: scope, }, }) } } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } return sdpItem, nil } func (s dbforPostgreSQLFlexibleServerBackupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLFlexibleServerBackupLookupByName, } } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/backups-automatic-and-on-demand/list-by-server?view=rest-postgresql-2025-08-01 func (s dbforPostgreSQLFlexibleServerBackupWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, backup := range page.Value { if backup.Name == nil { continue } item, sdpErr := s.azureBackupToSDPItem(backup, serverName, *backup.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforPostgreSQLFlexibleServerBackupWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, backup := range page.Value { if backup.Name == nil { continue } item, sdpErr := s.azureBackupToSDPItem(backup, serverName, *backup.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s dbforPostgreSQLFlexibleServerBackupWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (s dbforPostgreSQLFlexibleServerBackupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.DBforPostgreSQLFlexibleServer: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftdbforpostgresql func (s dbforPostgreSQLFlexibleServerBackupWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/backups/read", } } func (s dbforPostgreSQLFlexibleServerBackupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-backup_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockDBforPostgreSQLFlexibleServerBackupPager struct { pages []armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse index int } func (m *mockDBforPostgreSQLFlexibleServerBackupPager) More() bool { return m.index < len(m.pages) } func (m *mockDBforPostgreSQLFlexibleServerBackupPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorDBforPostgreSQLFlexibleServerBackupPager struct{} func (e *errorDBforPostgreSQLFlexibleServerBackupPager) More() bool { return true } func (e *errorDBforPostgreSQLFlexibleServerBackupPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse, error) { return armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse{}, errors.New("pager error") } type testDBforPostgreSQLFlexibleServerBackupClient struct { *mocks.MockDBforPostgreSQLFlexibleServerBackupClient pager clients.DBforPostgreSQLFlexibleServerBackupPager } func (t *testDBforPostgreSQLFlexibleServerBackupClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerBackupPager { return t.pager } func TestDBforPostgreSQLFlexibleServerBackup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" backupName := "test-backup" t.Run("Get", func(t *testing.T) { backup := createAzurePostgreSQLFlexibleServerBackup(serverName, backupName) mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, backupName).Return( armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientGetResponse{ BackupAutomaticAndOnDemand: *backup, }, nil) wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(&testDBforPostgreSQLFlexibleServerBackupClient{MockDBforPostgreSQLFlexibleServerBackupClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, backupName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerBackup.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerBackup, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, backupName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(&testDBforPostgreSQLFlexibleServerBackupClient{MockDBforPostgreSQLFlexibleServerBackupClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing only serverName (1 query part), but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(&testDBforPostgreSQLFlexibleServerBackupClient{MockDBforPostgreSQLFlexibleServerBackupClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", backupName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when serverName is empty, but got nil") } }) t.Run("GetWithEmptyBackupName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(&testDBforPostgreSQLFlexibleServerBackupClient{MockDBforPostgreSQLFlexibleServerBackupClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when backupName is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { backup1 := createAzurePostgreSQLFlexibleServerBackup(serverName, "backup1") backup2 := createAzurePostgreSQLFlexibleServerBackup(serverName, "backup2") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) pager := &mockDBforPostgreSQLFlexibleServerBackupPager{ pages: []armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse{ { BackupAutomaticAndOnDemandList: armpostgresqlflexibleservers.BackupAutomaticAndOnDemandList{ Value: []*armpostgresqlflexibleservers.BackupAutomaticAndOnDemand{backup1, backup2}, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerBackupClient{ MockDBforPostgreSQLFlexibleServerBackupClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error from Search, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items from Search, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { backup1 := createAzurePostgreSQLFlexibleServerBackup(serverName, "backup1") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) pager := &mockDBforPostgreSQLFlexibleServerBackupPager{ pages: []armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientListByServerResponse{ { BackupAutomaticAndOnDemandList: armpostgresqlflexibleservers.BackupAutomaticAndOnDemandList{ Value: []*armpostgresqlflexibleservers.BackupAutomaticAndOnDemand{backup1}, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerBackupClient{ MockDBforPostgreSQLFlexibleServerBackupClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) items := stream.GetItems() errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("Expected no errors from SearchStream, got: %v", errs) } if len(items) != 1 { t.Errorf("Expected 1 item from SearchStream, got %d", len(items)) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(&testDBforPostgreSQLFlexibleServerBackupClient{MockDBforPostgreSQLFlexibleServerBackupClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("backup not found") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-backup").Return( armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientGetResponse{}, expectedErr) wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(&testDBforPostgreSQLFlexibleServerBackupClient{MockDBforPostgreSQLFlexibleServerBackupClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-backup") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent backup, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) errorPager := &errorDBforPostgreSQLFlexibleServerBackupPager{} testClient := &testDBforPostgreSQLFlexibleServerBackupClient{ MockDBforPostgreSQLFlexibleServerBackupClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error from Search when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerBackup(&testDBforPostgreSQLFlexibleServerBackupClient{MockDBforPostgreSQLFlexibleServerBackupClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() if !potentialLinks[azureshared.DBforPostgreSQLFlexibleServer] { t.Error("Expected PotentialLinks to include DBforPostgreSQLFlexibleServer") } }) } func createAzurePostgreSQLFlexibleServerBackup(serverName, backupName string) *armpostgresqlflexibleservers.BackupAutomaticAndOnDemand { backupID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/" + serverName + "/backups/" + backupName backupType := armpostgresqlflexibleservers.BackupTypeFull return &armpostgresqlflexibleservers.BackupAutomaticAndOnDemand{ Name: new(backupName), ID: new(backupID), Type: new("Microsoft.DBforPostgreSQL/flexibleServers/backups"), Properties: &armpostgresqlflexibleservers.BackupAutomaticAndOnDemandProperties{ BackupType: &backupType, Source: new("Automatic"), }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-configuration.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var DBforPostgreSQLFlexibleServerConfigurationLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServerConfiguration) type dbforPostgreSQLFlexibleServerConfigurationWrapper struct { client clients.PostgreSQLConfigurationsClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLFlexibleServerConfiguration(client clients.PostgreSQLConfigurationsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforPostgreSQLFlexibleServerConfigurationWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServerConfiguration, ), } } // Get retrieves a single configuration by server name and configuration name. // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/configurations/get?view=rest-postgresql-2025-08-01 func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and configurationName", Scope: scope, ItemType: c.Type(), } } serverName := queryParts[0] configurationName := queryParts[1] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: c.Type(), } } if configurationName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "configurationName cannot be empty", Scope: scope, ItemType: c.Type(), } } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, serverName, configurationName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureConfigurationToSDPItem(&resp.Configuration, serverName, scope) } // Search lists all configurations for a given server. // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/configurations/list-by-server?view=rest-postgresql-2025-08-01 func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: c.Type(), } } serverName := queryParts[0] if serverName == "" { return nil, azureshared.QueryError(errors.New("serverName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByServerPager(rgScope.ResourceGroup, serverName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, configuration := range page.Value { if configuration.Name == nil { continue } item, sdpErr := c.azureConfigurationToSDPItem(configuration, serverName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } // SearchStream streams configurations for a given server. func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, c.Type())) return } serverName := queryParts[0] if serverName == "" { stream.SendError(azureshared.QueryError(errors.New("serverName cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByServerPager(rgScope.ResourceGroup, serverName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, configuration := range page.Value { if configuration.Name == nil { continue } item, sdpErr := c.azureConfigurationToSDPItem(configuration, serverName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) azureConfigurationToSDPItem(configuration *armpostgresqlflexibleservers.Configuration, serverName, scope string) (*sdp.Item, *sdp.QueryError) { if configuration.Name == nil { return nil, azureshared.QueryError(errors.New("configuration name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(configuration) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } configurationName := *configuration.Name err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, configurationName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: c.Type(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link back to parent Flexible Server sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) return sdpItem, nil } func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLFlexibleServerConfigurationLookupByName, } } func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.DBforPostgreSQLFlexibleServer, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/databases#microsoftdbforpostgresql func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/configurations/read", } } func (c dbforPostgreSQLFlexibleServerConfigurationWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-configuration_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockConfigurationsPager is a mock implementation of PostgreSQLConfigurationsPager type mockConfigurationsPager struct { pages []armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse index int } func (m *mockConfigurationsPager) More() bool { return m.index < len(m.pages) } func (m *mockConfigurationsPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorConfigurationsPager is a mock pager that always returns an error type errorConfigurationsPager struct{} func (e *errorConfigurationsPager) More() bool { return true } func (e *errorConfigurationsPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse, error) { return armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse{}, errors.New("pager error") } // testConfigurationsClient wraps the mock to implement the correct interface type testConfigurationsClient struct { *mocks.MockPostgreSQLConfigurationsClient pager clients.PostgreSQLConfigurationsPager } func (t *testConfigurationsClient) NewListByServerPager(resourceGroupName string, serverName string, options *armpostgresqlflexibleservers.ConfigurationsClientListByServerOptions) clients.PostgreSQLConfigurationsPager { return t.pager } func TestDBforPostgreSQLFlexibleServerConfiguration(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" configurationName := "shared_buffers" t.Run("Get", func(t *testing.T) { configuration := createAzureConfiguration(configurationName) mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, configurationName, nil).Return( armpostgresqlflexibleservers.ConfigurationsClientGetResponse{ Configuration: *configuration, }, nil) testClient := &testConfigurationsClient{MockPostgreSQLConfigurationsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, configurationName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerConfiguration, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(serverName, configurationName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(serverName, configurationName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { config1 := createAzureConfiguration("shared_buffers") config2 := createAzureConfiguration("work_mem") mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) mockPager := &mockConfigurationsPager{ pages: []armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse{ { ConfigurationList: armpostgresqlflexibleservers.ConfigurationList{ Value: []*armpostgresqlflexibleservers.Configuration{config1, config2}, }, }, }, } testClient := &testConfigurationsClient{ MockPostgreSQLConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.DBforPostgreSQLFlexibleServerConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerConfiguration, item.GetType()) } } }) t.Run("SearchStream", func(t *testing.T) { config1 := createAzureConfiguration("shared_buffers") config2 := createAzureConfiguration("work_mem") mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) mockPager := &mockConfigurationsPager{ pages: []armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse{ { ConfigurationList: armpostgresqlflexibleservers.ConfigurationList{ Value: []*armpostgresqlflexibleservers.Configuration{config1, config2}, }, }, }, } testClient := &testConfigurationsClient{ MockPostgreSQLConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) testClient := &testConfigurationsClient{MockPostgreSQLConfigurationsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) testClient := &testConfigurationsClient{MockPostgreSQLConfigurationsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", configurationName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("GetWithEmptyConfigurationName", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) testClient := &testConfigurationsClient{MockPostgreSQLConfigurationsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when providing empty configuration name, but got nil") } }) t.Run("SearchWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) testClient := &testConfigurationsClient{MockPostgreSQLConfigurationsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("SearchWithNoQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) testClient := &testConfigurationsClient{MockPostgreSQLConfigurationsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], "", true) if err == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_ConfigurationWithNilName", func(t *testing.T) { configWithName := createAzureConfiguration("shared_buffers") mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) mockPager := &mockConfigurationsPager{ pages: []armpostgresqlflexibleservers.ConfigurationsClientListByServerResponse{ { ConfigurationList: armpostgresqlflexibleservers.ConfigurationList{ Value: []*armpostgresqlflexibleservers.Configuration{ {Name: nil}, // Configuration with nil name should be skipped configWithName, }, }, }, }, } testClient := &testConfigurationsClient{ MockPostgreSQLConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(serverName, "shared_buffers") { t.Errorf("Expected configuration name '%s', got %s", shared.CompositeLookupKey(serverName, "shared_buffers"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("configuration not found") mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent", nil).Return( armpostgresqlflexibleservers.ConfigurationsClientGetResponse{}, expectedErr) testClient := &testConfigurationsClient{MockPostgreSQLConfigurationsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent configuration, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLConfigurationsClient(ctrl) errorPager := &errorConfigurationsPager{} testClient := &testConfigurationsClient{ MockPostgreSQLConfigurationsClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } // createAzureConfiguration creates a mock Azure configuration for testing func createAzureConfiguration(name string) *armpostgresqlflexibleservers.Configuration { dataType := armpostgresqlflexibleservers.ConfigurationDataTypeInteger return &armpostgresqlflexibleservers.Configuration{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/configurations/" + name), Name: new(name), Type: new("Microsoft.DBforPostgreSQL/flexibleServers/configurations"), Properties: &armpostgresqlflexibleservers.ConfigurationProperties{ Value: new("128MB"), DefaultValue: new("128MB"), DataType: &dataType, AllowedValues: new("16384-2097152"), Source: new("system-default"), Description: new("Sets the amount of memory the database server uses for shared memory buffers."), }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-firewall-rule.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var DBforPostgreSQLFlexibleServerFirewallRuleLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServerFirewallRule) type dbforPostgreSQLFlexibleServerFirewallRuleWrapper struct { client clients.PostgreSQLFlexibleServerFirewallRuleClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLFlexibleServerFirewallRule(client clients.PostgreSQLFlexibleServerFirewallRuleClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforPostgreSQLFlexibleServerFirewallRuleWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServerFirewallRule, ), } } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and firewallRuleName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] firewallRuleName := queryParts[1] if firewallRuleName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "firewallRuleName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, firewallRuleName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureDBforPostgreSQLFlexibleServerFirewallRuleToSDPItem(&resp.FirewallRule, serverName, firewallRuleName, scope) } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) azureDBforPostgreSQLFlexibleServerFirewallRuleToSDPItem(rule *armpostgresqlflexibleservers.FirewallRule, serverName, firewallRuleName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(rule, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, firewallRuleName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DBforPostgreSQLFlexibleServerFirewallRule.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: nil, } // Link to parent PostgreSQL Flexible Server if rule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*rule.ID, []string{"flexibleServers"}) if len(params) > 0 { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: params[0], Scope: scope, }, }) } } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } // Link to stdlib IP items for StartIPAddress and EndIPAddress if rule.Properties != nil { if rule.Properties.StartIPAddress != nil && *rule.Properties.StartIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *rule.Properties.StartIPAddress, Scope: "global", }, }) } if rule.Properties.EndIPAddress != nil && *rule.Properties.EndIPAddress != "" && (rule.Properties.StartIPAddress == nil || *rule.Properties.EndIPAddress != *rule.Properties.StartIPAddress) { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *rule.Properties.EndIPAddress, Scope: "global", }, }) } } return sdpItem, nil } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLFlexibleServerFirewallRuleLookupByName, } } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, rule := range page.Value { if rule.Name == nil { continue } item, sdpErr := s.azureDBforPostgreSQLFlexibleServerFirewallRuleToSDPItem(rule, serverName, *rule.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, rule := range page.Value { if rule.Name == nil { continue } item, sdpErr := s.azureDBforPostgreSQLFlexibleServerFirewallRuleToSDPItem(rule, serverName, *rule.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.DBforPostgreSQLFlexibleServer: true, stdlib.NetworkIP: true, } } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_postgresql_flexible_server_firewall_rule.id", }, } } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/read", } } func (s dbforPostgreSQLFlexibleServerFirewallRuleWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-firewall-rule_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockPostgreSQLFlexibleServerFirewallRulePager struct { pages []armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse index int } func (m *mockPostgreSQLFlexibleServerFirewallRulePager) More() bool { return m.index < len(m.pages) } func (m *mockPostgreSQLFlexibleServerFirewallRulePager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorPostgreSQLFlexibleServerFirewallRulePager struct{} func (e *errorPostgreSQLFlexibleServerFirewallRulePager) More() bool { return true } func (e *errorPostgreSQLFlexibleServerFirewallRulePager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse, error) { return armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse{}, errors.New("pager error") } type testPostgreSQLFlexibleServerFirewallRuleClient struct { *mocks.MockPostgreSQLFlexibleServerFirewallRuleClient pager clients.PostgreSQLFlexibleServerFirewallRulePager } func (t *testPostgreSQLFlexibleServerFirewallRuleClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.PostgreSQLFlexibleServerFirewallRulePager { return t.pager } func TestDBforPostgreSQLFlexibleServerFirewallRule(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" firewallRuleName := "test-rule" t.Run("Get", func(t *testing.T) { rule := createAzurePostgreSQLFlexibleServerFirewallRule(serverName, firewallRuleName) mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, firewallRuleName).Return( armpostgresqlflexibleservers.FirewallRulesClientGetResponse{ FirewallRule: *rule, }, nil) wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(&testPostgreSQLFlexibleServerFirewallRuleClient{MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, firewallRuleName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerFirewallRule.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerFirewallRule, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, firewallRuleName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "0.0.0.0", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "255.255.255.255", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(&testPostgreSQLFlexibleServerFirewallRuleClient{MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing only serverName (1 query part), but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(&testPostgreSQLFlexibleServerFirewallRuleClient{MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when firewall rule name is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { rule1 := createAzurePostgreSQLFlexibleServerFirewallRule(serverName, "rule1") rule2 := createAzurePostgreSQLFlexibleServerFirewallRule(serverName, "rule2") mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) pager := &mockPostgreSQLFlexibleServerFirewallRulePager{ pages: []armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse{ { FirewallRuleList: armpostgresqlflexibleservers.FirewallRuleList{ Value: []*armpostgresqlflexibleservers.FirewallRule{rule1, rule2}, }, }, }, } testClient := &testPostgreSQLFlexibleServerFirewallRuleClient{ MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error from Search, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items from Search, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { rule1 := createAzurePostgreSQLFlexibleServerFirewallRule(serverName, "rule1") mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) pager := &mockPostgreSQLFlexibleServerFirewallRulePager{ pages: []armpostgresqlflexibleservers.FirewallRulesClientListByServerResponse{ { FirewallRuleList: armpostgresqlflexibleservers.FirewallRuleList{ Value: []*armpostgresqlflexibleservers.FirewallRule{rule1}, }, }, }, } testClient := &testPostgreSQLFlexibleServerFirewallRuleClient{ MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) items := stream.GetItems() errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("Expected no errors from SearchStream, got: %v", errs) } if len(items) != 1 { t.Errorf("Expected 1 item from SearchStream, got %d", len(items)) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(&testPostgreSQLFlexibleServerFirewallRuleClient{MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("firewall rule not found") mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-rule").Return( armpostgresqlflexibleservers.FirewallRulesClientGetResponse{}, expectedErr) wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(&testPostgreSQLFlexibleServerFirewallRuleClient{MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-rule") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent firewall rule, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) errorPager := &errorPostgreSQLFlexibleServerFirewallRulePager{} testClient := &testPostgreSQLFlexibleServerFirewallRuleClient{ MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error from Search when pager returns error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerFirewallRule(&testPostgreSQLFlexibleServerFirewallRuleClient{MockPostgreSQLFlexibleServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } potentialLinks := w.PotentialLinks() if !potentialLinks[azureshared.DBforPostgreSQLFlexibleServer] { t.Error("Expected PotentialLinks to include DBforPostgreSQLFlexibleServer") } if !potentialLinks[stdlib.NetworkIP] { t.Error("Expected PotentialLinks to include stdlib.NetworkIP") } mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_postgresql_flexible_server_firewall_rule.id" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_postgresql_flexible_server_firewall_rule.id' mapping") } }) } func createAzurePostgreSQLFlexibleServerFirewallRule(serverName, firewallRuleName string) *armpostgresqlflexibleservers.FirewallRule { ruleID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/" + serverName + "/firewallRules/" + firewallRuleName return &armpostgresqlflexibleservers.FirewallRule{ Name: new(firewallRuleName), ID: new(ruleID), Properties: &armpostgresqlflexibleservers.FirewallRuleProperties{ StartIPAddress: new("0.0.0.0"), EndIPAddress: new("255.255.255.255"), }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-private-endpoint-connection.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var DBforPostgreSQLFlexibleServerPrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection) type dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper struct { client clients.DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient *azureshared.MultiResourceGroupBase } // NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection returns a SearchableWrapper for Azure DB for PostgreSQL flexible server private endpoint connections. func NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(client clients.DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection, ), } } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and privateEndpointConnectionName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] connectionName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, connectionName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(&resp.PrivateEndpointConnection, serverName, connectionName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLFlexibleServerPrivateEndpointConnectionLookupByName, } } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, serverName, *conn.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, serverName, *conn.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.DBforPostgreSQLFlexibleServer: true, azureshared.NetworkPrivateEndpoint: true, } } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) azurePrivateEndpointConnectionToSDPItem(conn *armpostgresqlflexibleservers.PrivateEndpointConnection, serverName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(conn) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, connectionName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health from provisioning state if conn.Properties != nil && conn.Properties.ProvisioningState != nil { state := strings.ToLower(string(*conn.Properties.ProvisioningState)) switch state { case "succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "creating", "updating", "deleting": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "failed": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent DB for PostgreSQL Flexible Server sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) // Link to Network Private Endpoint when present (may be in different resource group) if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { peID := *conn.Properties.PrivateEndpoint.ID peName := azureshared.ExtractResourceName(peID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/privateEndpointConnections/read", } } func (s dbforpostgresqlFlexibleServerPrivateEndpointConnectionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-private-endpoint-connection_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager struct { pages []armpostgresqlflexibleservers.PrivateEndpointConnectionsClientListByServerResponse index int } func (m *mockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager) More() bool { return m.index < len(m.pages) } func (m *mockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.PrivateEndpointConnectionsClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.PrivateEndpointConnectionsClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient struct { *mocks.MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient pager clients.DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager } func (t *testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager { return t.pager } func TestDBforPostgreSQLFlexibleServerPrivateEndpointConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-pg-server" connectionName := "test-pec" t.Run("Get", func(t *testing.T) { conn := createAzureDBforPostgreSQLFlexibleServerPrivateEndpointConnection(connectionName, "") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, connectionName).Return( armpostgresqlflexibleservers.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient{MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(serverName, connectionName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(serverName, connectionName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) < 1 { t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) } foundFlexibleServer := false for _, lq := range linkedQueries { if lq.GetQuery().GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() { foundFlexibleServer = true if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected DBforPostgreSQLFlexibleServer link method GET, got %v", lq.GetQuery().GetMethod()) } if lq.GetQuery().GetQuery() != serverName { t.Errorf("Expected DBforPostgreSQLFlexibleServer query %s, got %s", serverName, lq.GetQuery().GetQuery()) } } } if !foundFlexibleServer { t.Error("Expected linked query to DBforPostgreSQLFlexibleServer") } }) }) t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { peID := "/subscriptions/test-subscription/resourceGroups/other-rg/providers/Microsoft.Network/privateEndpoints/test-pe" conn := createAzureDBforPostgreSQLFlexibleServerPrivateEndpointConnection(connectionName, peID) mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, connectionName).Return( armpostgresqlflexibleservers.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient{MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundPrivateEndpoint := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { foundPrivateEndpoint = true if lq.GetQuery().GetQuery() != "test-pe" { t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) } } } if !foundPrivateEndpoint { t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") } }) t.Run("Search", func(t *testing.T) { conn1 := createAzureDBforPostgreSQLFlexibleServerPrivateEndpointConnection("pec-1", "") conn2 := createAzureDBforPostgreSQLFlexibleServerPrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(ctrl) mockPager := &mockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager{ pages: []armpostgresqlflexibleservers.PrivateEndpointConnectionsClientListByServerResponse{ { PrivateEndpointConnectionList: armpostgresqlflexibleservers.PrivateEndpointConnectionList{ Value: []*armpostgresqlflexibleservers.PrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient{ MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) items, qErr := wrapper.Search(ctx, subscriptionID+"."+resourceGroup, serverName) if qErr != nil { t.Fatalf("Search failed: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items, got %d", len(items)) } for _, item := range items { if item.GetType() != azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection, item.GetType()) } } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validConn := createAzureDBforPostgreSQLFlexibleServerPrivateEndpointConnection("valid-pec", "") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(ctrl) mockPager := &mockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager{ pages: []armpostgresqlflexibleservers.PrivateEndpointConnectionsClientListByServerResponse{ { PrivateEndpointConnectionList: armpostgresqlflexibleservers.PrivateEndpointConnectionList{ Value: []*armpostgresqlflexibleservers.PrivateEndpointConnection{ nil, {Name: nil}, validConn, }, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient{ MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) items, qErr := wrapper.Search(ctx, subscriptionID+"."+resourceGroup, serverName) if qErr != nil { t.Fatalf("Search failed: %v", qErr) } if len(items) != 1 { t.Errorf("Expected 1 item (nil names skipped), got %d", len(items)) } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(ctrl) testClient := &testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient{MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when query has only serverName") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("connection not found") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, connectionName).Return( armpostgresqlflexibleservers.PrivateEndpointConnectionsClientGetResponse{}, expectedErr) testClient := &testDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient{MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, connectionName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Fatal("Expected error when Get fails") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewDBforPostgreSQLFlexibleServerPrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.DBforPostgreSQLFlexibleServer] { t.Error("Expected PotentialLinks to include DBforPostgreSQLFlexibleServer") } if !links[azureshared.NetworkPrivateEndpoint] { t.Error("Expected PotentialLinks to include NetworkPrivateEndpoint") } }) } func createAzureDBforPostgreSQLFlexibleServerPrivateEndpointConnection(connectionName, privateEndpointID string) *armpostgresqlflexibleservers.PrivateEndpointConnection { state := armpostgresqlflexibleservers.PrivateEndpointConnectionProvisioningStateSucceeded conn := &armpostgresqlflexibleservers.PrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-pg-server/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.DBforPostgreSQL/flexibleServers/privateEndpointConnections"), Properties: &armpostgresqlflexibleservers.PrivateEndpointConnectionProperties{ ProvisioningState: &state, }, } if privateEndpointID != "" { conn.Properties.PrivateEndpoint = &armpostgresqlflexibleservers.PrivateEndpoint{ ID: new(privateEndpointID), } } return conn } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-replica.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var DBforPostgreSQLFlexibleServerReplicaLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServerReplica) type dbforPostgreSQLFlexibleServerReplicaWrapper struct { client clients.DBforPostgreSQLFlexibleServerReplicaClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLFlexibleServerReplica(client clients.DBforPostgreSQLFlexibleServerReplicaClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforPostgreSQLFlexibleServerReplicaWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServerReplica, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/flexibleserver/servers/get func (s dbforPostgreSQLFlexibleServerReplicaWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and replicaName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] replicaName := queryParts[1] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: s.Type(), } } if replicaName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "replicaName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, replicaName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureReplicaToSDPItem(&resp.Server, serverName, replicaName, scope) } func (s dbforPostgreSQLFlexibleServerReplicaWrapper) azureReplicaToSDPItem(server *armpostgresqlflexibleservers.Server, serverName, replicaName, scope string) (*sdp.Item, *sdp.QueryError) { if server.Name == nil { return nil, azureshared.QueryError(errors.New("replica name is nil"), scope, s.Type()) } attributes, err := shared.ToAttributesWithExclude(server, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, replicaName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DBforPostgreSQLFlexibleServerReplica.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(server.Tags), } // Map provisioning state to health if server.Properties != nil && server.Properties.State != nil { switch *server.Properties.State { case armpostgresqlflexibleservers.ServerStateReady: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armpostgresqlflexibleservers.ServerStateStarting, armpostgresqlflexibleservers.ServerStateStopping, armpostgresqlflexibleservers.ServerStateUpdating, armpostgresqlflexibleservers.ServerStateProvisioning, armpostgresqlflexibleservers.ServerStateRestarting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armpostgresqlflexibleservers.ServerStateDisabled, armpostgresqlflexibleservers.ServerStateStopped, armpostgresqlflexibleservers.ServerStateInaccessible: sdpItem.Health = sdp.Health_HEALTH_WARNING.Enum() case armpostgresqlflexibleservers.ServerStateDropping: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } else { sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } // Link to parent PostgreSQL Flexible Server (source server for replica) if server.Properties != nil && server.Properties.SourceServerResourceID != nil { sourceServerID := *server.Properties.SourceServerResourceID sourceServerName := azureshared.ExtractResourceName(sourceServerID) if sourceServerName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(sourceServerID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: sourceServerName, Scope: linkedScope, }, }) } } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } // Link to Fully Qualified Domain Name (DNS) if server.Properties != nil && server.Properties.FullyQualifiedDomainName != nil && *server.Properties.FullyQualifiedDomainName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *server.Properties.FullyQualifiedDomainName, Scope: "global", }, }) } // Link to Subnet (external resource) if server.Properties != nil && server.Properties.Network != nil && server.Properties.Network.DelegatedSubnetResourceID != nil { subnetID := *server.Properties.Network.DelegatedSubnetResourceID scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subscriptionID := scopeParams[0] resourceGroupName := scopeParams[1] vnetName := subnetParams[0] subnetName := subnetParams[1] query := shared.CompositeLookupKey(vnetName, subnetName) linkedScope := subscriptionID + "." + resourceGroupName sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: linkedScope, }, }) // Link to Virtual Network (parent of subnet) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // Link to Private DNS Zone (external resource) if server.Properties != nil && server.Properties.Network != nil && server.Properties.Network.PrivateDNSZoneArmResourceID != nil { privateDNSZoneID := *server.Properties.Network.PrivateDNSZoneArmResourceID privateDNSZoneName := azureshared.ExtractResourceName(privateDNSZoneID) if privateDNSZoneName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(privateDNSZoneID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateDNSZone.String(), Method: sdp.QueryMethod_GET, Query: privateDNSZoneName, Scope: linkedScope, }, }) } } // Link to User Assigned Managed Identities if server.Identity != nil && server.Identity.UserAssignedIdentities != nil { for identityResourceID := range server.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Link to Network Private Endpoints from PrivateEndpointConnections if server.Properties != nil && server.Properties.PrivateEndpointConnections != nil { for _, peConnection := range server.Properties.PrivateEndpointConnections { if peConnection.Properties != nil && peConnection.Properties.PrivateEndpoint != nil && peConnection.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *peConnection.Properties.PrivateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: linkedScope, }, }) } } } } // Link to Key Vault Vault from Data Encryption (Primary Key) if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.PrimaryKeyURI != nil { keyURI := *server.Properties.DataEncryption.PrimaryKeyURI if vaultName := azureshared.ExtractVaultNameFromURI(keyURI); vaultName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, }, }) // Link to Key Vault Key keyName := azureshared.ExtractKeyNameFromURI(keyURI) if keyName != "" { query := shared.CompositeLookupKey(vaultName, keyName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, }, }) } } } // Link to Primary User Assigned Managed Identity from Data Encryption if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.PrimaryUserAssignedIdentityID != nil { identityID := *server.Properties.DataEncryption.PrimaryUserAssignedIdentityID identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } // Link to Geo Backup Key Vault Vault from Data Encryption if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.GeoBackupKeyURI != nil { keyURI := *server.Properties.DataEncryption.GeoBackupKeyURI if vaultName := azureshared.ExtractVaultNameFromURI(keyURI); vaultName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, }, }) // Link to Geo Backup Key Vault Key keyName := azureshared.ExtractKeyNameFromURI(keyURI) if keyName != "" { query := shared.CompositeLookupKey(vaultName, keyName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, }, }) } } } // Link to Geo Backup User Assigned Managed Identity from Data Encryption if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.GeoBackupUserAssignedIdentityID != nil { identityID := *server.Properties.DataEncryption.GeoBackupUserAssignedIdentityID identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (s dbforPostgreSQLFlexibleServerReplicaWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLFlexibleServerReplicaLookupByName, } } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/flexibleserver/replicas/list-by-server func (s dbforPostgreSQLFlexibleServerReplicaWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, server := range page.Value { if server.Name == nil { continue } item, sdpErr := s.azureReplicaToSDPItem(server, serverName, *server.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforPostgreSQLFlexibleServerReplicaWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] if serverName == "" { stream.SendError(azureshared.QueryError(errors.New("serverName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, server := range page.Value { if server.Name == nil { continue } item, sdpErr := s.azureReplicaToSDPItem(server, serverName, *server.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s dbforPostgreSQLFlexibleServerReplicaWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (s dbforPostgreSQLFlexibleServerReplicaWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.DBforPostgreSQLFlexibleServer, azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, azureshared.NetworkPrivateDNSZone, azureshared.NetworkPrivateEndpoint, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.KeyVaultVault, azureshared.KeyVaultKey, stdlib.NetworkDNS, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftdbforpostgresql func (s dbforPostgreSQLFlexibleServerReplicaWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/read", "Microsoft.DBforPostgreSQL/flexibleServers/replicas/read", } } func (s dbforPostgreSQLFlexibleServerReplicaWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-replica_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockDBforPostgreSQLFlexibleServerReplicaPager struct { pages []armpostgresqlflexibleservers.ReplicasClientListByServerResponse index int } func (m *mockDBforPostgreSQLFlexibleServerReplicaPager) More() bool { return m.index < len(m.pages) } func (m *mockDBforPostgreSQLFlexibleServerReplicaPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.ReplicasClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.ReplicasClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorDBforPostgreSQLFlexibleServerReplicaPager struct{} func (e *errorDBforPostgreSQLFlexibleServerReplicaPager) More() bool { return true } func (e *errorDBforPostgreSQLFlexibleServerReplicaPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.ReplicasClientListByServerResponse, error) { return armpostgresqlflexibleservers.ReplicasClientListByServerResponse{}, errors.New("pager error") } type testDBforPostgreSQLFlexibleServerReplicaClient struct { *mocks.MockDBforPostgreSQLFlexibleServerReplicaClient pager clients.DBforPostgreSQLFlexibleServerReplicaPager } func (t *testDBforPostgreSQLFlexibleServerReplicaClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerReplicaPager { return t.pager } func TestDBforPostgreSQLFlexibleServerReplica(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" replicaName := "test-replica" t.Run("Get", func(t *testing.T) { replica := createAzurePostgreSQLFlexibleServerReplica(serverName, replicaName) mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, replicaName).Return( armpostgresqlflexibleservers.ServersClientGetResponse{ Server: *replica, }, nil) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, replicaName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerReplica.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerReplica, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, replicaName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %v", sdpItem.GetHealth()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-replica.postgres.database.azure.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing only serverName (1 query part), but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", replicaName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when serverName is empty, but got nil") } }) t.Run("GetWithEmptyReplicaName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when replicaName is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { replica1 := createAzurePostgreSQLFlexibleServerReplica(serverName, "replica1") replica2 := createAzurePostgreSQLFlexibleServerReplica(serverName, "replica2") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) pager := &mockDBforPostgreSQLFlexibleServerReplicaPager{ pages: []armpostgresqlflexibleservers.ReplicasClientListByServerResponse{ { ServerList: armpostgresqlflexibleservers.ServerList{ Value: []*armpostgresqlflexibleservers.Server{replica1, replica2}, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerReplicaClient{ MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error from Search, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items from Search, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { replica1 := createAzurePostgreSQLFlexibleServerReplica(serverName, "replica1") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) pager := &mockDBforPostgreSQLFlexibleServerReplicaPager{ pages: []armpostgresqlflexibleservers.ReplicasClientListByServerResponse{ { ServerList: armpostgresqlflexibleservers.ServerList{ Value: []*armpostgresqlflexibleservers.Server{replica1}, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerReplicaClient{ MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) items := stream.GetItems() errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("Expected no errors from SearchStream, got: %v", errs) } if len(items) != 1 { t.Errorf("Expected 1 item from SearchStream, got %d", len(items)) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("SearchWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when serverName is empty, but got nil") } }) t.Run("SearchStreamWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) searchStreamable := wrapper.(sources.SearchStreamableWrapper) stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, stream, sdpcache.NewNoOpCache(), sdpcache.CacheKey{}, wrapper.Scopes()[0], "") errs := stream.GetErrors() if len(errs) == 0 { t.Error("Expected error when serverName is empty, but got none") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("replica not found") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-replica").Return( armpostgresqlflexibleservers.ServersClientGetResponse{}, expectedErr) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-replica") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent replica, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) errorPager := &errorDBforPostgreSQLFlexibleServerReplicaPager{} testClient := &testDBforPostgreSQLFlexibleServerReplicaClient{ MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error from Search when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() expectedLinks := []shared.ItemType{ azureshared.DBforPostgreSQLFlexibleServer, azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, azureshared.NetworkPrivateDNSZone, azureshared.NetworkPrivateEndpoint, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.KeyVaultVault, azureshared.KeyVaultKey, stdlib.NetworkDNS, } for _, expected := range expectedLinks { if !potentialLinks[expected] { t.Errorf("Expected PotentialLinks to include %s", expected) } } }) t.Run("HealthMapping", func(t *testing.T) { testCases := []struct { state armpostgresqlflexibleservers.ServerState expectedHealth sdp.Health }{ {armpostgresqlflexibleservers.ServerStateReady, sdp.Health_HEALTH_OK}, {armpostgresqlflexibleservers.ServerStateStarting, sdp.Health_HEALTH_PENDING}, {armpostgresqlflexibleservers.ServerStateStopping, sdp.Health_HEALTH_PENDING}, {armpostgresqlflexibleservers.ServerStateUpdating, sdp.Health_HEALTH_PENDING}, {armpostgresqlflexibleservers.ServerStateDisabled, sdp.Health_HEALTH_WARNING}, {armpostgresqlflexibleservers.ServerStateStopped, sdp.Health_HEALTH_WARNING}, {armpostgresqlflexibleservers.ServerStateDropping, sdp.Health_HEALTH_ERROR}, } for _, tc := range testCases { t.Run(string(tc.state), func(t *testing.T) { replica := createAzurePostgreSQLFlexibleServerReplicaWithState(serverName, replicaName, tc.state) mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, replicaName).Return( armpostgresqlflexibleservers.ServersClientGetResponse{ Server: *replica, }, nil) wrapper := manual.NewDBforPostgreSQLFlexibleServerReplica(&testDBforPostgreSQLFlexibleServerReplicaClient{MockDBforPostgreSQLFlexibleServerReplicaClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, replicaName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expectedHealth { t.Errorf("Expected health %v for state %s, got %v", tc.expectedHealth, tc.state, sdpItem.GetHealth()) } }) } }) } func createAzurePostgreSQLFlexibleServerReplica(serverName, replicaName string) *armpostgresqlflexibleservers.Server { return createAzurePostgreSQLFlexibleServerReplicaWithState(serverName, replicaName, armpostgresqlflexibleservers.ServerStateReady) } func createAzurePostgreSQLFlexibleServerReplicaWithState(serverName, replicaName string, state armpostgresqlflexibleservers.ServerState) *armpostgresqlflexibleservers.Server { replicaID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/" + replicaName sourceServerID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/" + serverName replicationRole := armpostgresqlflexibleservers.ReplicationRoleAsyncReplica fqdn := replicaName + ".postgres.database.azure.com" return &armpostgresqlflexibleservers.Server{ Name: &replicaName, ID: &replicaID, Type: new(string), Location: new(string), Properties: &armpostgresqlflexibleservers.ServerProperties{ State: &state, ReplicationRole: &replicationRole, SourceServerResourceID: &sourceServerID, FullyQualifiedDomainName: &fqdn, }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-virtual-endpoint.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var DBforPostgreSQLFlexibleServerVirtualEndpointLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint) type dbforPostgreSQLFlexibleServerVirtualEndpointWrapper struct { client clients.DBforPostgreSQLFlexibleServerVirtualEndpointClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLFlexibleServerVirtualEndpoint(client clients.DBforPostgreSQLFlexibleServerVirtualEndpointClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &dbforPostgreSQLFlexibleServerVirtualEndpointWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/flexibleserver/virtual-endpoints/get?view=rest-postgresql-2025-08-01 func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and virtualEndpointName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] virtualEndpointName := queryParts[1] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: s.Type(), } } if virtualEndpointName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "virtualEndpointName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, virtualEndpointName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureVirtualEndpointToSDPItem(&resp.VirtualEndpoint, serverName, virtualEndpointName, scope) } func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) azureVirtualEndpointToSDPItem(virtualEndpoint *armpostgresqlflexibleservers.VirtualEndpoint, serverName, virtualEndpointName, scope string) (*sdp.Item, *sdp.QueryError) { if virtualEndpoint.Name == nil { return nil, azureshared.QueryError(errors.New("virtual endpoint name is nil"), scope, s.Type()) } attributes, err := shared.ToAttributesWithExclude(virtualEndpoint) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, virtualEndpointName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: nil, } // Link to parent PostgreSQL Flexible Server if virtualEndpoint.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*virtualEndpoint.ID, []string{"flexibleServers"}) if len(params) > 0 { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: params[0], Scope: scope, }, }) } } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } // Link to member servers (Members field contains server names that this virtual endpoint can refer to) if virtualEndpoint.Properties != nil && virtualEndpoint.Properties.Members != nil { for _, memberServerName := range virtualEndpoint.Properties.Members { if memberServerName != nil && *memberServerName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: *memberServerName, Scope: scope, }, }) } } } // Link to virtual endpoint DNS names (VirtualEndpoints field contains DNS names) if virtualEndpoint.Properties != nil && virtualEndpoint.Properties.VirtualEndpoints != nil { for _, dnsName := range virtualEndpoint.Properties.VirtualEndpoints { if dnsName != nil && *dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *dnsName, Scope: "global", }, }) } } } return sdpItem, nil } func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, DBforPostgreSQLFlexibleServerVirtualEndpointLookupByName, } } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/flexibleserver/virtual-endpoints/list-by-server?view=rest-postgresql-2025-08-01 func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, virtualEndpoint := range page.Value { if virtualEndpoint.Name == nil { continue } item, sdpErr := s.azureVirtualEndpointToSDPItem(virtualEndpoint, serverName, *virtualEndpoint.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] if serverName == "" { stream.SendError(azureshared.QueryError(errors.New("serverName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, virtualEndpoint := range page.Value { if virtualEndpoint.Name == nil { continue } item, sdpErr := s.azureVirtualEndpointToSDPItem(virtualEndpoint, serverName, *virtualEndpoint.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DBforPostgreSQLFlexibleServerLookupByName, }, } } func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.DBforPostgreSQLFlexibleServer: true, stdlib.NetworkDNS: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftdbforpostgresql func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/virtualEndpoints/read", } } func (s dbforPostgreSQLFlexibleServerVirtualEndpointWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server-virtual-endpoint_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockDBforPostgreSQLFlexibleServerVirtualEndpointPager struct { pages []armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse index int } func (m *mockDBforPostgreSQLFlexibleServerVirtualEndpointPager) More() bool { return m.index < len(m.pages) } func (m *mockDBforPostgreSQLFlexibleServerVirtualEndpointPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorDBforPostgreSQLFlexibleServerVirtualEndpointPager struct{} func (e *errorDBforPostgreSQLFlexibleServerVirtualEndpointPager) More() bool { return true } func (e *errorDBforPostgreSQLFlexibleServerVirtualEndpointPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse, error) { return armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse{}, errors.New("pager error") } type testDBforPostgreSQLFlexibleServerVirtualEndpointClient struct { *mocks.MockDBforPostgreSQLFlexibleServerVirtualEndpointClient pager clients.DBforPostgreSQLFlexibleServerVirtualEndpointPager } func (t *testDBforPostgreSQLFlexibleServerVirtualEndpointClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerVirtualEndpointPager { return t.pager } func TestDBforPostgreSQLFlexibleServerVirtualEndpoint(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" virtualEndpointName := "test-virtual-endpoint" t.Run("Get", func(t *testing.T) { virtualEndpoint := createAzurePostgreSQLFlexibleServerVirtualEndpoint(serverName, virtualEndpointName) mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, virtualEndpointName).Return( armpostgresqlflexibleservers.VirtualEndpointsClientGetResponse{ VirtualEndpoint: *virtualEndpoint, }, nil) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, virtualEndpointName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, virtualEndpointName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "member-server-1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-endpoint.postgres.database.azure.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing only serverName (1 query part), but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", virtualEndpointName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when serverName is empty, but got nil") } }) t.Run("GetWithEmptyVirtualEndpointName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when virtualEndpointName is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { virtualEndpoint1 := createAzurePostgreSQLFlexibleServerVirtualEndpoint(serverName, "vep1") virtualEndpoint2 := createAzurePostgreSQLFlexibleServerVirtualEndpoint(serverName, "vep2") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) pager := &mockDBforPostgreSQLFlexibleServerVirtualEndpointPager{ pages: []armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse{ { VirtualEndpointsList: armpostgresqlflexibleservers.VirtualEndpointsList{ Value: []*armpostgresqlflexibleservers.VirtualEndpoint{virtualEndpoint1, virtualEndpoint2}, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerVirtualEndpointClient{ MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error from Search, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items from Search, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { virtualEndpoint1 := createAzurePostgreSQLFlexibleServerVirtualEndpoint(serverName, "vep1") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) pager := &mockDBforPostgreSQLFlexibleServerVirtualEndpointPager{ pages: []armpostgresqlflexibleservers.VirtualEndpointsClientListByServerResponse{ { VirtualEndpointsList: armpostgresqlflexibleservers.VirtualEndpointsList{ Value: []*armpostgresqlflexibleservers.VirtualEndpoint{virtualEndpoint1}, }, }, }, } testClient := &testDBforPostgreSQLFlexibleServerVirtualEndpointClient{ MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient, pager: pager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) items := stream.GetItems() errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("Expected no errors from SearchStream, got: %v", errs) } if len(items) != 1 { t.Errorf("Expected 1 item from SearchStream, got %d", len(items)) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("SearchWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when serverName is empty, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("virtual endpoint not found") mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-vep").Return( armpostgresqlflexibleservers.VirtualEndpointsClientGetResponse{}, expectedErr) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-vep") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent virtual endpoint, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) errorPager := &errorDBforPostgreSQLFlexibleServerVirtualEndpointPager{} testClient := &testDBforPostgreSQLFlexibleServerVirtualEndpointClient{ MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error from Search when pager returns error, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl) wrapper := manual.NewDBforPostgreSQLFlexibleServerVirtualEndpoint(&testDBforPostgreSQLFlexibleServerVirtualEndpointClient{MockDBforPostgreSQLFlexibleServerVirtualEndpointClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() expectedLinks := map[shared.ItemType]bool{ azureshared.DBforPostgreSQLFlexibleServer: true, stdlib.NetworkDNS: true, } for linkType := range expectedLinks { if !potentialLinks[linkType] { t.Errorf("Expected PotentialLinks to include %s", linkType) } } }) } func createAzurePostgreSQLFlexibleServerVirtualEndpoint(serverName, virtualEndpointName string) *armpostgresqlflexibleservers.VirtualEndpoint { virtualEndpointID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/" + serverName + "/virtualEndpoints/" + virtualEndpointName endpointType := armpostgresqlflexibleservers.VirtualEndpointTypeReadWrite return &armpostgresqlflexibleservers.VirtualEndpoint{ Name: new(virtualEndpointName), ID: new(virtualEndpointID), Type: new("Microsoft.DBforPostgreSQL/flexibleServers/virtualEndpoints"), Properties: &armpostgresqlflexibleservers.VirtualEndpointResourceProperties{ EndpointType: &endpointType, Members: []*string{new("member-server-1")}, VirtualEndpoints: []*string{ new("test-endpoint.postgres.database.azure.com"), }, }, } } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server.go ================================================ package manual import ( "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var DBforPostgreSQLFlexibleServerLookupByName = shared.NewItemTypeLookup("name", azureshared.DBforPostgreSQLFlexibleServer) type dbforPostgreSQLFlexibleServerWrapper struct { client clients.PostgreSQLFlexibleServersClient *azureshared.MultiResourceGroupBase } func NewDBforPostgreSQLFlexibleServer(client clients.PostgreSQLFlexibleServersClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &dbforPostgreSQLFlexibleServerWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DBforPostgreSQLFlexibleServer, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/servers/get?view=rest-postgresql-2025-08-01&tabs=HTTP func (s dbforPostgreSQLFlexibleServerWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Get requires 1 query part: serverName"), scope, s.Type()) } serverName := queryParts[0] if serverName == "" { return nil, azureshared.QueryError(errors.New("serverName is empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureDBforPostgreSQLFlexibleServerToSDPItem(&resp.Server, scope) } // ref: https://learn.microsoft.com/en-us/rest/api/postgresql/servers/list-by-resource-group?view=rest-postgresql-2025-08-01&tabs=HTTP func (s dbforPostgreSQLFlexibleServerWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByResourceGroup(ctx, rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, server := range page.Value { if server.Name == nil { continue } item, sdpErr := s.azureDBforPostgreSQLFlexibleServerToSDPItem(server, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s dbforPostgreSQLFlexibleServerWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByResourceGroup(ctx, rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, server := range page.Value { if server.Name == nil { continue } item, sdpErr := s.azureDBforPostgreSQLFlexibleServerToSDPItem(server, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s dbforPostgreSQLFlexibleServerWrapper) azureDBforPostgreSQLFlexibleServerToSDPItem(server *armpostgresqlflexibleservers.Server, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(server, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } if server.Name == nil { return nil, azureshared.QueryError(errors.New("serverName is nil"), scope, s.Type()) } sdpItem := &sdp.Item{ Type: s.Type(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(server.Tags), } serverName := *server.Name // Link to Subnet (external resource) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{virtualNetworkName}/subnets/{subnetName} // // IMPORTANT: Subnets can be in a different resource group than the PostgreSQL Flexible Server. // We must extract the subscription ID and resource group from the subnet's resource ID to construct // the correct scope. if server.Properties != nil && server.Properties.Network != nil && server.Properties.Network.DelegatedSubnetResourceID != nil { subnetID := *server.Properties.Network.DelegatedSubnetResourceID // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName} // Extract subscription, resource group, virtual network name, and subnet name scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subscriptionID := scopeParams[0] resourceGroupName := scopeParams[1] vnetName := subnetParams[0] subnetName := subnetParams[1] // Subnet adapter requires: resourceGroup, virtualNetworkName, subnetName // Use composite lookup key to join them query := shared.CompositeLookupKey(vnetName, subnetName) // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the subnet actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, // Use the subnet's scope, not the server's scope }, }) // Link to Virtual Network (parent of subnet) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/virtual-networks/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{virtualNetworkName} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: scope, // Use the same scope as the subnet }, }) } } // Link to Databases (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/databases/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/databases?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLDatabase.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Firewall Rules (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/firewall-rules/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/firewallRules?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerFirewallRule.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Configurations (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/configurations/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/configurations?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerConfiguration.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Fully Qualified Domain Name (DNS) // If the server has an FQDN, link it to the DNS standard library type if server.Properties != nil && server.Properties.FullyQualifiedDomainName != nil && *server.Properties.FullyQualifiedDomainName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *server.Properties.FullyQualifiedDomainName, Scope: "global", }, }) } // Link to User Assigned Managed Identities (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if server.Identity != nil && server.Identity.UserAssignedIdentities != nil { for identityResourceID := range server.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Link to Private DNS Zone (external resource) // Reference: https://learn.microsoft.com/en-us/rest/api/dns/privatednszones/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/privateDnsZones/{privateZoneName} if server.Properties != nil && server.Properties.Network != nil && server.Properties.Network.PrivateDNSZoneArmResourceID != nil { privateDNSZoneID := *server.Properties.Network.PrivateDNSZoneArmResourceID privateDNSZoneName := azureshared.ExtractResourceName(privateDNSZoneID) if privateDNSZoneName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(privateDNSZoneID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateDNSZone.String(), Method: sdp.QueryMethod_GET, Query: privateDNSZoneName, Scope: linkedScope, }, }) } } // Link to Administrators (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/administrators/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/administrators?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerAdministrator.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Private Endpoint Connections (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/private-endpoint-connections/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/privateEndpointConnections?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Network Private Endpoints (external resources) from PrivateEndpointConnections // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/privateEndpoints/{privateEndpointName} if server.Properties != nil && server.Properties.PrivateEndpointConnections != nil { for _, peConnection := range server.Properties.PrivateEndpointConnections { if peConnection.Properties != nil && peConnection.Properties.PrivateEndpoint != nil && peConnection.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *peConnection.Properties.PrivateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: linkedScope, }, }) } } } } // Link to Private Link Resources (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/private-link-resources/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/privateLinkResources?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerPrivateLinkResource.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Replicas (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/replicas/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/replicas?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerReplica.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Migrations (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/migrations/list-by-target-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/migrations?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerMigration.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Backups (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/flexibleserver/backups/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/backups?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerBackup.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Virtual Endpoints (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/flexibleserver/virtual-endpoints/list-by-server?view=rest-postgresql-2025-08-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName}/virtualEndpoints?api-version=2025-08-01 sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Key Vault Vault (external resource) from Data Encryption // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName} if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.PrimaryKeyURI != nil { keyURI := *server.Properties.DataEncryption.PrimaryKeyURI // Key URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} // Extract vault name from URI if vaultName := azureshared.ExtractVaultNameFromURI(keyURI); vaultName != "" { // Key Vault can be in a different resource group, but we don't have that info from the URI // Use default scope and let the Key Vault adapter handle cross-resource-group lookups if needed sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, }, }) } } // Link to Key Vault Key (external resource) from Data Encryption // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keys/get-key // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}/keys/{keyName} if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.PrimaryKeyURI != nil { keyURI := *server.Properties.DataEncryption.PrimaryKeyURI // Key URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} // Extract vault name and key name from URI vaultName := azureshared.ExtractVaultNameFromURI(keyURI) keyName := azureshared.ExtractKeyNameFromURI(keyURI) if vaultName != "" && keyName != "" { // Use composite lookup key for vault name and key name query := shared.CompositeLookupKey(vaultName, keyName) // Key Vault can be in a different resource group, but we don't have that info from the URI // Use default scope and let the Key Vault adapter handle cross-resource-group lookups if needed sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, }, }) } } // Link to Primary User Assigned Managed Identity (external resource) from Data Encryption // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.PrimaryUserAssignedIdentityID != nil { identityID := *server.Properties.DataEncryption.PrimaryUserAssignedIdentityID identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } // Link to Geo Backup Key Vault Vault (external resource) from Data Encryption // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName} if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.GeoBackupKeyURI != nil { keyURI := *server.Properties.DataEncryption.GeoBackupKeyURI // Key URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} // Extract vault name from URI if vaultName := azureshared.ExtractVaultNameFromURI(keyURI); vaultName != "" { // Key Vault can be in a different resource group, but we don't have that info from the URI // Use default scope and let the Key Vault adapter handle cross-resource-group lookups if needed sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, }, }) } } // Link to Geo Backup Key Vault Key (external resource) from Data Encryption // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keys/get-key // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}/keys/{keyName} if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.GeoBackupKeyURI != nil { keyURI := *server.Properties.DataEncryption.GeoBackupKeyURI // Key URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} // Extract vault name and key name from URI vaultName := azureshared.ExtractVaultNameFromURI(keyURI) keyName := azureshared.ExtractKeyNameFromURI(keyURI) if vaultName != "" && keyName != "" { // Use composite lookup key for vault name and key name query := shared.CompositeLookupKey(vaultName, keyName) // Key Vault can be in a different resource group, but we don't have that info from the URI // Use default scope and let the Key Vault adapter handle cross-resource-group lookups if needed sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, }, }) } } // Link to Geo Backup User Assigned Managed Identity (external resource) from Data Encryption // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if server.Properties != nil && server.Properties.DataEncryption != nil && server.Properties.DataEncryption.GeoBackupUserAssignedIdentityID != nil { identityID := *server.Properties.DataEncryption.GeoBackupUserAssignedIdentityID identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } // Link to Source Server (for replica servers and point-in-time restore servers) // Reference: https://learn.microsoft.com/en-us/rest/api/postgresql/flexibleserver/servers/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforPostgreSQL/flexibleServers/{serverName} if server.Properties != nil && server.Properties.SourceServerResourceID != nil { sourceServerID := *server.Properties.SourceServerResourceID sourceServerName := azureshared.ExtractResourceName(sourceServerID) if sourceServerName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(sourceServerID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DBforPostgreSQLFlexibleServer.String(), Method: sdp.QueryMethod_GET, Query: sourceServerName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (s dbforPostgreSQLFlexibleServerWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DBforPostgreSQLFlexibleServerLookupByName, } } func (s dbforPostgreSQLFlexibleServerWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, azureshared.NetworkPrivateDNSZone, azureshared.NetworkPrivateEndpoint, azureshared.DBforPostgreSQLDatabase, azureshared.DBforPostgreSQLFlexibleServerFirewallRule, azureshared.DBforPostgreSQLFlexibleServerConfiguration, azureshared.DBforPostgreSQLFlexibleServerAdministrator, azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection, azureshared.DBforPostgreSQLFlexibleServerPrivateLinkResource, azureshared.DBforPostgreSQLFlexibleServerReplica, azureshared.DBforPostgreSQLFlexibleServerMigration, azureshared.DBforPostgreSQLFlexibleServerBackup, azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint, azureshared.DBforPostgreSQLFlexibleServer, // For replica-to-source server relationship stdlib.NetworkDNS, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.KeyVaultVault, azureshared.KeyVaultKey, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/postgresql_flexible_server func (s dbforPostgreSQLFlexibleServerWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_postgresql_flexible_server.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/databases#microsoftdbforpostgresql func (s dbforPostgreSQLFlexibleServerWrapper) IAMPermissions() []string { return []string{ "Microsoft.DBforPostgreSQL/flexibleServers/read", } } func (s dbforPostgreSQLFlexibleServerWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/dbforpostgresql-flexible-server_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockPostgreSQLFlexibleServersPager is a simple mock implementation of PostgreSQLFlexibleServersPager type mockPostgreSQLFlexibleServersPager struct { pages []armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse index int } func (m *mockPostgreSQLFlexibleServersPager) More() bool { return m.index < len(m.pages) } func (m *mockPostgreSQLFlexibleServersPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse, error) { if m.index >= len(m.pages) { return armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorPostgreSQLFlexibleServersPager is a mock pager that always returns an error type errorPostgreSQLFlexibleServersPager struct{} func (e *errorPostgreSQLFlexibleServersPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorPostgreSQLFlexibleServersPager) NextPage(ctx context.Context) (armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse, error) { return armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse{}, errors.New("pager error") } // testPostgreSQLFlexibleServersClient wraps the mock to implement the correct interface type testPostgreSQLFlexibleServersClient struct { *mocks.MockPostgreSQLFlexibleServersClient pager clients.PostgreSQLFlexibleServersPager } func (t *testPostgreSQLFlexibleServersClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armpostgresqlflexibleservers.ServersClientListByResourceGroupOptions) clients.PostgreSQLFlexibleServersPager { return t.pager } func TestDBforPostgreSQLFlexibleServer(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" t.Run("Get", func(t *testing.T) { server := createAzurePostgreSQLFlexibleServer(serverName, "", "") mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armpostgresqlflexibleservers.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DBforPostgreSQLFlexibleServer.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServer, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != serverName { t.Errorf("Expected unique attribute value %s, got %s", serverName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Child resources { ExpectedType: azureshared.DBforPostgreSQLDatabase.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerFirewallRule.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerConfiguration.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerAdministrator.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerPrivateLinkResource.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerReplica.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerMigration.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerBackup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithSubnet", func(t *testing.T) { subnetID := "/subscriptions/sub-id/resourceGroups/vnet-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" server := createAzurePostgreSQLFlexibleServer(serverName, subnetID, "") mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armpostgresqlflexibleservers.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify subnet and virtual network links are present foundSubnetLink := false foundVNetLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkSubnet.String() { foundSubnetLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected subnet link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetScope() != "sub-id.vnet-rg" { t.Errorf("Expected subnet link scope to be 'sub-id.vnet-rg', got %s", linkedQuery.GetQuery().GetScope()) } } if linkedQuery.GetQuery().GetType() == azureshared.NetworkVirtualNetwork.String() { foundVNetLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected virtual network link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetScope() != "sub-id.vnet-rg" { t.Errorf("Expected virtual network link scope to be 'sub-id.vnet-rg', got %s", linkedQuery.GetQuery().GetScope()) } } } if !foundSubnetLink { t.Error("Expected to find subnet link in linked item queries") } if !foundVNetLink { t.Error("Expected to find virtual network link in linked item queries") } }) t.Run("Get_WithFQDN", func(t *testing.T) { fqdn := "test-server.postgres.database.azure.com" server := createAzurePostgreSQLFlexibleServer(serverName, "", fqdn) mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armpostgresqlflexibleservers.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify DNS link is present foundDNSLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == stdlib.NetworkDNS.String() { foundDNSLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected DNS link to use SEARCH method, got %v", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetQuery() != fqdn { t.Errorf("Expected DNS link query to be %s, got %s", fqdn, linkedQuery.GetQuery().GetQuery()) } if linkedQuery.GetQuery().GetScope() != "global" { t.Errorf("Expected DNS link scope to be 'global', got %s", linkedQuery.GetQuery().GetScope()) } } } if !foundDNSLink { t.Error("Expected to find DNS link in linked item queries") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty query _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when providing empty query, but got nil") } }) t.Run("List", func(t *testing.T) { server1 := createAzurePostgreSQLFlexibleServer("server-1", "", "") server2 := createAzurePostgreSQLFlexibleServer("server-2", "", "") mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockPager := &mockPostgreSQLFlexibleServersPager{ pages: []armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse{ { ServerList: armpostgresqlflexibleservers.ServerList{ Value: []*armpostgresqlflexibleservers.Server{server1, server2}, }, }, }, } testClient := &testPostgreSQLFlexibleServersClient{ MockPostgreSQLFlexibleServersClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.DBforPostgreSQLFlexibleServer.String() { t.Errorf("Expected type %s, got %s", azureshared.DBforPostgreSQLFlexibleServer, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { server1 := createAzurePostgreSQLFlexibleServer("server-1", "", "") server2 := &armpostgresqlflexibleservers.Server{ Name: nil, // Server with nil name should be skipped Properties: &armpostgresqlflexibleservers.ServerProperties{ Version: new(armpostgresqlflexibleservers.PostgresMajorVersion("14")), }, } mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockPager := &mockPostgreSQLFlexibleServersPager{ pages: []armpostgresqlflexibleservers.ServersClientListByResourceGroupResponse{ { ServerList: armpostgresqlflexibleservers.ServerList{ Value: []*armpostgresqlflexibleservers.Server{server1, server2}, }, }, }, } testClient := &testPostgreSQLFlexibleServersClient{ MockPostgreSQLFlexibleServersClient: mockClient, pager: mockPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (server with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "server-1" { t.Fatalf("Expected server name 'server-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("server not found") mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-server", nil).Return( armpostgresqlflexibleservers.ServersClientGetResponse{}, expectedErr) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-server", true) if qErr == nil { t.Error("Expected error when getting non-existent server, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorPostgreSQLFlexibleServersPager{} testClient := &testPostgreSQLFlexibleServersClient{ MockPostgreSQLFlexibleServersClient: mockClient, pager: errorPager, } wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) // The List implementation should return an error when pager.NextPage returns an error if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("Get_WithDataEncryption", func(t *testing.T) { // Create server with DataEncryption fields server := createAzurePostgreSQLFlexibleServer(serverName, "", "") primaryKeyURI := "https://test-vault.vault.azure.net/keys/test-key/abc123" primaryIdentityID := "/subscriptions/sub-id/resourceGroups/rg-id/providers/Microsoft.ManagedIdentity/userAssignedIdentities/primary-identity" geoBackupKeyURI := "https://geo-vault.vault.azure.net/keys/geo-key/def456" geoBackupIdentityID := "/subscriptions/sub-id/resourceGroups/rg-id/providers/Microsoft.ManagedIdentity/userAssignedIdentities/geo-identity" server.Properties.DataEncryption = &armpostgresqlflexibleservers.DataEncryption{ PrimaryKeyURI: new(primaryKeyURI), PrimaryUserAssignedIdentityID: new(primaryIdentityID), GeoBackupKeyURI: new(geoBackupKeyURI), GeoBackupUserAssignedIdentityID: new(geoBackupIdentityID), } mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armpostgresqlflexibleservers.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify DataEncryption links are present foundPrimaryIdentityLink := false foundPrimaryKeyVaultLink := false foundPrimaryKeyLink := false foundGeoBackupVaultLink := false foundGeoBackupKeyLink := false foundGeoBackupIdentityLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { // Primary User Assigned Identity if linkedQuery.GetQuery().GetType() == azureshared.ManagedIdentityUserAssignedIdentity.String() && linkedQuery.GetQuery().GetQuery() == "primary-identity" { foundPrimaryIdentityLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected primary identity link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } } // Primary Key Vault Vault if linkedQuery.GetQuery().GetType() == azureshared.KeyVaultVault.String() && linkedQuery.GetQuery().GetQuery() == "test-vault" { foundPrimaryKeyVaultLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected primary vault link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } } // Primary Key Vault Key if linkedQuery.GetQuery().GetType() == azureshared.KeyVaultKey.String() && linkedQuery.GetQuery().GetQuery() == shared.CompositeLookupKey("test-vault", "test-key") { foundPrimaryKeyLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected primary key link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } } // Geo Backup Key Vault Vault if linkedQuery.GetQuery().GetType() == azureshared.KeyVaultVault.String() && linkedQuery.GetQuery().GetQuery() == "geo-vault" { foundGeoBackupVaultLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected geo backup vault link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } } // Geo Backup Key Vault Key if linkedQuery.GetQuery().GetType() == azureshared.KeyVaultKey.String() && linkedQuery.GetQuery().GetQuery() == shared.CompositeLookupKey("geo-vault", "geo-key") { foundGeoBackupKeyLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected geo backup key link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } } // Geo Backup User Assigned Identity if linkedQuery.GetQuery().GetType() == azureshared.ManagedIdentityUserAssignedIdentity.String() && linkedQuery.GetQuery().GetQuery() == "geo-identity" { foundGeoBackupIdentityLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected geo backup identity link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } } } if !foundPrimaryIdentityLink { t.Error("Expected to find primary user assigned identity link in linked item queries") } if !foundPrimaryKeyVaultLink { t.Error("Expected to find primary key vault vault link in linked item queries") } if !foundPrimaryKeyLink { t.Error("Expected to find primary key vault key link in linked item queries") } if !foundGeoBackupVaultLink { t.Error("Expected to find geo backup key vault vault link in linked item queries") } if !foundGeoBackupKeyLink { t.Error("Expected to find geo backup key vault key link in linked item queries") } if !foundGeoBackupIdentityLink { t.Error("Expected to find geo backup user assigned identity link in linked item queries") } }) t.Run("Get_WithSourceServer", func(t *testing.T) { // Create a replica server with SourceServerResourceID replicaServerName := "replica-server" sourceServerID := "/subscriptions/sub-id/resourceGroups/source-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/source-server" server := createAzurePostgreSQLFlexibleServer(replicaServerName, "", "") server.Properties.SourceServerResourceID = new(sourceServerID) server.Properties.ReplicationRole = new(armpostgresqlflexibleservers.ReplicationRoleAsyncReplica) mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, replicaServerName, nil).Return( armpostgresqlflexibleservers.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], replicaServerName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify source server link is present foundSourceServerLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.DBforPostgreSQLFlexibleServer.String() && linkedQuery.GetQuery().GetQuery() == "source-server" { foundSourceServerLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected source server link to use GET method, got %v", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetScope() != "sub-id.source-rg" { t.Errorf("Expected source server link scope to be 'sub-id.source-rg', got %s", linkedQuery.GetQuery().GetScope()) } } } if !foundSourceServerLink { t.Error("Expected to find source server link in linked item queries") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockPostgreSQLFlexibleServersClient(ctrl) testClient := &testPostgreSQLFlexibleServersClient{MockPostgreSQLFlexibleServersClient: mockClient} wrapper := manual.NewDBforPostgreSQLFlexibleServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() expectedLinks := map[shared.ItemType]bool{ azureshared.NetworkSubnet: true, azureshared.NetworkVirtualNetwork: true, azureshared.NetworkPrivateDNSZone: true, azureshared.NetworkPrivateEndpoint: true, azureshared.DBforPostgreSQLDatabase: true, azureshared.DBforPostgreSQLFlexibleServerFirewallRule: true, azureshared.DBforPostgreSQLFlexibleServerConfiguration: true, azureshared.DBforPostgreSQLFlexibleServerAdministrator: true, azureshared.DBforPostgreSQLFlexibleServerPrivateEndpointConnection: true, azureshared.DBforPostgreSQLFlexibleServerPrivateLinkResource: true, azureshared.DBforPostgreSQLFlexibleServerReplica: true, azureshared.DBforPostgreSQLFlexibleServerMigration: true, azureshared.DBforPostgreSQLFlexibleServerBackup: true, azureshared.DBforPostgreSQLFlexibleServerVirtualEndpoint: true, azureshared.DBforPostgreSQLFlexibleServer: true, // For replica-to-source server relationship stdlib.NetworkDNS: true, azureshared.ManagedIdentityUserAssignedIdentity: true, azureshared.KeyVaultVault: true, azureshared.KeyVaultKey: true, } for expectedType, expectedValue := range expectedLinks { if actualValue, exists := potentialLinks[expectedType]; !exists { t.Errorf("Expected PotentialLinks to include %s, but it was not found", expectedType) } else if actualValue != expectedValue { t.Errorf("Expected PotentialLinks[%s] to be %v, got %v", expectedType, expectedValue, actualValue) } } }) } // createAzurePostgreSQLFlexibleServer creates a mock Azure PostgreSQL Flexible Server for testing func createAzurePostgreSQLFlexibleServer(serverName, subnetID, fqdn string) *armpostgresqlflexibleservers.Server { serverID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/" + serverName server := &armpostgresqlflexibleservers.Server{ Name: new(serverName), ID: new(serverID), Location: new("eastus"), Properties: &armpostgresqlflexibleservers.ServerProperties{ Version: new(armpostgresqlflexibleservers.PostgresMajorVersion("14")), State: new(armpostgresqlflexibleservers.ServerStateReady), }, SKU: &armpostgresqlflexibleservers.SKU{ Name: new("Standard_B1ms"), Tier: new(armpostgresqlflexibleservers.SKUTierBurstable), }, Tags: map[string]*string{ "env": new("test"), }, } // Add network configuration if subnet ID is provided if subnetID != "" { server.Properties.Network = &armpostgresqlflexibleservers.Network{ DelegatedSubnetResourceID: new(subnetID), } } // Add FQDN if provided if fqdn != "" { server.Properties.FullyQualifiedDomainName = new(fqdn) } return server } ================================================ FILE: sources/azure/manual/dns_links.go ================================================ package manual import ( "net" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/stdlib" ) // appendDNSServerLinkIfValid appends a linked item query for a DNS server string: // stdlib.NetworkIP for IP addresses, stdlib.NetworkDNS for hostnames. // Skips empty strings and any value in skipValues (e.g. "AzureProvidedDNS" for Azure managed DNS). func appendDNSServerLinkIfValid(queries *[]*sdp.LinkedItemQuery, server string, skipValues ...string) { appendLinkIfValid(queries, server, skipValues, func(s string) *sdp.LinkedItemQuery { if net.ParseIP(s) != nil { return networkIPQuery(s) } return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: s, Scope: "global", }, } }) } ================================================ FILE: sources/azure/manual/documentdb-database-accounts.go ================================================ package manual import ( "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var DocumentDBDatabaseAccountsLookupByName = shared.NewItemTypeLookup("name", azureshared.DocumentDBDatabaseAccounts) type documentDBDatabaseAccountsWrapper struct { client clients.DocumentDBDatabaseAccountsClient *azureshared.MultiResourceGroupBase } func NewDocumentDBDatabaseAccounts(client clients.DocumentDBDatabaseAccountsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &documentDBDatabaseAccountsWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DocumentDBDatabaseAccounts, ), } } func (s documentDBDatabaseAccountsWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByResourceGroup(rgScope.ResourceGroup) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, account := range page.Value { item, sdpErr := s.azureDocumentDBDatabaseAccountToSDPItem(account, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s documentDBDatabaseAccountsWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByResourceGroup(rgScope.ResourceGroup) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, account := range page.Value { item, sdpErr := s.azureDocumentDBDatabaseAccountToSDPItem(account, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s documentDBDatabaseAccountsWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 1 query part: name", Scope: scope, ItemType: s.Type(), } } accountName := queryParts[0] if accountName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "name cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, accountName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureDocumentDBDatabaseAccountToSDPItem(&resp.DatabaseAccountGetResults, scope) } func (s documentDBDatabaseAccountsWrapper) azureDocumentDBDatabaseAccountToSDPItem(account *armcosmos.DatabaseAccountGetResults, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(account, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } if account.Name == nil { return nil, azureshared.QueryError(errors.New("name cannot be empty"), scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DocumentDBDatabaseAccounts.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(account.Tags), } // reference : https://learn.microsoft.com/en-us/rest/api/cosmos-db-resource-provider/private-endpoint-connections/list-by-database-account?view=rest-cosmos-db-resource-provider-2025-10-15&tabs=HTTP if account.Properties != nil && account.Properties.PrivateEndpointConnections != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DocumentDBPrivateEndpointConnection.String(), Method: sdp.QueryMethod_SEARCH, Query: *account.Name, Scope: scope, }, }) // Link to Private Endpoint resources // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/privateEndpoints/{privateEndpointName} // // IMPORTANT: Private Endpoints can be in a different resource group than the Cosmos DB account. // We must extract the subscription ID and resource group from the private endpoint's resource ID // to construct the correct scope. for _, conn := range account.Properties.PrivateEndpointConnections { if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *conn.Properties.PrivateEndpoint.ID // Private Endpoint ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateEndpoints/{peName} params := azureshared.ExtractPathParamsFromResourceID(privateEndpointID, []string{"subscriptions", "resourceGroups"}) if len(params) >= 2 { subscriptionID := params[0] resourceGroupName := params[1] privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the private endpoint actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: scope, // Use the private endpoint's scope, not the database account's scope }, }) } } } } } // Link to Virtual Network Subnets from VirtualNetworkRules // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{virtualNetworkName}/subnets/{subnetName} // // IMPORTANT: Virtual Network Subnets can be in a different resource group than the Cosmos DB account. // We must extract the subscription ID and resource group from the subnet's resource ID to construct // the correct scope. if account.Properties != nil && account.Properties.VirtualNetworkRules != nil { for _, vnetRule := range account.Properties.VirtualNetworkRules { if vnetRule.ID != nil { subnetID := *vnetRule.ID // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName} // Extract subscription, resource group, virtual network name, and subnet name scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subscriptionID := scopeParams[0] resourceGroupName := scopeParams[1] vnetName := subnetParams[0] subnetName := subnetParams[1] // Subnet adapter requires: resourceGroup, virtualNetworkName, subnetName // Use composite lookup key to join them query := shared.CompositeLookupKey(vnetName, subnetName) // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the subnet actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, // Use the subnet's scope, not the database account's scope }, }) } } } } // Link to Key Vault from KeyVaultKeyUri // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName} // // NOTE: Key Vaults can be in a different resource group than the Cosmos DB account. However, the Key Vault URI // format (https://{vaultName}.vault.azure.net/keys/{keyName}/{version}) does not contain resource group information. // Key Vault names are globally unique within a subscription, so we use the database account's scope as a best-effort // approach. If the Key Vault is in a different resource group, the query may fail and would need to be manually corrected // or the Key Vault adapter would need to support subscription-level search. if account.Properties != nil && account.Properties.KeyVaultKeyURI != nil { keyVaultURI := *account.Properties.KeyVaultKeyURI // Key Vault URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} vaultName := azureshared.ExtractVaultNameFromURI(keyVaultURI) if vaultName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } } // Link to User-Assigned Managed Identities Reference: // https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/list-by-resource-group?view=rest-managedidentity-2024-11-30&tabs=HTTP // GET // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=2024-11-30 // // IMPORTANT: User-Assigned Managed Identities can be in a different // resource group (or even subscription) than the Cosmos DB account. // User-assigned managed identities are standalone Azure resources that can // be assigned to multiple services across different resource groups. // Therefore, we must extract the subscription ID and resource group from // each identity's resource ID to construct the correct scope. Using the // database account's scope would fail if the identity is in a different // resource group, as the query would look in the wrong location. if account.Identity != nil && account.Identity.UserAssignedIdentities != nil { // Track scopes (subscription.resourceGroup) to avoid duplicate queries // Key: scope string (e.g., "subscription-id.resource-group-name") // Value: resource group name for the query parameter scopes := make(map[string]string) for identityID := range account.Identity.UserAssignedIdentities { // Identity ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} // Extract subscription ID and resource group using the utility function params := azureshared.ExtractPathParamsFromResourceID(identityID, []string{"subscriptions", "resourceGroups"}) if len(params) >= 2 { subscriptionID := params[0] resourceGroupName := params[1] // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the identity actually exists, // which may be different from the database account's resource group scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) // Only add one query per scope to list all identities in that resource group if _, exists := scopes[scope]; !exists { scopes[scope] = resourceGroupName sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_SEARCH, Query: resourceGroupName, Scope: scope, // Use the identity's scope, not the database account's scope }, }) } } } } // Link to stdlib for document endpoint and regional endpoints (DNS/HTTP) linkedDNSHostnames := make(map[string]struct{}) seenIPs := make(map[string]struct{}) if account.Properties != nil && account.Properties.DocumentEndpoint != nil && *account.Properties.DocumentEndpoint != "" { AppendURILinks(&sdpItem.LinkedItemQueries, *account.Properties.DocumentEndpoint, linkedDNSHostnames, seenIPs) } if account.Properties != nil { for _, loc := range account.Properties.ReadLocations { if loc != nil && loc.DocumentEndpoint != nil && *loc.DocumentEndpoint != "" { AppendURILinks(&sdpItem.LinkedItemQueries, *loc.DocumentEndpoint, linkedDNSHostnames, seenIPs) } } for _, loc := range account.Properties.WriteLocations { if loc != nil && loc.DocumentEndpoint != nil && *loc.DocumentEndpoint != "" { AppendURILinks(&sdpItem.LinkedItemQueries, *loc.DocumentEndpoint, linkedDNSHostnames, seenIPs) } } for _, loc := range account.Properties.Locations { if loc != nil && loc.DocumentEndpoint != nil && *loc.DocumentEndpoint != "" { AppendURILinks(&sdpItem.LinkedItemQueries, *loc.DocumentEndpoint, linkedDNSHostnames, seenIPs) } } // Link to stdlib.NetworkIP for IP rules (single IPv4 or CIDR) if account.Properties.IPRules != nil { for _, rule := range account.Properties.IPRules { if rule != nil && rule.IPAddressOrRange != nil && *rule.IPAddressOrRange != "" { val := *rule.IPAddressOrRange if _, seen := seenIPs[val]; !seen { seenIPs[val] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: val, Scope: "global", }, }) } } } } } return sdpItem, nil } func (s documentDBDatabaseAccountsWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DocumentDBDatabaseAccountsLookupByName, } } func (s documentDBDatabaseAccountsWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.DocumentDBPrivateEndpointConnection, azureshared.NetworkPrivateEndpoint, azureshared.NetworkSubnet, azureshared.KeyVaultVault, azureshared.ManagedIdentityUserAssignedIdentity, stdlib.NetworkIP, stdlib.NetworkDNS, stdlib.NetworkHTTP, ) } func (s documentDBDatabaseAccountsWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_cosmosdb_account.name", }, } } ================================================ FILE: sources/azure/manual/documentdb-database-accounts_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockDocumentDBDatabaseAccountsPager is a simple mock implementation of DocumentDBDatabaseAccountsPager type mockDocumentDBDatabaseAccountsPager struct { pages []armcosmos.DatabaseAccountsClientListByResourceGroupResponse index int } func (m *mockDocumentDBDatabaseAccountsPager) More() bool { return m.index < len(m.pages) } func (m *mockDocumentDBDatabaseAccountsPager) NextPage(ctx context.Context) (armcosmos.DatabaseAccountsClientListByResourceGroupResponse, error) { if m.index >= len(m.pages) { return armcosmos.DatabaseAccountsClientListByResourceGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorDocumentDBDatabaseAccountsPager is a mock pager that always returns an error type errorDocumentDBDatabaseAccountsPager struct{} func (e *errorDocumentDBDatabaseAccountsPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorDocumentDBDatabaseAccountsPager) NextPage(ctx context.Context) (armcosmos.DatabaseAccountsClientListByResourceGroupResponse, error) { return armcosmos.DatabaseAccountsClientListByResourceGroupResponse{}, errors.New("pager error") } // testDocumentDBDatabaseAccountsClient wraps the mock to implement the correct interface type testDocumentDBDatabaseAccountsClient struct { *mocks.MockDocumentDBDatabaseAccountsClient pager clients.DocumentDBDatabaseAccountsPager } func (t *testDocumentDBDatabaseAccountsClient) ListByResourceGroup(resourceGroupName string) clients.DocumentDBDatabaseAccountsPager { // Call the mock to satisfy expectations t.MockDocumentDBDatabaseAccountsClient.ListByResourceGroup(resourceGroupName) return t.pager } func TestDocumentDBDatabaseAccounts(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" accountName := "test-cosmos-account" t.Run("Get", func(t *testing.T) { account := createAzureCosmosDBAccount(accountName, "Succeeded", subscriptionID, resourceGroup) mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armcosmos.DatabaseAccountsClientGetResponse{ DatabaseAccountGetResults: *account, }, nil) wrapper := manual.NewDocumentDBDatabaseAccounts(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DocumentDBDatabaseAccounts.String() { t.Errorf("Expected type %s, got %s", azureshared.DocumentDBDatabaseAccounts, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != accountName { t.Errorf("Expected unique attribute value %s, got %s", accountName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Private Endpoint Connection (SEARCH) ExpectedType: azureshared.DocumentDBPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint (GET) - same resource group ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint (GET) - different resource group ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint-diff-rg", ExpectedScope: subscriptionID + ".different-rg", }, { // Subnet (GET) - same resource group ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Subnet (GET) - different resource group ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet-diff-rg", "test-subnet-diff-rg"), ExpectedScope: subscriptionID + ".different-rg", }, { // Key Vault (GET) ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-keyvault", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // User-Assigned Managed Identity (SEARCH) - same resource group ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: resourceGroup, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // User-Assigned Managed Identity (SEARCH) - different resource group ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "identity-rg", ExpectedScope: subscriptionID + ".identity-rg", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) wrapper := manual.NewDocumentDBDatabaseAccounts(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting database account with empty name, but got nil") } }) t.Run("Get_NoName", func(t *testing.T) { account := &armcosmos.DatabaseAccountGetResults{ Name: nil, // No name field Properties: &armcosmos.DatabaseAccountGetProperties{ ProvisioningState: new("Succeeded"), }, } mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armcosmos.DatabaseAccountsClientGetResponse{ DatabaseAccountGetResults: *account, }, nil) wrapper := manual.NewDocumentDBDatabaseAccounts(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr == nil { t.Error("Expected error when database account has no name, but got nil") } }) t.Run("Get_NoLinkedResources", func(t *testing.T) { account := createAzureCosmosDBAccountMinimal(accountName, "Succeeded") mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armcosmos.DatabaseAccountsClientGetResponse{ DatabaseAccountGetResults: *account, }, nil) wrapper := manual.NewDocumentDBDatabaseAccounts(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should have no linked item queries if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked item queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("List", func(t *testing.T) { account1 := createAzureCosmosDBAccount("test-cosmos-account-1", "Succeeded", subscriptionID, resourceGroup) account2 := createAzureCosmosDBAccount("test-cosmos-account-2", "Succeeded", subscriptionID, resourceGroup) mockPager := &mockDocumentDBDatabaseAccountsPager{ pages: []armcosmos.DatabaseAccountsClientListByResourceGroupResponse{ { DatabaseAccountsListResult: armcosmos.DatabaseAccountsListResult{ Value: []*armcosmos.DatabaseAccountGetResults{account1, account2}, }, }, }, } mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) mockClient.EXPECT().ListByResourceGroup(resourceGroup).Return(mockPager) testClient := &testDocumentDBDatabaseAccountsClient{ MockDocumentDBDatabaseAccountsClient: mockClient, pager: mockPager, } wrapper := manual.NewDocumentDBDatabaseAccounts(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("database account not found") mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-account").Return( armcosmos.DatabaseAccountsClientGetResponse{}, expectedErr) wrapper := manual.NewDocumentDBDatabaseAccounts(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-account", true) if qErr == nil { t.Error("Expected error when getting non-existent database account, but got nil") } }) t.Run("ListErrorHandling", func(t *testing.T) { mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) errorPager := &errorDocumentDBDatabaseAccountsPager{} testClient := &testDocumentDBDatabaseAccountsClient{ MockDocumentDBDatabaseAccountsClient: mockClient, pager: errorPager, } mockClient.EXPECT().ListByResourceGroup(resourceGroup).Return(errorPager) wrapper := manual.NewDocumentDBDatabaseAccounts(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when pager fails, but got nil") } }) t.Run("CrossResourceGroupScopes", func(t *testing.T) { // Test that linked resources in different resource groups use correct scopes account := createAzureCosmosDBAccountCrossRG(accountName, "Succeeded", subscriptionID, resourceGroup) mockClient := mocks.NewMockDocumentDBDatabaseAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armcosmos.DatabaseAccountsClientGetResponse{ DatabaseAccountGetResults: *account, }, nil) wrapper := manual.NewDocumentDBDatabaseAccounts(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked resources use their own scopes, not the database account's scope foundDifferentScope := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { scope := linkedQuery.GetQuery().GetScope() if scope != subscriptionID+"."+resourceGroup { foundDifferentScope = true // Verify the scope format is correct if scope != subscriptionID+".different-rg" && scope != subscriptionID+".identity-rg" { t.Errorf("Unexpected scope format: %s", scope) } } } if !foundDifferentScope { t.Error("Expected to find linked resources with different scopes, but all use the same scope") } }) } // createAzureCosmosDBAccount creates a mock Azure Cosmos DB account with all linked resources func createAzureCosmosDBAccount(accountName, provisioningState, subscriptionID, resourceGroup string) *armcosmos.DatabaseAccountGetResults { return &armcosmos.DatabaseAccountGetResults{ Name: new(accountName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armcosmos.DatabaseAccountGetProperties{ ProvisioningState: new(provisioningState), // Private Endpoint Connections PrivateEndpointConnections: []*armcosmos.PrivateEndpointConnection{ { Properties: &armcosmos.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armcosmos.PrivateEndpointProperty{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-private-endpoint"), }, }, }, { Properties: &armcosmos.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armcosmos.PrivateEndpointProperty{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/privateEndpoints/test-private-endpoint-diff-rg"), }, }, }, }, // Virtual Network Rules VirtualNetworkRules: []*armcosmos.VirtualNetworkRule{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/virtualNetworks/test-vnet-diff-rg/subnets/test-subnet-diff-rg"), }, }, // Key Vault Key URI KeyVaultKeyURI: new("https://test-keyvault.vault.azure.net/keys/test-key/version"), }, Identity: &armcosmos.ManagedServiceIdentity{ Type: new(armcosmos.ResourceIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armcosmos.Components1Jq1T4ISchemasManagedserviceidentityPropertiesUserassignedidentitiesAdditionalproperties{ "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity": {}, "/subscriptions/" + subscriptionID + "/resourceGroups/identity-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity-diff-rg": {}, }, }, } } // createAzureCosmosDBAccountMinimal creates a minimal mock Azure Cosmos DB account without linked resources func createAzureCosmosDBAccountMinimal(accountName, provisioningState string) *armcosmos.DatabaseAccountGetResults { return &armcosmos.DatabaseAccountGetResults{ Name: new(accountName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcosmos.DatabaseAccountGetProperties{ ProvisioningState: new(provisioningState), }, } } // createAzureCosmosDBAccountCrossRG creates a mock Azure Cosmos DB account with linked resources in different resource groups func createAzureCosmosDBAccountCrossRG(accountName, provisioningState, subscriptionID, resourceGroup string) *armcosmos.DatabaseAccountGetResults { return &armcosmos.DatabaseAccountGetResults{ Name: new(accountName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armcosmos.DatabaseAccountGetProperties{ ProvisioningState: new(provisioningState), // Private Endpoint in different resource group PrivateEndpointConnections: []*armcosmos.PrivateEndpointConnection{ { Properties: &armcosmos.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armcosmos.PrivateEndpointProperty{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/privateEndpoints/test-pe-diff-rg"), }, }, }, }, // Subnet in different resource group VirtualNetworkRules: []*armcosmos.VirtualNetworkRule{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, }, Identity: &armcosmos.ManagedServiceIdentity{ Type: new(armcosmos.ResourceIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armcosmos.Components1Jq1T4ISchemasManagedserviceidentityPropertiesUserassignedidentitiesAdditionalproperties{ "/subscriptions/" + subscriptionID + "/resourceGroups/identity-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity": {}, }, }, } } ================================================ FILE: sources/azure/manual/documentdb-private-endpoint-connection.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var DocumentDBPrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.DocumentDBPrivateEndpointConnection) type documentDBPrivateEndpointConnectionWrapper struct { client clients.DocumentDBPrivateEndpointConnectionsClient *azureshared.MultiResourceGroupBase } // NewDocumentDBPrivateEndpointConnection returns a SearchableWrapper for Azure Cosmos DB (DocumentDB) database account private endpoint connections. func NewDocumentDBPrivateEndpointConnection(client clients.DocumentDBPrivateEndpointConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &documentDBPrivateEndpointConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.DocumentDBPrivateEndpointConnection, ), } } func (s documentDBPrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: accountName and privateEndpointConnectionName", Scope: scope, ItemType: s.Type(), } } accountName := queryParts[0] connectionName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, accountName, connectionName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(&resp.PrivateEndpointConnection, accountName, connectionName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s documentDBPrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ DocumentDBDatabaseAccountsLookupByName, DocumentDBPrivateEndpointConnectionLookupByName, } } func (s documentDBPrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: accountName", Scope: scope, ItemType: s.Type(), } } accountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByDatabaseAccount(ctx, rgScope.ResourceGroup, accountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, accountName, *conn.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s documentDBPrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: accountName"), scope, s.Type())) return } accountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByDatabaseAccount(ctx, rgScope.ResourceGroup, accountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, accountName, *conn.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s documentDBPrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { DocumentDBDatabaseAccountsLookupByName, }, } } func (s documentDBPrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.DocumentDBDatabaseAccounts: true, azureshared.NetworkPrivateEndpoint: true, } } func (s documentDBPrivateEndpointConnectionWrapper) azurePrivateEndpointConnectionToSDPItem(conn *armcosmos.PrivateEndpointConnection, accountName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(conn) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(accountName, connectionName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.DocumentDBPrivateEndpointConnection.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health from provisioning state (Cosmos uses *string, not an enum) if conn.Properties != nil && conn.Properties.ProvisioningState != nil { state := strings.ToLower(*conn.Properties.ProvisioningState) switch state { case "succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "creating", "deleting": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "failed": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent DocumentDB Database Account sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.DocumentDBDatabaseAccounts.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) // Link to Network Private Endpoint when present (may be in different resource group) if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { peID := *conn.Properties.PrivateEndpoint.ID peName := azureshared.ExtractResourceName(peID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (s documentDBPrivateEndpointConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.DocumentDB/databaseAccounts/privateEndpointConnections/read", } } func (s documentDBPrivateEndpointConnectionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/documentdb-private-endpoint-connection_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockDocumentDBPrivateEndpointConnectionsPager struct { pages []armcosmos.PrivateEndpointConnectionsClientListByDatabaseAccountResponse index int } func (m *mockDocumentDBPrivateEndpointConnectionsPager) More() bool { return m.index < len(m.pages) } func (m *mockDocumentDBPrivateEndpointConnectionsPager) NextPage(ctx context.Context) (armcosmos.PrivateEndpointConnectionsClientListByDatabaseAccountResponse, error) { if m.index >= len(m.pages) { return armcosmos.PrivateEndpointConnectionsClientListByDatabaseAccountResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type testDocumentDBPrivateEndpointConnectionsClient struct { *mocks.MockDocumentDBPrivateEndpointConnectionsClient pager clients.DocumentDBPrivateEndpointConnectionsPager } func (t *testDocumentDBPrivateEndpointConnectionsClient) ListByDatabaseAccount(ctx context.Context, resourceGroupName, accountName string) clients.DocumentDBPrivateEndpointConnectionsPager { return t.pager } func TestDocumentDBPrivateEndpointConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" accountName := "test-cosmos-account" connectionName := "test-pec" t.Run("Get", func(t *testing.T) { conn := createAzureDocumentDBPrivateEndpointConnection(connectionName, "") mockClient := mocks.NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, connectionName).Return( armcosmos.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testDocumentDBPrivateEndpointConnectionsClient{MockDocumentDBPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDocumentDBPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.DocumentDBPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.DocumentDBPrivateEndpointConnection, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(accountName, connectionName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(accountName, connectionName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) < 1 { t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) } foundDocumentDBAccount := false for _, lq := range linkedQueries { if lq.GetQuery().GetType() == azureshared.DocumentDBDatabaseAccounts.String() { foundDocumentDBAccount = true if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected DocumentDBDatabaseAccounts link method GET, got %v", lq.GetQuery().GetMethod()) } if lq.GetQuery().GetQuery() != accountName { t.Errorf("Expected DocumentDBDatabaseAccounts query %s, got %s", accountName, lq.GetQuery().GetQuery()) } } } if !foundDocumentDBAccount { t.Error("Expected linked query to DocumentDBDatabaseAccounts") } }) }) t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { peID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe" conn := createAzureDocumentDBPrivateEndpointConnection(connectionName, peID) mockClient := mocks.NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, connectionName).Return( armcosmos.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testDocumentDBPrivateEndpointConnectionsClient{MockDocumentDBPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDocumentDBPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundPrivateEndpoint := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { foundPrivateEndpoint = true if lq.GetQuery().GetQuery() != "test-pe" { t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) } break } } if !foundPrivateEndpoint { t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl) testClient := &testDocumentDBPrivateEndpointConnectionsClient{MockDocumentDBPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDocumentDBPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { conn1 := createAzureDocumentDBPrivateEndpointConnection("pec-1", "") conn2 := createAzureDocumentDBPrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl) mockPager := &mockDocumentDBPrivateEndpointConnectionsPager{ pages: []armcosmos.PrivateEndpointConnectionsClientListByDatabaseAccountResponse{ { PrivateEndpointConnectionListResult: armcosmos.PrivateEndpointConnectionListResult{ Value: []*armcosmos.PrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testDocumentDBPrivateEndpointConnectionsClient{ MockDocumentDBPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewDocumentDBPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.DocumentDBPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.DocumentDBPrivateEndpointConnection, item.GetType()) } } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validConn := createAzureDocumentDBPrivateEndpointConnection("valid-pec", "") mockClient := mocks.NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl) mockPager := &mockDocumentDBPrivateEndpointConnectionsPager{ pages: []armcosmos.PrivateEndpointConnectionsClientListByDatabaseAccountResponse{ { PrivateEndpointConnectionListResult: armcosmos.PrivateEndpointConnectionListResult{ Value: []*armcosmos.PrivateEndpointConnection{ {Name: nil}, validConn, }, }, }, }, } testClient := &testDocumentDBPrivateEndpointConnectionsClient{ MockDocumentDBPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewDocumentDBPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(accountName, "valid-pec") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(accountName, "valid-pec"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl) testClient := &testDocumentDBPrivateEndpointConnectionsClient{MockDocumentDBPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDocumentDBPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("private endpoint connection not found") mockClient := mocks.NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, "nonexistent-pec").Return( armcosmos.PrivateEndpointConnectionsClientGetResponse{}, expectedErr) testClient := &testDocumentDBPrivateEndpointConnectionsClient{MockDocumentDBPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewDocumentDBPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, "nonexistent-pec") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent private endpoint connection, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewDocumentDBPrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.DocumentDBDatabaseAccounts] { t.Error("Expected DocumentDBDatabaseAccounts in PotentialLinks") } if !links[azureshared.NetworkPrivateEndpoint] { t.Error("Expected NetworkPrivateEndpoint in PotentialLinks") } }) } func createAzureDocumentDBPrivateEndpointConnection(connectionName, privateEndpointID string) *armcosmos.PrivateEndpointConnection { conn := &armcosmos.PrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-cosmos-account/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.DocumentDB/databaseAccounts/privateEndpointConnections"), Properties: &armcosmos.PrivateEndpointConnectionProperties{ ProvisioningState: new("Succeeded"), PrivateLinkServiceConnectionState: &armcosmos.PrivateLinkServiceConnectionStateProperty{ Status: new("Approved"), }, }, } if privateEndpointID != "" { conn.Properties.PrivateEndpoint = &armcosmos.PrivateEndpointProperty{ ID: new(privateEndpointID), } } return conn } ================================================ FILE: sources/azure/manual/elastic-san-volume-group.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type elasticSanVolumeGroupWrapper struct { client clients.ElasticSanVolumeGroupClient *azureshared.MultiResourceGroupBase } func NewElasticSanVolumeGroup(client clients.ElasticSanVolumeGroupClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &elasticSanVolumeGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ElasticSanVolumeGroup, ), } } func (e elasticSanVolumeGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, azureshared.QueryError(errors.New("Get requires 2 query parts: elasticSanName and volumeGroupName"), scope, e.Type()) } elasticSanName := queryParts[0] if elasticSanName == "" { return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) } volumeGroupName := queryParts[1] if volumeGroupName == "" { return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, e.Type()) } rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } resp, err := e.client.Get(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } return e.azureVolumeGroupToSDPItem(&resp.VolumeGroup, elasticSanName, volumeGroupName, scope) } func (e elasticSanVolumeGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ElasticSanLookupByName, ElasticSanVolumeGroupLookupByName, } } func (e elasticSanVolumeGroupWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Search requires 1 query part: elasticSanName"), scope, e.Type()) } elasticSanName := queryParts[0] if elasticSanName == "" { return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) } rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } pager := e.client.NewListByElasticSanPager(rgScope.ResourceGroup, elasticSanName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } for _, vg := range page.Value { if vg.Name == nil { continue } item, sdpErr := e.azureVolumeGroupToSDPItem(vg, elasticSanName, *vg.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (e elasticSanVolumeGroupWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: elasticSanName"), scope, e.Type())) return } elasticSanName := queryParts[0] if elasticSanName == "" { stream.SendError(azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type())) return } rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, e.Type())) return } pager := e.client.NewListByElasticSanPager(rgScope.ResourceGroup, elasticSanName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, e.Type())) return } for _, vg := range page.Value { if vg.Name == nil { continue } item, sdpErr := e.azureVolumeGroupToSDPItem(vg, elasticSanName, *vg.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (e elasticSanVolumeGroupWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {ElasticSanLookupByName}, } } func (e elasticSanVolumeGroupWrapper) azureVolumeGroupToSDPItem(vg *armelasticsan.VolumeGroup, elasticSanName, volumeGroupName, scope string) (*sdp.Item, *sdp.QueryError) { if vg.Name == nil { return nil, azureshared.QueryError(errors.New("volume group name is nil"), scope, e.Type()) } attributes, err := shared.ToAttributesWithExclude(vg, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(elasticSanName, volumeGroupName)) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } item := &sdp.Item{ Type: azureshared.ElasticSanVolumeGroup.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to parent Elastic SAN item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSan.String(), Method: sdp.QueryMethod_GET, Query: elasticSanName, Scope: scope, }, }) // Link to User Assigned Identities from top-level Identity (map keys are ARM resource IDs) if vg.Identity != nil && vg.Identity.UserAssignedIdentities != nil { for identityResourceID := range vg.Identity.UserAssignedIdentities { if identityResourceID == "" { continue } identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Link to Private Endpoints via PrivateEndpointConnections if vg.Properties != nil && vg.Properties.PrivateEndpointConnections != nil { for _, pec := range vg.Properties.PrivateEndpointConnections { if pec != nil && pec.Properties != nil && pec.Properties.PrivateEndpoint != nil && pec.Properties.PrivateEndpoint.ID != nil { peName := azureshared.ExtractResourceName(*pec.Properties.PrivateEndpoint.ID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*pec.Properties.PrivateEndpoint.ID); extractedScope != "" { linkedScope = extractedScope } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } } } // Link to child Volume Snapshots (SEARCH by parent Elastic SAN + Volume Group) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolumeSnapshot.String(), Method: sdp.QueryMethod_SEARCH, Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName), Scope: scope, }, }) // Link to child Volumes (SEARCH by parent Elastic SAN + Volume Group) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolume.String(), Method: sdp.QueryMethod_SEARCH, Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName), Scope: scope, }, }) // Link to subnets from NetworkACLs virtual network rules if vg.Properties != nil && vg.Properties.NetworkACLs != nil && vg.Properties.NetworkACLs.VirtualNetworkRules != nil { for _, rule := range vg.Properties.NetworkACLs.VirtualNetworkRules { if rule != nil && rule.VirtualNetworkResourceID != nil && *rule.VirtualNetworkResourceID != "" { subnetID := *rule.VirtualNetworkResourceID params := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(subnetID) if linkedScope == "" { linkedScope = scope } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } // Link to Key Vault and encryption identity from EncryptionProperties if vg.Properties != nil && vg.Properties.EncryptionProperties != nil { enc := vg.Properties.EncryptionProperties // Link to User Assigned Identity used for encryption (same pattern as storage-account.go) if enc.EncryptionIdentity != nil && enc.EncryptionIdentity.EncryptionUserAssignedIdentity != nil { identityResourceID := *enc.EncryptionIdentity.EncryptionUserAssignedIdentity identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } // Link to Key Vault and DNS from KeyVaultURI (DNS-resolvable hostname) if enc.KeyVaultProperties != nil && enc.KeyVaultProperties.KeyVaultURI != nil && *enc.KeyVaultProperties.KeyVaultURI != "" { keyVaultURI := *enc.KeyVaultProperties.KeyVaultURI vaultName := azureshared.ExtractVaultNameFromURI(keyVaultURI) if vaultName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, // Key Vault URI does not contain resource group }, }) } if dnsName := azureshared.ExtractDNSFromURL(keyVaultURI); dnsName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } // Health from provisioning state if vg.Properties != nil && vg.Properties.ProvisioningState != nil { switch *vg.Properties.ProvisioningState { case armelasticsan.ProvisioningStatesSucceeded: item.Health = sdp.Health_HEALTH_OK.Enum() case armelasticsan.ProvisioningStatesCreating, armelasticsan.ProvisioningStatesUpdating, armelasticsan.ProvisioningStatesDeleting, armelasticsan.ProvisioningStatesPending, armelasticsan.ProvisioningStatesRestoring: item.Health = sdp.Health_HEALTH_PENDING.Enum() case armelasticsan.ProvisioningStatesFailed, armelasticsan.ProvisioningStatesCanceled, armelasticsan.ProvisioningStatesDeleted, armelasticsan.ProvisioningStatesInvalid: item.Health = sdp.Health_HEALTH_ERROR.Enum() default: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return item, nil } func (e elasticSanVolumeGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ElasticSan: true, azureshared.ElasticSanVolume: true, azureshared.ElasticSanVolumeSnapshot: true, azureshared.NetworkPrivateEndpoint: true, azureshared.NetworkSubnet: true, azureshared.KeyVaultVault: true, azureshared.ManagedIdentityUserAssignedIdentity: true, stdlib.NetworkDNS: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/elastic_san_volume_group func (e elasticSanVolumeGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_elastic_san_volume_group.id", }, } } func (e elasticSanVolumeGroupWrapper) IAMPermissions() []string { return []string{ "Microsoft.ElasticSan/elasticSans/volumegroups/read", } } func (e elasticSanVolumeGroupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/elastic-san-volume-group_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockElasticSanVolumeGroupPager is a simple mock implementation of ElasticSanVolumeGroupPager type mockElasticSanVolumeGroupPager struct { pages []armelasticsan.VolumeGroupsClientListByElasticSanResponse index int } func (m *mockElasticSanVolumeGroupPager) More() bool { return m.index < len(m.pages) } func (m *mockElasticSanVolumeGroupPager) NextPage(ctx context.Context) (armelasticsan.VolumeGroupsClientListByElasticSanResponse, error) { if m.index >= len(m.pages) { return armelasticsan.VolumeGroupsClientListByElasticSanResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } func createAzureElasticSanVolumeGroup(name string) *armelasticsan.VolumeGroup { provisioningState := armelasticsan.ProvisioningStatesSucceeded return &armelasticsan.VolumeGroup{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/es/volumegroups/" + name), Name: new(name), Type: new("Microsoft.ElasticSan/elasticSans/volumegroups"), Properties: &armelasticsan.VolumeGroupProperties{ ProvisioningState: &provisioningState, }, } } func TestElasticSanVolumeGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" elasticSanName := "test-elastic-san" volumeGroupName := "test-volume-group" t.Run("Get", func(t *testing.T) { vg := createAzureElasticSanVolumeGroup(volumeGroupName) mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, nil).Return( armelasticsan.VolumeGroupsClientGetResponse{ VolumeGroup: *vg, }, nil) wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ElasticSanVolumeGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeGroup.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(elasticSanName, volumeGroupName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { scope := subscriptionID + "." + resourceGroup queryTests := shared.QueryTests{ {ExpectedType: azureshared.ElasticSan.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: elasticSanName, ExpectedScope: scope}, {ExpectedType: azureshared.ElasticSanVolumeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, {ExpectedType: azureshared.ElasticSanVolume.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], elasticSanName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when volume group name is empty, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, "nonexistent", nil).Return( armelasticsan.VolumeGroupsClientGetResponse{}, errors.New("volume group not found")) wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when resource not found, but got nil") } }) t.Run("Search", func(t *testing.T) { vg1 := createAzureElasticSanVolumeGroup("vg-1") vg2 := createAzureElasticSanVolumeGroup("vg-2") mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) mockPager := &mockElasticSanVolumeGroupPager{ pages: []armelasticsan.VolumeGroupsClientListByElasticSanResponse{ { VolumeGroupList: armelasticsan.VolumeGroupList{ Value: []*armelasticsan.VolumeGroup{vg1, vg2}, }, }, }, } mockClient.EXPECT().NewListByElasticSanPager(resourceGroup, elasticSanName, nil).Return(mockPager) wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } query := elasticSanName items, err := searchable.Search(ctx, wrapper.Scopes()[0], query, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for _, item := range items { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } } }) t.Run("SearchStream", func(t *testing.T) { vg := createAzureElasticSanVolumeGroup("stream-vg") mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) mockPager := &mockElasticSanVolumeGroupPager{ pages: []armelasticsan.VolumeGroupsClientListByElasticSanResponse{ { VolumeGroupList: armelasticsan.VolumeGroupList{ Value: []*armelasticsan.VolumeGroup{vg}, }, }, }, } mockClient.EXPECT().NewListByElasticSanPager(resourceGroup, elasticSanName, nil).Return(mockPager) wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) streamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } query := elasticSanName stream := discovery.NewRecordingQueryResultStream() streamable.SearchStream(ctx, wrapper.Scopes()[0], query, true, stream) items := stream.GetItems() if len(items) != 1 { t.Fatalf("Expected 1 item from stream, got %d", len(items)) } if items[0].GetType() != azureshared.ElasticSanVolumeGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeGroup.String(), items[0].GetType()) } }) } ================================================ FILE: sources/azure/manual/elastic-san-volume-snapshot.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ( ElasticSanLookupByName = shared.NewItemTypeLookup("name", azureshared.ElasticSan) ElasticSanVolumeGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.ElasticSanVolumeGroup) ElasticSanVolumeSnapshotLookupByName = shared.NewItemTypeLookup("name", azureshared.ElasticSanVolumeSnapshot) ) type elasticSanVolumeSnapshotWrapper struct { client clients.ElasticSanVolumeSnapshotClient *azureshared.MultiResourceGroupBase } func NewElasticSanVolumeSnapshot(client clients.ElasticSanVolumeSnapshotClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &elasticSanVolumeSnapshotWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ElasticSanVolumeSnapshot, ), } } func (s elasticSanVolumeSnapshotWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 3 { return nil, azureshared.QueryError(errors.New("Get requires 3 query parts: elasticSanName, volumeGroupName and snapshotName"), scope, s.Type()) } elasticSanName := queryParts[0] if elasticSanName == "" { return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, s.Type()) } volumeGroupName := queryParts[1] if volumeGroupName == "" { return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, s.Type()) } snapshotName := queryParts[2] if snapshotName == "" { return nil, azureshared.QueryError(errors.New("snapshotName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, snapshotName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureSnapshotToSDPItem(&resp.Snapshot, elasticSanName, volumeGroupName, snapshotName, scope) } func (s elasticSanVolumeSnapshotWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ElasticSanLookupByName, ElasticSanVolumeGroupLookupByName, ElasticSanVolumeSnapshotLookupByName, } } func (s elasticSanVolumeSnapshotWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, azureshared.QueryError(errors.New("Search requires 2 query parts: elasticSanName and volumeGroupName"), scope, s.Type()) } elasticSanName := queryParts[0] if elasticSanName == "" { return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, s.Type()) } volumeGroupName := queryParts[1] if volumeGroupName == "" { return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByVolumeGroup(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, snapshot := range page.Value { if snapshot.Name == nil { continue } item, sdpErr := s.azureSnapshotToSDPItem(snapshot, elasticSanName, volumeGroupName, *snapshot.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s elasticSanVolumeSnapshotWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 2 { stream.SendError(azureshared.QueryError(errors.New("Search requires 2 query parts: elasticSanName and volumeGroupName"), scope, s.Type())) return } elasticSanName := queryParts[0] if elasticSanName == "" { stream.SendError(azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, s.Type())) return } volumeGroupName := queryParts[1] if volumeGroupName == "" { stream.SendError(azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByVolumeGroup(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, snapshot := range page.Value { if snapshot.Name == nil { continue } item, sdpErr := s.azureSnapshotToSDPItem(snapshot, elasticSanName, volumeGroupName, *snapshot.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s elasticSanVolumeSnapshotWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ElasticSanLookupByName, ElasticSanVolumeGroupLookupByName, }, } } func (s elasticSanVolumeSnapshotWrapper) azureSnapshotToSDPItem(snapshot *armelasticsan.Snapshot, elasticSanName, volumeGroupName, snapshotName, scope string) (*sdp.Item, *sdp.QueryError) { if snapshot.Name == nil { return nil, azureshared.QueryError(errors.New("snapshot name is nil"), scope, s.Type()) } attributes, err := shared.ToAttributesWithExclude(snapshot, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(elasticSanName, volumeGroupName, snapshotName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ElasticSanVolumeSnapshot.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to parent Elastic SAN sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSan.String(), Method: sdp.QueryMethod_GET, Query: elasticSanName, Scope: scope, }, }) // Link to parent Volume Group sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolumeGroup.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName), Scope: scope, }, }) // Link to source volume from CreationData.SourceID if snapshot.Properties != nil && snapshot.Properties.CreationData != nil && snapshot.Properties.CreationData.SourceID != nil && *snapshot.Properties.CreationData.SourceID != "" { sourceID := *snapshot.Properties.CreationData.SourceID parts := azureshared.ExtractPathParamsFromResourceID(sourceID, []string{"elasticSans", "volumegroups", "volumes"}) if len(parts) >= 3 { extractedScope := azureshared.ExtractScopeFromResourceID(sourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolume.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(parts[0], parts[1], parts[2]), Scope: extractedScope, }, }) } } if snapshot.Properties != nil && snapshot.Properties.ProvisioningState != nil { switch *snapshot.Properties.ProvisioningState { case armelasticsan.ProvisioningStatesSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armelasticsan.ProvisioningStatesCreating, armelasticsan.ProvisioningStatesUpdating, armelasticsan.ProvisioningStatesDeleting, armelasticsan.ProvisioningStatesPending, armelasticsan.ProvisioningStatesRestoring: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armelasticsan.ProvisioningStatesFailed, armelasticsan.ProvisioningStatesCanceled, armelasticsan.ProvisioningStatesDeleted, armelasticsan.ProvisioningStatesInvalid: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return sdpItem, nil } func (s elasticSanVolumeSnapshotWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ElasticSan: true, azureshared.ElasticSanVolumeGroup: true, azureshared.ElasticSanVolume: true, } } func (s elasticSanVolumeSnapshotWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_elastic_san_volume_snapshot.id", }, } } func (s elasticSanVolumeSnapshotWrapper) IAMPermissions() []string { return []string{ "Microsoft.ElasticSan/elasticSans/volumegroups/snapshots/read", } } func (s elasticSanVolumeSnapshotWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/elastic-san-volume-snapshot_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockElasticSanVolumeSnapshotPager is a simple mock implementation of ElasticSanVolumeSnapshotPager type mockElasticSanVolumeSnapshotPager struct { pages []armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse index int } func (m *mockElasticSanVolumeSnapshotPager) More() bool { return m.index < len(m.pages) } func (m *mockElasticSanVolumeSnapshotPager) NextPage(ctx context.Context) (armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse, error) { if m.index >= len(m.pages) { return armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } func createAzureElasticSanSnapshot(name string) *armelasticsan.Snapshot { provisioningState := armelasticsan.ProvisioningStatesSucceeded return &armelasticsan.Snapshot{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/es/volumegroups/vg/snapshots/" + name), Name: new(name), Type: new("Microsoft.ElasticSan/elasticSans/volumegroups/snapshots"), Properties: &armelasticsan.SnapshotProperties{ ProvisioningState: &provisioningState, CreationData: &armelasticsan.SnapshotCreationData{}, }, } } func TestElasticSanVolumeSnapshot(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" elasticSanName := "test-elastic-san" volumeGroupName := "test-volume-group" snapshotName := "test-snapshot" t.Run("Get", func(t *testing.T) { snapshot := createAzureElasticSanSnapshot(snapshotName) mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, snapshotName, nil).Return( armelasticsan.VolumeSnapshotsClientGetResponse{ Snapshot: *snapshot, }, nil) wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, snapshotName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ElasticSanVolumeSnapshot.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeSnapshot.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(elasticSanName, volumeGroupName, snapshotName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { scope := subscriptionID + "." + resourceGroup queryTests := shared.QueryTests{ {ExpectedType: azureshared.ElasticSan.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: elasticSanName, ExpectedScope: scope}, {ExpectedType: azureshared.ElasticSanVolumeGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], elasticSanName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when snapshot name is empty, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, "nonexistent", nil).Return( armelasticsan.VolumeSnapshotsClientGetResponse{}, errors.New("snapshot not found")) wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when resource not found, but got nil") } }) t.Run("Search", func(t *testing.T) { snapshot1 := createAzureElasticSanSnapshot("snap-1") snapshot2 := createAzureElasticSanSnapshot("snap-2") mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) mockPager := &mockElasticSanVolumeSnapshotPager{ pages: []armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse{ { SnapshotList: armelasticsan.SnapshotList{ Value: []*armelasticsan.Snapshot{snapshot1, snapshot2}, }, }, }, } mockClient.EXPECT().ListByVolumeGroup(ctx, resourceGroup, elasticSanName, volumeGroupName, nil).Return(mockPager) wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) items, err := searchable.Search(ctx, wrapper.Scopes()[0], query, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for _, item := range items { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } } }) t.Run("SearchStream", func(t *testing.T) { snapshot := createAzureElasticSanSnapshot("stream-snap") mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) mockPager := &mockElasticSanVolumeSnapshotPager{ pages: []armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse{ { SnapshotList: armelasticsan.SnapshotList{ Value: []*armelasticsan.Snapshot{snapshot}, }, }, }, } mockClient.EXPECT().ListByVolumeGroup(ctx, resourceGroup, elasticSanName, volumeGroupName, nil).Return(mockPager) wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) streamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) stream := discovery.NewRecordingQueryResultStream() streamable.SearchStream(ctx, wrapper.Scopes()[0], query, true, stream) items := stream.GetItems() if len(items) != 1 { t.Fatalf("Expected 1 item from stream, got %d", len(items)) } if items[0].GetType() != azureshared.ElasticSanVolumeSnapshot.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeSnapshot.String(), items[0].GetType()) } }) } ================================================ FILE: sources/azure/manual/elastic-san-volume.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ElasticSanVolumeLookupByName = shared.NewItemTypeLookup("name", azureshared.ElasticSanVolume) type elasticSanVolumeWrapper struct { client clients.ElasticSanVolumeClient *azureshared.MultiResourceGroupBase } func NewElasticSanVolume(client clients.ElasticSanVolumeClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &elasticSanVolumeWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ElasticSanVolume, ), } } func (e elasticSanVolumeWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 3 { return nil, azureshared.QueryError(errors.New("Get requires 3 query parts: elasticSanName, volumeGroupName, and volumeName"), scope, e.Type()) } elasticSanName := queryParts[0] if elasticSanName == "" { return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) } volumeGroupName := queryParts[1] if volumeGroupName == "" { return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, e.Type()) } volumeName := queryParts[2] if volumeName == "" { return nil, azureshared.QueryError(errors.New("volumeName cannot be empty"), scope, e.Type()) } rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } resp, err := e.client.Get(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, volumeName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } return e.azureVolumeToSDPItem(&resp.Volume, elasticSanName, volumeGroupName, volumeName, scope) } func (e elasticSanVolumeWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ElasticSanLookupByName, ElasticSanVolumeGroupLookupByName, ElasticSanVolumeLookupByName, } } func (e elasticSanVolumeWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, azureshared.QueryError(errors.New("Search requires 2 query parts: elasticSanName and volumeGroupName"), scope, e.Type()) } elasticSanName := queryParts[0] if elasticSanName == "" { return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) } volumeGroupName := queryParts[1] if volumeGroupName == "" { return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, e.Type()) } rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } pager := e.client.NewListByVolumeGroupPager(rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } for _, vol := range page.Value { if vol.Name == nil { continue } item, sdpErr := e.azureVolumeToSDPItem(vol, elasticSanName, volumeGroupName, *vol.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (e elasticSanVolumeWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 2 { stream.SendError(azureshared.QueryError(errors.New("Search requires 2 query parts: elasticSanName and volumeGroupName"), scope, e.Type())) return } elasticSanName := queryParts[0] if elasticSanName == "" { stream.SendError(azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type())) return } volumeGroupName := queryParts[1] if volumeGroupName == "" { stream.SendError(azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, e.Type())) return } rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, e.Type())) return } pager := e.client.NewListByVolumeGroupPager(rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, e.Type())) return } for _, vol := range page.Value { if vol.Name == nil { continue } item, sdpErr := e.azureVolumeToSDPItem(vol, elasticSanName, volumeGroupName, *vol.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (e elasticSanVolumeWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {ElasticSanLookupByName, ElasticSanVolumeGroupLookupByName}, } } func (e elasticSanVolumeWrapper) azureVolumeToSDPItem(vol *armelasticsan.Volume, elasticSanName, volumeGroupName, volumeName, scope string) (*sdp.Item, *sdp.QueryError) { if vol.Name == nil { return nil, azureshared.QueryError(errors.New("volume name is nil"), scope, e.Type()) } attributes, err := shared.ToAttributesWithExclude(vol, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(elasticSanName, volumeGroupName, volumeName)) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } item := &sdp.Item{ Type: azureshared.ElasticSanVolume.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to parent Elastic SAN item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSan.String(), Method: sdp.QueryMethod_GET, Query: elasticSanName, Scope: scope, }, }) // Link to parent Volume Group item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolumeGroup.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName), Scope: scope, }, }) if vol.Properties != nil { // Link to source resource (snapshot or volume) via CreationData.SourceID if vol.Properties.CreationData != nil && vol.Properties.CreationData.SourceID != nil && *vol.Properties.CreationData.SourceID != "" { sourceID := *vol.Properties.CreationData.SourceID // Determine the type based on the resource ID path // Azure REST API uses /snapshots/ for Elastic SAN volume snapshots if strings.Contains(sourceID, "/snapshots/") { // It's a snapshot - extract elasticSanName, volumeGroupName, snapshotName params := azureshared.ExtractPathParamsFromResourceID(sourceID, []string{"elasticSans", "volumegroups", "snapshots"}) if len(params) >= 3 && params[0] != "" && params[1] != "" && params[2] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(sourceID) if linkedScope == "" { linkedScope = scope } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolumeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1], params[2]), Scope: linkedScope, }, }) } } else if strings.Contains(sourceID, "/volumes/") { // It's a volume - extract elasticSanName, volumeGroupName, volumeName params := azureshared.ExtractPathParamsFromResourceID(sourceID, []string{"elasticSans", "volumegroups", "volumes"}) if len(params) >= 3 && params[0] != "" && params[1] != "" && params[2] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(sourceID) if linkedScope == "" { linkedScope = scope } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolume.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1], params[2]), Scope: linkedScope, }, }) } } } // Link to managed-by resource via ManagedBy.ResourceID if vol.Properties.ManagedBy != nil && vol.Properties.ManagedBy.ResourceID != nil && *vol.Properties.ManagedBy.ResourceID != "" { managedByID := *vol.Properties.ManagedBy.ResourceID // ManagedBy can reference different resource types (e.g., AKS clusters, VMs) // We'll use the generic resource name extraction and link appropriately linkedScope := azureshared.ExtractScopeFromResourceID(managedByID) if linkedScope == "" { linkedScope = scope } // Detect the resource type based on the path if strings.Contains(managedByID, "/virtualMachines/") { vmName := azureshared.ExtractResourceName(managedByID) if vmName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: linkedScope, }, }) } } // Add other resource types as needed } // Link to storage target DNS/hostname if available if vol.Properties.StorageTarget != nil { if vol.Properties.StorageTarget.TargetPortalHostname != nil && *vol.Properties.StorageTarget.TargetPortalHostname != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *vol.Properties.StorageTarget.TargetPortalHostname, Scope: "global", }, }) } } } // Health from provisioning state if vol.Properties != nil && vol.Properties.ProvisioningState != nil { switch *vol.Properties.ProvisioningState { case armelasticsan.ProvisioningStatesSucceeded: item.Health = sdp.Health_HEALTH_OK.Enum() case armelasticsan.ProvisioningStatesCreating, armelasticsan.ProvisioningStatesUpdating, armelasticsan.ProvisioningStatesDeleting, armelasticsan.ProvisioningStatesPending, armelasticsan.ProvisioningStatesRestoring: item.Health = sdp.Health_HEALTH_PENDING.Enum() case armelasticsan.ProvisioningStatesFailed, armelasticsan.ProvisioningStatesCanceled, armelasticsan.ProvisioningStatesDeleted, armelasticsan.ProvisioningStatesInvalid: item.Health = sdp.Health_HEALTH_ERROR.Enum() default: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return item, nil } func (e elasticSanVolumeWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ElasticSan: true, azureshared.ElasticSanVolumeGroup: true, azureshared.ElasticSanVolumeSnapshot: true, azureshared.ElasticSanVolume: true, azureshared.ComputeVirtualMachine: true, stdlib.NetworkDNS: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftelasticsan func (e elasticSanVolumeWrapper) IAMPermissions() []string { return []string{ "Microsoft.ElasticSan/elasticSans/volumegroups/volumes/read", } } func (e elasticSanVolumeWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/elastic-san-volume_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockElasticSanVolumePager is a simple mock implementation of ElasticSanVolumePager type mockElasticSanVolumePager struct { pages []armelasticsan.VolumesClientListByVolumeGroupResponse index int } func (m *mockElasticSanVolumePager) More() bool { return m.index < len(m.pages) } func (m *mockElasticSanVolumePager) NextPage(ctx context.Context) (armelasticsan.VolumesClientListByVolumeGroupResponse, error) { if m.index >= len(m.pages) { return armelasticsan.VolumesClientListByVolumeGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } func createAzureElasticSanVolume(name string) *armelasticsan.Volume { provisioningState := armelasticsan.ProvisioningStatesSucceeded sizeGiB := int64(100) return &armelasticsan.Volume{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/es/volumegroups/vg/volumes/" + name), Name: new(name), Type: new("Microsoft.ElasticSan/elasticSans/volumegroups/volumes"), Properties: &armelasticsan.VolumeProperties{ SizeGiB: &sizeGiB, ProvisioningState: &provisioningState, }, } } func createAzureElasticSanVolumeWithLinks(name string) *armelasticsan.Volume { vol := createAzureElasticSanVolume(name) vol.Properties.StorageTarget = &armelasticsan.IscsiTargetInfo{ TargetPortalHostname: new("test-san.region.elasticsan.azure.net"), TargetIqn: new("iqn.2022-05.net.azure.elasticsan:test"), TargetPortalPort: new(int32(3260)), } vol.Properties.CreationData = &armelasticsan.SourceCreationData{ CreateSource: new(armelasticsan.VolumeCreateOptionVolumeSnapshot), SourceID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/es/volumegroups/vg/snapshots/snap1"), } return vol } func TestElasticSanVolume(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" elasticSanName := "test-elastic-san" volumeGroupName := "test-volume-group" volumeName := "test-volume" t.Run("Get", func(t *testing.T) { vol := createAzureElasticSanVolume(volumeName) mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, volumeName, nil).Return( armelasticsan.VolumesClientGetResponse{ Volume: *vol, }, nil) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, volumeName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ElasticSanVolume.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolume.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(elasticSanName, volumeGroupName, volumeName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { scope := subscriptionID + "." + resourceGroup queryTests := shared.QueryTests{ {ExpectedType: azureshared.ElasticSan.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: elasticSanName, ExpectedScope: scope}, {ExpectedType: azureshared.ElasticSanVolumeGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithLinks", func(t *testing.T) { vol := createAzureElasticSanVolumeWithLinks(volumeName) mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, volumeName, nil).Return( armelasticsan.VolumesClientGetResponse{ Volume: *vol, }, nil) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, volumeName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { scope := subscriptionID + "." + resourceGroup queryTests := shared.QueryTests{ {ExpectedType: azureshared.ElasticSan.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: elasticSanName, ExpectedScope: scope}, {ExpectedType: azureshared.ElasticSanVolumeGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, {ExpectedType: azureshared.ElasticSanVolumeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("es", "vg", "snap1"), ExpectedScope: "sub.rg"}, {ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-san.region.elasticsan.azure.net", ExpectedScope: "global"}, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Only 2 query parts - missing volumeName query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyElasticSanName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", volumeGroupName, volumeName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when elasticSanName is empty, but got nil") } }) t.Run("GetWithEmptyVolumeGroupName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, "", volumeName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when volumeGroupName is empty, but got nil") } }) t.Run("GetWithEmptyVolumeName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when volumeName is empty, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, "nonexistent", nil).Return( armelasticsan.VolumesClientGetResponse{}, errors.New("volume not found")) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when resource not found, but got nil") } }) t.Run("Search", func(t *testing.T) { vol1 := createAzureElasticSanVolume("vol-1") vol2 := createAzureElasticSanVolume("vol-2") mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) mockPager := &mockElasticSanVolumePager{ pages: []armelasticsan.VolumesClientListByVolumeGroupResponse{ { VolumeList: armelasticsan.VolumeList{ Value: []*armelasticsan.Volume{vol1, vol2}, }, }, }, } mockClient.EXPECT().NewListByVolumeGroupPager(resourceGroup, elasticSanName, volumeGroupName, nil).Return(mockPager) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) items, err := searchable.Search(ctx, wrapper.Scopes()[0], query, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for _, item := range items { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } } }) t.Run("SearchWithEmptyElasticSanName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } query := shared.CompositeLookupKey("", volumeGroupName) _, err := searchable.Search(ctx, wrapper.Scopes()[0], query, true) if err == nil { t.Error("Expected error when elasticSanName is empty, but got nil") } }) t.Run("SearchWithEmptyVolumeGroupName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } query := shared.CompositeLookupKey(elasticSanName, "") _, err := searchable.Search(ctx, wrapper.Scopes()[0], query, true) if err == nil { t.Error("Expected error when volumeGroupName is empty, but got nil") } }) t.Run("SearchStream", func(t *testing.T) { vol := createAzureElasticSanVolume("stream-vol") mockClient := mocks.NewMockElasticSanVolumeClient(ctrl) mockPager := &mockElasticSanVolumePager{ pages: []armelasticsan.VolumesClientListByVolumeGroupResponse{ { VolumeList: armelasticsan.VolumeList{ Value: []*armelasticsan.Volume{vol}, }, }, }, } mockClient.EXPECT().NewListByVolumeGroupPager(resourceGroup, elasticSanName, volumeGroupName, nil).Return(mockPager) wrapper := manual.NewElasticSanVolume(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) streamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) stream := discovery.NewRecordingQueryResultStream() streamable.SearchStream(ctx, wrapper.Scopes()[0], query, true, stream) items := stream.GetItems() if len(items) != 1 { t.Fatalf("Expected 1 item from stream, got %d", len(items)) } if items[0].GetType() != azureshared.ElasticSanVolume.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolume.String(), items[0].GetType()) } }) } ================================================ FILE: sources/azure/manual/elastic-san.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) type elasticSanWrapper struct { client clients.ElasticSanClient *azureshared.MultiResourceGroupBase } func NewElasticSan(client clients.ElasticSanClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &elasticSanWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.ElasticSan, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/elasticsan/elastic-sans/list-by-resource-group func (e elasticSanWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } pager := e.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } for _, elasticSan := range page.Value { if elasticSan.Name == nil { continue } item, sdpErr := e.azureElasticSanToSDPItem(elasticSan, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (e elasticSanWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, e.Type())) return } pager := e.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, e.Type())) return } for _, elasticSan := range page.Value { if elasticSan.Name == nil { continue } item, sdpErr := e.azureElasticSanToSDPItem(elasticSan, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/elasticsan/elastic-sans/get func (e elasticSanWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the Elastic SAN name"), scope, e.Type()) } elasticSanName := queryParts[0] if elasticSanName == "" { return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) } rgScope, err := e.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } resp, err := e.client.Get(ctx, rgScope.ResourceGroup, elasticSanName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } return e.azureElasticSanToSDPItem(&resp.ElasticSan, scope) } func (e elasticSanWrapper) azureElasticSanToSDPItem(elasticSan *armelasticsan.ElasticSan, scope string) (*sdp.Item, *sdp.QueryError) { if elasticSan.Name == nil { return nil, azureshared.QueryError(errors.New("elasticSan name is nil"), scope, e.Type()) } attributes, err := shared.ToAttributesWithExclude(elasticSan, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, e.Type()) } item := &sdp.Item{ Type: azureshared.ElasticSan.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(elasticSan.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to Private Endpoints via PrivateEndpointConnections if elasticSan.Properties != nil && elasticSan.Properties.PrivateEndpointConnections != nil { for _, pec := range elasticSan.Properties.PrivateEndpointConnections { if pec != nil && pec.Properties != nil && pec.Properties.PrivateEndpoint != nil && pec.Properties.PrivateEndpoint.ID != nil { peName := azureshared.ExtractResourceName(*pec.Properties.PrivateEndpoint.ID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*pec.Properties.PrivateEndpoint.ID); extractedScope != "" { linkedScope = extractedScope } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } } } // Link to child Volume Groups (SEARCH by parent Elastic SAN name) if elasticSan.Name != nil && *elasticSan.Name != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ElasticSanVolumeGroup.String(), Method: sdp.QueryMethod_SEARCH, Query: *elasticSan.Name, Scope: scope, }, }) } // Health from provisioning state if elasticSan.Properties != nil && elasticSan.Properties.ProvisioningState != nil { switch *elasticSan.Properties.ProvisioningState { case armelasticsan.ProvisioningStatesSucceeded: item.Health = sdp.Health_HEALTH_OK.Enum() case armelasticsan.ProvisioningStatesCreating, armelasticsan.ProvisioningStatesUpdating, armelasticsan.ProvisioningStatesDeleting, armelasticsan.ProvisioningStatesPending, armelasticsan.ProvisioningStatesRestoring: item.Health = sdp.Health_HEALTH_PENDING.Enum() case armelasticsan.ProvisioningStatesFailed, armelasticsan.ProvisioningStatesCanceled, armelasticsan.ProvisioningStatesDeleted, armelasticsan.ProvisioningStatesInvalid: item.Health = sdp.Health_HEALTH_ERROR.Enum() default: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return item, nil } func (e elasticSanWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ElasticSanLookupByName, // defined in elastic-san-volume-snapshot.go } } func (e elasticSanWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.ElasticSanVolumeGroup, azureshared.NetworkPrivateEndpoint, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/elastic_san func (e elasticSanWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_elastic_san.name", }, } } func (e elasticSanWrapper) IAMPermissions() []string { return []string{ "Microsoft.ElasticSan/elasticSans/read", } } func (e elasticSanWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/elastic-san_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func createAzureElasticSan(name string) *armelasticsan.ElasticSan { baseSize := int64(1) extendedSize := int64(2) provisioningState := armelasticsan.ProvisioningStatesSucceeded return &armelasticsan.ElasticSan{ ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/" + name), Name: new(name), Location: new("eastus"), Type: new("Microsoft.ElasticSan/elasticSans"), Tags: map[string]*string{"env": new("test")}, Properties: &armelasticsan.Properties{ BaseSizeTiB: &baseSize, ExtendedCapacitySizeTiB: &extendedSize, ProvisioningState: &provisioningState, VolumeGroupCount: new(int64(0)), }, } } func createAzureElasticSanWithPrivateEndpoint(name, subscriptionID, resourceGroup string) *armelasticsan.ElasticSan { es := createAzureElasticSan(name) es.Properties.PrivateEndpointConnections = []*armelasticsan.PrivateEndpointConnection{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ElasticSan/elasticSans/" + name + "/privateEndpointConnections/pec-1"), Name: new("pec-1"), Properties: &armelasticsan.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armelasticsan.PrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe"), }, }, }, } return es } type mockElasticSanPager struct { pages []armelasticsan.ElasticSansClientListByResourceGroupResponse index int } func (m *mockElasticSanPager) More() bool { return m.index < len(m.pages) } func (m *mockElasticSanPager) NextPage(ctx context.Context) (armelasticsan.ElasticSansClientListByResourceGroupResponse, error) { if m.index >= len(m.pages) { return armelasticsan.ElasticSansClientListByResourceGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } func TestElasticSan(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { elasticSanName := "test-elastic-san" es := createAzureElasticSan(elasticSanName) mockClient := mocks.NewMockElasticSanClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, nil).Return( armelasticsan.ElasticSansClientGetResponse{ ElasticSan: *es, }, nil) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, elasticSanName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ElasticSan.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSan.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != elasticSanName { t.Errorf("Expected unique attribute value %s, got %s", elasticSanName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { // ElasticSanVolumeGroup SEARCH link (parent→child); no private endpoints in createAzureElasticSan shared.RunStaticTests(t, adapter, sdpItem, shared.QueryTests{ { ExpectedType: azureshared.ElasticSanVolumeGroup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: elasticSanName, ExpectedScope: scope, }, }) }) }) t.Run("GetWithPrivateEndpointLink", func(t *testing.T) { elasticSanName := "test-elastic-san-pe" es := createAzureElasticSanWithPrivateEndpoint(elasticSanName, subscriptionID, resourceGroup) mockClient := mocks.NewMockElasticSanClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, nil).Return( armelasticsan.ElasticSansClientGetResponse{ ElasticSan: *es, }, nil) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, elasticSanName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: azureshared.ElasticSanVolumeGroup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: elasticSanName, ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pe", ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("List", func(t *testing.T) { es1 := createAzureElasticSan("es-1") es2 := createAzureElasticSan("es-2") mockClient := mocks.NewMockElasticSanClient(ctrl) mockPager := &mockElasticSanPager{ pages: []armelasticsan.ElasticSansClientListByResourceGroupResponse{ {List: armelasticsan.List{Value: []*armelasticsan.ElasticSan{es1, es2}}}, }, } mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { es := createAzureElasticSan("es-stream") mockClient := mocks.NewMockElasticSanClient(ctrl) mockPager := &mockElasticSanPager{ pages: []armelasticsan.ElasticSansClientListByResourceGroupResponse{ {List: armelasticsan.List{Value: []*armelasticsan.ElasticSan{es}}}, }, } mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(1) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, scope, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 1 { t.Fatalf("Expected 1 item, got: %d", len(items)) } if items[0].GetType() != azureshared.ElasticSan.String() { t.Errorf("Expected type %s, got %s", azureshared.ElasticSan.String(), items[0].GetType()) } }) t.Run("ListWithNilName", func(t *testing.T) { es1 := createAzureElasticSan("es-1") esNilName := &armelasticsan.ElasticSan{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, } mockClient := mocks.NewMockElasticSanClient(ctrl) mockPager := &mockElasticSanPager{ pages: []armelasticsan.ElasticSansClientListByResourceGroupResponse{ {List: armelasticsan.List{Value: []*armelasticsan.ElasticSan{es1, esNilName}}}, }, } mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { mockClient := mocks.NewMockElasticSanClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", nil).Return( armelasticsan.ElasticSansClientGetResponse{}, errors.New("elastic san not found")) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "nonexistent", true) if qErr == nil { t.Error("Expected error when getting non-existent Elastic SAN, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockElasticSanClient(ctrl) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting Elastic SAN with empty name, but got nil") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockElasticSanClient(ctrl) wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Get(ctx, scope) if qErr == nil { t.Error("Expected error when getting Elastic SAN with insufficient query parts, but got nil") } }) } ================================================ FILE: sources/azure/manual/keyvault-key.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var KeyVaultKeyLookupByName = shared.NewItemTypeLookup("name", azureshared.KeyVaultKey) type keyvaultKeyWrapper struct { client clients.KeysClient *azureshared.MultiResourceGroupBase } func NewKeyVaultKey(client clients.KeysClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &keyvaultKeyWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.KeyVaultKey, ), } } func (k keyvaultKeyWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, azureshared.QueryError(errors.New("Get requires 2 query parts: vaultName and keyName"), scope, k.Type()) } vaultName := queryParts[0] if vaultName == "" { return nil, azureshared.QueryError(errors.New("vaultName cannot be empty"), scope, k.Type()) } keyName := queryParts[1] if keyName == "" { return nil, azureshared.QueryError(errors.New("keyName cannot be empty"), scope, k.Type()) } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } resp, err := k.client.Get(ctx, rgScope.ResourceGroup, vaultName, keyName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } return k.azureKeyToSDPItem(&resp.Key, vaultName, keyName, scope) } func (k keyvaultKeyWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Search requires 1 query part: vaultName"), scope, k.Type()) } vaultName := queryParts[0] if vaultName == "" { return nil, azureshared.QueryError(errors.New("vaultName cannot be empty"), scope, k.Type()) } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } pager := k.client.NewListPager(rgScope.ResourceGroup, vaultName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } for _, key := range page.Value { if key.Name == nil { continue } var keyVaultName string if key.ID != nil && *key.ID != "" { vaultParams := azureshared.ExtractPathParamsFromResourceID(*key.ID, []string{"vaults"}) if len(vaultParams) > 0 { keyVaultName = vaultParams[0] } } if keyVaultName == "" { keyVaultName = vaultName } item, sdpErr := k.azureKeyToSDPItem(key, keyVaultName, *key.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (k keyvaultKeyWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: vaultName"), scope, k.Type())) return } vaultName := queryParts[0] if vaultName == "" { stream.SendError(azureshared.QueryError(errors.New("vaultName cannot be empty"), scope, k.Type())) return } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } pager := k.client.NewListPager(rgScope.ResourceGroup, vaultName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } for _, key := range page.Value { if key.Name == nil { continue } var keyVaultName string if key.ID != nil && *key.ID != "" { vaultParams := azureshared.ExtractPathParamsFromResourceID(*key.ID, []string{"vaults"}) if len(vaultParams) > 0 { keyVaultName = vaultParams[0] } } if keyVaultName == "" { keyVaultName = vaultName } item, sdpErr := k.azureKeyToSDPItem(key, keyVaultName, *key.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (k keyvaultKeyWrapper) azureKeyToSDPItem(key *armkeyvault.Key, vaultName, keyName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(key, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } if key.Name == nil { return nil, azureshared.QueryError(errors.New("key name is nil"), scope, k.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(vaultName, keyName)) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } sdpItem := &sdp.Item{ Type: azureshared.KeyVaultKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(key.Tags), } if key.ID != nil && *key.ID != "" { vaultParams := azureshared.ExtractPathParamsFromResourceID(*key.ID, []string{"vaults"}) if len(vaultParams) > 0 { extractedVaultName := vaultParams[0] if extractedVaultName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*key.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: extractedVaultName, Scope: linkedScope, }, }) } } } var linkedDNSName string if key.Properties != nil && key.Properties.KeyURI != nil && *key.Properties.KeyURI != "" { keyURI := *key.Properties.KeyURI dnsName := azureshared.ExtractDNSFromURL(keyURI) if dnsName != "" { linkedDNSName = dnsName sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: keyURI, Scope: "global", }, }) } if key.Properties != nil && key.Properties.KeyURIWithVersion != nil && *key.Properties.KeyURIWithVersion != "" { keyURIWithVersion := *key.Properties.KeyURIWithVersion dnsName := azureshared.ExtractDNSFromURL(keyURIWithVersion) if dnsName != "" && dnsName != linkedDNSName { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: keyURIWithVersion, Scope: "global", }, }) } return sdpItem, nil } func (k keyvaultKeyWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ KeyVaultVaultLookupByName, // First key: vault name (queryParts[0]) KeyVaultKeyLookupByName, // Second key: key name (queryParts[1]) } } func (k keyvaultKeyWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { KeyVaultVaultLookupByName, }, } } func (k keyvaultKeyWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_key_vault_key.id", }, } } func (k keyvaultKeyWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.KeyVaultVault, stdlib.NetworkDNS, stdlib.NetworkHTTP, ) } func (k keyvaultKeyWrapper) IAMPermissions() []string { return []string{ "Microsoft.KeyVault/vaults/keys/read", } } func (k keyvaultKeyWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/keyvault-key_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockKeysPager struct { pages []armkeyvault.KeysClientListResponse index int } func (m *mockKeysPager) More() bool { return m.index < len(m.pages) } func (m *mockKeysPager) NextPage(ctx context.Context) (armkeyvault.KeysClientListResponse, error) { if m.index >= len(m.pages) { return armkeyvault.KeysClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorKeysPager struct{} func (e *errorKeysPager) More() bool { return true } func (e *errorKeysPager) NextPage(ctx context.Context) (armkeyvault.KeysClientListResponse, error) { return armkeyvault.KeysClientListResponse{}, errors.New("pager error") } type testKeysClient struct { *mocks.MockKeysClient pager clients.KeysPager } func (t *testKeysClient) NewListPager(resourceGroupName, vaultName string, options *armkeyvault.KeysClientListOptions) clients.KeysPager { t.MockKeysClient.NewListPager(resourceGroupName, vaultName, options) return t.pager } func TestKeyVaultKey(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" vaultName := "test-keyvault" keyName := "test-key" t.Run("Get", func(t *testing.T) { key := createAzureKey(keyName, subscriptionID, resourceGroup, vaultName) mockClient := mocks.NewMockKeysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, keyName, nil).Return( armkeyvault.KeysClientGetResponse{ Key: *key, }, nil) wrapper := manual.NewKeyVaultKey(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + keyName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.KeyVaultKey.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultKey, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(vaultName, keyName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: vaultName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vaultName + ".vault.azure.net", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("https://%s.vault.azure.net/keys/%s", vaultName, keyName), ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) wrapper := manual.NewKeyVaultKey(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vaultName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Get_EmptyVaultName", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) wrapper := manual.NewKeyVaultKey(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.QuerySeparator + keyName _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when vault name is empty, but got nil") } }) t.Run("Get_EmptyKeyName", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) wrapper := manual.NewKeyVaultKey(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when key name is empty, but got nil") } }) t.Run("Get_NoName", func(t *testing.T) { key := &armkeyvault.Key{ Name: nil, } mockClient := mocks.NewMockKeysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, keyName, nil).Return( armkeyvault.KeysClientGetResponse{ Key: *key, }, nil) wrapper := manual.NewKeyVaultKey(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + keyName _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when key has no name, but got nil") } }) t.Run("Get_NoLinkedResources", func(t *testing.T) { key := createAzureKeyMinimal(keyName) mockClient := mocks.NewMockKeysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, keyName, nil).Return( armkeyvault.KeysClientGetResponse{ Key: *key, }, nil) wrapper := manual.NewKeyVaultKey(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + keyName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked item queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("Search", func(t *testing.T) { key1 := createAzureKey("key-1", subscriptionID, resourceGroup, vaultName) key2 := createAzureKey("key-2", subscriptionID, resourceGroup, vaultName) mockPager := &mockKeysPager{ pages: []armkeyvault.KeysClientListResponse{ { KeyListResult: armkeyvault.KeyListResult{ Value: []*armkeyvault.Key{ {ID: key1.ID, Name: key1.Name, Type: key1.Type, Properties: key1.Properties, Tags: key1.Tags}, {ID: key2.ID, Name: key2.Name, Type: key2.Type, Properties: key2.Properties, Tags: key2.Tags}, }, }, }, }, } mockClient := mocks.NewMockKeysClient(ctrl) mockClient.EXPECT().NewListPager(resourceGroup, vaultName, nil).Return(mockPager) testClient := &testKeysClient{ MockKeysClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], vaultName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.KeyVaultKey.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultKey, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) testClient := &testKeysClient{MockKeysClient: mockClient} wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_EmptyVaultName", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) testClient := &testKeysClient{MockKeysClient: mockClient} wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when vault name is empty, but got nil") } }) t.Run("Search_KeyWithNilName", func(t *testing.T) { validKey := createAzureKey("valid-key", subscriptionID, resourceGroup, vaultName) mockPager := &mockKeysPager{ pages: []armkeyvault.KeysClientListResponse{ { KeyListResult: armkeyvault.KeyListResult{ Value: []*armkeyvault.Key{ {Name: nil}, {ID: validKey.ID, Name: validKey.Name, Type: validKey.Type, Properties: validKey.Properties, Tags: validKey.Tags}, }, }, }, }, } mockClient := mocks.NewMockKeysClient(ctrl) mockClient.EXPECT().NewListPager(resourceGroup, vaultName, nil).Return(mockPager) testClient := &testKeysClient{ MockKeysClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], vaultName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } expectedUniqueAttrValue := shared.CompositeLookupKey(vaultName, "valid-key") if sdpItems[0].UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("key not found") mockClient := mocks.NewMockKeysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, "nonexistent-key", nil).Return( armkeyvault.KeysClientGetResponse{}, expectedErr) wrapper := manual.NewKeyVaultKey(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + "nonexistent-key" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent key, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) errorPager := &errorKeysPager{} mockClient.EXPECT().NewListPager(resourceGroup, vaultName, nil).Return(errorPager) testClient := &testKeysClient{ MockKeysClient: mockClient, pager: errorPager, } wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], vaultName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) testClient := &testKeysClient{MockKeysClient: mockClient} wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) if wrapper == nil { t.Error("Wrapper should not be nil") } adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) testClient := &testKeysClient{MockKeysClient: mockClient} wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if len(links) == 0 { t.Error("Expected potential links to be defined") } if !links[azureshared.KeyVaultVault] { t.Error("Expected KeyVaultVault to be in potential links") } if !links[stdlib.NetworkDNS] { t.Error("Expected stdlib.NetworkDNS to be in potential links") } if !links[stdlib.NetworkHTTP] { t.Error("Expected stdlib.NetworkHTTP to be in potential links") } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) testClient := &testKeysClient{MockKeysClient: mockClient} wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) == 0 { t.Fatal("Expected TerraformMappings to be defined") } foundIDMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_key_vault_key.id" { foundIDMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected TerraformMethod to be SEARCH for id mapping, got %s", mapping.GetTerraformMethod()) } } } if !foundIDMapping { t.Error("Expected TerraformMappings to include 'azurerm_key_vault_key.id' mapping") } if len(mappings) != 1 { t.Errorf("Expected 1 TerraformMapping, got %d", len(mappings)) } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) testClient := &testKeysClient{MockKeysClient: mockClient} wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to be defined") } expectedPermission := "Microsoft.KeyVault/vaults/keys/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } }) t.Run("PredefinedRole", func(t *testing.T) { mockClient := mocks.NewMockKeysClient(ctrl) testClient := &testKeysClient{MockKeysClient: mockClient} wrapper := manual.NewKeyVaultKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) type predefinedRoleInterface interface { PredefinedRole() string } if roleInterface, ok := wrapper.(predefinedRoleInterface); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } else { t.Error("Wrapper should implement PredefinedRole method") } }) } func createAzureKey(keyName, subscriptionID, resourceGroup, vaultName string) *armkeyvault.Key { return &armkeyvault.Key{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s/keys/%s", subscriptionID, resourceGroup, vaultName, keyName)), Name: new(keyName), Type: new("Microsoft.KeyVault/vaults/keys"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armkeyvault.KeyProperties{ KeyURI: new(fmt.Sprintf("https://%s.vault.azure.net/keys/%s", vaultName, keyName)), }, } } func createAzureKeyMinimal(keyName string) *armkeyvault.Key { return &armkeyvault.Key{ Name: new(keyName), Type: new("Microsoft.KeyVault/vaults/keys"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armkeyvault.KeyProperties{}, } } ================================================ FILE: sources/azure/manual/keyvault-managed-hsm-private-endpoint-connection.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var KeyVaultManagedHSMPrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.KeyVaultManagedHSMPrivateEndpointConnection) type keyvaultManagedHSMPrivateEndpointConnectionWrapper struct { client clients.KeyVaultManagedHSMPrivateEndpointConnectionsClient *azureshared.MultiResourceGroupBase } // NewKeyVaultManagedHSMPrivateEndpointConnection returns a SearchableWrapper for Azure Key Vault Managed HSM private endpoint connections. func NewKeyVaultManagedHSMPrivateEndpointConnection(client clients.KeyVaultManagedHSMPrivateEndpointConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &keyvaultManagedHSMPrivateEndpointConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.KeyVaultManagedHSMPrivateEndpointConnection, ), } } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: hsmName and privateEndpointConnectionName", Scope: scope, ItemType: s.Type(), } } hsmName := queryParts[0] connectionName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, hsmName, connectionName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azureMHSMPrivateEndpointConnectionToSDPItem(&resp.MHSMPrivateEndpointConnection, hsmName, connectionName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ KeyVaultManagedHSMsLookupByName, KeyVaultManagedHSMPrivateEndpointConnectionLookupByName, } } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: hsmName", Scope: scope, ItemType: s.Type(), } } hsmName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByResource(ctx, rgScope.ResourceGroup, hsmName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azureMHSMPrivateEndpointConnectionToSDPItem(conn, hsmName, *conn.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: hsmName"), scope, s.Type())) return } hsmName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByResource(ctx, rgScope.ResourceGroup, hsmName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azureMHSMPrivateEndpointConnectionToSDPItem(conn, hsmName, *conn.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { KeyVaultManagedHSMsLookupByName, }, } } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.KeyVaultManagedHSM: true, azureshared.NetworkPrivateEndpoint: true, azureshared.ManagedIdentityUserAssignedIdentity: true, } } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) azureMHSMPrivateEndpointConnectionToSDPItem(conn *armkeyvault.MHSMPrivateEndpointConnection, hsmName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(conn, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(hsmName, connectionName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.KeyVaultManagedHSMPrivateEndpointConnection.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(conn.Tags), } // Health from provisioning state if conn.Properties != nil && conn.Properties.ProvisioningState != nil { state := strings.ToLower(string(*conn.Properties.ProvisioningState)) switch state { case "succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "creating", "updating", "deleting": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "failed": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Key Vault Managed HSM sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultManagedHSM.String(), Method: sdp.QueryMethod_GET, Query: hsmName, Scope: scope, }, }) // Link to Network Private Endpoint when present (may be in different resource group) if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { peID := *conn.Properties.PrivateEndpoint.ID peName := azureshared.ExtractResourceName(peID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } // Link to User Assigned Managed Identities (same pattern as KeyVaultManagedHSM adapter) // User Assigned Identities can be in a different resource group than the Managed HSM. if conn.Identity != nil && conn.Identity.UserAssignedIdentities != nil { for identityResourceID := range conn.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } return sdpItem, nil } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.KeyVault/managedHSMs/privateEndpointConnections/read", } } func (s keyvaultManagedHSMPrivateEndpointConnectionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/keyvault-managed-hsm-private-endpoint-connection_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockKeyVaultManagedHSMPrivateEndpointConnectionsPager struct { pages []armkeyvault.MHSMPrivateEndpointConnectionsClientListByResourceResponse index int } func (m *mockKeyVaultManagedHSMPrivateEndpointConnectionsPager) More() bool { return m.index < len(m.pages) } func (m *mockKeyVaultManagedHSMPrivateEndpointConnectionsPager) NextPage(ctx context.Context) (armkeyvault.MHSMPrivateEndpointConnectionsClientListByResourceResponse, error) { if m.index >= len(m.pages) { return armkeyvault.MHSMPrivateEndpointConnectionsClientListByResourceResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type testKeyVaultManagedHSMPrivateEndpointConnectionsClient struct { *mocks.MockKeyVaultManagedHSMPrivateEndpointConnectionsClient pager clients.KeyVaultManagedHSMPrivateEndpointConnectionsPager } func (t *testKeyVaultManagedHSMPrivateEndpointConnectionsClient) ListByResource(ctx context.Context, resourceGroupName, hsmName string) clients.KeyVaultManagedHSMPrivateEndpointConnectionsPager { return t.pager } func TestKeyVaultManagedHSMPrivateEndpointConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" hsmName := "test-hsm" connectionName := "test-pec" t.Run("Get", func(t *testing.T) { conn := createAzureMHSMPrivateEndpointConnection(connectionName, "") mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, connectionName).Return( armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse{ MHSMPrivateEndpointConnection: *conn, }, nil) testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hsmName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.KeyVaultManagedHSMPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultManagedHSMPrivateEndpointConnection, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(hsmName, connectionName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(hsmName, connectionName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) < 1 { t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) } foundKeyVaultManagedHSM := false for _, lq := range linkedQueries { if lq.GetQuery().GetType() == azureshared.KeyVaultManagedHSM.String() { foundKeyVaultManagedHSM = true if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected KeyVaultManagedHSM link method GET, got %v", lq.GetQuery().GetMethod()) } if lq.GetQuery().GetQuery() != hsmName { t.Errorf("Expected KeyVaultManagedHSM query %s, got %s", hsmName, lq.GetQuery().GetQuery()) } } } if !foundKeyVaultManagedHSM { t.Error("Expected linked query to KeyVaultManagedHSM") } }) }) t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { peID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe" conn := createAzureMHSMPrivateEndpointConnection(connectionName, peID) mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, connectionName).Return( armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse{ MHSMPrivateEndpointConnection: *conn, }, nil) testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hsmName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundPrivateEndpoint := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { foundPrivateEndpoint = true if lq.GetQuery().GetQuery() != "test-pe" { t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) } break } } if !foundPrivateEndpoint { t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], hsmName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { conn1 := createAzureMHSMPrivateEndpointConnection("pec-1", "") conn2 := createAzureMHSMPrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) mockPager := &mockKeyVaultManagedHSMPrivateEndpointConnectionsPager{ pages: []armkeyvault.MHSMPrivateEndpointConnectionsClientListByResourceResponse{ { MHSMPrivateEndpointConnectionsListResult: armkeyvault.MHSMPrivateEndpointConnectionsListResult{ Value: []*armkeyvault.MHSMPrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{ MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], hsmName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.KeyVaultManagedHSMPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultManagedHSMPrivateEndpointConnection, item.GetType()) } } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validConn := createAzureMHSMPrivateEndpointConnection("valid-pec", "") mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) mockPager := &mockKeyVaultManagedHSMPrivateEndpointConnectionsPager{ pages: []armkeyvault.MHSMPrivateEndpointConnectionsClientListByResourceResponse{ { MHSMPrivateEndpointConnectionsListResult: armkeyvault.MHSMPrivateEndpointConnectionsListResult{ Value: []*armkeyvault.MHSMPrivateEndpointConnection{ {Name: nil}, validConn, }, }, }, }, } testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{ MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], hsmName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(hsmName, "valid-pec") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(hsmName, "valid-pec"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("private endpoint connection not found") mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, "nonexistent-pec").Return( armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse{}, expectedErr) testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hsmName, "nonexistent-pec") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent private endpoint connection, but got nil") } }) t.Run("Get_WithUserAssignedIdentityLink", func(t *testing.T) { identityID := "/subscriptions/" + subscriptionID + "/resourceGroups/identity-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" conn := createAzureMHSMPrivateEndpointConnectionWithIdentity(connectionName, "", identityID) mockClient := mocks.NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, connectionName).Return( armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse{ MHSMPrivateEndpointConnection: *conn, }, nil) testClient := &testKeyVaultManagedHSMPrivateEndpointConnectionsClient{MockKeyVaultManagedHSMPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(hsmName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundIdentity := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.ManagedIdentityUserAssignedIdentity.String() { foundIdentity = true if lq.GetQuery().GetQuery() != "test-identity" { t.Errorf("Expected ManagedIdentityUserAssignedIdentity query 'test-identity', got %s", lq.GetQuery().GetQuery()) } if lq.GetQuery().GetScope() != subscriptionID+".identity-rg" { t.Errorf("Expected scope %s.identity-rg for identity in different RG, got %s", subscriptionID, lq.GetQuery().GetScope()) } } } if !foundIdentity { t.Error("Expected linked query to ManagedIdentityUserAssignedIdentity when Identity.UserAssignedIdentities is set") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewKeyVaultManagedHSMPrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.KeyVaultManagedHSM] { t.Error("Expected KeyVaultManagedHSM in PotentialLinks") } if !links[azureshared.NetworkPrivateEndpoint] { t.Error("Expected NetworkPrivateEndpoint in PotentialLinks") } if !links[azureshared.ManagedIdentityUserAssignedIdentity] { t.Error("Expected ManagedIdentityUserAssignedIdentity in PotentialLinks") } }) } func createAzureMHSMPrivateEndpointConnection(connectionName, privateEndpointID string) *armkeyvault.MHSMPrivateEndpointConnection { return createAzureMHSMPrivateEndpointConnectionWithIdentity(connectionName, privateEndpointID, "") } func createAzureMHSMPrivateEndpointConnectionWithIdentity(connectionName, privateEndpointID, identityResourceID string) *armkeyvault.MHSMPrivateEndpointConnection { state := armkeyvault.PrivateEndpointConnectionProvisioningStateSucceeded conn := &armkeyvault.MHSMPrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.KeyVault/managedHSMs/test-hsm/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.KeyVault/managedHSMs/privateEndpointConnections"), Properties: &armkeyvault.MHSMPrivateEndpointConnectionProperties{ ProvisioningState: &state, }, } if privateEndpointID != "" { conn.Properties.PrivateEndpoint = &armkeyvault.MHSMPrivateEndpoint{ ID: new(privateEndpointID), } } if identityResourceID != "" { conn.Identity = &armkeyvault.ManagedServiceIdentity{ Type: new(armkeyvault.ManagedServiceIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armkeyvault.UserAssignedIdentity{ identityResourceID: {}, }, } } return conn } ================================================ FILE: sources/azure/manual/keyvault-managed-hsm.go ================================================ package manual import ( "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var KeyVaultManagedHSMsLookupByName = shared.NewItemTypeLookup("name", azureshared.KeyVaultManagedHSM) type keyvaultManagedHSMsWrapper struct { client clients.ManagedHSMsClient *azureshared.MultiResourceGroupBase } func NewKeyVaultManagedHSM(client clients.ManagedHSMsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &keyvaultManagedHSMsWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.KeyVaultManagedHSM, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/keyvault/managedhsm/managed-hsms/list-by-resource-group?view=rest-keyvault-managedhsm-2024-11-01&tabs=HTTP func (k keyvaultManagedHSMsWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } pager := k.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } for _, hsm := range page.Value { if hsm.Name == nil { continue } item, sdpErr := k.azureManagedHSMToSDPItem(hsm, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (k keyvaultManagedHSMsWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } pager := k.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } for _, hsm := range page.Value { if hsm.Name == nil { continue } item, sdpErr := k.azureManagedHSMToSDPItem(hsm, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (k keyvaultManagedHSMsWrapper) azureManagedHSMToSDPItem(hsm *armkeyvault.ManagedHsm, scope string) (*sdp.Item, *sdp.QueryError) { if hsm.Name == nil { return nil, azureshared.QueryError(errors.New("name is nil"), scope, k.Type()) } attributes, err := shared.ToAttributesWithExclude(hsm, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } sdpItem := &sdp.Item{ Type: azureshared.KeyVaultManagedHSM.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(hsm.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to MHSM Private Endpoint Connections (child resources with their own GET endpoint) // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/managedhsm/mhsm-private-endpoint-connections/get // GET .../managedHSMs/{name}/privateEndpointConnections/{privateEndpointConnectionName} if hsm.Properties != nil && hsm.Properties.PrivateEndpointConnections != nil && hsm.Name != nil { for _, conn := range hsm.Properties.PrivateEndpointConnections { if conn != nil && conn.ID != nil && *conn.ID != "" { connectionName := azureshared.ExtractResourceName(*conn.ID) if connectionName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultManagedHSMPrivateEndpointConnection.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(*hsm.Name, connectionName), Scope: scope, }, }) } } } } // Link to Private Endpoints from Private Endpoint Connections // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/privateEndpoints/{privateEndpointName} // // IMPORTANT: Private Endpoints can be in a different resource group than the Managed HSM. // We must extract the subscription ID and resource group from the private endpoint's resource ID // to construct the correct scope. if hsm.Properties != nil && hsm.Properties.PrivateEndpointConnections != nil { for _, conn := range hsm.Properties.PrivateEndpointConnections { if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *conn.Properties.PrivateEndpoint.ID // Private Endpoint ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateEndpoints/{peName} params := azureshared.ExtractPathParamsFromResourceID(privateEndpointID, []string{"subscriptions", "resourceGroups"}) if len(params) >= 2 { subscriptionID := params[0] resourceGroupName := params[1] privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the private endpoint actually exists peScope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: peScope, // Use the private endpoint's scope, not the Managed HSM's scope }, }) } } } } } // Link to Virtual Network Subnets from Network ACLs // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{virtualNetworkName}/subnets/{subnetName} // // IMPORTANT: Virtual Network Subnets can be in a different resource group than the Managed HSM. // We must extract the subscription ID and resource group from the subnet's resource ID to construct // the correct scope. if hsm.Properties != nil && hsm.Properties.NetworkACLs != nil && hsm.Properties.NetworkACLs.VirtualNetworkRules != nil { for _, vnetRule := range hsm.Properties.NetworkACLs.VirtualNetworkRules { if vnetRule.ID != nil { subnetID := *vnetRule.ID // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName} // Extract subscription, resource group, virtual network name, and subnet name scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subscriptionID := scopeParams[0] resourceGroupName := scopeParams[1] vnetName := subnetParams[0] subnetName := subnetParams[1] // Subnet adapter requires: resourceGroup, virtualNetworkName, subnetName // Use composite lookup key to join them query := shared.CompositeLookupKey(vnetName, subnetName) // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the subnet actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, // Use the subnet's scope, not the Managed HSM's scope }, }) } } } } // Link to IP addresses (standard library) from NetworkACLs IPRules // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/managedhsm/managed-hsms/get?view=rest-keyvault-managedhsm-2024-11-01&tabs=HTTP if hsm.Properties != nil && hsm.Properties.NetworkACLs != nil && hsm.Properties.NetworkACLs.IPRules != nil { for _, ipRule := range hsm.Properties.NetworkACLs.IPRules { if ipRule != nil && ipRule.Value != nil && *ipRule.Value != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipRule.Value, Scope: "global", }, }) } } } // Link to User Assigned Managed Identities (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} // // IMPORTANT: User Assigned Identities can be in a different resource group than the Managed HSM. // We must extract the subscription ID and resource group from each identity's resource ID to construct the correct scope. if hsm.Identity != nil && hsm.Identity.UserAssignedIdentities != nil { for identityResourceID := range hsm.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Link to DNS name (standard library) from HsmURI // The HsmURI contains the Managed HSM endpoint URL (e.g., https://myhsm.managedhsm.azure.net) if hsm.Properties != nil && hsm.Properties.HsmURI != nil && *hsm.Properties.HsmURI != "" { hsmURI := *hsm.Properties.HsmURI // Extract DNS name from URL (e.g., https://myhsm.managedhsm.azure.net -> myhsm.managedhsm.azure.net) dnsName := azureshared.ExtractDNSFromURL(hsmURI) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } // Link to HTTP/HTTPS endpoint (standard library) from HsmURI sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: hsmURI, Scope: "global", }, }) } return sdpItem, nil } // ref: https://learn.microsoft.com/en-us/rest/api/keyvault/managedhsm/managed-hsms/get?view=rest-keyvault-managedhsm-2024-11-01&tabs=HTTP func (k keyvaultManagedHSMsWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Get requires 1 query part: name"), scope, k.Type()) } name := queryParts[0] if name == "" { return nil, azureshared.QueryError(errors.New("name cannot be empty"), scope, k.Type()) } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } resp, err := k.client.Get(ctx, rgScope.ResourceGroup, name, nil) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } return k.azureManagedHSMToSDPItem(&resp.ManagedHsm, scope) } func (k keyvaultManagedHSMsWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ KeyVaultManagedHSMsLookupByName, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault_managed_hardware_security_module func (k keyvaultManagedHSMsWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_key_vault_managed_hardware_security_module.name", }, } } func (k keyvaultManagedHSMsWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.KeyVaultManagedHSMPrivateEndpointConnection: true, azureshared.NetworkPrivateEndpoint: true, azureshared.NetworkSubnet: true, azureshared.ManagedIdentityUserAssignedIdentity: true, stdlib.NetworkDNS: true, stdlib.NetworkHTTP: true, stdlib.NetworkIP: true, } } func (k keyvaultManagedHSMsWrapper) IAMPermissions() []string { return []string{ "Microsoft.KeyVault/managedHSMs/read", } } func (k keyvaultManagedHSMsWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/keyvault-managed-hsm_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockManagedHSMsPager is a simple mock implementation of ManagedHSMsPager type mockManagedHSMsPager struct { pages []armkeyvault.ManagedHsmsClientListByResourceGroupResponse index int } func (m *mockManagedHSMsPager) More() bool { return m.index < len(m.pages) } func (m *mockManagedHSMsPager) NextPage(ctx context.Context) (armkeyvault.ManagedHsmsClientListByResourceGroupResponse, error) { if m.index >= len(m.pages) { return armkeyvault.ManagedHsmsClientListByResourceGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorManagedHSMsPager is a mock pager that always returns an error type errorManagedHSMsPager struct{} func (e *errorManagedHSMsPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorManagedHSMsPager) NextPage(ctx context.Context) (armkeyvault.ManagedHsmsClientListByResourceGroupResponse, error) { return armkeyvault.ManagedHsmsClientListByResourceGroupResponse{}, errors.New("pager error") } // testManagedHSMsClient wraps the mock to implement the correct interface type testManagedHSMsClient struct { *mocks.MockManagedHSMsClient pager clients.ManagedHSMsPager } func (t *testManagedHSMsClient) NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.ManagedHsmsClientListByResourceGroupOptions) clients.ManagedHSMsPager { // Call the mock to satisfy expectations t.MockManagedHSMsClient.NewListByResourceGroupPager(resourceGroupName, options) return t.pager } func TestKeyVaultManagedHSM(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" hsmName := "test-managed-hsm" t.Run("Get", func(t *testing.T) { hsm := createAzureManagedHSM(hsmName, subscriptionID, resourceGroup) mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, nil).Return( armkeyvault.ManagedHsmsClientGetResponse{ ManagedHsm: *hsm, }, nil) wrapper := manual.NewKeyVaultManagedHSM(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], hsmName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.KeyVaultManagedHSM.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultManagedHSM, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != hsmName { t.Errorf("Expected unique attribute value %s, got %s", hsmName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // MHSM Private Endpoint Connection (GET) - child resource ExpectedType: azureshared.KeyVaultManagedHSMPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(hsmName, "test-pec-1"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // MHSM Private Endpoint Connection (GET) - child resource ExpectedType: azureshared.KeyVaultManagedHSMPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(hsmName, "test-pec-2"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint (GET) - same resource group ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint (GET) - different resource group ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint-diff-rg", ExpectedScope: subscriptionID + ".different-rg", }, { // Subnet (GET) - same resource group ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Subnet (GET) - different resource group ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet-diff-rg", "test-subnet-diff-rg"), ExpectedScope: subscriptionID + ".different-rg", }, { // User Assigned Managed Identity (GET) - same resource group ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // User Assigned Managed Identity (GET) - different resource group ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity-diff-rg", ExpectedScope: subscriptionID + ".identity-rg", }, { // DNS (SEARCH) - from HsmURI ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: hsmName + ".managedhsm.azure.net", ExpectedScope: "global", }, { // HTTP (SEARCH) - from HsmURI ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://" + hsmName + ".managedhsm.azure.net", ExpectedScope: "global", }, { // IP (GET) - from NetworkACLs IPRules ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { // IP (GET) - from NetworkACLs IPRules (CIDR range) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.0/24", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockManagedHSMsClient(ctrl) wrapper := manual.NewKeyVaultManagedHSM(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting Managed HSM with empty name, but got nil") } }) t.Run("Get_NoName", func(t *testing.T) { hsm := &armkeyvault.ManagedHsm{ Name: nil, // No name field Properties: &armkeyvault.ManagedHsmProperties{ TenantID: new("test-tenant-id"), }, } mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, nil).Return( armkeyvault.ManagedHsmsClientGetResponse{ ManagedHsm: *hsm, }, nil) wrapper := manual.NewKeyVaultManagedHSM(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], hsmName, true) if qErr == nil { t.Error("Expected error when Managed HSM has no name, but got nil") } }) t.Run("Get_NoLinkedResources", func(t *testing.T) { hsm := createAzureManagedHSMMinimal(hsmName) mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, nil).Return( armkeyvault.ManagedHsmsClientGetResponse{ ManagedHsm: *hsm, }, nil) wrapper := manual.NewKeyVaultManagedHSM(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], hsmName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should have no linked item queries if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked item queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("List", func(t *testing.T) { hsm1 := createAzureManagedHSM("test-managed-hsm-1", subscriptionID, resourceGroup) hsm2 := createAzureManagedHSM("test-managed-hsm-2", subscriptionID, resourceGroup) mockPager := &mockManagedHSMsPager{ pages: []armkeyvault.ManagedHsmsClientListByResourceGroupResponse{ { ManagedHsmListResult: armkeyvault.ManagedHsmListResult{ Value: []*armkeyvault.ManagedHsm{hsm1, hsm2}, }, }, }, } mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) testClient := &testManagedHSMsClient{ MockManagedHSMsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultManagedHSM(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } // Verify first item if sdpItems[0].UniqueAttributeValue() != "test-managed-hsm-1" { t.Errorf("Expected first item name 'test-managed-hsm-1', got %s", sdpItems[0].UniqueAttributeValue()) } // Verify second item if sdpItems[1].UniqueAttributeValue() != "test-managed-hsm-2" { t.Errorf("Expected second item name 'test-managed-hsm-2', got %s", sdpItems[1].UniqueAttributeValue()) } }) t.Run("List_Error", func(t *testing.T) { errorPager := &errorManagedHSMsPager{} mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) testClient := &testManagedHSMsClient{ MockManagedHSMsClient: mockClient, pager: errorPager, } wrapper := manual.NewKeyVaultManagedHSM(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("List_SkipNilName", func(t *testing.T) { hsm1 := createAzureManagedHSM("test-managed-hsm-1", subscriptionID, resourceGroup) hsm2 := &armkeyvault.ManagedHsm{ Name: nil, // This should be skipped Properties: &armkeyvault.ManagedHsmProperties{ TenantID: new("test-tenant-id"), }, } hsm3 := createAzureManagedHSM("test-managed-hsm-3", subscriptionID, resourceGroup) mockPager := &mockManagedHSMsPager{ pages: []armkeyvault.ManagedHsmsClientListByResourceGroupResponse{ { ManagedHsmListResult: armkeyvault.ManagedHsmListResult{ Value: []*armkeyvault.ManagedHsm{hsm1, hsm2, hsm3}, }, }, }, } mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) testClient := &testManagedHSMsClient{ MockManagedHSMsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultManagedHSM(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only have 2 items (hsm2 with nil name should be skipped) if len(sdpItems) != 2 { t.Fatalf("Expected 2 items (skipping nil name), got: %d", len(sdpItems)) } }) t.Run("ListStream", func(t *testing.T) { hsm1 := createAzureManagedHSM("test-managed-hsm-1", subscriptionID, resourceGroup) hsm2 := createAzureManagedHSM("test-managed-hsm-2", subscriptionID, resourceGroup) mockPager := &mockManagedHSMsPager{ pages: []armkeyvault.ManagedHsmsClientListByResourceGroupResponse{ { ManagedHsmListResult: armkeyvault.ManagedHsmListResult{ Value: []*armkeyvault.ManagedHsm{hsm1, hsm2}, }, }, }, } mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) testClient := &testManagedHSMsClient{ MockManagedHSMsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultManagedHSM(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify first item if items[0].UniqueAttributeValue() != "test-managed-hsm-1" { t.Errorf("Expected first item name 'test-managed-hsm-1', got %s", items[0].UniqueAttributeValue()) } // Verify second item if items[1].UniqueAttributeValue() != "test-managed-hsm-2" { t.Errorf("Expected second item name 'test-managed-hsm-2', got %s", items[1].UniqueAttributeValue()) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream_Error", func(t *testing.T) { errorPager := &errorManagedHSMsPager{} mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) testClient := &testManagedHSMsClient{ MockManagedHSMsClient: mockClient, pager: errorPager, } wrapper := manual.NewKeyVaultManagedHSM(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(item *sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) if len(errs) == 0 { t.Error("Expected error when pager returns error, but got none") } }) t.Run("ListStream_SkipNilName", func(t *testing.T) { hsm1 := createAzureManagedHSM("test-managed-hsm-1", subscriptionID, resourceGroup) hsm2 := &armkeyvault.ManagedHsm{ Name: nil, // This should be skipped Properties: &armkeyvault.ManagedHsmProperties{ TenantID: new("test-tenant-id"), }, } hsm3 := createAzureManagedHSM("test-managed-hsm-3", subscriptionID, resourceGroup) mockPager := &mockManagedHSMsPager{ pages: []armkeyvault.ManagedHsmsClientListByResourceGroupResponse{ { ManagedHsmListResult: armkeyvault.ManagedHsmListResult{ Value: []*armkeyvault.ManagedHsm{hsm1, hsm2, hsm3}, }, }, }, } mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) testClient := &testManagedHSMsClient{ MockManagedHSMsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultManagedHSM(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we expect two items (hsm2 with nil name should be skipped) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } // Should only have 2 items (hsm2 with nil name should be skipped) if len(items) != 2 { t.Fatalf("Expected 2 items (skipping nil name), got: %d", len(items)) } // Verify items if items[0].UniqueAttributeValue() != "test-managed-hsm-1" { t.Errorf("Expected first item name 'test-managed-hsm-1', got %s", items[0].UniqueAttributeValue()) } if items[1].UniqueAttributeValue() != "test-managed-hsm-3" { t.Errorf("Expected second item name 'test-managed-hsm-3', got %s", items[1].UniqueAttributeValue()) } }) t.Run("Get_Error", func(t *testing.T) { mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, nil).Return( armkeyvault.ManagedHsmsClientGetResponse{}, errors.New("client error")) wrapper := manual.NewKeyVaultManagedHSM(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], hsmName, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("CrossResourceGroupScopes", func(t *testing.T) { // Test that linked resources in different resource groups use correct scopes hsm := createAzureManagedHSMCrossRG(hsmName, subscriptionID, resourceGroup) mockClient := mocks.NewMockManagedHSMsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, hsmName, nil).Return( armkeyvault.ManagedHsmsClientGetResponse{ ManagedHsm: *hsm, }, nil) wrapper := manual.NewKeyVaultManagedHSM(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], hsmName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked resources use their own scopes, not the Managed HSM's scope foundDifferentScope := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { scope := linkedQuery.GetQuery().GetScope() if scope != subscriptionID+"."+resourceGroup { foundDifferentScope = true // Verify the scope format is correct if scope != subscriptionID+".different-rg" && scope != subscriptionID+".identity-rg" { t.Errorf("Unexpected scope format: %s", scope) } } } if !foundDifferentScope { t.Error("Expected to find at least one linked item query with a different scope, but all used default scope") } }) } // createAzureManagedHSM creates a mock Azure Managed HSM with linked resources func createAzureManagedHSM(hsmName, subscriptionID, resourceGroup string) *armkeyvault.ManagedHsm { return &armkeyvault.ManagedHsm{ Name: new(hsmName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armkeyvault.ManagedHsmProperties{ TenantID: new("test-tenant-id"), HsmURI: new("https://" + hsmName + ".managedhsm.azure.net"), // Private Endpoint Connections (ID is the connection resource ID for child resource linking) PrivateEndpointConnections: []*armkeyvault.MHSMPrivateEndpointConnectionItem{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/managedHSMs/" + hsmName + "/privateEndpointConnections/test-pec-1"), Properties: &armkeyvault.MHSMPrivateEndpointConnectionProperties{ PrivateEndpoint: &armkeyvault.MHSMPrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-private-endpoint"), }, }, }, { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.KeyVault/managedHSMs/" + hsmName + "/privateEndpointConnections/test-pec-2"), Properties: &armkeyvault.MHSMPrivateEndpointConnectionProperties{ PrivateEndpoint: &armkeyvault.MHSMPrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/privateEndpoints/test-private-endpoint-diff-rg"), }, }, }, }, // Network ACLs with Virtual Network Rules and IP Rules NetworkACLs: &armkeyvault.MHSMNetworkRuleSet{ VirtualNetworkRules: []*armkeyvault.MHSMVirtualNetworkRule{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/virtualNetworks/test-vnet-diff-rg/subnets/test-subnet-diff-rg"), }, }, IPRules: []*armkeyvault.MHSMIPRule{ { Value: new("192.168.1.1"), }, { Value: new("10.0.0.0/24"), }, }, }, }, // User Assigned Identities Identity: &armkeyvault.ManagedServiceIdentity{ Type: new(armkeyvault.ManagedServiceIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armkeyvault.UserAssignedIdentity{ "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity": {}, "/subscriptions/" + subscriptionID + "/resourceGroups/identity-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity-diff-rg": {}, }, }, } } // createAzureManagedHSMMinimal creates a minimal mock Azure Managed HSM without linked resources func createAzureManagedHSMMinimal(hsmName string) *armkeyvault.ManagedHsm { return &armkeyvault.ManagedHsm{ Name: new(hsmName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armkeyvault.ManagedHsmProperties{ TenantID: new("test-tenant-id"), }, } } // createAzureManagedHSMCrossRG creates a mock Azure Managed HSM with linked resources in different resource groups func createAzureManagedHSMCrossRG(hsmName, subscriptionID, resourceGroup string) *armkeyvault.ManagedHsm { return &armkeyvault.ManagedHsm{ Name: new(hsmName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armkeyvault.ManagedHsmProperties{ TenantID: new("test-tenant-id"), // Private Endpoint in different resource group PrivateEndpointConnections: []*armkeyvault.MHSMPrivateEndpointConnectionItem{ { Properties: &armkeyvault.MHSMPrivateEndpointConnectionProperties{ PrivateEndpoint: &armkeyvault.MHSMPrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/privateEndpoints/test-pe-diff-rg"), }, }, }, }, // Subnet in different resource group NetworkACLs: &armkeyvault.MHSMNetworkRuleSet{ VirtualNetworkRules: []*armkeyvault.MHSMVirtualNetworkRule{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, }, }, // User Assigned Identity in different resource group Identity: &armkeyvault.ManagedServiceIdentity{ Type: new(armkeyvault.ManagedServiceIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armkeyvault.UserAssignedIdentity{ "/subscriptions/" + subscriptionID + "/resourceGroups/identity-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity-diff-rg": {}, }, }, } } ================================================ FILE: sources/azure/manual/keyvault-secret.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var KeyVaultSecretLookupByName = shared.NewItemTypeLookup("name", azureshared.KeyVaultSecret) type keyvaultSecretWrapper struct { client clients.SecretsClient *azureshared.MultiResourceGroupBase } func NewKeyVaultSecret(client clients.SecretsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &keyvaultSecretWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.KeyVaultSecret, ), } } func (k keyvaultSecretWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, azureshared.QueryError(errors.New("Get requires 2 query parts: vaultName and secretName"), scope, k.Type()) } vaultName := queryParts[0] if vaultName == "" { return nil, azureshared.QueryError(errors.New("vaultName cannot be empty"), scope, k.Type()) } secretName := queryParts[1] if secretName == "" { return nil, azureshared.QueryError(errors.New("secretName cannot be empty"), scope, k.Type()) } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } resp, err := k.client.Get(ctx, rgScope.ResourceGroup, vaultName, secretName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } return k.azureSecretToSDPItem(&resp.Secret, vaultName, secretName, scope) } // ref: https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secrets/get-secrets?view=rest-keyvault-secrets-2025-07-01&tabs=HTTP func (k keyvaultSecretWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Search requires 1 query part: vaultName"), scope, k.Type()) } vaultName := queryParts[0] if vaultName == "" { return nil, azureshared.QueryError(errors.New("vaultName cannot be empty"), scope, k.Type()) } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } pager := k.client.NewListPager(rgScope.ResourceGroup, vaultName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } for _, secret := range page.Value { if secret.Name == nil { continue } // Extract vault name from secret ID for composite key // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/vaults/{vaultName}/secrets/{secretName} var secretVaultName string if secret.ID != nil && *secret.ID != "" { vaultParams := azureshared.ExtractPathParamsFromResourceID(*secret.ID, []string{"vaults"}) if len(vaultParams) > 0 { secretVaultName = vaultParams[0] } } // Fallback to queryParts vaultName if extraction fails if secretVaultName == "" { secretVaultName = vaultName } item, sdpErr := k.azureSecretToSDPItem(secret, secretVaultName, *secret.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (k keyvaultSecretWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: vaultName"), scope, k.Type())) return } vaultName := queryParts[0] if vaultName == "" { stream.SendError(azureshared.QueryError(errors.New("vaultName cannot be empty"), scope, k.Type())) return } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } pager := k.client.NewListPager(rgScope.ResourceGroup, vaultName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } for _, secret := range page.Value { if secret.Name == nil { continue } var secretVaultName string if secret.ID != nil && *secret.ID != "" { vaultParams := azureshared.ExtractPathParamsFromResourceID(*secret.ID, []string{"vaults"}) if len(vaultParams) > 0 { secretVaultName = vaultParams[0] } } if secretVaultName == "" { secretVaultName = vaultName } item, sdpErr := k.azureSecretToSDPItem(secret, secretVaultName, *secret.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (k keyvaultSecretWrapper) azureSecretToSDPItem(secret *armkeyvault.Secret, vaultName, secretName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(secret, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } if secret.Name == nil { return nil, azureshared.QueryError(errors.New("secret name is nil"), scope, k.Type()) } // Set composite unique attribute to prevent collisions when secrets with the same name exist in different vaults err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(vaultName, secretName)) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } sdpItem := &sdp.Item{ Type: azureshared.KeyVaultSecret.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(secret.Tags), } // Link to parent Key Vault from ID // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName} // // IMPORTANT: The Key Vault can be in a different resource group than the secret's resource group. // We must extract the subscription ID and resource group from the secret's resource ID // to construct the correct scope. if secret.ID != nil && *secret.ID != "" { // Extract vault name from resource ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/vaults/{vaultName}/secrets/{secretName} vaultParams := azureshared.ExtractPathParamsFromResourceID(*secret.ID, []string{"vaults"}) if len(vaultParams) > 0 { vaultName := vaultParams[0] if vaultName != "" { // Extract scope from resource ID (subscription and resource group) linkedScope := azureshared.ExtractScopeFromResourceID(*secret.ID) if linkedScope == "" { // Fallback to default scope if extraction fails linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: linkedScope, // Use the vault's scope from the resource ID }, }) } } } // Link to DNS name and HTTP endpoints (standard library) from SecretURI and SecretURIWithVersion. // Both URIs share the same Key Vault hostname (e.g., myvault.vault.azure.net), so we add the DNS link only once. var linkedDNSName string if secret.Properties != nil && secret.Properties.SecretURI != nil && *secret.Properties.SecretURI != "" { secretURI := *secret.Properties.SecretURI dnsName := azureshared.ExtractDNSFromURL(secretURI) if dnsName != "" { linkedDNSName = dnsName sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: secretURI, Scope: "global", }, }) } // SecretURIWithVersion is the versioned URL; add HTTP link. Skip DNS link if same hostname already linked. if secret.Properties != nil && secret.Properties.SecretURIWithVersion != nil && *secret.Properties.SecretURIWithVersion != "" { secretURIWithVersion := *secret.Properties.SecretURIWithVersion dnsName := azureshared.ExtractDNSFromURL(secretURIWithVersion) if dnsName != "" && dnsName != linkedDNSName { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: secretURIWithVersion, Scope: "global", }, }) } return sdpItem, nil } func (k keyvaultSecretWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ KeyVaultVaultLookupByName, // First key: vault name (queryParts[0]) KeyVaultSecretLookupByName, // Second key: secret name (queryParts[1]) } } func (k keyvaultSecretWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { KeyVaultVaultLookupByName, }, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/ephemeral-resources/key_vault_secret func (k keyvaultSecretWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_key_vault_secret.id", }, } } func (k keyvaultSecretWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.KeyVaultVault, stdlib.NetworkDNS, stdlib.NetworkHTTP, ) } func (k keyvaultSecretWrapper) IAMPermissions() []string { return []string{ "Microsoft.KeyVault/vaults/secrets/read", } } func (k keyvaultSecretWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/keyvault-secret_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockSecretsPager is a simple mock implementation of SecretsPager type mockSecretsPager struct { pages []armkeyvault.SecretsClientListResponse index int } func (m *mockSecretsPager) More() bool { return m.index < len(m.pages) } func (m *mockSecretsPager) NextPage(ctx context.Context) (armkeyvault.SecretsClientListResponse, error) { if m.index >= len(m.pages) { return armkeyvault.SecretsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorSecretsPager is a mock pager that always returns an error type errorSecretsPager struct{} func (e *errorSecretsPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorSecretsPager) NextPage(ctx context.Context) (armkeyvault.SecretsClientListResponse, error) { return armkeyvault.SecretsClientListResponse{}, errors.New("pager error") } // testSecretsClient wraps the mock to implement the correct interface type testSecretsClient struct { *mocks.MockSecretsClient pager clients.SecretsPager } func (t *testSecretsClient) NewListPager(resourceGroupName, vaultName string, options *armkeyvault.SecretsClientListOptions) clients.SecretsPager { // Call the mock to satisfy expectations t.MockSecretsClient.NewListPager(resourceGroupName, vaultName, options) return t.pager } func TestKeyVaultSecret(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" vaultName := "test-keyvault" secretName := "test-secret" t.Run("Get", func(t *testing.T) { secret := createAzureSecret(secretName, subscriptionID, resourceGroup, vaultName) mockClient := mocks.NewMockSecretsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, secretName, nil).Return( armkeyvault.SecretsClientGetResponse{ Secret: *secret, }, nil) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires vaultName and secretName as query parts query := vaultName + shared.QuerySeparator + secretName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.KeyVaultSecret.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultSecret, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(vaultName, secretName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Key Vault (GET) - same resource group ExpectedType: azureshared.KeyVaultVault.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: vaultName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // stdlib.NetworkDNS from SecretURI hostname ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vaultName + ".vault.azure.net", ExpectedScope: "global", }, { // stdlib.NetworkHTTP from SecretURI ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("https://%s.vault.azure.net/secrets/%s", vaultName, secretName), ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only vault name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vaultName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Get_EmptyVaultName", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty vault name query := shared.QuerySeparator + secretName _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when vault name is empty, but got nil") } }) t.Run("Get_EmptySecretName", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty secret name query := vaultName + shared.QuerySeparator _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when secret name is empty, but got nil") } }) t.Run("Get_NoName", func(t *testing.T) { secret := &armkeyvault.Secret{ Name: nil, // No name field } mockClient := mocks.NewMockSecretsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, secretName, nil).Return( armkeyvault.SecretsClientGetResponse{ Secret: *secret, }, nil) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + secretName _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when secret has no name, but got nil") } }) t.Run("Get_NoLinkedResources", func(t *testing.T) { secret := createAzureSecretMinimal(secretName) mockClient := mocks.NewMockSecretsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, secretName, nil).Return( armkeyvault.SecretsClientGetResponse{ Secret: *secret, }, nil) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + secretName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should have no linked item queries when ID is nil or empty if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked item queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("Search", func(t *testing.T) { secret1 := createAzureSecret("secret-1", subscriptionID, resourceGroup, vaultName) secret2 := createAzureSecret("secret-2", subscriptionID, resourceGroup, vaultName) mockPager := &mockSecretsPager{ pages: []armkeyvault.SecretsClientListResponse{ { SecretListResult: armkeyvault.SecretListResult{ Value: []*armkeyvault.Secret{ { ID: secret1.ID, Name: secret1.Name, Type: secret1.Type, Properties: secret1.Properties, Tags: secret1.Tags, }, { ID: secret2.ID, Name: secret2.Name, Type: secret2.Type, Properties: secret2.Properties, Tags: secret2.Tags, }, }, }, }, }, } mockClient := mocks.NewMockSecretsClient(ctrl) mockClient.EXPECT().NewListPager(resourceGroup, vaultName, nil).Return(mockPager) testClient := &testSecretsClient{ MockSecretsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], vaultName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.KeyVaultSecret.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultSecret, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) testClient := &testSecretsClient{MockSecretsClient: mockClient} wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling List _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_EmptyVaultName", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) testClient := &testSecretsClient{MockSecretsClient: mockClient} wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with empty vault name _, qErr := wrapper.Search(ctx, "") if qErr == nil { t.Error("Expected error when vault name is empty, but got nil") } }) t.Run("Search_SecretWithNilName", func(t *testing.T) { validSecret := createAzureSecret("valid-secret", subscriptionID, resourceGroup, vaultName) mockPager := &mockSecretsPager{ pages: []armkeyvault.SecretsClientListResponse{ { SecretListResult: armkeyvault.SecretListResult{ Value: []*armkeyvault.Secret{ { // Secret with nil name should be skipped Name: nil, }, { ID: validSecret.ID, Name: validSecret.Name, Type: validSecret.Type, Properties: validSecret.Properties, Tags: validSecret.Tags, }, }, }, }, }, } mockClient := mocks.NewMockSecretsClient(ctrl) mockClient.EXPECT().NewListPager(resourceGroup, vaultName, nil).Return(mockPager) testClient := &testSecretsClient{ MockSecretsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], vaultName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a valid name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } expectedUniqueAttrValue := shared.CompositeLookupKey(vaultName, "valid-secret") if sdpItems[0].UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("secret not found") mockClient := mocks.NewMockSecretsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, "nonexistent-secret", nil).Return( armkeyvault.SecretsClientGetResponse{}, expectedErr) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + "nonexistent-secret" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent secret, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorSecretsPager{} mockClient.EXPECT().NewListPager(resourceGroup, vaultName, nil).Return(errorPager) testClient := &testSecretsClient{ MockSecretsClient: mockClient, pager: errorPager, } wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], vaultName, true) // The Search implementation should return an error when pager.NextPage returns an error // Errors from NextPage are converted to QueryError by the implementation if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) testClient := &testSecretsClient{MockSecretsClient: mockClient} wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements SearchableWrapper (it's returned as this type) if wrapper == nil { t.Error("Wrapper should not be nil") } // Verify adapter implements SearchableAdapter adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) testClient := &testSecretsClient{MockSecretsClient: mockClient} wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if len(links) == 0 { t.Error("Expected potential links to be defined") } if !links[azureshared.KeyVaultVault] { t.Error("Expected KeyVaultVault to be in potential links") } if !links[stdlib.NetworkDNS] { t.Error("Expected stdlib.NetworkDNS to be in potential links") } if !links[stdlib.NetworkHTTP] { t.Error("Expected stdlib.NetworkHTTP to be in potential links") } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) testClient := &testSecretsClient{MockSecretsClient: mockClient} wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) == 0 { t.Fatal("Expected TerraformMappings to be defined") } // Verify we have the correct mapping for azurerm_key_vault_secret.id foundIDMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_key_vault_secret.id" { foundIDMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected TerraformMethod to be SEARCH for id mapping, got %s", mapping.GetTerraformMethod()) } } } if !foundIDMapping { t.Error("Expected TerraformMappings to include 'azurerm_key_vault_secret.id' mapping") } // Verify we only have one mapping if len(mappings) != 1 { t.Errorf("Expected 1 TerraformMapping, got %d", len(mappings)) } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) testClient := &testSecretsClient{MockSecretsClient: mockClient} wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to be defined") } expectedPermission := "Microsoft.KeyVault/vaults/secrets/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } }) t.Run("PredefinedRole", func(t *testing.T) { mockClient := mocks.NewMockSecretsClient(ctrl) testClient := &testSecretsClient{MockSecretsClient: mockClient} wrapper := manual.NewKeyVaultSecret(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // PredefinedRole is available on the wrapper, not the interface // Use type assertion to access the concrete type type predefinedRoleInterface interface { PredefinedRole() string } if roleInterface, ok := wrapper.(predefinedRoleInterface); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } else { t.Error("Wrapper should implement PredefinedRole method") } }) t.Run("CrossResourceGroupScopes", func(t *testing.T) { // Test that linked resources in different resource groups use correct scopes // Secret ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/vaults/{vaultName}/secrets/{secretName} // The vault can be in a different resource group differentResourceGroup := "different-rg" secret := createAzureSecretCrossRG(secretName, subscriptionID, differentResourceGroup, vaultName) mockClient := mocks.NewMockSecretsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, secretName, nil).Return( armkeyvault.SecretsClientGetResponse{ Secret: *secret, }, nil) wrapper := manual.NewKeyVaultSecret(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := vaultName + shared.QuerySeparator + secretName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked vault uses its own scope, not the secret's resource group scope linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Fatalf("Expected 1 linked item query, got %d", len(linkedQueries)) } linkedQuery := linkedQueries[0] scope := linkedQuery.GetQuery().GetScope() expectedScope := fmt.Sprintf("%s.%s", subscriptionID, differentResourceGroup) if scope != expectedScope { t.Errorf("Expected linked vault scope to be %s, got %s", expectedScope, scope) } if linkedQuery.GetQuery().GetQuery() != vaultName { t.Errorf("Expected linked vault query to be %s, got %s", vaultName, linkedQuery.GetQuery().GetQuery()) } }) } // createAzureSecret creates a mock Azure Key Vault secret with linked vault func createAzureSecret(secretName, subscriptionID, resourceGroup, vaultName string) *armkeyvault.Secret { return &armkeyvault.Secret{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s/secrets/%s", subscriptionID, resourceGroup, vaultName, secretName)), Name: new(secretName), Type: new("Microsoft.KeyVault/vaults/secrets"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armkeyvault.SecretProperties{ Value: new("secret-value"), SecretURI: new(fmt.Sprintf("https://%s.vault.azure.net/secrets/%s", vaultName, secretName)), }, } } // createAzureSecretMinimal creates a minimal mock Azure Key Vault secret without ID (no linked resources) func createAzureSecretMinimal(secretName string) *armkeyvault.Secret { return &armkeyvault.Secret{ Name: new(secretName), Type: new("Microsoft.KeyVault/vaults/secrets"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armkeyvault.SecretProperties{ Value: new("secret-value"), }, } } // createAzureSecretCrossRG creates a mock Azure Key Vault secret with vault in a different resource group func createAzureSecretCrossRG(secretName, subscriptionID, vaultResourceGroup, vaultName string) *armkeyvault.Secret { return &armkeyvault.Secret{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s/secrets/%s", subscriptionID, vaultResourceGroup, vaultName, secretName)), Name: new(secretName), Type: new("Microsoft.KeyVault/vaults/secrets"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armkeyvault.SecretProperties{ Value: new("secret-value"), }, } } ================================================ FILE: sources/azure/manual/keyvault-vault.go ================================================ package manual import ( "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var KeyVaultVaultLookupByName = shared.NewItemTypeLookup("name", azureshared.KeyVaultVault) type keyvaultVaultWrapper struct { client clients.VaultsClient *azureshared.MultiResourceGroupBase } func NewKeyVaultVault(client clients.VaultsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &keyvaultVaultWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.KeyVaultVault, ), } } func (k keyvaultVaultWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } pager := k.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } for _, vault := range page.Value { if vault.Name == nil { continue } item, sdpErr := k.azureKeyVaultToSDPItem(vault, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (k keyvaultVaultWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } pager := k.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, k.Type())) return } for _, vault := range page.Value { if vault.Name == nil { continue } item, sdpErr := k.azureKeyVaultToSDPItem(vault, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (k keyvaultVaultWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Get requires 1 query part: vaultName"), scope, k.Type()) } vaultName := queryParts[0] if vaultName == "" { return nil, azureshared.QueryError(errors.New("vaultName cannot be empty"), scope, k.Type()) } rgScope, err := k.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } resp, err := k.client.Get(ctx, rgScope.ResourceGroup, vaultName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } return k.azureKeyVaultToSDPItem(&resp.Vault, scope) } func (k keyvaultVaultWrapper) azureKeyVaultToSDPItem(vault *armkeyvault.Vault, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(vault, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, k.Type()) } if vault.Name == nil { return nil, azureshared.QueryError(errors.New("vault name is nil"), scope, k.Type()) } sdpItem := &sdp.Item{ Type: azureshared.KeyVaultVault.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(vault.Tags), } // Child resources: list secrets and keys in this vault (Search by vault name) vaultName := *vault.Name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultSecret.String(), Method: sdp.QueryMethod_SEARCH, Query: vaultName, Scope: scope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_SEARCH, Query: vaultName, Scope: scope, }, }) // Link to Private Endpoints from Private Endpoint Connections // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/privateEndpoints/{privateEndpointName} // // IMPORTANT: Private Endpoints can be in a different resource group than the Key Vault. // We must extract the subscription ID and resource group from the private endpoint's resource ID // to construct the correct scope. if vault.Properties != nil && vault.Properties.PrivateEndpointConnections != nil { for _, conn := range vault.Properties.PrivateEndpointConnections { if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *conn.Properties.PrivateEndpoint.ID // Private Endpoint ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateEndpoints/{peName} params := azureshared.ExtractPathParamsFromResourceID(privateEndpointID, []string{"subscriptions", "resourceGroups"}) if len(params) >= 2 { subscriptionID := params[0] resourceGroupName := params[1] privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the private endpoint actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: scope, // Use the private endpoint's scope, not the vault's scope }, }) } } } } } // Link to Virtual Network Subnets from Network ACLs // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{virtualNetworkName}/subnets/{subnetName} // // IMPORTANT: Virtual Network Subnets can be in a different resource group than the Key Vault. // We must extract the subscription ID and resource group from the subnet's resource ID to construct // the correct scope. if vault.Properties != nil && vault.Properties.NetworkACLs != nil && vault.Properties.NetworkACLs.VirtualNetworkRules != nil { for _, vnetRule := range vault.Properties.NetworkACLs.VirtualNetworkRules { if vnetRule.ID != nil { subnetID := *vnetRule.ID // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName} // Extract subscription, resource group, virtual network name, and subnet name scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subscriptionID := scopeParams[0] resourceGroupName := scopeParams[1] vnetName := subnetParams[0] subnetName := subnetParams[1] // Subnet adapter requires: resourceGroup, virtualNetworkName, subnetName // Use composite lookup key to join them query := shared.CompositeLookupKey(vnetName, subnetName) // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the subnet actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, // Use the subnet's scope, not the vault's scope }, }) } } } } // Link to IP addresses (standard library) from NetworkACLs IPRules // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/vaults/get if vault.Properties != nil && vault.Properties.NetworkACLs != nil && vault.Properties.NetworkACLs.IPRules != nil { for _, ipRule := range vault.Properties.NetworkACLs.IPRules { if ipRule != nil && ipRule.Value != nil && *ipRule.Value != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipRule.Value, Scope: "global", }, }) } } } // Link to stdlib.NetworkHTTP for the vault URI (HTTPS endpoint for keys and secrets operations) if vault.Properties != nil && vault.Properties.VaultURI != nil && *vault.Properties.VaultURI != "" { vaultURI := *vault.Properties.VaultURI sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: vaultURI, Scope: "global", }, }) } // Link to Managed HSM from HsmPoolResourceID // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/managed-hsms/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/managedHSMs/{name} // // IMPORTANT: Managed HSM can be in a different resource group than the Key Vault. // We must extract the subscription ID and resource group from the HSM Pool resource ID // to construct the correct scope. if vault.Properties != nil && vault.Properties.HsmPoolResourceID != nil { hsmPoolResourceID := *vault.Properties.HsmPoolResourceID // HSM Pool Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/managedHSMs/{name} params := azureshared.ExtractPathParamsFromResourceID(hsmPoolResourceID, []string{"subscriptions", "resourceGroups"}) if len(params) >= 2 { subscriptionID := params[0] resourceGroupName := params[1] hsmName := azureshared.ExtractResourceName(hsmPoolResourceID) if hsmName != "" { // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the Managed HSM actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultManagedHSM.String(), Method: sdp.QueryMethod_GET, Query: hsmName, Scope: scope, // Use the Managed HSM's scope, not the vault's scope }, }) } } } return sdpItem, nil } func (k keyvaultVaultWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ KeyVaultVaultLookupByName, } } func (k keyvaultVaultWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_key_vault.name", }, } } func (k keyvaultVaultWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.KeyVaultSecret, azureshared.KeyVaultKey, azureshared.NetworkPrivateEndpoint, azureshared.NetworkSubnet, azureshared.KeyVaultManagedHSM, stdlib.NetworkIP, stdlib.NetworkHTTP, ) } func (k keyvaultVaultWrapper) IAMPermissions() []string { return []string{ "Microsoft.KeyVault/vaults/*/read", } } // Reference: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-reader func (k keyvaultVaultWrapper) PredefinedRole() string { return "Key Vault Reader" } ================================================ FILE: sources/azure/manual/keyvault-vault_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockVaultsPager is a simple mock implementation of VaultsPager type mockVaultsPager struct { pages []armkeyvault.VaultsClientListByResourceGroupResponse index int } func (m *mockVaultsPager) More() bool { return m.index < len(m.pages) } func (m *mockVaultsPager) NextPage(ctx context.Context) (armkeyvault.VaultsClientListByResourceGroupResponse, error) { if m.index >= len(m.pages) { return armkeyvault.VaultsClientListByResourceGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorVaultsPager is a mock pager that always returns an error type errorVaultsPager struct{} func (e *errorVaultsPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorVaultsPager) NextPage(ctx context.Context) (armkeyvault.VaultsClientListByResourceGroupResponse, error) { return armkeyvault.VaultsClientListByResourceGroupResponse{}, errors.New("pager error") } // testVaultsClient wraps the mock to implement the correct interface type testVaultsClient struct { *mocks.MockVaultsClient pager clients.VaultsPager } func (t *testVaultsClient) NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.VaultsClientListByResourceGroupOptions) clients.VaultsPager { // Call the mock to satisfy expectations t.MockVaultsClient.NewListByResourceGroupPager(resourceGroupName, options) return t.pager } func TestKeyVaultVault(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" vaultName := "test-keyvault" t.Run("Get", func(t *testing.T) { vault := createAzureKeyVault(vaultName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVaultsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, nil).Return( armkeyvault.VaultsClientGetResponse{ Vault: *vault, }, nil) wrapper := manual.NewKeyVaultVault(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vaultName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.KeyVaultVault.String() { t.Errorf("Expected type %s, got %s", azureshared.KeyVaultVault, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != vaultName { t.Errorf("Expected unique attribute value %s, got %s", vaultName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Child resources: secrets in this vault (SEARCH by vault name) ExpectedType: azureshared.KeyVaultSecret.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vaultName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Child resources: keys in this vault (SEARCH by vault name) ExpectedType: azureshared.KeyVaultKey.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vaultName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint (GET) - same resource group ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private Endpoint (GET) - different resource group ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-private-endpoint-diff-rg", ExpectedScope: subscriptionID + ".different-rg", }, { // Subnet (GET) - same resource group ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Subnet (GET) - different resource group ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet-diff-rg", "test-subnet-diff-rg"), ExpectedScope: subscriptionID + ".different-rg", }, { // Managed HSM (GET) - different resource group ExpectedType: azureshared.KeyVaultManagedHSM.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-managed-hsm", ExpectedScope: subscriptionID + ".hsm-rg", }, { // stdlib.NetworkIP (GET) - from NetworkACLs IPRules ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.100", ExpectedScope: "global", }, { // stdlib.NetworkIP (GET) - from NetworkACLs IPRules (CIDR range) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.0/24", ExpectedScope: "global", }, { // stdlib.NetworkHTTP (SEARCH) - from VaultURI ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://test-keyvault.vault.azure.net/", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVaultsClient(ctrl) wrapper := manual.NewKeyVaultVault(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting vault with empty name, but got nil") } }) t.Run("Get_NoName", func(t *testing.T) { vault := &armkeyvault.Vault{ Name: nil, // No name field Properties: &armkeyvault.VaultProperties{ TenantID: new("test-tenant-id"), }, } mockClient := mocks.NewMockVaultsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, nil).Return( armkeyvault.VaultsClientGetResponse{ Vault: *vault, }, nil) wrapper := manual.NewKeyVaultVault(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vaultName, true) if qErr == nil { t.Error("Expected error when vault has no name, but got nil") } }) t.Run("Get_NoLinkedResources", func(t *testing.T) { vault := createAzureKeyVaultMinimal(vaultName) mockClient := mocks.NewMockVaultsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, nil).Return( armkeyvault.VaultsClientGetResponse{ Vault: *vault, }, nil) wrapper := manual.NewKeyVaultVault(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vaultName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should only have the child SEARCH links (secrets and keys in vault); no private endpoints, subnets, etc. if len(sdpItem.GetLinkedItemQueries()) != 2 { t.Errorf("Expected 2 linked item queries (KeyVaultSecret and KeyVaultKey SEARCH), got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("List", func(t *testing.T) { vault1 := createAzureKeyVault("test-keyvault-1", subscriptionID, resourceGroup) vault2 := createAzureKeyVault("test-keyvault-2", subscriptionID, resourceGroup) mockPager := &mockVaultsPager{ pages: []armkeyvault.VaultsClientListByResourceGroupResponse{ { VaultListResult: armkeyvault.VaultListResult{ Value: []*armkeyvault.Vault{vault1, vault2}, }, }, }, } mockClient := mocks.NewMockVaultsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) testClient := &testVaultsClient{ MockVaultsClient: mockClient, pager: mockPager, } wrapper := manual.NewKeyVaultVault(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } // Verify first item if sdpItems[0].UniqueAttributeValue() != "test-keyvault-1" { t.Errorf("Expected first item name 'test-keyvault-1', got %s", sdpItems[0].UniqueAttributeValue()) } // Verify second item if sdpItems[1].UniqueAttributeValue() != "test-keyvault-2" { t.Errorf("Expected second item name 'test-keyvault-2', got %s", sdpItems[1].UniqueAttributeValue()) } }) t.Run("List_Error", func(t *testing.T) { errorPager := &errorVaultsPager{} mockClient := mocks.NewMockVaultsClient(ctrl) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(errorPager) testClient := &testVaultsClient{ MockVaultsClient: mockClient, pager: errorPager, } wrapper := manual.NewKeyVaultVault(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("Get_Error", func(t *testing.T) { mockClient := mocks.NewMockVaultsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, nil).Return( armkeyvault.VaultsClientGetResponse{}, errors.New("client error")) wrapper := manual.NewKeyVaultVault(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vaultName, true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("CrossResourceGroupScopes", func(t *testing.T) { // Test that linked resources in different resource groups use correct scopes vault := createAzureKeyVaultCrossRG(vaultName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVaultsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vaultName, nil).Return( armkeyvault.VaultsClientGetResponse{ Vault: *vault, }, nil) wrapper := manual.NewKeyVaultVault(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vaultName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that linked resources use their own scopes, not the vault's scope foundDifferentScope := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { scope := linkedQuery.GetQuery().GetScope() if scope != subscriptionID+"."+resourceGroup { foundDifferentScope = true // Verify the scope format is correct if scope != subscriptionID+".different-rg" && scope != subscriptionID+".hsm-rg" { t.Errorf("Unexpected scope format: %s", scope) } } } if !foundDifferentScope { t.Error("Expected to find at least one linked item query with a different scope, but all used default scope") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockVaultsClient(ctrl) wrapper := manual.NewKeyVaultVault(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if len(links) == 0 { t.Error("Expected potential links to be defined") } expectedLinks := map[shared.ItemType]bool{ azureshared.KeyVaultSecret: true, azureshared.KeyVaultKey: true, azureshared.NetworkPrivateEndpoint: true, azureshared.NetworkSubnet: true, azureshared.KeyVaultManagedHSM: true, stdlib.NetworkIP: true, stdlib.NetworkHTTP: true, } for expectedType, expectedValue := range expectedLinks { if links[expectedType] != expectedValue { t.Errorf("Expected PotentialLinks[%s] = %v, got %v", expectedType.String(), expectedValue, links[expectedType]) } } }) } // createAzureKeyVault creates a mock Azure Key Vault with linked resources func createAzureKeyVault(vaultName, subscriptionID, resourceGroup string) *armkeyvault.Vault { return &armkeyvault.Vault{ Name: new(vaultName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armkeyvault.VaultProperties{ TenantID: new("test-tenant-id"), // Private Endpoint Connections PrivateEndpointConnections: []*armkeyvault.PrivateEndpointConnectionItem{ { Properties: &armkeyvault.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armkeyvault.PrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-private-endpoint"), }, }, }, { Properties: &armkeyvault.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armkeyvault.PrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/privateEndpoints/test-private-endpoint-diff-rg"), }, }, }, }, // Network ACLs with Virtual Network Rules and IP Rules NetworkACLs: &armkeyvault.NetworkRuleSet{ VirtualNetworkRules: []*armkeyvault.VirtualNetworkRule{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/virtualNetworks/test-vnet-diff-rg/subnets/test-subnet-diff-rg"), }, }, IPRules: []*armkeyvault.IPRule{ {Value: new("192.168.1.100")}, {Value: new("10.0.0.0/24")}, }, }, // Vault URI for keys and secrets operations VaultURI: new("https://" + vaultName + ".vault.azure.net/"), // Managed HSM Pool Resource ID HsmPoolResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/hsm-rg/providers/Microsoft.KeyVault/managedHSMs/test-managed-hsm"), }, } } // createAzureKeyVaultMinimal creates a minimal mock Azure Key Vault without linked resources func createAzureKeyVaultMinimal(vaultName string) *armkeyvault.Vault { return &armkeyvault.Vault{ Name: new(vaultName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armkeyvault.VaultProperties{ TenantID: new("test-tenant-id"), }, } } // createAzureKeyVaultCrossRG creates a mock Azure Key Vault with linked resources in different resource groups func createAzureKeyVaultCrossRG(vaultName, subscriptionID, resourceGroup string) *armkeyvault.Vault { return &armkeyvault.Vault{ Name: new(vaultName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armkeyvault.VaultProperties{ TenantID: new("test-tenant-id"), // Private Endpoint in different resource group PrivateEndpointConnections: []*armkeyvault.PrivateEndpointConnectionItem{ { Properties: &armkeyvault.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armkeyvault.PrivateEndpoint{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/privateEndpoints/test-pe-diff-rg"), }, }, }, }, // Subnet in different resource group NetworkACLs: &armkeyvault.NetworkRuleSet{ VirtualNetworkRules: []*armkeyvault.VirtualNetworkRule{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, }, // Managed HSM in different resource group HsmPoolResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/hsm-rg/providers/Microsoft.KeyVault/managedHSMs/test-managed-hsm"), }, } } ================================================ FILE: sources/azure/manual/links_helpers.go ================================================ package manual import ( "net" "slices" "strings" "github.com/overmindtech/cli/go/sdp-go" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/stdlib" ) // appendLinkIfValid appends a linked item query when the value passes validation. // Skips empty strings and any value in skipValues. If createQuery returns a non-nil query, it is appended. // Use this for reusable link-creation logic with configurable skip rules (e.g. DNS servers, IP/CIDR prefixes). func appendLinkIfValid( queries *[]*sdp.LinkedItemQuery, value string, skipValues []string, createQuery func(string) *sdp.LinkedItemQuery, ) { if value == "" { return } if slices.Contains(skipValues, value) { return } if q := createQuery(value); q != nil { *queries = append(*queries, q) } } // AppendURILinks appends linked item queries for a URI: HTTP link plus DNS or IP link from the host (with deduplication). // It mutates linkedItemQueries and the dedupe maps. Skips empty or non-http(s) URIs. func AppendURILinks( linkedItemQueries *[]*sdp.LinkedItemQuery, uri string, linkedDNSHostnames map[string]struct{}, seenIPs map[string]struct{}, ) { if uri == "" || (!strings.HasPrefix(uri, "http://") && !strings.HasPrefix(uri, "https://")) { return } *linkedItemQueries = append(*linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: uri, Scope: "global", }, }) hostFromURL := azureshared.ExtractDNSFromURL(uri) if hostFromURL != "" { hostOnly := hostFromURL if h, _, err := net.SplitHostPort(hostFromURL); err == nil { hostOnly = h } if net.ParseIP(hostOnly) != nil { if _, seen := seenIPs[hostOnly]; !seen { seenIPs[hostOnly] = struct{}{} *linkedItemQueries = append(*linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: hostOnly, Scope: "global", }, }) } } else { if _, seen := linkedDNSHostnames[hostOnly]; !seen { linkedDNSHostnames[hostOnly] = struct{}{} *linkedItemQueries = append(*linkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: hostOnly, Scope: "global", }, }) } } } } // networkIPQuery returns a linked item query for stdlib.NetworkIP. func networkIPQuery(query string) *sdp.LinkedItemQuery { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: "global", }, } } ================================================ FILE: sources/azure/manual/maintenance-maintenance-configuration.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var MaintenanceMaintenanceConfigurationLookupByName = shared.NewItemTypeLookup("name", azureshared.MaintenanceMaintenanceConfiguration) type maintenanceMaintenanceConfigurationWrapper struct { client clients.MaintenanceConfigurationClient *azureshared.MultiResourceGroupBase } func NewMaintenanceMaintenanceConfiguration(client clients.MaintenanceConfigurationClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &maintenanceMaintenanceConfigurationWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, azureshared.MaintenanceMaintenanceConfiguration, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/maintenance/maintenance-configurations-for-resource-group/list func (c maintenanceMaintenanceConfigurationWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, config := range page.Value { if config.Name == nil { continue } item, sdpErr := c.azureMaintenanceConfigurationToSDPItem(config, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c maintenanceMaintenanceConfigurationWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, config := range page.Value { if config.Name == nil { continue } var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = c.azureMaintenanceConfigurationToSDPItem(config, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/maintenance/maintenance-configurations/get func (c maintenanceMaintenanceConfigurationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the maintenance configuration name"), scope, c.Type()) } configName := queryParts[0] if configName == "" { return nil, azureshared.QueryError(errors.New("configName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } result, err := c.client.Get(ctx, rgScope.ResourceGroup, configName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureMaintenanceConfigurationToSDPItem(&result.Configuration, scope) } func (c maintenanceMaintenanceConfigurationWrapper) azureMaintenanceConfigurationToSDPItem(config *armmaintenance.Configuration, scope string) (*sdp.Item, *sdp.QueryError) { if config.Name == nil { return nil, azureshared.QueryError(errors.New("maintenance configuration name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(config, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.MaintenanceMaintenanceConfiguration.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(config.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } return sdpItem, nil } func (c maintenanceMaintenanceConfigurationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ MaintenanceMaintenanceConfigurationLookupByName, } } func (c maintenanceMaintenanceConfigurationWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet() } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftmaintenance func (c maintenanceMaintenanceConfigurationWrapper) IAMPermissions() []string { return []string{ "Microsoft.Maintenance/maintenanceConfigurations/read", } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles func (c maintenanceMaintenanceConfigurationWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/maintenance-maintenance-configuration_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestMaintenanceMaintenanceConfiguration(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { configName := "test-maintenance-config" config := createMaintenanceConfiguration(configName) mockClient := mocks.NewMockMaintenanceConfigurationClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, configName, nil).Return( armmaintenance.ConfigurationsClientGetResponse{ Configuration: *config, }, nil) wrapper := manual.NewMaintenanceMaintenanceConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], configName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.MaintenanceMaintenanceConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.MaintenanceMaintenanceConfiguration, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != configName { t.Errorf("Expected unique attribute value %s, got %s", configName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { config1 := createMaintenanceConfiguration("test-config-1") config2 := createMaintenanceConfiguration("test-config-2") mockClient := mocks.NewMockMaintenanceConfigurationClient(ctrl) mockPager := newMockMaintenanceConfigurationPager(ctrl, []*armmaintenance.Configuration{config1, config2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewMaintenanceMaintenanceConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { config1 := createMaintenanceConfiguration("test-config-1") config2 := createMaintenanceConfiguration("test-config-2") mockClient := mocks.NewMockMaintenanceConfigurationClient(ctrl) mockPager := newMockMaintenanceConfigurationPager(ctrl, []*armmaintenance.Configuration{config1, config2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewMaintenanceMaintenanceConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("resource not found") mockClient := mocks.NewMockMaintenanceConfigurationClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", nil).Return( armmaintenance.ConfigurationsClientGetResponse{}, expectedErr) wrapper := manual.NewMaintenanceMaintenanceConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent", true) if qErr == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockMaintenanceConfigurationClient(ctrl) wrapper := manual.NewMaintenanceMaintenanceConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting resource with empty name, but got nil") } }) t.Run("ListWithNilName", func(t *testing.T) { config1 := createMaintenanceConfiguration("test-config-1") configNilName := &armmaintenance.Configuration{ ID: new(string), Name: nil, Location: new(string), } mockClient := mocks.NewMockMaintenanceConfigurationClient(ctrl) mockPager := newMockMaintenanceConfigurationPager(ctrl, []*armmaintenance.Configuration{config1, configNilName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewMaintenanceMaintenanceConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name should be skipped), got: %d", len(sdpItems)) } }) } func createMaintenanceConfiguration(name string) *armmaintenance.Configuration { location := "eastus" maintenanceScope := armmaintenance.MaintenanceScopeHost visibility := armmaintenance.VisibilityCustom configID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Maintenance/maintenanceConfigurations/" + name return &armmaintenance.Configuration{ ID: &configID, Name: &name, Location: &location, Type: new("Microsoft.Maintenance/maintenanceConfigurations"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armmaintenance.ConfigurationProperties{ MaintenanceScope: &maintenanceScope, Visibility: &visibility, Namespace: new("Microsoft.Compute"), MaintenanceWindow: &armmaintenance.Window{ StartDateTime: new("2025-01-01 00:00"), Duration: new("02:00"), TimeZone: new("Pacific Standard Time"), RecurEvery: new("Day"), }, }, } } type mockMaintenanceConfigurationPager struct { ctrl *gomock.Controller items []*armmaintenance.Configuration index int more bool } func newMockMaintenanceConfigurationPager(ctrl *gomock.Controller, items []*armmaintenance.Configuration) clients.MaintenanceConfigurationPager { return &mockMaintenanceConfigurationPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockMaintenanceConfigurationPager) More() bool { return m.more } func (m *mockMaintenanceConfigurationPager) NextPage(ctx context.Context) (armmaintenance.ConfigurationsForResourceGroupClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armmaintenance.ConfigurationsForResourceGroupClientListResponse{ ListMaintenanceConfigurationsResult: armmaintenance.ListMaintenanceConfigurationsResult{ Value: []*armmaintenance.Configuration{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armmaintenance.ConfigurationsForResourceGroupClientListResponse{ ListMaintenanceConfigurationsResult: armmaintenance.ListMaintenanceConfigurationsResult{ Value: []*armmaintenance.Configuration{item}, }, }, nil } ================================================ FILE: sources/azure/manual/managedidentity-federated-identity-credential.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ManagedIdentityFederatedIdentityCredentialLookupByName = shared.NewItemTypeLookup("name", azureshared.ManagedIdentityFederatedIdentityCredential) type managedIdentityFederatedIdentityCredentialWrapper struct { client clients.FederatedIdentityCredentialsClient *azureshared.MultiResourceGroupBase } func NewManagedIdentityFederatedIdentityCredential(client clients.FederatedIdentityCredentialsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &managedIdentityFederatedIdentityCredentialWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.ManagedIdentityFederatedIdentityCredential, ), } } func (m managedIdentityFederatedIdentityCredentialWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: identityName and federatedCredentialName", Scope: scope, ItemType: m.Type(), } } identityName := queryParts[0] if identityName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "identityName cannot be empty", Scope: scope, ItemType: m.Type(), } } federatedCredentialName := queryParts[1] if federatedCredentialName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "federatedCredentialName cannot be empty", Scope: scope, ItemType: m.Type(), } } rgScope, err := m.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } resp, err := m.client.Get(ctx, rgScope.ResourceGroup, identityName, federatedCredentialName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } return m.azureFederatedIdentityCredentialToSDPItem(&resp.FederatedIdentityCredential, identityName, federatedCredentialName, scope) } func (m managedIdentityFederatedIdentityCredentialWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ManagedIdentityUserAssignedIdentityLookupByName, ManagedIdentityFederatedIdentityCredentialLookupByName, } } func (m managedIdentityFederatedIdentityCredentialWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: identityName", Scope: scope, ItemType: m.Type(), } } identityName := queryParts[0] if identityName == "" { return nil, azureshared.QueryError(errors.New("identityName cannot be empty"), scope, m.Type()) } rgScope, err := m.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } pager := m.client.NewListPager(rgScope.ResourceGroup, identityName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } for _, credential := range page.Value { if credential.Name == nil { continue } item, sdpErr := m.azureFederatedIdentityCredentialToSDPItem(credential, identityName, *credential.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (m managedIdentityFederatedIdentityCredentialWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: identityName"), scope, m.Type())) return } identityName := queryParts[0] if identityName == "" { stream.SendError(azureshared.QueryError(errors.New("identityName cannot be empty"), scope, m.Type())) return } rgScope, err := m.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, m.Type())) return } pager := m.client.NewListPager(rgScope.ResourceGroup, identityName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, m.Type())) return } for _, credential := range page.Value { if credential.Name == nil { continue } item, sdpErr := m.azureFederatedIdentityCredentialToSDPItem(credential, identityName, *credential.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (m managedIdentityFederatedIdentityCredentialWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ManagedIdentityUserAssignedIdentityLookupByName, }, } } func (m managedIdentityFederatedIdentityCredentialWrapper) azureFederatedIdentityCredentialToSDPItem(credential *armmsi.FederatedIdentityCredential, identityName, credentialName, scope string) (*sdp.Item, *sdp.QueryError) { if credential.Name == nil { return nil, azureshared.QueryError(errors.New("credential name is nil"), scope, m.Type()) } attributes, err := shared.ToAttributesWithExclude(credential) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(identityName, credentialName)) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ManagedIdentityFederatedIdentityCredential.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link back to the parent user assigned identity sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: scope, }, }) // Link to DNS hostname from Issuer URL (e.g., https://token.actions.githubusercontent.com) // The Issuer is the URL of the external identity provider if credential.Properties != nil && credential.Properties.Issuer != nil && *credential.Properties.Issuer != "" { dnsName := azureshared.ExtractDNSFromURL(*credential.Properties.Issuer) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } return sdpItem, nil } func (m managedIdentityFederatedIdentityCredentialWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ManagedIdentityUserAssignedIdentity: true, stdlib.NetworkDNS: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/identity#microsoftmanagedidentity func (m managedIdentityFederatedIdentityCredentialWrapper) IAMPermissions() []string { return []string{ "Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials/read", } } func (m managedIdentityFederatedIdentityCredentialWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/managedidentity-federated-identity-credential_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockFederatedIdentityCredentialsPager is a simple mock implementation of FederatedIdentityCredentialsPager type mockFederatedIdentityCredentialsPager struct { pages []armmsi.FederatedIdentityCredentialsClientListResponse index int } func (m *mockFederatedIdentityCredentialsPager) More() bool { return m.index < len(m.pages) } func (m *mockFederatedIdentityCredentialsPager) NextPage(ctx context.Context) (armmsi.FederatedIdentityCredentialsClientListResponse, error) { if m.index >= len(m.pages) { return armmsi.FederatedIdentityCredentialsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorFederatedIdentityCredentialsPager is a mock pager that always returns an error type errorFederatedIdentityCredentialsPager struct{} func (e *errorFederatedIdentityCredentialsPager) More() bool { return true } func (e *errorFederatedIdentityCredentialsPager) NextPage(ctx context.Context) (armmsi.FederatedIdentityCredentialsClientListResponse, error) { return armmsi.FederatedIdentityCredentialsClientListResponse{}, errors.New("pager error") } // testFederatedIdentityCredentialsClient wraps the mock to implement the correct interface type testFederatedIdentityCredentialsClient struct { *mocks.MockFederatedIdentityCredentialsClient pager clients.FederatedIdentityCredentialsPager } func (t *testFederatedIdentityCredentialsClient) NewListPager(resourceGroupName string, resourceName string, options *armmsi.FederatedIdentityCredentialsClientListOptions) clients.FederatedIdentityCredentialsPager { return t.pager } func TestManagedIdentityFederatedIdentityCredential(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" identityName := "test-identity" credentialName := "test-credential" t.Run("Get", func(t *testing.T) { credential := createAzureFederatedIdentityCredential(credentialName) mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, identityName, credentialName, nil).Return( armmsi.FederatedIdentityCredentialsClientGetResponse{ FederatedIdentityCredential: *credential, }, nil) testClient := &testFederatedIdentityCredentialsClient{MockFederatedIdentityCredentialsClient: mockClient} wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(identityName, credentialName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ManagedIdentityFederatedIdentityCredential.String() { t.Errorf("Expected type %s, got %s", azureshared.ManagedIdentityFederatedIdentityCredential, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(identityName, credentialName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(identityName, credentialName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 2 { t.Fatalf("Expected 2 linked queries, got: %d", len(linkedQueries)) } queryTests := shared.QueryTests{ { ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: identityName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "token.actions.githubusercontent.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) testClient := &testFederatedIdentityCredentialsClient{MockFederatedIdentityCredentialsClient: mockClient} wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], identityName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyIdentityName", func(t *testing.T) { mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) testClient := &testFederatedIdentityCredentialsClient{MockFederatedIdentityCredentialsClient: mockClient} wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", credentialName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting with empty identity name, but got nil") } }) t.Run("GetWithEmptyCredentialName", func(t *testing.T) { mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) testClient := &testFederatedIdentityCredentialsClient{MockFederatedIdentityCredentialsClient: mockClient} wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(identityName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting with empty credential name, but got nil") } }) t.Run("Search", func(t *testing.T) { credential1 := createAzureFederatedIdentityCredential("credential-1") credential2 := createAzureFederatedIdentityCredential("credential-2") mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) mockPager := &mockFederatedIdentityCredentialsPager{ pages: []armmsi.FederatedIdentityCredentialsClientListResponse{ { FederatedIdentityCredentialsListResult: armmsi.FederatedIdentityCredentialsListResult{ Value: []*armmsi.FederatedIdentityCredential{credential1, credential2}, }, }, }, } testClient := &testFederatedIdentityCredentialsClient{ MockFederatedIdentityCredentialsClient: mockClient, pager: mockPager, } wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], identityName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.ManagedIdentityFederatedIdentityCredential.String() { t.Errorf("Expected type %s, got %s", azureshared.ManagedIdentityFederatedIdentityCredential, item.GetType()) } } }) t.Run("SearchStream", func(t *testing.T) { credential1 := createAzureFederatedIdentityCredential("credential-1") credential2 := createAzureFederatedIdentityCredential("credential-2") mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) mockPager := &mockFederatedIdentityCredentialsPager{ pages: []armmsi.FederatedIdentityCredentialsClientListResponse{ { FederatedIdentityCredentialsListResult: armmsi.FederatedIdentityCredentialsListResult{ Value: []*armmsi.FederatedIdentityCredential{credential1, credential2}, }, }, }, } testClient := &testFederatedIdentityCredentialsClient{ MockFederatedIdentityCredentialsClient: mockClient, pager: mockPager, } wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], identityName, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("SearchWithEmptyIdentityName", func(t *testing.T) { mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) testClient := &testFederatedIdentityCredentialsClient{MockFederatedIdentityCredentialsClient: mockClient} wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when providing empty identity name, but got nil") } }) t.Run("SearchWithNoQueryParts", func(t *testing.T) { mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) testClient := &testFederatedIdentityCredentialsClient{MockFederatedIdentityCredentialsClient: mockClient} wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_CredentialWithNilName", func(t *testing.T) { mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) mockPager := &mockFederatedIdentityCredentialsPager{ pages: []armmsi.FederatedIdentityCredentialsClientListResponse{ { FederatedIdentityCredentialsListResult: armmsi.FederatedIdentityCredentialsListResult{ Value: []*armmsi.FederatedIdentityCredential{ {Name: nil}, createAzureFederatedIdentityCredential("valid-credential"), }, }, }, }, } testClient := &testFederatedIdentityCredentialsClient{ MockFederatedIdentityCredentialsClient: mockClient, pager: mockPager, } wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], identityName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(identityName, "valid-credential") { t.Errorf("Expected credential unique value '%s', got %s", shared.CompositeLookupKey(identityName, "valid-credential"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("credential not found") mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, identityName, "nonexistent", nil).Return( armmsi.FederatedIdentityCredentialsClientGetResponse{}, expectedErr) testClient := &testFederatedIdentityCredentialsClient{MockFederatedIdentityCredentialsClient: mockClient} wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(identityName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent credential, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockFederatedIdentityCredentialsClient(ctrl) errorPager := &errorFederatedIdentityCredentialsPager{} testClient := &testFederatedIdentityCredentialsClient{ MockFederatedIdentityCredentialsClient: mockClient, pager: errorPager, } wrapper := manual.NewManagedIdentityFederatedIdentityCredential(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], identityName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } func createAzureFederatedIdentityCredential(name string) *armmsi.FederatedIdentityCredential { return &armmsi.FederatedIdentityCredential{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity/federatedIdentityCredentials/" + name), Name: new(name), Type: new("Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials"), Properties: &armmsi.FederatedIdentityCredentialProperties{ Issuer: new("https://token.actions.githubusercontent.com"), Subject: new("repo:example/repo:ref:refs/heads/main"), Audiences: []*string{new("api://AzureADTokenExchange")}, }, } } ================================================ FILE: sources/azure/manual/managedidentity-user-assigned-identity.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ManagedIdentityUserAssignedIdentityLookupByName = shared.NewItemTypeLookup("name", azureshared.ManagedIdentityUserAssignedIdentity) type managedIdentityUserAssignedIdentityWrapper struct { client clients.UserAssignedIdentitiesClient *azureshared.MultiResourceGroupBase } func NewManagedIdentityUserAssignedIdentity(client clients.UserAssignedIdentitiesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &managedIdentityUserAssignedIdentityWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, azureshared.ManagedIdentityUserAssignedIdentity, ), } } func (m managedIdentityUserAssignedIdentityWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := m.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } pager := m.client.ListByResourceGroup(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } for _, identity := range page.Value { if identity.Name == nil { continue } item, sdpErr := m.azureManagedIdentityUserAssignedIdentityToSDPItem(identity, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (m managedIdentityUserAssignedIdentityWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := m.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, m.Type())) return } pager := m.client.ListByResourceGroup(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, m.Type())) return } for _, identity := range page.Value { if identity.Name == nil { continue } item, sdpErr := m.azureManagedIdentityUserAssignedIdentityToSDPItem(identity, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (m managedIdentityUserAssignedIdentityWrapper) azureManagedIdentityUserAssignedIdentityToSDPItem(identity *armmsi.Identity, scope string) (*sdp.Item, *sdp.QueryError) { if identity.Name == nil { return nil, azureshared.QueryError(errors.New("name is nil"), scope, m.Type()) } attributes, err := shared.ToAttributesWithExclude(identity, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } sdpItem := &sdp.Item{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(identity.Tags), } // Link to federated identity credentials (child resource) // Federated identity credentials can be listed using the identity's resource group and name // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/2023-01-31/federated-identity-credentials/list // The Azure SDK provides FederatedIdentityCredentialsClient with NewListPager(resourceGroupName, resourceName, options) // Since we can list all federated credentials for this identity, we use SEARCH method sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityFederatedIdentityCredential.String(), Method: sdp.QueryMethod_SEARCH, Query: *identity.Name, // Identity name is sufficient since resource group is available to the adapter Scope: scope, }, }) return sdpItem, nil } func (m managedIdentityUserAssignedIdentityWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("user assigned identity name is required"), scope, m.Type()) } name := queryParts[0] if name == "" { return nil, azureshared.QueryError(errors.New("user assigned identity name cannot be empty"), scope, m.Type()) } rgScope, err := m.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } identity, err := m.client.Get(ctx, rgScope.ResourceGroup, name, nil) if err != nil { return nil, azureshared.QueryError(err, scope, m.Type()) } return m.azureManagedIdentityUserAssignedIdentityToSDPItem(&identity.Identity, scope) } func (m managedIdentityUserAssignedIdentityWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ManagedIdentityUserAssignedIdentityLookupByName, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity func (m managedIdentityUserAssignedIdentityWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_user_assigned_identity.name", }, } } func (m managedIdentityUserAssignedIdentityWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.ManagedIdentityFederatedIdentityCredential: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/identity#microsoftmanagedidentity func (m managedIdentityUserAssignedIdentityWrapper) IAMPermissions() []string { return []string{ "Microsoft.ManagedIdentity/userAssignedIdentities/read", } } func (m managedIdentityUserAssignedIdentityWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/managedidentity-user-assigned-identity_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestManagedIdentityUserAssignedIdentity(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { identityName := "test-identity" identity := createAzureUserAssignedIdentity(identityName) mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, identityName, nil).Return( armmsi.UserAssignedIdentitiesClientGetResponse{ Identity: *identity, }, nil) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], identityName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.ManagedIdentityUserAssignedIdentity.String() { t.Errorf("Expected type %s, got %s", azureshared.ManagedIdentityUserAssignedIdentity.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != identityName { t.Errorf("Expected unique attribute value %s, got %s", identityName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Federated identity credentials link ExpectedType: azureshared.ManagedIdentityFederatedIdentityCredential.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: identityName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting user assigned identity with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { identity1 := createAzureUserAssignedIdentity("test-identity-1") identity2 := createAzureUserAssignedIdentity("test-identity-2") mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) mockPager := newMockUserAssignedIdentitiesPager(ctrl, []*armmsi.Identity{identity1, identity2}) mockClient.EXPECT().ListByResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.ManagedIdentityUserAssignedIdentity.String() { t.Fatalf("Expected type %s, got: %s", azureshared.ManagedIdentityUserAssignedIdentity.String(), item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Create identity with nil name to test filtering identity1 := createAzureUserAssignedIdentity("test-identity-1") identity2 := &armmsi.Identity{ Name: nil, // Identity with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armmsi.UserAssignedIdentityProperties{ ClientID: new("test-client-id-2"), PrincipalID: new("test-principal-id-2"), TenantID: new("test-tenant-id"), }, } mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) mockPager := newMockUserAssignedIdentitiesPager(ctrl, []*armmsi.Identity{identity1, identity2}) mockClient.EXPECT().ListByResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (identity with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "test-identity-1" { t.Fatalf("Expected identity name 'test-identity-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ListStream", func(t *testing.T) { identity1 := createAzureUserAssignedIdentity("test-identity-1") identity2 := createAzureUserAssignedIdentity("test-identity-2") mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) mockPager := newMockUserAssignedIdentitiesPager(ctrl, []*armmsi.Identity{identity1, identity2}) mockClient.EXPECT().ListByResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream_ErrorHandling", func(t *testing.T) { expectedErr := errors.New("failed to list user assigned identities") mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) mockPager := newErrorUserAssignedIdentitiesPager(ctrl, expectedErr) mockClient.EXPECT().ListByResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(*sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) if len(errs) == 0 { t.Error("Expected error when listing user assigned identities fails, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("user assigned identity not found") mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-identity", nil).Return( armmsi.UserAssignedIdentitiesClientGetResponse{}, expectedErr) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-identity", true) if qErr == nil { t.Error("Expected error when getting non-existent user assigned identity, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { expectedErr := errors.New("failed to list user assigned identities") mockClient := mocks.NewMockUserAssignedIdentitiesClient(ctrl) mockPager := newErrorUserAssignedIdentitiesPager(ctrl, expectedErr) mockClient.EXPECT().ListByResourceGroup(resourceGroup, nil).Return(mockPager) wrapper := manual.NewManagedIdentityUserAssignedIdentity(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing user assigned identities fails, but got nil") } }) } // createAzureUserAssignedIdentity creates a mock Azure User Assigned Identity for testing func createAzureUserAssignedIdentity(identityName string) *armmsi.Identity { return &armmsi.Identity{ Name: new(identityName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armmsi.UserAssignedIdentityProperties{ ClientID: new("test-client-id"), PrincipalID: new("test-principal-id"), TenantID: new("test-tenant-id"), }, } } // mockUserAssignedIdentitiesPager is a simple mock implementation of UserAssignedIdentitiesPager type mockUserAssignedIdentitiesPager struct { ctrl *gomock.Controller items []*armmsi.Identity index int more bool } func newMockUserAssignedIdentitiesPager(ctrl *gomock.Controller, items []*armmsi.Identity) clients.UserAssignedIdentitiesPager { return &mockUserAssignedIdentitiesPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockUserAssignedIdentitiesPager) More() bool { return m.more } func (m *mockUserAssignedIdentitiesPager) NextPage(ctx context.Context) (armmsi.UserAssignedIdentitiesClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armmsi.UserAssignedIdentitiesClientListByResourceGroupResponse{ UserAssignedIdentitiesListResult: armmsi.UserAssignedIdentitiesListResult{ Value: []*armmsi.Identity{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armmsi.UserAssignedIdentitiesClientListByResourceGroupResponse{ UserAssignedIdentitiesListResult: armmsi.UserAssignedIdentitiesListResult{ Value: []*armmsi.Identity{item}, }, }, nil } // errorUserAssignedIdentitiesPager is a mock pager that returns an error on NextPage type errorUserAssignedIdentitiesPager struct { ctrl *gomock.Controller err error more bool } func newErrorUserAssignedIdentitiesPager(ctrl *gomock.Controller, err error) clients.UserAssignedIdentitiesPager { return &errorUserAssignedIdentitiesPager{ ctrl: ctrl, err: err, more: true, // Return true initially so NextPage will be called } } func (e *errorUserAssignedIdentitiesPager) More() bool { return e.more } func (e *errorUserAssignedIdentitiesPager) NextPage(ctx context.Context) (armmsi.UserAssignedIdentitiesClientListByResourceGroupResponse, error) { e.more = false // After returning error, More() should return false to stop the loop return armmsi.UserAssignedIdentitiesClientListByResourceGroupResponse{}, e.err } ================================================ FILE: sources/azure/manual/network-application-gateway.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkApplicationGatewayLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkApplicationGateway) type networkApplicationGatewayWrapper struct { client clients.ApplicationGatewaysClient *azureshared.MultiResourceGroupBase } func NewNetworkApplicationGateway(client clients.ApplicationGatewaysClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkApplicationGatewayWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkApplicationGateway, ), } } func (n networkApplicationGatewayWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } for _, applicationGateway := range page.Value { if applicationGateway.Name == nil { continue } item, sdpErr := n.azureApplicationGatewayToSDPItem(applicationGateway) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkApplicationGatewayWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, n.DefaultScope(), n.Type())) return } for _, applicationGateway := range page.Value { if applicationGateway.Name == nil { continue } item, sdpErr := n.azureApplicationGatewayToSDPItem(applicationGateway) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkApplicationGatewayWrapper) azureApplicationGatewayToSDPItem(applicationGateway *armnetwork.ApplicationGateway) (*sdp.Item, *sdp.QueryError) { if applicationGateway.Name == nil { return nil, azureshared.QueryError(errors.New("application gateway name is nil"), n.DefaultScope(), n.Type()) } attributes, err := shared.ToAttributesWithExclude(applicationGateway, "tags") if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } applicationGatewayName := *applicationGateway.Name if applicationGatewayName == "" { return nil, azureshared.QueryError(errors.New("application gateway name cannot be empty"), n.DefaultScope(), n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkApplicationGateway.String(), UniqueAttribute: "name", Attributes: attributes, Scope: n.DefaultScope(), Tags: azureshared.ConvertAzureTags(applicationGateway.Tags), } if applicationGateway.Properties == nil { return sdpItem, nil } // Process GatewayIPConfigurations (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-ip-configurations/get if applicationGateway.Properties.GatewayIPConfigurations != nil { for _, gatewayIPConfig := range applicationGateway.Properties.GatewayIPConfigurations { if gatewayIPConfig.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayGatewayIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *gatewayIPConfig.Name), Scope: n.DefaultScope(), }, }) // Link to Subnet from GatewayIPConfiguration // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get if gatewayIPConfig.Properties != nil && gatewayIPConfig.Properties.Subnet != nil && gatewayIPConfig.Properties.Subnet.ID != nil { subnetID := *gatewayIPConfig.Properties.Subnet.ID subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(subnetParams) >= 2 { vnetName := subnetParams[0] subnetName := subnetParams[1] scope := n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(subnetID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: scope, }, }) // Link to VirtualNetwork (extracted from subnet ID) scope = n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(subnetID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: scope, }, }) } } } } } // Process FrontendIPConfigurations (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-frontend-ip-configurations/get if applicationGateway.Properties.FrontendIPConfigurations != nil { for _, frontendIPConfig := range applicationGateway.Properties.FrontendIPConfigurations { if frontendIPConfig.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *frontendIPConfig.Name), Scope: n.DefaultScope(), }, }) } if frontendIPConfig.Properties != nil { // Link to Public IP Address if referenced // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/public-ip-addresses/get if frontendIPConfig.Properties.PublicIPAddress != nil && frontendIPConfig.Properties.PublicIPAddress.ID != nil { publicIPName := azureshared.ExtractResourceName(*frontendIPConfig.Properties.PublicIPAddress.ID) if publicIPName != "" { scope := n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(*frontendIPConfig.Properties.PublicIPAddress.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: publicIPName, Scope: scope, }, }) } } // Link to Subnet if referenced (for private IP) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get if frontendIPConfig.Properties.Subnet != nil && frontendIPConfig.Properties.Subnet.ID != nil { subnetID := *frontendIPConfig.Properties.Subnet.ID subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(subnetParams) >= 2 { vnetName := subnetParams[0] subnetName := subnetParams[1] scope := n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(subnetID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: scope, }, }) } } // Link to IP address (standard library) if private IP address is assigned if frontendIPConfig.Properties.PrivateIPAddress != nil && *frontendIPConfig.Properties.PrivateIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *frontendIPConfig.Properties.PrivateIPAddress, Scope: "global", }, }) } } } } // Process BackendAddressPools (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-backend-address-pools/get if applicationGateway.Properties.BackendAddressPools != nil { for _, backendPool := range applicationGateway.Properties.BackendAddressPools { if backendPool.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *backendPool.Name), Scope: n.DefaultScope(), }, }) } // Link to IP addresses in backend addresses if backendPool.Properties != nil && backendPool.Properties.BackendAddresses != nil { for _, backendAddress := range backendPool.Properties.BackendAddresses { if backendAddress.IPAddress != nil && *backendAddress.IPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *backendAddress.IPAddress, Scope: "global", }, }) } // Link to DNS name (standard library) if FQDN is configured if backendAddress.Fqdn != nil && *backendAddress.Fqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *backendAddress.Fqdn, Scope: "global", }, }) } } } } } // Process HTTPListeners (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-http-listeners/get if applicationGateway.Properties.HTTPListeners != nil { for _, httpListener := range applicationGateway.Properties.HTTPListeners { if httpListener.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayHTTPListener.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *httpListener.Name), Scope: n.DefaultScope(), }, }) } // Link to DNS names (standard library) if hostnames are configured // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-http-listeners/get if httpListener.Properties != nil { // Single hostname (HostName) if httpListener.Properties.HostName != nil && *httpListener.Properties.HostName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *httpListener.Properties.HostName, Scope: "global", }, }) } // Multiple hostnames (HostNames) for multi-site listeners if httpListener.Properties.HostNames != nil { for _, hostName := range httpListener.Properties.HostNames { if hostName != nil && *hostName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *hostName, Scope: "global", }, }) } } } } } } // Process BackendHTTPSettingsCollection (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-backend-http-settings/get if applicationGateway.Properties.BackendHTTPSettingsCollection != nil { for _, backendHTTPSettings := range applicationGateway.Properties.BackendHTTPSettingsCollection { if backendHTTPSettings.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayBackendHTTPSettings.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *backendHTTPSettings.Name), Scope: n.DefaultScope(), }, }) } // Link to DNS name (standard library) if hostname override is configured // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-backend-http-settings/get if backendHTTPSettings.Properties != nil && backendHTTPSettings.Properties.HostName != nil && *backendHTTPSettings.Properties.HostName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *backendHTTPSettings.Properties.HostName, Scope: "global", }, }) } } } // Process RequestRoutingRules (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-request-routing-rules/get if applicationGateway.Properties.RequestRoutingRules != nil { for _, rule := range applicationGateway.Properties.RequestRoutingRules { if rule.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayRequestRoutingRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *rule.Name), Scope: n.DefaultScope(), }, }) } } } // Process Probes (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-health-probes/get if applicationGateway.Properties.Probes != nil { for _, probe := range applicationGateway.Properties.Probes { if probe.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayProbe.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *probe.Name), Scope: n.DefaultScope(), }, }) } // Link to DNS name (standard library) if probe host is configured // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-health-probes/get if probe.Properties != nil && probe.Properties.Host != nil && *probe.Properties.Host != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *probe.Properties.Host, Scope: "global", }, }) } } } // Process SSLCertificates (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-ssl-certificates/get if applicationGateway.Properties.SSLCertificates != nil { for _, sslCert := range applicationGateway.Properties.SSLCertificates { if sslCert.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewaySSLCertificate.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *sslCert.Name), Scope: n.DefaultScope(), }, }) } // Link to Key Vault Secret from KeyVaultSecretID // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/secrets/get-secret?view=rest-keyvault-keyvault-2024-11-01&tabs=HTTP if sslCert.Properties != nil && sslCert.Properties.KeyVaultSecretID != nil && *sslCert.Properties.KeyVaultSecretID != "" { secretID := *sslCert.Properties.KeyVaultSecretID vaultName := azureshared.ExtractVaultNameFromURI(secretID) secretName := azureshared.ExtractSecretNameFromURI(secretID) if vaultName != "" && secretName != "" { // Key Vault URI doesn't contain resource group, use gateway's scope as best effort sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultSecret.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, secretName), Scope: n.DefaultScope(), // Limitation: Key Vault URI doesn't contain resource group info }, }) } // Link to DNS name (standard library) from KeyVaultSecretID dnsName := azureshared.ExtractDNSFromURL(secretID) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } // Process URLPathMaps (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-url-path-maps/get if applicationGateway.Properties.URLPathMaps != nil { for _, urlPathMap := range applicationGateway.Properties.URLPathMaps { if urlPathMap.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayURLPathMap.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *urlPathMap.Name), Scope: n.DefaultScope(), }, }) } } } // Process AuthenticationCertificates (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-authentication-certificates/get if applicationGateway.Properties.AuthenticationCertificates != nil { for _, authCert := range applicationGateway.Properties.AuthenticationCertificates { if authCert.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayAuthenticationCertificate.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *authCert.Name), Scope: n.DefaultScope(), }, }) } } } // Process TrustedRootCertificates (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-trusted-root-certificates/get if applicationGateway.Properties.TrustedRootCertificates != nil { for _, trustedRootCert := range applicationGateway.Properties.TrustedRootCertificates { if trustedRootCert.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayTrustedRootCertificate.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *trustedRootCert.Name), Scope: n.DefaultScope(), }, }) } // Link to Key Vault Secret from KeyVaultSecretID // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/secrets/get-secret?view=rest-keyvault-keyvault-2024-11-01&tabs=HTTP if trustedRootCert.Properties != nil && trustedRootCert.Properties.KeyVaultSecretID != nil && *trustedRootCert.Properties.KeyVaultSecretID != "" { secretID := *trustedRootCert.Properties.KeyVaultSecretID vaultName := azureshared.ExtractVaultNameFromURI(secretID) secretName := azureshared.ExtractSecretNameFromURI(secretID) if vaultName != "" && secretName != "" { // Key Vault URI doesn't contain resource group, use gateway's scope as best effort sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultSecret.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, secretName), Scope: n.DefaultScope(), // Limitation: Key Vault URI doesn't contain resource group info }, }) } // Link to DNS name (standard library) from KeyVaultSecretID dnsName := azureshared.ExtractDNSFromURL(secretID) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } // Process RewriteRuleSets (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-rewrite-rule-sets/get if applicationGateway.Properties.RewriteRuleSets != nil { for _, rewriteRuleSet := range applicationGateway.Properties.RewriteRuleSets { if rewriteRuleSet.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayRewriteRuleSet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *rewriteRuleSet.Name), Scope: n.DefaultScope(), }, }) } } } // Process RedirectConfigurations (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-redirect-configurations/get if applicationGateway.Properties.RedirectConfigurations != nil { for _, redirectConfig := range applicationGateway.Properties.RedirectConfigurations { if redirectConfig.Name != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayRedirectConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(applicationGatewayName, *redirectConfig.Name), Scope: n.DefaultScope(), }, }) } // Link to DNS name (standard library) if target URL is configured // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-redirect-configurations/get if redirectConfig.Properties != nil && redirectConfig.Properties.TargetURL != nil && *redirectConfig.Properties.TargetURL != "" { dnsName := azureshared.ExtractDNSFromURL(*redirectConfig.Properties.TargetURL) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } // Link to Web Application Firewall Policy (External Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/application-gateway/application-gateway-web-application-firewall-policies/get if applicationGateway.Properties.FirewallPolicy != nil && applicationGateway.Properties.FirewallPolicy.ID != nil { firewallPolicyName := azureshared.ExtractResourceName(*applicationGateway.Properties.FirewallPolicy.ID) if firewallPolicyName != "" { scope := n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(*applicationGateway.Properties.FirewallPolicy.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayWebApplicationFirewallPolicy.String(), Method: sdp.QueryMethod_GET, Query: firewallPolicyName, Scope: scope, }, }) } } // Link to User Assigned Managed Identities (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if applicationGateway.Identity != nil && applicationGateway.Identity.UserAssignedIdentities != nil { for identityResourceID := range applicationGateway.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group scope := n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: scope, }, }) } } } return sdpItem, nil } func (n networkApplicationGatewayWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part and be a application gateway name"), n.DefaultScope(), n.Type()) } applicationGatewayName := queryParts[0] if applicationGatewayName == "" { return nil, azureshared.QueryError(errors.New("application gateway name cannot be empty"), n.DefaultScope(), n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, applicationGatewayName, nil) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } return n.azureApplicationGatewayToSDPItem(&resp.ApplicationGateway) } func (n networkApplicationGatewayWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkApplicationGatewayLookupByName, } } func (n networkApplicationGatewayWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( // Child resources azureshared.NetworkApplicationGatewayGatewayIPConfiguration, azureshared.NetworkApplicationGatewayFrontendIPConfiguration, azureshared.NetworkApplicationGatewayBackendAddressPool, azureshared.NetworkApplicationGatewayHTTPListener, azureshared.NetworkApplicationGatewayBackendHTTPSettings, azureshared.NetworkApplicationGatewayRequestRoutingRule, azureshared.NetworkApplicationGatewayProbe, azureshared.NetworkApplicationGatewaySSLCertificate, azureshared.NetworkApplicationGatewayURLPathMap, azureshared.NetworkApplicationGatewayAuthenticationCertificate, azureshared.NetworkApplicationGatewayTrustedRootCertificate, azureshared.NetworkApplicationGatewayRewriteRuleSet, azureshared.NetworkApplicationGatewayRedirectConfiguration, // External resources azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, azureshared.NetworkPublicIPAddress, azureshared.NetworkApplicationGatewayWebApplicationFirewallPolicy, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.KeyVaultSecret, // Standard library types stdlib.NetworkIP, stdlib.NetworkDNS, ) } func (n networkApplicationGatewayWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_application_gateway.name", }, } } func (n networkApplicationGatewayWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/applicationGateways/read", } } func (n networkApplicationGatewayWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-application-gateway_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "reflect" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkApplicationGateway(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { agName := "test-ag" applicationGateway := createAzureApplicationGateway(agName, subscriptionID, resourceGroup) mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, agName, nil).Return( armnetwork.ApplicationGatewaysClientGetResponse{ ApplicationGateway: *applicationGateway, }, nil) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], agName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkApplicationGateway.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkApplicationGateway, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != agName { t.Errorf("Expected unique attribute value %s, got %s", agName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // GatewayIPConfiguration child resource ExpectedType: azureshared.NetworkApplicationGatewayGatewayIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "gateway-ip-config"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Subnet from GatewayIPConfiguration ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // VirtualNetwork from GatewayIPConfiguration subnet ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // FrontendIPConfiguration child resource ExpectedType: azureshared.NetworkApplicationGatewayFrontendIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "frontend-ip-config"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // PublicIPAddress external resource ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-public-ip", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Private IP address link (standard library) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.2.0.5", ExpectedScope: "global", }, { // BackendAddressPool child resource ExpectedType: azureshared.NetworkApplicationGatewayBackendAddressPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "backend-pool"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Backend IP address link (standard library) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.1.4", ExpectedScope: "global", }, { // HTTPListener child resource ExpectedType: azureshared.NetworkApplicationGatewayHTTPListener.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "http-listener"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // BackendHTTPSettings child resource ExpectedType: azureshared.NetworkApplicationGatewayBackendHTTPSettings.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "backend-http-settings"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // RequestRoutingRule child resource ExpectedType: azureshared.NetworkApplicationGatewayRequestRoutingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "routing-rule"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Probe child resource ExpectedType: azureshared.NetworkApplicationGatewayProbe.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "health-probe"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // SSLCertificate child resource ExpectedType: azureshared.NetworkApplicationGatewaySSLCertificate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "ssl-cert"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Key Vault Secret from SSLCertificate KeyVaultSecretID ExpectedType: azureshared.KeyVaultSecret.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-keyvault", "test-secret"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // DNS name from SSLCertificate KeyVaultSecretID ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-keyvault.vault.azure.net", ExpectedScope: "global", }, { // URLPathMap child resource ExpectedType: azureshared.NetworkApplicationGatewayURLPathMap.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "url-path-map"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // AuthenticationCertificate child resource ExpectedType: azureshared.NetworkApplicationGatewayAuthenticationCertificate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "auth-cert"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // TrustedRootCertificate child resource ExpectedType: azureshared.NetworkApplicationGatewayTrustedRootCertificate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "trusted-root-cert"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Key Vault Secret from TrustedRootCertificate KeyVaultSecretID ExpectedType: azureshared.KeyVaultSecret.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-trusted-keyvault", "test-trusted-secret"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // DNS name from TrustedRootCertificate KeyVaultSecretID ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-trusted-keyvault.vault.azure.net", ExpectedScope: "global", }, { // RewriteRuleSet child resource ExpectedType: azureshared.NetworkApplicationGatewayRewriteRuleSet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "rewrite-rule-set"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // RedirectConfiguration child resource ExpectedType: azureshared.NetworkApplicationGatewayRedirectConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(agName, "redirect-config"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // WAF Policy external resource ExpectedType: azureshared.NetworkApplicationGatewayWebApplicationFirewallPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-waf-policy", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // User Assigned Managed Identity external resource ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test with wrong number of query parts - need to call through the wrapper directly _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0], "part1", "part2") if qErr == nil { t.Error("Expected error when getting application gateway with wrong number of query parts, but got nil") } }) t.Run("Get_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty string name - validation happens before client.Get is called // so no mock expectation is needed _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting application gateway with empty name, but got nil") } }) t.Run("Get_WithNilName", func(t *testing.T) { applicationGateway := &armnetwork.ApplicationGateway{ Name: nil, // Application Gateway with nil name should cause an error Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-ag", nil).Return( armnetwork.ApplicationGatewaysClientGetResponse{ ApplicationGateway: *applicationGateway, }, nil) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-ag", true) if qErr == nil { t.Error("Expected error when application gateway has nil name, but got nil") } }) t.Run("Get_ErrorHandling", func(t *testing.T) { mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-ag", nil).Return( armnetwork.ApplicationGatewaysClientGetResponse{}, errors.New("not found")) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-ag", true) if qErr == nil { t.Error("Expected error when client returns error, but got nil") } }) t.Run("List", func(t *testing.T) { ag1 := createAzureApplicationGateway("test-ag-1", subscriptionID, resourceGroup) ag2 := createAzureApplicationGateway("test-ag-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) mockPager := NewMockApplicationGatewaysPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.ApplicationGatewaysClientListResponse{ ApplicationGatewayListResult: armnetwork.ApplicationGatewayListResult{ Value: []*armnetwork.ApplicationGateway{ag1, ag2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkApplicationGateway.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkApplicationGateway, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { ag1 := createAzureApplicationGateway("test-ag-1", subscriptionID, resourceGroup) ag2 := &armnetwork.ApplicationGateway{ Name: nil, // Application Gateway with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) mockPager := NewMockApplicationGatewaysPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.ApplicationGatewaysClientListResponse{ ApplicationGatewayListResult: armnetwork.ApplicationGatewayListResult{ Value: []*armnetwork.ApplicationGateway{ag1, ag2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (ag1), ag2 should be skipped if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } }) t.Run("List_ErrorHandling", func(t *testing.T) { mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) mockPager := NewMockApplicationGatewaysPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.ApplicationGatewaysClientListResponse{}, errors.New("list error")), ) mockClient.EXPECT().List(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when pager returns error, but got nil") } }) t.Run("CrossResourceGroupLinks", func(t *testing.T) { agName := "test-ag" applicationGateway := createAzureApplicationGatewayWithDifferentScopePublicIP(agName, subscriptionID, resourceGroup, "other-sub", "other-rg") mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, agName, nil).Return( armnetwork.ApplicationGatewaysClientGetResponse{ ApplicationGateway: *applicationGateway, }, nil) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], agName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Find the PublicIPAddress linked query found := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkPublicIPAddress.String() { found = true expectedScope := fmt.Sprintf("%s.%s", "other-sub", "other-rg") if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected PublicIPAddress scope to be %s, got: %s", expectedScope, linkedQuery.GetQuery().GetScope()) } break } } if !found { t.Error("Expected to find PublicIPAddress linked query") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockApplicationGatewaysClient(ctrl) wrapper := manual.NewNetworkApplicationGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Verify adapter implements ListableAdapter interface _, ok := adapter.(discovery.ListableAdapter) if !ok { t.Error("Adapter should implement ListableAdapter interface") } // Verify GetLookups lookups := wrapper.GetLookups() if len(lookups) == 0 { t.Error("Expected GetLookups to return at least one lookup") } // Verify PotentialLinks potentialLinks := wrapper.PotentialLinks() expectedLinks := []shared.ItemType{ azureshared.NetworkApplicationGatewayGatewayIPConfiguration, azureshared.NetworkApplicationGatewayFrontendIPConfiguration, azureshared.NetworkApplicationGatewayBackendAddressPool, azureshared.NetworkApplicationGatewayHTTPListener, azureshared.NetworkApplicationGatewayBackendHTTPSettings, azureshared.NetworkApplicationGatewayRequestRoutingRule, azureshared.NetworkApplicationGatewayProbe, azureshared.NetworkApplicationGatewaySSLCertificate, azureshared.NetworkApplicationGatewayURLPathMap, azureshared.NetworkApplicationGatewayAuthenticationCertificate, azureshared.NetworkApplicationGatewayTrustedRootCertificate, azureshared.NetworkApplicationGatewayRewriteRuleSet, azureshared.NetworkApplicationGatewayRedirectConfiguration, azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, azureshared.NetworkPublicIPAddress, azureshared.NetworkApplicationGatewayWebApplicationFirewallPolicy, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.KeyVaultSecret, stdlib.NetworkIP, stdlib.NetworkDNS, } for _, expectedLink := range expectedLinks { if !potentialLinks[expectedLink] { t.Errorf("Expected PotentialLinks to include %s", expectedLink) } } // Verify TerraformMappings mappings := wrapper.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_application_gateway.name" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_application_gateway.name' mapping") } // Verify PredefinedRole if roleInterface, ok := any(wrapper).(interface{ PredefinedRole() string }); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } else { t.Error("Wrapper does not implement PredefinedRole method") } }) } // MockApplicationGatewaysPager is a simple mock for ApplicationGatewaysPager type MockApplicationGatewaysPager struct { ctrl *gomock.Controller recorder *MockApplicationGatewaysPagerMockRecorder } type MockApplicationGatewaysPagerMockRecorder struct { mock *MockApplicationGatewaysPager } func NewMockApplicationGatewaysPager(ctrl *gomock.Controller) *MockApplicationGatewaysPager { mock := &MockApplicationGatewaysPager{ctrl: ctrl} mock.recorder = &MockApplicationGatewaysPagerMockRecorder{mock} return mock } func (m *MockApplicationGatewaysPager) EXPECT() *MockApplicationGatewaysPagerMockRecorder { return m.recorder } func (m *MockApplicationGatewaysPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockApplicationGatewaysPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockApplicationGatewaysPager) NextPage(ctx context.Context) (armnetwork.ApplicationGatewaysClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.ApplicationGatewaysClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockApplicationGatewaysPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.ApplicationGatewaysClientListResponse, error)](), ctx) } // createAzureApplicationGateway creates a mock Azure Application Gateway for testing func createAzureApplicationGateway(agName, subscriptionID, resourceGroup string) *armnetwork.ApplicationGateway { return &armnetwork.ApplicationGateway{ Name: new(agName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.ApplicationGatewayPropertiesFormat{ // GatewayIPConfigurations (Child Resource) GatewayIPConfigurations: []*armnetwork.ApplicationGatewayIPConfiguration{ { Name: new("gateway-ip-config"), Properties: &armnetwork.ApplicationGatewayIPConfigurationPropertiesFormat{ Subnet: &armnetwork.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, }, }, // FrontendIPConfigurations (Child Resource) FrontendIPConfigurations: []*armnetwork.ApplicationGatewayFrontendIPConfiguration{ { Name: new("frontend-ip-config"), Properties: &armnetwork.ApplicationGatewayFrontendIPConfigurationPropertiesFormat{ PublicIPAddress: &armnetwork.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/publicIPAddresses/test-public-ip"), }, PrivateIPAddress: new("10.2.0.5"), }, }, }, // BackendAddressPools (Child Resource) BackendAddressPools: []*armnetwork.ApplicationGatewayBackendAddressPool{ { Name: new("backend-pool"), Properties: &armnetwork.ApplicationGatewayBackendAddressPoolPropertiesFormat{ BackendAddresses: []*armnetwork.ApplicationGatewayBackendAddress{ { IPAddress: new("10.0.1.4"), }, }, }, }, }, // HTTPListeners (Child Resource) HTTPListeners: []*armnetwork.ApplicationGatewayHTTPListener{ { Name: new("http-listener"), }, }, // BackendHTTPSettingsCollection (Child Resource) BackendHTTPSettingsCollection: []*armnetwork.ApplicationGatewayBackendHTTPSettings{ { Name: new("backend-http-settings"), }, }, // RequestRoutingRules (Child Resource) RequestRoutingRules: []*armnetwork.ApplicationGatewayRequestRoutingRule{ { Name: new("routing-rule"), }, }, // Probes (Child Resource) Probes: []*armnetwork.ApplicationGatewayProbe{ { Name: new("health-probe"), }, }, // SSLCertificates (Child Resource) SSLCertificates: []*armnetwork.ApplicationGatewaySSLCertificate{ { Name: new("ssl-cert"), Properties: &armnetwork.ApplicationGatewaySSLCertificatePropertiesFormat{ KeyVaultSecretID: new("https://test-keyvault.vault.azure.net/secrets/test-secret/version"), }, }, }, // URLPathMaps (Child Resource) URLPathMaps: []*armnetwork.ApplicationGatewayURLPathMap{ { Name: new("url-path-map"), }, }, // AuthenticationCertificates (Child Resource) AuthenticationCertificates: []*armnetwork.ApplicationGatewayAuthenticationCertificate{ { Name: new("auth-cert"), }, }, // TrustedRootCertificates (Child Resource) TrustedRootCertificates: []*armnetwork.ApplicationGatewayTrustedRootCertificate{ { Name: new("trusted-root-cert"), Properties: &armnetwork.ApplicationGatewayTrustedRootCertificatePropertiesFormat{ KeyVaultSecretID: new("https://test-trusted-keyvault.vault.azure.net/secrets/test-trusted-secret/version"), }, }, }, // RewriteRuleSets (Child Resource) RewriteRuleSets: []*armnetwork.ApplicationGatewayRewriteRuleSet{ { Name: new("rewrite-rule-set"), }, }, // RedirectConfigurations (Child Resource) RedirectConfigurations: []*armnetwork.ApplicationGatewayRedirectConfiguration{ { Name: new("redirect-config"), }, }, // FirewallPolicy (External Resource) FirewallPolicy: &armnetwork.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies/test-waf-policy"), }, }, Identity: &armnetwork.ManagedServiceIdentity{ Type: new(armnetwork.ResourceIdentityTypeUserAssigned), UserAssignedIdentities: map[string]*armnetwork.Components1Jq1T4ISchemasManagedserviceidentityPropertiesUserassignedidentitiesAdditionalproperties{ "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity": {}, }, }, } } // createAzureApplicationGatewayWithDifferentScopePublicIP creates an Application Gateway with PublicIPAddress in different scope func createAzureApplicationGatewayWithDifferentScopePublicIP(agName, subscriptionID, resourceGroup, otherSubscriptionID, otherResourceGroup string) *armnetwork.ApplicationGateway { ag := createAzureApplicationGateway(agName, subscriptionID, resourceGroup) // Override FrontendIPConfiguration with PublicIPAddress in different scope ag.Properties.FrontendIPConfigurations = []*armnetwork.ApplicationGatewayFrontendIPConfiguration{ { Name: new("frontend-ip-config"), Properties: &armnetwork.ApplicationGatewayFrontendIPConfigurationPropertiesFormat{ PublicIPAddress: &armnetwork.SubResource{ ID: new("/subscriptions/" + otherSubscriptionID + "/resourceGroups/" + otherResourceGroup + "/providers/Microsoft.Network/publicIPAddresses/test-public-ip"), }, }, }, } return ag } ================================================ FILE: sources/azure/manual/network-application-security-group.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkApplicationSecurityGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkApplicationSecurityGroup) type networkApplicationSecurityGroupWrapper struct { client clients.ApplicationSecurityGroupsClient *azureshared.MultiResourceGroupBase } func NewNetworkApplicationSecurityGroup(client clients.ApplicationSecurityGroupsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkApplicationSecurityGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkApplicationSecurityGroup, ), } } func (n networkApplicationSecurityGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, asg := range page.Value { if asg.Name == nil { continue } item, sdpErr := n.azureApplicationSecurityGroupToSDPItem(asg, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkApplicationSecurityGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, asg := range page.Value { if asg.Name == nil { continue } item, sdpErr := n.azureApplicationSecurityGroupToSDPItem(asg, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkApplicationSecurityGroupWrapper) azureApplicationSecurityGroupToSDPItem(asg *armnetwork.ApplicationSecurityGroup, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(asg, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } if asg.Name == nil { return nil, azureshared.QueryError(errors.New("application security group name is nil"), scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkApplicationSecurityGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(asg.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // no links - https://learn.microsoft.com/en-us/rest/api/virtualnetwork/application-security-groups/get?view=rest-virtualnetwork-2025-05-01&tabs=HTTP // Health from provisioning state if asg.Properties != nil && asg.Properties.ProvisioningState != nil { switch *asg.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return sdpItem, nil } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/application-security-groups/get func (n networkApplicationSecurityGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part (application security group name)"), scope, n.Type()) } asgName := queryParts[0] if asgName == "" { return nil, azureshared.QueryError(errors.New("application security group name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, asgName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureApplicationSecurityGroupToSDPItem(&resp.ApplicationSecurityGroup, scope) } func (n networkApplicationSecurityGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkApplicationSecurityGroupLookupByName, } } func (n networkApplicationSecurityGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{} } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/application_security_group func (n networkApplicationSecurityGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_application_security_group.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (n networkApplicationSecurityGroupWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/applicationSecurityGroups/read", } } func (n networkApplicationSecurityGroupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-application-security-group_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestNetworkApplicationSecurityGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { asgName := "test-asg" asg := createAzureApplicationSecurityGroup(asgName) mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, asgName, nil).Return( armnetwork.ApplicationSecurityGroupsClientGetResponse{ ApplicationSecurityGroup: *asg, }, nil) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], asgName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkApplicationSecurityGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkApplicationSecurityGroup, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != asgName { t.Errorf("Expected unique attribute value %s, got %s", asgName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { // Application Security Group has no linked item queries queryTests := shared.QueryTests{} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when application security group name is empty, but got nil") } }) t.Run("Get_ASGWithNilName", func(t *testing.T) { provisioningState := armnetwork.ProvisioningStateSucceeded asgWithNilName := &armnetwork.ApplicationSecurityGroup{ Name: nil, Location: new("eastus"), Properties: &armnetwork.ApplicationSecurityGroupPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-asg", nil).Return( armnetwork.ApplicationSecurityGroupsClientGetResponse{ ApplicationSecurityGroup: *asgWithNilName, }, nil) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-asg", true) if qErr == nil { t.Error("Expected error when application security group has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { asg1 := createAzureApplicationSecurityGroup("asg-1") asg2 := createAzureApplicationSecurityGroup("asg-2") mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) mockPager := newMockApplicationSecurityGroupsPager(ctrl, []*armnetwork.ApplicationSecurityGroup{asg1, asg2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkApplicationSecurityGroup.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkApplicationSecurityGroup, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { asg1 := createAzureApplicationSecurityGroup("asg-1") provisioningState := armnetwork.ProvisioningStateSucceeded asg2NilName := &armnetwork.ApplicationSecurityGroup{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, Properties: &armnetwork.ApplicationSecurityGroupPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) mockPager := newMockApplicationSecurityGroupsPager(ctrl, []*armnetwork.ApplicationSecurityGroup{asg1, asg2NilName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "asg-1" { t.Errorf("Expected item name 'asg-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ListStream", func(t *testing.T) { asg1 := createAzureApplicationSecurityGroup("stream-asg-1") asg2 := createAzureApplicationSecurityGroup("stream-asg-2") mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) mockPager := newMockApplicationSecurityGroupsPager(ctrl, []*armnetwork.ApplicationSecurityGroup{asg1, asg2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("application security group not found") mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-asg", nil).Return( armnetwork.ApplicationSecurityGroupsClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-asg", true) if qErr == nil { t.Error("Expected error when getting non-existent application security group, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockApplicationSecurityGroupsClient(ctrl) wrapper := manual.NewNetworkApplicationSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/applicationSecurityGroups/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } mappings := w.TerraformMappings() foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_application_security_group.name" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected TerraformMethod GET, got: %s", mapping.GetTerraformMethod()) } break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_application_security_group.name'") } lookups := w.GetLookups() foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.NetworkApplicationSecurityGroup { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include NetworkApplicationSecurityGroup") } }) } type mockApplicationSecurityGroupsPager struct { ctrl *gomock.Controller items []*armnetwork.ApplicationSecurityGroup index int more bool } func newMockApplicationSecurityGroupsPager(ctrl *gomock.Controller, items []*armnetwork.ApplicationSecurityGroup) clients.ApplicationSecurityGroupsPager { return &mockApplicationSecurityGroupsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockApplicationSecurityGroupsPager) More() bool { return m.more } func (m *mockApplicationSecurityGroupsPager) NextPage(ctx context.Context) (armnetwork.ApplicationSecurityGroupsClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.ApplicationSecurityGroupsClientListResponse{ ApplicationSecurityGroupListResult: armnetwork.ApplicationSecurityGroupListResult{ Value: []*armnetwork.ApplicationSecurityGroup{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.ApplicationSecurityGroupsClientListResponse{ ApplicationSecurityGroupListResult: armnetwork.ApplicationSecurityGroupListResult{ Value: []*armnetwork.ApplicationSecurityGroup{item}, }, }, nil } func createAzureApplicationSecurityGroup(name string) *armnetwork.ApplicationSecurityGroup { provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.ApplicationSecurityGroup{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/applicationSecurityGroups/" + name), Name: new(name), Type: new("Microsoft.Network/applicationSecurityGroups"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.ApplicationSecurityGroupPropertiesFormat{ ProvisioningState: &provisioningState, ResourceGUID: new("00000000-0000-0000-0000-000000000001"), }, } } // Ensure mockApplicationSecurityGroupsPager satisfies the pager interface at compile time. var _ clients.ApplicationSecurityGroupsPager = (*mockApplicationSecurityGroupsPager)(nil) ================================================ FILE: sources/azure/manual/network-ddos-protection-plan.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkDdosProtectionPlanLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkDdosProtectionPlan) type networkDdosProtectionPlanWrapper struct { client clients.DdosProtectionPlansClient *azureshared.MultiResourceGroupBase } func NewNetworkDdosProtectionPlan(client clients.DdosProtectionPlansClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkDdosProtectionPlanWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkDdosProtectionPlan, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/ddos-protection-plans/list-by-resource-group func (n networkDdosProtectionPlanWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, plan := range page.Value { if plan.Name == nil { continue } item, sdpErr := n.azureDdosProtectionPlanToSDPItem(plan, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkDdosProtectionPlanWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, plan := range page.Value { if plan.Name == nil { continue } item, sdpErr := n.azureDdosProtectionPlanToSDPItem(plan, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/ddos-protection-plans/get func (n networkDdosProtectionPlanWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part (DDoS protection plan name)"), scope, n.Type()) } planName := queryParts[0] if planName == "" { return nil, azureshared.QueryError(errors.New("DDoS protection plan name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, planName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureDdosProtectionPlanToSDPItem(&resp.DdosProtectionPlan, scope) } func (n networkDdosProtectionPlanWrapper) azureDdosProtectionPlanToSDPItem(plan *armnetwork.DdosProtectionPlan, scope string) (*sdp.Item, *sdp.QueryError) { if plan.Name == nil { return nil, azureshared.QueryError(errors.New("DDoS protection plan name is nil"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(plan, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkDdosProtectionPlan.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(plan.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } if plan.Properties != nil { // Link to each associated virtual network for _, ref := range plan.Properties.VirtualNetworks { if ref != nil && ref.ID != nil { vnetID := *ref.ID vnetName := azureshared.ExtractResourceName(vnetID) if vnetName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(vnetID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } } // Link to each associated public IP address for _, ref := range plan.Properties.PublicIPAddresses { if ref != nil && ref.ID != nil { publicIPID := *ref.ID publicIPName := azureshared.ExtractResourceName(publicIPID) if publicIPName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(publicIPID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: publicIPName, Scope: linkedScope, }, }) } } } } // Health from provisioning state if plan.Properties != nil && plan.Properties.ProvisioningState != nil { switch *plan.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return sdpItem, nil } func (n networkDdosProtectionPlanWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkDdosProtectionPlanLookupByName, } } func (n networkDdosProtectionPlanWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkVirtualNetwork: true, azureshared.NetworkPublicIPAddress: true, } } func (n networkDdosProtectionPlanWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_network_ddos_protection_plan.name", }, } } // https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (n networkDdosProtectionPlanWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/ddosProtectionPlans/read", } } func (n networkDdosProtectionPlanWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-ddos-protection-plan_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestNetworkDdosProtectionPlan(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { planName := "test-ddos-plan" plan := createAzureDdosProtectionPlan(planName) mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, planName, nil).Return( armnetwork.DdosProtectionPlansClientGetResponse{ DdosProtectionPlan: *plan, }, nil) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], planName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkDdosProtectionPlan.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDdosProtectionPlan.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != planName { t.Errorf("Expected unique attribute value %s, got %s", planName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithLinkedResources", func(t *testing.T) { planName := "test-ddos-plan-with-links" plan := createAzureDdosProtectionPlanWithLinks(planName, subscriptionID, resourceGroup) mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, planName, nil).Return( armnetwork.DdosProtectionPlansClientGetResponse{ DdosProtectionPlan: *plan, }, nil) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], planName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { scope := subscriptionID + "." + resourceGroup queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-public-ip", ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when DDoS protection plan name is empty, but got nil") } }) t.Run("Get_PlanWithNilName", func(t *testing.T) { provisioningState := armnetwork.ProvisioningStateSucceeded planWithNilName := &armnetwork.DdosProtectionPlan{ Name: nil, Location: new("eastus"), Properties: &armnetwork.DdosProtectionPlanPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-plan", nil).Return( armnetwork.DdosProtectionPlansClientGetResponse{ DdosProtectionPlan: *planWithNilName, }, nil) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-plan", true) if qErr == nil { t.Error("Expected error when DDoS protection plan has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { plan1 := createAzureDdosProtectionPlan("plan-1") plan2 := createAzureDdosProtectionPlan("plan-2") mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) mockPager := newMockDdosProtectionPlansPager(ctrl, []*armnetwork.DdosProtectionPlan{plan1, plan2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkDdosProtectionPlan.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkDdosProtectionPlan.String(), item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { plan1 := createAzureDdosProtectionPlan("plan-1") provisioningState := armnetwork.ProvisioningStateSucceeded plan2NilName := &armnetwork.DdosProtectionPlan{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, Properties: &armnetwork.DdosProtectionPlanPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) mockPager := newMockDdosProtectionPlansPager(ctrl, []*armnetwork.DdosProtectionPlan{plan1, plan2NilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "plan-1" { t.Errorf("Expected item name 'plan-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ListStream", func(t *testing.T) { plan1 := createAzureDdosProtectionPlan("stream-plan-1") plan2 := createAzureDdosProtectionPlan("stream-plan-2") mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) mockPager := newMockDdosProtectionPlansPager(ctrl, []*armnetwork.DdosProtectionPlan{plan1, plan2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("DDoS protection plan not found") mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-plan", nil).Return( armnetwork.DdosProtectionPlansClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-plan", true) if qErr == nil { t.Error("Expected error when getting non-existent DDoS protection plan, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockDdosProtectionPlansClient(ctrl) wrapper := manual.NewNetworkDdosProtectionPlan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/ddosProtectionPlans/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } mappings := w.TerraformMappings() foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_network_ddos_protection_plan.name" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected TerraformMethod GET, got: %s", mapping.GetTerraformMethod()) } break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_network_ddos_protection_plan.name'") } lookups := w.GetLookups() foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.NetworkDdosProtectionPlan { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include NetworkDdosProtectionPlan") } potentialLinks := w.PotentialLinks() for _, linkType := range []shared.ItemType{azureshared.NetworkVirtualNetwork, azureshared.NetworkPublicIPAddress} { if !potentialLinks[linkType] { t.Errorf("Expected PotentialLinks to include %s", linkType) } } }) } type mockDdosProtectionPlansPager struct { ctrl *gomock.Controller items []*armnetwork.DdosProtectionPlan index int more bool } func newMockDdosProtectionPlansPager(ctrl *gomock.Controller, items []*armnetwork.DdosProtectionPlan) clients.DdosProtectionPlansPager { return &mockDdosProtectionPlansPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockDdosProtectionPlansPager) More() bool { return m.more } func (m *mockDdosProtectionPlansPager) NextPage(ctx context.Context) (armnetwork.DdosProtectionPlansClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.DdosProtectionPlansClientListByResourceGroupResponse{ DdosProtectionPlanListResult: armnetwork.DdosProtectionPlanListResult{ Value: []*armnetwork.DdosProtectionPlan{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.DdosProtectionPlansClientListByResourceGroupResponse{ DdosProtectionPlanListResult: armnetwork.DdosProtectionPlanListResult{ Value: []*armnetwork.DdosProtectionPlan{item}, }, }, nil } func createAzureDdosProtectionPlan(name string) *armnetwork.DdosProtectionPlan { provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.DdosProtectionPlan{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/ddosProtectionPlans/" + name), Name: new(name), Type: new("Microsoft.Network/ddosProtectionPlans"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.DdosProtectionPlanPropertiesFormat{ ProvisioningState: &provisioningState, }, } } func createAzureDdosProtectionPlanWithLinks(name, subscriptionID, resourceGroup string) *armnetwork.DdosProtectionPlan { plan := createAzureDdosProtectionPlan(name) plan.Properties.VirtualNetworks = []*armnetwork.SubResource{ {ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet")}, } plan.Properties.PublicIPAddresses = []*armnetwork.SubResource{ {ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/publicIPAddresses/test-public-ip")}, } return plan } var _ clients.DdosProtectionPlansPager = (*mockDdosProtectionPlansPager)(nil) ================================================ FILE: sources/azure/manual/network-default-security-rule.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkDefaultSecurityRuleLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkDefaultSecurityRule) type networkDefaultSecurityRuleWrapper struct { client clients.DefaultSecurityRulesClient *azureshared.MultiResourceGroupBase } // NewNetworkDefaultSecurityRule creates a new networkDefaultSecurityRuleWrapper instance (SearchableWrapper: child of network security group). func NewNetworkDefaultSecurityRule(client clients.DefaultSecurityRulesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkDefaultSecurityRuleWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkDefaultSecurityRule, ), } } func (n networkDefaultSecurityRuleWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: networkSecurityGroupName and defaultSecurityRuleName", Scope: scope, ItemType: n.Type(), } } nsgName := queryParts[0] ruleName := queryParts[1] if ruleName == "" { return nil, azureshared.QueryError(errors.New("default security rule name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, nsgName, ruleName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureDefaultSecurityRuleToSDPItem(&resp.SecurityRule, nsgName, ruleName, scope) } func (n networkDefaultSecurityRuleWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkNetworkSecurityGroupLookupByName, NetworkDefaultSecurityRuleLookupByUniqueAttr, } } func (n networkDefaultSecurityRuleWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: networkSecurityGroupName", Scope: scope, ItemType: n.Type(), } } nsgName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, nsgName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, rule := range page.Value { if rule == nil || rule.Name == nil { continue } item, sdpErr := n.azureDefaultSecurityRuleToSDPItem(rule, nsgName, *rule.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkDefaultSecurityRuleWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: networkSecurityGroupName"), scope, n.Type())) return } nsgName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, nsgName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, rule := range page.Value { if rule == nil || rule.Name == nil { continue } item, sdpErr := n.azureDefaultSecurityRuleToSDPItem(rule, nsgName, *rule.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkDefaultSecurityRuleWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {NetworkNetworkSecurityGroupLookupByName}, } } func (n networkDefaultSecurityRuleWrapper) azureDefaultSecurityRuleToSDPItem(rule *armnetwork.SecurityRule, nsgName, ruleName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(rule, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(nsgName, ruleName)) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkDefaultSecurityRule.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to parent Network Security Group sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: nsgName, Scope: scope, }, }) if rule.Properties != nil { // Link to SourceApplicationSecurityGroups if rule.Properties.SourceApplicationSecurityGroups != nil { for _, asgRef := range rule.Properties.SourceApplicationSecurityGroups { if asgRef != nil && asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: linkScope, }, }) } } } } // Link to DestinationApplicationSecurityGroups if rule.Properties.DestinationApplicationSecurityGroups != nil { for _, asgRef := range rule.Properties.DestinationApplicationSecurityGroups { if asgRef != nil && asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: linkScope, }, }) } } } } // Link to stdlib.NetworkIP for source/destination address prefixes when they are IPs or CIDRs if rule.Properties.SourceAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *rule.Properties.SourceAddressPrefix) } for _, p := range rule.Properties.SourceAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } if rule.Properties.DestinationAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *rule.Properties.DestinationAddressPrefix) } for _, p := range rule.Properties.DestinationAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } } return sdpItem, nil } func (n networkDefaultSecurityRuleWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkNetworkSecurityGroup, azureshared.NetworkApplicationSecurityGroup, stdlib.NetworkIP, ) } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get#defaultsecurityrules func (n networkDefaultSecurityRuleWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/networkSecurityGroups/defaultSecurityRules/read", } } func (n networkDefaultSecurityRuleWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-default-security-rule_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockDefaultSecurityRulesPager struct { pages []armnetwork.DefaultSecurityRulesClientListResponse index int } func (m *mockDefaultSecurityRulesPager) More() bool { return m.index < len(m.pages) } func (m *mockDefaultSecurityRulesPager) NextPage(ctx context.Context) (armnetwork.DefaultSecurityRulesClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.DefaultSecurityRulesClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorDefaultSecurityRulesPager struct{} func (e *errorDefaultSecurityRulesPager) More() bool { return true } func (e *errorDefaultSecurityRulesPager) NextPage(ctx context.Context) (armnetwork.DefaultSecurityRulesClientListResponse, error) { return armnetwork.DefaultSecurityRulesClientListResponse{}, errors.New("pager error") } type testDefaultSecurityRulesClient struct { *mocks.MockDefaultSecurityRulesClient pager clients.DefaultSecurityRulesPager } func (t *testDefaultSecurityRulesClient) NewListPager(resourceGroupName, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) clients.DefaultSecurityRulesPager { return t.pager } func TestNetworkDefaultSecurityRule(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" nsgName := "test-nsg" ruleName := "AllowVnetInBound" t.Run("Get", func(t *testing.T) { rule := createAzureDefaultSecurityRule(ruleName, nsgName) mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, ruleName, nil).Return( armnetwork.DefaultSecurityRulesClientGetResponse{ SecurityRule: rule, }, nil) testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(nsgName, ruleName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkDefaultSecurityRule.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDefaultSecurityRule, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(nsgName, ruleName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(nsgName, ruleName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkNetworkSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: nsgName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyRuleName", func(t *testing.T) { mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(nsgName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when rule name is empty, but got nil") } }) t.Run("Get_InsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], nsgName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { rule1 := createAzureDefaultSecurityRule("AllowVnetInBound", nsgName) rule2 := createAzureDefaultSecurityRule("AllowAzureLoadBalancerInBound", nsgName) mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) mockPager := &mockDefaultSecurityRulesPager{ pages: []armnetwork.DefaultSecurityRulesClientListResponse{ { SecurityRuleListResult: armnetwork.SecurityRuleListResult{ Value: []*armnetwork.SecurityRule{&rule1, &rule2}, }, }, }, } testClient := &testDefaultSecurityRulesClient{ MockDefaultSecurityRulesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkDefaultSecurityRule.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDefaultSecurityRule, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_RuleWithNilName", func(t *testing.T) { validRule := createAzureDefaultSecurityRule("AllowVnetInBound", nsgName) mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) mockPager := &mockDefaultSecurityRulesPager{ pages: []armnetwork.DefaultSecurityRulesClientListResponse{ { SecurityRuleListResult: armnetwork.SecurityRuleListResult{ Value: []*armnetwork.SecurityRule{ {Name: nil, ID: new(string)}, &validRule, }, }, }, }, } testClient := &testDefaultSecurityRulesClient{ MockDefaultSecurityRulesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(nsgName, "AllowVnetInBound") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(nsgName, "AllowVnetInBound"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("default security rule not found") mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, "nonexistent-rule", nil).Return( armnetwork.DefaultSecurityRulesClientGetResponse{}, expectedErr) testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(nsgName, "nonexistent-rule") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent rule, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) testClient := &testDefaultSecurityRulesClient{ MockDefaultSecurityRulesClient: mockClient, pager: &errorDefaultSecurityRulesPager{}, } wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } func createAzureDefaultSecurityRule(ruleName, nsgName string) armnetwork.SecurityRule { idStr := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/" + nsgName + "/defaultSecurityRules/" + ruleName typeStr := "Microsoft.Network/networkSecurityGroups/defaultSecurityRules" access := armnetwork.SecurityRuleAccessAllow direction := armnetwork.SecurityRuleDirectionInbound protocol := armnetwork.SecurityRuleProtocolAsterisk priority := int32(65000) return armnetwork.SecurityRule{ ID: &idStr, Name: &ruleName, Type: &typeStr, Properties: &armnetwork.SecurityRulePropertiesFormat{ Access: &access, Direction: &direction, Protocol: &protocol, Priority: &priority, }, } } ================================================ FILE: sources/azure/manual/network-dns-record-set.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ( NetworkDNSRecordSetLookupByRecordType = shared.NewItemTypeLookup("recordType", azureshared.NetworkDNSRecordSet) NetworkDNSRecordSetLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkDNSRecordSet) ) type networkDNSRecordSetWrapper struct { client clients.RecordSetsClient *azureshared.MultiResourceGroupBase } func NewNetworkDNSRecordSet(client clients.RecordSetsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkDNSRecordSetWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkDNSRecordSet, ), } } // recordTypeFromResourceType extracts the DNS record type (e.g. "A", "AAAA") from the ARM resource type (e.g. "Microsoft.Network/dnszones/A"). func recordTypeFromResourceType(resourceType string) string { if resourceType == "" { return "" } parts := strings.Split(resourceType, "/") if len(parts) > 0 { return parts[len(parts)-1] } return "" } // ref: https://learn.microsoft.com/en-us/rest/api/dns/record-sets/get?view=rest-dns-2018-05-01&tabs=HTTP func (n networkDNSRecordSetWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 3 { return nil, azureshared.QueryError(errors.New("Get requires 3 query parts: zoneName, recordType, and relativeRecordSetName"), scope, n.Type()) } zoneName := queryParts[0] recordTypeStr := queryParts[1] relativeRecordSetName := queryParts[2] if zoneName == "" || recordTypeStr == "" || relativeRecordSetName == "" { return nil, azureshared.QueryError(errors.New("zoneName, recordType and relativeRecordSetName cannot be empty"), scope, n.Type()) } recordType := armdns.RecordType(recordTypeStr) rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, zoneName, relativeRecordSetName, recordType, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureRecordSetToSDPItem(&resp.RecordSet, zoneName, scope) } func (n networkDNSRecordSetWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkZoneLookupByName, NetworkDNSRecordSetLookupByRecordType, NetworkDNSRecordSetLookupByName, } } func (n networkDNSRecordSetWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Search requires 1 query part: zoneName"), scope, n.Type()) } zoneName := queryParts[0] if zoneName == "" { return nil, azureshared.QueryError(errors.New("zoneName cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListAllByDNSZonePager(rgScope.ResourceGroup, zoneName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, rs := range page.Value { if rs == nil || rs.Name == nil { continue } item, sdpErr := n.azureRecordSetToSDPItem(rs, zoneName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkDNSRecordSetWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: zoneName"), scope, n.Type())) return } zoneName := queryParts[0] if zoneName == "" { stream.SendError(azureshared.QueryError(errors.New("zoneName cannot be empty"), scope, n.Type())) return } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListAllByDNSZonePager(rgScope.ResourceGroup, zoneName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, rs := range page.Value { if rs == nil || rs.Name == nil { continue } item, sdpErr := n.azureRecordSetToSDPItem(rs, zoneName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkDNSRecordSetWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {NetworkZoneLookupByName}, } } func (n networkDNSRecordSetWrapper) azureRecordSetToSDPItem(rs *armdns.RecordSet, zoneName, scope string) (*sdp.Item, *sdp.QueryError) { if rs.Name == nil { return nil, azureshared.QueryError(errors.New("record set name is nil"), scope, n.Type()) } relativeName := *rs.Name recordTypeStr := "" if rs.Type != nil { recordTypeStr = recordTypeFromResourceType(*rs.Type) } if recordTypeStr == "" { return nil, azureshared.QueryError(errors.New("record set type is nil or invalid"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(rs, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } uniqueAttr := shared.CompositeLookupKey(zoneName, recordTypeStr, relativeName) if err := attributes.Set("uniqueAttr", uniqueAttr); err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkDNSRecordSet.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to parent DNS zone sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkZone.String(), Method: sdp.QueryMethod_GET, Query: zoneName, Scope: scope, }, }) // Link to DNS name (standard library) from FQDN if present if rs.Properties != nil && rs.Properties.Fqdn != nil && *rs.Properties.Fqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *rs.Properties.Fqdn, Scope: "global", }, }) } // LinkedItemQueries for IP addresses and DNS names in record data if rs.Properties != nil { seenIPs := make(map[string]struct{}) seenDNS := make(map[string]struct{}) // A records (IPv4) -> stdlib.NetworkIP, GET, global for _, a := range rs.Properties.ARecords { if a != nil && a.IPv4Address != nil && *a.IPv4Address != "" { ip := *a.IPv4Address if _, seen := seenIPs[ip]; !seen { seenIPs[ip] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: ip, Scope: "global", }, }) } } } // AAAA records (IPv6) -> stdlib.NetworkIP, GET, global for _, aaaa := range rs.Properties.AaaaRecords { if aaaa != nil && aaaa.IPv6Address != nil && *aaaa.IPv6Address != "" { ip := *aaaa.IPv6Address if _, seen := seenIPs[ip]; !seen { seenIPs[ip] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: ip, Scope: "global", }, }) } } } // DNS names in record data -> stdlib.NetworkDNS, SEARCH, global appendDNSLink := func(name string) { if name == "" { return } if _, seen := seenDNS[name]; !seen { seenDNS[name] = struct{}{} sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: name, Scope: "global", }, }) } } if rs.Properties.CnameRecord != nil && rs.Properties.CnameRecord.Cname != nil && *rs.Properties.CnameRecord.Cname != "" { appendDNSLink(*rs.Properties.CnameRecord.Cname) } for _, mx := range rs.Properties.MxRecords { if mx != nil && mx.Exchange != nil && *mx.Exchange != "" { appendDNSLink(*mx.Exchange) } } for _, ns := range rs.Properties.NsRecords { if ns != nil && ns.Nsdname != nil && *ns.Nsdname != "" { appendDNSLink(*ns.Nsdname) } } for _, ptr := range rs.Properties.PtrRecords { if ptr != nil && ptr.Ptrdname != nil && *ptr.Ptrdname != "" { appendDNSLink(*ptr.Ptrdname) } } // SOA Host is the authoritative name server (DNS name). SOA Email is an email in DNS // notation (e.g. admin.example.com = admin@example.com), not a resolvable hostname. if rs.Properties.SoaRecord != nil && rs.Properties.SoaRecord.Host != nil && *rs.Properties.SoaRecord.Host != "" { appendDNSLink(*rs.Properties.SoaRecord.Host) } // Only "issue" and "issuewild" CAA values are DNS names (CA domain). "iodef" values // are URLs (e.g. mailto: or https:) and must not be passed to appendDNSLink. for _, caa := range rs.Properties.CaaRecords { if caa == nil || caa.Tag == nil || caa.Value == nil || *caa.Value == "" { continue } tag := *caa.Tag if tag != "issue" && tag != "issuewild" { continue } appendDNSLink(*caa.Value) } for _, srv := range rs.Properties.SrvRecords { if srv != nil && srv.Target != nil && *srv.Target != "" { appendDNSLink(*srv.Target) } } // TargetResource (Azure resource ID) -> link to referenced resource. // Pass the composite lookup key (extracted query parts) so the target adapter's Get // receives the expected parts when the transformer splits by QuerySeparator; it does // not parse full resource IDs for linked GET queries. // For types in pathKeysMap we use ExtractPathParamsFromResourceIDByType; for simple // single-name resources (e.g. public IP, Traffic Manager) we fall back to ExtractResourceName. if rs.Properties.TargetResource != nil && rs.Properties.TargetResource.ID != nil && *rs.Properties.TargetResource.ID != "" { targetID := *rs.Properties.TargetResource.ID linkScope := azureshared.ExtractScopeFromResourceID(targetID) if linkScope == "" { linkScope = scope } itemType := azureshared.ItemTypeFromLinkedResourceID(targetID) if itemType != "" { queryParts := azureshared.ExtractPathParamsFromResourceIDByType(itemType, targetID) var query string if queryParts != nil { query = shared.CompositeLookupKey(queryParts...) } else { // Simple resource type (no pathKeysMap): use resource name as single query part query = azureshared.ExtractResourceName(targetID) } if query != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: itemType, Method: sdp.QueryMethod_GET, Query: query, Scope: linkScope, }, }) } } } } // Health from provisioning state if rs.Properties != nil && rs.Properties.ProvisioningState != nil { switch *rs.Properties.ProvisioningState { case "Succeeded": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "Creating", "Updating", "Deleting": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "Failed", "Canceled": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return sdpItem, nil } func (n networkDNSRecordSetWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkZone, stdlib.NetworkDNS, stdlib.NetworkIP, ) } func (n networkDNSRecordSetWrapper) TerraformMappings() []*sdp.TerraformMapping { return nil } func (n networkDNSRecordSetWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/dnszones/*/read", } } func (n networkDNSRecordSetWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-dns-record-set_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func createAzureRecordSet(relativeName, recordType, zoneName, subscriptionID, resourceGroup string) *armdns.RecordSet { fqdn := relativeName + "." + zoneName armType := "Microsoft.Network/dnszones/" + recordType provisioningState := "Succeeded" return &armdns.RecordSet{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/dnszones/" + zoneName + "/" + recordType + "/" + relativeName), Name: new(relativeName), Type: new(armType), Properties: &armdns.RecordSetProperties{ Fqdn: new(fqdn), ProvisioningState: &provisioningState, TTL: new(int64(3600)), ARecords: nil, AaaaRecords: nil, CnameRecord: nil, MxRecords: nil, NsRecords: nil, PtrRecords: nil, SoaRecord: nil, SrvRecords: nil, TxtRecords: nil, CaaRecords: nil, TargetResource: nil, Metadata: nil, }, } } type mockRecordSetsPager struct { pages []armdns.RecordSetsClientListAllByDNSZoneResponse index int } func (m *mockRecordSetsPager) More() bool { return m.index < len(m.pages) } func (m *mockRecordSetsPager) NextPage(ctx context.Context) (armdns.RecordSetsClientListAllByDNSZoneResponse, error) { if m.index >= len(m.pages) { return armdns.RecordSetsClientListAllByDNSZoneResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } func TestNetworkDNSRecordSet(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" zoneName := "example.com" relativeName := "www" recordType := "A" query := shared.CompositeLookupKey(zoneName, recordType, relativeName) t.Run("Get", func(t *testing.T) { rs := createAzureRecordSet(relativeName, recordType, zoneName, subscriptionID, resourceGroup) mockClient := mocks.NewMockRecordSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, zoneName, relativeName, armdns.RecordType(recordType), nil).Return( armdns.RecordSetsClientGetResponse{ RecordSet: *rs, }, nil) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkDNSRecordSet.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDNSRecordSet.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(zoneName, recordType, relativeName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkZone.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: zoneName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "www.example.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockRecordSetsClient(ctrl) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Single part (zone only) is insufficient _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], zoneName, true) if qErr == nil { t.Error("Expected error when providing only one query part, got nil") } }) t.Run("Search", func(t *testing.T) { rs1 := createAzureRecordSet("www", "A", zoneName, subscriptionID, resourceGroup) rs2 := createAzureRecordSet("mail", "MX", zoneName, subscriptionID, resourceGroup) mockClient := mocks.NewMockRecordSetsClient(ctrl) mockPager := &mockRecordSetsPager{ pages: []armdns.RecordSetsClientListAllByDNSZoneResponse{ { RecordSetListResult: armdns.RecordSetListResult{ Value: []*armdns.RecordSet{rs1, rs2}, }, }, }, } mockClient.EXPECT().NewListAllByDNSZonePager(resourceGroup, zoneName, nil).Return(mockPager) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not implement SearchableAdapter") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected valid item, got: %v", item.Validate()) } } }) t.Run("SearchStream", func(t *testing.T) { rs := createAzureRecordSet("www", "A", zoneName, subscriptionID, resourceGroup) mockClient := mocks.NewMockRecordSetsClient(ctrl) mockPager := &mockRecordSetsPager{ pages: []armdns.RecordSetsClientListAllByDNSZoneResponse{ { RecordSetListResult: armdns.RecordSetListResult{ Value: []*armdns.RecordSet{rs}, }, }, }, } mockClient.EXPECT().NewListAllByDNSZonePager(resourceGroup, zoneName, nil).Return(mockPager) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) streamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not implement SearchStreamableAdapter") } var received []*sdp.Item stream := discovery.NewQueryResultStream( func(item *sdp.Item) { received = append(received, item) }, func(error) {}, ) streamable.SearchStream(ctx, wrapper.Scopes()[0], zoneName, true, stream) if len(received) != 1 { t.Fatalf("Expected 1 item from SearchStream, got %d", len(received)) } if received[0].GetType() != azureshared.NetworkDNSRecordSet.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDNSRecordSet.String(), received[0].GetType()) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockRecordSetsClient(ctrl) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when providing empty zone name, got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("record set not found") mockClient := mocks.NewMockRecordSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, zoneName, relativeName, armdns.RecordType(recordType), nil).Return( armdns.RecordSetsClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when Get fails, got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockRecordSetsClient(ctrl) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() if !potentialLinks[azureshared.NetworkZone] { t.Error("Expected PotentialLinks to include NetworkZone") } if !potentialLinks[stdlib.NetworkDNS] { t.Error("Expected PotentialLinks to include stdlib.NetworkDNS") } if !potentialLinks[stdlib.NetworkIP] { t.Error("Expected PotentialLinks to include stdlib.NetworkIP") } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockRecordSetsClient(ctrl) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) perms := wrapper.IAMPermissions() if len(perms) == 0 { t.Error("Expected at least one IAM permission") } expectedPermission := "Microsoft.Network/dnszones/*/read" found := slices.Contains(perms, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %q", expectedPermission) } }) t.Run("GetWithARecordsAndCnameLinkedQueries", func(t *testing.T) { rs := createAzureRecordSet(relativeName, recordType, zoneName, subscriptionID, resourceGroup) rs.Properties.ARecords = []*armdns.ARecord{{IPv4Address: new("192.168.1.1")}} rs.Properties.CnameRecord = &armdns.CnameRecord{Cname: new("backend.example.com")} mockClient := mocks.NewMockRecordSetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, zoneName, relativeName, armdns.RecordType(recordType), nil).Return( armdns.RecordSetsClientGetResponse{RecordSet: *rs}, nil) wrapper := manual.NewNetworkDNSRecordSet(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } var hasIPLink, hasCnameLink bool for _, lq := range sdpItem.GetLinkedItemQueries() { q := lq.GetQuery() if q == nil { continue } if q.GetType() == stdlib.NetworkIP.String() && q.GetQuery() == "192.168.1.1" && q.GetMethod() == sdp.QueryMethod_GET && q.GetScope() == "global" { hasIPLink = true } if q.GetType() == stdlib.NetworkDNS.String() && q.GetQuery() == "backend.example.com" && q.GetMethod() == sdp.QueryMethod_SEARCH && q.GetScope() == "global" { hasCnameLink = true } } if !hasIPLink { t.Error("Expected LinkedItemQueries to include stdlib.NetworkIP for A record 192.168.1.1 (GET, global)") } if !hasCnameLink { t.Error("Expected LinkedItemQueries to include stdlib.NetworkDNS for CNAME backend.example.com (SEARCH, global)") } }) } ================================================ FILE: sources/azure/manual/network-dns-virtual-network-link.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkDNSVirtualNetworkLinkLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkDNSVirtualNetworkLink) type networkDNSVirtualNetworkLinkWrapper struct { client clients.VirtualNetworkLinksClient *azureshared.MultiResourceGroupBase } func NewNetworkDNSVirtualNetworkLink(client clients.VirtualNetworkLinksClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkDNSVirtualNetworkLinkWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkDNSVirtualNetworkLink, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/dns/privatednszones/virtualnetworklinks/get func (c networkDNSVirtualNetworkLinkWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, azureshared.QueryError(errors.New("Get requires 2 query parts: privateZoneName and virtualNetworkLinkName"), scope, c.Type()) } privateZoneName := queryParts[0] linkName := queryParts[1] if privateZoneName == "" || linkName == "" { return nil, azureshared.QueryError(errors.New("privateZoneName and virtualNetworkLinkName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, privateZoneName, linkName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureVirtualNetworkLinkToSDPItem(&resp.VirtualNetworkLink, privateZoneName, scope) } func (c networkDNSVirtualNetworkLinkWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkPrivateDNSZoneLookupByName, NetworkDNSVirtualNetworkLinkLookupByName, } } // ref: https://learn.microsoft.com/en-us/rest/api/dns/privatednszones/virtualnetworklinks/list func (c networkDNSVirtualNetworkLinkWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Search requires 1 query part: privateZoneName"), scope, c.Type()) } privateZoneName := queryParts[0] if privateZoneName == "" { return nil, azureshared.QueryError(errors.New("privateZoneName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, privateZoneName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, link := range page.Value { if link == nil || link.Name == nil { continue } item, sdpErr := c.azureVirtualNetworkLinkToSDPItem(link, privateZoneName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c networkDNSVirtualNetworkLinkWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: privateZoneName"), scope, c.Type())) return } privateZoneName := queryParts[0] if privateZoneName == "" { stream.SendError(azureshared.QueryError(errors.New("privateZoneName cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, privateZoneName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, link := range page.Value { if link == nil || link.Name == nil { continue } item, sdpErr := c.azureVirtualNetworkLinkToSDPItem(link, privateZoneName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c networkDNSVirtualNetworkLinkWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {NetworkPrivateDNSZoneLookupByName}, } } func (c networkDNSVirtualNetworkLinkWrapper) azureVirtualNetworkLinkToSDPItem(link *armprivatedns.VirtualNetworkLink, privateZoneName, scope string) (*sdp.Item, *sdp.QueryError) { if link.Name == nil { return nil, azureshared.QueryError(errors.New("virtual network link name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(link, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } uniqueAttr := shared.CompositeLookupKey(privateZoneName, *link.Name) if err := attributes.Set("uniqueAttr", uniqueAttr); err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkDNSVirtualNetworkLink.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(link.Tags), } // Health from provisioning state if link.Properties != nil && link.Properties.ProvisioningState != nil { switch *link.Properties.ProvisioningState { case armprivatedns.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armprivatedns.ProvisioningStateCreating, armprivatedns.ProvisioningStateUpdating, armprivatedns.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armprivatedns.ProvisioningStateFailed, armprivatedns.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Private DNS Zone sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateDNSZone.String(), Method: sdp.QueryMethod_GET, Query: privateZoneName, Scope: scope, }, }) // Link to the Virtual Network referenced by this link if link.Properties != nil && link.Properties.VirtualNetwork != nil && link.Properties.VirtualNetwork.ID != nil { vnetName := azureshared.ExtractResourceName(*link.Properties.VirtualNetwork.ID) if vnetName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*link.Properties.VirtualNetwork.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (c networkDNSVirtualNetworkLinkWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkPrivateDNSZone, azureshared.NetworkVirtualNetwork, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_zone_virtual_network_link func (c networkDNSVirtualNetworkLinkWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_private_dns_zone_virtual_network_link.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (c networkDNSVirtualNetworkLinkWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/privateDnsZones/virtualNetworkLinks/read", } } func (c networkDNSVirtualNetworkLinkWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-dns-virtual-network-link_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func createAzureVirtualNetworkLink(name, privateZoneName, subscriptionID, resourceGroup string) *armprivatedns.VirtualNetworkLink { provisioningState := armprivatedns.ProvisioningStateSucceeded linkState := armprivatedns.VirtualNetworkLinkStateCompleted registrationEnabled := true return &armprivatedns.VirtualNetworkLink{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateDnsZones/" + privateZoneName + "/virtualNetworkLinks/" + name), Name: new(name), Type: new("Microsoft.Network/privateDnsZones/virtualNetworkLinks"), Location: new("global"), Tags: map[string]*string{"env": new("test")}, Properties: &armprivatedns.VirtualNetworkLinkProperties{ ProvisioningState: &provisioningState, VirtualNetworkLinkState: &linkState, RegistrationEnabled: ®istrationEnabled, VirtualNetwork: &armprivatedns.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet"), }, }, } } type mockVirtualNetworkLinksPager struct { pages []armprivatedns.VirtualNetworkLinksClientListResponse index int } func (m *mockVirtualNetworkLinksPager) More() bool { return m.index < len(m.pages) } func (m *mockVirtualNetworkLinksPager) NextPage(_ context.Context) (armprivatedns.VirtualNetworkLinksClientListResponse, error) { if m.index >= len(m.pages) { return armprivatedns.VirtualNetworkLinksClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } func newMockVirtualNetworkLinksPager(_ *gomock.Controller, items []*armprivatedns.VirtualNetworkLink) clients.VirtualNetworkLinksPager { return &mockVirtualNetworkLinksPager{ pages: []armprivatedns.VirtualNetworkLinksClientListResponse{ { VirtualNetworkLinkListResult: armprivatedns.VirtualNetworkLinkListResult{ Value: items, }, }, }, } } func TestNetworkDNSVirtualNetworkLink(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" privateZoneName := "example.private.zone" linkName := "test-link" query := shared.CompositeLookupKey(privateZoneName, linkName) t.Run("Get", func(t *testing.T) { link := createAzureVirtualNetworkLink(linkName, privateZoneName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, privateZoneName, linkName, nil).Return( armprivatedns.VirtualNetworkLinksClientGetResponse{ VirtualNetworkLink: *link, }, nil) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkDNSVirtualNetworkLink.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDNSVirtualNetworkLink.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUnique := shared.CompositeLookupKey(privateZoneName, linkName) if sdpItem.UniqueAttributeValue() != expectedUnique { t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkPrivateDNSZone.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: privateZoneName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], privateZoneName, true) if qErr == nil { t.Error("Expected error when providing only one query part, got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) emptyQuery := shared.CompositeLookupKey(privateZoneName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], emptyQuery, true) if qErr == nil { t.Error("Expected error when getting resource with empty link name, but got nil") } }) t.Run("Search", func(t *testing.T) { link1 := createAzureVirtualNetworkLink("link-1", privateZoneName, subscriptionID, resourceGroup) link2 := createAzureVirtualNetworkLink("link-2", privateZoneName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) mockPager := newMockVirtualNetworkLinksPager(ctrl, []*armprivatedns.VirtualNetworkLink{link1, link2}) mockClient.EXPECT().NewListPager(resourceGroup, privateZoneName, nil).Return(mockPager) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not implement SearchableAdapter") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], privateZoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected valid item, got: %v", item.Validate()) } } }) t.Run("SearchStream", func(t *testing.T) { link := createAzureVirtualNetworkLink(linkName, privateZoneName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) mockPager := newMockVirtualNetworkLinksPager(ctrl, []*armprivatedns.VirtualNetworkLink{link}) mockClient.EXPECT().NewListPager(resourceGroup, privateZoneName, nil).Return(mockPager) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) streamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not implement SearchStreamableAdapter") } var received []*sdp.Item stream := discovery.NewQueryResultStream( func(item *sdp.Item) { received = append(received, item) }, func(error) {}, ) streamable.SearchStream(ctx, wrapper.Scopes()[0], privateZoneName, true, stream) if len(received) != 1 { t.Fatalf("Expected 1 item from SearchStream, got %d", len(received)) } if received[0].GetType() != azureshared.NetworkDNSVirtualNetworkLink.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkDNSVirtualNetworkLink.String(), received[0].GetType()) } }) t.Run("SearchWithEmptyZoneName", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when providing empty zone name, got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("virtual network link not found") mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, privateZoneName, linkName, nil).Return( armprivatedns.VirtualNetworkLinksClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when Get fails, got nil") } }) t.Run("GetWithCrossResourceGroupVNet", func(t *testing.T) { link := createAzureVirtualNetworkLink(linkName, privateZoneName, subscriptionID, resourceGroup) link.Properties.VirtualNetwork = &armprivatedns.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/Microsoft.Network/virtualNetworks/cross-rg-vnet"), } mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, privateZoneName, linkName, nil).Return( armprivatedns.VirtualNetworkLinksClientGetResponse{ VirtualNetworkLink: *link, }, nil) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } var hasVNetLink bool for _, lq := range sdpItem.GetLinkedItemQueries() { q := lq.GetQuery() if q.GetType() == azureshared.NetworkVirtualNetwork.String() && q.GetQuery() == "cross-rg-vnet" && q.GetScope() == subscriptionID+".other-rg" { hasVNetLink = true } } if !hasVNetLink { t.Error("Expected LinkedItemQueries to include VirtualNetwork with cross-resource-group scope") } }) t.Run("GetWithoutVirtualNetwork", func(t *testing.T) { link := createAzureVirtualNetworkLink(linkName, privateZoneName, subscriptionID, resourceGroup) link.Properties.VirtualNetwork = nil mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, privateZoneName, linkName, nil).Return( armprivatedns.VirtualNetworkLinksClientGetResponse{ VirtualNetworkLink: *link, }, nil) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should have only the parent DNS zone link, not a VNet link vnetLinks := 0 for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkVirtualNetwork.String() { vnetLinks++ } } if vnetLinks != 0 { t.Errorf("Expected no VirtualNetwork linked queries when VirtualNetwork is nil, got %d", vnetLinks) } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkLinksClient(ctrl) wrapper := manual.NewNetworkDNSVirtualNetworkLink(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() if !potentialLinks[azureshared.NetworkPrivateDNSZone] { t.Error("Expected PotentialLinks to include NetworkPrivateDNSZone") } if !potentialLinks[azureshared.NetworkVirtualNetwork] { t.Error("Expected PotentialLinks to include NetworkVirtualNetwork") } }) } ================================================ FILE: sources/azure/manual/network-flow-log.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var ( NetworkWatcherLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkNetworkWatcher) NetworkFlowLogLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkFlowLog) ) type networkFlowLogWrapper struct { client clients.FlowLogsClient *azureshared.MultiResourceGroupBase } // NewNetworkFlowLog creates a new networkFlowLogWrapper instance (SearchableWrapper: child of network watcher). func NewNetworkFlowLog(client clients.FlowLogsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkFlowLogWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkFlowLog, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/network-watcher/flow-logs/get func (c networkFlowLogWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: networkWatcherName and flowLogName", Scope: scope, ItemType: c.Type(), } } networkWatcherName := queryParts[0] flowLogName := queryParts[1] if networkWatcherName == "" { return nil, azureshared.QueryError(errors.New("networkWatcherName cannot be empty"), scope, c.Type()) } if flowLogName == "" { return nil, azureshared.QueryError(errors.New("flowLogName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, networkWatcherName, flowLogName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureFlowLogToSDPItem(&resp.FlowLog, networkWatcherName, flowLogName, scope) } func (c networkFlowLogWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkWatcherLookupByName, NetworkFlowLogLookupByUniqueAttr, } } // ref: https://learn.microsoft.com/en-us/rest/api/network-watcher/flow-logs/list func (c networkFlowLogWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: networkWatcherName", Scope: scope, ItemType: c.Type(), } } networkWatcherName := queryParts[0] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, networkWatcherName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, flowLog := range page.Value { if flowLog == nil || flowLog.Name == nil { continue } item, sdpErr := c.azureFlowLogToSDPItem(flowLog, networkWatcherName, *flowLog.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c networkFlowLogWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: networkWatcherName"), scope, c.Type())) return } networkWatcherName := queryParts[0] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, networkWatcherName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, flowLog := range page.Value { if flowLog == nil || flowLog.Name == nil { continue } item, sdpErr := c.azureFlowLogToSDPItem(flowLog, networkWatcherName, *flowLog.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c networkFlowLogWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {NetworkWatcherLookupByName}, } } func (c networkFlowLogWrapper) azureFlowLogToSDPItem(flowLog *armnetwork.FlowLog, networkWatcherName, flowLogName, scope string) (*sdp.Item, *sdp.QueryError) { if flowLog.Name == nil { return nil, azureshared.QueryError(errors.New("resource name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(flowLog, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(networkWatcherName, flowLogName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkFlowLog.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(flowLog.Tags), } if flowLog.Properties != nil { // Health mapping from ProvisioningState if flowLog.Properties.ProvisioningState != nil { switch *flowLog.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to TargetResourceID (polymorphic: NSG, VNet, or Subnet) if flowLog.Properties.TargetResourceID != nil && *flowLog.Properties.TargetResourceID != "" { targetID := *flowLog.Properties.TargetResourceID linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(targetID); extractedScope != "" { linkedScope = extractedScope } switch { case strings.Contains(targetID, "/networkSecurityGroups/"): nsgName := azureshared.ExtractResourceName(targetID) if nsgName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: nsgName, Scope: linkedScope, }, }) } case strings.Contains(targetID, "/subnets/"): params := azureshared.ExtractPathParamsFromResourceID(targetID, []string{"virtualNetworks", "subnets"}) if len(params) >= 2 { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } case strings.Contains(targetID, "/virtualNetworks/"): vnetName := azureshared.ExtractResourceName(targetID) if vnetName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } } // Link to StorageID (storage account) if flowLog.Properties.StorageID != nil && *flowLog.Properties.StorageID != "" { storageAccountName := azureshared.ExtractResourceName(*flowLog.Properties.StorageID) if storageAccountName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*flowLog.Properties.StorageID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: linkedScope, }, }) } } // Link to Traffic Analytics workspace if flowLog.Properties.FlowAnalyticsConfiguration != nil && flowLog.Properties.FlowAnalyticsConfiguration.NetworkWatcherFlowAnalyticsConfiguration != nil && flowLog.Properties.FlowAnalyticsConfiguration.NetworkWatcherFlowAnalyticsConfiguration.WorkspaceResourceID != nil && *flowLog.Properties.FlowAnalyticsConfiguration.NetworkWatcherFlowAnalyticsConfiguration.WorkspaceResourceID != "" { workspaceName := azureshared.ExtractResourceName(*flowLog.Properties.FlowAnalyticsConfiguration.NetworkWatcherFlowAnalyticsConfiguration.WorkspaceResourceID) if workspaceName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*flowLog.Properties.FlowAnalyticsConfiguration.NetworkWatcherFlowAnalyticsConfiguration.WorkspaceResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.OperationalInsightsWorkspace.String(), Method: sdp.QueryMethod_GET, Query: workspaceName, Scope: linkedScope, }, }) } } } // Link to parent NetworkWatcher sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkWatcher.String(), Method: sdp.QueryMethod_GET, Query: networkWatcherName, Scope: scope, }, }) // Link to user-assigned managed identities if flowLog.Identity != nil && flowLog.Identity.UserAssignedIdentities != nil { for identityID := range flowLog.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } return sdpItem, nil } func (c networkFlowLogWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkNetworkWatcher, azureshared.NetworkNetworkSecurityGroup, azureshared.NetworkVirtualNetwork, azureshared.NetworkSubnet, azureshared.StorageAccount, azureshared.OperationalInsightsWorkspace, azureshared.ManagedIdentityUserAssignedIdentity, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (c networkFlowLogWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/networkWatchers/flowLogs/read", } } func (c networkFlowLogWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-flow-log_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockFlowLogsPager struct { pages []armnetwork.FlowLogsClientListResponse index int } func (m *mockFlowLogsPager) More() bool { return m.index < len(m.pages) } func (m *mockFlowLogsPager) NextPage(ctx context.Context) (armnetwork.FlowLogsClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.FlowLogsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorFlowLogsPager struct{} func (e *errorFlowLogsPager) More() bool { return true } func (e *errorFlowLogsPager) NextPage(ctx context.Context) (armnetwork.FlowLogsClientListResponse, error) { return armnetwork.FlowLogsClientListResponse{}, errors.New("pager error") } type testFlowLogsClient struct { *mocks.MockFlowLogsClient pager clients.FlowLogsPager } func (t *testFlowLogsClient) NewListPager(resourceGroupName, networkWatcherName string, options *armnetwork.FlowLogsClientListOptions) clients.FlowLogsPager { return t.pager } func TestNetworkFlowLog(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" networkWatcherName := "test-watcher" flowLogName := "test-flow-log" t.Run("Get", func(t *testing.T) { flowLog := createAzureFlowLog(flowLogName, networkWatcherName, subscriptionID, resourceGroup) mockClient := mocks.NewMockFlowLogsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkWatcherName, flowLogName, nil).Return( armnetwork.FlowLogsClientGetResponse{ FlowLog: *flowLog, }, nil) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkWatcherName, flowLogName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkFlowLog.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkFlowLog, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(networkWatcherName, flowLogName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(networkWatcherName, flowLogName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkNetworkSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nsg", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.StorageAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "teststorageaccount", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.OperationalInsightsWorkspace.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-workspace", ExpectedScope: subscriptionID + ".test-workspace-rg", }, { ExpectedType: azureshared.NetworkNetworkWatcher.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: networkWatcherName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.ManagedIdentityUserAssignedIdentity.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-identity", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_VNetTarget", func(t *testing.T) { flowLog := createAzureFlowLogWithVNetTarget(flowLogName, networkWatcherName, subscriptionID, resourceGroup) mockClient := mocks.NewMockFlowLogsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkWatcherName, flowLogName, nil).Return( armnetwork.FlowLogsClientGetResponse{ FlowLog: *flowLog, }, nil) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkWatcherName, flowLogName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } found := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.NetworkVirtualNetwork.String() { found = true if link.GetQuery().GetQuery() != "test-vnet" { t.Errorf("Expected VNet query 'test-vnet', got %s", link.GetQuery().GetQuery()) } } } if !found { t.Error("Expected a linked item query for VirtualNetwork, but none found") } }) t.Run("Get_SubnetTarget", func(t *testing.T) { flowLog := createAzureFlowLogWithSubnetTarget(flowLogName, networkWatcherName, subscriptionID, resourceGroup) mockClient := mocks.NewMockFlowLogsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkWatcherName, flowLogName, nil).Return( armnetwork.FlowLogsClientGetResponse{ FlowLog: *flowLog, }, nil) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkWatcherName, flowLogName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } found := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.NetworkSubnet.String() { found = true expectedQuery := shared.CompositeLookupKey("test-vnet", "test-subnet") if link.GetQuery().GetQuery() != expectedQuery { t.Errorf("Expected Subnet query %s, got %s", expectedQuery, link.GetQuery().GetQuery()) } } } if !found { t.Error("Expected a linked item query for Subnet, but none found") } }) t.Run("Get_EmptyFlowLogName", func(t *testing.T) { mockClient := mocks.NewMockFlowLogsClient(ctrl) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkWatcherName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when flow log name is empty, but got nil") } }) t.Run("Get_EmptyNetworkWatcherName", func(t *testing.T) { mockClient := mocks.NewMockFlowLogsClient(ctrl) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", flowLogName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when network watcher name is empty, but got nil") } }) t.Run("Get_InsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockFlowLogsClient(ctrl) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], networkWatcherName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { flowLog1 := createAzureFlowLog("flow-log-1", networkWatcherName, subscriptionID, resourceGroup) flowLog2 := createAzureFlowLog("flow-log-2", networkWatcherName, subscriptionID, resourceGroup) mockClient := mocks.NewMockFlowLogsClient(ctrl) mockPager := &mockFlowLogsPager{ pages: []armnetwork.FlowLogsClientListResponse{ { FlowLogListResult: armnetwork.FlowLogListResult{ Value: []*armnetwork.FlowLog{flowLog1, flowLog2}, }, }, }, } testClient := &testFlowLogsClient{ MockFlowLogsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], networkWatcherName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkFlowLog.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkFlowLog, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockFlowLogsClient(ctrl) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_FlowLogWithNilName", func(t *testing.T) { validFlowLog := createAzureFlowLog("valid-flow-log", networkWatcherName, subscriptionID, resourceGroup) mockClient := mocks.NewMockFlowLogsClient(ctrl) mockPager := &mockFlowLogsPager{ pages: []armnetwork.FlowLogsClientListResponse{ { FlowLogListResult: armnetwork.FlowLogListResult{ Value: []*armnetwork.FlowLog{ {Name: nil, ID: new("/some/id")}, validFlowLog, }, }, }, }, } testClient := &testFlowLogsClient{ MockFlowLogsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], networkWatcherName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(networkWatcherName, "valid-flow-log") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(networkWatcherName, "valid-flow-log"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("flow log not found") mockClient := mocks.NewMockFlowLogsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkWatcherName, "nonexistent", nil).Return( armnetwork.FlowLogsClientGetResponse{}, expectedErr) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkWatcherName, "nonexistent") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent flow log, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockFlowLogsClient(ctrl) testClient := &testFlowLogsClient{ MockFlowLogsClient: mockClient, pager: &errorFlowLogsPager{}, } wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], networkWatcherName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("HealthMapping", func(t *testing.T) { tests := []struct { name string state armnetwork.ProvisioningState expectedHealth sdp.Health }{ {"Succeeded", armnetwork.ProvisioningStateSucceeded, sdp.Health_HEALTH_OK}, {"Updating", armnetwork.ProvisioningStateUpdating, sdp.Health_HEALTH_PENDING}, {"Failed", armnetwork.ProvisioningStateFailed, sdp.Health_HEALTH_ERROR}, {"Unknown", armnetwork.ProvisioningState("SomeOtherState"), sdp.Health_HEALTH_UNKNOWN}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { flowLog := createAzureFlowLog(flowLogName, networkWatcherName, subscriptionID, resourceGroup) flowLog.Properties.ProvisioningState = &tc.state mockClient := mocks.NewMockFlowLogsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkWatcherName, flowLogName, nil).Return( armnetwork.FlowLogsClientGetResponse{ FlowLog: *flowLog, }, nil) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkWatcherName, flowLogName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expectedHealth { t.Errorf("Expected health %s, got %s", tc.expectedHealth, sdpItem.GetHealth()) } }) } }) t.Run("Get_NoLinks", func(t *testing.T) { flowLog := createAzureFlowLogWithoutLinks(flowLogName, networkWatcherName, subscriptionID, resourceGroup) mockClient := mocks.NewMockFlowLogsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkWatcherName, flowLogName, nil).Return( armnetwork.FlowLogsClientGetResponse{ FlowLog: *flowLog, }, nil) testClient := &testFlowLogsClient{MockFlowLogsClient: mockClient} wrapper := manual.NewNetworkFlowLog(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkWatcherName, flowLogName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should only have the parent NetworkWatcher link if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Errorf("Expected 1 linked query (parent only), got %d", len(sdpItem.GetLinkedItemQueries())) } if sdpItem.GetLinkedItemQueries()[0].GetQuery().GetType() != azureshared.NetworkNetworkWatcher.String() { t.Errorf("Expected parent link to NetworkWatcher, got %s", sdpItem.GetLinkedItemQueries()[0].GetQuery().GetType()) } }) } func createAzureFlowLog(name, networkWatcherName, subscriptionID, resourceGroup string) *armnetwork.FlowLog { provisioningState := armnetwork.ProvisioningStateSucceeded enabled := true nsgID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/networkSecurityGroups/test-nsg" storageID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Storage/storageAccounts/teststorageaccount" workspaceResourceID := "/subscriptions/" + subscriptionID + "/resourceGroups/test-workspace-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace" identityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" return &armnetwork.FlowLog{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/networkWatchers/" + networkWatcherName + "/flowLogs/" + name), Name: &name, Type: new("Microsoft.Network/networkWatchers/flowLogs"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Identity: &armnetwork.ManagedServiceIdentity{ UserAssignedIdentities: map[string]*armnetwork.Components1Jq1T4ISchemasManagedserviceidentityPropertiesUserassignedidentitiesAdditionalproperties{ identityID: {}, }, }, Properties: &armnetwork.FlowLogPropertiesFormat{ TargetResourceID: &nsgID, StorageID: &storageID, Enabled: &enabled, ProvisioningState: &provisioningState, FlowAnalyticsConfiguration: &armnetwork.TrafficAnalyticsProperties{ NetworkWatcherFlowAnalyticsConfiguration: &armnetwork.TrafficAnalyticsConfigurationProperties{ Enabled: &enabled, WorkspaceResourceID: &workspaceResourceID, }, }, RetentionPolicy: &armnetwork.RetentionPolicyParameters{ Enabled: &enabled, Days: new(int32(90)), }, }, } } func createAzureFlowLogWithVNetTarget(name, networkWatcherName, subscriptionID, resourceGroup string) *armnetwork.FlowLog { provisioningState := armnetwork.ProvisioningStateSucceeded enabled := true vnetID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet" storageID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Storage/storageAccounts/teststorageaccount" return &armnetwork.FlowLog{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/networkWatchers/" + networkWatcherName + "/flowLogs/" + name), Name: &name, Type: new("Microsoft.Network/networkWatchers/flowLogs"), Location: new("eastus"), Properties: &armnetwork.FlowLogPropertiesFormat{ TargetResourceID: &vnetID, StorageID: &storageID, Enabled: &enabled, ProvisioningState: &provisioningState, }, } } func createAzureFlowLogWithSubnetTarget(name, networkWatcherName, subscriptionID, resourceGroup string) *armnetwork.FlowLog { provisioningState := armnetwork.ProvisioningStateSucceeded enabled := true subnetID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" storageID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Storage/storageAccounts/teststorageaccount" return &armnetwork.FlowLog{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/networkWatchers/" + networkWatcherName + "/flowLogs/" + name), Name: &name, Type: new("Microsoft.Network/networkWatchers/flowLogs"), Location: new("eastus"), Properties: &armnetwork.FlowLogPropertiesFormat{ TargetResourceID: &subnetID, StorageID: &storageID, Enabled: &enabled, ProvisioningState: &provisioningState, }, } } func createAzureFlowLogWithoutLinks(name, networkWatcherName, subscriptionID, resourceGroup string) *armnetwork.FlowLog { return &armnetwork.FlowLog{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/networkWatchers/" + networkWatcherName + "/flowLogs/" + name), Name: &name, Type: new("Microsoft.Network/networkWatchers/flowLogs"), Location: new("eastus"), } } ================================================ FILE: sources/azure/manual/network-ip-group.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkIPGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkIPGroup) type networkIPGroupWrapper struct { client clients.IPGroupsClient *azureshared.MultiResourceGroupBase } // NewNetworkIPGroup creates a new networkIPGroupWrapper instance. func NewNetworkIPGroup(client clients.IPGroupsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkIPGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkIPGroup, ), } } // List retrieves all IP groups in a resource group. // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/ip-groups/list-by-resource-group func (c networkIPGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, ipGroup := range page.Value { if ipGroup.Name == nil { continue } item, sdpErr := c.azureIPGroupToSDPItem(ipGroup, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } // ListStream streams all IP groups in a resource group. func (c networkIPGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, ipGroup := range page.Value { if ipGroup.Name == nil { continue } item, sdpErr := c.azureIPGroupToSDPItem(ipGroup, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // Get retrieves a single IP group by name. // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/ip-groups/get func (c networkIPGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part (IP group name)"), scope, c.Type()) } ipGroupName := queryParts[0] if ipGroupName == "" { return nil, azureshared.QueryError(errors.New("IP group name cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, ipGroupName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureIPGroupToSDPItem(&resp.IPGroup, scope) } func (c networkIPGroupWrapper) azureIPGroupToSDPItem(ipGroup *armnetwork.IPGroup, scope string) (*sdp.Item, *sdp.QueryError) { if ipGroup.Name == nil { return nil, azureshared.QueryError(errors.New("IP group name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(ipGroup, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkIPGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(ipGroup.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Health from provisioning state if ipGroup.Properties != nil && ipGroup.Properties.ProvisioningState != nil { switch *ipGroup.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } if ipGroup.Properties != nil { // Link to IP addresses // IP Groups contain a list of IP addresses or prefixes for _, ipAddr := range ipGroup.Properties.IPAddresses { if ipAddr != nil && *ipAddr != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipAddr, Scope: "global", }, }) } } // Link to Firewalls (read-only, references back to Azure Firewalls using this IP Group) // Note: These are SubResource references containing just IDs for _, firewall := range ipGroup.Properties.Firewalls { if firewall != nil && firewall.ID != nil { firewallName := azureshared.ExtractResourceName(*firewall.ID) if firewallName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*firewall.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkFirewall.String(), Method: sdp.QueryMethod_GET, Query: firewallName, Scope: linkedScope, }, }) } } } // Link to Firewall Policies (read-only, references back to Firewall Policies using this IP Group) for _, policy := range ipGroup.Properties.FirewallPolicies { if policy != nil && policy.ID != nil { policyName := azureshared.ExtractResourceName(*policy.ID) if policyName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*policy.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkFirewallPolicy.String(), Method: sdp.QueryMethod_GET, Query: policyName, Scope: linkedScope, }, }) } } } } return sdpItem, nil } func (c networkIPGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkIPGroupLookupByName, } } func (c networkIPGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ stdlib.NetworkIP: true, azureshared.NetworkFirewall: true, azureshared.NetworkFirewallPolicy: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (c networkIPGroupWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/ipGroups/read", } } func (c networkIPGroupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-ip-group_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkIPGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { ipGroupName := "test-ip-group" ipGroup := createAzureIPGroup(ipGroupName) mockClient := mocks.NewMockIPGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, ipGroupName, nil).Return( armnetwork.IPGroupsClientGetResponse{ IPGroup: *ipGroup, }, nil) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], ipGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkIPGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkIPGroup, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != ipGroupName { t.Errorf("Expected unique attribute value %s, got %s", ipGroupName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.0/24", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: azureshared.NetworkFirewall.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-firewall", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkFirewallPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-firewall-policy", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockIPGroupsClient(ctrl) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when IP group name is empty, but got nil") } }) t.Run("Get_IPGroupWithNilName", func(t *testing.T) { provisioningState := armnetwork.ProvisioningStateSucceeded ipGroupWithNilName := &armnetwork.IPGroup{ Name: nil, Location: new("eastus"), Properties: &armnetwork.IPGroupPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockIPGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-ip-group", nil).Return( armnetwork.IPGroupsClientGetResponse{ IPGroup: *ipGroupWithNilName, }, nil) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-ip-group", true) if qErr == nil { t.Error("Expected error when IP group has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { ipGroup1 := createAzureIPGroup("ip-group-1") ipGroup2 := createAzureIPGroup("ip-group-2") mockClient := mocks.NewMockIPGroupsClient(ctrl) mockPager := newMockIPGroupsPager(ctrl, []*armnetwork.IPGroup{ipGroup1, ipGroup2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkIPGroup.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkIPGroup, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { ipGroup1 := createAzureIPGroup("ip-group-1") provisioningState := armnetwork.ProvisioningStateSucceeded ipGroup2NilName := &armnetwork.IPGroup{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, Properties: &armnetwork.IPGroupPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockIPGroupsClient(ctrl) mockPager := newMockIPGroupsPager(ctrl, []*armnetwork.IPGroup{ipGroup1, ipGroup2NilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "ip-group-1" { t.Errorf("Expected item name 'ip-group-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ListStream", func(t *testing.T) { ipGroup1 := createAzureIPGroup("stream-ip-group-1") ipGroup2 := createAzureIPGroup("stream-ip-group-2") mockClient := mocks.NewMockIPGroupsClient(ctrl) mockPager := newMockIPGroupsPager(ctrl, []*armnetwork.IPGroup{ipGroup1, ipGroup2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("IP group not found") mockClient := mocks.NewMockIPGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-ip-group", nil).Return( armnetwork.IPGroupsClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-ip-group", true) if qErr == nil { t.Error("Expected error when getting non-existent IP group, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockIPGroupsClient(ctrl) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/ipGroups/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } lookups := w.GetLookups() foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.NetworkIPGroup { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include NetworkIPGroup") } potentialLinks := w.PotentialLinks() if !potentialLinks[stdlib.NetworkIP] { t.Error("Expected PotentialLinks to include stdlib.NetworkIP") } if !potentialLinks[azureshared.NetworkFirewall] { t.Error("Expected PotentialLinks to include NetworkFirewall") } if !potentialLinks[azureshared.NetworkFirewallPolicy] { t.Error("Expected PotentialLinks to include NetworkFirewallPolicy") } }) t.Run("HealthStatus", func(t *testing.T) { tests := []struct { name string provisioningState armnetwork.ProvisioningState expectedHealth sdp.Health }{ {"Succeeded", armnetwork.ProvisioningStateSucceeded, sdp.Health_HEALTH_OK}, {"Updating", armnetwork.ProvisioningStateUpdating, sdp.Health_HEALTH_PENDING}, {"Deleting", armnetwork.ProvisioningStateDeleting, sdp.Health_HEALTH_PENDING}, {"Failed", armnetwork.ProvisioningStateFailed, sdp.Health_HEALTH_ERROR}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ipGroup := &armnetwork.IPGroup{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/ipGroups/test-ip-group"), Name: new("test-ip-group"), Type: new("Microsoft.Network/ipGroups"), Location: new("eastus"), Tags: map[string]*string{}, Properties: &armnetwork.IPGroupPropertiesFormat{ ProvisioningState: &tc.provisioningState, }, } mockClient := mocks.NewMockIPGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-ip-group", nil).Return( armnetwork.IPGroupsClientGetResponse{ IPGroup: *ipGroup, }, nil) wrapper := manual.NewNetworkIPGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-ip-group", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expectedHealth { t.Errorf("Expected health %v, got %v", tc.expectedHealth, sdpItem.GetHealth()) } }) } }) } type mockIPGroupsPager struct { ctrl *gomock.Controller items []*armnetwork.IPGroup index int more bool } func newMockIPGroupsPager(ctrl *gomock.Controller, items []*armnetwork.IPGroup) clients.IPGroupsPager { return &mockIPGroupsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockIPGroupsPager) More() bool { return m.more } func (m *mockIPGroupsPager) NextPage(ctx context.Context) (armnetwork.IPGroupsClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.IPGroupsClientListByResourceGroupResponse{ IPGroupListResult: armnetwork.IPGroupListResult{ Value: []*armnetwork.IPGroup{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.IPGroupsClientListByResourceGroupResponse{ IPGroupListResult: armnetwork.IPGroupListResult{ Value: []*armnetwork.IPGroup{item}, }, }, nil } func createAzureIPGroup(name string) *armnetwork.IPGroup { provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.IPGroup{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/ipGroups/" + name), Name: new(name), Type: new("Microsoft.Network/ipGroups"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.IPGroupPropertiesFormat{ ProvisioningState: &provisioningState, IPAddresses: []*string{ new("10.0.0.0/24"), new("192.168.1.1"), }, Firewalls: []*armnetwork.SubResource{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/azureFirewalls/test-firewall"), }, }, FirewallPolicies: []*armnetwork.SubResource{ { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/firewallPolicies/test-firewall-policy"), }, }, }, } } var _ clients.IPGroupsPager = (*mockIPGroupsPager)(nil) ================================================ FILE: sources/azure/manual/network-load-balancer-backend-address-pool.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkLoadBalancerBackendAddressPoolLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkLoadBalancerBackendAddressPool) type networkLoadBalancerBackendAddressPoolWrapper struct { client clients.LoadBalancerBackendAddressPoolsClient *azureshared.MultiResourceGroupBase } func NewNetworkLoadBalancerBackendAddressPool(client clients.LoadBalancerBackendAddressPoolsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkLoadBalancerBackendAddressPoolWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkLoadBalancerBackendAddressPool, ), } } func (c networkLoadBalancerBackendAddressPoolWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: loadBalancerName and backendAddressPoolName", Scope: scope, ItemType: c.Type(), } } loadBalancerName := queryParts[0] backendAddressPoolName := queryParts[1] if loadBalancerName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "loadBalancerName cannot be empty", Scope: scope, ItemType: c.Type(), } } if backendAddressPoolName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "backendAddressPoolName cannot be empty", Scope: scope, ItemType: c.Type(), } } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, loadBalancerName, backendAddressPoolName) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureBackendAddressPoolToSDPItem(&resp.BackendAddressPool, loadBalancerName, scope) } func (c networkLoadBalancerBackendAddressPoolWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: loadBalancerName", Scope: scope, ItemType: c.Type(), } } loadBalancerName := queryParts[0] if loadBalancerName == "" { return nil, azureshared.QueryError(errors.New("loadBalancerName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, loadBalancerName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, backendPool := range page.Value { if backendPool == nil || backendPool.Name == nil { continue } item, sdpErr := c.azureBackendAddressPoolToSDPItem(backendPool, loadBalancerName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c networkLoadBalancerBackendAddressPoolWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: loadBalancerName"), scope, c.Type())) return } loadBalancerName := queryParts[0] if loadBalancerName == "" { stream.SendError(azureshared.QueryError(errors.New("loadBalancerName cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, loadBalancerName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, backendPool := range page.Value { if backendPool == nil || backendPool.Name == nil { continue } item, sdpErr := c.azureBackendAddressPoolToSDPItem(backendPool, loadBalancerName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c networkLoadBalancerBackendAddressPoolWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkLoadBalancerLookupByName, NetworkLoadBalancerBackendAddressPoolLookupByUniqueAttr, } } func (c networkLoadBalancerBackendAddressPoolWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { NetworkLoadBalancerLookupByName, }, } } func (c networkLoadBalancerBackendAddressPoolWrapper) azureBackendAddressPoolToSDPItem(backendPool *armnetwork.BackendAddressPool, loadBalancerName string, scope string) (*sdp.Item, *sdp.QueryError) { if backendPool.Name == nil { return nil, azureshared.QueryError(errors.New("backend address pool name is nil"), scope, c.Type()) } backendPoolName := *backendPool.Name attributes, err := shared.ToAttributesWithExclude(backendPool, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(loadBalancerName, backendPoolName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkLoadBalancerBackendAddressPool.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health status from provisioning state if backendPool.Properties != nil && backendPool.Properties.ProvisioningState != nil { switch *backendPool.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Load Balancer sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: loadBalancerName, Scope: scope, }, }) if backendPool.Properties != nil { // Link to Virtual Network (pool level) if backendPool.Properties.VirtualNetwork != nil && backendPool.Properties.VirtualNetwork.ID != nil { vnetName := azureshared.ExtractResourceName(*backendPool.Properties.VirtualNetwork.ID) if vnetName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*backendPool.Properties.VirtualNetwork.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // Link to Inbound NAT Rules (read-only references) for _, natRule := range backendPool.Properties.InboundNatRules { if natRule != nil && natRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*natRule.ID, []string{"loadBalancers", "inboundNatRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*natRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Load Balancing Rules (read-only references) for _, lbRule := range backendPool.Properties.LoadBalancingRules { if lbRule != nil && lbRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*lbRule.ID, []string{"loadBalancers", "loadBalancingRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*lbRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Outbound Rule (single read-only reference) if backendPool.Properties.OutboundRule != nil && backendPool.Properties.OutboundRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*backendPool.Properties.OutboundRule.ID, []string{"loadBalancers", "outboundRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*backendPool.Properties.OutboundRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerOutboundRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Outbound Rules (read-only references array) for _, outboundRule := range backendPool.Properties.OutboundRules { if outboundRule != nil && outboundRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*outboundRule.ID, []string{"loadBalancers", "outboundRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*outboundRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerOutboundRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Backend IP Configurations (Network Interface IP Configurations) for _, backendIPConfig := range backendPool.Properties.BackendIPConfigurations { if backendIPConfig != nil && backendIPConfig.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*backendIPConfig.ID, []string{"networkInterfaces", "ipConfigurations"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*backendIPConfig.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterfaceIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Backend Addresses (IP addresses, VNets, Subnets, Frontend IP Configs) for _, addr := range backendPool.Properties.LoadBalancerBackendAddresses { if addr == nil || addr.Properties == nil { continue } // Link to Virtual Network if addr.Properties.VirtualNetwork != nil && addr.Properties.VirtualNetwork.ID != nil { vnetName := azureshared.ExtractResourceName(*addr.Properties.VirtualNetwork.ID) if vnetName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*addr.Properties.VirtualNetwork.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // Link to Subnet if addr.Properties.Subnet != nil && addr.Properties.Subnet.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*addr.Properties.Subnet.ID, []string{"virtualNetworks", "subnets"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*addr.Properties.Subnet.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Frontend IP Configuration (regional LB) if addr.Properties.LoadBalancerFrontendIPConfiguration != nil && addr.Properties.LoadBalancerFrontendIPConfiguration.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*addr.Properties.LoadBalancerFrontendIPConfiguration.ID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*addr.Properties.LoadBalancerFrontendIPConfiguration.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Network Interface IP Configuration if addr.Properties.NetworkInterfaceIPConfiguration != nil && addr.Properties.NetworkInterfaceIPConfiguration.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*addr.Properties.NetworkInterfaceIPConfiguration.ID, []string{"networkInterfaces", "ipConfigurations"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*addr.Properties.NetworkInterfaceIPConfiguration.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterfaceIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to IP Address (stdlib) if addr.Properties.IPAddress != nil && *addr.Properties.IPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *addr.Properties.IPAddress, Scope: "global", }, }) } } } return sdpItem, nil } func (c networkLoadBalancerBackendAddressPoolWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkLoadBalancer: true, azureshared.NetworkVirtualNetwork: true, azureshared.NetworkSubnet: true, azureshared.NetworkNetworkInterfaceIPConfiguration: true, azureshared.NetworkLoadBalancerInboundNatRule: true, azureshared.NetworkLoadBalancerLoadBalancingRule: true, azureshared.NetworkLoadBalancerOutboundRule: true, azureshared.NetworkLoadBalancerFrontendIPConfiguration: true, stdlib.NetworkIP: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (c networkLoadBalancerBackendAddressPoolWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/loadBalancers/backendAddressPools/read", } } func (c networkLoadBalancerBackendAddressPoolWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-load-balancer-backend-address-pool_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockBackendAddressPoolPager struct { pages []armnetwork.LoadBalancerBackendAddressPoolsClientListResponse index int } func (m *mockBackendAddressPoolPager) More() bool { return m.index < len(m.pages) } func (m *mockBackendAddressPoolPager) NextPage(ctx context.Context) (armnetwork.LoadBalancerBackendAddressPoolsClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.LoadBalancerBackendAddressPoolsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorBackendAddressPoolPager struct{} func (e *errorBackendAddressPoolPager) More() bool { return true } func (e *errorBackendAddressPoolPager) NextPage(ctx context.Context) (armnetwork.LoadBalancerBackendAddressPoolsClientListResponse, error) { return armnetwork.LoadBalancerBackendAddressPoolsClientListResponse{}, errors.New("pager error") } type testBackendAddressPoolClient struct { *mocks.MockLoadBalancerBackendAddressPoolsClient pager clients.LoadBalancerBackendAddressPoolsPager } func (t *testBackendAddressPoolClient) NewListPager(resourceGroupName, loadBalancerName string) clients.LoadBalancerBackendAddressPoolsPager { return t.pager } func TestNetworkLoadBalancerBackendAddressPool(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" loadBalancerName := "test-lb" backendPoolName := "test-backend-pool" t.Run("Get", func(t *testing.T) { backendPool := createAzureBackendAddressPool(backendPoolName, loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, backendPoolName).Return( armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse{ BackendAddressPool: *backendPool, }, nil) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, backendPoolName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerBackendAddressPool.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerBackendAddressPool, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueValue := shared.CompositeLookupKey(loadBalancerName, backendPoolName) if sdpItem.UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkLoadBalancer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: loadBalancerName, ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerInboundNatRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "nat-rule-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "lb-rule-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerOutboundRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "outbound-rule-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkNetworkInterfaceIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-nic", "test-ip-config"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "addr-vnet", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("addr-vnet", "addr-subnet"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("regional-lb", "frontend-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.10", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], loadBalancerName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Get_WithEmptyLoadBalancerName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", backendPoolName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when loadBalancerName is empty, but got nil") } }) t.Run("Get_WithEmptyBackendPoolName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when backendAddressPoolName is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { pool1 := createAzureBackendAddressPoolMinimal("pool-1", loadBalancerName, subscriptionID, resourceGroup) pool2 := createAzureBackendAddressPoolMinimal("pool-2", loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) mockPager := &mockBackendAddressPoolPager{ pages: []armnetwork.LoadBalancerBackendAddressPoolsClientListResponse{ { LoadBalancerBackendAddressPoolListResult: armnetwork.LoadBalancerBackendAddressPoolListResult{ Value: []*armnetwork.BackendAddressPool{pool1, pool2}, }, }, }, } testClient := &testBackendAddressPoolClient{ MockLoadBalancerBackendAddressPoolsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkLoadBalancerBackendAddressPool.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerBackendAddressPool, item.GetType()) } } }) t.Run("Search_WithNilName", func(t *testing.T) { validPool := createAzureBackendAddressPoolMinimal("valid-pool", loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) mockPager := &mockBackendAddressPoolPager{ pages: []armnetwork.LoadBalancerBackendAddressPoolsClientListResponse{ { LoadBalancerBackendAddressPoolListResult: armnetwork.LoadBalancerBackendAddressPoolListResult{ Value: []*armnetwork.BackendAddressPool{ {Name: nil, ID: new("/some/id")}, validPool, }, }, }, }, } testClient := &testBackendAddressPoolClient{ MockLoadBalancerBackendAddressPoolsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } expectedValue := shared.CompositeLookupKey(loadBalancerName, "valid-pool") if sdpItems[0].UniqueAttributeValue() != expectedValue { t.Errorf("Expected unique value %s, got %s", expectedValue, sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_WithEmptyLoadBalancerName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when loadBalancerName is empty, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("backend pool not found") mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, "nonexistent-pool").Return( armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse{}, expectedErr) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, "nonexistent-pool") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent backend pool, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) testClient := &testBackendAddressPoolClient{ MockLoadBalancerBackendAddressPoolsClient: mockClient, pager: &errorBackendAddressPoolPager{}, } wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("Get_CrossResourceGroupLinks", func(t *testing.T) { backendPool := createAzureBackendAddressPoolCrossRG(backendPoolName, loadBalancerName, "other-sub", "other-rg") mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, backendPoolName).Return( armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse{ BackendAddressPool: *backendPool, }, nil) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, backendPoolName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } found := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkVirtualNetwork.String() { found = true expectedScope := "other-sub.other-rg" if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected VirtualNetwork scope to be %s, got: %s", expectedScope, linkedQuery.GetQuery().GetScope()) } break } } if !found { t.Error("Expected to find VirtualNetwork linked query") } }) t.Run("Get_NoProperties", func(t *testing.T) { backendPool := &armnetwork.BackendAddressPool{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/%s", subscriptionID, resourceGroup, loadBalancerName, backendPoolName)), Name: new(backendPoolName), Type: new("Microsoft.Network/loadBalancers/backendAddressPools"), } mockClient := mocks.NewMockLoadBalancerBackendAddressPoolsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, backendPoolName).Return( armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse{ BackendAddressPool: *backendPool, }, nil) testClient := &testBackendAddressPoolClient{MockLoadBalancerBackendAddressPoolsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerBackendAddressPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, backendPoolName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should only have the parent load balancer link linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Errorf("Expected 1 linked query (parent LB only), got %d", len(linkedQueries)) } if linkedQueries[0].GetQuery().GetType() != azureshared.NetworkLoadBalancer.String() { t.Errorf("Expected parent LB link, got type %s", linkedQueries[0].GetQuery().GetType()) } }) } func createAzureBackendAddressPool(name, lbName, subscriptionID, resourceGroup string) *armnetwork.BackendAddressPool { provisioningState := armnetwork.ProvisioningStateSucceeded vnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-vnet", subscriptionID, resourceGroup) natRuleID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/inboundNatRules/nat-rule-1", subscriptionID, resourceGroup, lbName) lbRuleID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/loadBalancingRules/lb-rule-1", subscriptionID, resourceGroup, lbName) outboundRuleID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/outboundRules/outbound-rule-1", subscriptionID, resourceGroup, lbName) nicIPConfigID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkInterfaces/test-nic/ipConfigurations/test-ip-config", subscriptionID, resourceGroup) addrVnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/addr-vnet", subscriptionID, resourceGroup) addrSubnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/addr-vnet/subnets/addr-subnet", subscriptionID, resourceGroup) frontendIPConfigID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/regional-lb/frontendIPConfigurations/frontend-1", subscriptionID, resourceGroup) return &armnetwork.BackendAddressPool{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/%s", subscriptionID, resourceGroup, lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/backendAddressPools"), Properties: &armnetwork.BackendAddressPoolPropertiesFormat{ ProvisioningState: &provisioningState, VirtualNetwork: &armnetwork.SubResource{ ID: new(vnetID), }, InboundNatRules: []*armnetwork.SubResource{ {ID: new(natRuleID)}, }, LoadBalancingRules: []*armnetwork.SubResource{ {ID: new(lbRuleID)}, }, OutboundRules: []*armnetwork.SubResource{ {ID: new(outboundRuleID)}, }, BackendIPConfigurations: []*armnetwork.InterfaceIPConfiguration{ {ID: new(nicIPConfigID)}, }, LoadBalancerBackendAddresses: []*armnetwork.LoadBalancerBackendAddress{ { Name: new("backend-addr-1"), Properties: &armnetwork.LoadBalancerBackendAddressPropertiesFormat{ IPAddress: new("10.0.0.10"), VirtualNetwork: &armnetwork.SubResource{ ID: new(addrVnetID), }, Subnet: &armnetwork.SubResource{ ID: new(addrSubnetID), }, LoadBalancerFrontendIPConfiguration: &armnetwork.SubResource{ ID: new(frontendIPConfigID), }, }, }, }, }, } } func createAzureBackendAddressPoolMinimal(name, lbName, subscriptionID, resourceGroup string) *armnetwork.BackendAddressPool { provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.BackendAddressPool{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/%s", subscriptionID, resourceGroup, lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/backendAddressPools"), Properties: &armnetwork.BackendAddressPoolPropertiesFormat{ ProvisioningState: &provisioningState, }, } } func createAzureBackendAddressPoolCrossRG(name, lbName, otherSub, otherRG string) *armnetwork.BackendAddressPool { provisioningState := armnetwork.ProvisioningStateSucceeded vnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/cross-rg-vnet", otherSub, otherRG) return &armnetwork.BackendAddressPool{ ID: new(fmt.Sprintf("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/loadBalancers/%s/backendAddressPools/%s", lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/backendAddressPools"), Properties: &armnetwork.BackendAddressPoolPropertiesFormat{ ProvisioningState: &provisioningState, VirtualNetwork: &armnetwork.SubResource{ ID: new(vnetID), }, }, } } ================================================ FILE: sources/azure/manual/network-load-balancer-frontend-ip-configuration.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkLoadBalancerFrontendIPConfigurationLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkLoadBalancerFrontendIPConfiguration) type networkLoadBalancerFrontendIPConfigurationWrapper struct { client clients.LoadBalancerFrontendIPConfigurationsClient *azureshared.MultiResourceGroupBase } func NewNetworkLoadBalancerFrontendIPConfiguration(client clients.LoadBalancerFrontendIPConfigurationsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkLoadBalancerFrontendIPConfigurationWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkLoadBalancerFrontendIPConfiguration, ), } } func (c networkLoadBalancerFrontendIPConfigurationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: loadBalancerName and frontendIPConfigurationName", Scope: scope, ItemType: c.Type(), } } loadBalancerName := queryParts[0] frontendIPConfigName := queryParts[1] if loadBalancerName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "loadBalancerName cannot be empty", Scope: scope, ItemType: c.Type(), } } if frontendIPConfigName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "frontendIPConfigurationName cannot be empty", Scope: scope, ItemType: c.Type(), } } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, loadBalancerName, frontendIPConfigName) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureFrontendIPConfigToSDPItem(&resp.FrontendIPConfiguration, loadBalancerName, scope) } func (c networkLoadBalancerFrontendIPConfigurationWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: loadBalancerName", Scope: scope, ItemType: c.Type(), } } loadBalancerName := queryParts[0] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, loadBalancerName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, frontendIPConfig := range page.Value { if frontendIPConfig == nil || frontendIPConfig.Name == nil { continue } item, sdpErr := c.azureFrontendIPConfigToSDPItem(frontendIPConfig, loadBalancerName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c networkLoadBalancerFrontendIPConfigurationWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: loadBalancerName"), scope, c.Type())) return } loadBalancerName := queryParts[0] rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, loadBalancerName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, frontendIPConfig := range page.Value { if frontendIPConfig == nil || frontendIPConfig.Name == nil { continue } item, sdpErr := c.azureFrontendIPConfigToSDPItem(frontendIPConfig, loadBalancerName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c networkLoadBalancerFrontendIPConfigurationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkLoadBalancerLookupByName, NetworkLoadBalancerFrontendIPConfigurationLookupByUniqueAttr, } } func (c networkLoadBalancerFrontendIPConfigurationWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { NetworkLoadBalancerLookupByName, }, } } func (c networkLoadBalancerFrontendIPConfigurationWrapper) azureFrontendIPConfigToSDPItem(frontendIPConfig *armnetwork.FrontendIPConfiguration, loadBalancerName string, scope string) (*sdp.Item, *sdp.QueryError) { if frontendIPConfig.Name == nil { return nil, azureshared.QueryError(errors.New("frontend IP configuration name is nil"), scope, c.Type()) } frontendIPConfigName := *frontendIPConfig.Name attributes, err := shared.ToAttributesWithExclude(frontendIPConfig, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(loadBalancerName, frontendIPConfigName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health status from provisioning state if frontendIPConfig.Properties != nil && frontendIPConfig.Properties.ProvisioningState != nil { switch *frontendIPConfig.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Load Balancer sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: loadBalancerName, Scope: scope, }, }) if frontendIPConfig.Properties != nil { // Link to Public IP Address if frontendIPConfig.Properties.PublicIPAddress != nil && frontendIPConfig.Properties.PublicIPAddress.ID != nil { publicIPName := azureshared.ExtractResourceName(*frontendIPConfig.Properties.PublicIPAddress.ID) if publicIPName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*frontendIPConfig.Properties.PublicIPAddress.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: publicIPName, Scope: linkedScope, }, }) } } // Link to Subnet if frontendIPConfig.Properties.Subnet != nil && frontendIPConfig.Properties.Subnet.ID != nil { subnetID := *frontendIPConfig.Properties.Subnet.ID params := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(subnetID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Public IP Prefix if frontendIPConfig.Properties.PublicIPPrefix != nil && frontendIPConfig.Properties.PublicIPPrefix.ID != nil { publicIPPrefixName := azureshared.ExtractResourceName(*frontendIPConfig.Properties.PublicIPPrefix.ID) if publicIPPrefixName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*frontendIPConfig.Properties.PublicIPPrefix.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPPrefix.String(), Method: sdp.QueryMethod_GET, Query: publicIPPrefixName, Scope: linkedScope, }, }) } } // Link to Gateway Load Balancer Frontend IP Configuration if frontendIPConfig.Properties.GatewayLoadBalancer != nil && frontendIPConfig.Properties.GatewayLoadBalancer.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*frontendIPConfig.Properties.GatewayLoadBalancer.ID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*frontendIPConfig.Properties.GatewayLoadBalancer.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Inbound NAT Rules (read-only references) for _, natRule := range frontendIPConfig.Properties.InboundNatRules { if natRule != nil && natRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*natRule.ID, []string{"loadBalancers", "inboundNatRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*natRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Inbound NAT Pools (read-only references) for _, natPool := range frontendIPConfig.Properties.InboundNatPools { if natPool != nil && natPool.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*natPool.ID, []string{"loadBalancers", "inboundNatPools"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*natPool.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Outbound Rules (read-only references) for _, outboundRule := range frontendIPConfig.Properties.OutboundRules { if outboundRule != nil && outboundRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*outboundRule.ID, []string{"loadBalancers", "outboundRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*outboundRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerOutboundRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Load Balancing Rules (read-only references) for _, lbRule := range frontendIPConfig.Properties.LoadBalancingRules { if lbRule != nil && lbRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*lbRule.ID, []string{"loadBalancers", "loadBalancingRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*lbRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } // Link to Private IP Address (stdlib) if frontendIPConfig.Properties.PrivateIPAddress != nil && *frontendIPConfig.Properties.PrivateIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *frontendIPConfig.Properties.PrivateIPAddress, Scope: "global", }, }) } } return sdpItem, nil } func (c networkLoadBalancerFrontendIPConfigurationWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkLoadBalancer: true, azureshared.NetworkPublicIPAddress: true, azureshared.NetworkSubnet: true, azureshared.NetworkPublicIPPrefix: true, azureshared.NetworkLoadBalancerFrontendIPConfiguration: true, azureshared.NetworkLoadBalancerInboundNatRule: true, azureshared.NetworkLoadBalancerInboundNatPool: true, azureshared.NetworkLoadBalancerOutboundRule: true, azureshared.NetworkLoadBalancerLoadBalancingRule: true, stdlib.NetworkIP: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (c networkLoadBalancerFrontendIPConfigurationWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/loadBalancers/frontendIPConfigurations/read", } } func (c networkLoadBalancerFrontendIPConfigurationWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-load-balancer-frontend-ip-configuration_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockFrontendIPConfigPager struct { pages []armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse index int } func (m *mockFrontendIPConfigPager) More() bool { return m.index < len(m.pages) } func (m *mockFrontendIPConfigPager) NextPage(ctx context.Context) (armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorFrontendIPConfigPager struct{} func (e *errorFrontendIPConfigPager) More() bool { return true } func (e *errorFrontendIPConfigPager) NextPage(ctx context.Context) (armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse, error) { return armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse{}, errors.New("pager error") } type testFrontendIPConfigClient struct { *mocks.MockLoadBalancerFrontendIPConfigurationsClient pager clients.LoadBalancerFrontendIPConfigurationsPager } func (t *testFrontendIPConfigClient) NewListPager(resourceGroupName, loadBalancerName string) clients.LoadBalancerFrontendIPConfigurationsPager { return t.pager } func TestNetworkLoadBalancerFrontendIPConfiguration(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" loadBalancerName := "test-lb" frontendIPConfigName := "test-frontend-ip" t.Run("Get", func(t *testing.T) { frontendIPConfig := createAzureFrontendIPConfiguration(frontendIPConfigName, loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, frontendIPConfigName).Return( armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse{ FrontendIPConfiguration: *frontendIPConfig, }, nil) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, frontendIPConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerFrontendIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerFrontendIPConfiguration, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueValue := shared.CompositeLookupKey(loadBalancerName, frontendIPConfigName) if sdpItem.UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkLoadBalancer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: loadBalancerName, ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-public-ip", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkPublicIPPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-ip-prefix", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("gateway-lb", "gateway-frontend"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerInboundNatRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "nat-rule-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerInboundNatPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "nat-pool-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerOutboundRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "outbound-rule-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "lb-rule-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.5", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], loadBalancerName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Get_WithEmptyLoadBalancerName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", frontendIPConfigName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when loadBalancerName is empty, but got nil") } }) t.Run("Get_WithEmptyFrontendIPConfigName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when frontendIPConfigurationName is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { frontendIP1 := createAzureFrontendIPConfigurationMinimal("frontend-1", loadBalancerName, subscriptionID, resourceGroup) frontendIP2 := createAzureFrontendIPConfigurationMinimal("frontend-2", loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) mockPager := &mockFrontendIPConfigPager{ pages: []armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse{ { LoadBalancerFrontendIPConfigurationListResult: armnetwork.LoadBalancerFrontendIPConfigurationListResult{ Value: []*armnetwork.FrontendIPConfiguration{frontendIP1, frontendIP2}, }, }, }, } testClient := &testFrontendIPConfigClient{ MockLoadBalancerFrontendIPConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkLoadBalancerFrontendIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerFrontendIPConfiguration, item.GetType()) } } }) t.Run("Search_WithNilName", func(t *testing.T) { validFrontendIP := createAzureFrontendIPConfigurationMinimal("valid-frontend", loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) mockPager := &mockFrontendIPConfigPager{ pages: []armnetwork.LoadBalancerFrontendIPConfigurationsClientListResponse{ { LoadBalancerFrontendIPConfigurationListResult: armnetwork.LoadBalancerFrontendIPConfigurationListResult{ Value: []*armnetwork.FrontendIPConfiguration{ {Name: nil, ID: new("/some/id")}, validFrontendIP, }, }, }, }, } testClient := &testFrontendIPConfigClient{ MockLoadBalancerFrontendIPConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } expectedValue := shared.CompositeLookupKey(loadBalancerName, "valid-frontend") if sdpItems[0].UniqueAttributeValue() != expectedValue { t.Errorf("Expected unique value %s, got %s", expectedValue, sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("frontend IP config not found") mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, "nonexistent-frontend").Return( armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse{}, expectedErr) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, "nonexistent-frontend") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent frontend IP config, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) testClient := &testFrontendIPConfigClient{ MockLoadBalancerFrontendIPConfigurationsClient: mockClient, pager: &errorFrontendIPConfigPager{}, } wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("Get_CrossResourceGroupLinks", func(t *testing.T) { frontendIPConfig := createAzureFrontendIPConfigCrossRG(frontendIPConfigName, loadBalancerName, "other-sub", "other-rg") mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, frontendIPConfigName).Return( armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse{ FrontendIPConfiguration: *frontendIPConfig, }, nil) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, frontendIPConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } found := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkPublicIPAddress.String() { found = true expectedScope := "other-sub.other-rg" if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected PublicIPAddress scope to be %s, got: %s", expectedScope, linkedQuery.GetQuery().GetScope()) } break } } if !found { t.Error("Expected to find PublicIPAddress linked query") } }) t.Run("Get_NoProperties", func(t *testing.T) { frontendIPConfig := &armnetwork.FrontendIPConfiguration{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/%s", subscriptionID, resourceGroup, loadBalancerName, frontendIPConfigName)), Name: new(frontendIPConfigName), Type: new("Microsoft.Network/loadBalancers/frontendIPConfigurations"), } mockClient := mocks.NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, frontendIPConfigName).Return( armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse{ FrontendIPConfiguration: *frontendIPConfig, }, nil) testClient := &testFrontendIPConfigClient{MockLoadBalancerFrontendIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkLoadBalancerFrontendIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, frontendIPConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should only have the parent load balancer link linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Errorf("Expected 1 linked query (parent LB only), got %d", len(linkedQueries)) } if linkedQueries[0].GetQuery().GetType() != azureshared.NetworkLoadBalancer.String() { t.Errorf("Expected parent LB link, got type %s", linkedQueries[0].GetQuery().GetType()) } }) } func createAzureFrontendIPConfiguration(name, lbName, subscriptionID, resourceGroup string) *armnetwork.FrontendIPConfiguration { provisioningState := armnetwork.ProvisioningStateSucceeded publicIPID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/publicIPAddresses/test-public-ip", subscriptionID, resourceGroup) subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet", subscriptionID, resourceGroup) publicIPPrefixID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/publicIPPrefixes/test-ip-prefix", subscriptionID, resourceGroup) gatewayLBFrontendID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/gateway-lb/frontendIPConfigurations/gateway-frontend", subscriptionID, resourceGroup) natRuleID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/inboundNatRules/nat-rule-1", subscriptionID, resourceGroup, lbName) natPoolID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/inboundNatPools/nat-pool-1", subscriptionID, resourceGroup, lbName) outboundRuleID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/outboundRules/outbound-rule-1", subscriptionID, resourceGroup, lbName) lbRuleID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/loadBalancingRules/lb-rule-1", subscriptionID, resourceGroup, lbName) return &armnetwork.FrontendIPConfiguration{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/%s", subscriptionID, resourceGroup, lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/frontendIPConfigurations"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ ProvisioningState: &provisioningState, PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PublicIPPrefix: &armnetwork.SubResource{ ID: new(publicIPPrefixID), }, GatewayLoadBalancer: &armnetwork.SubResource{ ID: new(gatewayLBFrontendID), }, PrivateIPAddress: new("10.0.0.5"), InboundNatRules: []*armnetwork.SubResource{ {ID: new(natRuleID)}, }, InboundNatPools: []*armnetwork.SubResource{ {ID: new(natPoolID)}, }, OutboundRules: []*armnetwork.SubResource{ {ID: new(outboundRuleID)}, }, LoadBalancingRules: []*armnetwork.SubResource{ {ID: new(lbRuleID)}, }, }, } } func createAzureFrontendIPConfigurationMinimal(name, lbName, subscriptionID, resourceGroup string) *armnetwork.FrontendIPConfiguration { provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.FrontendIPConfiguration{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/%s", subscriptionID, resourceGroup, lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/frontendIPConfigurations"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ ProvisioningState: &provisioningState, }, } } func createAzureFrontendIPConfigCrossRG(name, lbName, otherSub, otherRG string) *armnetwork.FrontendIPConfiguration { provisioningState := armnetwork.ProvisioningStateSucceeded publicIPID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/publicIPAddresses/cross-rg-ip", otherSub, otherRG) return &armnetwork.FrontendIPConfiguration{ ID: new(fmt.Sprintf("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/loadBalancers/%s/frontendIPConfigurations/%s", lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/frontendIPConfigurations"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ ProvisioningState: &provisioningState, PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, }, } } ================================================ FILE: sources/azure/manual/network-load-balancer-probe.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkLoadBalancerProbeLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkLoadBalancerProbe) type networkLoadBalancerProbeWrapper struct { client clients.LoadBalancerProbesClient *azureshared.MultiResourceGroupBase } func NewNetworkLoadBalancerProbe(client clients.LoadBalancerProbesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkLoadBalancerProbeWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkLoadBalancerProbe, ), } } func (c networkLoadBalancerProbeWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: loadBalancerName and probeName", Scope: scope, ItemType: c.Type(), } } loadBalancerName := queryParts[0] probeName := queryParts[1] if loadBalancerName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "loadBalancerName cannot be empty", Scope: scope, ItemType: c.Type(), } } if probeName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "probeName cannot be empty", Scope: scope, ItemType: c.Type(), } } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, loadBalancerName, probeName) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureProbeToSDPItem(&resp.Probe, loadBalancerName, scope) } func (c networkLoadBalancerProbeWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: loadBalancerName", Scope: scope, ItemType: c.Type(), } } loadBalancerName := queryParts[0] if loadBalancerName == "" { return nil, azureshared.QueryError(errors.New("loadBalancerName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, loadBalancerName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, probe := range page.Value { if probe == nil || probe.Name == nil { continue } item, sdpErr := c.azureProbeToSDPItem(probe, loadBalancerName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c networkLoadBalancerProbeWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: loadBalancerName"), scope, c.Type())) return } loadBalancerName := queryParts[0] if loadBalancerName == "" { stream.SendError(azureshared.QueryError(errors.New("loadBalancerName cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, loadBalancerName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, probe := range page.Value { if probe == nil || probe.Name == nil { continue } item, sdpErr := c.azureProbeToSDPItem(probe, loadBalancerName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c networkLoadBalancerProbeWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkLoadBalancerLookupByName, NetworkLoadBalancerProbeLookupByUniqueAttr, } } func (c networkLoadBalancerProbeWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { NetworkLoadBalancerLookupByName, }, } } func (c networkLoadBalancerProbeWrapper) azureProbeToSDPItem(probe *armnetwork.Probe, loadBalancerName string, scope string) (*sdp.Item, *sdp.QueryError) { if probe.Name == nil { return nil, azureshared.QueryError(errors.New("probe name is nil"), scope, c.Type()) } probeName := *probe.Name attributes, err := shared.ToAttributesWithExclude(probe, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(loadBalancerName, probeName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkLoadBalancerProbe.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } if probe.Properties != nil && probe.Properties.ProvisioningState != nil { switch *probe.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Load Balancer sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: loadBalancerName, Scope: scope, }, }) if probe.Properties != nil { // Link to Load Balancing Rules that reference this probe for _, lbRule := range probe.Properties.LoadBalancingRules { if lbRule != nil && lbRule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*lbRule.ID, []string{"loadBalancers", "loadBalancingRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*lbRule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } return sdpItem, nil } func (c networkLoadBalancerProbeWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkLoadBalancer: true, azureshared.NetworkLoadBalancerLoadBalancingRule: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (c networkLoadBalancerProbeWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/loadBalancers/probes/read", } } func (c networkLoadBalancerProbeWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-load-balancer-probe_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockLoadBalancerProbePager struct { pages []armnetwork.LoadBalancerProbesClientListResponse index int } func (m *mockLoadBalancerProbePager) More() bool { return m.index < len(m.pages) } func (m *mockLoadBalancerProbePager) NextPage(ctx context.Context) (armnetwork.LoadBalancerProbesClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.LoadBalancerProbesClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorLoadBalancerProbePager struct{} func (e *errorLoadBalancerProbePager) More() bool { return true } func (e *errorLoadBalancerProbePager) NextPage(ctx context.Context) (armnetwork.LoadBalancerProbesClientListResponse, error) { return armnetwork.LoadBalancerProbesClientListResponse{}, errors.New("pager error") } type testLoadBalancerProbeClient struct { *mocks.MockLoadBalancerProbesClient pager clients.LoadBalancerProbesPager } func (t *testLoadBalancerProbeClient) NewListPager(resourceGroupName, loadBalancerName string) clients.LoadBalancerProbesPager { return t.pager } func TestNetworkLoadBalancerProbe(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" loadBalancerName := "test-lb" probeName := "test-probe" t.Run("Get", func(t *testing.T) { probe := createAzureLoadBalancerProbe(probeName, loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, probeName).Return( armnetwork.LoadBalancerProbesClientGetResponse{ Probe: *probe, }, nil) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, probeName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancerProbe.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerProbe, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueValue := shared.CompositeLookupKey(loadBalancerName, probeName) if sdpItem.UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkLoadBalancer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: loadBalancerName, ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(loadBalancerName, "lb-rule-1"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], loadBalancerName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Get_WithEmptyLoadBalancerName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey("", probeName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when loadBalancerName is empty, but got nil") } }) t.Run("Get_WithEmptyProbeName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when probeName is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { probe1 := createAzureLoadBalancerProbeMinimal("probe-1", loadBalancerName, subscriptionID, resourceGroup) probe2 := createAzureLoadBalancerProbeMinimal("probe-2", loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) mockPager := &mockLoadBalancerProbePager{ pages: []armnetwork.LoadBalancerProbesClientListResponse{ { LoadBalancerProbeListResult: armnetwork.LoadBalancerProbeListResult{ Value: []*armnetwork.Probe{probe1, probe2}, }, }, }, } testClient := &testLoadBalancerProbeClient{ MockLoadBalancerProbesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkLoadBalancerProbe.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancerProbe, item.GetType()) } } }) t.Run("Search_WithNilName", func(t *testing.T) { validProbe := createAzureLoadBalancerProbeMinimal("valid-probe", loadBalancerName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) mockPager := &mockLoadBalancerProbePager{ pages: []armnetwork.LoadBalancerProbesClientListResponse{ { LoadBalancerProbeListResult: armnetwork.LoadBalancerProbeListResult{ Value: []*armnetwork.Probe{ {Name: nil, ID: new("/some/id")}, validProbe, }, }, }, }, } testClient := &testLoadBalancerProbeClient{ MockLoadBalancerProbesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } expectedValue := shared.CompositeLookupKey(loadBalancerName, "valid-probe") if sdpItems[0].UniqueAttributeValue() != expectedValue { t.Errorf("Expected unique value %s, got %s", expectedValue, sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_WithEmptyLoadBalancerName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when loadBalancerName is empty, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("probe not found") mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, "nonexistent-probe").Return( armnetwork.LoadBalancerProbesClientGetResponse{}, expectedErr) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, "nonexistent-probe") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent probe, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) testClient := &testLoadBalancerProbeClient{ MockLoadBalancerProbesClient: mockClient, pager: &errorLoadBalancerProbePager{}, } wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], loadBalancerName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("Get_NoProperties", func(t *testing.T) { probe := &armnetwork.Probe{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/probes/%s", subscriptionID, resourceGroup, loadBalancerName, probeName)), Name: new(probeName), Type: new("Microsoft.Network/loadBalancers/probes"), } mockClient := mocks.NewMockLoadBalancerProbesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, loadBalancerName, probeName).Return( armnetwork.LoadBalancerProbesClientGetResponse{ Probe: *probe, }, nil) testClient := &testLoadBalancerProbeClient{MockLoadBalancerProbesClient: mockClient} wrapper := manual.NewNetworkLoadBalancerProbe(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(loadBalancerName, probeName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Errorf("Expected 1 linked query (parent LB only), got %d", len(linkedQueries)) } if linkedQueries[0].GetQuery().GetType() != azureshared.NetworkLoadBalancer.String() { t.Errorf("Expected parent LB link, got type %s", linkedQueries[0].GetQuery().GetType()) } }) } func createAzureLoadBalancerProbe(name, lbName, subscriptionID, resourceGroup string) *armnetwork.Probe { provisioningState := armnetwork.ProvisioningStateSucceeded port := int32(80) protocol := armnetwork.ProbeProtocolHTTP intervalInSeconds := int32(15) numberOfProbes := int32(2) lbRuleID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/loadBalancingRules/lb-rule-1", subscriptionID, resourceGroup, lbName) return &armnetwork.Probe{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/probes/%s", subscriptionID, resourceGroup, lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/probes"), Properties: &armnetwork.ProbePropertiesFormat{ ProvisioningState: &provisioningState, Port: &port, Protocol: &protocol, IntervalInSeconds: &intervalInSeconds, NumberOfProbes: &numberOfProbes, RequestPath: new("/health"), LoadBalancingRules: []*armnetwork.SubResource{ {ID: new(lbRuleID)}, }, }, } } func createAzureLoadBalancerProbeMinimal(name, lbName, subscriptionID, resourceGroup string) *armnetwork.Probe { provisioningState := armnetwork.ProvisioningStateSucceeded port := int32(80) protocol := armnetwork.ProbeProtocolTCP return &armnetwork.Probe{ ID: new(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/%s/probes/%s", subscriptionID, resourceGroup, lbName, name)), Name: new(name), Type: new("Microsoft.Network/loadBalancers/probes"), Properties: &armnetwork.ProbePropertiesFormat{ ProvisioningState: &provisioningState, Port: &port, Protocol: &protocol, }, } } ================================================ FILE: sources/azure/manual/network-load-balancer.go ================================================ package manual import ( "context" "errors" "fmt" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkLoadBalancerLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkLoadBalancer) type networkLoadBalancerWrapper struct { client clients.LoadBalancersClient *azureshared.MultiResourceGroupBase } func NewNetworkLoadBalancer(client clients.LoadBalancersClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkLoadBalancerWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkLoadBalancer, ), } } func (n networkLoadBalancerWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(rgScope.ResourceGroup) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, loadBalancer := range page.Value { if loadBalancer.Name == nil { continue } item, sdpErr := n.azureLoadBalancerToSDPItem(loadBalancer, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkLoadBalancerWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(rgScope.ResourceGroup) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, loadBalancer := range page.Value { if loadBalancer.Name == nil { continue } item, sdpErr := n.azureLoadBalancerToSDPItem(loadBalancer, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkLoadBalancerWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be a load balancer name"), scope, n.Type()) } loadBalancerName := queryParts[0] if loadBalancerName == "" { return nil, azureshared.QueryError(errors.New("load balancer name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, loadBalancerName) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } item, sdpErr := n.azureLoadBalancerToSDPItem(&resp.LoadBalancer, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (n networkLoadBalancerWrapper) azureLoadBalancerToSDPItem(loadBalancer *armnetwork.LoadBalancer, scope string) (*sdp.Item, *sdp.QueryError) { if loadBalancer.Name == nil { return nil, azureshared.QueryError(errors.New("load balancer name is nil"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(loadBalancer, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkLoadBalancer.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(loadBalancer.Tags), } loadBalancerName := *loadBalancer.Name // Process FrontendIPConfigurations (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancer-frontend-ip-configurations/get?view=rest-load-balancer-2025-03-01&tabs=HTTP if loadBalancer.Properties != nil && loadBalancer.Properties.FrontendIPConfigurations != nil { for _, frontendIPConfig := range loadBalancer.Properties.FrontendIPConfigurations { if frontendIPConfig.Name != nil { // Link to FrontendIPConfiguration child resource sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loadBalancerName, *frontendIPConfig.Name), Scope: scope, }, }) } if frontendIPConfig.Properties != nil { // Link to Public IP Address if referenced // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/public-ip-addresses/get?view=rest-virtualnetwork-2025-03-01&tabs=HTTP if frontendIPConfig.Properties.PublicIPAddress != nil && frontendIPConfig.Properties.PublicIPAddress.ID != nil { publicIPName := azureshared.ExtractResourceName(*frontendIPConfig.Properties.PublicIPAddress.ID) if publicIPName != "" { // Extract subscription ID and resource group from the resource ID to determine scope resourceID := *frontendIPConfig.Properties.PublicIPAddress.ID parts := strings.Split(strings.Trim(resourceID, "/"), "/") linkedScope := scope if len(parts) >= 4 && parts[0] == "subscriptions" && parts[2] == "resourceGroups" { subscriptionID := parts[1] resourceGroup := parts[3] linkedScope = fmt.Sprintf("%s.%s", subscriptionID, resourceGroup) } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: publicIPName, Scope: linkedScope, }, }) } } // Link to Subnet if referenced // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get?view=rest-virtualnetwork-2025-03-01&tabs=HTTP if frontendIPConfig.Properties.Subnet != nil && frontendIPConfig.Properties.Subnet.ID != nil { subnetID := *frontendIPConfig.Properties.Subnet.ID // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnet}/subnets/{subnet} parts := strings.Split(strings.Trim(subnetID, "/"), "/") if len(parts) >= 10 && parts[0] == "subscriptions" && parts[2] == "resourceGroups" && parts[4] == "providers" && parts[5] == "Microsoft.Network" && parts[6] == "virtualNetworks" && parts[8] == "subnets" { vnetName := parts[7] subnetName := parts[9] linkedScope := fmt.Sprintf("%s.%s", parts[1], parts[3]) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: linkedScope, }, }) } } // Link to Gateway Load Balancer frontend IP if referenced (e.g. LB chained to Gateway LB) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancer-frontend-ip-configurations/get?view=rest-load-balancer-2025-03-01&tabs=HTTP if frontendIPConfig.Properties.GatewayLoadBalancer != nil && frontendIPConfig.Properties.GatewayLoadBalancer.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*frontendIPConfig.Properties.GatewayLoadBalancer.ID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 { linkedScope := azureshared.ExtractScopeFromResourceID(*frontendIPConfig.Properties.GatewayLoadBalancer.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Public IP Prefix if referenced // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/public-ip-prefixes/get?view=rest-virtualnetwork-2025-03-01&tabs=HTTP if frontendIPConfig.Properties.PublicIPPrefix != nil && frontendIPConfig.Properties.PublicIPPrefix.ID != nil { publicIPPrefixName := azureshared.ExtractResourceName(*frontendIPConfig.Properties.PublicIPPrefix.ID) if publicIPPrefixName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*frontendIPConfig.Properties.PublicIPPrefix.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPPrefix.String(), Method: sdp.QueryMethod_GET, Query: publicIPPrefixName, Scope: linkedScope, }, }) } } // Link to IP address (standard library) if private IP address is assigned if frontendIPConfig.Properties.PrivateIPAddress != nil && *frontendIPConfig.Properties.PrivateIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *frontendIPConfig.Properties.PrivateIPAddress, Scope: "global", }, }) } } } } // Process BackendAddressPools (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/backend-address-pools/get if loadBalancer.Properties != nil && loadBalancer.Properties.BackendAddressPools != nil { for _, backendPool := range loadBalancer.Properties.BackendAddressPools { if backendPool.Name != nil { // Link to BackendAddressPool child resource sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loadBalancerName, *backendPool.Name), Scope: scope, }, }) } // Link to Virtual Network if backend pool references one // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/virtual-networks/get?view=rest-virtualnetwork-2025-03-01&tabs=HTTP if backendPool.Properties != nil && backendPool.Properties.VirtualNetwork != nil && backendPool.Properties.VirtualNetwork.ID != nil { vnetName := azureshared.ExtractResourceName(*backendPool.Properties.VirtualNetwork.ID) if vnetName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*backendPool.Properties.VirtualNetwork.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // Link from backend addresses (LoadBalancerBackendAddress) to frontend IP config, subnet, VNet, and IP if backendPool.Properties != nil && backendPool.Properties.LoadBalancerBackendAddresses != nil { for _, addr := range backendPool.Properties.LoadBalancerBackendAddresses { if addr == nil || addr.Properties == nil { continue } // Link to Frontend IP Configuration (regional LB) if referenced if addr.Properties.LoadBalancerFrontendIPConfiguration != nil && addr.Properties.LoadBalancerFrontendIPConfiguration.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*addr.Properties.LoadBalancerFrontendIPConfiguration.ID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 { linkedScope := azureshared.ExtractScopeFromResourceID(*addr.Properties.LoadBalancerFrontendIPConfiguration.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Subnet if referenced if addr.Properties.Subnet != nil && addr.Properties.Subnet.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*addr.Properties.Subnet.ID, []string{"virtualNetworks", "subnets"}) if len(params) >= 2 { linkedScope := azureshared.ExtractScopeFromResourceID(*addr.Properties.Subnet.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to Virtual Network if referenced if addr.Properties.VirtualNetwork != nil && addr.Properties.VirtualNetwork.ID != nil { vnetName := azureshared.ExtractResourceName(*addr.Properties.VirtualNetwork.ID) if vnetName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*addr.Properties.VirtualNetwork.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // Link to stdlib IP if backend address has IP if addr.Properties.IPAddress != nil && *addr.Properties.IPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *addr.Properties.IPAddress, Scope: "global", }, }) } } } } } // Process InboundNatRules (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/inbound-nat-rules/get?view=rest-load-balancer-2025-03-01&tabs=HTTP if loadBalancer.Properties != nil && loadBalancer.Properties.InboundNatRules != nil { for _, natRule := range loadBalancer.Properties.InboundNatRules { if natRule.Name != nil { // Link to InboundNatRule child resource sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loadBalancerName, *natRule.Name), Scope: scope, }, }) } // Link to Network Interface via BackendIPConfiguration if natRule.Properties != nil && natRule.Properties.BackendIPConfiguration != nil && natRule.Properties.BackendIPConfiguration.ID != nil { // BackendIPConfiguration.ID points to a Network Interface IP Configuration // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/networkInterfaces/{nic}/ipConfigurations/{ipConfig} backendIPConfigID := *natRule.Properties.BackendIPConfiguration.ID parts := strings.Split(strings.Trim(backendIPConfigID, "/"), "/") if len(parts) >= 8 && parts[0] == "subscriptions" && parts[2] == "resourceGroups" && parts[4] == "providers" && parts[5] == "Microsoft.Network" && parts[6] == "networkInterfaces" { subscriptionID := parts[1] resourceGroup := parts[3] nicName := parts[7] scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroup) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: nicName, Scope: scope, }, }) } } } } // Process LoadBalancingRules (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancer-load-balancing-rules/get?view=rest-load-balancer-2025-03-01&tabs=HTTP if loadBalancer.Properties != nil && loadBalancer.Properties.LoadBalancingRules != nil { for _, lbRule := range loadBalancer.Properties.LoadBalancingRules { if lbRule.Name != nil { // Link to LoadBalancingRule child resource sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loadBalancerName, *lbRule.Name), Scope: scope, }, }) } } } // Process Probes (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancer-probes/get?view=rest-load-balancer-2025-03-01&tabs=HTTP if loadBalancer.Properties != nil && loadBalancer.Properties.Probes != nil { for _, probe := range loadBalancer.Properties.Probes { if probe.Name != nil { // Link to Probe child resource sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerProbe.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loadBalancerName, *probe.Name), Scope: scope, }, }) } } } // Process OutboundRules (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancer-outbound-rules/get?view=rest-load-balancer-2025-03-01&tabs=HTTP if loadBalancer.Properties != nil && loadBalancer.Properties.OutboundRules != nil { for _, outboundRule := range loadBalancer.Properties.OutboundRules { if outboundRule.Name != nil { // Link to OutboundRule child resource sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerOutboundRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loadBalancerName, *outboundRule.Name), Scope: scope, }, }) } } } // Process InboundNatPools (Child Resource) // Reference: https://learn.microsoft.com/en-us/rest/api/load-balancer/inbound-nat-pools/get if loadBalancer.Properties != nil && loadBalancer.Properties.InboundNatPools != nil { for _, natPool := range loadBalancer.Properties.InboundNatPools { if natPool.Name != nil { // Link to InboundNatPool child resource sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loadBalancerName, *natPool.Name), Scope: scope, }, }) } } } return sdpItem, nil } // ref: https://learn.microsoft.com/en-us/rest/api/load-balancer/load-balancers/get?view=rest-load-balancer-2025-03-01&tabs=HTTP func (n networkLoadBalancerWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkLoadBalancerLookupByName, } } func (n networkLoadBalancerWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ // Child resources azureshared.NetworkLoadBalancerFrontendIPConfiguration: true, azureshared.NetworkLoadBalancerBackendAddressPool: true, azureshared.NetworkLoadBalancerInboundNatRule: true, azureshared.NetworkLoadBalancerLoadBalancingRule: true, azureshared.NetworkLoadBalancerProbe: true, azureshared.NetworkLoadBalancerOutboundRule: true, azureshared.NetworkLoadBalancerInboundNatPool: true, // External resources azureshared.NetworkPublicIPAddress: true, azureshared.NetworkPublicIPPrefix: true, azureshared.NetworkSubnet: true, azureshared.NetworkVirtualNetwork: true, azureshared.NetworkNetworkInterface: true, // Standard library resources stdlib.NetworkIP: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/lb func (n networkLoadBalancerWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_lb.name", }, } } // ref; https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (n networkLoadBalancerWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/loadBalancers/read", } } func (n networkLoadBalancerWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-load-balancer_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestNetworkLoadBalancer(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { lbName := "test-lb" loadBalancer := createAzureLoadBalancer(lbName, subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, lbName).Return( armnetwork.LoadBalancersClientGetResponse{ LoadBalancer: *loadBalancer, }, nil) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], lbName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLoadBalancer.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLoadBalancer, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != lbName { t.Errorf("Expected unique attribute value %s, got %s", lbName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // FrontendIPConfiguration child resource ExpectedType: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(lbName, "frontend-ip-config"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // PublicIPAddress external resource ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-public-ip", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Subnet external resource ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Private IP address link (standard library) ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.2.0.5", ExpectedScope: "global", }, { // BackendAddressPool child resource ExpectedType: azureshared.NetworkLoadBalancerBackendAddressPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(lbName, "backend-pool"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // InboundNatRule child resource ExpectedType: azureshared.NetworkLoadBalancerInboundNatRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(lbName, "inbound-nat-rule"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // NetworkInterface via InboundNatRule BackendIPConfiguration ExpectedType: azureshared.NetworkNetworkInterface.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nic", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // LoadBalancingRule child resource ExpectedType: azureshared.NetworkLoadBalancerLoadBalancingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(lbName, "lb-rule"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Probe child resource ExpectedType: azureshared.NetworkLoadBalancerProbe.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(lbName, "probe"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // OutboundRule child resource ExpectedType: azureshared.NetworkLoadBalancerOutboundRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(lbName, "outbound-rule"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // InboundNatPool child resource ExpectedType: azureshared.NetworkLoadBalancerInboundNatPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(lbName, "nat-pool"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancersClient(ctrl) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty string name - the wrapper validates this before calling the client // So the client.Get should not be called _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting load balancer with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { lb1 := createAzureLoadBalancer("test-lb-1", subscriptionID, resourceGroup) lb2 := createAzureLoadBalancer("test-lb-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockLoadBalancersClient(ctrl) mockPager := NewMockLoadBalancersPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.LoadBalancersClientListResponse{ LoadBalancerListResult: armnetwork.LoadBalancerListResult{ Value: []*armnetwork.LoadBalancer{lb1, lb2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkLoadBalancer.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkLoadBalancer, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Test that load balancers with nil names are skipped in List lb1 := createAzureLoadBalancer("test-lb-1", subscriptionID, resourceGroup) lb2 := &armnetwork.LoadBalancer{ Name: nil, // Load balancer with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.LoadBalancerPropertiesFormat{}, } mockClient := mocks.NewMockLoadBalancersClient(ctrl) mockPager := NewMockLoadBalancersPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.LoadBalancersClientListResponse{ LoadBalancerListResult: armnetwork.LoadBalancerListResult{ Value: []*armnetwork.LoadBalancer{lb1, lb2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (lb1), lb2 with nil name should be skipped if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name should be skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "test-lb-1" { t.Errorf("Expected item name 'test-lb-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("load balancer not found") mockClient := mocks.NewMockLoadBalancersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-lb").Return( armnetwork.LoadBalancersClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-lb", true) if qErr == nil { t.Error("Expected error when getting non-existent load balancer, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { expectedErr := errors.New("failed to list load balancers") mockClient := mocks.NewMockLoadBalancersClient(ctrl) mockPager := NewMockLoadBalancersPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.LoadBalancersClientListResponse{}, expectedErr), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing load balancers fails, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockLoadBalancersClient(ctrl) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements ListableWrapper interface _ = wrapper // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/loadBalancers/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Note: PredefinedRole() is not part of the Wrapper interface, so we can't test it here // It's tested implicitly by ensuring the wrapper implements all required methods // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.NetworkLoadBalancerFrontendIPConfiguration] { t.Error("Expected PotentialLinks to include NetworkLoadBalancerFrontendIPConfiguration") } if !potentialLinks[azureshared.NetworkPublicIPAddress] { t.Error("Expected PotentialLinks to include NetworkPublicIPAddress") } if !potentialLinks[azureshared.NetworkSubnet] { t.Error("Expected PotentialLinks to include NetworkSubnet") } if !potentialLinks[azureshared.NetworkNetworkInterface] { t.Error("Expected PotentialLinks to include NetworkNetworkInterface") } if !potentialLinks[azureshared.NetworkPublicIPPrefix] { t.Error("Expected PotentialLinks to include NetworkPublicIPPrefix") } if !potentialLinks[azureshared.NetworkVirtualNetwork] { t.Error("Expected PotentialLinks to include NetworkVirtualNetwork") } // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_lb.name" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected TerraformMethod to be GET, got: %s", mapping.GetTerraformMethod()) } break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_lb.name' mapping") } // Verify GetLookups lookups := w.GetLookups() if len(lookups) == 0 { t.Error("Expected GetLookups to return at least one lookup") } foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.NetworkLoadBalancer { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include NetworkLoadBalancer") } }) t.Run("Get_PublicIPAddress_DifferentScope", func(t *testing.T) { // Test that PublicIPAddress with different subscription/resource group uses correct scope lbName := "test-lb" loadBalancer := createAzureLoadBalancerWithDifferentScopePublicIP(lbName, subscriptionID, resourceGroup, "other-sub", "other-rg") mockClient := mocks.NewMockLoadBalancersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, lbName).Return( armnetwork.LoadBalancersClientGetResponse{ LoadBalancer: *loadBalancer, }, nil) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], lbName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Find the PublicIPAddress linked query found := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkPublicIPAddress.String() { found = true expectedScope := fmt.Sprintf("%s.%s", "other-sub", "other-rg") if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected PublicIPAddress scope to be %s, got: %s", expectedScope, linkedQuery.GetQuery().GetScope()) } break } } if !found { t.Error("Expected to find PublicIPAddress linked query") } }) t.Run("Get_Subnet_DifferentScope", func(t *testing.T) { // Test that Subnet with different subscription/resource group uses correct scope lbName := "test-lb" loadBalancer := createAzureLoadBalancerWithDifferentScopeSubnet(lbName, subscriptionID, resourceGroup, "other-sub", "other-rg") mockClient := mocks.NewMockLoadBalancersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, lbName).Return( armnetwork.LoadBalancersClientGetResponse{ LoadBalancer: *loadBalancer, }, nil) wrapper := manual.NewNetworkLoadBalancer(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], lbName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Find the Subnet linked query found := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkSubnet.String() { found = true expectedScope := fmt.Sprintf("%s.%s", "other-sub", "other-rg") if linkedQuery.GetQuery().GetScope() != expectedScope { t.Errorf("Expected Subnet scope to be %s, got: %s", expectedScope, linkedQuery.GetQuery().GetScope()) } break } } if !found { t.Error("Expected to find Subnet linked query") } }) } // MockLoadBalancersPager is a mock for LoadBalancersPager type MockLoadBalancersPager struct { ctrl *gomock.Controller recorder *MockLoadBalancersPagerMockRecorder } type MockLoadBalancersPagerMockRecorder struct { mock *MockLoadBalancersPager } func NewMockLoadBalancersPager(ctrl *gomock.Controller) *MockLoadBalancersPager { mock := &MockLoadBalancersPager{ctrl: ctrl} mock.recorder = &MockLoadBalancersPagerMockRecorder{mock} return mock } func (m *MockLoadBalancersPager) EXPECT() *MockLoadBalancersPagerMockRecorder { return m.recorder } func (m *MockLoadBalancersPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockLoadBalancersPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockLoadBalancersPager) NextPage(ctx context.Context) (armnetwork.LoadBalancersClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.LoadBalancersClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockLoadBalancersPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.LoadBalancersClientListResponse, error)](), ctx) } // createAzureLoadBalancer creates a mock Azure load balancer for testing with all linked resources func createAzureLoadBalancer(lbName, subscriptionID, resourceGroup string) *armnetwork.LoadBalancer { publicIPID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/publicIPAddresses/test-public-ip", subscriptionID, resourceGroup) subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet", subscriptionID, resourceGroup) nicID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkInterfaces/test-nic/ipConfigurations/ipconfig1", subscriptionID, resourceGroup) return &armnetwork.LoadBalancer{ Name: new(lbName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new("frontend-ip-config"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, // PrivateIPAddress is present when using a subnet (internal load balancer) PrivateIPAddress: new("10.2.0.5"), }, }, }, BackendAddressPools: []*armnetwork.BackendAddressPool{ { Name: new("backend-pool"), }, }, InboundNatRules: []*armnetwork.InboundNatRule{ { Name: new("inbound-nat-rule"), Properties: &armnetwork.InboundNatRulePropertiesFormat{ BackendIPConfiguration: &armnetwork.InterfaceIPConfiguration{ ID: new(nicID), }, }, }, }, LoadBalancingRules: []*armnetwork.LoadBalancingRule{ { Name: new("lb-rule"), }, }, Probes: []*armnetwork.Probe{ { Name: new("probe"), }, }, OutboundRules: []*armnetwork.OutboundRule{ { Name: new("outbound-rule"), }, }, InboundNatPools: []*armnetwork.InboundNatPool{ { Name: new("nat-pool"), }, }, }, } } // createAzureLoadBalancerWithDifferentScopePublicIP creates a load balancer with PublicIPAddress in different scope func createAzureLoadBalancerWithDifferentScopePublicIP(lbName, subscriptionID, resourceGroup, otherSub, otherRG string) *armnetwork.LoadBalancer { publicIPID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/publicIPAddresses/test-public-ip", otherSub, otherRG) return &armnetwork.LoadBalancer{ Name: new(lbName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new("frontend-ip-config"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(publicIPID), }, }, }, }, }, } } // createAzureLoadBalancerWithDifferentScopeSubnet creates a load balancer with Subnet in different scope func createAzureLoadBalancerWithDifferentScopeSubnet(lbName, subscriptionID, resourceGroup, otherSub, otherRG string) *armnetwork.LoadBalancer { subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet", otherSub, otherRG) return &armnetwork.LoadBalancer{ Name: new(lbName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.LoadBalancerPropertiesFormat{ FrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { Name: new("frontend-ip-config"), Properties: &armnetwork.FrontendIPConfigurationPropertiesFormat{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, }, }, }, }, } } ================================================ FILE: sources/azure/manual/network-local-network-gateway.go ================================================ package manual import ( "context" "errors" "net" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkLocalNetworkGatewayLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkLocalNetworkGateway) type networkLocalNetworkGatewayWrapper struct { client clients.LocalNetworkGatewaysClient *azureshared.MultiResourceGroupBase } // NewNetworkLocalNetworkGateway creates a new networkLocalNetworkGatewayWrapper instance. func NewNetworkLocalNetworkGateway(client clients.LocalNetworkGatewaysClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkLocalNetworkGatewayWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkLocalNetworkGateway, ), } } // List retrieves all local network gateways in a scope. // ref: https://learn.microsoft.com/en-us/rest/api/network-gateway/local-network-gateways/list func (c networkLocalNetworkGatewayWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, gw := range page.Value { if gw.Name == nil { continue } item, sdpErr := c.azureLocalNetworkGatewayToSDPItem(gw, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } // ListStream streams all local network gateways in a scope. func (c networkLocalNetworkGatewayWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, gw := range page.Value { if gw.Name == nil { continue } item, sdpErr := c.azureLocalNetworkGatewayToSDPItem(gw, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // Get retrieves a single local network gateway by name. // ref: https://learn.microsoft.com/en-us/rest/api/network-gateway/local-network-gateways/get func (c networkLocalNetworkGatewayWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the local network gateway name"), scope, c.Type()) } gatewayName := queryParts[0] if gatewayName == "" { return nil, azureshared.QueryError(errors.New("localNetworkGatewayName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } result, err := c.client.Get(ctx, rgScope.ResourceGroup, gatewayName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureLocalNetworkGatewayToSDPItem(&result.LocalNetworkGateway, scope) } func (c networkLocalNetworkGatewayWrapper) azureLocalNetworkGatewayToSDPItem(gw *armnetwork.LocalNetworkGateway, scope string) (*sdp.Item, *sdp.QueryError) { if gw.Name == nil { return nil, azureshared.QueryError(errors.New("local network gateway name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(gw, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkLocalNetworkGateway.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(gw.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Health from provisioning state if gw.Properties != nil && gw.Properties.ProvisioningState != nil { switch *gw.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Gateway IP address (on-premises VPN device IP) if gw.Properties != nil && gw.Properties.GatewayIPAddress != nil && *gw.Properties.GatewayIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *gw.Properties.GatewayIPAddress, Scope: "global", }, }) } // FQDN (if used instead of IP address for the on-premises device) if gw.Properties != nil && gw.Properties.Fqdn != nil && *gw.Properties.Fqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *gw.Properties.Fqdn, Scope: "global", }, }) } // BGP settings if gw.Properties != nil && gw.Properties.BgpSettings != nil { bgp := gw.Properties.BgpSettings // BgpPeeringAddress - can be IP or hostname if bgp.BgpPeeringAddress != nil && *bgp.BgpPeeringAddress != "" { if net.ParseIP(*bgp.BgpPeeringAddress) != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *bgp.BgpPeeringAddress, Scope: "global", }, }) } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *bgp.BgpPeeringAddress, Scope: "global", }, }) } } // BgpPeeringAddresses array if bgp.BgpPeeringAddresses != nil { for _, peeringAddr := range bgp.BgpPeeringAddresses { if peeringAddr == nil { continue } // DefaultBgpIPAddresses for _, ipStr := range peeringAddr.DefaultBgpIPAddresses { if ipStr != nil && *ipStr != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipStr, Scope: "global", }, }) } } // CustomBgpIPAddresses for _, ipStr := range peeringAddr.CustomBgpIPAddresses { if ipStr != nil && *ipStr != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipStr, Scope: "global", }, }) } } // TunnelIPAddresses for _, ipStr := range peeringAddr.TunnelIPAddresses { if ipStr != nil && *ipStr != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipStr, Scope: "global", }, }) } } } } } return sdpItem, nil } func (c networkLocalNetworkGatewayWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkLocalNetworkGatewayLookupByName, } } func (c networkLocalNetworkGatewayWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ stdlib.NetworkIP: true, stdlib.NetworkDNS: true, } } // IAMPermissions returns the Azure RBAC permissions required to read this resource. // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (c networkLocalNetworkGatewayWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/localNetworkGateways/read", } } // PredefinedRole returns the Azure built-in role that grants the required permissions. func (c networkLocalNetworkGatewayWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-local-network-gateway_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkLocalNetworkGateway(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { gatewayName := "test-local-gateway" gw := createAzureLocalNetworkGateway(gatewayName) mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, gatewayName, nil).Return( armnetwork.LocalNetworkGatewaysClientGetResponse{ LocalNetworkGateway: *gw, }, nil) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, gatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkLocalNetworkGateway.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkLocalNetworkGateway.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != gatewayName { t.Errorf("Expected unique attribute value %s, got %s", gatewayName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithFqdn", func(t *testing.T) { gatewayName := "test-local-gateway-fqdn" gw := createAzureLocalNetworkGatewayWithFqdn(gatewayName) mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, gatewayName, nil).Return( armnetwork.LocalNetworkGatewaysClientGetResponse{ LocalNetworkGateway: *gw, }, nil) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, gatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "vpn.example.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithBgpSettings", func(t *testing.T) { gatewayName := "test-local-gateway-bgp" gw := createAzureLocalNetworkGatewayWithBgp(gatewayName) mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, gatewayName, nil).Return( armnetwork.LocalNetworkGatewaysClientGetResponse{ LocalNetworkGateway: *gw, }, nil) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, gatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting gateway with empty name, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { gatewayName := "nonexistent-gateway" expectedErr := errors.New("local network gateway not found") mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, gatewayName, nil).Return( armnetwork.LocalNetworkGatewaysClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, gatewayName, true) if qErr == nil { t.Fatal("Expected error when gateway not found, got nil") } }) t.Run("List", func(t *testing.T) { gw1 := createAzureLocalNetworkGateway("local-gateway-1") gw2 := createAzureLocalNetworkGateway("local-gateway-2") mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) mockPager := newMockLocalNetworkGatewaysPager(ctrl, []*armnetwork.LocalNetworkGateway{gw1, gw2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for i, item := range items { if item.GetType() != azureshared.NetworkLocalNetworkGateway.String() { t.Errorf("Item %d: expected type %s, got %s", i, azureshared.NetworkLocalNetworkGateway.String(), item.GetType()) } if item.Validate() != nil { t.Errorf("Item %d: validation error: %v", i, item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { gw1 := createAzureLocalNetworkGateway("local-gateway-1") gw2 := createAzureLocalNetworkGateway("local-gateway-2") mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) mockPager := newMockLocalNetworkGatewaysPager(ctrl, []*armnetwork.LocalNetworkGateway{gw1, gw2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listStream, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } var received []*sdp.Item stream := &localNetworkGatewayCollectingStream{items: &received} listStream.ListStream(ctx, scope, true, stream) if len(received) != 2 { t.Fatalf("Expected 2 items from stream, got %d", len(received)) } }) t.Run("List_NilNameSkipped", func(t *testing.T) { gw1 := createAzureLocalNetworkGateway("local-gateway-1") gw2NilName := createAzureLocalNetworkGateway("local-gateway-2") gw2NilName.Name = nil mockClient := mocks.NewMockLocalNetworkGatewaysClient(ctrl) mockPager := newMockLocalNetworkGatewaysPager(ctrl, []*armnetwork.LocalNetworkGateway{gw1, gw2NilName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkLocalNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got %d", len(items)) } if items[0].UniqueAttributeValue() != "local-gateway-1" { t.Errorf("Expected only local-gateway-1, got %s", items[0].UniqueAttributeValue()) } }) t.Run("GetLookups", func(t *testing.T) { wrapper := manual.NewNetworkLocalNetworkGateway(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) == 0 { t.Error("Expected GetLookups to return at least one lookup") } found := false for _, l := range lookups { if l.ItemType.String() == azureshared.NetworkLocalNetworkGateway.String() { found = true break } } if !found { t.Error("Expected GetLookups to include NetworkLocalNetworkGateway") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewNetworkLocalNetworkGateway(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() for _, linkType := range []shared.ItemType{ stdlib.NetworkIP, stdlib.NetworkDNS, } { if !potentialLinks[linkType] { t.Errorf("Expected PotentialLinks to include %s", linkType) } } }) } type localNetworkGatewayCollectingStream struct { items *[]*sdp.Item } func (c *localNetworkGatewayCollectingStream) SendItem(item *sdp.Item) { *c.items = append(*c.items, item) } func (c *localNetworkGatewayCollectingStream) SendError(err error) {} type mockLocalNetworkGatewaysPager struct { ctrl *gomock.Controller items []*armnetwork.LocalNetworkGateway index int more bool } func newMockLocalNetworkGatewaysPager(ctrl *gomock.Controller, items []*armnetwork.LocalNetworkGateway) *mockLocalNetworkGatewaysPager { return &mockLocalNetworkGatewaysPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockLocalNetworkGatewaysPager) More() bool { return m.more } func (m *mockLocalNetworkGatewaysPager) NextPage(ctx context.Context) (armnetwork.LocalNetworkGatewaysClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.LocalNetworkGatewaysClientListResponse{ LocalNetworkGatewayListResult: armnetwork.LocalNetworkGatewayListResult{ Value: []*armnetwork.LocalNetworkGateway{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.LocalNetworkGatewaysClientListResponse{ LocalNetworkGatewayListResult: armnetwork.LocalNetworkGatewayListResult{ Value: []*armnetwork.LocalNetworkGateway{item}, }, }, nil } func createAzureLocalNetworkGateway(name string) *armnetwork.LocalNetworkGateway { provisioningState := armnetwork.ProvisioningStateSucceeded gatewayIP := "203.0.113.1" return &armnetwork.LocalNetworkGateway{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/localNetworkGateways/" + name), Name: new(name), Type: new("Microsoft.Network/localNetworkGateways"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.LocalNetworkGatewayPropertiesFormat{ ProvisioningState: &provisioningState, GatewayIPAddress: &gatewayIP, LocalNetworkAddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{ new("10.1.0.0/16"), new("10.2.0.0/16"), }, }, }, } } func createAzureLocalNetworkGatewayWithFqdn(name string) *armnetwork.LocalNetworkGateway { provisioningState := armnetwork.ProvisioningStateSucceeded fqdn := "vpn.example.com" return &armnetwork.LocalNetworkGateway{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/localNetworkGateways/" + name), Name: new(name), Type: new("Microsoft.Network/localNetworkGateways"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.LocalNetworkGatewayPropertiesFormat{ ProvisioningState: &provisioningState, Fqdn: &fqdn, LocalNetworkAddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{ new("10.1.0.0/16"), }, }, }, } } func createAzureLocalNetworkGatewayWithBgp(name string) *armnetwork.LocalNetworkGateway { provisioningState := armnetwork.ProvisioningStateSucceeded gatewayIP := "203.0.113.1" bgpPeeringAddress := "10.0.0.1" asn := int64(65001) return &armnetwork.LocalNetworkGateway{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/localNetworkGateways/" + name), Name: new(name), Type: new("Microsoft.Network/localNetworkGateways"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.LocalNetworkGatewayPropertiesFormat{ ProvisioningState: &provisioningState, GatewayIPAddress: &gatewayIP, BgpSettings: &armnetwork.BgpSettings{ Asn: &asn, BgpPeeringAddress: &bgpPeeringAddress, }, LocalNetworkAddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{ new("10.1.0.0/16"), }, }, }, } } ================================================ FILE: sources/azure/manual/network-nat-gateway.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkNatGatewayLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkNatGateway) type networkNatGatewayWrapper struct { client clients.NatGatewaysClient *azureshared.MultiResourceGroupBase } // NewNetworkNatGateway creates a new networkNatGatewayWrapper instance. func NewNetworkNatGateway(client clients.NatGatewaysClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkNatGatewayWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkNatGateway, ), } } func (n networkNatGatewayWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, ng := range page.Value { if ng.Name == nil { continue } item, sdpErr := n.azureNatGatewayToSDPItem(ng, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkNatGatewayWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, ng := range page.Value { if ng.Name == nil { continue } item, sdpErr := n.azureNatGatewayToSDPItem(ng, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkNatGatewayWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 1 query part: natGatewayName", Scope: scope, ItemType: n.Type(), } } natGatewayName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, natGatewayName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureNatGatewayToSDPItem(&resp.NatGateway, scope) } func (n networkNatGatewayWrapper) azureNatGatewayToSDPItem(ng *armnetwork.NatGateway, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(ng, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } if ng.Name == nil { return nil, azureshared.QueryError(errors.New("nat gateway name is nil"), scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkNatGateway.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(ng.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Health from provisioning state if ng.Properties != nil && ng.Properties.ProvisioningState != nil { switch *ng.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Linked resources from Properties if ng.Properties == nil { return sdpItem, nil } props := ng.Properties // Public IP addresses (V4 and V6) for _, refs := range [][]*armnetwork.SubResource{props.PublicIPAddresses, props.PublicIPAddressesV6} { for _, ref := range refs { if ref != nil && ref.ID != nil { refID := *ref.ID refName := azureshared.ExtractResourceName(refID) if refName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(refID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: refName, Scope: linkedScope, }, }) } } } } // Public IP prefixes (V4 and V6) for _, refs := range [][]*armnetwork.SubResource{props.PublicIPPrefixes, props.PublicIPPrefixesV6} { for _, ref := range refs { if ref != nil && ref.ID != nil { refID := *ref.ID refName := azureshared.ExtractResourceName(refID) if refName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(refID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPPrefix.String(), Method: sdp.QueryMethod_GET, Query: refName, Scope: linkedScope, }, }) } } } } // Subnets (read-only references: subnets using this NAT gateway) for _, ref := range props.Subnets { if ref != nil && ref.ID != nil { subnetID := *ref.ID params := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(subnetID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Scope: linkedScope, Query: shared.CompositeLookupKey(params[0], params[1]), }, }) } } } // Source virtual network if props.SourceVirtualNetwork != nil && props.SourceVirtualNetwork.ID != nil { vnetID := *props.SourceVirtualNetwork.ID vnetName := azureshared.ExtractResourceName(vnetID) if vnetName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(vnetID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (n networkNatGatewayWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkNatGatewayLookupByName, } } func (n networkNatGatewayWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkPublicIPAddress: true, azureshared.NetworkPublicIPPrefix: true, azureshared.NetworkSubnet: true, azureshared.NetworkVirtualNetwork: true, } } func (n networkNatGatewayWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_nat_gateway.name", }, } } func (n networkNatGatewayWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/natGateways/read", } } func (n networkNatGatewayWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-nat-gateway_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestNetworkNatGateway(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { natGatewayName := "test-nat-gateway" ng := createAzureNatGateway(natGatewayName) mockClient := mocks.NewMockNatGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, natGatewayName, nil).Return( armnetwork.NatGatewaysClientGetResponse{ NatGateway: *ng, }, nil) wrapper := manual.NewNetworkNatGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, natGatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkNatGateway.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNatGateway.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != natGatewayName { t.Errorf("Expected unique attribute value %s, got %s", natGatewayName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithLinkedResources", func(t *testing.T) { natGatewayName := "test-nat-gateway-with-links" ng := createAzureNatGatewayWithLinks(natGatewayName, subscriptionID, resourceGroup) mockClient := mocks.NewMockNatGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, natGatewayName, nil).Return( armnetwork.NatGatewaysClientGetResponse{ NatGateway: *ng, }, nil) wrapper := manual.NewNetworkNatGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, natGatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-public-ip", ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkPublicIPPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-public-ip-prefix", ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-vnet", ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockNatGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "", nil).Return( armnetwork.NatGatewaysClientGetResponse{}, errors.New("nat gateway not found")) wrapper := manual.NewNetworkNatGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting nat gateway with empty name, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { natGatewayName := "nonexistent-nat-gateway" expectedErr := errors.New("nat gateway not found") mockClient := mocks.NewMockNatGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, natGatewayName, nil).Return( armnetwork.NatGatewaysClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkNatGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, natGatewayName, true) if qErr == nil { t.Fatal("Expected error when nat gateway not found, got nil") } }) t.Run("List", func(t *testing.T) { ng1 := createAzureNatGateway("nat-gateway-1") ng2 := createAzureNatGateway("nat-gateway-2") mockClient := mocks.NewMockNatGatewaysClient(ctrl) mockPager := newMockNatGatewaysPager(ctrl, []*armnetwork.NatGateway{ng1, ng2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNatGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for i, item := range items { if item.GetType() != azureshared.NetworkNatGateway.String() { t.Errorf("Item %d: expected type %s, got %s", i, azureshared.NetworkNatGateway.String(), item.GetType()) } if item.Validate() != nil { t.Errorf("Item %d: validation error: %v", i, item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { ng1 := createAzureNatGateway("nat-gateway-1") ng2 := createAzureNatGateway("nat-gateway-2") mockClient := mocks.NewMockNatGatewaysClient(ctrl) mockPager := newMockNatGatewaysPager(ctrl, []*armnetwork.NatGateway{ng1, ng2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNatGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listStream, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } var received []*sdp.Item stream := &collectingStream{items: &received} listStream.ListStream(ctx, scope, true, stream) if len(received) != 2 { t.Fatalf("Expected 2 items from stream, got %d", len(received)) } }) t.Run("List_NilNameSkipped", func(t *testing.T) { ng1 := createAzureNatGateway("nat-gateway-1") ng2NilName := createAzureNatGateway("nat-gateway-2") ng2NilName.Name = nil mockClient := mocks.NewMockNatGatewaysClient(ctrl) mockPager := newMockNatGatewaysPager(ctrl, []*armnetwork.NatGateway{ng1, ng2NilName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNatGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got %d", len(items)) } if items[0].UniqueAttributeValue() != "nat-gateway-1" { t.Errorf("Expected only nat-gateway-1, got %s", items[0].UniqueAttributeValue()) } }) t.Run("GetLookups", func(t *testing.T) { wrapper := manual.NewNetworkNatGateway(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) == 0 { t.Error("Expected GetLookups to return at least one lookup") } found := false for _, l := range lookups { if l.ItemType.String() == azureshared.NetworkNatGateway.String() { found = true break } } if !found { t.Error("Expected GetLookups to include NetworkNatGateway") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewNetworkNatGateway(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() for _, linkType := range []shared.ItemType{ azureshared.NetworkPublicIPAddress, azureshared.NetworkPublicIPPrefix, azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, } { if !potentialLinks[linkType] { t.Errorf("Expected PotentialLinks to include %s", linkType) } } }) } type mockNatGatewaysPager struct { ctrl *gomock.Controller items []*armnetwork.NatGateway index int more bool } func newMockNatGatewaysPager(ctrl *gomock.Controller, items []*armnetwork.NatGateway) *mockNatGatewaysPager { return &mockNatGatewaysPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockNatGatewaysPager) More() bool { return m.more } func (m *mockNatGatewaysPager) NextPage(ctx context.Context) (armnetwork.NatGatewaysClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.NatGatewaysClientListResponse{ NatGatewayListResult: armnetwork.NatGatewayListResult{ Value: []*armnetwork.NatGateway{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.NatGatewaysClientListResponse{ NatGatewayListResult: armnetwork.NatGatewayListResult{ Value: []*armnetwork.NatGateway{item}, }, }, nil } func createAzureNatGateway(name string) *armnetwork.NatGateway { provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.NatGateway{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/natGateways/" + name), Name: new(name), Type: new("Microsoft.Network/natGateways"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.NatGatewayPropertiesFormat{ ProvisioningState: &provisioningState, }, } } func createAzureNatGatewayWithLinks(name, subscriptionID, resourceGroup string) *armnetwork.NatGateway { ng := createAzureNatGateway(name) baseID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network" publicIPID := baseID + "/publicIPAddresses/test-public-ip" publicIPPrefixID := baseID + "/publicIPPrefixes/test-public-ip-prefix" subnetID := baseID + "/virtualNetworks/test-vnet/subnets/test-subnet" sourceVnetID := baseID + "/virtualNetworks/source-vnet" ng.Properties.PublicIPAddresses = []*armnetwork.SubResource{ {ID: new(publicIPID)}, } ng.Properties.PublicIPPrefixes = []*armnetwork.SubResource{ {ID: new(publicIPPrefixID)}, } ng.Properties.Subnets = []*armnetwork.SubResource{ {ID: new(subnetID)}, } ng.Properties.SourceVirtualNetwork = &armnetwork.SubResource{ ID: new(sourceVnetID), } return ng } ================================================ FILE: sources/azure/manual/network-network-interface-ip-configuration.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkNetworkInterfaceIPConfigurationLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkNetworkInterfaceIPConfiguration) type networkNetworkInterfaceIPConfigurationWrapper struct { client clients.InterfaceIPConfigurationsClient *azureshared.MultiResourceGroupBase } func NewNetworkNetworkInterfaceIPConfiguration(client clients.InterfaceIPConfigurationsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkNetworkInterfaceIPConfigurationWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkNetworkInterfaceIPConfiguration, ), } } func (n networkNetworkInterfaceIPConfigurationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: networkInterfaceName and ipConfigurationName", Scope: scope, ItemType: n.Type(), } } networkInterfaceName := queryParts[0] ipConfigurationName := queryParts[1] if networkInterfaceName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "networkInterfaceName cannot be empty", Scope: scope, ItemType: n.Type(), } } if ipConfigurationName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "ipConfigurationName cannot be empty", Scope: scope, ItemType: n.Type(), } } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, networkInterfaceName, ipConfigurationName) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureIPConfigurationToSDPItem(&resp.InterfaceIPConfiguration, networkInterfaceName, scope) } func (n networkNetworkInterfaceIPConfigurationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkNetworkInterfaceLookupByName, NetworkNetworkInterfaceIPConfigurationLookupByName, } } func (n networkNetworkInterfaceIPConfigurationWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: networkInterfaceName", Scope: scope, ItemType: n.Type(), } } networkInterfaceName := queryParts[0] if networkInterfaceName == "" { return nil, azureshared.QueryError(errors.New("networkInterfaceName cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(ctx, rgScope.ResourceGroup, networkInterfaceName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, ipConfig := range page.Value { if ipConfig.Name == nil { continue } item, sdpErr := n.azureIPConfigurationToSDPItem(ipConfig, networkInterfaceName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkNetworkInterfaceIPConfigurationWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("SearchStream requires 1 query part: networkInterfaceName"), scope, n.Type())) return } networkInterfaceName := queryParts[0] if networkInterfaceName == "" { stream.SendError(azureshared.QueryError(errors.New("networkInterfaceName cannot be empty"), scope, n.Type())) return } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(ctx, rgScope.ResourceGroup, networkInterfaceName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, ipConfig := range page.Value { if ipConfig.Name == nil { continue } item, sdpErr := n.azureIPConfigurationToSDPItem(ipConfig, networkInterfaceName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkNetworkInterfaceIPConfigurationWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { NetworkNetworkInterfaceLookupByName, }, } } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-interface-ip-configurations/get func (n networkNetworkInterfaceIPConfigurationWrapper) azureIPConfigurationToSDPItem(ipConfig *armnetwork.InterfaceIPConfiguration, networkInterfaceName, scope string) (*sdp.Item, *sdp.QueryError) { if ipConfig.Name == nil { return nil, azureshared.QueryError(errors.New("IP configuration name is nil"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(ipConfig) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(networkInterfaceName, *ipConfig.Name)) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkNetworkInterfaceIPConfiguration.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health status based on provisioning state if ipConfig.Properties != nil && ipConfig.Properties.ProvisioningState != nil { switch *ipConfig.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting, armnetwork.ProvisioningStateCreating: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link back to parent NetworkInterface sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: networkInterfaceName, Scope: scope, }, }) if ipConfig.Properties != nil { props := ipConfig.Properties // Subnet link if props.Subnet != nil && props.Subnet.ID != nil { subnetParams := azureshared.ExtractPathParamsFromResourceID(*props.Subnet.ID, []string{"virtualNetworks", "subnets"}) if len(subnetParams) >= 2 { vnetName, subnetName := subnetParams[0], subnetParams[1] linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*props.Subnet.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: linkedScope, }, }) } } // Public IP address link if props.PublicIPAddress != nil && props.PublicIPAddress.ID != nil { pipName := azureshared.ExtractResourceName(*props.PublicIPAddress.ID) if pipName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*props.PublicIPAddress.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: pipName, Scope: linkedScope, }, }) } } // Private IP address -> stdlib ip if props.PrivateIPAddress != nil && *props.PrivateIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *props.PrivateIPAddress, Scope: "global", }, }) } // Application security groups if props.ApplicationSecurityGroups != nil { for _, asg := range props.ApplicationSecurityGroups { if asg != nil && asg.ID != nil { asgName := azureshared.ExtractResourceName(*asg.ID) if asgName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*asg.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: linkedScope, }, }) } } } } // Load balancer backend address pools if props.LoadBalancerBackendAddressPools != nil { for _, pool := range props.LoadBalancerBackendAddressPools { if pool != nil && pool.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*pool.ID, []string{"loadBalancers", "backendAddressPools"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*pool.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } // Load balancer inbound NAT rules if props.LoadBalancerInboundNatRules != nil { for _, rule := range props.LoadBalancerInboundNatRules { if rule != nil && rule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*rule.ID, []string{"loadBalancers", "inboundNatRules"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*rule.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } // Application gateway backend address pools if props.ApplicationGatewayBackendAddressPools != nil { for _, pool := range props.ApplicationGatewayBackendAddressPools { if pool != nil && pool.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*pool.ID, []string{"applicationGateways", "backendAddressPools"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*pool.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } // Gateway load balancer (frontend IP config reference) if props.GatewayLoadBalancer != nil && props.GatewayLoadBalancer.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*props.GatewayLoadBalancer.ID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*props.GatewayLoadBalancer.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Virtual network taps if props.VirtualNetworkTaps != nil { for _, tap := range props.VirtualNetworkTaps { if tap != nil && tap.ID != nil { tapName := azureshared.ExtractResourceName(*tap.ID) if tapName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*tap.ID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkTap.String(), Method: sdp.QueryMethod_GET, Query: tapName, Scope: linkedScope, }, }) } } } } // PrivateLinkConnectionProperties - FQDNs if props.PrivateLinkConnectionProperties != nil && props.PrivateLinkConnectionProperties.Fqdns != nil { for _, fqdn := range props.PrivateLinkConnectionProperties.Fqdns { if fqdn != nil && *fqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *fqdn, Scope: "global", }, }) } } } } return sdpItem, nil } func (n networkNetworkInterfaceIPConfigurationWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkNetworkInterface: true, azureshared.NetworkSubnet: true, azureshared.NetworkPublicIPAddress: true, azureshared.NetworkApplicationSecurityGroup: true, azureshared.NetworkLoadBalancerBackendAddressPool: true, azureshared.NetworkLoadBalancerInboundNatRule: true, azureshared.NetworkApplicationGatewayBackendAddressPool: true, azureshared.NetworkLoadBalancerFrontendIPConfiguration: true, azureshared.NetworkVirtualNetworkTap: true, stdlib.NetworkIP: true, stdlib.NetworkDNS: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (n networkNetworkInterfaceIPConfigurationWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/networkInterfaces/ipConfigurations/read", } } func (n networkNetworkInterfaceIPConfigurationWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-network-interface-ip-configuration_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // MockInterfaceIPConfigurationsPager is a simple mock for InterfaceIPConfigurationsPager type MockInterfaceIPConfigurationsPager struct { ctrl *gomock.Controller recorder *MockInterfaceIPConfigurationsPagerMockRecorder } type MockInterfaceIPConfigurationsPagerMockRecorder struct { mock *MockInterfaceIPConfigurationsPager } func NewMockInterfaceIPConfigurationsPager(ctrl *gomock.Controller) *MockInterfaceIPConfigurationsPager { mock := &MockInterfaceIPConfigurationsPager{ctrl: ctrl} mock.recorder = &MockInterfaceIPConfigurationsPagerMockRecorder{mock} return mock } func (m *MockInterfaceIPConfigurationsPager) EXPECT() *MockInterfaceIPConfigurationsPagerMockRecorder { return m.recorder } func (m *MockInterfaceIPConfigurationsPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockInterfaceIPConfigurationsPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockInterfaceIPConfigurationsPager) NextPage(ctx context.Context) (armnetwork.InterfaceIPConfigurationsClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.InterfaceIPConfigurationsClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockInterfaceIPConfigurationsPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.InterfaceIPConfigurationsClientListResponse, error)](), ctx) } // testInterfaceIPConfigurationsClient wraps the mock to implement the correct interface type testInterfaceIPConfigurationsClient struct { *mocks.MockInterfaceIPConfigurationsClient pager clients.InterfaceIPConfigurationsPager } func (t *testInterfaceIPConfigurationsClient) List(ctx context.Context, resourceGroupName, networkInterfaceName string) clients.InterfaceIPConfigurationsPager { return t.pager } func TestNetworkNetworkInterfaceIPConfiguration(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" networkInterfaceName := "test-nic" ipConfigName := "ipconfig1" t.Run("Get", func(t *testing.T) { ipConfig := createAzureIPConfiguration(subscriptionID, resourceGroup, networkInterfaceName, ipConfigName) mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkInterfaceName, ipConfigName).Return( armnetwork.InterfaceIPConfigurationsClientGetResponse{ InterfaceIPConfiguration: *ipConfig, }, nil) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkInterfaceName, ipConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkNetworkInterfaceIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNetworkInterfaceIPConfiguration, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueValue := shared.CompositeLookupKey(networkInterfaceName, ipConfigName) if sdpItem.UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if sdpItem.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", sdpItem.Validate()) } // Verify health status is OK for Succeeded provisioning state if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %v", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Parent NetworkInterface link ExpectedType: azureshared.NetworkNetworkInterface.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: networkInterfaceName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Subnet link ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Public IP address link ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pip", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Private IP address link (stdlib) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.4", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with only network interface name (missing ipConfigName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], networkInterfaceName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyNetworkInterfaceName", func(t *testing.T) { mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test directly on wrapper to get the QueryError _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0], "", ipConfigName) if qErr == nil { t.Fatal("Expected error when providing empty network interface name, but got nil") } if qErr.GetErrorString() != "networkInterfaceName cannot be empty" { t.Errorf("Expected specific error message, got: %s", qErr.GetErrorString()) } }) t.Run("GetWithEmptyIPConfigName", func(t *testing.T) { mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test directly on wrapper to get the QueryError _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0], networkInterfaceName, "") if qErr == nil { t.Fatal("Expected error when providing empty IP config name, but got nil") } if qErr.GetErrorString() != "ipConfigurationName cannot be empty" { t.Errorf("Expected specific error message, got: %s", qErr.GetErrorString()) } }) t.Run("Search", func(t *testing.T) { ipConfig1 := createAzureIPConfiguration(subscriptionID, resourceGroup, networkInterfaceName, "ipconfig1") ipConfig2 := createAzureIPConfiguration(subscriptionID, resourceGroup, networkInterfaceName, "ipconfig2") mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockPager := NewMockInterfaceIPConfigurationsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.InterfaceIPConfigurationsClientListResponse{ InterfaceIPConfigurationListResult: armnetwork.InterfaceIPConfigurationListResult{ Value: []*armnetwork.InterfaceIPConfiguration{ipConfig1, ipConfig2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) testClient := &testInterfaceIPConfigurationsClient{ MockInterfaceIPConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], networkInterfaceName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkNetworkInterfaceIPConfiguration.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNetworkInterfaceIPConfiguration, item.GetType()) } } }) t.Run("SearchWithEmptyNetworkInterfaceName", func(t *testing.T) { mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) testClient := &testInterfaceIPConfigurationsClient{MockInterfaceIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with empty network interface name _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when providing empty network interface name, but got nil") } }) t.Run("SearchWithNoQueryParts", func(t *testing.T) { mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) testClient := &testInterfaceIPConfigurationsClient{MockInterfaceIPConfigurationsClient: mockClient} wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_IPConfigWithNilName", func(t *testing.T) { mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockPager := NewMockInterfaceIPConfigurationsPager(ctrl) ipConfigValid := createAzureIPConfiguration(subscriptionID, resourceGroup, networkInterfaceName, "ipconfig-valid") ipConfigNilName := &armnetwork.InterfaceIPConfiguration{ Name: nil, } gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.InterfaceIPConfigurationsClientListResponse{ InterfaceIPConfigurationListResult: armnetwork.InterfaceIPConfigurationListResult{ Value: []*armnetwork.InterfaceIPConfiguration{ipConfigNilName, ipConfigValid}, }, }, nil), mockPager.EXPECT().More().Return(false), ) testClient := &testInterfaceIPConfigurationsClient{ MockInterfaceIPConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], networkInterfaceName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a valid name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } expectedUniqueValue := shared.CompositeLookupKey(networkInterfaceName, "ipconfig-valid") if sdpItems[0].UniqueAttributeValue() != expectedUniqueValue { t.Errorf("Expected unique value %s, got %s", expectedUniqueValue, sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("IP configuration not found") mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkInterfaceName, "nonexistent-ipconfig").Return( armnetwork.InterfaceIPConfigurationsClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkInterfaceName, "nonexistent-ipconfig") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent IP configuration, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { expectedErr := errors.New("failed to list IP configurations") mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockPager := NewMockInterfaceIPConfigurationsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.InterfaceIPConfigurationsClientListResponse{}, expectedErr), ) testClient := &testInterfaceIPConfigurationsClient{ MockInterfaceIPConfigurationsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], networkInterfaceName, true) if err == nil { t.Error("Expected error when listing IP configurations fails, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/networkInterfaces/ipConfigurations/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s, got %v", expectedPermission, permissions) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.NetworkNetworkInterface] { t.Error("Expected PotentialLinks to include NetworkNetworkInterface") } if !potentialLinks[azureshared.NetworkSubnet] { t.Error("Expected PotentialLinks to include NetworkSubnet") } if !potentialLinks[stdlib.NetworkIP] { t.Error("Expected PotentialLinks to include NetworkIP") } // Verify SearchLookups searchLookups := wrapper.SearchLookups() if len(searchLookups) == 0 { t.Error("Expected SearchLookups to return at least one lookup") } // Verify GetLookups getLookups := wrapper.GetLookups() if len(getLookups) != 2 { t.Errorf("Expected GetLookups to return 2 lookups (parent + child), got %d", len(getLookups)) } }) t.Run("HealthStatus_Pending", func(t *testing.T) { ipConfig := createAzureIPConfigurationWithProvisioningState(subscriptionID, resourceGroup, networkInterfaceName, ipConfigName, armnetwork.ProvisioningStateUpdating) mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkInterfaceName, ipConfigName).Return( armnetwork.InterfaceIPConfigurationsClientGetResponse{ InterfaceIPConfiguration: *ipConfig, }, nil) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkInterfaceName, ipConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != sdp.Health_HEALTH_PENDING { t.Errorf("Expected health PENDING, got %v", sdpItem.GetHealth()) } }) t.Run("HealthStatus_Error", func(t *testing.T) { ipConfig := createAzureIPConfigurationWithProvisioningState(subscriptionID, resourceGroup, networkInterfaceName, ipConfigName, armnetwork.ProvisioningStateFailed) mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkInterfaceName, ipConfigName).Return( armnetwork.InterfaceIPConfigurationsClientGetResponse{ InterfaceIPConfiguration: *ipConfig, }, nil) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkInterfaceName, ipConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != sdp.Health_HEALTH_ERROR { t.Errorf("Expected health ERROR, got %v", sdpItem.GetHealth()) } }) t.Run("GetWithApplicationSecurityGroups", func(t *testing.T) { ipConfig := createAzureIPConfigurationWithASG(subscriptionID, resourceGroup, networkInterfaceName, ipConfigName, "test-asg") mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkInterfaceName, ipConfigName).Return( armnetwork.InterfaceIPConfigurationsClientGetResponse{ InterfaceIPConfiguration: *ipConfig, }, nil) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkInterfaceName, ipConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify ASG link exists among the linked queries foundASG := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkApplicationSecurityGroup.String() && lq.GetQuery().GetMethod() == sdp.QueryMethod_GET && lq.GetQuery().GetQuery() == "test-asg" && lq.GetQuery().GetScope() == subscriptionID+"."+resourceGroup { foundASG = true break } } if !foundASG { t.Error("Expected to find ASG link in linked item queries") } }) t.Run("GetWithFQDNs", func(t *testing.T) { ipConfig := createAzureIPConfigurationWithFQDNs(subscriptionID, resourceGroup, networkInterfaceName, ipConfigName, []string{"test.privatelink.blob.core.windows.net", "example.internal"}) mockClient := mocks.NewMockInterfaceIPConfigurationsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, networkInterfaceName, ipConfigName).Return( armnetwork.InterfaceIPConfigurationsClientGetResponse{ InterfaceIPConfiguration: *ipConfig, }, nil) wrapper := manual.NewNetworkNetworkInterfaceIPConfiguration(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(networkInterfaceName, ipConfigName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify DNS links exist among the linked queries expectedFQDNs := []string{"test.privatelink.blob.core.windows.net", "example.internal"} for _, fqdn := range expectedFQDNs { found := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == stdlib.NetworkDNS.String() && lq.GetQuery().GetMethod() == sdp.QueryMethod_SEARCH && lq.GetQuery().GetQuery() == fqdn && lq.GetQuery().GetScope() == "global" { found = true break } } if !found { t.Errorf("Expected to find DNS link for FQDN %s in linked item queries", fqdn) } } }) } // createAzureIPConfiguration creates a mock Azure IP configuration for testing func createAzureIPConfiguration(subscriptionID, resourceGroup, nicName, ipConfigName string) *armnetwork.InterfaceIPConfiguration { subnetID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" pipID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/publicIPAddresses/test-pip" provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.InterfaceIPConfiguration{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/networkInterfaces/" + nicName + "/ipConfigurations/" + ipConfigName), Name: new(ipConfigName), Type: new("Microsoft.Network/networkInterfaces/ipConfigurations"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ ProvisioningState: &provisioningState, PrivateIPAddress: new("10.0.0.4"), PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), Primary: new(true), Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(pipID), }, }, } } // createAzureIPConfigurationWithProvisioningState creates a mock IP config with a specific provisioning state func createAzureIPConfigurationWithProvisioningState(subscriptionID, resourceGroup, nicName, ipConfigName string, state armnetwork.ProvisioningState) *armnetwork.InterfaceIPConfiguration { ipConfig := createAzureIPConfiguration(subscriptionID, resourceGroup, nicName, ipConfigName) ipConfig.Properties.ProvisioningState = &state return ipConfig } // createAzureIPConfigurationWithASG creates a mock IP config with application security groups func createAzureIPConfigurationWithASG(subscriptionID, resourceGroup, nicName, ipConfigName, asgName string) *armnetwork.InterfaceIPConfiguration { ipConfig := createAzureIPConfiguration(subscriptionID, resourceGroup, nicName, ipConfigName) asgID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/applicationSecurityGroups/" + asgName ipConfig.Properties.ApplicationSecurityGroups = []*armnetwork.ApplicationSecurityGroup{ { ID: new(asgID), }, } return ipConfig } // createAzureIPConfigurationWithFQDNs creates a mock IP config with PrivateLinkConnectionProperties FQDNs func createAzureIPConfigurationWithFQDNs(subscriptionID, resourceGroup, nicName, ipConfigName string, fqdns []string) *armnetwork.InterfaceIPConfiguration { ipConfig := createAzureIPConfiguration(subscriptionID, resourceGroup, nicName, ipConfigName) fqdnPtrs := make([]*string, len(fqdns)) for i := range fqdns { fqdnPtrs[i] = new(fqdns[i]) } ipConfig.Properties.PrivateLinkConnectionProperties = &armnetwork.InterfaceIPConfigurationPrivateLinkConnectionProperties{ Fqdns: fqdnPtrs, } return ipConfig } ================================================ FILE: sources/azure/manual/network-network-interface.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkNetworkInterfaceLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkNetworkInterface) type networkNetworkInterfaceWrapper struct { client clients.NetworkInterfacesClient *azureshared.MultiResourceGroupBase } func NewNetworkNetworkInterface(client clients.NetworkInterfacesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkNetworkInterfaceWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkNetworkInterface, ), } } func (n networkNetworkInterfaceWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(ctx, rgScope.ResourceGroup) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } for _, networkInterface := range page.Value { item, sdpErr := n.azureNetworkInterfaceToSDPItem(networkInterface) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkNetworkInterfaceWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(ctx, rgScope.ResourceGroup) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, networkInterface := range page.Value { if networkInterface.Name == nil { continue } item, sdpErr := n.azureNetworkInterfaceToSDPItem(networkInterface) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-interfaces/get?view=rest-virtualnetwork-2025-03-01&tabs=HTTP#response func (n networkNetworkInterfaceWrapper) azureNetworkInterfaceToSDPItem(networkInterface *armnetwork.Interface) (*sdp.Item, *sdp.QueryError) { if networkInterface.Name == nil { return nil, azureshared.QueryError(errors.New("network interface name is nil"), n.DefaultScope(), n.Type()) } attributes, err := shared.ToAttributesWithExclude(networkInterface, "tags") if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkNetworkInterface.String(), UniqueAttribute: "name", Attributes: attributes, Scope: n.DefaultScope(), Tags: azureshared.ConvertAzureTags(networkInterface.Tags), } // Add IP configuration link (name is guaranteed to be non-nil due to validation above) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterfaceIPConfiguration.String(), Method: sdp.QueryMethod_SEARCH, Query: *networkInterface.Name, Scope: n.DefaultScope(), }, }) if networkInterface.Properties != nil && networkInterface.Properties.VirtualMachine != nil { if networkInterface.Properties.VirtualMachine.ID != nil { vmName := azureshared.ExtractResourceName(*networkInterface.Properties.VirtualMachine.ID) if vmName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ComputeVirtualMachine.String(), Method: sdp.QueryMethod_GET, Query: vmName, Scope: n.DefaultScope(), }, }) } } } if networkInterface.Properties != nil && networkInterface.Properties.NetworkSecurityGroup != nil { if networkInterface.Properties.NetworkSecurityGroup.ID != nil { nsgName := azureshared.ExtractResourceName(*networkInterface.Properties.NetworkSecurityGroup.ID) if nsgName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: nsgName, Scope: n.DefaultScope(), }, }) } } } // Private endpoint (read-only reference when NIC is used by a private endpoint) if networkInterface.Properties != nil && networkInterface.Properties.PrivateEndpoint != nil && networkInterface.Properties.PrivateEndpoint.ID != nil { peName := azureshared.ExtractResourceName(*networkInterface.Properties.PrivateEndpoint.ID) if peName != "" { scope := azureshared.ExtractScopeFromResourceID(*networkInterface.Properties.PrivateEndpoint.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: scope, }, }) } } // Private Link Service (when this NIC is the frontend of a private link service) if networkInterface.Properties != nil && networkInterface.Properties.PrivateLinkService != nil && networkInterface.Properties.PrivateLinkService.ID != nil { plsName := azureshared.ExtractResourceName(*networkInterface.Properties.PrivateLinkService.ID) if plsName != "" { scope := azureshared.ExtractScopeFromResourceID(*networkInterface.Properties.PrivateLinkService.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateLinkService.String(), Method: sdp.QueryMethod_GET, Query: plsName, Scope: scope, }, }) } } // DSCP configuration (read-only reference) if networkInterface.Properties != nil && networkInterface.Properties.DscpConfiguration != nil && networkInterface.Properties.DscpConfiguration.ID != nil { dscpName := azureshared.ExtractResourceName(*networkInterface.Properties.DscpConfiguration.ID) if dscpName != "" { scope := azureshared.ExtractScopeFromResourceID(*networkInterface.Properties.DscpConfiguration.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkDscpConfiguration.String(), Method: sdp.QueryMethod_GET, Query: dscpName, Scope: scope, }, }) } } // Tap configurations (child resource; list by NIC name) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterfaceTapConfiguration.String(), Method: sdp.QueryMethod_SEARCH, Query: *networkInterface.Name, Scope: n.DefaultScope(), }, }) // IP configuration references: subnet, public IP, private IP (stdlib), ASGs, LB pools/rules, App Gateway pools, gateway LB, VNet taps if networkInterface.Properties != nil && networkInterface.Properties.IPConfigurations != nil { for _, ipConfig := range networkInterface.Properties.IPConfigurations { if ipConfig == nil || ipConfig.Properties == nil { continue } props := ipConfig.Properties // Subnet if props.Subnet != nil && props.Subnet.ID != nil { subnetParams := azureshared.ExtractPathParamsFromResourceID(*props.Subnet.ID, []string{"virtualNetworks", "subnets"}) if len(subnetParams) >= 2 { vnetName, subnetName := subnetParams[0], subnetParams[1] scope := azureshared.ExtractScopeFromResourceID(*props.Subnet.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: scope, }, }) } } // Public IP address if props.PublicIPAddress != nil && props.PublicIPAddress.ID != nil { pipName := azureshared.ExtractResourceName(*props.PublicIPAddress.ID) if pipName != "" { scope := azureshared.ExtractScopeFromResourceID(*props.PublicIPAddress.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: pipName, Scope: scope, }, }) } } // Private IP address -> stdlib ip if props.PrivateIPAddress != nil && *props.PrivateIPAddress != "" { addr := *props.PrivateIPAddress sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: addr, Scope: "global", }, }) } // Application security groups if props.ApplicationSecurityGroups != nil { for _, asg := range props.ApplicationSecurityGroups { if asg != nil && asg.ID != nil { asgName := azureshared.ExtractResourceName(*asg.ID) if asgName != "" { scope := azureshared.ExtractScopeFromResourceID(*asg.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: scope, }, }) } } } } // Load balancer backend address pools if props.LoadBalancerBackendAddressPools != nil { for _, pool := range props.LoadBalancerBackendAddressPools { if pool != nil && pool.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*pool.ID, []string{"loadBalancers", "backendAddressPools"}) if len(params) >= 2 { scope := azureshared.ExtractScopeFromResourceID(*pool.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: scope, }, }) } } } } // Load balancer inbound NAT rules if props.LoadBalancerInboundNatRules != nil { for _, rule := range props.LoadBalancerInboundNatRules { if rule != nil && rule.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*rule.ID, []string{"loadBalancers", "inboundNatRules"}) if len(params) >= 2 { scope := azureshared.ExtractScopeFromResourceID(*rule.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerInboundNatRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: scope, }, }) } } } } // Application Gateway backend address pools if props.ApplicationGatewayBackendAddressPools != nil { for _, pool := range props.ApplicationGatewayBackendAddressPools { if pool != nil && pool.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*pool.ID, []string{"applicationGateways", "backendAddressPools"}) if len(params) >= 2 { scope := azureshared.ExtractScopeFromResourceID(*pool.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGatewayBackendAddressPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: scope, }, }) } } } } // Gateway Load Balancer (frontend IP config reference) if props.GatewayLoadBalancer != nil && props.GatewayLoadBalancer.ID != nil { params := azureshared.ExtractPathParamsFromResourceID(*props.GatewayLoadBalancer.ID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 { scope := azureshared.ExtractScopeFromResourceID(*props.GatewayLoadBalancer.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: scope, }, }) } } // Virtual Network Taps if props.VirtualNetworkTaps != nil { for _, tap := range props.VirtualNetworkTaps { if tap != nil && tap.ID != nil { tapName := azureshared.ExtractResourceName(*tap.ID) if tapName != "" { scope := azureshared.ExtractScopeFromResourceID(*tap.ID) if scope == "" { scope = n.DefaultScope() } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkTap.String(), Method: sdp.QueryMethod_GET, Query: tapName, Scope: scope, }, }) } } } } } } // DNS settings: link IPs to stdlib.NetworkIP and hostnames to stdlib.NetworkDNS if networkInterface.Properties != nil && networkInterface.Properties.DNSSettings != nil { dns := networkInterface.Properties.DNSSettings if dns.DNSServers != nil { for _, srv := range dns.DNSServers { if srv == nil { continue } appendDNSServerLinkIfValid(&sdpItem.LinkedItemQueries, *srv, "AzureProvidedDNS") } } if dns.InternalDNSNameLabel != nil && *dns.InternalDNSNameLabel != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *dns.InternalDNSNameLabel, Scope: "global", }, }) } if dns.InternalFqdn != nil && *dns.InternalFqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *dns.InternalFqdn, Scope: "global", }, }) } } return sdpItem, nil } func (n networkNetworkInterfaceWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part and be a network interface name"), n.DefaultScope(), n.Type()) } networkInterfaceName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } networkInterface, err := n.client.Get(ctx, rgScope.ResourceGroup, networkInterfaceName) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } return n.azureNetworkInterfaceToSDPItem(&networkInterface.Interface) } func (n networkNetworkInterfaceWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkNetworkInterfaceLookupByName, } } func (n networkNetworkInterfaceWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkVirtualNetwork: true, azureshared.ComputeVirtualMachine: true, azureshared.NetworkNetworkSecurityGroup: true, azureshared.NetworkNetworkInterfaceIPConfiguration: true, azureshared.NetworkNetworkInterfaceTapConfiguration: true, azureshared.NetworkSubnet: true, azureshared.NetworkPublicIPAddress: true, azureshared.NetworkPrivateEndpoint: true, azureshared.NetworkPrivateLinkService: true, azureshared.NetworkDscpConfiguration: true, azureshared.NetworkApplicationSecurityGroup: true, azureshared.NetworkLoadBalancerBackendAddressPool: true, azureshared.NetworkLoadBalancerInboundNatRule: true, azureshared.NetworkApplicationGatewayBackendAddressPool: true, azureshared.NetworkLoadBalancerFrontendIPConfiguration: true, azureshared.NetworkVirtualNetworkTap: true, stdlib.NetworkIP: true, stdlib.NetworkDNS: true, } } func (n networkNetworkInterfaceWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_network_interface.name", }, } } func (n networkNetworkInterfaceWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/networkInterfaces/read", } } func (n networkNetworkInterfaceWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-network-interface_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkNetworkInterface(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { nicName := "test-nic" nic := createAzureNetworkInterface(nicName, "test-vm", "test-nsg") mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nicName).Return( armnetwork.InterfacesClientGetResponse{ Interface: *nic, }, nil) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], nicName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkNetworkInterface.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNetworkInterface, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != nicName { t.Errorf("Expected unique attribute value %s, got %s", nicName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // NetworkNetworkInterfaceIPConfiguration link ExpectedType: azureshared.NetworkNetworkInterfaceIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: nicName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // ComputeVirtualMachine link ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // NetworkNetworkSecurityGroup link ExpectedType: azureshared.NetworkNetworkSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nsg", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // NetworkNetworkInterfaceTapConfiguration link (child resource) ExpectedType: azureshared.NetworkNetworkInterfaceTapConfiguration.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: nicName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("DNSServers_IP_and_hostname", func(t *testing.T) { nicWithDNS := createAzureNetworkInterfaceWithDNSServers(nicName, "test-vm", "test-nsg", []string{"10.0.0.1", "dns.internal"}) mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nicName).Return( armnetwork.InterfacesClientGetResponse{ Interface: *nicWithDNS, }, nil) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], nicName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Same base links as main Get test, plus DNS server links (IP → NetworkIP, hostname → NetworkDNS) queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkNetworkInterfaceIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: nicName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.ComputeVirtualMachine.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vm", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkNetworkSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nsg", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkNetworkInterfaceTapConfiguration.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: nicName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dns.internal", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty string name - Get will still be called with empty string // and Azure will return an error mockClient.EXPECT().Get(ctx, resourceGroup, "").Return( armnetwork.InterfacesClientGetResponse{}, errors.New("network interface not found")) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting network interface with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { nic1 := createAzureNetworkInterface("test-nic-1", "test-vm-1", "test-nsg-1") nic2 := createAzureNetworkInterface("test-nic-2", "test-vm-2", "test-nsg-2") mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) mockPager := NewMockNetworkInterfacesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.InterfacesClientListResponse{ InterfaceListResult: armnetwork.InterfaceListResult{ Value: []*armnetwork.Interface{nic1, nic2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkNetworkInterface.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkNetworkInterface, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Create NIC with nil name to test error handling nic1 := createAzureNetworkInterface("test-nic-1", "test-vm-1", "test-nsg-1") nic2 := &armnetwork.Interface{ Name: nil, // NIC with nil name should cause an error in azureNetworkInterfaceToSDPItem Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.InterfacePropertiesFormat{ IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new("ipconfig1"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), }, }, }, }, } mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) mockPager := NewMockNetworkInterfacesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.InterfacesClientListResponse{ InterfaceListResult: armnetwork.InterfaceListResult{ Value: []*armnetwork.Interface{nic1, nic2}, }, }, nil), ) // Note: More() won't be called again after NextPage returns the items with nil name // because azureNetworkInterfaceToSDPItem will return an error mockClient.EXPECT().List(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) // Should return an error because nic2 has nil name if err == nil { t.Fatalf("Expected error when listing network interfaces with nil name, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("network interface not found") mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-nic").Return( armnetwork.InterfacesClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-nic", true) if qErr == nil { t.Error("Expected error when getting non-existent network interface, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { expectedErr := errors.New("failed to list network interfaces") mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) mockPager := NewMockNetworkInterfacesPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.InterfacesClientListResponse{}, expectedErr), ) mockClient.EXPECT().List(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing network interfaces fails, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockNetworkInterfacesClient(ctrl) wrapper := manual.NewNetworkNetworkInterface(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements ListableWrapper interface _ = wrapper // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/networkInterfaces/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.NetworkVirtualNetwork] { t.Error("Expected PotentialLinks to include NetworkVirtualNetwork") } // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_network_interface.name" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_network_interface.name' mapping") } }) } // MockNetworkInterfacesPager is a simple mock for NetworkInterfacesPager type MockNetworkInterfacesPager struct { ctrl *gomock.Controller recorder *MockNetworkInterfacesPagerMockRecorder } type MockNetworkInterfacesPagerMockRecorder struct { mock *MockNetworkInterfacesPager } func NewMockNetworkInterfacesPager(ctrl *gomock.Controller) *MockNetworkInterfacesPager { mock := &MockNetworkInterfacesPager{ctrl: ctrl} mock.recorder = &MockNetworkInterfacesPagerMockRecorder{mock} return mock } func (m *MockNetworkInterfacesPager) EXPECT() *MockNetworkInterfacesPagerMockRecorder { return m.recorder } func (m *MockNetworkInterfacesPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockNetworkInterfacesPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockNetworkInterfacesPager) NextPage(ctx context.Context) (armnetwork.InterfacesClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.InterfacesClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockNetworkInterfacesPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.InterfacesClientListResponse, error)](), ctx) } // createAzureNetworkInterface creates a mock Azure network interface for testing func createAzureNetworkInterface(nicName, vmName, nsgName string) *armnetwork.Interface { vmID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/" + vmName nsgID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/" + nsgName return &armnetwork.Interface{ Name: new(nicName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.InterfacePropertiesFormat{ VirtualMachine: &armnetwork.SubResource{ ID: new(vmID), }, NetworkSecurityGroup: &armnetwork.SecurityGroup{ ID: new(nsgID), }, IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ { Name: new("ipconfig1"), Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ PrivateIPAllocationMethod: new(armnetwork.IPAllocationMethodDynamic), }, }, }, }, } } // createAzureNetworkInterfaceWithDNSServers creates a mock Azure network interface with DNSSettings for testing DNS server links (IP vs hostname). func createAzureNetworkInterfaceWithDNSServers(nicName, vmName, nsgName string, dnsServers []string) *armnetwork.Interface { nic := createAzureNetworkInterface(nicName, vmName, nsgName) ptrs := make([]*string, len(dnsServers)) for i := range dnsServers { ptrs[i] = new(dnsServers[i]) } nic.Properties.DNSSettings = &armnetwork.InterfaceDNSSettings{ DNSServers: ptrs, } return nic } ================================================ FILE: sources/azure/manual/network-network-security-group.go ================================================ package manual import ( "context" "errors" "net" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkNetworkSecurityGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkNetworkSecurityGroup) // appendIPOrCIDRLinkIfValid appends a linked item query to stdlib.NetworkIP when the prefix is an IP address or CIDR (not a service tag like VirtualNetwork, Internet, *). func appendIPOrCIDRLinkIfValid(queries *[]*sdp.LinkedItemQuery, prefix string) { appendLinkIfValid(queries, prefix, []string{"*"}, func(p string) *sdp.LinkedItemQuery { if net.ParseIP(p) != nil { return networkIPQuery(p) } if _, _, err := net.ParseCIDR(p); err == nil { return networkIPQuery(p) } return nil }) } type networkNetworkSecurityGroupWrapper struct { client clients.NetworkSecurityGroupsClient *azureshared.MultiResourceGroupBase } func NewNetworkNetworkSecurityGroup(client clients.NetworkSecurityGroupsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkNetworkSecurityGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkNetworkSecurityGroup, ), } } // reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/list?view=rest-virtualnetwork-2025-03-01&tabs=HTTP func (n networkNetworkSecurityGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(ctx, rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } for _, networkSecurityGroup := range page.Value { if networkSecurityGroup.Name == nil { continue } item, sdpErr := n.azureNetworkSecurityGroupToSDPItem(networkSecurityGroup) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkNetworkSecurityGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(ctx, rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, n.DefaultScope(), n.Type())) return } for _, networkSecurityGroup := range page.Value { if networkSecurityGroup.Name == nil { continue } item, sdpErr := n.azureNetworkSecurityGroupToSDPItem(networkSecurityGroup) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get?view=rest-virtualnetwork-2025-03-01&tabs=HTTP func (n networkNetworkSecurityGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the network security group name"), n.DefaultScope(), n.Type()) } networkSecurityGroupName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } networkSecurityGroup, err := n.client.Get(ctx, rgScope.ResourceGroup, networkSecurityGroupName, nil) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } return n.azureNetworkSecurityGroupToSDPItem(&networkSecurityGroup.SecurityGroup) } func (n networkNetworkSecurityGroupWrapper) azureNetworkSecurityGroupToSDPItem(networkSecurityGroup *armnetwork.SecurityGroup) (*sdp.Item, *sdp.QueryError) { if networkSecurityGroup.Name == nil { return nil, azureshared.QueryError(errors.New("network security group name is nil"), n.DefaultScope(), n.Type()) } attributes, err := shared.ToAttributesWithExclude(networkSecurityGroup, "tags") if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } nsgName := *networkSecurityGroup.Name sdpItem := &sdp.Item{ Type: azureshared.NetworkNetworkSecurityGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: n.DefaultScope(), Tags: azureshared.ConvertAzureTags(networkSecurityGroup.Tags), } // Link to SecurityRules (child resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/security-rules/get if networkSecurityGroup.Properties != nil && networkSecurityGroup.Properties.SecurityRules != nil { for _, securityRule := range networkSecurityGroup.Properties.SecurityRules { if securityRule.Name != nil && *securityRule.Name != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSecurityRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(nsgName, *securityRule.Name), Scope: n.DefaultScope(), }, }) } } } // Link to DefaultSecurityRules (child resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get#defaultsecurityrules if networkSecurityGroup.Properties != nil && networkSecurityGroup.Properties.DefaultSecurityRules != nil { for _, defaultSecurityRule := range networkSecurityGroup.Properties.DefaultSecurityRules { if defaultSecurityRule.Name != nil && *defaultSecurityRule.Name != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkDefaultSecurityRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(nsgName, *defaultSecurityRule.Name), Scope: n.DefaultScope(), }, }) } } } // Link to Subnets (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get if networkSecurityGroup.Properties != nil && networkSecurityGroup.Properties.Subnets != nil { for _, subnetRef := range networkSecurityGroup.Properties.Subnets { if subnetRef.ID != nil { // Extract subnet name and virtual network name from the resource ID // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnet}/subnets/{subnet} subnetName := azureshared.ExtractResourceName(*subnetRef.ID) if subnetName != "" { // Extract virtual network name (second to last segment) parts := strings.Split(strings.Trim(*subnetRef.ID, "/"), "/") vnetName := "" for i, part := range parts { if part == "virtualNetworks" && i+1 < len(parts) { vnetName = parts[i+1] break } } if vnetName != "" { scope := n.DefaultScope() // Check if subnet is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*subnetRef.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: scope, }, }) } } } } } // Link to NetworkInterfaces (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-interfaces/get if networkSecurityGroup.Properties != nil && networkSecurityGroup.Properties.NetworkInterfaces != nil { for _, nicRef := range networkSecurityGroup.Properties.NetworkInterfaces { if nicRef.ID != nil { nicName := azureshared.ExtractResourceName(*nicRef.ID) if nicName != "" { scope := n.DefaultScope() // Check if network interface is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*nicRef.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: nicName, Scope: scope, }, }) } } } } // Link to FlowLogs (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/network-watcher/flow-logs/get if networkSecurityGroup.Properties != nil && networkSecurityGroup.Properties.FlowLogs != nil { for _, flowLogRef := range networkSecurityGroup.Properties.FlowLogs { if flowLogRef != nil && flowLogRef.ID != nil && *flowLogRef.ID != "" { flowLogID := *flowLogRef.ID params := azureshared.ExtractPathParamsFromResourceID(flowLogID, []string{"networkWatchers", "flowLogs"}) if len(params) < 2 { params = azureshared.ExtractPathParamsFromResourceID(flowLogID, []string{"networkWatchers", "FlowLogs"}) } if len(params) >= 2 { networkWatcherName := params[0] flowLogName := params[1] scope := n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(flowLogID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkFlowLog.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(networkWatcherName, flowLogName), Scope: scope, }, }) } } } } // Link to ApplicationSecurityGroups and IPGroups from SecurityRules // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/application-security-groups/get // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/ip-groups/get if networkSecurityGroup.Properties != nil { // Process SecurityRules if networkSecurityGroup.Properties.SecurityRules != nil { for _, securityRule := range networkSecurityGroup.Properties.SecurityRules { if securityRule.Properties != nil { // Link to SourceApplicationSecurityGroups if securityRule.Properties.SourceApplicationSecurityGroups != nil { for _, asgRef := range securityRule.Properties.SourceApplicationSecurityGroups { if asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { scope := n.DefaultScope() // Check if Application Security Group is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: scope, }, }) } } } } // Link to DestinationApplicationSecurityGroups if securityRule.Properties.DestinationApplicationSecurityGroups != nil { for _, asgRef := range securityRule.Properties.DestinationApplicationSecurityGroups { if asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { scope := n.DefaultScope() // Check if Application Security Group is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: scope, }, }) } } } } // Link to stdlib.NetworkIP for source/destination address prefixes when they are IPs or CIDRs if securityRule.Properties.SourceAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *securityRule.Properties.SourceAddressPrefix) } for _, p := range securityRule.Properties.SourceAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } if securityRule.Properties.DestinationAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *securityRule.Properties.DestinationAddressPrefix) } for _, p := range securityRule.Properties.DestinationAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } } } } // Process DefaultSecurityRules (they can also reference ApplicationSecurityGroups and IPGroups) if networkSecurityGroup.Properties.DefaultSecurityRules != nil { for _, defaultSecurityRule := range networkSecurityGroup.Properties.DefaultSecurityRules { if defaultSecurityRule.Properties != nil { // Link to SourceApplicationSecurityGroups if defaultSecurityRule.Properties.SourceApplicationSecurityGroups != nil { for _, asgRef := range defaultSecurityRule.Properties.SourceApplicationSecurityGroups { if asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { scope := n.DefaultScope() // Check if Application Security Group is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: scope, }, }) } } } } // Link to DestinationApplicationSecurityGroups if defaultSecurityRule.Properties.DestinationApplicationSecurityGroups != nil { for _, asgRef := range defaultSecurityRule.Properties.DestinationApplicationSecurityGroups { if asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { scope := n.DefaultScope() // Check if Application Security Group is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: scope, }, }) } } } } // Link to stdlib.NetworkIP for source/destination address prefixes when they are IPs or CIDRs if defaultSecurityRule.Properties.SourceAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *defaultSecurityRule.Properties.SourceAddressPrefix) } for _, p := range defaultSecurityRule.Properties.SourceAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } if defaultSecurityRule.Properties.DestinationAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *defaultSecurityRule.Properties.DestinationAddressPrefix) } for _, p := range defaultSecurityRule.Properties.DestinationAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } } } } } return sdpItem, nil } func (n networkNetworkSecurityGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkNetworkSecurityGroupLookupByName, } } func (n networkNetworkSecurityGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkSecurityRule: true, azureshared.NetworkDefaultSecurityRule: true, azureshared.NetworkSubnet: true, azureshared.NetworkNetworkInterface: true, azureshared.NetworkFlowLog: true, azureshared.NetworkApplicationSecurityGroup: true, azureshared.NetworkIPGroup: true, stdlib.NetworkIP: true, } } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/network_security_group func (n networkNetworkSecurityGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_network_security_group.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (n networkNetworkSecurityGroupWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/networkSecurityGroups/read", } } func (n networkNetworkSecurityGroupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-network-security-group_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkNetworkSecurityGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { nsgName := "test-nsg" nsg := createAzureNetworkSecurityGroup(nsgName) mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, nil).Return( armnetwork.SecurityGroupsClientGetResponse{ SecurityGroup: *nsg, }, nil) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], nsgName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkNetworkSecurityGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNetworkSecurityGroup, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != nsgName { t.Errorf("Expected unique attribute value %s, got %s", nsgName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // SecurityRule link ExpectedType: azureshared.NetworkSecurityRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(nsgName, "test-security-rule"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DefaultSecurityRule link ExpectedType: azureshared.NetworkDefaultSecurityRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(nsgName, "AllowVnetInBound"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Subnet link ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // NetworkInterface link ExpectedType: azureshared.NetworkNetworkInterface.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nic", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // ApplicationSecurityGroup link (from SecurityRule Source) ExpectedType: azureshared.NetworkApplicationSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-asg-source", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // ApplicationSecurityGroup link (from SecurityRule Destination) ExpectedType: azureshared.NetworkApplicationSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-asg-dest", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // ApplicationSecurityGroup link (from DefaultSecurityRule Source) ExpectedType: azureshared.NetworkApplicationSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-asg-default-source", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty string name - Get will still be called with empty string // and Azure will return an error mockClient.EXPECT().Get(ctx, resourceGroup, "", nil).Return( armnetwork.SecurityGroupsClientGetResponse{}, errors.New("network security group not found")) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting network security group with empty name, but got nil") } }) t.Run("Get_WithNilName", func(t *testing.T) { nsg := &armnetwork.SecurityGroup{ Name: nil, // NSG with nil name should cause an error Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-nsg", nil).Return( armnetwork.SecurityGroupsClientGetResponse{ SecurityGroup: *nsg, }, nil) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-nsg", true) if qErr == nil { t.Error("Expected error when network security group has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { nsg1 := createAzureNetworkSecurityGroup("test-nsg-1") nsg2 := createAzureNetworkSecurityGroup("test-nsg-2") mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) mockPager := NewMockNetworkSecurityGroupsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.SecurityGroupsClientListResponse{ SecurityGroupListResult: armnetwork.SecurityGroupListResult{ Value: []*armnetwork.SecurityGroup{nsg1, nsg2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(ctx, resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkNetworkSecurityGroup.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkNetworkSecurityGroup, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Create NSG with nil name to test error handling nsg1 := createAzureNetworkSecurityGroup("test-nsg-1") nsg2 := &armnetwork.SecurityGroup{ Name: nil, // NSG with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) mockPager := NewMockNetworkSecurityGroupsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.SecurityGroupsClientListResponse{ SecurityGroupListResult: armnetwork.SecurityGroupListResult{ Value: []*armnetwork.SecurityGroup{nsg1, nsg2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(ctx, resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (nsg1), nsg2 should be skipped if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name should be skipped), got: %d", len(sdpItems)) } }) t.Run("List_ErrorHandling", func(t *testing.T) { expectedErr := errors.New("failed to list network security groups") mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) mockPager := NewMockNetworkSecurityGroupsPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.SecurityGroupsClientListResponse{}, expectedErr), ) mockClient.EXPECT().List(ctx, resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing network security groups fails, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("network security group not found") mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-nsg", nil).Return( armnetwork.SecurityGroupsClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-nsg", true) if qErr == nil { t.Error("Expected error when getting non-existent network security group, but got nil") } }) t.Run("CrossResourceGroupLinks", func(t *testing.T) { // Test NSG with subnet and NIC in different resource groups nsgName := "test-nsg" otherResourceGroup := "other-rg" otherSubscriptionID := "other-subscription" nsg := &armnetwork.SecurityGroup{ Name: new(nsgName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.SecurityGroupPropertiesFormat{ Subnets: []*armnetwork.Subnet{ { ID: new("/subscriptions/" + otherSubscriptionID + "/resourceGroups/" + otherResourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, NetworkInterfaces: []*armnetwork.Interface{ { ID: new("/subscriptions/" + otherSubscriptionID + "/resourceGroups/" + otherResourceGroup + "/providers/Microsoft.Network/networkInterfaces/test-nic"), }, }, }, } mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, nil).Return( armnetwork.SecurityGroupsClientGetResponse{ SecurityGroup: *nsg, }, nil) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], nsgName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Check that subnet link uses the correct scope foundSubnetLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.NetworkSubnet.String() { foundSubnetLink = true expectedScope := otherSubscriptionID + "." + otherResourceGroup if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected subnet scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } } if !foundSubnetLink { t.Error("Expected to find subnet link") } // Check that NIC link uses the correct scope foundNICLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.NetworkNetworkInterface.String() { foundNICLink = true expectedScope := otherSubscriptionID + "." + otherResourceGroup if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected NIC scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } } if !foundNICLink { t.Error("Expected to find NIC link") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockNetworkSecurityGroupsClient(ctrl) wrapper := manual.NewNetworkNetworkSecurityGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements ListableWrapper interface _ = wrapper // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/networkSecurityGroups/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } expectedLinks := []shared.ItemType{ azureshared.NetworkSecurityRule, azureshared.NetworkDefaultSecurityRule, azureshared.NetworkSubnet, azureshared.NetworkNetworkInterface, azureshared.NetworkFlowLog, azureshared.NetworkApplicationSecurityGroup, azureshared.NetworkIPGroup, stdlib.NetworkIP, } for _, expectedLink := range expectedLinks { if !potentialLinks[expectedLink] { t.Errorf("Expected PotentialLinks to include %s", expectedLink) } } // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_network_security_group.name" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_network_security_group.name' mapping") } // Verify PredefinedRole // PredefinedRole is available on the wrapper, not the adapter role := wrapper.(interface{ PredefinedRole() string }).PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } }) } // MockNetworkSecurityGroupsPager is a simple mock for NetworkSecurityGroupsPager type MockNetworkSecurityGroupsPager struct { ctrl *gomock.Controller recorder *MockNetworkSecurityGroupsPagerMockRecorder } type MockNetworkSecurityGroupsPagerMockRecorder struct { mock *MockNetworkSecurityGroupsPager } func NewMockNetworkSecurityGroupsPager(ctrl *gomock.Controller) *MockNetworkSecurityGroupsPager { mock := &MockNetworkSecurityGroupsPager{ctrl: ctrl} mock.recorder = &MockNetworkSecurityGroupsPagerMockRecorder{mock} return mock } func (m *MockNetworkSecurityGroupsPager) EXPECT() *MockNetworkSecurityGroupsPagerMockRecorder { return m.recorder } func (m *MockNetworkSecurityGroupsPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockNetworkSecurityGroupsPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockNetworkSecurityGroupsPager) NextPage(ctx context.Context) (armnetwork.SecurityGroupsClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.SecurityGroupsClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockNetworkSecurityGroupsPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.SecurityGroupsClientListResponse, error)](), ctx) } // createAzureNetworkSecurityGroup creates a mock Azure network security group for testing func createAzureNetworkSecurityGroup(nsgName string) *armnetwork.SecurityGroup { subscriptionID := "test-subscription" resourceGroup := "test-rg" return &armnetwork.SecurityGroup{ Name: new(nsgName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.SecurityGroupPropertiesFormat{ // SecurityRules (child resources) SecurityRules: []*armnetwork.SecurityRule{ { Name: new("test-security-rule"), Properties: &armnetwork.SecurityRulePropertiesFormat{ Priority: new(int32(1000)), Direction: new(armnetwork.SecurityRuleDirectionInbound), Access: new(armnetwork.SecurityRuleAccessAllow), SourceApplicationSecurityGroups: []*armnetwork.ApplicationSecurityGroup{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/applicationSecurityGroups/test-asg-source"), }, }, DestinationApplicationSecurityGroups: []*armnetwork.ApplicationSecurityGroup{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/applicationSecurityGroups/test-asg-dest"), }, }, }, }, }, // DefaultSecurityRules (child resources) DefaultSecurityRules: []*armnetwork.SecurityRule{ { Name: new("AllowVnetInBound"), Properties: &armnetwork.SecurityRulePropertiesFormat{ Priority: new(int32(65000)), Direction: new(armnetwork.SecurityRuleDirectionInbound), Access: new(armnetwork.SecurityRuleAccessAllow), SourceApplicationSecurityGroups: []*armnetwork.ApplicationSecurityGroup{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/applicationSecurityGroups/test-asg-default-source"), }, }, }, }, }, // Subnets (external resources) Subnets: []*armnetwork.Subnet{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, // NetworkInterfaces (external resources) NetworkInterfaces: []*armnetwork.Interface{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/networkInterfaces/test-nic"), }, }, }, } } ================================================ FILE: sources/azure/manual/network-network-watcher.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkNetworkWatcherLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkNetworkWatcher) type networkNetworkWatcherWrapper struct { client clients.NetworkWatchersClient *azureshared.MultiResourceGroupBase } // NewNetworkNetworkWatcher creates a new NetworkNetworkWatcher adapter (ListableWrapper: top-level resource). func NewNetworkNetworkWatcher(client clients.NetworkWatchersClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkNetworkWatcherWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkNetworkWatcher, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/network-watcher/network-watchers/list func (c networkNetworkWatcherWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, watcher := range page.Value { if watcher.Name == nil { continue } item, sdpErr := c.azureNetworkWatcherToSDPItem(watcher, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c networkNetworkWatcherWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, watcher := range page.Value { if watcher.Name == nil { continue } var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = c.azureNetworkWatcherToSDPItem(watcher, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/network-watcher/network-watchers/get func (c networkNetworkWatcherWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the network watcher name"), scope, c.Type()) } networkWatcherName := queryParts[0] if networkWatcherName == "" { return nil, azureshared.QueryError(errors.New("networkWatcherName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } result, err := c.client.Get(ctx, rgScope.ResourceGroup, networkWatcherName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureNetworkWatcherToSDPItem(&result.Watcher, scope) } func (c networkNetworkWatcherWrapper) azureNetworkWatcherToSDPItem(watcher *armnetwork.Watcher, scope string) (*sdp.Item, *sdp.QueryError) { if watcher.Name == nil { return nil, azureshared.QueryError(errors.New("network watcher name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(watcher, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkNetworkWatcher.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(watcher.Tags), } // Map provisioning state to health if watcher.Properties != nil && watcher.Properties.ProvisioningState != nil { switch *watcher.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting, armnetwork.ProvisioningStateCreating: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to child FlowLogs via SEARCH // FlowLogs are child resources of NetworkWatcher, so we link via SEARCH with the network watcher name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkFlowLog.String(), Method: sdp.QueryMethod_SEARCH, Query: *watcher.Name, Scope: scope, }, }) return sdpItem, nil } func (c networkNetworkWatcherWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkNetworkWatcherLookupByName, } } func (c networkNetworkWatcherWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkFlowLog, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (c networkNetworkWatcherWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/networkWatchers/read", } } func (c networkNetworkWatcherWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-network-watcher_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestNetworkNetworkWatcher(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { resourceName := "test-network-watcher" resource := createNetworkWatcher(resourceName) mockClient := mocks.NewMockNetworkWatchersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, resourceName, nil).Return( armnetwork.WatchersClientGetResponse{ Watcher: *resource, }, nil) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], resourceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkNetworkWatcher.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkNetworkWatcher, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != resourceName { t.Errorf("Expected unique attribute value %s, got %s", resourceName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkFlowLog.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: resourceName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_ProvisioningStateSucceeded", func(t *testing.T) { resourceName := "test-network-watcher-succeeded" resource := createNetworkWatcherWithProvisioningState(resourceName, armnetwork.ProvisioningStateSucceeded) mockClient := mocks.NewMockNetworkWatchersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, resourceName, nil).Return( armnetwork.WatchersClientGetResponse{ Watcher: *resource, }, nil) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], resourceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health HEALTH_OK, got %s", sdpItem.GetHealth()) } }) t.Run("Get_ProvisioningStateFailed", func(t *testing.T) { resourceName := "test-network-watcher-failed" resource := createNetworkWatcherWithProvisioningState(resourceName, armnetwork.ProvisioningStateFailed) mockClient := mocks.NewMockNetworkWatchersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, resourceName, nil).Return( armnetwork.WatchersClientGetResponse{ Watcher: *resource, }, nil) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], resourceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != sdp.Health_HEALTH_ERROR { t.Errorf("Expected health HEALTH_ERROR, got %s", sdpItem.GetHealth()) } }) t.Run("List", func(t *testing.T) { resource1 := createNetworkWatcher("test-network-watcher-1") resource2 := createNetworkWatcher("test-network-watcher-2") mockClient := mocks.NewMockNetworkWatchersClient(ctrl) mockPager := newMockNetworkWatchersPager(ctrl, []*armnetwork.Watcher{resource1, resource2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("List_SkipNilName", func(t *testing.T) { resource1 := createNetworkWatcher("test-network-watcher-1") resource2 := &armnetwork.Watcher{ Name: nil, // nil name should be skipped } mockClient := mocks.NewMockNetworkWatchersClient(ctrl) mockPager := newMockNetworkWatchersPager(ctrl, []*armnetwork.Watcher{resource1, resource2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (skipping nil name), got: %d", len(sdpItems)) } }) t.Run("ListStream", func(t *testing.T) { resource1 := createNetworkWatcher("test-network-watcher-1") resource2 := createNetworkWatcher("test-network-watcher-2") mockClient := mocks.NewMockNetworkWatchersClient(ctrl) mockPager := newMockNetworkWatchersPager(ctrl, []*armnetwork.Watcher{resource1, resource2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("resource not found") mockClient := mocks.NewMockNetworkWatchersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", nil).Return( armnetwork.WatchersClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent", true) if qErr == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockNetworkWatchersClient(ctrl) wrapper := manual.NewNetworkNetworkWatcher(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting resource with empty name, but got nil") } }) } func createNetworkWatcher(name string) *armnetwork.Watcher { provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.Watcher{ ID: new(string), Name: &name, Type: new(string), Location: new(string), Tags: map[string]*string{ "env": new(string), }, Properties: &armnetwork.WatcherPropertiesFormat{ ProvisioningState: &provisioningState, }, } } func createNetworkWatcherWithProvisioningState(name string, state armnetwork.ProvisioningState) *armnetwork.Watcher { return &armnetwork.Watcher{ ID: new(string), Name: &name, Type: new(string), Location: new(string), Tags: map[string]*string{ "env": new(string), }, Properties: &armnetwork.WatcherPropertiesFormat{ ProvisioningState: &state, }, } } type mockNetworkWatchersPager struct { ctrl *gomock.Controller items []*armnetwork.Watcher index int more bool } func newMockNetworkWatchersPager(ctrl *gomock.Controller, items []*armnetwork.Watcher) clients.NetworkWatchersPager { return &mockNetworkWatchersPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockNetworkWatchersPager) More() bool { return m.more } func (m *mockNetworkWatchersPager) NextPage(ctx context.Context) (armnetwork.WatchersClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.WatchersClientListResponse{ WatcherListResult: armnetwork.WatcherListResult{ Value: []*armnetwork.Watcher{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.WatchersClientListResponse{ WatcherListResult: armnetwork.WatcherListResult{ Value: []*armnetwork.Watcher{item}, }, }, nil } ================================================ FILE: sources/azure/manual/network-private-dns-zone.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkPrivateDNSZoneLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkPrivateDNSZone) type networkPrivateDNSZoneWrapper struct { client clients.PrivateDNSZonesClient *azureshared.MultiResourceGroupBase } func NewNetworkPrivateDNSZone(client clients.PrivateDNSZonesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkPrivateDNSZoneWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkPrivateDNSZone, ), } } func (n networkPrivateDNSZoneWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, zone := range page.Value { if zone.Name == nil { continue } item, sdpErr := n.azurePrivateZoneToSDPItem(zone, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkPrivateDNSZoneWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, zone := range page.Value { if zone.Name == nil { continue } item, sdpErr := n.azurePrivateZoneToSDPItem(zone, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkPrivateDNSZoneWrapper) azurePrivateZoneToSDPItem(zone *armprivatedns.PrivateZone, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(zone, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } if zone.Name == nil { return nil, azureshared.QueryError(errors.New("zone name is nil"), scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkPrivateDNSZone.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(zone.Tags), } // Health from provisioning state if zone.Properties != nil && zone.Properties.ProvisioningState != nil { switch *zone.Properties.ProvisioningState { case armprivatedns.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armprivatedns.ProvisioningStateCreating, armprivatedns.ProvisioningStateUpdating, armprivatedns.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armprivatedns.ProvisioningStateFailed, armprivatedns.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } zoneName := *zone.Name // Link to DNS name (standard library) for the zone name if zoneName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: zoneName, Scope: "global", }, }) } // Link to Virtual Network Links (child resource of Private DNS Zone) // Reference: https://learn.microsoft.com/en-us/rest/api/dns/privatednszones/virtualnetworklinks/list // Virtual network links can be listed by zone name, so we use SEARCH method if zoneName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkDNSVirtualNetworkLink.String(), Method: sdp.QueryMethod_SEARCH, Query: zoneName, Scope: scope, }, }) } // Link to DNS Record Sets (child resource of Private DNS Zone) // Reference: https://learn.microsoft.com/en-us/rest/api/dns/privatednszones/recordsets/list // Record sets (A, AAAA, CNAME, MX, PTR, SOA, SRV, TXT) can be listed by zone name, so we use SEARCH method if zoneName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkDNSRecordSet.String(), Method: sdp.QueryMethod_SEARCH, Query: zoneName, Scope: scope, }, }) } return sdpItem, nil } // ref: https://learn.microsoft.com/en-us/rest/api/dns/privatednszones/get func (n networkPrivateDNSZoneWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part (private zone name)"), scope, n.Type()) } zoneName := queryParts[0] if zoneName == "" { return nil, azureshared.QueryError(errors.New("private zone name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, zoneName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azurePrivateZoneToSDPItem(&resp.PrivateZone, scope) } func (n networkPrivateDNSZoneWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkPrivateDNSZoneLookupByName, } } func (n networkPrivateDNSZoneWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkDNSRecordSet, azureshared.NetworkDNSVirtualNetworkLink, stdlib.NetworkDNS, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_zone func (n networkPrivateDNSZoneWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_private_dns_zone.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (n networkPrivateDNSZoneWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/privateDnsZones/read", } } func (n networkPrivateDNSZoneWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-private-dns-zone_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkPrivateDNSZone(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { zoneName := "private.example.com" zone := createAzurePrivateZone(zoneName) mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, zoneName, nil).Return( armprivatedns.PrivateZonesClientGetResponse{ PrivateZone: *zone, }, nil) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkPrivateDNSZone.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPrivateDNSZone, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != zoneName { t.Errorf("Expected unique attribute value %s, got %s", zoneName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { scope := subscriptionID + "." + resourceGroup queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: zoneName, ExpectedScope: "global", }, { ExpectedType: azureshared.NetworkDNSVirtualNetworkLink.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: zoneName, ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkDNSRecordSet.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: zoneName, ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when zone name is empty, but got nil") } }) t.Run("Get_ZoneWithNilName", func(t *testing.T) { provisioningState := armprivatedns.ProvisioningStateSucceeded zoneWithNilName := &armprivatedns.PrivateZone{ Name: nil, Location: new("eastus"), Properties: &armprivatedns.PrivateZoneProperties{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-zone", nil).Return( armprivatedns.PrivateZonesClientGetResponse{ PrivateZone: *zoneWithNilName, }, nil) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-zone", true) if qErr == nil { t.Error("Expected error when zone has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { zone1 := createAzurePrivateZone("private1.example.com") zone2 := createAzurePrivateZone("private2.example.com") mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) mockPager := newMockPrivateDNSZonesPager(ctrl, []*armprivatedns.PrivateZone{zone1, zone2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkPrivateDNSZone.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkPrivateDNSZone, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { zone1 := createAzurePrivateZone("private1.example.com") provisioningState := armprivatedns.ProvisioningStateSucceeded zone2NilName := &armprivatedns.PrivateZone{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, Properties: &armprivatedns.PrivateZoneProperties{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) mockPager := newMockPrivateDNSZonesPager(ctrl, []*armprivatedns.PrivateZone{zone1, zone2NilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "private1.example.com" { t.Errorf("Expected item name 'private1.example.com', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ListStream", func(t *testing.T) { zone1 := createAzurePrivateZone("stream1.example.com") zone2 := createAzurePrivateZone("stream2.example.com") mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) mockPager := newMockPrivateDNSZonesPager(ctrl, []*armprivatedns.PrivateZone{zone1, zone2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("private zone not found") mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-zone", nil).Return( armprivatedns.PrivateZonesClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-zone", true) if qErr == nil { t.Error("Expected error when getting non-existent zone, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockPrivateDNSZonesClient(ctrl) wrapper := manual.NewNetworkPrivateDNSZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/privateDnsZones/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } potentialLinks := w.PotentialLinks() if !potentialLinks[azureshared.NetworkDNSRecordSet] { t.Error("Expected PotentialLinks to include NetworkDNSRecordSet") } if !potentialLinks[azureshared.NetworkDNSVirtualNetworkLink] { t.Error("Expected PotentialLinks to include NetworkDNSVirtualNetworkLink") } if !potentialLinks[stdlib.NetworkDNS] { t.Error("Expected PotentialLinks to include stdlib.NetworkDNS") } mappings := w.TerraformMappings() foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_private_dns_zone.name" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected TerraformMethod GET, got: %s", mapping.GetTerraformMethod()) } break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_private_dns_zone.name'") } lookups := w.GetLookups() foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.NetworkPrivateDNSZone { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include NetworkPrivateDNSZone") } }) } type mockPrivateDNSZonesPager struct { ctrl *gomock.Controller items []*armprivatedns.PrivateZone index int more bool } func newMockPrivateDNSZonesPager(ctrl *gomock.Controller, items []*armprivatedns.PrivateZone) clients.PrivateDNSZonesPager { return &mockPrivateDNSZonesPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockPrivateDNSZonesPager) More() bool { return m.more } func (m *mockPrivateDNSZonesPager) NextPage(ctx context.Context) (armprivatedns.PrivateZonesClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armprivatedns.PrivateZonesClientListByResourceGroupResponse{ PrivateZoneListResult: armprivatedns.PrivateZoneListResult{ Value: []*armprivatedns.PrivateZone{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armprivatedns.PrivateZonesClientListByResourceGroupResponse{ PrivateZoneListResult: armprivatedns.PrivateZoneListResult{ Value: []*armprivatedns.PrivateZone{item}, }, }, nil } func createAzurePrivateZone(zoneName string) *armprivatedns.PrivateZone { state := armprivatedns.ProvisioningStateSucceeded return &armprivatedns.PrivateZone{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/privateDnsZones/" + zoneName), Name: new(zoneName), Type: new("Microsoft.Network/privateDnsZones"), Location: new("global"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armprivatedns.PrivateZoneProperties{ ProvisioningState: &state, MaxNumberOfRecordSets: new(int64(5000)), NumberOfRecordSets: new(int64(0)), }, } } // Ensure mockPrivateDNSZonesPager satisfies the pager interface at compile time. var _ clients.PrivateDNSZonesPager = (*mockPrivateDNSZonesPager)(nil) ================================================ FILE: sources/azure/manual/network-private-endpoint.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkPrivateEndpointLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkPrivateEndpoint) type networkPrivateEndpointWrapper struct { client clients.PrivateEndpointsClient *azureshared.MultiResourceGroupBase } func NewNetworkPrivateEndpoint(client clients.PrivateEndpointsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkPrivateEndpointWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkPrivateEndpoint, ), } } func (n networkPrivateEndpointWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(rgScope.ResourceGroup) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, pe := range page.Value { if pe.Name == nil { continue } item, sdpErr := n.azurePrivateEndpointToSDPItem(pe, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkPrivateEndpointWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(rgScope.ResourceGroup) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, pe := range page.Value { if pe.Name == nil { continue } item, sdpErr := n.azurePrivateEndpointToSDPItem(pe, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkPrivateEndpointWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be a private endpoint name"), scope, n.Type()) } name := queryParts[0] if name == "" { return nil, azureshared.QueryError(errors.New("private endpoint name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, name) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azurePrivateEndpointToSDPItem(&resp.PrivateEndpoint, scope) } func (n networkPrivateEndpointWrapper) azurePrivateEndpointToSDPItem(pe *armnetwork.PrivateEndpoint, scope string) (*sdp.Item, *sdp.QueryError) { if pe.Name == nil { return nil, azureshared.QueryError(errors.New("private endpoint name is nil"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(pe, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkPrivateEndpoint.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(pe.Tags), } // Health status from ProvisioningState if pe.Properties != nil && pe.Properties.ProvisioningState != nil { switch *pe.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } } // Link to Subnet and parent VirtualNetwork if pe.Properties != nil && pe.Properties.Subnet != nil && pe.Properties.Subnet.ID != nil { subnetParams := azureshared.ExtractPathParamsFromResourceID(*pe.Properties.Subnet.ID, []string{"virtualNetworks", "subnets"}) if len(subnetParams) >= 2 { vnetName, subnetName := subnetParams[0], subnetParams[1] linkedScope := azureshared.ExtractScopeFromResourceID(*pe.Properties.Subnet.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: linkedScope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // Link to NetworkInterfaces (read-only array of NICs created for this private endpoint) if pe.Properties != nil && pe.Properties.NetworkInterfaces != nil { for _, iface := range pe.Properties.NetworkInterfaces { if iface != nil && iface.ID != nil { nicName := azureshared.ExtractResourceName(*iface.ID) if nicName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*iface.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: nicName, Scope: linkedScope, }, }) } } } } // Link to ApplicationSecurityGroups if pe.Properties != nil && pe.Properties.ApplicationSecurityGroups != nil { for _, asg := range pe.Properties.ApplicationSecurityGroups { if asg != nil && asg.ID != nil { asgName := azureshared.ExtractResourceName(*asg.ID) if asgName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*asg.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: linkedScope, }, }) } } } } // Link IPConfigurations[].Properties.PrivateIPAddress to stdlib ip (GET, global) if pe.Properties != nil && pe.Properties.IPConfigurations != nil { for _, ipConfig := range pe.Properties.IPConfigurations { if ipConfig == nil || ipConfig.Properties == nil || ipConfig.Properties.PrivateIPAddress == nil { continue } if *ipConfig.Properties.PrivateIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipConfig.Properties.PrivateIPAddress, Scope: "global", }, }) } } } // Link to Private Link Services from PrivateLinkServiceConnections and ManualPrivateLinkServiceConnections if pe.Properties != nil { seenPLS := make(map[string]struct{}) for _, conns := range [][]*armnetwork.PrivateLinkServiceConnection{ pe.Properties.PrivateLinkServiceConnections, pe.Properties.ManualPrivateLinkServiceConnections, } { for _, conn := range conns { if conn == nil || conn.Properties == nil || conn.Properties.PrivateLinkServiceID == nil { continue } plsID := *conn.Properties.PrivateLinkServiceID if plsID == "" { continue } if _, ok := seenPLS[plsID]; ok { continue } seenPLS[plsID] = struct{}{} plsName := azureshared.ExtractResourceName(plsID) if plsName == "" { continue } linkedScope := azureshared.ExtractScopeFromResourceID(plsID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateLinkService.String(), Method: sdp.QueryMethod_GET, Query: plsName, Scope: linkedScope, }, }) } } } // Link CustomDnsConfigs: Fqdn -> stdlib dns (SEARCH, global), IPAddresses -> stdlib ip (GET, global) if pe.Properties != nil && pe.Properties.CustomDNSConfigs != nil { for _, dnsConfig := range pe.Properties.CustomDNSConfigs { if dnsConfig == nil { continue } if dnsConfig.Fqdn != nil && *dnsConfig.Fqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *dnsConfig.Fqdn, Scope: "global", }, }) } if dnsConfig.IPAddresses != nil { for _, ip := range dnsConfig.IPAddresses { if ip != nil && *ip != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ip, Scope: "global", }, }) } } } } } return sdpItem, nil } func (n networkPrivateEndpointWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkPrivateEndpointLookupByName, } } func (n networkPrivateEndpointWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, azureshared.NetworkNetworkInterface, azureshared.NetworkApplicationSecurityGroup, azureshared.NetworkPrivateLinkService, stdlib.NetworkIP, stdlib.NetworkDNS, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint func (n networkPrivateEndpointWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_private_endpoint.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions-reference#microsoftnetwork func (n networkPrivateEndpointWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/privateEndpoints/read", } } func (n networkPrivateEndpointWrapper) PredefinedRole() string { return "Network Contributor" } ================================================ FILE: sources/azure/manual/network-private-endpoint_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "reflect" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkPrivateEndpoint(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { peName := "test-pe" pe := createAzurePrivateEndpoint(peName, subscriptionID, resourceGroup) mockClient := mocks.NewMockPrivateEndpointsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, peName).Return( armnetwork.PrivateEndpointsClientGetResponse{ PrivateEndpoint: *pe, }, nil) wrapper := manual.NewNetworkPrivateEndpoint(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], peName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkPrivateEndpoint.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPrivateEndpoint, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != peName { t.Errorf("Expected unique attribute value %s, got %s", peName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkNetworkInterface.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nic", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkApplicationSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-asg", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.10", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "myendpoint.example.com", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.5", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockPrivateEndpointsClient(ctrl) wrapper := manual.NewNetworkPrivateEndpoint(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting private endpoint with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { pe1 := createAzurePrivateEndpoint("test-pe-1", subscriptionID, resourceGroup) pe2 := createAzurePrivateEndpoint("test-pe-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockPrivateEndpointsClient(ctrl) mockPager := NewMockPrivateEndpointsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PrivateEndpointsClientListResponse{ PrivateEndpointListResult: armnetwork.PrivateEndpointListResult{ Value: []*armnetwork.PrivateEndpoint{pe1, pe2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPrivateEndpoint(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkPrivateEndpoint.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkPrivateEndpoint, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { pe1 := createAzurePrivateEndpoint("test-pe-1", subscriptionID, resourceGroup) pe2 := &armnetwork.PrivateEndpoint{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, Properties: &armnetwork.PrivateEndpointProperties{ ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), }, } mockClient := mocks.NewMockPrivateEndpointsClient(ctrl) mockPager := NewMockPrivateEndpointsPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PrivateEndpointsClientListResponse{ PrivateEndpointListResult: armnetwork.PrivateEndpointListResult{ Value: []*armnetwork.PrivateEndpoint{pe1, pe2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPrivateEndpoint(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "test-pe-1" { t.Errorf("Expected item name 'test-pe-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("private endpoint not found") mockClient := mocks.NewMockPrivateEndpointsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-pe").Return( armnetwork.PrivateEndpointsClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkPrivateEndpoint(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-pe", true) if qErr == nil { t.Fatal("Expected error when getting nonexistent private endpoint, got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockPrivateEndpointsClient(ctrl) wrapper := manual.NewNetworkPrivateEndpoint(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link type") } if !potentialLinks[azureshared.NetworkSubnet] { t.Error("Expected PotentialLinks to include NetworkSubnet") } if !potentialLinks[azureshared.NetworkVirtualNetwork] { t.Error("Expected PotentialLinks to include NetworkVirtualNetwork") } }) } // MockPrivateEndpointsPager is a mock for PrivateEndpointsPager type MockPrivateEndpointsPager struct { ctrl *gomock.Controller recorder *MockPrivateEndpointsPagerMockRecorder } type MockPrivateEndpointsPagerMockRecorder struct { mock *MockPrivateEndpointsPager } func NewMockPrivateEndpointsPager(ctrl *gomock.Controller) *MockPrivateEndpointsPager { mock := &MockPrivateEndpointsPager{ctrl: ctrl} mock.recorder = &MockPrivateEndpointsPagerMockRecorder{mock} return mock } func (m *MockPrivateEndpointsPager) EXPECT() *MockPrivateEndpointsPagerMockRecorder { return m.recorder } func (m *MockPrivateEndpointsPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockPrivateEndpointsPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockPrivateEndpointsPager) NextPage(ctx context.Context) (armnetwork.PrivateEndpointsClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.PrivateEndpointsClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockPrivateEndpointsPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.PrivateEndpointsClientListResponse, error)](), ctx) } func createAzurePrivateEndpoint(peName, subscriptionID, resourceGroup string) *armnetwork.PrivateEndpoint { subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet", subscriptionID, resourceGroup) nicID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkInterfaces/test-nic", subscriptionID, resourceGroup) asgID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/applicationSecurityGroups/test-asg", subscriptionID, resourceGroup) return &armnetwork.PrivateEndpoint{ Name: new(peName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.PrivateEndpointProperties{ ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, NetworkInterfaces: []*armnetwork.Interface{ {ID: new(nicID)}, }, ApplicationSecurityGroups: []*armnetwork.ApplicationSecurityGroup{ {ID: new(asgID)}, }, IPConfigurations: []*armnetwork.PrivateEndpointIPConfiguration{ { Properties: &armnetwork.PrivateEndpointIPConfigurationProperties{ PrivateIPAddress: new("10.0.0.10"), }, }, }, CustomDNSConfigs: []*armnetwork.CustomDNSConfigPropertiesFormat{ { Fqdn: new("myendpoint.example.com"), IPAddresses: []*string{new("10.0.0.5")}, }, }, }, } } ================================================ FILE: sources/azure/manual/network-private-link-service.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkPrivateLinkServiceLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkPrivateLinkService) type networkPrivateLinkServiceWrapper struct { client clients.PrivateLinkServicesClient *azureshared.MultiResourceGroupBase } func NewNetworkPrivateLinkService(client clients.PrivateLinkServicesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkPrivateLinkServiceWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkPrivateLinkService, ), } } func (n networkPrivateLinkServiceWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(rgScope.ResourceGroup) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, pls := range page.Value { if pls.Name == nil { continue } item, sdpErr := n.azurePrivateLinkServiceToSDPItem(pls, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkPrivateLinkServiceWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(rgScope.ResourceGroup) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, pls := range page.Value { if pls.Name == nil { continue } item, sdpErr := n.azurePrivateLinkServiceToSDPItem(pls, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkPrivateLinkServiceWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be a private link service name"), scope, n.Type()) } serviceName := queryParts[0] if serviceName == "" { return nil, azureshared.QueryError(errors.New("private link service name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, serviceName) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azurePrivateLinkServiceToSDPItem(&resp.PrivateLinkService, scope) } func (n networkPrivateLinkServiceWrapper) azurePrivateLinkServiceToSDPItem(pls *armnetwork.PrivateLinkService, scope string) (*sdp.Item, *sdp.QueryError) { if pls.Name == nil { return nil, azureshared.QueryError(errors.New("private link service name is nil"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(pls, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkPrivateLinkService.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(pls.Tags), } // Health status from ProvisioningState if pls.Properties != nil && pls.Properties.ProvisioningState != nil { switch *pls.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to Custom Location when ExtendedLocation.Name is a custom location resource ID if pls.ExtendedLocation != nil && pls.ExtendedLocation.Name != nil { customLocationID := *pls.ExtendedLocation.Name if strings.Contains(customLocationID, "customLocations") { customLocationName := azureshared.ExtractResourceName(customLocationID) if customLocationName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(customLocationID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ExtendedLocationCustomLocation.String(), Method: sdp.QueryMethod_GET, Query: customLocationName, Scope: linkedScope, }, }) } } } if pls.Properties != nil { // Link to IPConfigurations[].Properties.Subnet and PrivateIPAddress if pls.Properties.IPConfigurations != nil { for _, ipConfig := range pls.Properties.IPConfigurations { if ipConfig == nil || ipConfig.Properties == nil { continue } // Link to Subnet and VirtualNetwork if ipConfig.Properties.Subnet != nil && ipConfig.Properties.Subnet.ID != nil { subnetParams := azureshared.ExtractPathParamsFromResourceID(*ipConfig.Properties.Subnet.ID, []string{"virtualNetworks", "subnets"}) if len(subnetParams) >= 2 { vnetName, subnetName := subnetParams[0], subnetParams[1] linkedScope := azureshared.ExtractScopeFromResourceID(*ipConfig.Properties.Subnet.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: linkedScope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // Link to PrivateIPAddress if ipConfig.Properties.PrivateIPAddress != nil && *ipConfig.Properties.PrivateIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipConfig.Properties.PrivateIPAddress, Scope: "global", }, }) } } } // Link to LoadBalancerFrontendIPConfigurations if pls.Properties.LoadBalancerFrontendIPConfigurations != nil { for _, lbFrontendIPConfig := range pls.Properties.LoadBalancerFrontendIPConfigurations { if lbFrontendIPConfig == nil || lbFrontendIPConfig.ID == nil { continue } params := azureshared.ExtractPathParamsFromResourceID(*lbFrontendIPConfig.ID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 { lbName, frontendIPConfigName := params[0], params[1] linkedScope := azureshared.ExtractScopeFromResourceID(*lbFrontendIPConfig.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(lbName, frontendIPConfigName), Scope: linkedScope, }, }) // Also link to the parent LoadBalancer sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: lbName, Scope: linkedScope, }, }) } } } // Link to NetworkInterfaces (read-only array) if pls.Properties.NetworkInterfaces != nil { for _, iface := range pls.Properties.NetworkInterfaces { if iface == nil || iface.ID == nil { continue } nicName := azureshared.ExtractResourceName(*iface.ID) if nicName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*iface.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: nicName, Scope: linkedScope, }, }) } } } // Link to PrivateEndpointConnections[].PrivateEndpoint if pls.Properties.PrivateEndpointConnections != nil { for _, peConn := range pls.Properties.PrivateEndpointConnections { if peConn == nil || peConn.Properties == nil || peConn.Properties.PrivateEndpoint == nil || peConn.Properties.PrivateEndpoint.ID == nil { continue } peName := azureshared.ExtractResourceName(*peConn.Properties.PrivateEndpoint.ID) if peName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*peConn.Properties.PrivateEndpoint.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } } // Link to Fqdns as DNS names if pls.Properties.Fqdns != nil { for _, fqdn := range pls.Properties.Fqdns { if fqdn != nil && *fqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *fqdn, Scope: "global", }, }) } } } // Link to DestinationIPAddress if pls.Properties.DestinationIPAddress != nil && *pls.Properties.DestinationIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *pls.Properties.DestinationIPAddress, Scope: "global", }, }) } // Link to Alias (read-only DNS-resolvable name for the private link service) if pls.Properties.Alias != nil && *pls.Properties.Alias != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *pls.Properties.Alias, Scope: "global", }, }) } } return sdpItem, nil } func (n networkPrivateLinkServiceWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkPrivateLinkServiceLookupByName, } } func (n networkPrivateLinkServiceWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkSubnet, azureshared.NetworkVirtualNetwork, azureshared.NetworkLoadBalancerFrontendIPConfiguration, azureshared.NetworkLoadBalancer, azureshared.NetworkNetworkInterface, azureshared.NetworkPrivateEndpoint, azureshared.ExtendedLocationCustomLocation, stdlib.NetworkIP, stdlib.NetworkDNS, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (n networkPrivateLinkServiceWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/privateLinkServices/read", } } func (n networkPrivateLinkServiceWrapper) PredefinedRole() string { return "Network Contributor" } ================================================ FILE: sources/azure/manual/network-private-link-service_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "reflect" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkPrivateLinkService(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { plsName := "test-pls" pls := createAzurePrivateLinkService(plsName, subscriptionID, resourceGroup) mockClient := mocks.NewMockPrivateLinkServicesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, plsName).Return( armnetwork.PrivateLinkServicesClientGetResponse{ PrivateLinkService: *pls, }, nil) wrapper := manual.NewNetworkPrivateLinkService(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], plsName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkPrivateLinkService.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPrivateLinkService, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != plsName { t.Errorf("Expected unique attribute value %s, got %s", plsName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.100", ExpectedScope: "global", }, { ExpectedType: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-lb", "test-frontend-ip"), ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkLoadBalancer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-lb", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkNetworkInterface.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nic", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: azureshared.NetworkPrivateEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pe", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "pls.example.com", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.200", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-pls.abc123.westus2.azure.privatelinkservice", ExpectedScope: "global", }, { ExpectedType: azureshared.ExtendedLocationCustomLocation.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-custom-location", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyName", func(t *testing.T) { mockClient := mocks.NewMockPrivateLinkServicesClient(ctrl) wrapper := manual.NewNetworkPrivateLinkService(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting private link service with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { pls1 := createAzurePrivateLinkService("test-pls-1", subscriptionID, resourceGroup) pls2 := createAzurePrivateLinkService("test-pls-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockPrivateLinkServicesClient(ctrl) mockPager := NewMockPrivateLinkServicesPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PrivateLinkServicesClientListResponse{ PrivateLinkServiceListResult: armnetwork.PrivateLinkServiceListResult{ Value: []*armnetwork.PrivateLinkService{pls1, pls2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPrivateLinkService(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkPrivateLinkService.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkPrivateLinkService, item.GetType()) } } }) t.Run("ListStream", func(t *testing.T) { pls1 := createAzurePrivateLinkService("test-pls-1", subscriptionID, resourceGroup) pls2 := createAzurePrivateLinkService("test-pls-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockPrivateLinkServicesClient(ctrl) mockPager := NewMockPrivateLinkServicesPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PrivateLinkServicesClientListResponse{ PrivateLinkServiceListResult: armnetwork.PrivateLinkServiceListResult{ Value: []*armnetwork.PrivateLinkService{pls1, pls2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPrivateLinkService(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("List_WithNilName", func(t *testing.T) { pls1 := createAzurePrivateLinkService("test-pls-1", subscriptionID, resourceGroup) pls2 := &armnetwork.PrivateLinkService{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, Properties: &armnetwork.PrivateLinkServiceProperties{ ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), }, } mockClient := mocks.NewMockPrivateLinkServicesClient(ctrl) mockPager := NewMockPrivateLinkServicesPager(ctrl) gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PrivateLinkServicesClientListResponse{ PrivateLinkServiceListResult: armnetwork.PrivateLinkServiceListResult{ Value: []*armnetwork.PrivateLinkService{pls1, pls2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPrivateLinkService(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "test-pls-1" { t.Errorf("Expected item name 'test-pls-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("private link service not found") mockClient := mocks.NewMockPrivateLinkServicesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-pls").Return( armnetwork.PrivateLinkServicesClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkPrivateLinkService(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-pls", true) if qErr == nil { t.Fatal("Expected error when getting nonexistent private link service, got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockPrivateLinkServicesClient(ctrl) wrapper := manual.NewNetworkPrivateLinkService(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link type") } if !potentialLinks[azureshared.NetworkSubnet] { t.Error("Expected PotentialLinks to include NetworkSubnet") } if !potentialLinks[azureshared.NetworkVirtualNetwork] { t.Error("Expected PotentialLinks to include NetworkVirtualNetwork") } if !potentialLinks[azureshared.NetworkLoadBalancer] { t.Error("Expected PotentialLinks to include NetworkLoadBalancer") } if !potentialLinks[azureshared.NetworkLoadBalancerFrontendIPConfiguration] { t.Error("Expected PotentialLinks to include NetworkLoadBalancerFrontendIPConfiguration") } if !potentialLinks[azureshared.NetworkNetworkInterface] { t.Error("Expected PotentialLinks to include NetworkNetworkInterface") } if !potentialLinks[azureshared.NetworkPrivateEndpoint] { t.Error("Expected PotentialLinks to include NetworkPrivateEndpoint") } if !potentialLinks[stdlib.NetworkIP] { t.Error("Expected PotentialLinks to include stdlib.NetworkIP") } if !potentialLinks[stdlib.NetworkDNS] { t.Error("Expected PotentialLinks to include stdlib.NetworkDNS") } }) } // MockPrivateLinkServicesPager is a mock for PrivateLinkServicesPager type MockPrivateLinkServicesPager struct { ctrl *gomock.Controller recorder *MockPrivateLinkServicesPagerMockRecorder } type MockPrivateLinkServicesPagerMockRecorder struct { mock *MockPrivateLinkServicesPager } func NewMockPrivateLinkServicesPager(ctrl *gomock.Controller) *MockPrivateLinkServicesPager { mock := &MockPrivateLinkServicesPager{ctrl: ctrl} mock.recorder = &MockPrivateLinkServicesPagerMockRecorder{mock} return mock } func (m *MockPrivateLinkServicesPager) EXPECT() *MockPrivateLinkServicesPagerMockRecorder { return m.recorder } func (m *MockPrivateLinkServicesPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockPrivateLinkServicesPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockPrivateLinkServicesPager) NextPage(ctx context.Context) (armnetwork.PrivateLinkServicesClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.PrivateLinkServicesClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockPrivateLinkServicesPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.PrivateLinkServicesClientListResponse, error)](), ctx) } func createAzurePrivateLinkService(plsName, subscriptionID, resourceGroup string) *armnetwork.PrivateLinkService { subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet", subscriptionID, resourceGroup) lbFrontendIPID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/loadBalancers/test-lb/frontendIPConfigurations/test-frontend-ip", subscriptionID, resourceGroup) nicID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkInterfaces/test-nic", subscriptionID, resourceGroup) peID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/privateEndpoints/test-pe", subscriptionID, resourceGroup) customLocationID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ExtendedLocation/customLocations/test-custom-location", subscriptionID, resourceGroup) return &armnetwork.PrivateLinkService{ Name: new(plsName), Location: new("eastus"), ExtendedLocation: &armnetwork.ExtendedLocation{ Name: new(customLocationID), }, Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.PrivateLinkServiceProperties{ ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), IPConfigurations: []*armnetwork.PrivateLinkServiceIPConfiguration{ { Properties: &armnetwork.PrivateLinkServiceIPConfigurationProperties{ Subnet: &armnetwork.Subnet{ ID: new(subnetID), }, PrivateIPAddress: new("10.0.0.100"), }, }, }, LoadBalancerFrontendIPConfigurations: []*armnetwork.FrontendIPConfiguration{ { ID: new(lbFrontendIPID), }, }, NetworkInterfaces: []*armnetwork.Interface{ { ID: new(nicID), }, }, PrivateEndpointConnections: []*armnetwork.PrivateEndpointConnection{ { Properties: &armnetwork.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armnetwork.PrivateEndpoint{ ID: new(peID), }, }, }, }, Fqdns: []*string{ new("pls.example.com"), }, DestinationIPAddress: new("10.0.0.200"), Alias: new("test-pls.abc123.westus2.azure.privatelinkservice"), }, } } ================================================ FILE: sources/azure/manual/network-public-ip-address.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkPublicIPAddressLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkPublicIPAddress) type networkPublicIPAddressWrapper struct { client clients.PublicIPAddressesClient *azureshared.MultiResourceGroupBase } func NewNetworkPublicIPAddress(client clients.PublicIPAddressesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkPublicIPAddressWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkPublicIPAddress, ), } } // reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/public-ip-addresses/list?view=rest-virtualnetwork-2025-03-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/publicIPAddresses?api-version=2025-03-01 func (n networkPublicIPAddressWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(ctx, rgScope.ResourceGroup) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, publicIPAddress := range page.Value { if publicIPAddress.Name == nil { continue } item, sdpErr := n.azurePublicIPAddressToSDPItem(publicIPAddress, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkPublicIPAddressWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(ctx, rgScope.ResourceGroup) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, publicIPAddress := range page.Value { if publicIPAddress.Name == nil { continue } item, sdpErr := n.azurePublicIPAddressToSDPItem(publicIPAddress, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/public-ip-addresses/get?view=rest-virtualnetwork-2025-03-01&tabs=HTTP // GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/publicIPAddresses/{publicIpAddressName}?api-version=2025-03-01 func (n networkPublicIPAddressWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part and be a public IP address name"), scope, n.Type()) } publicIPAddressName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } publicIPAddress, err := n.client.Get(ctx, rgScope.ResourceGroup, publicIPAddressName) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azurePublicIPAddressToSDPItem(&publicIPAddress.PublicIPAddress, scope) } func (n networkPublicIPAddressWrapper) azurePublicIPAddressToSDPItem(publicIPAddress *armnetwork.PublicIPAddress, scope string) (*sdp.Item, *sdp.QueryError) { if publicIPAddress.Name == nil { return nil, azureshared.QueryError(errors.New("public IP address name is nil"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(publicIPAddress, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkPublicIPAddress.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(publicIPAddress.Tags), } // Link to IP address (standard library) if IP address is assigned if publicIPAddress.Properties != nil && publicIPAddress.Properties.IPAddress != nil && *publicIPAddress.Properties.IPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *publicIPAddress.Properties.IPAddress, Scope: "global", }, }) } // Link to DNS name (standard library) if FQDN is configured if publicIPAddress.Properties != nil && publicIPAddress.Properties.DNSSettings != nil && publicIPAddress.Properties.DNSSettings.Fqdn != nil && *publicIPAddress.Properties.DNSSettings.Fqdn != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *publicIPAddress.Properties.DNSSettings.Fqdn, Scope: "global", }, }) } // Link to Network Interface if IPConfiguration references a network interface if publicIPAddress.Properties != nil && publicIPAddress.Properties.IPConfiguration != nil { if publicIPAddress.Properties.IPConfiguration.ID != nil { ipConfigID := *publicIPAddress.Properties.IPConfiguration.ID // Check if this IP configuration belongs to a network interface // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/networkInterfaces/{nicName}/ipConfigurations/{ipConfigName} if strings.Contains(ipConfigID, "/networkInterfaces/") { nicName := azureshared.ExtractPathParamsFromResourceID(ipConfigID, []string{"networkInterfaces"}) if len(nicName) > 0 && nicName[0] != "" { // Extract scope from the IP configuration ID (may be in different resource group) linkedScope := azureshared.ExtractScopeFromResourceID(ipConfigID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: nicName[0], Scope: linkedScope, }, }) } } } } // Link to linked public IP address if publicIPAddress.Properties != nil && publicIPAddress.Properties.LinkedPublicIPAddress != nil { if publicIPAddress.Properties.LinkedPublicIPAddress.ID != nil { linkedIPID := *publicIPAddress.Properties.LinkedPublicIPAddress.ID linkedIPName := azureshared.ExtractResourceName(linkedIPID) if linkedIPName != "" { // Extract scope from the linked IP address ID (may be in different resource group) linkedScope := azureshared.ExtractScopeFromResourceID(linkedIPID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: linkedIPName, Scope: linkedScope, }, }) } } } // Link to service public IP address if publicIPAddress.Properties != nil && publicIPAddress.Properties.ServicePublicIPAddress != nil { if publicIPAddress.Properties.ServicePublicIPAddress.ID != nil { serviceIPID := *publicIPAddress.Properties.ServicePublicIPAddress.ID serviceIPName := azureshared.ExtractResourceName(serviceIPID) if serviceIPName != "" { // Extract scope from the service IP address ID (may be in different resource group) linkedScope := azureshared.ExtractScopeFromResourceID(serviceIPID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: serviceIPName, Scope: linkedScope, }, }) } } } // Link to public IP prefix if publicIPAddress.Properties != nil && publicIPAddress.Properties.PublicIPPrefix != nil { if publicIPAddress.Properties.PublicIPPrefix.ID != nil { prefixID := *publicIPAddress.Properties.PublicIPPrefix.ID prefixName := azureshared.ExtractResourceName(prefixID) if prefixName != "" { // Extract scope from the public IP prefix ID (may be in different resource group) linkedScope := azureshared.ExtractScopeFromResourceID(prefixID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPPrefix.String(), Method: sdp.QueryMethod_GET, Query: prefixName, Scope: linkedScope, }, }) } } } // Link to NAT gateway if publicIPAddress.Properties != nil && publicIPAddress.Properties.NatGateway != nil { if publicIPAddress.Properties.NatGateway.ID != nil { natGatewayID := *publicIPAddress.Properties.NatGateway.ID natGatewayName := azureshared.ExtractResourceName(natGatewayID) if natGatewayName != "" { // Extract scope from the NAT gateway ID (may be in different resource group) linkedScope := azureshared.ExtractScopeFromResourceID(natGatewayID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNatGateway.String(), Method: sdp.QueryMethod_GET, Query: natGatewayName, Scope: linkedScope, }, }) } } } // Link to DDoS protection plan if publicIPAddress.Properties != nil && publicIPAddress.Properties.DdosSettings != nil { if publicIPAddress.Properties.DdosSettings.DdosProtectionPlan != nil { if publicIPAddress.Properties.DdosSettings.DdosProtectionPlan.ID != nil { ddosPlanID := *publicIPAddress.Properties.DdosSettings.DdosProtectionPlan.ID ddosPlanName := azureshared.ExtractResourceName(ddosPlanID) if ddosPlanName != "" { // Extract scope from the DDoS protection plan ID (may be in different resource group) linkedScope := azureshared.ExtractScopeFromResourceID(ddosPlanID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkDdosProtectionPlan.String(), Method: sdp.QueryMethod_GET, Query: ddosPlanName, Scope: linkedScope, }, }) } } } } // Link to Load Balancer if IPConfiguration references a load balancer frontend IP configuration if publicIPAddress.Properties != nil && publicIPAddress.Properties.IPConfiguration != nil { if publicIPAddress.Properties.IPConfiguration.ID != nil { ipConfigID := *publicIPAddress.Properties.IPConfiguration.ID // Check if this IP configuration belongs to a load balancer // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/loadBalancers/{lbName}/frontendIPConfigurations/{frontendIPConfigName} if strings.Contains(ipConfigID, "/loadBalancers/") { lbName := azureshared.ExtractPathParamsFromResourceID(ipConfigID, []string{"loadBalancers"}) if len(lbName) > 0 && lbName[0] != "" { // Extract scope from the load balancer ID (may be in different resource group) linkedScope := azureshared.ExtractScopeFromResourceID(ipConfigID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: lbName[0], Scope: linkedScope, }, }) } } } } return sdpItem, nil } func (n networkPublicIPAddressWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkPublicIPAddressLookupByName, } } func (n networkPublicIPAddressWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkNetworkInterface: true, azureshared.NetworkPublicIPAddress: true, azureshared.NetworkPublicIPPrefix: true, azureshared.NetworkNatGateway: true, azureshared.NetworkDdosProtectionPlan: true, azureshared.NetworkLoadBalancer: true, stdlib.NetworkIP: true, stdlib.NetworkDNS: true, } } func (n networkPublicIPAddressWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_public_ip.name", }, } } // https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (n networkPublicIPAddressWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/publicIPAddresses/read", } } func (n networkPublicIPAddressWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-public-ip-address_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestNetworkPublicIPAddress(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { publicIPName := "test-public-ip" // Create public IP with network interface (not load balancer, as they're mutually exclusive) publicIP := createAzurePublicIPAddress(publicIPName, "test-nic", "test-prefix", "test-nat-gateway", "test-ddos-plan", "") mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, publicIPName).Return( armnetwork.PublicIPAddressesClientGetResponse{ PublicIPAddress: *publicIP, }, nil) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], publicIPName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkPublicIPAddress.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPublicIPAddress, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != publicIPName { t.Errorf("Expected unique attribute value %s, got %s", publicIPName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // IP address link (standard library) ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, { // NetworkNetworkInterface link (via IPConfiguration) ExpectedType: azureshared.NetworkNetworkInterface.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nic", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // NetworkPublicIPPrefix link ExpectedType: azureshared.NetworkPublicIPPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-prefix", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // NetworkNatGateway link ExpectedType: azureshared.NetworkNatGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nat-gateway", ExpectedScope: subscriptionID + "." + resourceGroup, }, { // NetworkDdosProtectionPlan link ExpectedType: azureshared.NetworkDdosProtectionPlan.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-ddos-plan", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithLoadBalancer", func(t *testing.T) { publicIPName := "test-public-ip-lb" // Create public IP with load balancer (not network interface, as they're mutually exclusive) publicIP := createAzurePublicIPAddress(publicIPName, "", "", "", "", "test-load-balancer") mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, publicIPName).Return( armnetwork.PublicIPAddressesClientGetResponse{ PublicIPAddress: *publicIP, }, nil) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], publicIPName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify LoadBalancer link exists foundLoadBalancer := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkLoadBalancer.String() && linkedQuery.GetQuery().GetQuery() == "test-load-balancer" { foundLoadBalancer = true break } } if !foundLoadBalancer { t.Error("Expected to find LoadBalancer linked item query") } }) t.Run("Get_WithLinkedPublicIP", func(t *testing.T) { publicIPName := "test-public-ip" linkedIPName := "linked-public-ip" publicIP := createAzurePublicIPAddressWithLinkedIP(publicIPName, linkedIPName, subscriptionID, resourceGroup) mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, publicIPName).Return( armnetwork.PublicIPAddressesClientGetResponse{ PublicIPAddress: *publicIP, }, nil) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], publicIPName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify linked public IP address query foundLinkedIP := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkPublicIPAddress.String() && linkedQuery.GetQuery().GetQuery() == linkedIPName { foundLinkedIP = true break } } if !foundLinkedIP { t.Error("Expected to find linked public IP address query") } }) t.Run("Get_WithServicePublicIP", func(t *testing.T) { publicIPName := "test-public-ip" serviceIPName := "service-public-ip" publicIP := createAzurePublicIPAddressWithServiceIP(publicIPName, serviceIPName, subscriptionID, resourceGroup) mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, publicIPName).Return( armnetwork.PublicIPAddressesClientGetResponse{ PublicIPAddress: *publicIP, }, nil) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], publicIPName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify service public IP address query foundServiceIP := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkPublicIPAddress.String() && linkedQuery.GetQuery().GetQuery() == serviceIPName { foundServiceIP = true break } } if !foundServiceIP { t.Error("Expected to find service public IP address query") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty name - Get will still be called with empty string // and Azure will return an error mockClient.EXPECT().Get(ctx, resourceGroup, "").Return( armnetwork.PublicIPAddressesClientGetResponse{}, errors.New("public IP address not found")) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting public IP address with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { publicIP1 := createAzurePublicIPAddress("test-public-ip-1", "", "", "", "", "") publicIP2 := createAzurePublicIPAddress("test-public-ip-2", "", "", "", "", "") mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockPager := NewMockPublicIPAddressesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PublicIPAddressesClientListResponse{ PublicIPAddressListResult: armnetwork.PublicIPAddressListResult{ Value: []*armnetwork.PublicIPAddress{publicIP1, publicIP2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkPublicIPAddress.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkPublicIPAddress, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Create public IP with nil name to test error handling // Note: The List method skips items with nil names (continues), so it doesn't return an error publicIP1 := createAzurePublicIPAddress("test-public-ip-1", "", "", "", "", "") publicIP2 := &armnetwork.PublicIPAddress{ Name: nil, // Public IP with nil name will be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.PublicIPAddressPropertiesFormat{}, } mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockPager := NewMockPublicIPAddressesPager(ctrl) // Setup pager expectations // More() is called: once before NextPage, once after processing the page gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PublicIPAddressesClientListResponse{ PublicIPAddressListResult: armnetwork.PublicIPAddressListResult{ Value: []*armnetwork.PublicIPAddress{publicIP1, publicIP2}, }, }, nil), mockPager.EXPECT().More().Return(false), // No more pages after processing ) mockClient.EXPECT().List(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) // Should not return an error - items with nil names are skipped if err != nil { t.Fatalf("Expected no error when listing public IP addresses with nil name (they are skipped), but got: %v", err) } // Should only return 1 item (the one with a valid name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name item should be skipped), got %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "test-public-ip-1" { t.Fatalf("Expected item with name 'test-public-ip-1', got %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("public IP address not found") mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-ip").Return( armnetwork.PublicIPAddressesClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-ip", true) if qErr == nil { t.Error("Expected error when getting non-existent public IP address, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { expectedErr := errors.New("failed to list public IP addresses") mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) mockPager := NewMockPublicIPAddressesPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.PublicIPAddressesClientListResponse{}, expectedErr), ) mockClient.EXPECT().List(ctx, resourceGroup).Return(mockPager) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing public IP addresses fails, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockPublicIPAddressesClient(ctrl) wrapper := manual.NewNetworkPublicIPAddress(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements ListableWrapper interface _ = wrapper // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/publicIPAddresses/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.NetworkNetworkInterface] { t.Error("Expected PotentialLinks to include NetworkNetworkInterface") } if !potentialLinks[azureshared.NetworkPublicIPAddress] { t.Error("Expected PotentialLinks to include NetworkPublicIPAddress") } // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_public_ip.name" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_public_ip.name' mapping") } }) } // MockPublicIPAddressesPager is a simple mock for PublicIPAddressesPager type MockPublicIPAddressesPager struct { ctrl *gomock.Controller recorder *MockPublicIPAddressesPagerMockRecorder } type MockPublicIPAddressesPagerMockRecorder struct { mock *MockPublicIPAddressesPager } func NewMockPublicIPAddressesPager(ctrl *gomock.Controller) *MockPublicIPAddressesPager { mock := &MockPublicIPAddressesPager{ctrl: ctrl} mock.recorder = &MockPublicIPAddressesPagerMockRecorder{mock} return mock } func (m *MockPublicIPAddressesPager) EXPECT() *MockPublicIPAddressesPagerMockRecorder { return m.recorder } func (m *MockPublicIPAddressesPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockPublicIPAddressesPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockPublicIPAddressesPager) NextPage(ctx context.Context) (armnetwork.PublicIPAddressesClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.PublicIPAddressesClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockPublicIPAddressesPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.PublicIPAddressesClientListResponse, error)](), ctx) } // createAzurePublicIPAddress creates a mock Azure public IP address for testing func createAzurePublicIPAddress(name, nicName, prefixName, natGatewayName, ddosPlanName, loadBalancerName string) *armnetwork.PublicIPAddress { publicIP := &armnetwork.PublicIPAddress{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), IPAddress: new("203.0.113.1"), // Add IP address for testing }, } // Add IPConfiguration if nicName is provided if nicName != "" { ipConfigID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/" + nicName + "/ipConfigurations/ipconfig1" publicIP.Properties.IPConfiguration = &armnetwork.IPConfiguration{ ID: new(ipConfigID), } } // Add PublicIPPrefix if prefixName is provided if prefixName != "" { prefixID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/publicIPPrefixes/" + prefixName publicIP.Properties.PublicIPPrefix = &armnetwork.SubResource{ ID: new(prefixID), } } // Add NatGateway if natGatewayName is provided if natGatewayName != "" { natGatewayID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/natGateways/" + natGatewayName publicIP.Properties.NatGateway = &armnetwork.NatGateway{ ID: new(natGatewayID), } } // Add DDoS Protection Plan if ddosPlanName is provided if ddosPlanName != "" { ddosPlanID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/ddosProtectionPlans/" + ddosPlanName publicIP.Properties.DdosSettings = &armnetwork.DdosSettings{ DdosProtectionPlan: &armnetwork.SubResource{ ID: new(ddosPlanID), }, } } // Add LoadBalancer IPConfiguration if loadBalancerName is provided if loadBalancerName != "" { lbIPConfigID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/loadBalancers/" + loadBalancerName + "/frontendIPConfigurations/frontendIPConfig1" publicIP.Properties.IPConfiguration = &armnetwork.IPConfiguration{ ID: new(lbIPConfigID), } } return publicIP } // createAzurePublicIPAddressWithLinkedIP creates a mock Azure public IP address with a linked public IP func createAzurePublicIPAddressWithLinkedIP(name, linkedIPName, subscriptionID, resourceGroup string) *armnetwork.PublicIPAddress { linkedIPID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/publicIPAddresses/" + linkedIPName return &armnetwork.PublicIPAddress{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), LinkedPublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(linkedIPID), }, }, } } // createAzurePublicIPAddressWithServiceIP creates a mock Azure public IP address with a service public IP func createAzurePublicIPAddressWithServiceIP(name, serviceIPName, subscriptionID, resourceGroup string) *armnetwork.PublicIPAddress { serviceIPID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/publicIPAddresses/" + serviceIPName return &armnetwork.PublicIPAddress{ Name: new(name), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.PublicIPAddressPropertiesFormat{ PublicIPAddressVersion: new(armnetwork.IPVersionIPv4), PublicIPAllocationMethod: new(armnetwork.IPAllocationMethodStatic), ServicePublicIPAddress: &armnetwork.PublicIPAddress{ ID: new(serviceIPID), }, }, } } ================================================ FILE: sources/azure/manual/network-public-ip-prefix.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkPublicIPPrefixLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkPublicIPPrefix) type networkPublicIPPrefixWrapper struct { client clients.PublicIPPrefixesClient *azureshared.MultiResourceGroupBase } func NewNetworkPublicIPPrefix(client clients.PublicIPPrefixesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkPublicIPPrefixWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkPublicIPPrefix, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/public-ip-prefixes/list func (n networkPublicIPPrefixWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, prefix := range page.Value { if prefix.Name == nil { continue } item, sdpErr := n.azurePublicIPPrefixToSDPItem(prefix, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkPublicIPPrefixWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, prefix := range page.Value { if prefix.Name == nil { continue } item, sdpErr := n.azurePublicIPPrefixToSDPItem(prefix, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/public-ip-prefixes/get func (n networkPublicIPPrefixWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part (public IP prefix name)"), scope, n.Type()) } publicIPPrefixName := queryParts[0] if publicIPPrefixName == "" { return nil, azureshared.QueryError(errors.New("public IP prefix name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, publicIPPrefixName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azurePublicIPPrefixToSDPItem(&resp.PublicIPPrefix, scope) } func (n networkPublicIPPrefixWrapper) azurePublicIPPrefixToSDPItem(prefix *armnetwork.PublicIPPrefix, scope string) (*sdp.Item, *sdp.QueryError) { if prefix.Name == nil { return nil, azureshared.QueryError(errors.New("public IP prefix name is nil"), scope, n.Type()) } attributes, err := shared.ToAttributesWithExclude(prefix, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkPublicIPPrefix.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(prefix.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Link to Custom Location when ExtendedLocation.Name is a custom location resource ID (Microsoft.ExtendedLocation/customLocations) if prefix.ExtendedLocation != nil && prefix.ExtendedLocation.Name != nil { customLocationID := *prefix.ExtendedLocation.Name if strings.Contains(customLocationID, "customLocations") { customLocationName := azureshared.ExtractResourceName(customLocationID) if customLocationName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(customLocationID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ExtendedLocationCustomLocation.String(), Method: sdp.QueryMethod_GET, Query: customLocationName, Scope: linkedScope, }, }) } } } // Link to IP (standard library) for allocated prefix (e.g. "20.10.0.0/28") if prefix.Properties != nil && prefix.Properties.IPPrefix != nil && *prefix.Properties.IPPrefix != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *prefix.Properties.IPPrefix, Scope: "global", }, }) } if prefix.Properties != nil { // Link to Custom IP Prefix (parent prefix this prefix is associated with) if prefix.Properties.CustomIPPrefix != nil && prefix.Properties.CustomIPPrefix.ID != nil { customPrefixID := *prefix.Properties.CustomIPPrefix.ID customPrefixName := azureshared.ExtractResourceName(customPrefixID) if customPrefixName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(customPrefixID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkCustomIPPrefix.String(), Method: sdp.QueryMethod_GET, Query: customPrefixName, Scope: linkedScope, }, }) } } // Link to NAT Gateway if prefix.Properties.NatGateway != nil && prefix.Properties.NatGateway.ID != nil { natGatewayID := *prefix.Properties.NatGateway.ID natGatewayName := azureshared.ExtractResourceName(natGatewayID) if natGatewayName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(natGatewayID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNatGateway.String(), Method: sdp.QueryMethod_GET, Query: natGatewayName, Scope: linkedScope, }, }) } } // Link to Load Balancer and Frontend IP Configuration (from frontend IP configuration reference) if prefix.Properties.LoadBalancerFrontendIPConfiguration != nil && prefix.Properties.LoadBalancerFrontendIPConfiguration.ID != nil { feConfigID := *prefix.Properties.LoadBalancerFrontendIPConfiguration.ID // Format: .../loadBalancers/{lbName}/frontendIPConfigurations/{feConfigName} params := azureshared.ExtractPathParamsFromResourceID(feConfigID, []string{"loadBalancers", "frontendIPConfigurations"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(feConfigID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancer.String(), Method: sdp.QueryMethod_GET, Query: params[0], Scope: linkedScope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // Link to each referenced Public IP Address for _, ref := range prefix.Properties.PublicIPAddresses { if ref != nil && ref.ID != nil { refID := *ref.ID refName := azureshared.ExtractResourceName(refID) if refName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(refID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: refName, Scope: linkedScope, }, }) } } } } // Health from provisioning state if prefix.Properties != nil && prefix.Properties.ProvisioningState != nil { switch *prefix.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return sdpItem, nil } func (n networkPublicIPPrefixWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkPublicIPPrefixLookupByName, } } func (n networkPublicIPPrefixWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkCustomIPPrefix: true, azureshared.NetworkNatGateway: true, azureshared.NetworkLoadBalancer: true, azureshared.NetworkLoadBalancerFrontendIPConfiguration: true, azureshared.NetworkPublicIPAddress: true, azureshared.ExtendedLocationCustomLocation: true, stdlib.NetworkIP: true, } } func (n networkPublicIPPrefixWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_public_ip_prefix.name", }, } } // https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (n networkPublicIPPrefixWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/publicIPPrefixes/read", } } func (n networkPublicIPPrefixWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-public-ip-prefix_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkPublicIPPrefix(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { prefixName := "test-prefix" prefix := createAzurePublicIPPrefix(prefixName) mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, prefixName, nil).Return( armnetwork.PublicIPPrefixesClientGetResponse{ PublicIPPrefix: *prefix, }, nil) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], prefixName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkPublicIPPrefix.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkPublicIPPrefix.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != prefixName { t.Errorf("Expected unique attribute value %s, got %s", prefixName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { // Public IP prefix with no linked resources in base createAzurePublicIPPrefix queryTests := shared.QueryTests{} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithLinkedResources", func(t *testing.T) { prefixName := "test-prefix-with-links" prefix := createAzurePublicIPPrefixWithLinks(prefixName, subscriptionID, resourceGroup) mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, prefixName, nil).Return( armnetwork.PublicIPPrefixesClientGetResponse{ PublicIPPrefix: *prefix, }, nil) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], prefixName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { scope := subscriptionID + "." + resourceGroup queryTests := shared.QueryTests{ { ExpectedType: azureshared.ExtendedLocationCustomLocation.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-custom-location", ExpectedScope: scope, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "20.10.0.0/28", ExpectedScope: "global", }, { ExpectedType: azureshared.NetworkCustomIPPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-custom-prefix", ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkNatGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nat-gateway", ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkLoadBalancer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-load-balancer", ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkLoadBalancerFrontendIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-load-balancer", "frontend"), ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "referenced-public-ip", ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when public IP prefix name is empty, but got nil") } }) t.Run("Get_PrefixWithNilName", func(t *testing.T) { provisioningState := armnetwork.ProvisioningStateSucceeded prefixWithNilName := &armnetwork.PublicIPPrefix{ Name: nil, Location: new("eastus"), Properties: &armnetwork.PublicIPPrefixPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-prefix", nil).Return( armnetwork.PublicIPPrefixesClientGetResponse{ PublicIPPrefix: *prefixWithNilName, }, nil) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-prefix", true) if qErr == nil { t.Error("Expected error when public IP prefix has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { prefix1 := createAzurePublicIPPrefix("prefix-1") prefix2 := createAzurePublicIPPrefix("prefix-2") mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) mockPager := newMockPublicIPPrefixesPager(ctrl, []*armnetwork.PublicIPPrefix{prefix1, prefix2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetType() != azureshared.NetworkPublicIPPrefix.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkPublicIPPrefix.String(), item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { prefix1 := createAzurePublicIPPrefix("prefix-1") provisioningState := armnetwork.ProvisioningStateSucceeded prefix2NilName := &armnetwork.PublicIPPrefix{ Name: nil, Location: new("eastus"), Tags: map[string]*string{"env": new("test")}, Properties: &armnetwork.PublicIPPrefixPropertiesFormat{ ProvisioningState: &provisioningState, }, } mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) mockPager := newMockPublicIPPrefixesPager(ctrl, []*armnetwork.PublicIPPrefix{prefix1, prefix2NilName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "prefix-1" { t.Errorf("Expected item name 'prefix-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ListStream", func(t *testing.T) { prefix1 := createAzurePublicIPPrefix("stream-prefix-1") prefix2 := createAzurePublicIPPrefix("stream-prefix-2") mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) mockPager := newMockPublicIPPrefixesPager(ctrl, []*armnetwork.PublicIPPrefix{prefix1, prefix2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("public IP prefix not found") mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-prefix", nil).Return( armnetwork.PublicIPPrefixesClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-prefix", true) if qErr == nil { t.Error("Expected error when getting non-existent public IP prefix, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockPublicIPPrefixesClient(ctrl) wrapper := manual.NewNetworkPublicIPPrefix(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/publicIPPrefixes/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } mappings := w.TerraformMappings() foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_public_ip_prefix.name" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected TerraformMethod GET, got: %s", mapping.GetTerraformMethod()) } break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_public_ip_prefix.name'") } lookups := w.GetLookups() foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.NetworkPublicIPPrefix { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include NetworkPublicIPPrefix") } potentialLinks := w.PotentialLinks() for _, linkType := range []shared.ItemType{azureshared.ExtendedLocationCustomLocation, azureshared.NetworkCustomIPPrefix, azureshared.NetworkNatGateway, azureshared.NetworkLoadBalancer, azureshared.NetworkLoadBalancerFrontendIPConfiguration, azureshared.NetworkPublicIPAddress, stdlib.NetworkIP} { if !potentialLinks[linkType] { t.Errorf("Expected PotentialLinks to include %s", linkType) } } }) } type mockPublicIPPrefixesPager struct { ctrl *gomock.Controller items []*armnetwork.PublicIPPrefix index int more bool } func newMockPublicIPPrefixesPager(ctrl *gomock.Controller, items []*armnetwork.PublicIPPrefix) clients.PublicIPPrefixesPager { return &mockPublicIPPrefixesPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockPublicIPPrefixesPager) More() bool { return m.more } func (m *mockPublicIPPrefixesPager) NextPage(ctx context.Context) (armnetwork.PublicIPPrefixesClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.PublicIPPrefixesClientListResponse{ PublicIPPrefixListResult: armnetwork.PublicIPPrefixListResult{ Value: []*armnetwork.PublicIPPrefix{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.PublicIPPrefixesClientListResponse{ PublicIPPrefixListResult: armnetwork.PublicIPPrefixListResult{ Value: []*armnetwork.PublicIPPrefix{item}, }, }, nil } func createAzurePublicIPPrefix(name string) *armnetwork.PublicIPPrefix { provisioningState := armnetwork.ProvisioningStateSucceeded prefixLength := int32(28) return &armnetwork.PublicIPPrefix{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/publicIPPrefixes/" + name), Name: new(name), Type: new("Microsoft.Network/publicIPPrefixes"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.PublicIPPrefixPropertiesFormat{ ProvisioningState: &provisioningState, PrefixLength: &prefixLength, }, } } func createAzurePublicIPPrefixWithLinks(name, subscriptionID, resourceGroup string) *armnetwork.PublicIPPrefix { prefix := createAzurePublicIPPrefix(name) prefix.Properties.IPPrefix = new("20.10.0.0/28") customLocationID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ExtendedLocation/customLocations/test-custom-location" customPrefixID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/customIPPrefixes/test-custom-prefix" natGatewayID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/natGateways/test-nat-gateway" lbFeConfigID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/loadBalancers/test-load-balancer/frontendIPConfigurations/frontend" publicIPID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/publicIPAddresses/referenced-public-ip" prefix.ExtendedLocation = &armnetwork.ExtendedLocation{ Name: new(customLocationID), } prefix.Properties.CustomIPPrefix = &armnetwork.SubResource{ ID: new(customPrefixID), } prefix.Properties.NatGateway = &armnetwork.NatGateway{ ID: new(natGatewayID), } prefix.Properties.LoadBalancerFrontendIPConfiguration = &armnetwork.SubResource{ ID: new(lbFeConfigID), } prefix.Properties.PublicIPAddresses = []*armnetwork.ReferencedPublicIPAddress{ {ID: new(publicIPID)}, } return prefix } var _ clients.PublicIPPrefixesPager = (*mockPublicIPPrefixesPager)(nil) ================================================ FILE: sources/azure/manual/network-route-table.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkRouteTableLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkRouteTable) type networkRouteTableWrapper struct { client clients.RouteTablesClient *azureshared.MultiResourceGroupBase } func NewNetworkRouteTable(client clients.RouteTablesClient, resourceGroupScopes []azureshared.ResourceGroupScope) *networkRouteTableWrapper { return &networkRouteTableWrapper{ MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkRouteTable, ), client: client, } } func (n networkRouteTableWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.List(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } for _, routeTable := range page.Value { if routeTable.Name == nil { continue } item, sdpErr := n.azureRouteTableToSDPItem(routeTable) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkRouteTableWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.List(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, routeTable := range page.Value { if routeTable.Name == nil { continue } item, sdpErr := n.azureRouteTableToSDPItem(routeTable) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkRouteTableWrapper) azureRouteTableToSDPItem(routeTable *armnetwork.RouteTable) (*sdp.Item, *sdp.QueryError) { if routeTable.Name == nil { return nil, azureshared.QueryError(errors.New("route table name is nil"), n.DefaultScope(), n.Type()) } attributes, err := shared.ToAttributesWithExclude(routeTable, "tags") if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } routeTableName := *routeTable.Name sdpItem := &sdp.Item{ Type: azureshared.NetworkRouteTable.String(), UniqueAttribute: "name", Attributes: attributes, Scope: n.DefaultScope(), Tags: azureshared.ConvertAzureTags(routeTable.Tags), } // Link to Routes (child resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/routes/get if routeTable.Properties != nil && routeTable.Properties.Routes != nil { for _, route := range routeTable.Properties.Routes { if route != nil && route.Name != nil && *route.Name != "" { // Routes are child resources accessed via: routeTables/{routeTableName}/routes/{routeName} // Query requires routeTableName and routeName sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkRoute.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(routeTableName, *route.Name), Scope: n.DefaultScope(), }, }) // Link to NextHopIPAddress (IP address to stdlib) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/routes/get if route.Properties != nil && route.Properties.NextHopIPAddress != nil && *route.Properties.NextHopIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *route.Properties.NextHopIPAddress, Scope: "global", }, }) } // Note: We don't link to VirtualNetworkGateway when nextHopType is VirtualNetworkGateway // because the Route struct doesn't contain a direct gateway ID. The gateway name would need // to be derivable from the route or searched, but typically VirtualNetworkGateway routes // don't have nextHopIPAddress. This link will be implemented when we can determine how to // identify the gateway from the route. // Reference: https://learn.microsoft.com/en-us/rest/api/network/virtual-network-gateways/get } } } // Link to Subnets (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get if routeTable.Properties != nil && routeTable.Properties.Subnets != nil { for _, subnetRef := range routeTable.Properties.Subnets { if subnetRef != nil && subnetRef.ID != nil { subnetID := *subnetRef.ID // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnet}/subnets/{subnet} // Extract virtual network name and subnet name using helper function subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(subnetParams) >= 2 { vnetName := subnetParams[0] subnetName := subnetParams[1] scope := n.DefaultScope() // Check if subnet is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(subnetID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: scope, }, }) } } } } return sdpItem, nil } func (n networkRouteTableWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the route table name"), n.DefaultScope(), n.Type()) } routeTableName := queryParts[0] if routeTableName == "" { return nil, azureshared.QueryError(errors.New("route table name is empty"), n.DefaultScope(), n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, routeTableName, nil) if err != nil { return nil, azureshared.QueryError(err, n.DefaultScope(), n.Type()) } return n.azureRouteTableToSDPItem(&resp.RouteTable) } func (n networkRouteTableWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkRouteTableLookupByName, } } func (n networkRouteTableWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkRoute, azureshared.NetworkSubnet, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/route_table func (n networkRouteTableWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_route_table.name", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (n networkRouteTableWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/routeTables/read", } } func (n networkRouteTableWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-route-table_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkRouteTable(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { routeTableName := "test-route-table" routeTable := createAzureRouteTable(routeTableName) mockClient := mocks.NewMockRouteTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, routeTableName, nil).Return( armnetwork.RouteTablesClientGetResponse{ RouteTable: *routeTable, }, nil) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], routeTableName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkRouteTable.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkRouteTable, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != routeTableName { t.Errorf("Expected unique attribute value %s, got %s", routeTableName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Route link (child resource) ExpectedType: azureshared.NetworkRoute.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(routeTableName, "test-route"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Route with NextHopIPAddress link (IP address to stdlib) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { // Subnet link (external resource) ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockRouteTablesClient(ctrl) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty string name - validation happens before client.Get is called // so no mock expectation is needed _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting route table with empty name, but got nil") } }) t.Run("Get_WithNilName", func(t *testing.T) { routeTable := &armnetwork.RouteTable{ Name: nil, // Route table with nil name should cause an error Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockRouteTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "test-route-table", nil).Return( armnetwork.RouteTablesClientGetResponse{ RouteTable: *routeTable, }, nil) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-route-table", true) if qErr == nil { t.Error("Expected error when route table has nil name, but got nil") } }) t.Run("List", func(t *testing.T) { routeTable1 := createAzureRouteTable("test-route-table-1") routeTable2 := createAzureRouteTable("test-route-table-2") mockClient := mocks.NewMockRouteTablesClient(ctrl) mockPager := NewMockRouteTablesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.RouteTablesClientListResponse{ RouteTableListResult: armnetwork.RouteTableListResult{ Value: []*armnetwork.RouteTable{routeTable1, routeTable2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkRouteTable.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkRouteTable, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Create route table with nil name to test error handling routeTable1 := createAzureRouteTable("test-route-table-1") routeTable2 := &armnetwork.RouteTable{ Name: nil, // Route table with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockRouteTablesClient(ctrl) mockPager := NewMockRouteTablesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.RouteTablesClientListResponse{ RouteTableListResult: armnetwork.RouteTableListResult{ Value: []*armnetwork.RouteTable{routeTable1, routeTable2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().List(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (routeTable1), routeTable2 should be skipped if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name should be skipped), got: %d", len(sdpItems)) } }) t.Run("List_ErrorHandling", func(t *testing.T) { expectedErr := errors.New("failed to list route tables") mockClient := mocks.NewMockRouteTablesClient(ctrl) mockPager := NewMockRouteTablesPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.RouteTablesClientListResponse{}, expectedErr), ) mockClient.EXPECT().List(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing route tables fails, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("route table not found") mockClient := mocks.NewMockRouteTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-route-table", nil).Return( armnetwork.RouteTablesClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-route-table", true) if qErr == nil { t.Error("Expected error when getting non-existent route table, but got nil") } }) t.Run("CrossResourceGroupLinks", func(t *testing.T) { // Test route table with subnet in different resource group routeTableName := "test-route-table" otherResourceGroup := "other-rg" otherSubscriptionID := "other-subscription" routeTable := &armnetwork.RouteTable{ Name: new(routeTableName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.RouteTablePropertiesFormat{ Subnets: []*armnetwork.Subnet{ { ID: new("/subscriptions/" + otherSubscriptionID + "/resourceGroups/" + otherResourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, }, } mockClient := mocks.NewMockRouteTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, routeTableName, nil).Return( armnetwork.RouteTablesClientGetResponse{ RouteTable: *routeTable, }, nil) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], routeTableName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Check that subnet link uses the correct scope foundSubnetLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.NetworkSubnet.String() { foundSubnetLink = true expectedScope := otherSubscriptionID + "." + otherResourceGroup if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected subnet scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } } if !foundSubnetLink { t.Error("Expected to find subnet link") } }) t.Run("RouteWithNextHopIPAddress", func(t *testing.T) { // Test route table with route that has NextHopIPAddress routeTableName := "test-route-table" routeTable := &armnetwork.RouteTable{ Name: new(routeTableName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.RouteTablePropertiesFormat{ Routes: []*armnetwork.Route{ { Name: new("test-route"), Properties: &armnetwork.RoutePropertiesFormat{ AddressPrefix: new("10.0.0.0/16"), NextHopType: new(armnetwork.RouteNextHopTypeVirtualAppliance), NextHopIPAddress: new("10.0.0.1"), }, }, }, }, } mockClient := mocks.NewMockRouteTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, routeTableName, nil).Return( armnetwork.RouteTablesClientGetResponse{ RouteTable: *routeTable, }, nil) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], routeTableName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Check that IP address link exists foundIPLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == stdlib.NetworkIP.String() { foundIPLink = true if link.GetQuery().GetQuery() != "10.0.0.1" { t.Errorf("Expected IP address '10.0.0.1', got %s", link.GetQuery().GetQuery()) } if link.GetQuery().GetScope() != "global" { t.Errorf("Expected IP scope 'global', got %s", link.GetQuery().GetScope()) } } } if !foundIPLink { t.Error("Expected to find IP address link") } }) t.Run("RouteWithoutNextHopIPAddress", func(t *testing.T) { // Test route table with route that doesn't have NextHopIPAddress routeTableName := "test-route-table" routeTable := &armnetwork.RouteTable{ Name: new(routeTableName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.RouteTablePropertiesFormat{ Routes: []*armnetwork.Route{ { Name: new("test-route"), Properties: &armnetwork.RoutePropertiesFormat{ AddressPrefix: new("10.0.0.0/16"), NextHopType: new(armnetwork.RouteNextHopTypeInternet), // No NextHopIPAddress }, }, }, }, } mockClient := mocks.NewMockRouteTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, routeTableName, nil).Return( armnetwork.RouteTablesClientGetResponse{ RouteTable: *routeTable, }, nil) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], routeTableName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Check that no IP address link exists for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == stdlib.NetworkIP.String() { t.Error("Expected no IP address link when NextHopIPAddress is not set") } } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockRouteTablesClient(ctrl) wrapper := manual.NewNetworkRouteTable(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements ListableWrapper interface var _ sources.ListableWrapper = wrapper // Verify IAMPermissions permissions := wrapper.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/routeTables/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := wrapper.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } expectedLinks := []shared.ItemType{ azureshared.NetworkRoute, azureshared.NetworkSubnet, stdlib.NetworkIP, } for _, expectedLink := range expectedLinks { if !potentialLinks[expectedLink] { t.Errorf("Expected PotentialLinks to include %s", expectedLink) } } // Verify TerraformMappings mappings := wrapper.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_route_table.name" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_route_table.name' mapping") } // Verify PredefinedRole // PredefinedRole is available on the wrapper, not the adapter // Use type assertion with interface{} to access the method if roleInterface, ok := any(wrapper).(interface{ PredefinedRole() string }); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } else { t.Error("Wrapper does not implement PredefinedRole method") } }) } // MockRouteTablesPager is a simple mock for RouteTablesPager type MockRouteTablesPager struct { ctrl *gomock.Controller recorder *MockRouteTablesPagerMockRecorder } type MockRouteTablesPagerMockRecorder struct { mock *MockRouteTablesPager } func NewMockRouteTablesPager(ctrl *gomock.Controller) *MockRouteTablesPager { mock := &MockRouteTablesPager{ctrl: ctrl} mock.recorder = &MockRouteTablesPagerMockRecorder{mock} return mock } func (m *MockRouteTablesPager) EXPECT() *MockRouteTablesPagerMockRecorder { return m.recorder } func (m *MockRouteTablesPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockRouteTablesPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockRouteTablesPager) NextPage(ctx context.Context) (armnetwork.RouteTablesClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.RouteTablesClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockRouteTablesPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.RouteTablesClientListResponse, error)](), ctx) } // createAzureRouteTable creates a mock Azure route table for testing func createAzureRouteTable(routeTableName string) *armnetwork.RouteTable { subscriptionID := "test-subscription" resourceGroup := "test-rg" return &armnetwork.RouteTable{ Name: new(routeTableName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.RouteTablePropertiesFormat{ // Routes (child resources) Routes: []*armnetwork.Route{ { Name: new("test-route"), Properties: &armnetwork.RoutePropertiesFormat{ AddressPrefix: new("10.0.0.0/16"), NextHopType: new(armnetwork.RouteNextHopTypeVirtualAppliance), NextHopIPAddress: new("10.0.0.1"), }, }, }, // Subnets (external resources) Subnets: []*armnetwork.Subnet{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet"), }, }, }, } } ================================================ FILE: sources/azure/manual/network-route.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkRouteLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkRoute) type networkRouteWrapper struct { client clients.RoutesClient *azureshared.MultiResourceGroupBase } // NewNetworkRoute creates a new networkRouteWrapper instance (SearchableWrapper: child of route table). func NewNetworkRoute(client clients.RoutesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkRouteWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkRoute, ), } } func (n networkRouteWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: routeTableName and routeName", Scope: scope, ItemType: n.Type(), } } routeTableName := queryParts[0] routeName := queryParts[1] if routeName == "" { return nil, azureshared.QueryError(errors.New("route name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, routeTableName, routeName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureRouteToSDPItem(&resp.Route, routeTableName, routeName, scope) } func (n networkRouteWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkRouteTableLookupByName, NetworkRouteLookupByUniqueAttr, } } func (n networkRouteWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: routeTableName", Scope: scope, ItemType: n.Type(), } } routeTableName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, routeTableName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, route := range page.Value { if route == nil || route.Name == nil { continue } item, sdpErr := n.azureRouteToSDPItem(route, routeTableName, *route.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkRouteWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: routeTableName"), scope, n.Type())) return } routeTableName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, routeTableName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, route := range page.Value { if route == nil || route.Name == nil { continue } item, sdpErr := n.azureRouteToSDPItem(route, routeTableName, *route.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkRouteWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {NetworkRouteTableLookupByName}, } } func (n networkRouteWrapper) azureRouteToSDPItem(route *armnetwork.Route, routeTableName, routeName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(route, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(routeTableName, routeName)) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkRoute.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health status from ProvisioningState if route.Properties != nil && route.Properties.ProvisioningState != nil { switch *route.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } } // Link to parent Route Table sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkRouteTable.String(), Method: sdp.QueryMethod_GET, Query: routeTableName, Scope: scope, }, }) // Link to NextHopIPAddress (IP address to stdlib) if route.Properties != nil && route.Properties.NextHopIPAddress != nil && *route.Properties.NextHopIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *route.Properties.NextHopIPAddress, Scope: "global", }, }) } return sdpItem, nil } func (n networkRouteWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkRouteTable, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/route func (n networkRouteWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ {TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_route.id"}, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions-reference#microsoftnetwork func (n networkRouteWrapper) IAMPermissions() []string { return []string{"Microsoft.Network/routeTables/routes/read"} } func (n networkRouteWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-route_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockRoutesPager struct { pages []armnetwork.RoutesClientListResponse index int } func (m *mockRoutesPager) More() bool { return m.index < len(m.pages) } func (m *mockRoutesPager) NextPage(ctx context.Context) (armnetwork.RoutesClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.RoutesClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorRoutesPager struct{} func (e *errorRoutesPager) More() bool { return true } func (e *errorRoutesPager) NextPage(ctx context.Context) (armnetwork.RoutesClientListResponse, error) { return armnetwork.RoutesClientListResponse{}, errors.New("pager error") } type testRoutesClient struct { *mocks.MockRoutesClient pager clients.RoutesPager } func (t *testRoutesClient) NewListPager(resourceGroupName, routeTableName string, options *armnetwork.RoutesClientListOptions) clients.RoutesPager { return t.pager } func TestNetworkRoute(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" routeTableName := "test-route-table" routeName := "test-route" t.Run("Get", func(t *testing.T) { route := createAzureRoute(routeName, routeTableName) mockClient := mocks.NewMockRoutesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, routeTableName, routeName, nil).Return( armnetwork.RoutesClientGetResponse{ Route: *route, }, nil) testClient := &testRoutesClient{MockRoutesClient: mockClient} wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(routeTableName, routeName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkRoute.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkRoute, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(routeTableName, routeName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(routeTableName, routeName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkRouteTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: routeTableName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyRouteName", func(t *testing.T) { mockClient := mocks.NewMockRoutesClient(ctrl) testClient := &testRoutesClient{MockRoutesClient: mockClient} wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(routeTableName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when route name is empty, but got nil") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockRoutesClient(ctrl) testClient := &testRoutesClient{MockRoutesClient: mockClient} wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], routeTableName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { route1 := createAzureRoute("route-1", routeTableName) route2 := createAzureRoute("route-2", routeTableName) mockClient := mocks.NewMockRoutesClient(ctrl) mockPager := &mockRoutesPager{ pages: []armnetwork.RoutesClientListResponse{ { RouteListResult: armnetwork.RouteListResult{ Value: []*armnetwork.Route{route1, route2}, }, }, }, } testClient := &testRoutesClient{ MockRoutesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], routeTableName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkRoute.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkRoute, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockRoutesClient(ctrl) testClient := &testRoutesClient{MockRoutesClient: mockClient} wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_RouteWithNilName", func(t *testing.T) { validRoute := createAzureRoute("valid-route", routeTableName) mockClient := mocks.NewMockRoutesClient(ctrl) mockPager := &mockRoutesPager{ pages: []armnetwork.RoutesClientListResponse{ { RouteListResult: armnetwork.RouteListResult{ Value: []*armnetwork.Route{ {Name: nil, ID: new("/some/id")}, validRoute, }, }, }, }, } testClient := &testRoutesClient{ MockRoutesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], routeTableName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(routeTableName, "valid-route") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(routeTableName, "valid-route"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("route not found") mockClient := mocks.NewMockRoutesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, routeTableName, "nonexistent-route", nil).Return( armnetwork.RoutesClientGetResponse{}, expectedErr) testClient := &testRoutesClient{MockRoutesClient: mockClient} wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(routeTableName, "nonexistent-route") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent route, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockRoutesClient(ctrl) testClient := &testRoutesClient{ MockRoutesClient: mockClient, pager: &errorRoutesPager{}, } wrapper := manual.NewNetworkRoute(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], routeTableName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } func createAzureRoute(routeName, routeTableName string) *armnetwork.Route { idStr := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/routeTables/" + routeTableName + "/routes/" + routeName typeStr := "Microsoft.Network/routeTables/routes" provisioningState := armnetwork.ProvisioningStateSucceeded nextHopIP := "10.0.0.1" nextHopType := armnetwork.RouteNextHopTypeVnetLocal return &armnetwork.Route{ ID: &idStr, Name: &routeName, Type: &typeStr, Properties: &armnetwork.RoutePropertiesFormat{ ProvisioningState: &provisioningState, NextHopIPAddress: &nextHopIP, AddressPrefix: new("10.0.0.0/24"), NextHopType: &nextHopType, }, } } ================================================ FILE: sources/azure/manual/network-security-rule.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkSecurityRuleLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkSecurityRule) type networkSecurityRuleWrapper struct { client clients.SecurityRulesClient *azureshared.MultiResourceGroupBase } // NewNetworkSecurityRule creates a new networkSecurityRuleWrapper instance (SearchableWrapper: child of network security group). func NewNetworkSecurityRule(client clients.SecurityRulesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkSecurityRuleWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkSecurityRule, ), } } func (n networkSecurityRuleWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: networkSecurityGroupName and securityRuleName", Scope: scope, ItemType: n.Type(), } } nsgName := queryParts[0] ruleName := queryParts[1] if ruleName == "" { return nil, azureshared.QueryError(errors.New("security rule name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, nsgName, ruleName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureSecurityRuleToSDPItem(&resp.SecurityRule, nsgName, ruleName, scope) } func (n networkSecurityRuleWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkNetworkSecurityGroupLookupByName, NetworkSecurityRuleLookupByUniqueAttr, } } func (n networkSecurityRuleWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: networkSecurityGroupName", Scope: scope, ItemType: n.Type(), } } nsgName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, nsgName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, rule := range page.Value { if rule == nil || rule.Name == nil { continue } item, sdpErr := n.azureSecurityRuleToSDPItem(rule, nsgName, *rule.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkSecurityRuleWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: networkSecurityGroupName"), scope, n.Type())) return } nsgName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, nsgName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, rule := range page.Value { if rule == nil || rule.Name == nil { continue } item, sdpErr := n.azureSecurityRuleToSDPItem(rule, nsgName, *rule.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkSecurityRuleWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {NetworkNetworkSecurityGroupLookupByName}, } } func (n networkSecurityRuleWrapper) azureSecurityRuleToSDPItem(rule *armnetwork.SecurityRule, nsgName, ruleName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(rule, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(nsgName, ruleName)) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkSecurityRule.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to parent Network Security Group sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: nsgName, Scope: scope, }, }) if rule.Properties != nil { // Link to SourceApplicationSecurityGroups if rule.Properties.SourceApplicationSecurityGroups != nil { for _, asgRef := range rule.Properties.SourceApplicationSecurityGroups { if asgRef != nil && asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: linkScope, }, }) } } } } // Link to DestinationApplicationSecurityGroups if rule.Properties.DestinationApplicationSecurityGroups != nil { for _, asgRef := range rule.Properties.DestinationApplicationSecurityGroups { if asgRef != nil && asgRef.ID != nil { asgName := azureshared.ExtractResourceName(*asgRef.ID) if asgName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: asgName, Scope: linkScope, }, }) } } } } // Link to stdlib.NetworkIP for source/destination address prefixes when they are IPs or CIDRs if rule.Properties.SourceAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *rule.Properties.SourceAddressPrefix) } for _, p := range rule.Properties.SourceAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } if rule.Properties.DestinationAddressPrefix != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *rule.Properties.DestinationAddressPrefix) } for _, p := range rule.Properties.DestinationAddressPrefixes { if p != nil { appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) } } } return sdpItem, nil } func (n networkSecurityRuleWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkNetworkSecurityGroup, azureshared.NetworkApplicationSecurityGroup, stdlib.NetworkIP, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/network_security_rule func (n networkSecurityRuleWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_network_security_rule.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions-reference#microsoftnetwork func (n networkSecurityRuleWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/networkSecurityGroups/securityRules/read", } } func (n networkSecurityRuleWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-security-rule_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockSecurityRulesPager struct { pages []armnetwork.SecurityRulesClientListResponse index int } func (m *mockSecurityRulesPager) More() bool { return m.index < len(m.pages) } func (m *mockSecurityRulesPager) NextPage(ctx context.Context) (armnetwork.SecurityRulesClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.SecurityRulesClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorSecurityRulesPager struct{} func (e *errorSecurityRulesPager) More() bool { return true } func (e *errorSecurityRulesPager) NextPage(ctx context.Context) (armnetwork.SecurityRulesClientListResponse, error) { return armnetwork.SecurityRulesClientListResponse{}, errors.New("pager error") } type testSecurityRulesClient struct { *mocks.MockSecurityRulesClient pager clients.SecurityRulesPager } func (t *testSecurityRulesClient) NewListPager(resourceGroupName, networkSecurityGroupName string, options *armnetwork.SecurityRulesClientListOptions) clients.SecurityRulesPager { return t.pager } func TestNetworkSecurityRule(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" nsgName := "test-nsg" ruleName := "test-rule" t.Run("Get", func(t *testing.T) { rule := createAzureSecurityRule(ruleName, nsgName) mockClient := mocks.NewMockSecurityRulesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, ruleName, nil).Return( armnetwork.SecurityRulesClientGetResponse{ SecurityRule: *rule, }, nil) testClient := &testSecurityRulesClient{MockSecurityRulesClient: mockClient} wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(nsgName, ruleName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkSecurityRule.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkSecurityRule, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(nsgName, ruleName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(nsgName, ruleName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkNetworkSecurityGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: nsgName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyRuleName", func(t *testing.T) { mockClient := mocks.NewMockSecurityRulesClient(ctrl) testClient := &testSecurityRulesClient{MockSecurityRulesClient: mockClient} wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(nsgName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when rule name is empty, but got nil") } }) t.Run("Get_InsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSecurityRulesClient(ctrl) testClient := &testSecurityRulesClient{MockSecurityRulesClient: mockClient} wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], nsgName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { rule1 := createAzureSecurityRule("rule-1", nsgName) rule2 := createAzureSecurityRule("rule-2", nsgName) mockClient := mocks.NewMockSecurityRulesClient(ctrl) mockPager := &mockSecurityRulesPager{ pages: []armnetwork.SecurityRulesClientListResponse{ { SecurityRuleListResult: armnetwork.SecurityRuleListResult{ Value: []*armnetwork.SecurityRule{rule1, rule2}, }, }, }, } testClient := &testSecurityRulesClient{ MockSecurityRulesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkSecurityRule.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkSecurityRule, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSecurityRulesClient(ctrl) testClient := &testSecurityRulesClient{MockSecurityRulesClient: mockClient} wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_RuleWithNilName", func(t *testing.T) { validRule := createAzureSecurityRule("valid-rule", nsgName) mockClient := mocks.NewMockSecurityRulesClient(ctrl) mockPager := &mockSecurityRulesPager{ pages: []armnetwork.SecurityRulesClientListResponse{ { SecurityRuleListResult: armnetwork.SecurityRuleListResult{ Value: []*armnetwork.SecurityRule{ {Name: nil, ID: new("/some/id")}, validRule, }, }, }, }, } testClient := &testSecurityRulesClient{ MockSecurityRulesClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(nsgName, "valid-rule") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(nsgName, "valid-rule"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("security rule not found") mockClient := mocks.NewMockSecurityRulesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, "nonexistent-rule", nil).Return( armnetwork.SecurityRulesClientGetResponse{}, expectedErr) testClient := &testSecurityRulesClient{MockSecurityRulesClient: mockClient} wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(nsgName, "nonexistent-rule") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent rule, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSecurityRulesClient(ctrl) testClient := &testSecurityRulesClient{ MockSecurityRulesClient: mockClient, pager: &errorSecurityRulesPager{}, } wrapper := manual.NewNetworkSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } func createAzureSecurityRule(ruleName, nsgName string) *armnetwork.SecurityRule { idStr := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/" + nsgName + "/securityRules/" + ruleName typeStr := "Microsoft.Network/networkSecurityGroups/securityRules" access := armnetwork.SecurityRuleAccessAllow direction := armnetwork.SecurityRuleDirectionInbound protocol := armnetwork.SecurityRuleProtocolAsterisk priority := int32(100) return &armnetwork.SecurityRule{ ID: &idStr, Name: &ruleName, Type: &typeStr, Properties: &armnetwork.SecurityRulePropertiesFormat{ Access: &access, Direction: &direction, Protocol: &protocol, Priority: &priority, }, } } ================================================ FILE: sources/azure/manual/network-subnet.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkSubnetLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkSubnet) type networkSubnetWrapper struct { client clients.SubnetsClient *azureshared.MultiResourceGroupBase } // NewNetworkSubnet creates a new networkSubnetWrapper instance (SearchableWrapper: child of virtual network). func NewNetworkSubnet(client clients.SubnetsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkSubnetWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkSubnet, ), } } func (n networkSubnetWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: virtualNetworkName and subnetName", Scope: scope, ItemType: n.Type(), } } virtualNetworkName := queryParts[0] subnetName := queryParts[1] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, virtualNetworkName, subnetName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureSubnetToSDPItem(&resp.Subnet, virtualNetworkName, subnetName, scope) } func (n networkSubnetWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkVirtualNetworkLookupByName, NetworkSubnetLookupByUniqueAttr, } } func (n networkSubnetWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: virtualNetworkName", Scope: scope, ItemType: n.Type(), } } virtualNetworkName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, virtualNetworkName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, subnet := range page.Value { if subnet == nil || subnet.Name == nil { continue } item, sdpErr := n.azureSubnetToSDPItem(subnet, virtualNetworkName, *subnet.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkSubnetWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: virtualNetworkName"), scope, n.Type())) return } virtualNetworkName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, virtualNetworkName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, subnet := range page.Value { if subnet == nil || subnet.Name == nil { continue } item, sdpErr := n.azureSubnetToSDPItem(subnet, virtualNetworkName, *subnet.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkSubnetWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { NetworkVirtualNetworkLookupByName, }, } } func (n networkSubnetWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkVirtualNetwork: true, azureshared.NetworkNetworkSecurityGroup: true, azureshared.NetworkRouteTable: true, azureshared.NetworkNatGateway: true, azureshared.NetworkPrivateEndpoint: true, azureshared.NetworkServiceEndpointPolicy: true, azureshared.NetworkIpAllocation: true, azureshared.NetworkNetworkInterface: true, azureshared.NetworkApplicationGateway: true, } } func (n networkSubnetWrapper) azureSubnetToSDPItem(subnet *armnetwork.Subnet, virtualNetworkName, subnetName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(subnet, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(virtualNetworkName, subnetName)) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkSubnet.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to parent Virtual Network sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: virtualNetworkName, Scope: scope, }, }) // Link to Network Security Group from subnet if subnet.Properties != nil && subnet.Properties.NetworkSecurityGroup != nil && subnet.Properties.NetworkSecurityGroup.ID != nil { nsgID := *subnet.Properties.NetworkSecurityGroup.ID nsgName := azureshared.ExtractResourceName(nsgID) if nsgName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(nsgID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: nsgName, Scope: linkScope, }, }) } } // Link to Route Table from subnet if subnet.Properties != nil && subnet.Properties.RouteTable != nil && subnet.Properties.RouteTable.ID != nil { routeTableID := *subnet.Properties.RouteTable.ID routeTableName := azureshared.ExtractResourceName(routeTableID) if routeTableName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(routeTableID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkRouteTable.String(), Method: sdp.QueryMethod_GET, Query: routeTableName, Scope: linkScope, }, }) } } // Link to NAT Gateway from subnet if subnet.Properties != nil && subnet.Properties.NatGateway != nil && subnet.Properties.NatGateway.ID != nil { natGatewayID := *subnet.Properties.NatGateway.ID natGatewayName := azureshared.ExtractResourceName(natGatewayID) if natGatewayName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(natGatewayID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNatGateway.String(), Method: sdp.QueryMethod_GET, Query: natGatewayName, Scope: linkScope, }, }) } } // Link to Private Endpoints from subnet (read-only references) if subnet.Properties != nil && subnet.Properties.PrivateEndpoints != nil { for _, privateEndpoint := range subnet.Properties.PrivateEndpoints { if privateEndpoint != nil && privateEndpoint.ID != nil { privateEndpointID := *privateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: linkScope, }, }) } } } } // Link to Service Endpoint Policies from subnet if subnet.Properties != nil && subnet.Properties.ServiceEndpointPolicies != nil { for _, policy := range subnet.Properties.ServiceEndpointPolicies { if policy != nil && policy.ID != nil { policyID := *policy.ID policyName := azureshared.ExtractResourceName(policyID) if policyName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(policyID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkServiceEndpointPolicy.String(), Method: sdp.QueryMethod_GET, Query: policyName, Scope: linkScope, }, }) } } } } // Link to IP Allocations from subnet (references that use this subnet) if subnet.Properties != nil && subnet.Properties.IPAllocations != nil { for _, ipAlloc := range subnet.Properties.IPAllocations { if ipAlloc != nil && ipAlloc.ID != nil { ipAllocID := *ipAlloc.ID ipAllocName := azureshared.ExtractResourceName(ipAllocID) if ipAllocName != "" { linkScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(ipAllocID); extractedScope != "" { linkScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkIpAllocation.String(), Method: sdp.QueryMethod_GET, Query: ipAllocName, Scope: linkScope, }, }) } } } } // Link to Network Interfaces that have IP configurations in this subnet (read-only references) if subnet.Properties != nil && subnet.Properties.IPConfigurations != nil { for _, ipConfig := range subnet.Properties.IPConfigurations { if ipConfig != nil && ipConfig.ID != nil { ipConfigID := *ipConfig.ID // Format: .../networkInterfaces/{nicName}/ipConfigurations/{ipConfigName} if strings.Contains(ipConfigID, "/networkInterfaces/") { nicNames := azureshared.ExtractPathParamsFromResourceID(ipConfigID, []string{"networkInterfaces"}) if len(nicNames) > 0 && nicNames[0] != "" { linkScope := azureshared.ExtractScopeFromResourceID(ipConfigID) if linkScope == "" { linkScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkInterface.String(), Method: sdp.QueryMethod_GET, Query: nicNames[0], Scope: linkScope, }, }) } } } } } // Link to Application Gateways that have gateway IP configurations in this subnet (read-only references) if subnet.Properties != nil && subnet.Properties.ApplicationGatewayIPConfigurations != nil { for _, agIPConfig := range subnet.Properties.ApplicationGatewayIPConfigurations { if agIPConfig != nil && agIPConfig.ID != nil { agIPConfigID := *agIPConfig.ID // Format: .../applicationGateways/{agName}/applicationGatewayIPConfigurations/... agNames := azureshared.ExtractPathParamsFromResourceID(agIPConfigID, []string{"applicationGateways"}) if len(agNames) > 0 && agNames[0] != "" { linkScope := azureshared.ExtractScopeFromResourceID(agIPConfigID) if linkScope == "" { linkScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkApplicationGateway.String(), Method: sdp.QueryMethod_GET, Query: agNames[0], Scope: linkScope, }, }) } } } } // Link to external resources referenced by ResourceNavigationLinks (e.g. SQL Managed Instance) if subnet.Properties != nil && subnet.Properties.ResourceNavigationLinks != nil { for _, rnl := range subnet.Properties.ResourceNavigationLinks { if rnl != nil && rnl.Properties != nil && rnl.Properties.Link != nil { linkID := *rnl.Properties.Link resourceName := azureshared.ExtractResourceName(linkID) if resourceName != "" { linkScope := azureshared.ExtractScopeFromResourceID(linkID) if linkScope == "" { linkScope = scope } itemType := azureshared.ItemTypeFromLinkedResourceID(linkID) if itemType == "" { itemType = "azure-resource" } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: itemType, Method: sdp.QueryMethod_GET, Query: resourceName, Scope: linkScope, }, }) } } } } // Link to external resources referenced by ServiceAssociationLinks (e.g. App Service Environment) if subnet.Properties != nil && subnet.Properties.ServiceAssociationLinks != nil { for _, sal := range subnet.Properties.ServiceAssociationLinks { if sal != nil && sal.Properties != nil && sal.Properties.Link != nil { linkID := *sal.Properties.Link resourceName := azureshared.ExtractResourceName(linkID) if resourceName != "" { linkScope := azureshared.ExtractScopeFromResourceID(linkID) if linkScope == "" { linkScope = scope } itemType := azureshared.ItemTypeFromLinkedResourceID(linkID) if itemType == "" { itemType = "azure-resource" } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: itemType, Method: sdp.QueryMethod_GET, Query: resourceName, Scope: linkScope, }, }) } } } } return sdpItem, nil } func (n networkSubnetWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_subnet.id", }, } } func (n networkSubnetWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/virtualNetworks/subnets/read", } } func (n networkSubnetWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-subnet_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockSubnetsPager struct { pages []armnetwork.SubnetsClientListResponse index int } func (m *mockSubnetsPager) More() bool { return m.index < len(m.pages) } func (m *mockSubnetsPager) NextPage(ctx context.Context) (armnetwork.SubnetsClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.SubnetsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorSubnetsPager struct{} func (e *errorSubnetsPager) More() bool { return true } func (e *errorSubnetsPager) NextPage(ctx context.Context) (armnetwork.SubnetsClientListResponse, error) { return armnetwork.SubnetsClientListResponse{}, errors.New("pager error") } type testSubnetsClient struct { *mocks.MockSubnetsClient pager clients.SubnetsPager } func (t *testSubnetsClient) NewListPager(resourceGroupName, virtualNetworkName string, options *armnetwork.SubnetsClientListOptions) clients.SubnetsPager { return t.pager } func TestNetworkSubnet(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" virtualNetworkName := "test-vnet" subnetName := "test-subnet" t.Run("Get", func(t *testing.T) { subnet := createAzureSubnet(subnetName, virtualNetworkName) mockClient := mocks.NewMockSubnetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, virtualNetworkName, subnetName, nil).Return( armnetwork.SubnetsClientGetResponse{ Subnet: *subnet, }, nil) testClient := &testSubnetsClient{MockSubnetsClient: mockClient} wrapper := manual.NewNetworkSubnet(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(virtualNetworkName, subnetName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkSubnet.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkSubnet, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(virtualNetworkName, subnetName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(virtualNetworkName, subnetName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: virtualNetworkName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSubnetsClient(ctrl) testClient := &testSubnetsClient{MockSubnetsClient: mockClient} wrapper := manual.NewNetworkSubnet(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { subnet1 := createAzureSubnet("subnet-1", virtualNetworkName) subnet2 := createAzureSubnet("subnet-2", virtualNetworkName) mockClient := mocks.NewMockSubnetsClient(ctrl) mockPager := &mockSubnetsPager{ pages: []armnetwork.SubnetsClientListResponse{ { SubnetListResult: armnetwork.SubnetListResult{ Value: []*armnetwork.Subnet{subnet1, subnet2}, }, }, }, } testClient := &testSubnetsClient{ MockSubnetsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkSubnet(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkSubnet.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkSubnet, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSubnetsClient(ctrl) testClient := &testSubnetsClient{MockSubnetsClient: mockClient} wrapper := manual.NewNetworkSubnet(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_SubnetWithNilName", func(t *testing.T) { validSubnet := createAzureSubnet("valid-subnet", virtualNetworkName) mockClient := mocks.NewMockSubnetsClient(ctrl) mockPager := &mockSubnetsPager{ pages: []armnetwork.SubnetsClientListResponse{ { SubnetListResult: armnetwork.SubnetListResult{ Value: []*armnetwork.Subnet{ {Name: nil, ID: new("/some/id")}, validSubnet, }, }, }, }, } testClient := &testSubnetsClient{ MockSubnetsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkSubnet(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(virtualNetworkName, "valid-subnet") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(virtualNetworkName, "valid-subnet"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("subnet not found") mockClient := mocks.NewMockSubnetsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, virtualNetworkName, "nonexistent-subnet", nil).Return( armnetwork.SubnetsClientGetResponse{}, expectedErr) testClient := &testSubnetsClient{MockSubnetsClient: mockClient} wrapper := manual.NewNetworkSubnet(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(virtualNetworkName, "nonexistent-subnet") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent subnet, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSubnetsClient(ctrl) testClient := &testSubnetsClient{ MockSubnetsClient: mockClient, pager: &errorSubnetsPager{}, } wrapper := manual.NewNetworkSubnet(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } func createAzureSubnet(subnetName, vnetName string) *armnetwork.Subnet { return &armnetwork.Subnet{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/" + vnetName + "/subnets/" + subnetName), Name: new(subnetName), Type: new("Microsoft.Network/virtualNetworks/subnets"), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.0.0.0/24"), }, } } ================================================ FILE: sources/azure/manual/network-virtual-network-gateway-connection.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkVirtualNetworkGatewayConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkVirtualNetworkGatewayConnection) type networkVirtualNetworkGatewayConnectionWrapper struct { client clients.VirtualNetworkGatewayConnectionsClient *azureshared.MultiResourceGroupBase } // NewNetworkVirtualNetworkGatewayConnection creates a new networkVirtualNetworkGatewayConnectionWrapper instance. func NewNetworkVirtualNetworkGatewayConnection(client clients.VirtualNetworkGatewayConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkVirtualNetworkGatewayConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkVirtualNetworkGatewayConnection, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/network-gateway/virtual-network-gateway-connections/list func (c networkVirtualNetworkGatewayConnectionWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, conn := range page.Value { if conn.Name == nil { continue } item, sdpErr := c.azureVirtualNetworkGatewayConnectionToSDPItem(conn, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c networkVirtualNetworkGatewayConnectionWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, conn := range page.Value { if conn.Name == nil { continue } item, sdpErr := c.azureVirtualNetworkGatewayConnectionToSDPItem(conn, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/network-gateway/virtual-network-gateway-connections/get func (c networkVirtualNetworkGatewayConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the connection name"), scope, c.Type()) } connectionName := queryParts[0] if connectionName == "" { return nil, azureshared.QueryError(errors.New("connectionName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } result, err := c.client.Get(ctx, rgScope.ResourceGroup, connectionName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureVirtualNetworkGatewayConnectionToSDPItem(&result.VirtualNetworkGatewayConnection, scope) } func (c networkVirtualNetworkGatewayConnectionWrapper) azureVirtualNetworkGatewayConnectionToSDPItem(conn *armnetwork.VirtualNetworkGatewayConnection, scope string) (*sdp.Item, *sdp.QueryError) { if conn.Name == nil { return nil, azureshared.QueryError(errors.New("connection name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(conn, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkVirtualNetworkGatewayConnection.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(conn.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Health from provisioning state if conn.Properties != nil && conn.Properties.ProvisioningState != nil { switch *conn.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } if conn.Properties == nil { return sdpItem, nil } // VirtualNetworkGateway1 (required) if conn.Properties.VirtualNetworkGateway1 != nil && conn.Properties.VirtualNetworkGateway1.ID != nil { gwID := *conn.Properties.VirtualNetworkGateway1.ID gwName := azureshared.ExtractResourceName(gwID) if gwName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(gwID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkGateway.String(), Method: sdp.QueryMethod_GET, Query: gwName, Scope: linkedScope, }, }) } } // VirtualNetworkGateway2 (optional - for VNet-to-VNet connections) if conn.Properties.VirtualNetworkGateway2 != nil && conn.Properties.VirtualNetworkGateway2.ID != nil { gw2ID := *conn.Properties.VirtualNetworkGateway2.ID gw2Name := azureshared.ExtractResourceName(gw2ID) if gw2Name != "" { linkedScope := azureshared.ExtractScopeFromResourceID(gw2ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkGateway.String(), Method: sdp.QueryMethod_GET, Query: gw2Name, Scope: linkedScope, }, }) } } // LocalNetworkGateway2 (optional - for Site-to-Site connections) if conn.Properties.LocalNetworkGateway2 != nil && conn.Properties.LocalNetworkGateway2.ID != nil { lgwID := *conn.Properties.LocalNetworkGateway2.ID lgwName := azureshared.ExtractResourceName(lgwID) if lgwName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(lgwID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLocalNetworkGateway.String(), Method: sdp.QueryMethod_GET, Query: lgwName, Scope: linkedScope, }, }) } } // Peer (ExpressRoute circuit peering) // Path: expressRouteCircuits/{circuitName}/peerings/{peeringName} if conn.Properties.Peer != nil && conn.Properties.Peer.ID != nil { peerID := *conn.Properties.Peer.ID params := azureshared.ExtractPathParamsFromResourceID(peerID, []string{"expressRouteCircuits", "peerings"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(peerID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkExpressRouteCircuitPeering.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } // EgressNatRules (NAT rules for outbound traffic) // Path: virtualNetworkGateways/{gwName}/natRules/{ruleName} if conn.Properties.EgressNatRules != nil { for _, natRule := range conn.Properties.EgressNatRules { if natRule != nil && natRule.ID != nil { natRuleID := *natRule.ID params := azureshared.ExtractPathParamsFromResourceID(natRuleID, []string{"virtualNetworkGateways", "natRules"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(natRuleID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkGatewayNatRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } // IngressNatRules (NAT rules for inbound traffic) // Path: virtualNetworkGateways/{gwName}/natRules/{ruleName} if conn.Properties.IngressNatRules != nil { for _, natRule := range conn.Properties.IngressNatRules { if natRule != nil && natRule.ID != nil { natRuleID := *natRule.ID params := azureshared.ExtractPathParamsFromResourceID(natRuleID, []string{"virtualNetworkGateways", "natRules"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(natRuleID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkGatewayNatRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } // GatewayCustomBgpIPAddresses - link to custom BGP IP addresses and IP configurations if conn.Properties.GatewayCustomBgpIPAddresses != nil { for _, bgpConfig := range conn.Properties.GatewayCustomBgpIPAddresses { if bgpConfig == nil { continue } // Custom BGP IP address if bgpConfig.CustomBgpIPAddress != nil && *bgpConfig.CustomBgpIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *bgpConfig.CustomBgpIPAddress, Scope: "global", }, }) } // IPConfigurationID - reference to VirtualNetworkGateway IP configuration if bgpConfig.IPConfigurationID != nil && *bgpConfig.IPConfigurationID != "" { ipConfigID := *bgpConfig.IPConfigurationID params := azureshared.ExtractPathParamsFromResourceID(ipConfigID, []string{"virtualNetworkGateways", "ipConfigurations"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(ipConfigID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkGatewayIPConfiguration.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(params[0], params[1]), Scope: linkedScope, }, }) } } } } // TunnelProperties - tunnel IP addresses and BGP peering addresses if conn.Properties.TunnelProperties != nil { for _, tunnel := range conn.Properties.TunnelProperties { if tunnel == nil { continue } // Tunnel IP address if tunnel.TunnelIPAddress != nil && *tunnel.TunnelIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *tunnel.TunnelIPAddress, Scope: "global", }, }) } // BGP peering address if tunnel.BgpPeeringAddress != nil && *tunnel.BgpPeeringAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *tunnel.BgpPeeringAddress, Scope: "global", }, }) } } } return sdpItem, nil } func (c networkVirtualNetworkGatewayConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkVirtualNetworkGatewayConnectionLookupByName, } } func (c networkVirtualNetworkGatewayConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkVirtualNetworkGateway: true, azureshared.NetworkLocalNetworkGateway: true, azureshared.NetworkExpressRouteCircuitPeering: true, azureshared.NetworkVirtualNetworkGatewayNatRule: true, azureshared.NetworkVirtualNetworkGatewayIPConfiguration: true, stdlib.NetworkIP: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftnetwork func (c networkVirtualNetworkGatewayConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/connections/read", } } func (c networkVirtualNetworkGatewayConnectionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-virtual-network-gateway-connection_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkVirtualNetworkGatewayConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { connectionName := "test-vpn-connection" resource := createVirtualNetworkGatewayConnection(connectionName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworkGatewayConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, connectionName, nil).Return( armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse{ VirtualNetworkGatewayConnection: *resource, }, nil) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], connectionName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkVirtualNetworkGatewayConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkVirtualNetworkGatewayConnection.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != connectionName { t.Errorf("Expected unique attribute value %s, got %s", connectionName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkVirtualNetworkGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gateway1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkVirtualNetworkGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gateway2", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkLocalNetworkGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "local-gw", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkExpressRouteCircuitPeering.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "circuit1|peering1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkVirtualNetworkGatewayNatRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gateway1|egress-rule1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkVirtualNetworkGatewayNatRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gateway1|ingress-rule1", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { ExpectedType: azureshared.NetworkVirtualNetworkGatewayIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gateway1|default", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.2", ExpectedScope: "global", }, { ExpectedType: azureshared.NetworkVirtualNetworkGatewayIPConfiguration.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gateway2|default", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.1.1.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { resource1 := createVirtualNetworkGatewayConnectionMinimal("vpn-conn-1", subscriptionID, resourceGroup) resource2 := createVirtualNetworkGatewayConnectionMinimal("vpn-conn-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworkGatewayConnectionsClient(ctrl) mockPager := newMockVirtualNetworkGatewayConnectionsPager(ctrl, []*armnetwork.VirtualNetworkGatewayConnection{resource1, resource2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { resource1 := createVirtualNetworkGatewayConnectionMinimal("vpn-conn-1", subscriptionID, resourceGroup) resource2 := createVirtualNetworkGatewayConnectionMinimal("vpn-conn-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworkGatewayConnectionsClient(ctrl) mockPager := newMockVirtualNetworkGatewayConnectionsPager(ctrl, []*armnetwork.VirtualNetworkGatewayConnection{resource1, resource2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListWithNilName", func(t *testing.T) { resourceWithName := createVirtualNetworkGatewayConnectionMinimal("valid-conn", subscriptionID, resourceGroup) connTypeIPsec := armnetwork.VirtualNetworkGatewayConnectionTypeIPsec resourceWithNilName := &armnetwork.VirtualNetworkGatewayConnection{ Name: nil, Properties: &armnetwork.VirtualNetworkGatewayConnectionPropertiesFormat{ ConnectionType: &connTypeIPsec, VirtualNetworkGateway1: &armnetwork.VirtualNetworkGateway{ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway1")}, }, } mockClient := mocks.NewMockVirtualNetworkGatewayConnectionsClient(ctrl) mockPager := newMockVirtualNetworkGatewayConnectionsPager(ctrl, []*armnetwork.VirtualNetworkGatewayConnection{resourceWithNilName, resourceWithName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "valid-conn" { t.Errorf("Expected valid-conn, got %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("resource not found") mockClient := mocks.NewMockVirtualNetworkGatewayConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", nil).Return( armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent", true) if qErr == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkGatewayConnectionsClient(ctrl) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting resource with empty name, but got nil") } }) t.Run("HealthStatus", func(t *testing.T) { tests := []struct { name string state armnetwork.ProvisioningState expectedHealth sdp.Health }{ {"Succeeded", armnetwork.ProvisioningStateSucceeded, sdp.Health_HEALTH_OK}, {"Updating", armnetwork.ProvisioningStateUpdating, sdp.Health_HEALTH_PENDING}, {"Deleting", armnetwork.ProvisioningStateDeleting, sdp.Health_HEALTH_PENDING}, {"Failed", armnetwork.ProvisioningStateFailed, sdp.Health_HEALTH_ERROR}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { connTypeIPsec := armnetwork.VirtualNetworkGatewayConnectionTypeIPsec resource := &armnetwork.VirtualNetworkGatewayConnection{ Name: new("conn-" + tc.name), Location: new("eastus"), Type: new("Microsoft.Network/connections"), ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/connections/conn-" + tc.name), Properties: &armnetwork.VirtualNetworkGatewayConnectionPropertiesFormat{ ProvisioningState: &tc.state, ConnectionType: &connTypeIPsec, VirtualNetworkGateway1: &armnetwork.VirtualNetworkGateway{ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway1")}, }, } mockClient := mocks.NewMockVirtualNetworkGatewayConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "conn-"+tc.name, nil).Return( armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse{ VirtualNetworkGatewayConnection: *resource, }, nil) wrapper := manual.NewNetworkVirtualNetworkGatewayConnection(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "conn-"+tc.name, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expectedHealth { t.Errorf("Expected health %v, got %v", tc.expectedHealth, sdpItem.GetHealth()) } }) } }) } func createVirtualNetworkGatewayConnection(name, subscriptionID, resourceGroup string) *armnetwork.VirtualNetworkGatewayConnection { provisioningState := armnetwork.ProvisioningStateSucceeded connTypeVnet2Vnet := armnetwork.VirtualNetworkGatewayConnectionTypeVnet2Vnet return &armnetwork.VirtualNetworkGatewayConnection{ Name: new(name), Location: new("eastus"), Type: new("Microsoft.Network/connections"), ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/connections/" + name), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.VirtualNetworkGatewayConnectionPropertiesFormat{ ProvisioningState: &provisioningState, ConnectionType: &connTypeVnet2Vnet, VirtualNetworkGateway1: &armnetwork.VirtualNetworkGateway{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway1"), }, VirtualNetworkGateway2: &armnetwork.VirtualNetworkGateway{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway2"), }, LocalNetworkGateway2: &armnetwork.LocalNetworkGateway{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/localNetworkGateways/local-gw"), }, Peer: &armnetwork.SubResource{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/expressRouteCircuits/circuit1/peerings/peering1"), }, EgressNatRules: []*armnetwork.SubResource{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway1/natRules/egress-rule1"), }, }, IngressNatRules: []*armnetwork.SubResource{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway1/natRules/ingress-rule1"), }, }, GatewayCustomBgpIPAddresses: []*armnetwork.GatewayCustomBgpIPAddressIPConfiguration{ { CustomBgpIPAddress: new("10.0.0.1"), IPConfigurationID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway1/ipConfigurations/default"), }, { CustomBgpIPAddress: new("10.0.0.2"), IPConfigurationID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway2/ipConfigurations/default"), }, }, TunnelProperties: []*armnetwork.VirtualNetworkGatewayConnectionTunnelProperties{ { TunnelIPAddress: new("192.168.1.1"), BgpPeeringAddress: new("10.1.1.1"), }, }, }, } } func createVirtualNetworkGatewayConnectionMinimal(name, subscriptionID, resourceGroup string) *armnetwork.VirtualNetworkGatewayConnection { provisioningState := armnetwork.ProvisioningStateSucceeded connTypeIPsec := armnetwork.VirtualNetworkGatewayConnectionTypeIPsec return &armnetwork.VirtualNetworkGatewayConnection{ Name: new(name), Location: new("eastus"), Type: new("Microsoft.Network/connections"), ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/connections/" + name), Properties: &armnetwork.VirtualNetworkGatewayConnectionPropertiesFormat{ ProvisioningState: &provisioningState, ConnectionType: &connTypeIPsec, VirtualNetworkGateway1: &armnetwork.VirtualNetworkGateway{ ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/gateway1"), }, }, } } type mockVirtualNetworkGatewayConnectionsPager struct { ctrl *gomock.Controller items []*armnetwork.VirtualNetworkGatewayConnection index int more bool } func newMockVirtualNetworkGatewayConnectionsPager(ctrl *gomock.Controller, items []*armnetwork.VirtualNetworkGatewayConnection) clients.VirtualNetworkGatewayConnectionsPager { return &mockVirtualNetworkGatewayConnectionsPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockVirtualNetworkGatewayConnectionsPager) More() bool { return m.more } func (m *mockVirtualNetworkGatewayConnectionsPager) NextPage(ctx context.Context) (armnetwork.VirtualNetworkGatewayConnectionsClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.VirtualNetworkGatewayConnectionsClientListResponse{ VirtualNetworkGatewayConnectionListResult: armnetwork.VirtualNetworkGatewayConnectionListResult{ Value: []*armnetwork.VirtualNetworkGatewayConnection{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.VirtualNetworkGatewayConnectionsClientListResponse{ VirtualNetworkGatewayConnectionListResult: armnetwork.VirtualNetworkGatewayConnectionListResult{ Value: []*armnetwork.VirtualNetworkGatewayConnection{item}, }, }, nil } ================================================ FILE: sources/azure/manual/network-virtual-network-gateway.go ================================================ package manual import ( "context" "errors" "net" "net/url" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkVirtualNetworkGatewayLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkVirtualNetworkGateway) type networkVirtualNetworkGatewayWrapper struct { client clients.VirtualNetworkGatewaysClient *azureshared.MultiResourceGroupBase } // NewNetworkVirtualNetworkGateway creates a new networkVirtualNetworkGatewayWrapper instance. func NewNetworkVirtualNetworkGateway(client clients.VirtualNetworkGatewaysClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkVirtualNetworkGatewayWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkVirtualNetworkGateway, ), } } func (n networkVirtualNetworkGatewayWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, gw := range page.Value { if gw.Name == nil { continue } item, sdpErr := n.azureVirtualNetworkGatewayToSDPItem(gw, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkVirtualNetworkGatewayWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, gw := range page.Value { if gw.Name == nil { continue } item, sdpErr := n.azureVirtualNetworkGatewayToSDPItem(gw, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkVirtualNetworkGatewayWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 1 query part: virtualNetworkGatewayName", Scope: scope, ItemType: n.Type(), } } gatewayName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, gatewayName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureVirtualNetworkGatewayToSDPItem(&resp.VirtualNetworkGateway, scope) } func (n networkVirtualNetworkGatewayWrapper) azureVirtualNetworkGatewayToSDPItem(gw *armnetwork.VirtualNetworkGateway, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(gw, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } if gw.Name == nil { return nil, azureshared.QueryError(errors.New("virtual network gateway name is nil"), scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkVirtualNetworkGateway.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(gw.Tags), LinkedItemQueries: []*sdp.LinkedItemQuery{}, } // Health from provisioning state if gw.Properties != nil && gw.Properties.ProvisioningState != nil { switch *gw.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link from IP configurations: subnet, public IP, private IP if gw.Properties != nil && gw.Properties.IPConfigurations != nil { for _, ipConfig := range gw.Properties.IPConfigurations { if ipConfig == nil || ipConfig.Properties == nil { continue } // Subnet (SearchableWrapper: virtualNetworks/{vnet}/subnets/{subnet}) if ipConfig.Properties.Subnet != nil && ipConfig.Properties.Subnet.ID != nil { subnetID := *ipConfig.Properties.Subnet.ID params := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { linkedScope := azureshared.ExtractScopeFromResourceID(subnetID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Scope: linkedScope, Query: shared.CompositeLookupKey(params[0], params[1]), }, }) } } // Public IP address if ipConfig.Properties.PublicIPAddress != nil && ipConfig.Properties.PublicIPAddress.ID != nil { pubIPID := *ipConfig.Properties.PublicIPAddress.ID pubIPName := azureshared.ExtractResourceName(pubIPID) if pubIPName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(pubIPID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPublicIPAddress.String(), Method: sdp.QueryMethod_GET, Query: pubIPName, Scope: linkedScope, }, }) } } // Private IP address -> stdlib ip if ipConfig.Properties.PrivateIPAddress != nil && *ipConfig.Properties.PrivateIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipConfig.Properties.PrivateIPAddress, Scope: "global", }, }) } } } // Inbound DNS forwarding endpoint (read-only IP) if gw.Properties != nil && gw.Properties.InboundDNSForwardingEndpoint != nil && *gw.Properties.InboundDNSForwardingEndpoint != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *gw.Properties.InboundDNSForwardingEndpoint, Scope: "global", }, }) } // Gateway default site (Local Network Gateway) if gw.Properties != nil && gw.Properties.GatewayDefaultSite != nil && gw.Properties.GatewayDefaultSite.ID != nil { localGWID := *gw.Properties.GatewayDefaultSite.ID localGWName := azureshared.ExtractResourceName(localGWID) if localGWName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(localGWID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkLocalNetworkGateway.String(), Method: sdp.QueryMethod_GET, Query: localGWName, Scope: linkedScope, }, }) } } // Extended location (custom location) when Name is a custom location resource ID if gw.ExtendedLocation != nil && gw.ExtendedLocation.Name != nil { customLocationID := *gw.ExtendedLocation.Name if strings.Contains(customLocationID, "customLocations") { customLocationName := azureshared.ExtractResourceName(customLocationID) if customLocationName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(customLocationID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ExtendedLocationCustomLocation.String(), Method: sdp.QueryMethod_GET, Query: customLocationName, Scope: linkedScope, }, }) } } } // User-assigned managed identities (map keys are ARM resource IDs) if gw.Identity != nil && gw.Identity.UserAssignedIdentities != nil { for identityID := range gw.Identity.UserAssignedIdentities { if identityID == "" { continue } identityName := azureshared.ExtractResourceName(identityID) if identityName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(identityID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // VNet extended location resource (customer VNet when gateway type is local) if gw.Properties != nil && gw.Properties.VNetExtendedLocationResourceID != nil && *gw.Properties.VNetExtendedLocationResourceID != "" { vnetID := *gw.Properties.VNetExtendedLocationResourceID vnetName := azureshared.ExtractResourceName(vnetID) if vnetName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(vnetID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } // VPN client configuration: RADIUS server address(es) (IP or DNS) if gw.Properties != nil && gw.Properties.VPNClientConfiguration != nil { vpnCfg := gw.Properties.VPNClientConfiguration if vpnCfg.RadiusServerAddress != nil && *vpnCfg.RadiusServerAddress != "" { appendDNSServerLinkIfValid(&sdpItem.LinkedItemQueries, *vpnCfg.RadiusServerAddress) } if vpnCfg.RadiusServers != nil { for _, radiusServer := range vpnCfg.RadiusServers { if radiusServer != nil && radiusServer.RadiusServerAddress != nil && *radiusServer.RadiusServerAddress != "" { appendDNSServerLinkIfValid(&sdpItem.LinkedItemQueries, *radiusServer.RadiusServerAddress) } } } // AAD authentication URLs (e.g. https://login.microsoftonline.com/{tenant}/) — link DNS hostnames for _, s := range []*string{vpnCfg.AADTenant, vpnCfg.AADAudience, vpnCfg.AADIssuer} { if s == nil || *s == "" { continue } host := extractHostFromURLOrHostname(*s) if host == "" { continue } // Skip if it's an IP address; stdlib ip links are added elsewhere for IPs if net.ParseIP(host) != nil { continue } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: host, Scope: "global", }, }) } } // BGP settings: peering address and IP arrays if gw.Properties != nil && gw.Properties.BgpSettings != nil { bgp := gw.Properties.BgpSettings if bgp.BgpPeeringAddress != nil && *bgp.BgpPeeringAddress != "" { if net.ParseIP(*bgp.BgpPeeringAddress) != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *bgp.BgpPeeringAddress, Scope: "global", }, }) } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *bgp.BgpPeeringAddress, Scope: "global", }, }) } } if bgp.BgpPeeringAddresses != nil { for _, peeringAddr := range bgp.BgpPeeringAddresses { if peeringAddr == nil { continue } for _, ipStr := range peeringAddr.DefaultBgpIPAddresses { if ipStr != nil && *ipStr != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipStr, Scope: "global", }, }) } } for _, ipStr := range peeringAddr.CustomBgpIPAddresses { if ipStr != nil && *ipStr != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipStr, Scope: "global", }, }) } } for _, ipStr := range peeringAddr.TunnelIPAddresses { if ipStr != nil && *ipStr != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *ipStr, Scope: "global", }, }) } } } } } // Virtual Network Gateway Connections (child resource; list by parent gateway name) if gw.Name != nil && *gw.Name != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkGatewayConnection.String(), Method: sdp.QueryMethod_SEARCH, Scope: scope, Query: *gw.Name, }, }) } return sdpItem, nil } func (n networkVirtualNetworkGatewayWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkVirtualNetworkGatewayLookupByName, } } func (n networkVirtualNetworkGatewayWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.NetworkSubnet: true, azureshared.NetworkPublicIPAddress: true, azureshared.NetworkLocalNetworkGateway: true, azureshared.NetworkVirtualNetworkGatewayConnection: true, azureshared.ExtendedLocationCustomLocation: true, azureshared.ManagedIdentityUserAssignedIdentity: true, azureshared.NetworkVirtualNetwork: true, stdlib.NetworkIP: true, stdlib.NetworkDNS: true, } } func (n networkVirtualNetworkGatewayWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_virtual_network_gateway.name", }, } } func (n networkVirtualNetworkGatewayWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/virtualNetworkGateways/read", } } func extractHostFromURLOrHostname(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } u, err := url.Parse(s) if err != nil { return s } if u.Host != "" { return u.Hostname() } return s } func (n networkVirtualNetworkGatewayWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-virtual-network-gateway_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkVirtualNetworkGateway(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" scope := subscriptionID + "." + resourceGroup t.Run("Get", func(t *testing.T) { gatewayName := "test-gateway" gw := createAzureVirtualNetworkGateway(gatewayName) mockClient := mocks.NewMockVirtualNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, gatewayName, nil).Return( armnetwork.VirtualNetworkGatewaysClientGetResponse{ VirtualNetworkGateway: *gw, }, nil) wrapper := manual.NewNetworkVirtualNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, gatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkVirtualNetworkGateway.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkVirtualNetworkGateway.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != gatewayName { t.Errorf("Expected unique attribute value %s, got %s", gatewayName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkVirtualNetworkGatewayConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: gatewayName, ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithLinkedResources", func(t *testing.T) { gatewayName := "test-gateway-with-links" gw := createAzureVirtualNetworkGatewayWithLinks(gatewayName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, gatewayName, nil).Return( armnetwork.VirtualNetworkGatewaysClientGetResponse{ VirtualNetworkGateway: *gw, }, nil) wrapper := manual.NewNetworkVirtualNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, gatewayName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "GatewaySubnet"), ExpectedScope: scope, }, { ExpectedType: azureshared.NetworkPublicIPAddress.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-gateway-pip", ExpectedScope: scope, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.1.4", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.5", ExpectedScope: "global", }, { ExpectedType: azureshared.NetworkVirtualNetworkGatewayConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: gatewayName, ExpectedScope: scope, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "", nil).Return( armnetwork.VirtualNetworkGatewaysClientGetResponse{}, errors.New("virtual network gateway not found")) wrapper := manual.NewNetworkVirtualNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, "", true) if qErr == nil { t.Error("Expected error when getting gateway with empty name, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { gatewayName := "nonexistent-gateway" expectedErr := errors.New("virtual network gateway not found") mockClient := mocks.NewMockVirtualNetworkGatewaysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, gatewayName, nil).Return( armnetwork.VirtualNetworkGatewaysClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkVirtualNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, scope, gatewayName, true) if qErr == nil { t.Fatal("Expected error when gateway not found, got nil") } }) t.Run("List", func(t *testing.T) { gw1 := createAzureVirtualNetworkGateway("gateway-1") gw2 := createAzureVirtualNetworkGateway("gateway-2") mockClient := mocks.NewMockVirtualNetworkGatewaysClient(ctrl) mockPager := newMockVirtualNetworkGatewaysPager(ctrl, []*armnetwork.VirtualNetworkGateway{gw1, gw2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 2 { t.Fatalf("Expected 2 items, got %d", len(items)) } for i, item := range items { if item.GetType() != azureshared.NetworkVirtualNetworkGateway.String() { t.Errorf("Item %d: expected type %s, got %s", i, azureshared.NetworkVirtualNetworkGateway.String(), item.GetType()) } if item.Validate() != nil { t.Errorf("Item %d: validation error: %v", i, item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { gw1 := createAzureVirtualNetworkGateway("gateway-1") gw2 := createAzureVirtualNetworkGateway("gateway-2") mockClient := mocks.NewMockVirtualNetworkGatewaysClient(ctrl) mockPager := newMockVirtualNetworkGatewaysPager(ctrl, []*armnetwork.VirtualNetworkGateway{gw1, gw2}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listStream, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } var received []*sdp.Item stream := &collectingStream{items: &received} listStream.ListStream(ctx, scope, true, stream) if len(received) != 2 { t.Fatalf("Expected 2 items from stream, got %d", len(received)) } }) t.Run("List_NilNameSkipped", func(t *testing.T) { gw1 := createAzureVirtualNetworkGateway("gateway-1") gw2NilName := createAzureVirtualNetworkGateway("gateway-2") gw2NilName.Name = nil mockClient := mocks.NewMockVirtualNetworkGatewaysClient(ctrl) mockPager := newMockVirtualNetworkGatewaysPager(ctrl, []*armnetwork.VirtualNetworkGateway{gw1, gw2NilName}) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetworkGateway(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got %d", len(items)) } if items[0].UniqueAttributeValue() != "gateway-1" { t.Errorf("Expected only gateway-1, got %s", items[0].UniqueAttributeValue()) } }) t.Run("GetLookups", func(t *testing.T) { wrapper := manual.NewNetworkVirtualNetworkGateway(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) lookups := wrapper.GetLookups() if len(lookups) == 0 { t.Error("Expected GetLookups to return at least one lookup") } found := false for _, l := range lookups { if l.ItemType.String() == azureshared.NetworkVirtualNetworkGateway.String() { found = true break } } if !found { t.Error("Expected GetLookups to include NetworkVirtualNetworkGateway") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewNetworkVirtualNetworkGateway(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) potentialLinks := wrapper.PotentialLinks() for _, linkType := range []shared.ItemType{ azureshared.NetworkSubnet, azureshared.NetworkPublicIPAddress, azureshared.NetworkLocalNetworkGateway, azureshared.NetworkVirtualNetworkGatewayConnection, azureshared.ExtendedLocationCustomLocation, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.NetworkVirtualNetwork, stdlib.NetworkIP, stdlib.NetworkDNS, } { if !potentialLinks[linkType] { t.Errorf("Expected PotentialLinks to include %s", linkType) } } }) } type collectingStream struct { items *[]*sdp.Item } func (c *collectingStream) SendItem(item *sdp.Item) { *c.items = append(*c.items, item) } func (c *collectingStream) SendError(err error) {} type mockVirtualNetworkGatewaysPager struct { ctrl *gomock.Controller items []*armnetwork.VirtualNetworkGateway index int more bool } func newMockVirtualNetworkGatewaysPager(ctrl *gomock.Controller, items []*armnetwork.VirtualNetworkGateway) *mockVirtualNetworkGatewaysPager { return &mockVirtualNetworkGatewaysPager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockVirtualNetworkGatewaysPager) More() bool { return m.more } func (m *mockVirtualNetworkGatewaysPager) NextPage(ctx context.Context) (armnetwork.VirtualNetworkGatewaysClientListResponse, error) { if m.index >= len(m.items) { m.more = false return armnetwork.VirtualNetworkGatewaysClientListResponse{ VirtualNetworkGatewayListResult: armnetwork.VirtualNetworkGatewayListResult{ Value: []*armnetwork.VirtualNetworkGateway{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armnetwork.VirtualNetworkGatewaysClientListResponse{ VirtualNetworkGatewayListResult: armnetwork.VirtualNetworkGatewayListResult{ Value: []*armnetwork.VirtualNetworkGateway{item}, }, }, nil } func createAzureVirtualNetworkGateway(name string) *armnetwork.VirtualNetworkGateway { provisioningState := armnetwork.ProvisioningStateSucceeded gatewayType := armnetwork.VirtualNetworkGatewayTypeVPN vpnType := armnetwork.VPNTypeRouteBased return &armnetwork.VirtualNetworkGateway{ ID: new("/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworkGateways/" + name), Name: new(name), Type: new("Microsoft.Network/virtualNetworkGateways"), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.VirtualNetworkGatewayPropertiesFormat{ ProvisioningState: &provisioningState, GatewayType: &gatewayType, VPNType: &vpnType, }, } } func createAzureVirtualNetworkGatewayWithLinks(name, subscriptionID, resourceGroup string) *armnetwork.VirtualNetworkGateway { gw := createAzureVirtualNetworkGateway(name) subnetID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/GatewaySubnet" publicIPID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/publicIPAddresses/test-gateway-pip" privateIP := "10.0.1.4" inboundDNS := "10.0.0.5" gw.Properties.IPConfigurations = []*armnetwork.VirtualNetworkGatewayIPConfiguration{ { ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/virtualNetworkGateways/" + name + "/ipConfigurations/default"), Name: new("default"), Properties: &armnetwork.VirtualNetworkGatewayIPConfigurationPropertiesFormat{ Subnet: &armnetwork.SubResource{ ID: new(subnetID), }, PublicIPAddress: &armnetwork.SubResource{ ID: new(publicIPID), }, PrivateIPAddress: &privateIP, }, }, } gw.Properties.InboundDNSForwardingEndpoint = &inboundDNS return gw } ================================================ FILE: sources/azure/manual/network-virtual-network-peering.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var NetworkVirtualNetworkPeeringLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkVirtualNetworkPeering) type networkVirtualNetworkPeeringWrapper struct { client clients.VirtualNetworkPeeringsClient *azureshared.MultiResourceGroupBase } // NewNetworkVirtualNetworkPeering creates a new networkVirtualNetworkPeeringWrapper instance (SearchableWrapper: child of virtual network). func NewNetworkVirtualNetworkPeering(client clients.VirtualNetworkPeeringsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &networkVirtualNetworkPeeringWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkVirtualNetworkPeering, ), } } func (n networkVirtualNetworkPeeringWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: virtualNetworkName and peeringName", Scope: scope, ItemType: n.Type(), } } virtualNetworkName := queryParts[0] peeringName := queryParts[1] if peeringName == "" { return nil, azureshared.QueryError(errors.New("peering name cannot be empty"), scope, n.Type()) } rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, virtualNetworkName, peeringName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureVirtualNetworkPeeringToSDPItem(&resp.VirtualNetworkPeering, virtualNetworkName, peeringName, scope) } func (n networkVirtualNetworkPeeringWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkVirtualNetworkLookupByName, NetworkVirtualNetworkPeeringLookupByUniqueAttr, } } func (n networkVirtualNetworkPeeringWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: virtualNetworkName", Scope: scope, ItemType: n.Type(), } } virtualNetworkName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, virtualNetworkName, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, peering := range page.Value { if peering == nil || peering.Name == nil { continue } item, sdpErr := n.azureVirtualNetworkPeeringToSDPItem(peering, virtualNetworkName, *peering.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkVirtualNetworkPeeringWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: virtualNetworkName"), scope, n.Type())) return } virtualNetworkName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, virtualNetworkName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, peering := range page.Value { if peering == nil || peering.Name == nil { continue } item, sdpErr := n.azureVirtualNetworkPeeringToSDPItem(peering, virtualNetworkName, *peering.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkVirtualNetworkPeeringWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { NetworkVirtualNetworkLookupByName, }, } } func (n networkVirtualNetworkPeeringWrapper) azureVirtualNetworkPeeringToSDPItem(peering *armnetwork.VirtualNetworkPeering, virtualNetworkName, peeringName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(peering, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(virtualNetworkName, peeringName)) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkVirtualNetworkPeering.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health status from ProvisioningState if peering.Properties != nil && peering.Properties.ProvisioningState != nil { switch *peering.Properties.ProvisioningState { case armnetwork.ProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armnetwork.ProvisioningStateCreating, armnetwork.ProvisioningStateUpdating, armnetwork.ProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armnetwork.ProvisioningStateFailed, armnetwork.ProvisioningStateCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } } // Link to parent (local) Virtual Network sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: virtualNetworkName, Scope: scope, }, }) // Link to remote Virtual Network and remote subnets (selective peering) if peering.Properties != nil && peering.Properties.RemoteVirtualNetwork != nil && peering.Properties.RemoteVirtualNetwork.ID != nil { remoteVNetID := *peering.Properties.RemoteVirtualNetwork.ID remoteVNetName := azureshared.ExtractResourceName(remoteVNetID) if remoteVNetName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(remoteVNetID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: remoteVNetName, Scope: linkedScope, }, }) // Link to remote subnets (selective subnet peering) if peering.Properties.RemoteSubnetNames != nil { for _, name := range peering.Properties.RemoteSubnetNames { if name != nil && *name != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(remoteVNetName, *name), Scope: linkedScope, }, }) } } } } } // Link to local subnets (selective subnet peering) if peering.Properties != nil && peering.Properties.LocalSubnetNames != nil { for _, name := range peering.Properties.LocalSubnetNames { if name != nil && *name != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(virtualNetworkName, *name), Scope: scope, }, }) } } } return sdpItem, nil } func (n networkVirtualNetworkPeeringWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkVirtualNetwork, azureshared.NetworkSubnet, ) } // ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network_peering func (n networkVirtualNetworkPeeringWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_virtual_network_peering.id", }, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions-reference#microsoftnetwork func (n networkVirtualNetworkPeeringWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/virtualNetworks/virtualNetworkPeerings/read", } } func (n networkVirtualNetworkPeeringWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-virtual-network-peering_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" sdp "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockVirtualNetworkPeeringsPager struct { pages []armnetwork.VirtualNetworkPeeringsClientListResponse index int } func (m *mockVirtualNetworkPeeringsPager) More() bool { return m.index < len(m.pages) } func (m *mockVirtualNetworkPeeringsPager) NextPage(ctx context.Context) (armnetwork.VirtualNetworkPeeringsClientListResponse, error) { if m.index >= len(m.pages) { return armnetwork.VirtualNetworkPeeringsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorVirtualNetworkPeeringsPager struct{} func (e *errorVirtualNetworkPeeringsPager) More() bool { return true } func (e *errorVirtualNetworkPeeringsPager) NextPage(ctx context.Context) (armnetwork.VirtualNetworkPeeringsClientListResponse, error) { return armnetwork.VirtualNetworkPeeringsClientListResponse{}, errors.New("pager error") } type testVirtualNetworkPeeringsClient struct { *mocks.MockVirtualNetworkPeeringsClient pager clients.VirtualNetworkPeeringsPager } func (t *testVirtualNetworkPeeringsClient) NewListPager(resourceGroupName, virtualNetworkName string, options *armnetwork.VirtualNetworkPeeringsClientListOptions) clients.VirtualNetworkPeeringsPager { return t.pager } func TestNetworkVirtualNetworkPeering(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" virtualNetworkName := "test-vnet" peeringName := "test-peering" t.Run("Get", func(t *testing.T) { peering := createAzureVirtualNetworkPeering(peeringName, virtualNetworkName) mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, virtualNetworkName, peeringName, nil).Return( armnetwork.VirtualNetworkPeeringsClientGetResponse{ VirtualNetworkPeering: *peering, }, nil) testClient := &testVirtualNetworkPeeringsClient{MockVirtualNetworkPeeringsClient: mockClient} wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(virtualNetworkName, peeringName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkVirtualNetworkPeering.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkVirtualNetworkPeering, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(virtualNetworkName, peeringName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(virtualNetworkName, peeringName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: virtualNetworkName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_EmptyPeeringName", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) testClient := &testVirtualNetworkPeeringsClient{MockVirtualNetworkPeeringsClient: mockClient} wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(virtualNetworkName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when peering name is empty, but got nil") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) testClient := &testVirtualNetworkPeeringsClient{MockVirtualNetworkPeeringsClient: mockClient} wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { peering1 := createAzureVirtualNetworkPeering("peering-1", virtualNetworkName) peering2 := createAzureVirtualNetworkPeering("peering-2", virtualNetworkName) mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) mockPager := &mockVirtualNetworkPeeringsPager{ pages: []armnetwork.VirtualNetworkPeeringsClientListResponse{ { VirtualNetworkPeeringListResult: armnetwork.VirtualNetworkPeeringListResult{ Value: []*armnetwork.VirtualNetworkPeering{peering1, peering2}, }, }, }, } testClient := &testVirtualNetworkPeeringsClient{ MockVirtualNetworkPeeringsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.NetworkVirtualNetworkPeering.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkVirtualNetworkPeering, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) testClient := &testVirtualNetworkPeeringsClient{MockVirtualNetworkPeeringsClient: mockClient} wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_PeeringWithNilName", func(t *testing.T) { validPeering := createAzureVirtualNetworkPeering("valid-peering", virtualNetworkName) mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) mockPager := &mockVirtualNetworkPeeringsPager{ pages: []armnetwork.VirtualNetworkPeeringsClientListResponse{ { VirtualNetworkPeeringListResult: armnetwork.VirtualNetworkPeeringListResult{ Value: []*armnetwork.VirtualNetworkPeering{ {Name: nil, ID: new("/some/id")}, validPeering, }, }, }, }, } testClient := &testVirtualNetworkPeeringsClient{ MockVirtualNetworkPeeringsClient: mockClient, pager: mockPager, } wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(virtualNetworkName, "valid-peering") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(virtualNetworkName, "valid-peering"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("peering not found") mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, virtualNetworkName, "nonexistent-peering", nil).Return( armnetwork.VirtualNetworkPeeringsClientGetResponse{}, expectedErr) testClient := &testVirtualNetworkPeeringsClient{MockVirtualNetworkPeeringsClient: mockClient} wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(virtualNetworkName, "nonexistent-peering") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent peering, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworkPeeringsClient(ctrl) testClient := &testVirtualNetworkPeeringsClient{ MockVirtualNetworkPeeringsClient: mockClient, pager: &errorVirtualNetworkPeeringsPager{}, } wrapper := manual.NewNetworkVirtualNetworkPeering(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) _, err := searchable.Search(ctx, wrapper.Scopes()[0], virtualNetworkName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } func createAzureVirtualNetworkPeering(peeringName, vnetName string) *armnetwork.VirtualNetworkPeering { idStr := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/" + vnetName + "/virtualNetworkPeerings/" + peeringName typeStr := "Microsoft.Network/virtualNetworks/virtualNetworkPeerings" provisioningState := armnetwork.ProvisioningStateSucceeded return &armnetwork.VirtualNetworkPeering{ ID: &idStr, Name: &peeringName, Type: &typeStr, Properties: &armnetwork.VirtualNetworkPeeringPropertiesFormat{ ProvisioningState: &provisioningState, }, } } ================================================ FILE: sources/azure/manual/network-virtual-network.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkVirtualNetworkLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkVirtualNetwork) type networkVirtualNetworkWrapper struct { client clients.VirtualNetworksClient *azureshared.MultiResourceGroupBase } // NewNetworkVirtualNetwork creates a new networkVirtualNetworkWrapper instance func NewNetworkVirtualNetwork(client clients.VirtualNetworksClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkVirtualNetworkWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkVirtualNetwork, ), } } func (n networkVirtualNetworkWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, network := range page.Value { item, sdpErr := n.azureVirtualNetworkToSDPItem(network, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (n networkVirtualNetworkWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, network := range page.Value { if network.Name == nil { continue } item, sdpErr := n.azureVirtualNetworkToSDPItem(network, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkVirtualNetworkWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 1 query part: virtualNetworkName", Scope: scope, ItemType: n.Type(), } } virtualNetworkName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } resp, err := n.client.Get(ctx, rgScope.ResourceGroup, virtualNetworkName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureVirtualNetworkToSDPItem(&resp.VirtualNetwork, scope) } func (n networkVirtualNetworkWrapper) azureVirtualNetworkToSDPItem(network *armnetwork.VirtualNetwork, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(network) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } if network.Name == nil { return nil, azureshared.QueryError(errors.New("network name is nil"), scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkVirtualNetwork.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(network.Tags), } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_SEARCH, Scope: scope, Query: *network.Name, // List subnets in the virtual network }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetworkPeering.String(), Method: sdp.QueryMethod_SEARCH, Scope: scope, Query: *network.Name, // List virtual network peerings in the virtual network }, }) // Link to DDoS protection plan // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/ddos-protection-plans/get if network.Properties != nil && network.Properties.DdosProtectionPlan != nil && network.Properties.DdosProtectionPlan.ID != nil { ddosPlanID := *network.Properties.DdosProtectionPlan.ID ddosPlanName := azureshared.ExtractResourceName(ddosPlanID) if ddosPlanName != "" { scope := n.DefaultScope() // Check if DDoS protection plan is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(ddosPlanID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkDdosProtectionPlan.String(), Method: sdp.QueryMethod_GET, Query: ddosPlanName, Scope: scope, }, }) } } // Link to resources from subnets if network.Properties != nil && network.Properties.Subnets != nil { for _, subnet := range network.Properties.Subnets { if subnet == nil || subnet.Properties == nil { continue } // Link to Network Security Group from subnet // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get if subnet.Properties.NetworkSecurityGroup != nil && subnet.Properties.NetworkSecurityGroup.ID != nil { nsgID := *subnet.Properties.NetworkSecurityGroup.ID nsgName := azureshared.ExtractResourceName(nsgID) if nsgName != "" { scope := n.DefaultScope() // Check if NSG is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(nsgID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNetworkSecurityGroup.String(), Method: sdp.QueryMethod_GET, Query: nsgName, Scope: scope, }, }) } } // Link to Route Table from subnet // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/route-tables/get if subnet.Properties.RouteTable != nil && subnet.Properties.RouteTable.ID != nil { routeTableID := *subnet.Properties.RouteTable.ID routeTableName := azureshared.ExtractResourceName(routeTableID) if routeTableName != "" { scope := n.DefaultScope() // Check if Route Table is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(routeTableID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkRouteTable.String(), Method: sdp.QueryMethod_GET, Query: routeTableName, Scope: scope, }, }) } } // Link to NAT Gateway from subnet // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/nat-gateways/get if subnet.Properties.NatGateway != nil && subnet.Properties.NatGateway.ID != nil { natGatewayID := *subnet.Properties.NatGateway.ID natGatewayName := azureshared.ExtractResourceName(natGatewayID) if natGatewayName != "" { scope := n.DefaultScope() // Check if NAT Gateway is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(natGatewayID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNatGateway.String(), Method: sdp.QueryMethod_GET, Query: natGatewayName, Scope: scope, }, }) } } // Link to Private Endpoints from subnet (read-only references) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get if subnet.Properties.PrivateEndpoints != nil { for _, privateEndpoint := range subnet.Properties.PrivateEndpoints { if privateEndpoint != nil && privateEndpoint.ID != nil { privateEndpointID := *privateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { scope := n.DefaultScope() // Check if Private Endpoint is in a different resource group if extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: scope, }, }) } } } } } } // Link to remote Virtual Networks from peerings // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/virtual-networks/get if network.Properties != nil && network.Properties.VirtualNetworkPeerings != nil { for _, peering := range network.Properties.VirtualNetworkPeerings { if peering != nil && peering.Properties != nil && peering.Properties.RemoteVirtualNetwork != nil && peering.Properties.RemoteVirtualNetwork.ID != nil { remoteVNetID := *peering.Properties.RemoteVirtualNetwork.ID remoteVNetName := azureshared.ExtractResourceName(remoteVNetID) if remoteVNetName != "" { scope := n.DefaultScope() // Check if remote Virtual Network is in a different resource group or subscription if extractedScope := azureshared.ExtractScopeFromResourceID(remoteVNetID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: remoteVNetName, Scope: scope, }, }) } } } } // Link to default public NAT Gateway (VNet-level) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/nat-gateways/get if network.Properties != nil && network.Properties.DefaultPublicNatGateway != nil && network.Properties.DefaultPublicNatGateway.ID != nil { natGatewayID := *network.Properties.DefaultPublicNatGateway.ID natGatewayName := azureshared.ExtractResourceName(natGatewayID) if natGatewayName != "" { scope := n.DefaultScope() if extractedScope := azureshared.ExtractScopeFromResourceID(natGatewayID); extractedScope != "" { scope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkNatGateway.String(), Method: sdp.QueryMethod_GET, Query: natGatewayName, Scope: scope, }, }) } } // Link DHCP DNS servers to stdlib ip (IP addresses) or stdlib dns (hostnames) // Reference: DhcpOptions contains DNS servers available to VMs in the VNet if network.Properties != nil && network.Properties.DhcpOptions != nil && network.Properties.DhcpOptions.DNSServers != nil { for _, dnsServerPtr := range network.Properties.DhcpOptions.DNSServers { if dnsServerPtr == nil { continue } appendDNSServerLinkIfValid(&sdpItem.LinkedItemQueries, *dnsServerPtr, "AzureProvidedDNS") } } return sdpItem, nil } func (n networkVirtualNetworkWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkVirtualNetworkLookupByName, } } func (n networkVirtualNetworkWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkSubnet, azureshared.NetworkVirtualNetworkPeering, azureshared.NetworkDdosProtectionPlan, azureshared.NetworkNatGateway, azureshared.NetworkNetworkSecurityGroup, azureshared.NetworkRouteTable, azureshared.NetworkPrivateEndpoint, azureshared.NetworkVirtualNetwork, stdlib.NetworkIP, stdlib.NetworkDNS, ) } func (n networkVirtualNetworkWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network TerraformQueryMap: "azurerm_virtual_network.name", }, } } func (n networkVirtualNetworkWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/virtualNetworks/read", } } func (n networkVirtualNetworkWrapper) PredefinedRole() string { return "Reader" // there is no predefined role for virtual networks, so we use the most restrictive role (Reader) } ================================================ FILE: sources/azure/manual/network-virtual-network_test.go ================================================ package manual_test import ( "context" "errors" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkVirtualNetwork(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { vnetName := "test-vnet" vnet := createAzureVirtualNetwork(vnetName) mockClient := mocks.NewMockVirtualNetworksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vnetName, nil).Return( armnetwork.VirtualNetworksClientGetResponse{ VirtualNetwork: *vnet, }, nil) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vnetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkVirtualNetwork.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkVirtualNetwork, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != vnetName { t.Errorf("Expected unique attribute value %s, got %s", vnetName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // NetworkSubnet link ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vnetName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // NetworkVirtualNetworkPeering link ExpectedType: azureshared.NetworkVirtualNetworkPeering.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vnetName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithDefaultPublicNatGatewayAndDhcpOptions", func(t *testing.T) { vnetName := "test-vnet-with-links" vnet := createAzureVirtualNetworkWithDefaultNatGatewayAndDhcpOptions(vnetName, subscriptionID, resourceGroup) mockClient := mocks.NewMockVirtualNetworksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, vnetName, nil).Return( armnetwork.VirtualNetworksClientGetResponse{ VirtualNetwork: *vnet, }, nil) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], vnetName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vnetName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkVirtualNetworkPeering.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: vnetName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkNatGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-nat-gateway", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "dns.internal", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworksClient(ctrl) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty string name - Get will still be called with empty string // and Azure will return an error mockClient.EXPECT().Get(ctx, resourceGroup, "", nil).Return( armnetwork.VirtualNetworksClientGetResponse{}, errors.New("virtual network not found")) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting virtual network with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { vnet1 := createAzureVirtualNetwork("test-vnet-1") vnet2 := createAzureVirtualNetwork("test-vnet-2") mockClient := mocks.NewMockVirtualNetworksClient(ctrl) mockPager := NewMockVirtualNetworksPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.VirtualNetworksClientListResponse{ VirtualNetworkListResult: armnetwork.VirtualNetworkListResult{ Value: []*armnetwork.VirtualNetwork{vnet1, vnet2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkVirtualNetwork.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkVirtualNetwork, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Create vnet with nil name to test error handling vnet1 := createAzureVirtualNetwork("test-vnet-1") vnet2 := &armnetwork.VirtualNetwork{ Name: nil, // VNet with nil name should cause an error in azureVirtualNetworkToSDPItem Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.0.0.0/16")}, }, }, } mockClient := mocks.NewMockVirtualNetworksClient(ctrl) mockPager := NewMockVirtualNetworksPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.VirtualNetworksClientListResponse{ VirtualNetworkListResult: armnetwork.VirtualNetworkListResult{ Value: []*armnetwork.VirtualNetwork{vnet1, vnet2}, }, }, nil), ) // Note: More() won't be called again after NextPage returns the items with nil name // because azureVirtualNetworkToSDPItem will return an error mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) // Should return an error because vnet2 has nil name if err == nil { t.Fatalf("Expected error when listing virtual networks with nil name, but got nil") } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("virtual network not found") mockClient := mocks.NewMockVirtualNetworksClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-vnet", nil).Return( armnetwork.VirtualNetworksClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-vnet", true) if qErr == nil { t.Error("Expected error when getting non-existent virtual network, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { expectedErr := errors.New("failed to list virtual networks") mockClient := mocks.NewMockVirtualNetworksClient(ctrl) mockPager := NewMockVirtualNetworksPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armnetwork.VirtualNetworksClientListResponse{}, expectedErr), ) mockClient.EXPECT().NewListPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing virtual networks fails, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockVirtualNetworksClient(ctrl) wrapper := manual.NewNetworkVirtualNetwork(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements ListableWrapper interface _ = wrapper // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/virtualNetworks/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.NetworkSubnet] { t.Error("Expected PotentialLinks to include NetworkSubnet") } if !potentialLinks[azureshared.NetworkVirtualNetworkPeering] { t.Error("Expected PotentialLinks to include NetworkVirtualNetworkPeering") } if !potentialLinks[stdlib.NetworkIP] { t.Error("Expected PotentialLinks to include stdlib.NetworkIP") } if !potentialLinks[stdlib.NetworkDNS] { t.Error("Expected PotentialLinks to include stdlib.NetworkDNS") } // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_virtual_network.name" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected TerraformMethod to be GET for name mapping, got %s", mapping.GetTerraformMethod()) } } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_virtual_network.name' mapping") } }) } // MockVirtualNetworksPager is a simple mock for VirtualNetworksPager type MockVirtualNetworksPager struct { ctrl *gomock.Controller recorder *MockVirtualNetworksPagerMockRecorder } type MockVirtualNetworksPagerMockRecorder struct { mock *MockVirtualNetworksPager } func NewMockVirtualNetworksPager(ctrl *gomock.Controller) *MockVirtualNetworksPager { mock := &MockVirtualNetworksPager{ctrl: ctrl} mock.recorder = &MockVirtualNetworksPagerMockRecorder{mock} return mock } func (m *MockVirtualNetworksPager) EXPECT() *MockVirtualNetworksPagerMockRecorder { return m.recorder } func (m *MockVirtualNetworksPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockVirtualNetworksPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockVirtualNetworksPager) NextPage(ctx context.Context) (armnetwork.VirtualNetworksClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armnetwork.VirtualNetworksClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockVirtualNetworksPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armnetwork.VirtualNetworksClientListResponse, error)](), ctx) } // createAzureVirtualNetwork creates a mock Azure virtual network for testing func createAzureVirtualNetwork(vnetName string) *armnetwork.VirtualNetwork { return &armnetwork.VirtualNetwork{ Name: new(vnetName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.0.0.0/16")}, }, Subnets: []*armnetwork.Subnet{ { Name: new("default"), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.0.0.0/24"), }, }, }, }, } } // createAzureVirtualNetworkWithDefaultNatGatewayAndDhcpOptions creates a VNet with // DefaultPublicNatGateway and DhcpOptions.DNSServers (IP and hostname) for testing linked queries. func createAzureVirtualNetworkWithDefaultNatGatewayAndDhcpOptions(vnetName, subscriptionID, resourceGroup string) *armnetwork.VirtualNetwork { natGatewayID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/natGateways/test-nat-gateway" return &armnetwork.VirtualNetwork{ Name: new(vnetName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armnetwork.VirtualNetworkPropertiesFormat{ AddressSpace: &armnetwork.AddressSpace{ AddressPrefixes: []*string{new("10.0.0.0/16")}, }, DefaultPublicNatGateway: &armnetwork.SubResource{ ID: new(natGatewayID), }, DhcpOptions: &armnetwork.DhcpOptions{ DNSServers: []*string{ new("10.0.0.1"), // IP address → stdlib.NetworkIP new("dns.internal"), // hostname → stdlib.NetworkDNS }, }, Subnets: []*armnetwork.Subnet{ { Name: new("default"), Properties: &armnetwork.SubnetPropertiesFormat{ AddressPrefix: new("10.0.0.0/24"), }, }, }, }, } } ================================================ FILE: sources/azure/manual/network-zone.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var NetworkZoneLookupByName = shared.NewItemTypeLookup("name", azureshared.NetworkZone) type networkZoneWrapper struct { client clients.ZonesClient *azureshared.MultiResourceGroupBase } func NewNetworkZone(client clients.ZonesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &networkZoneWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, azureshared.NetworkZone, ), } } func (n networkZoneWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } pager := n.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } for _, zone := range page.Value { if zone.Name == nil { continue } item, sdpErr := n.azureZoneToSDPItem(zone, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } // ref: https://learn.microsoft.com/en-us/rest/api/dns/zones/list-by-resource-group?view=rest-dns-2018-05-01&tabs=HTTP func (n networkZoneWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } pager := n.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, n.Type())) return } for _, zone := range page.Value { if zone.Name == nil { continue } item, sdpErr := n.azureZoneToSDPItem(zone, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (n networkZoneWrapper) azureZoneToSDPItem(zone *armdns.Zone, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(zone, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } if zone.Name == nil { return nil, azureshared.QueryError(errors.New("zone name is nil"), scope, n.Type()) } sdpItem := &sdp.Item{ Type: azureshared.NetworkZone.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(zone.Tags), } zoneName := *zone.Name // Link to DNS name (standard library) for the zone name itself // The zone name is a DNS name and should be linked to verify proper delegation and show the public DNS view if zoneName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: zoneName, Scope: "global", }, }) } // Link to Virtual Networks from RegistrationVirtualNetworks (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/virtual-networks/get if zone.Properties != nil && zone.Properties.RegistrationVirtualNetworks != nil { for _, vnetRef := range zone.Properties.RegistrationVirtualNetworks { if vnetRef != nil && vnetRef.ID != nil { vnetName := azureshared.ExtractResourceName(*vnetRef.ID) if vnetName != "" { // Extract subscription ID and resource group from the resource ID to determine scope linkedScope := azureshared.ExtractScopeFromResourceID(*vnetRef.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } } } // Link to Virtual Networks from ResolutionVirtualNetworks (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/virtual-networks/get if zone.Properties != nil && zone.Properties.ResolutionVirtualNetworks != nil { for _, vnetRef := range zone.Properties.ResolutionVirtualNetworks { if vnetRef != nil && vnetRef.ID != nil { vnetName := azureshared.ExtractResourceName(*vnetRef.ID) if vnetName != "" { // Extract subscription ID and resource group from the resource ID to determine scope linkedScope := azureshared.ExtractScopeFromResourceID(*vnetRef.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: linkedScope, }, }) } } } } // Link to DNS Record Sets (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/dns/record-sets/list-by-dns-zone // Record sets can be listed by zone name, so we use SEARCH method // The zone name is available, which is sufficient to list record sets sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkDNSRecordSet.String(), Method: sdp.QueryMethod_SEARCH, Query: zoneName, Scope: scope, }, }) // Link to DNS names (standard library) from NameServers // Reference: DNS name servers are external resources if zone.Properties != nil && zone.Properties.NameServers != nil { for _, nameServer := range zone.Properties.NameServers { if nameServer != nil && *nameServer != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *nameServer, Scope: "global", }, }) } } } return sdpItem, nil } // ref: https://learn.microsoft.com/en-us/rest/api/dns/zones/get?view=rest-dns-2018-05-01&tabs=HTTP func (n networkZoneWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("query must be exactly one part and be a zone name"), scope, n.Type()) } zoneName := queryParts[0] rgScope, err := n.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } zone, err := n.client.Get(ctx, rgScope.ResourceGroup, zoneName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, n.Type()) } return n.azureZoneToSDPItem(&zone.Zone, scope) } func (n networkZoneWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ NetworkZoneLookupByName, } } // ref https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/dns_zone func (n networkZoneWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_dns_zone.name", }, } } func (n networkZoneWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.NetworkVirtualNetwork, azureshared.NetworkDNSRecordSet, stdlib.NetworkDNS, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/permissions/networking func (n networkZoneWrapper) IAMPermissions() []string { return []string{ "Microsoft.Network/dnszones/read", } } func (n networkZoneWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/network-zone_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "reflect" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestNetworkZone(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { zoneName := "example.com" zone := createAzureZone(zoneName, subscriptionID, resourceGroup) mockClient := mocks.NewMockZonesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, zoneName, nil).Return( armdns.ZonesClientGetResponse{ Zone: *zone, }, nil) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.NetworkZone.String() { t.Errorf("Expected type %s, got %s", azureshared.NetworkZone, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != zoneName { t.Errorf("Expected unique attribute value %s, got %s", zoneName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // DNS name for the zone itself (standard library) ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: zoneName, ExpectedScope: "global", }, { // Virtual Network from RegistrationVirtualNetworks ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-reg-vnet", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // Virtual Network from ResolutionVirtualNetworks ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-res-vnet", ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // DNS Record Set (child resource) ExpectedType: azureshared.NetworkDNSRecordSet.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: zoneName, ExpectedScope: fmt.Sprintf("%s.%s", subscriptionID, resourceGroup), }, { // DNS name server (standard library) ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ns1.example.com", ExpectedScope: "global", }, { // DNS name server (standard library) ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "ns2.example.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockZonesClient(ctrl) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty name - the client will be called but will return an error // or we can test by not setting up expectations and letting it fail // Actually, the wrapper validates len(queryParts) < 1, so we need to test that // But adapter.Get takes a single query string, so we can't test empty queryParts // Let's test with a zone that has nil name which will cause an error zoneWithNilName := &armdns.Zone{ Name: nil, Location: new("eastus"), Properties: &armdns.ZoneProperties{}, } mockClient.EXPECT().Get(ctx, resourceGroup, "test-zone", nil).Return( armdns.ZonesClientGetResponse{ Zone: *zoneWithNilName, }, nil) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-zone", true) if qErr == nil { t.Error("Expected error when zone has nil name, but got nil") } }) t.Run("Get_DifferentScopeVirtualNetwork", func(t *testing.T) { // Test that Virtual Network with different subscription/resource group uses correct scope zoneName := "example.com" otherSubscriptionID := "other-sub" otherResourceGroup := "other-rg" zone := createAzureZoneWithDifferentScopeVNet(zoneName, subscriptionID, resourceGroup, otherSubscriptionID, otherResourceGroup) mockClient := mocks.NewMockZonesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, zoneName, nil).Return( armdns.ZonesClientGetResponse{ Zone: *zone, }, nil) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that the virtual network link uses the correct scope found := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkVirtualNetwork.String() { expectedScope := fmt.Sprintf("%s.%s", otherSubscriptionID, otherResourceGroup) if linkedQuery.GetQuery().GetScope() == expectedScope { found = true break } } } if !found { t.Error("Expected to find virtual network link with different scope") } }) t.Run("List", func(t *testing.T) { zone1 := createAzureZone("example.com", subscriptionID, resourceGroup) zone2 := createAzureZone("test.com", subscriptionID, resourceGroup) mockClient := mocks.NewMockZonesClient(ctrl) mockPager := NewMockZonesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armdns.ZonesClientListByResourceGroupResponse{ ZoneListResult: armdns.ZoneListResult{ Value: []*armdns.Zone{zone1, zone2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.NetworkZone.String() { t.Fatalf("Expected type %s, got: %s", azureshared.NetworkZone, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Test that zones with nil names are skipped in List zone1 := createAzureZone("example.com", subscriptionID, resourceGroup) zone2 := &armdns.Zone{ Name: nil, // Zone with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armdns.ZoneProperties{}, } mockClient := mocks.NewMockZonesClient(ctrl) mockPager := NewMockZonesPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armdns.ZonesClientListByResourceGroupResponse{ ZoneListResult: armdns.ZoneListResult{ Value: []*armdns.Zone{zone1, zone2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (zone1), zone2 with nil name should be skipped if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name should be skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "example.com" { t.Errorf("Expected item name 'example.com', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("zone not found") mockClient := mocks.NewMockZonesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-zone", nil).Return( armdns.ZonesClientGetResponse{}, expectedErr) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-zone", true) if qErr == nil { t.Error("Expected error when getting non-existent zone, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { expectedErr := errors.New("failed to list zones") mockClient := mocks.NewMockZonesClient(ctrl) mockPager := NewMockZonesPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armdns.ZonesClientListByResourceGroupResponse{}, expectedErr), ) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing zones fails, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockZonesClient(ctrl) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements ListableWrapper interface _ = wrapper // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Network/dnszones/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.NetworkVirtualNetwork] { t.Error("Expected PotentialLinks to include NetworkVirtualNetwork") } if !potentialLinks[azureshared.NetworkDNSRecordSet] { t.Error("Expected PotentialLinks to include NetworkDNSRecordSet") } if !potentialLinks[stdlib.NetworkDNS] { t.Error("Expected PotentialLinks to include stdlib.NetworkDNS") } // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_dns_zone.name" { foundMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("Expected TerraformMethod to be GET, got: %s", mapping.GetTerraformMethod()) } break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_dns_zone.name' mapping") } // Verify GetLookups lookups := w.GetLookups() if len(lookups) == 0 { t.Error("Expected GetLookups to return at least one lookup") } foundLookup := false for _, lookup := range lookups { if lookup.ItemType == azureshared.NetworkZone { foundLookup = true break } } if !foundLookup { t.Error("Expected GetLookups to include NetworkZone") } }) t.Run("Get_NoVirtualNetworks", func(t *testing.T) { // Test zone without virtual networks zoneName := "example.com" zone := &armdns.Zone{ Name: new(zoneName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armdns.ZoneProperties{ NameServers: []*string{ new("ns1.example.com"), new("ns2.example.com"), }, }, } mockClient := mocks.NewMockZonesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, zoneName, nil).Return( armdns.ZonesClientGetResponse{ Zone: *zone, }, nil) wrapper := manual.NewNetworkZone(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], zoneName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Should still have child resource links and name server links hasRecordSetLink := false hasNameServerLink := false for _, linkedQuery := range sdpItem.GetLinkedItemQueries() { if linkedQuery.GetQuery().GetType() == azureshared.NetworkDNSRecordSet.String() { hasRecordSetLink = true } if linkedQuery.GetQuery().GetType() == "dns" { hasNameServerLink = true } } if !hasRecordSetLink { t.Error("Expected DNS Record Set link even without virtual networks") } if !hasNameServerLink { t.Error("Expected name server DNS link") } }) } // MockZonesPager is a mock implementation of ZonesPager type MockZonesPager struct { ctrl *gomock.Controller recorder *MockZonesPagerMockRecorder } type MockZonesPagerMockRecorder struct { mock *MockZonesPager } func NewMockZonesPager(ctrl *gomock.Controller) *MockZonesPager { mock := &MockZonesPager{ctrl: ctrl} mock.recorder = &MockZonesPagerMockRecorder{mock} return mock } func (m *MockZonesPager) EXPECT() *MockZonesPagerMockRecorder { return m.recorder } func (m *MockZonesPager) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } func (mr *MockZonesPagerMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeFor[func() bool]()) } func (m *MockZonesPager) NextPage(ctx context.Context) (armdns.ZonesClientListByResourceGroupResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armdns.ZonesClientListByResourceGroupResponse) ret1, _ := ret[1].(error) return ret0, ret1 } func (mr *MockZonesPagerMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeFor[func(ctx context.Context) (armdns.ZonesClientListByResourceGroupResponse, error)](), ctx) } // createAzureZone creates a mock Azure DNS zone for testing with all linked resources func createAzureZone(zoneName, subscriptionID, resourceGroup string) *armdns.Zone { registrationVNetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-reg-vnet", subscriptionID, resourceGroup) resolutionVNetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-res-vnet", subscriptionID, resourceGroup) return &armdns.Zone{ Name: new(zoneName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armdns.ZoneProperties{ MaxNumberOfRecordSets: new(int64(5000)), NumberOfRecordSets: new(int64(10)), NameServers: []*string{ new("ns1.example.com"), new("ns2.example.com"), }, RegistrationVirtualNetworks: []*armdns.SubResource{ { ID: new(registrationVNetID), }, }, ResolutionVirtualNetworks: []*armdns.SubResource{ { ID: new(resolutionVNetID), }, }, }, } } // createAzureZoneWithDifferentScopeVNet creates a zone with a virtual network in a different scope func createAzureZoneWithDifferentScopeVNet(zoneName, subscriptionID, resourceGroup, otherSubscriptionID, otherResourceGroup string) *armdns.Zone { registrationVNetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/test-reg-vnet", otherSubscriptionID, otherResourceGroup) return &armdns.Zone{ Name: new(zoneName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armdns.ZoneProperties{ MaxNumberOfRecordSets: new(int64(5000)), NumberOfRecordSets: new(int64(10)), NameServers: []*string{ new("ns1.example.com"), }, RegistrationVirtualNetworks: []*armdns.SubResource{ { ID: new(registrationVNetID), }, }, }, } } ================================================ FILE: sources/azure/manual/operational-insights-workspace.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var OperationalInsightsWorkspaceLookupByName = shared.NewItemTypeLookup("name", azureshared.OperationalInsightsWorkspace) type operationalInsightsWorkspaceWrapper struct { client clients.OperationalInsightsWorkspaceClient *azureshared.MultiResourceGroupBase } func NewOperationalInsightsWorkspace(client clients.OperationalInsightsWorkspaceClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &operationalInsightsWorkspaceWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, azureshared.OperationalInsightsWorkspace, ), } } // ref: https://learn.microsoft.com/en-us/rest/api/loganalytics/workspaces/list-by-resource-group func (c operationalInsightsWorkspaceWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, workspace := range page.Value { if workspace.Name == nil { continue } item, sdpErr := c.azureWorkspaceToSDPItem(workspace, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c operationalInsightsWorkspaceWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, workspace := range page.Value { if workspace.Name == nil { continue } var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = c.azureWorkspaceToSDPItem(workspace, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } // ref: https://learn.microsoft.com/en-us/rest/api/loganalytics/workspaces/get func (c operationalInsightsWorkspaceWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the workspace name"), scope, c.Type()) } workspaceName := queryParts[0] if workspaceName == "" { return nil, azureshared.QueryError(errors.New("workspaceName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } result, err := c.client.Get(ctx, rgScope.ResourceGroup, workspaceName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureWorkspaceToSDPItem(&result.Workspace, scope) } func (c operationalInsightsWorkspaceWrapper) azureWorkspaceToSDPItem(workspace *armoperationalinsights.Workspace, scope string) (*sdp.Item, *sdp.QueryError) { if workspace.Name == nil { return nil, azureshared.QueryError(errors.New("workspace name is nil"), scope, c.Type()) } attributes, err := shared.ToAttributesWithExclude(workspace, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.OperationalInsightsWorkspace.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(workspace.Tags), } // Health status mapping based on provisioning state if workspace.Properties != nil && workspace.Properties.ProvisioningState != nil { switch *workspace.Properties.ProvisioningState { case armoperationalinsights.WorkspaceEntityStatusSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armoperationalinsights.WorkspaceEntityStatusCreating, armoperationalinsights.WorkspaceEntityStatusUpdating, armoperationalinsights.WorkspaceEntityStatusDeleting, armoperationalinsights.WorkspaceEntityStatusProvisioningAccount: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armoperationalinsights.WorkspaceEntityStatusFailed, armoperationalinsights.WorkspaceEntityStatusCanceled: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to Private Link Scope Scoped Resources // PrivateLinkScopedResources[].ResourceID refers to Azure Monitor Private Link Scope // scoped resources (microsoft.insights/privateLinkScopes/scopedResources) if workspace.Properties != nil && workspace.Properties.PrivateLinkScopedResources != nil { for _, plsr := range workspace.Properties.PrivateLinkScopedResources { if plsr != nil && plsr.ResourceID != nil { params := azureshared.ExtractPathParamsFromResourceID(*plsr.ResourceID, []string{"privateLinkScopes", "scopedResources"}) if len(params) >= 2 && params[0] != "" && params[1] != "" { scopeName, scopedResourceName := params[0], params[1] linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*plsr.ResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.InsightsPrivateLinkScopeScopedResource.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(scopeName, scopedResourceName), Scope: linkedScope, }, }) } } } } // Link to Cluster (Dedicated Log Analytics cluster) if workspace.Properties != nil && workspace.Properties.Features != nil && workspace.Properties.Features.ClusterResourceID != nil { clusterName := azureshared.ExtractResourceName(*workspace.Properties.Features.ClusterResourceID) if clusterName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(*workspace.Properties.Features.ClusterResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.OperationalInsightsCluster.String(), Method: sdp.QueryMethod_GET, Query: clusterName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (c operationalInsightsWorkspaceWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ OperationalInsightsWorkspaceLookupByName, } } func (c operationalInsightsWorkspaceWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.InsightsPrivateLinkScopeScopedResource, azureshared.OperationalInsightsCluster, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftoperationalinsights func (c operationalInsightsWorkspaceWrapper) IAMPermissions() []string { return []string{ "Microsoft.OperationalInsights/workspaces/read", } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/monitor#log-analytics-reader func (c operationalInsightsWorkspaceWrapper) PredefinedRole() string { return "Log Analytics Reader" } ================================================ FILE: sources/azure/manual/operational-insights-workspace_test.go ================================================ package manual_test import ( "context" "errors" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestOperationalInsightsWorkspace(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { workspaceName := "test-workspace" workspace := createAzureWorkspace(workspaceName, subscriptionID, resourceGroup) mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, workspaceName, nil).Return( armoperationalinsights.WorkspacesClientGetResponse{ Workspace: *workspace, }, nil) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], workspaceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.OperationalInsightsWorkspace.String() { t.Errorf("Expected type %s, got %s", azureshared.OperationalInsightsWorkspace, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != workspaceName { t.Errorf("Expected unique attribute value %s, got %s", workspaceName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } // Verify health status based on provisioning state if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Errorf("Expected health OK, got %s", sdpItem.GetHealth()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Properties.PrivateLinkScopedResources[0].ResourceID ExpectedType: azureshared.InsightsPrivateLinkScopeScopedResource.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-pls", "test-scoped-resource"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Properties.Features.ClusterResourceID ExpectedType: azureshared.OperationalInsightsCluster.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-cluster", ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithCrossResourceGroupLinks", func(t *testing.T) { workspaceName := "test-workspace-cross-rg" workspace := createAzureWorkspaceWithCrossResourceGroupLinks(workspaceName, subscriptionID) mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, workspaceName, nil).Return( armoperationalinsights.WorkspacesClientGetResponse{ Workspace: *workspace, }, nil) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], workspaceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that links use the correct scope from different resource groups foundClusterLink := false foundPLSScopedResourceLink := false expectedScope := subscriptionID + ".other-rg" for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.OperationalInsightsCluster.String() { foundClusterLink = true if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected Cluster scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } } if link.GetQuery().GetType() == azureshared.InsightsPrivateLinkScopeScopedResource.String() { foundPLSScopedResourceLink = true if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected Private Link Scope Scoped Resource scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } expectedQuery := shared.CompositeLookupKey("test-pls-cross", "test-scoped-resource-cross") if link.GetQuery().GetQuery() != expectedQuery { t.Errorf("Expected query %s, got %s", expectedQuery, link.GetQuery().GetQuery()) } } } if !foundClusterLink { t.Error("Expected to find Operational Insights Cluster link") } if !foundPLSScopedResourceLink { t.Error("Expected to find Private Link Scope Scoped Resource link") } }) t.Run("GetWithoutLinks", func(t *testing.T) { workspaceName := "test-workspace-no-links" workspace := createAzureWorkspaceWithoutLinks(workspaceName) mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, workspaceName, nil).Return( armoperationalinsights.WorkspacesClientGetResponse{ Workspace: *workspace, }, nil) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], workspaceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Errorf("Expected no linked queries, got %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("GetWithDifferentHealthStates", func(t *testing.T) { healthTests := []struct { state armoperationalinsights.WorkspaceEntityStatus expectedHealth sdp.Health }{ {armoperationalinsights.WorkspaceEntityStatusSucceeded, sdp.Health_HEALTH_OK}, {armoperationalinsights.WorkspaceEntityStatusCreating, sdp.Health_HEALTH_PENDING}, {armoperationalinsights.WorkspaceEntityStatusUpdating, sdp.Health_HEALTH_PENDING}, {armoperationalinsights.WorkspaceEntityStatusDeleting, sdp.Health_HEALTH_PENDING}, {armoperationalinsights.WorkspaceEntityStatusProvisioningAccount, sdp.Health_HEALTH_PENDING}, {armoperationalinsights.WorkspaceEntityStatusFailed, sdp.Health_HEALTH_ERROR}, {armoperationalinsights.WorkspaceEntityStatusCanceled, sdp.Health_HEALTH_ERROR}, } for _, ht := range healthTests { t.Run(string(ht.state), func(t *testing.T) { workspaceName := "test-workspace-" + string(ht.state) workspace := createAzureWorkspaceWithProvisioningState(workspaceName, ht.state) mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, workspaceName, nil).Return( armoperationalinsights.WorkspacesClientGetResponse{ Workspace: *workspace, }, nil) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], workspaceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != ht.expectedHealth { t.Errorf("Expected health %s for state %s, got %s", ht.expectedHealth, ht.state, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { workspace1 := createAzureWorkspace("test-workspace-1", subscriptionID, resourceGroup) workspace2 := createAzureWorkspace("test-workspace-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockPager := newMockOperationalInsightsWorkspacePager(ctrl, []*armoperationalinsights.Workspace{workspace1, workspace2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { workspace1 := createAzureWorkspace("test-workspace-1", subscriptionID, resourceGroup) workspace2 := createAzureWorkspace("test-workspace-2", subscriptionID, resourceGroup) mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockPager := newMockOperationalInsightsWorkspacePager(ctrl, []*armoperationalinsights.Workspace{workspace1, workspace2}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListWithNilName", func(t *testing.T) { workspace1 := createAzureWorkspace("test-workspace-1", subscriptionID, resourceGroup) workspaceNilName := &armoperationalinsights.Workspace{ Name: nil, // nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockPager := newMockOperationalInsightsWorkspacePager(ctrl, []*armoperationalinsights.Workspace{workspace1, workspaceNilName}) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("workspace not found") mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-workspace", nil).Return( armoperationalinsights.WorkspacesClientGetResponse{}, expectedErr) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-workspace", true) if qErr == nil { t.Error("Expected error when getting non-existent workspace, but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting workspace with empty name, but got nil") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockOperationalInsightsWorkspaceClient(ctrl) wrapper := manual.NewOperationalInsightsWorkspace(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test the wrapper's Get method directly with insufficient query parts _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when getting workspace with insufficient query parts, but got nil") } }) } // createAzureWorkspace creates a mock Azure Log Analytics Workspace for testing func createAzureWorkspace(workspaceName, subscriptionID, resourceGroup string) *armoperationalinsights.Workspace { succeededState := armoperationalinsights.WorkspaceEntityStatusSucceeded retentionDays := int32(30) return &armoperationalinsights.Workspace{ Name: new(workspaceName), Location: new("eastus"), ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.OperationalInsights/workspaces/" + workspaceName), Type: new("Microsoft.OperationalInsights/workspaces"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armoperationalinsights.WorkspaceProperties{ ProvisioningState: &succeededState, RetentionInDays: &retentionDays, Features: &armoperationalinsights.WorkspaceFeatures{ ClusterResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.OperationalInsights/clusters/test-cluster"), }, PrivateLinkScopedResources: []*armoperationalinsights.PrivateLinkScopedResource{ { // Note: ResourceID refers to microsoft.insights/privateLinkScopes/scopedResources ResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/microsoft.insights/privateLinkScopes/test-pls/scopedResources/test-scoped-resource"), ScopeID: new("test-scope-id"), }, }, }, } } // createAzureWorkspaceWithCrossResourceGroupLinks creates a mock Workspace with links to resources in different resource groups func createAzureWorkspaceWithCrossResourceGroupLinks(workspaceName, subscriptionID string) *armoperationalinsights.Workspace { succeededState := armoperationalinsights.WorkspaceEntityStatusSucceeded return &armoperationalinsights.Workspace{ Name: new(workspaceName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armoperationalinsights.WorkspaceProperties{ ProvisioningState: &succeededState, Features: &armoperationalinsights.WorkspaceFeatures{ ClusterResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/Microsoft.OperationalInsights/clusters/test-cluster-cross-rg"), }, PrivateLinkScopedResources: []*armoperationalinsights.PrivateLinkScopedResource{ { ResourceID: new("/subscriptions/" + subscriptionID + "/resourceGroups/other-rg/providers/microsoft.insights/privateLinkScopes/test-pls-cross/scopedResources/test-scoped-resource-cross"), ScopeID: new("test-scope-id"), }, }, }, } } // createAzureWorkspaceWithoutLinks creates a mock Workspace without any linked resources func createAzureWorkspaceWithoutLinks(workspaceName string) *armoperationalinsights.Workspace { succeededState := armoperationalinsights.WorkspaceEntityStatusSucceeded return &armoperationalinsights.Workspace{ Name: new(workspaceName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armoperationalinsights.WorkspaceProperties{ ProvisioningState: &succeededState, // No PrivateLinkScopedResources }, } } // createAzureWorkspaceWithProvisioningState creates a mock Workspace with a specific provisioning state func createAzureWorkspaceWithProvisioningState(workspaceName string, state armoperationalinsights.WorkspaceEntityStatus) *armoperationalinsights.Workspace { return &armoperationalinsights.Workspace{ Name: new(workspaceName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armoperationalinsights.WorkspaceProperties{ ProvisioningState: &state, }, } } // mockOperationalInsightsWorkspacePager is a simple mock implementation of the Pager interface for testing type mockOperationalInsightsWorkspacePager struct { ctrl *gomock.Controller items []*armoperationalinsights.Workspace index int more bool } func newMockOperationalInsightsWorkspacePager(ctrl *gomock.Controller, items []*armoperationalinsights.Workspace) clients.OperationalInsightsWorkspacePager { return &mockOperationalInsightsWorkspacePager{ ctrl: ctrl, items: items, index: 0, more: len(items) > 0, } } func (m *mockOperationalInsightsWorkspacePager) More() bool { return m.more } func (m *mockOperationalInsightsWorkspacePager) NextPage(ctx context.Context) (armoperationalinsights.WorkspacesClientListByResourceGroupResponse, error) { if m.index >= len(m.items) { m.more = false return armoperationalinsights.WorkspacesClientListByResourceGroupResponse{ WorkspaceListResult: armoperationalinsights.WorkspaceListResult{ Value: []*armoperationalinsights.Workspace{}, }, }, nil } item := m.items[m.index] m.index++ m.more = m.index < len(m.items) return armoperationalinsights.WorkspacesClientListByResourceGroupResponse{ WorkspaceListResult: armoperationalinsights.WorkspaceListResult{ Value: []*armoperationalinsights.Workspace{item}, }, }, nil } ================================================ FILE: sources/azure/manual/sql-database-schema.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var SQLDatabaseSchemaLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLDatabaseSchema) type sqlDatabaseSchemaWrapper struct { client clients.SqlDatabaseSchemasClient *azureshared.MultiResourceGroupBase } func NewSqlDatabaseSchema(client clients.SqlDatabaseSchemasClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlDatabaseSchemaWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLDatabaseSchema, ), } } // Get retrieves a specific database schema by serverName, databaseName, and schemaName // ref: https://learn.microsoft.com/en-us/rest/api/sql/database-schemas/get func (s sqlDatabaseSchemaWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 3 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 3 query parts: serverName, databaseName, and schemaName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] databaseName := queryParts[1] schemaName := queryParts[2] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: s.Type(), } } if databaseName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "databaseName cannot be empty", Scope: scope, ItemType: s.Type(), } } if schemaName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "schemaName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, databaseName, schemaName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureDatabaseSchemaToSDPItem(&resp.DatabaseSchema, serverName, databaseName, schemaName, scope) } func (s sqlDatabaseSchemaWrapper) azureDatabaseSchemaToSDPItem(schema *armsql.DatabaseSchema, serverName, databaseName, schemaName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(schema) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, databaseName, schemaName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLDatabaseSchema.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to parent SQL Database sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLDatabase.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(serverName, databaseName), Scope: scope, }, }) return sdpItem, nil } func (s sqlDatabaseSchemaWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLDatabaseLookupByName, SQLDatabaseSchemaLookupByName, } } // Search lists all database schemas for a given serverName and databaseName // ref: https://learn.microsoft.com/en-us/rest/api/sql/database-schemas/list-by-database func (s sqlDatabaseSchemaWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 2 query parts: serverName and databaseName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] databaseName := queryParts[1] if serverName == "" { return nil, azureshared.QueryError(errors.New("serverName cannot be empty"), scope, s.Type()) } if databaseName == "" { return nil, azureshared.QueryError(errors.New("databaseName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByDatabase(ctx, rgScope.ResourceGroup, serverName, databaseName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, schema := range page.Value { if schema.Name == nil { continue } item, sdpErr := s.azureDatabaseSchemaToSDPItem(schema, serverName, databaseName, *schema.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s sqlDatabaseSchemaWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 2 { stream.SendError(azureshared.QueryError(errors.New("Search requires 2 query parts: serverName and databaseName"), scope, s.Type())) return } serverName := queryParts[0] databaseName := queryParts[1] if serverName == "" { stream.SendError(azureshared.QueryError(errors.New("serverName cannot be empty"), scope, s.Type())) return } if databaseName == "" { stream.SendError(azureshared.QueryError(errors.New("databaseName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByDatabase(ctx, rgScope.ResourceGroup, serverName, databaseName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, schema := range page.Value { if schema.Name == nil { continue } item, sdpErr := s.azureDatabaseSchemaToSDPItem(schema, serverName, databaseName, *schema.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s sqlDatabaseSchemaWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, SQLDatabaseLookupByName, }, } } func (s sqlDatabaseSchemaWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.SQLDatabase: true, } } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftsql func (s sqlDatabaseSchemaWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/databases/schemas/read", } } func (s sqlDatabaseSchemaWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-database-schema_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockSqlDatabaseSchemasPager is a simple mock implementation of SqlDatabaseSchemasPager type mockSqlDatabaseSchemasPager struct { pages []armsql.DatabaseSchemasClientListByDatabaseResponse index int } func (m *mockSqlDatabaseSchemasPager) More() bool { return m.index < len(m.pages) } func (m *mockSqlDatabaseSchemasPager) NextPage(ctx context.Context) (armsql.DatabaseSchemasClientListByDatabaseResponse, error) { if m.index >= len(m.pages) { return armsql.DatabaseSchemasClientListByDatabaseResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorSqlDatabaseSchemasPager is a mock pager that always returns an error type errorSqlDatabaseSchemasPager struct{} func (e *errorSqlDatabaseSchemasPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorSqlDatabaseSchemasPager) NextPage(ctx context.Context) (armsql.DatabaseSchemasClientListByDatabaseResponse, error) { return armsql.DatabaseSchemasClientListByDatabaseResponse{}, errors.New("pager error") } // testSqlDatabaseSchemasClient wraps the mock to implement the correct interface type testSqlDatabaseSchemasClient struct { *mocks.MockSqlDatabaseSchemasClient pager clients.SqlDatabaseSchemasPager } func (t *testSqlDatabaseSchemasClient) ListByDatabase(ctx context.Context, resourceGroupName, serverName, databaseName string) clients.SqlDatabaseSchemasPager { return t.pager } func TestSqlDatabaseSchema(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" databaseName := "test-database" schemaName := "dbo" t.Run("Get", func(t *testing.T) { schema := createAzureDatabaseSchema(serverName, databaseName, schemaName) mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, databaseName, schemaName).Return( armsql.DatabaseSchemasClientGetResponse{ DatabaseSchema: *schema, }, nil) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires serverName, databaseName, and schemaName as query parts query := shared.CompositeLookupKey(serverName, databaseName, schemaName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLDatabaseSchema.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLDatabaseSchema, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, databaseName, schemaName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // SQLDatabase parent link ExpectedType: azureshared.SQLDatabase.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(serverName, databaseName), ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only server and database name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(serverName, databaseName), true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty server name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey("", databaseName, schemaName), true) if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("GetWithEmptyDatabaseName", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty database name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(serverName, "", schemaName), true) if qErr == nil { t.Error("Expected error when providing empty database name, but got nil") } }) t.Run("GetWithEmptySchemaName", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty schema name _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(serverName, databaseName, ""), true) if qErr == nil { t.Error("Expected error when providing empty schema name, but got nil") } }) t.Run("Search", func(t *testing.T) { schema1 := createAzureDatabaseSchema(serverName, databaseName, "dbo") schema2 := createAzureDatabaseSchema(serverName, databaseName, "sys") mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) mockPager := &mockSqlDatabaseSchemasPager{ pages: []armsql.DatabaseSchemasClientListByDatabaseResponse{ { DatabaseSchemaListResult: armsql.DatabaseSchemaListResult{ Value: []*armsql.DatabaseSchema{schema1, schema2}, }, }, }, } testClient := &testSqlDatabaseSchemasClient{ MockSqlDatabaseSchemasClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(serverName, databaseName), true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.SQLDatabaseSchema.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLDatabaseSchema, item.GetType()) } } }) t.Run("Search_WithNilName", func(t *testing.T) { schema1 := createAzureDatabaseSchema(serverName, databaseName, "dbo") schema2 := &armsql.DatabaseSchema{ Name: nil, // Schema with nil name should be skipped ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/schemas/nil-schema"), } mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) mockPager := &mockSqlDatabaseSchemasPager{ pages: []armsql.DatabaseSchemasClientListByDatabaseResponse{ { DatabaseSchemaListResult: armsql.DatabaseSchemaListResult{ Value: []*armsql.DatabaseSchema{schema1, schema2}, }, }, }, } testClient := &testSqlDatabaseSchemasClient{ MockSqlDatabaseSchemasClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(serverName, databaseName), true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (schema with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(serverName, databaseName, "dbo") { t.Fatalf("Expected schema name 'dbo', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with insufficient query parts - should return error before calling ListByDatabase _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("SearchWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search with empty server name _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "", databaseName) if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("SearchWithEmptyDatabaseName", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search with empty database name _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName, "") if qErr == nil { t.Error("Expected error when providing empty database name, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("schema not found") mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, databaseName, "nonexistent-schema").Return( armsql.DatabaseSchemasClientGetResponse{}, expectedErr) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, databaseName, "nonexistent-schema") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent schema, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorSqlDatabaseSchemasPager{} testClient := &testSqlDatabaseSchemasClient{ MockSqlDatabaseSchemasClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(serverName, databaseName), true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabaseSchemasClient(ctrl) testClient := &testSqlDatabaseSchemasClient{MockSqlDatabaseSchemasClient: mockClient} wrapper := manual.NewSqlDatabaseSchema(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/databases/schemas/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.SQLDatabase] { t.Error("Expected PotentialLinks to include SQLDatabase") } }) } // createAzureDatabaseSchema creates a mock Azure database schema for testing func createAzureDatabaseSchema(serverName, databaseName, schemaName string) *armsql.DatabaseSchema { schemaID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/" + serverName + "/databases/" + databaseName + "/schemas/" + schemaName return &armsql.DatabaseSchema{ Name: new(schemaName), ID: new(schemaID), Type: new("Microsoft.Sql/servers/databases/schemas"), } } ================================================ FILE: sources/azure/manual/sql-database.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var SQLDatabaseLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLDatabase) type sqlDatabaseWrapper struct { client clients.SqlDatabasesClient *azureshared.MultiResourceGroupBase } func NewSqlDatabase(client clients.SqlDatabasesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlDatabaseWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLDatabase, ), } } func (s sqlDatabaseWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and databaseName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] databaseName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, databaseName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureSqlDatabaseToSDPItem(&resp.Database, serverName, databaseName, scope) } func (s sqlDatabaseWrapper) azureSqlDatabaseToSDPItem(database *armsql.Database, serverName, databaseName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(database, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, databaseName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLDatabase.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(database.Tags), } // Extract server name from database ID if database.ID != nil { extractedServerName := azureshared.ExtractSQLServerNameFromDatabaseID(*database.ID) if extractedServerName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: extractedServerName, Scope: scope, }, }) } } if database.Properties != nil && database.Properties.ElasticPoolID != nil { elasticPoolServerName, elasticPoolName := azureshared.ExtractSQLElasticPoolInfoFromResourceID(*database.Properties.ElasticPoolID) if elasticPoolServerName != "" && elasticPoolName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLElasticPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(elasticPoolServerName, elasticPoolName), Scope: scope, }, }) } } if database.Properties != nil && database.Properties.RecoverableDatabaseID != nil { // Extract server name and database name from RecoverableDatabaseID resource ID // This handles cross-server scenarios where geo-replicated backups exist on different servers // RecoverableDatabaseID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/recoverableDatabases/{databaseName} recoverableServerName, recoverableDatabaseName := azureshared.ExtractSQLRecoverableDatabaseInfoFromResourceID(*database.Properties.RecoverableDatabaseID) if recoverableServerName != "" && recoverableDatabaseName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLRecoverableDatabase.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(recoverableServerName, recoverableDatabaseName), Scope: scope, }, }) } } if database.Properties != nil && database.Properties.RestorableDroppedDatabaseID != nil { // Extract server name and database name from RestorableDroppedDatabaseID resource ID // This handles cross-server scenarios where dropped databases may be on different servers // RestorableDroppedDatabaseID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/restorableDroppedDatabases/{databaseName} restorableDroppedServerName, restorableDroppedDatabaseName := azureshared.ExtractSQLRestorableDroppedDatabaseInfoFromResourceID(*database.Properties.RestorableDroppedDatabaseID) if restorableDroppedServerName != "" && restorableDroppedDatabaseName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLRestorableDroppedDatabase.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(restorableDroppedServerName, restorableDroppedDatabaseName), Scope: scope, }, }) } } if database.Properties != nil && database.Properties.RecoveryServicesRecoveryPointID != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLRecoveryServicesRecoveryPoint.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) } if database.Properties != nil && database.Properties.SourceDatabaseID != nil { // Extract server name and database name from SourceDatabaseID resource ID // This handles cross-server copy scenarios where the source database may be on a different server sourceServerName, sourceDatabaseName := azureshared.ExtractSQLDatabaseInfoFromResourceID(*database.Properties.SourceDatabaseID) if sourceServerName != "" && sourceDatabaseName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLDatabase.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(sourceServerName, sourceDatabaseName), Scope: scope, }, }) } } // Handle SourceResourceID - a generic resource ID that can reference different Azure resource types // When sourceResourceId is specified, it's used for PointInTimeRestore, Restore, or Recover operations // and can point to SQL databases, SQL elastic pools, or Synapse SQL pools if database.Properties != nil && database.Properties.SourceResourceID != nil { resourceType, params := azureshared.DetermineSourceResourceType(*database.Properties.SourceResourceID) switch resourceType { case azureshared.SourceResourceTypeSQLDatabase: serverName := params["serverName"] databaseName := params["databaseName"] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLDatabase.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(serverName, databaseName), Scope: scope, }, }) case azureshared.SourceResourceTypeSQLElasticPool: elasticPoolServerName := params["serverName"] elasticPoolName := params["elasticPoolName"] if elasticPoolServerName != "" && elasticPoolName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLElasticPool.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(elasticPoolServerName, elasticPoolName), Scope: scope, }, }) } case azureshared.SourceResourceTypeUnknown: // Synapse SQL Pool and other resource types not yet supported // This could be extended in the future to support Synapse SQL pools // when Synapse item types are added to the codebase } } if database.Properties != nil && database.Properties.FailoverGroupID != nil { // FailoverGroupID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/failoverGroups/{failoverGroupName} params := azureshared.ExtractPathParamsFromResourceID(*database.Properties.FailoverGroupID, []string{"servers", "failoverGroups"}) if len(params) >= 2 { failoverServerName := params[0] failoverGroupName := params[1] linkedScope := azureshared.ExtractScopeFromResourceID(*database.Properties.FailoverGroupID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerFailoverGroup.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(failoverServerName, failoverGroupName), Scope: linkedScope, }, }) } } if database.Properties != nil && database.Properties.LongTermRetentionBackupResourceID != nil { locationName, ltrServerName, ltrDatabaseName, backupName := azureshared.ExtractSQLLongTermRetentionBackupInfoFromResourceID(*database.Properties.LongTermRetentionBackupResourceID) if locationName != "" && ltrServerName != "" && ltrDatabaseName != "" && backupName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*database.Properties.LongTermRetentionBackupResourceID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLLongTermRetentionBackup.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(locationName, ltrServerName, ltrDatabaseName, backupName), Scope: linkedScope, }, }) } } if database.Properties != nil && database.Properties.MaintenanceConfigurationID != nil && *database.Properties.MaintenanceConfigurationID != "" { configName := azureshared.ExtractResourceName(*database.Properties.MaintenanceConfigurationID) if configName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*database.Properties.MaintenanceConfigurationID) if linkedScope == "" && strings.Contains(*database.Properties.MaintenanceConfigurationID, "publicMaintenanceConfigurations") { linkedScope = azureshared.ExtractSubscriptionIDFromResourceID(*database.Properties.MaintenanceConfigurationID) } if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.MaintenanceMaintenanceConfiguration.String(), Method: sdp.QueryMethod_GET, Query: configName, Scope: linkedScope, }, }) } } // Link Key Vault Keys from EncryptionProtector and Keys map (deduplicate by vaultName+keyName) seenKeyVaultKeys := make(map[string]bool) addKeyVaultKeyLink := func(vaultName, keyName string) { if vaultName == "" || keyName == "" { return } key := vaultName + "|" + keyName if seenKeyVaultKeys[key] { return } seenKeyVaultKeys[key] = true sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, keyName), Scope: scope, }, }) } if database.Properties != nil && database.Properties.EncryptionProtector != nil && *database.Properties.EncryptionProtector != "" { addKeyVaultKeyLink( azureshared.ExtractVaultNameFromURI(*database.Properties.EncryptionProtector), azureshared.ExtractKeyNameFromURI(*database.Properties.EncryptionProtector), ) } if database.Properties != nil && database.Properties.Keys != nil { for keyURI := range database.Properties.Keys { addKeyVaultKeyLink( azureshared.ExtractVaultNameFromURI(keyURI), azureshared.ExtractKeyNameFromURI(keyURI), ) } } if database.Identity != nil && database.Identity.UserAssignedIdentities != nil { for identityResourceID := range database.Identity.UserAssignedIdentities { if identityResourceID == "" { continue } identityName := azureshared.ExtractResourceName(identityResourceID) linkedScope := azureshared.ExtractScopeFromResourceID(identityResourceID) if identityName != "" && linkedScope != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Database Schemas - child resource with LIST endpoint // GET /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/databases/{databaseName}/schemas sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLDatabaseSchema.String(), Method: sdp.QueryMethod_SEARCH, Query: shared.CompositeLookupKey(serverName, databaseName), Scope: scope, }, }) return sdpItem, nil } func (s sqlDatabaseWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLDatabaseLookupByName, } } func (s sqlDatabaseWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, database := range page.Value { if database.Name == nil { continue } item, sdpErr := s.azureSqlDatabaseToSDPItem(database, serverName, *database.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s sqlDatabaseWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, database := range page.Value { if database.Name == nil { continue } item, sdpErr := s.azureSqlDatabaseToSDPItem(database, serverName, *database.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s sqlDatabaseWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, }, } } func (s sqlDatabaseWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.SQLServer: true, azureshared.SQLDatabase: true, // source database / copy source azureshared.SQLElasticPool: true, azureshared.SQLRecoverableDatabase: true, azureshared.SQLRestorableDroppedDatabase: true, azureshared.SQLRecoveryServicesRecoveryPoint: true, azureshared.SQLServerFailoverGroup: true, azureshared.SQLLongTermRetentionBackup: true, azureshared.MaintenanceMaintenanceConfiguration: true, azureshared.KeyVaultKey: true, azureshared.ManagedIdentityUserAssignedIdentity: true, azureshared.SQLDatabaseSchema: true, } } func (s sqlDatabaseWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_mssql_database.id", }, } } func (s sqlDatabaseWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/databases/read", } } func (s sqlDatabaseWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-database_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockSqlDatabasesPager is a simple mock implementation of SqlDatabasesPager type mockSqlDatabasesPager struct { pages []armsql.DatabasesClientListByServerResponse index int } func (m *mockSqlDatabasesPager) More() bool { return m.index < len(m.pages) } func (m *mockSqlDatabasesPager) NextPage(ctx context.Context) (armsql.DatabasesClientListByServerResponse, error) { if m.index >= len(m.pages) { return armsql.DatabasesClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorSqlDatabasesPager is a mock pager that always returns an error type errorSqlDatabasesPager struct{} func (e *errorSqlDatabasesPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorSqlDatabasesPager) NextPage(ctx context.Context) (armsql.DatabasesClientListByServerResponse, error) { return armsql.DatabasesClientListByServerResponse{}, errors.New("pager error") } // testSqlDatabasesClient wraps the mock to implement the correct interface type testSqlDatabasesClient struct { *mocks.MockSqlDatabasesClient pager clients.SqlDatabasesPager } func (t *testSqlDatabasesClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlDatabasesPager { return t.pager } func TestSqlDatabase(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" databaseName := "test-database" t.Run("Get", func(t *testing.T) { database := createAzureSqlDatabase(serverName, databaseName, "") mockClient := mocks.NewMockSqlDatabasesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, databaseName).Return( armsql.DatabasesClientGetResponse{ Database: *database, }, nil) testClient := &testSqlDatabasesClient{MockSqlDatabasesClient: mockClient} wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires serverName and databaseName as query parts query := shared.CompositeLookupKey(serverName, databaseName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLDatabase.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLDatabase, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, databaseName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // SQLServer link ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // SQLDatabaseSchema child resource link ExpectedType: azureshared.SQLDatabaseSchema.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(serverName, databaseName), ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithElasticPool", func(t *testing.T) { elasticPoolID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/elasticPools/test-pool" database := createAzureSqlDatabase(serverName, databaseName, elasticPoolID) mockClient := mocks.NewMockSqlDatabasesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, databaseName).Return( armsql.DatabasesClientGetResponse{ Database: *database, }, nil) testClient := &testSqlDatabasesClient{MockSqlDatabasesClient: mockClient} wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := serverName + shared.QuerySeparator + databaseName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // SQLServer link ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // SQLElasticPool link (composite: serverName + elasticPoolName) ExpectedType: azureshared.SQLElasticPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-server", "test-pool"), ExpectedScope: subscriptionID + "." + resourceGroup, }, { // SQLDatabaseSchema child resource link ExpectedType: azureshared.SQLDatabaseSchema.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(serverName, databaseName), ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabasesClient(ctrl) testClient := &testSqlDatabasesClient{MockSqlDatabasesClient: mockClient} wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only server name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { database1 := createAzureSqlDatabase(serverName, "database-1", "") database2 := createAzureSqlDatabase(serverName, "database-2", "") mockClient := mocks.NewMockSqlDatabasesClient(ctrl) mockPager := &mockSqlDatabasesPager{ pages: []armsql.DatabasesClientListByServerResponse{ { DatabaseListResult: armsql.DatabaseListResult{ Value: []*armsql.Database{database1, database2}, }, }, }, } testClient := &testSqlDatabasesClient{ MockSqlDatabasesClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.SQLDatabase.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLDatabase, item.GetType()) } } }) t.Run("Search_WithNilName", func(t *testing.T) { database1 := createAzureSqlDatabase(serverName, "database-1", "") database2 := &armsql.Database{ Name: nil, // Database with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/database-2"), Properties: &armsql.DatabaseProperties{ Status: new(armsql.DatabaseStatusOnline), }, } mockClient := mocks.NewMockSqlDatabasesClient(ctrl) mockPager := &mockSqlDatabasesPager{ pages: []armsql.DatabasesClientListByServerResponse{ { DatabaseListResult: armsql.DatabaseListResult{ Value: []*armsql.Database{database1, database2}, }, }, }, } testClient := &testSqlDatabasesClient{ MockSqlDatabasesClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (database with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(serverName, "database-1") { t.Fatalf("Expected database name 'database-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabasesClient(ctrl) testClient := &testSqlDatabasesClient{MockSqlDatabasesClient: mockClient} wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling ListByServer _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("database not found") mockClient := mocks.NewMockSqlDatabasesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-database").Return( armsql.DatabasesClientGetResponse{}, expectedErr) testClient := &testSqlDatabasesClient{MockSqlDatabasesClient: mockClient} wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := serverName + shared.QuerySeparator + "nonexistent-database" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent database, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabasesClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorSqlDatabasesPager{} testClient := &testSqlDatabasesClient{ MockSqlDatabasesClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) // The Search implementation should return an error when pager.NextPage returns an error if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlDatabasesClient(ctrl) testClient := &testSqlDatabasesClient{MockSqlDatabasesClient: mockClient} wrapper := manual.NewSqlDatabase(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/databases/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } /* //todo: uncomment when sql server adapter and elastic pool adapter are made if !potentialLinks[azureshared.SQLServer] { t.Error("Expected PotentialLinks to include SQLServer") } if !potentialLinks[azureshared.SQLElasticPool] { t.Error("Expected PotentialLinks to include SQLElasticPool") }*/ // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_mssql_database.id" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_mssql_database.id' mapping") } }) } // createAzureSqlDatabase creates a mock Azure SQL database for testing func createAzureSqlDatabase(serverName, databaseName, elasticPoolID string) *armsql.Database { databaseID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/" + serverName + "/databases/" + databaseName db := &armsql.Database{ Name: new(databaseName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, ID: new(databaseID), Properties: &armsql.DatabaseProperties{ Status: new(armsql.DatabaseStatusOnline), }, } if elasticPoolID != "" { db.Properties.ElasticPoolID = new(elasticPoolID) } return db } ================================================ FILE: sources/azure/manual/sql-elastic-pool.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var SQLElasticPoolLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLElasticPool) type sqlElasticPoolWrapper struct { client clients.SqlElasticPoolClient *azureshared.MultiResourceGroupBase } func NewSqlElasticPool(client clients.SqlElasticPoolClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlElasticPoolWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLElasticPool, ), } } func (s sqlElasticPoolWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and elasticPoolName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] elasticPoolName := queryParts[1] if elasticPoolName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "elasticPoolName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, elasticPoolName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureSqlElasticPoolToSDPItem(&resp.ElasticPool, serverName, elasticPoolName, scope) } func (s sqlElasticPoolWrapper) azureSqlElasticPoolToSDPItem(pool *armsql.ElasticPool, serverName, elasticPoolName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(pool, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, elasticPoolName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLElasticPool.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(pool.Tags), } // Link to parent SQL Server (from resource ID or known server name) if pool.ID != nil { extractedServerName := azureshared.ExtractPathParamsFromResourceID(*pool.ID, []string{"servers"}) if len(extractedServerName) >= 1 && extractedServerName[0] != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: extractedServerName[0], Scope: scope, }, }) } } if len(sdpItem.GetLinkedItemQueries()) == 0 { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } // Link to Maintenance Configuration when set if pool.Properties != nil && pool.Properties.MaintenanceConfigurationID != nil && *pool.Properties.MaintenanceConfigurationID != "" { configName := azureshared.ExtractResourceName(*pool.Properties.MaintenanceConfigurationID) if configName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*pool.Properties.MaintenanceConfigurationID) if linkedScope == "" && strings.Contains(*pool.Properties.MaintenanceConfigurationID, "publicMaintenanceConfigurations") { linkedScope = azureshared.ExtractSubscriptionIDFromResourceID(*pool.Properties.MaintenanceConfigurationID) } if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.MaintenanceMaintenanceConfiguration.String(), Method: sdp.QueryMethod_GET, Query: configName, Scope: linkedScope, }, }) } } // Link to SQL Databases (child resource; list by server returns all databases; those in this pool reference this pool via ElasticPoolID) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLDatabase.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) return sdpItem, nil } func (s sqlElasticPoolWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLElasticPoolLookupByName, } } func (s sqlElasticPoolWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, pool := range page.Value { if pool.Name == nil { continue } item, sdpErr := s.azureSqlElasticPoolToSDPItem(pool, serverName, *pool.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s sqlElasticPoolWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, pool := range page.Value { if pool.Name == nil { continue } item, sdpErr := s.azureSqlElasticPoolToSDPItem(pool, serverName, *pool.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s sqlElasticPoolWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, }, } } func (s sqlElasticPoolWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.SQLServer: true, azureshared.SQLDatabase: true, azureshared.MaintenanceMaintenanceConfiguration: true, } } func (s sqlElasticPoolWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_mssql_elasticpool.id", }, } } func (s sqlElasticPoolWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/elasticPools/read", } } func (s sqlElasticPoolWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-elastic-pool_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockSqlElasticPoolPager struct { pages []armsql.ElasticPoolsClientListByServerResponse index int } func (m *mockSqlElasticPoolPager) More() bool { return m.index < len(m.pages) } func (m *mockSqlElasticPoolPager) NextPage(ctx context.Context) (armsql.ElasticPoolsClientListByServerResponse, error) { if m.index >= len(m.pages) { return armsql.ElasticPoolsClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorSqlElasticPoolPager struct{} func (e *errorSqlElasticPoolPager) More() bool { return true } func (e *errorSqlElasticPoolPager) NextPage(ctx context.Context) (armsql.ElasticPoolsClientListByServerResponse, error) { return armsql.ElasticPoolsClientListByServerResponse{}, errors.New("pager error") } type testSqlElasticPoolClient struct { *mocks.MockSqlElasticPoolClient pager clients.SqlElasticPoolPager } func (t *testSqlElasticPoolClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlElasticPoolPager { return t.pager } func TestSqlElasticPool(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" elasticPoolName := "test-pool" t.Run("Get", func(t *testing.T) { pool := createAzureSqlElasticPool(serverName, elasticPoolName) mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, elasticPoolName).Return( armsql.ElasticPoolsClientGetResponse{ ElasticPool: *pool, }, nil) wrapper := manual.NewSqlElasticPool(&testSqlElasticPoolClient{MockSqlElasticPoolClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, elasticPoolName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLElasticPool.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLElasticPool.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, elasticPoolName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLDatabase.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) wrapper := manual.NewSqlElasticPool(&testSqlElasticPoolClient{MockSqlElasticPoolClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing only serverName (1 query part), but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) wrapper := manual.NewSqlElasticPool(&testSqlElasticPoolClient{MockSqlElasticPoolClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when elastic pool name is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { pool1 := createAzureSqlElasticPool(serverName, "pool-1") pool2 := createAzureSqlElasticPool(serverName, "pool-2") mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) pager := &mockSqlElasticPoolPager{ pages: []armsql.ElasticPoolsClientListByServerResponse{ { ElasticPoolListResult: armsql.ElasticPoolListResult{ Value: []*armsql.ElasticPool{pool1, pool2}, }, }, }, } testClient := &testSqlElasticPoolClient{ MockSqlElasticPoolClient: mockClient, pager: pager, } wrapper := manual.NewSqlElasticPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error from Search, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items from Search, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { pool := createAzureSqlElasticPool(serverName, elasticPoolName) mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) pager := &mockSqlElasticPoolPager{ pages: []armsql.ElasticPoolsClientListByServerResponse{ { ElasticPoolListResult: armsql.ElasticPoolListResult{ Value: []*armsql.ElasticPool{pool}, }, }, }, } testClient := &testSqlElasticPoolClient{ MockSqlElasticPoolClient: mockClient, pager: pager, } wrapper := manual.NewSqlElasticPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) items := stream.GetItems() errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("Expected no errors from SearchStream, got: %v", errs) } if len(items) != 1 { t.Errorf("Expected 1 item from SearchStream, got %d", len(items)) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) wrapper := manual.NewSqlElasticPool(&testSqlElasticPoolClient{MockSqlElasticPoolClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("elastic pool not found") mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-pool").Return( armsql.ElasticPoolsClientGetResponse{}, expectedErr) wrapper := manual.NewSqlElasticPool(&testSqlElasticPoolClient{MockSqlElasticPoolClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-pool") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent elastic pool, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) errorPager := &errorSqlElasticPoolPager{} testClient := &testSqlElasticPoolClient{ MockSqlElasticPoolClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlElasticPool(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error from Search when pager returns error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlElasticPoolClient(ctrl) wrapper := manual.NewSqlElasticPool(&testSqlElasticPoolClient{MockSqlElasticPoolClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/elasticPools/read" if !slices.Contains(permissions, expectedPermission) { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } potentialLinks := w.PotentialLinks() if !potentialLinks[azureshared.SQLServer] { t.Error("Expected PotentialLinks to include SQLServer") } if !potentialLinks[azureshared.SQLDatabase] { t.Error("Expected PotentialLinks to include SQLDatabase") } if !potentialLinks[azureshared.MaintenanceMaintenanceConfiguration] { t.Error("Expected PotentialLinks to include MaintenanceMaintenanceConfiguration") } mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_mssql_elasticpool.id" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_mssql_elasticpool.id' mapping") } }) } func createAzureSqlElasticPool(serverName, elasticPoolName string) *armsql.ElasticPool { poolID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/" + serverName + "/elasticPools/" + elasticPoolName state := armsql.ElasticPoolStateReady return &armsql.ElasticPool{ Name: &elasticPoolName, ID: &poolID, Properties: &armsql.ElasticPoolProperties{ State: &state, }, } } ================================================ FILE: sources/azure/manual/sql-server-failover-group.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var SQLServerFailoverGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLServerFailoverGroup) type sqlServerFailoverGroupWrapper struct { client clients.SqlFailoverGroupsClient *azureshared.MultiResourceGroupBase } func NewSqlServerFailoverGroup(client clients.SqlFailoverGroupsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlServerFailoverGroupWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLServerFailoverGroup, ), } } // Get retrieves a specific failover group by server name and failover group name // ref: https://learn.microsoft.com/en-us/rest/api/sql/failover-groups/get func (c sqlServerFailoverGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and failoverGroupName", Scope: scope, ItemType: c.Type(), } } serverName := queryParts[0] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: c.Type(), } } failoverGroupName := queryParts[1] if failoverGroupName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "failoverGroupName cannot be empty", Scope: scope, ItemType: c.Type(), } } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, serverName, failoverGroupName) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureFailoverGroupToSDPItem(&resp.FailoverGroup, serverName, scope) } // Search retrieves all failover groups for a given server // ref: https://learn.microsoft.com/en-us/rest/api/sql/failover-groups/list-by-server func (c sqlServerFailoverGroupWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: c.Type(), } } serverName := queryParts[0] if serverName == "" { return nil, azureshared.QueryError(errors.New("serverName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, failoverGroup := range page.Value { if failoverGroup.Name == nil { continue } item, sdpErr := c.azureFailoverGroupToSDPItem(failoverGroup, serverName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } // SearchStream streams all failover groups for a given server func (c sqlServerFailoverGroupWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, c.Type())) return } serverName := queryParts[0] if serverName == "" { stream.SendError(azureshared.QueryError(errors.New("serverName cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, failoverGroup := range page.Value { if failoverGroup.Name == nil { continue } item, sdpErr := c.azureFailoverGroupToSDPItem(failoverGroup, serverName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c sqlServerFailoverGroupWrapper) azureFailoverGroupToSDPItem(failoverGroup *armsql.FailoverGroup, serverName, scope string) (*sdp.Item, *sdp.QueryError) { if failoverGroup.Name == nil { return nil, azureshared.QueryError(errors.New("failover group name is nil"), scope, c.Type()) } failoverGroupName := *failoverGroup.Name attributes, err := shared.ToAttributesWithExclude(failoverGroup, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, failoverGroupName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLServerFailoverGroup.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(failoverGroup.Tags), } // Health mapping based on replication state if failoverGroup.Properties != nil && failoverGroup.Properties.ReplicationState != nil { switch *failoverGroup.Properties.ReplicationState { case "CATCH_UP", "PENDING", "SEEDING": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "SUSPENDED": sdpItem.Health = sdp.Health_HEALTH_WARNING.Enum() case "": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link back to the parent SQL Server sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) if failoverGroup.Properties != nil { // Link to partner servers if failoverGroup.Properties.PartnerServers != nil { for _, partner := range failoverGroup.Properties.PartnerServers { if partner != nil && partner.ID != nil && *partner.ID != "" { partnerServerName := azureshared.ExtractResourceName(*partner.ID) if partnerServerName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*partner.ID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: partnerServerName, Scope: linkedScope, }, }) } } } } // Link to databases in the failover group if failoverGroup.Properties.Databases != nil { for _, databaseID := range failoverGroup.Properties.Databases { if databaseID != nil && *databaseID != "" { // Extract server name and database name from the database resource ID params := azureshared.ExtractPathParamsFromResourceID(*databaseID, []string{"servers", "databases"}) if len(params) >= 2 { dbServerName := params[0] dbName := params[1] linkedScope := azureshared.ExtractScopeFromResourceID(*databaseID) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLDatabase.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(dbServerName, dbName), Scope: linkedScope, }, }) } } } } // Link to read-only endpoint target server if specified if failoverGroup.Properties.ReadOnlyEndpoint != nil && failoverGroup.Properties.ReadOnlyEndpoint.TargetServer != nil && *failoverGroup.Properties.ReadOnlyEndpoint.TargetServer != "" { // TargetServer is a resource ID targetServerName := azureshared.ExtractResourceName(*failoverGroup.Properties.ReadOnlyEndpoint.TargetServer) if targetServerName != "" { linkedScope := azureshared.ExtractScopeFromResourceID(*failoverGroup.Properties.ReadOnlyEndpoint.TargetServer) if linkedScope == "" { linkedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: targetServerName, Scope: linkedScope, }, }) } } } return sdpItem, nil } func (c sqlServerFailoverGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLServerFailoverGroupLookupByName, } } func (c sqlServerFailoverGroupWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, }, } } func (c sqlServerFailoverGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.SQLServer, azureshared.SQLDatabase, ) } // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftsql func (c sqlServerFailoverGroupWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/failoverGroups/read", } } func (c sqlServerFailoverGroupWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-server-failover-group_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockSqlFailoverGroupsPager is a simple mock implementation of SqlFailoverGroupsPager type mockSqlFailoverGroupsPager struct { pages []armsql.FailoverGroupsClientListByServerResponse index int } func (m *mockSqlFailoverGroupsPager) More() bool { return m.index < len(m.pages) } func (m *mockSqlFailoverGroupsPager) NextPage(ctx context.Context) (armsql.FailoverGroupsClientListByServerResponse, error) { if m.index >= len(m.pages) { return armsql.FailoverGroupsClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorSqlFailoverGroupsPager is a mock pager that always returns an error type errorSqlFailoverGroupsPager struct{} func (e *errorSqlFailoverGroupsPager) More() bool { return true } func (e *errorSqlFailoverGroupsPager) NextPage(ctx context.Context) (armsql.FailoverGroupsClientListByServerResponse, error) { return armsql.FailoverGroupsClientListByServerResponse{}, errors.New("pager error") } // testSqlFailoverGroupsClient wraps the mock to implement the correct interface type testSqlFailoverGroupsClient struct { *mocks.MockSqlFailoverGroupsClient pager clients.SqlFailoverGroupsPager } func (t *testSqlFailoverGroupsClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlFailoverGroupsPager { return t.pager } func TestSqlServerFailoverGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" failoverGroupName := "test-failover-group" t.Run("Get", func(t *testing.T) { failoverGroup := createAzureSqlServerFailoverGroup(subscriptionID, resourceGroup, serverName, failoverGroupName) mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, failoverGroupName).Return( armsql.FailoverGroupsClientGetResponse{ FailoverGroup: *failoverGroup, }, nil) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, failoverGroupName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLServerFailoverGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerFailoverGroup, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, failoverGroupName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // SQLServer link (parent) ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Partner server link ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "partner-server", ExpectedScope: subscriptionID + ".partner-rg", }, { // Database link ExpectedType: azureshared.SQLDatabase.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(serverName, "test-database"), ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Only provide serverName without failoverGroupName _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Provide empty server name and valid failover group name // Call wrapper.Get directly to get *sdp.QueryError _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0], "", failoverGroupName) if qErr == nil { t.Fatal("Expected error when serverName is empty, but got nil") } if qErr.GetErrorString() != "serverName cannot be empty" { t.Errorf("Expected error string 'serverName cannot be empty', got: %s", qErr.GetErrorString()) } }) t.Run("GetWithEmptyFailoverGroupName", func(t *testing.T) { mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Provide valid server name and empty failover group name // Call wrapper.Get directly to get *sdp.QueryError _, qErr := wrapper.Get(ctx, wrapper.Scopes()[0], serverName, "") if qErr == nil { t.Fatal("Expected error when failoverGroupName is empty, but got nil") } if qErr.GetErrorString() != "failoverGroupName cannot be empty" { t.Errorf("Expected error string 'failoverGroupName cannot be empty', got: %s", qErr.GetErrorString()) } }) t.Run("Search", func(t *testing.T) { failoverGroup1 := createAzureSqlServerFailoverGroup(subscriptionID, resourceGroup, serverName, "failover-group-1") failoverGroup2 := createAzureSqlServerFailoverGroup(subscriptionID, resourceGroup, serverName, "failover-group-2") mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) mockPager := &mockSqlFailoverGroupsPager{ pages: []armsql.FailoverGroupsClientListByServerResponse{ { FailoverGroupListResult: armsql.FailoverGroupListResult{ Value: []*armsql.FailoverGroup{failoverGroup1, failoverGroup2}, }, }, }, } testClient := &testSqlFailoverGroupsClient{ MockSqlFailoverGroupsClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.SQLServerFailoverGroup.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerFailoverGroup, item.GetType()) } } }) t.Run("SearchStream", func(t *testing.T) { failoverGroup1 := createAzureSqlServerFailoverGroup(subscriptionID, resourceGroup, serverName, "failover-group-1") failoverGroup2 := createAzureSqlServerFailoverGroup(subscriptionID, resourceGroup, serverName, "failover-group-2") mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) mockPager := &mockSqlFailoverGroupsPager{ pages: []armsql.FailoverGroupsClientListByServerResponse{ { FailoverGroupListResult: armsql.FailoverGroupListResult{ Value: []*armsql.FailoverGroup{failoverGroup1, failoverGroup2}, }, }, }, } testClient := &testSqlFailoverGroupsClient{ MockSqlFailoverGroupsClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("SearchWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with empty server name _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when serverName is empty, but got nil") } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_WithNilName", func(t *testing.T) { failoverGroup1 := createAzureSqlServerFailoverGroup(subscriptionID, resourceGroup, serverName, "failover-group-1") failoverGroup2 := &armsql.FailoverGroup{ Name: nil, // FailoverGroup with nil name should be skipped ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/failoverGroups/failover-group-2"), Tags: map[string]*string{ "env": new("test"), }, } mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) mockPager := &mockSqlFailoverGroupsPager{ pages: []armsql.FailoverGroupsClientListByServerResponse{ { FailoverGroupListResult: armsql.FailoverGroupListResult{ Value: []*armsql.FailoverGroup{failoverGroup1, failoverGroup2}, }, }, }, } testClient := &testSqlFailoverGroupsClient{ MockSqlFailoverGroupsClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (failover group with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("failover group not found") mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-failover-group").Return( armsql.FailoverGroupsClientGetResponse{}, expectedErr) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-failover-group") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent failover group, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) errorPager := &errorSqlFailoverGroupsPager{} testClient := &testSqlFailoverGroupsClient{ MockSqlFailoverGroupsClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlFailoverGroupsClient(ctrl) testClient := &testSqlFailoverGroupsClient{MockSqlFailoverGroupsClient: mockClient} wrapper := manual.NewSqlServerFailoverGroup(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/failoverGroups/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.SQLServer] { t.Error("Expected PotentialLinks to include SQLServer") } if !potentialLinks[azureshared.SQLDatabase] { t.Error("Expected PotentialLinks to include SQLDatabase") } }) } // createAzureSqlServerFailoverGroup creates a mock Azure SQL Server Failover Group for testing func createAzureSqlServerFailoverGroup(subscriptionID, resourceGroup, serverName, failoverGroupName string) *armsql.FailoverGroup { failoverGroupID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Sql/servers/" + serverName + "/failoverGroups/" + failoverGroupName partnerServerID := "/subscriptions/" + subscriptionID + "/resourceGroups/partner-rg/providers/Microsoft.Sql/servers/partner-server" databaseID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Sql/servers/" + serverName + "/databases/test-database" replicationState := "" return &armsql.FailoverGroup{ Name: new(failoverGroupName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, ID: new(failoverGroupID), Properties: &armsql.FailoverGroupProperties{ ReplicationState: &replicationState, PartnerServers: []*armsql.PartnerInfo{ { ID: new(partnerServerID), Location: new("westus"), }, }, Databases: []*string{ new(databaseID), }, ReadWriteEndpoint: &armsql.FailoverGroupReadWriteEndpoint{ FailoverPolicy: new(armsql.ReadWriteEndpointFailoverPolicyAutomatic), }, }, } } ================================================ FILE: sources/azure/manual/sql-server-firewall-rule.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var SQLServerFirewallRuleLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLServerFirewallRule) type sqlServerFirewallRuleWrapper struct { client clients.SqlServerFirewallRuleClient *azureshared.MultiResourceGroupBase } func NewSqlServerFirewallRule(client clients.SqlServerFirewallRuleClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlServerFirewallRuleWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLServerFirewallRule, ), } } func (s sqlServerFirewallRuleWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and firewallRuleName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] firewallRuleName := queryParts[1] if firewallRuleName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "firewallRuleName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, firewallRuleName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureSqlServerFirewallRuleToSDPItem(&resp.FirewallRule, serverName, firewallRuleName, scope) } func (s sqlServerFirewallRuleWrapper) azureSqlServerFirewallRuleToSDPItem(rule *armsql.FirewallRule, serverName, firewallRuleName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(rule, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, firewallRuleName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLServerFirewallRule.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: nil, // FirewallRule has no Tags in the Azure SDK } // Link to parent SQL Server (from resource ID or known server name) if rule.ID != nil { extractedServerName := azureshared.ExtractSQLServerNameFromDatabaseID(*rule.ID) if extractedServerName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: extractedServerName, Scope: scope, }, }) } } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } // Link to stdlib IP items for StartIPAddress and EndIPAddress (global scope, GET) if rule.Properties != nil { if rule.Properties.StartIPAddress != nil && *rule.Properties.StartIPAddress != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *rule.Properties.StartIPAddress, Scope: "global", }, }) } if rule.Properties.EndIPAddress != nil && *rule.Properties.EndIPAddress != "" && (rule.Properties.StartIPAddress == nil || *rule.Properties.EndIPAddress != *rule.Properties.StartIPAddress) { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: *rule.Properties.EndIPAddress, Scope: "global", }, }) } } return sdpItem, nil } func (s sqlServerFirewallRuleWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLServerFirewallRuleLookupByName, } } func (s sqlServerFirewallRuleWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, rule := range page.Value { if rule.Name == nil { continue } item, sdpErr := s.azureSqlServerFirewallRuleToSDPItem(rule, serverName, *rule.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s sqlServerFirewallRuleWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, rule := range page.Value { if rule.Name == nil { continue } item, sdpErr := s.azureSqlServerFirewallRuleToSDPItem(rule, serverName, *rule.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s sqlServerFirewallRuleWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, }, } } func (s sqlServerFirewallRuleWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.SQLServer: true, stdlib.NetworkIP: true, } } func (s sqlServerFirewallRuleWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_mssql_firewall_rule.id", }, } } func (s sqlServerFirewallRuleWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/firewallRules/read", } } func (s sqlServerFirewallRuleWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-server-firewall-rule_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type mockSqlServerFirewallRulePager struct { pages []armsql.FirewallRulesClientListByServerResponse index int } func (m *mockSqlServerFirewallRulePager) More() bool { return m.index < len(m.pages) } func (m *mockSqlServerFirewallRulePager) NextPage(ctx context.Context) (armsql.FirewallRulesClientListByServerResponse, error) { if m.index >= len(m.pages) { return armsql.FirewallRulesClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorSqlServerFirewallRulePager struct{} func (e *errorSqlServerFirewallRulePager) More() bool { return true } func (e *errorSqlServerFirewallRulePager) NextPage(ctx context.Context) (armsql.FirewallRulesClientListByServerResponse, error) { return armsql.FirewallRulesClientListByServerResponse{}, errors.New("pager error") } type testSqlServerFirewallRuleClient struct { *mocks.MockSqlServerFirewallRuleClient pager clients.SqlServerFirewallRulePager } func (t *testSqlServerFirewallRuleClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlServerFirewallRulePager { return t.pager } func TestSqlServerFirewallRule(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" firewallRuleName := "test-rule" t.Run("Get", func(t *testing.T) { rule := createAzureSqlServerFirewallRule(serverName, firewallRuleName) mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, firewallRuleName).Return( armsql.FirewallRulesClientGetResponse{ FirewallRule: *rule, }, nil) wrapper := manual.NewSqlServerFirewallRule(&testSqlServerFirewallRuleClient{MockSqlServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, firewallRuleName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLServerFirewallRule.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerFirewallRule, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, firewallRuleName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "0.0.0.0", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "255.255.255.255", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) wrapper := manual.NewSqlServerFirewallRule(&testSqlServerFirewallRuleClient{MockSqlServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing only serverName (1 query part), but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) wrapper := manual.NewSqlServerFirewallRule(&testSqlServerFirewallRuleClient{MockSqlServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when firewall rule name is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { rule1 := createAzureSqlServerFirewallRule(serverName, "rule1") rule2 := createAzureSqlServerFirewallRule(serverName, "rule2") mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) pager := &mockSqlServerFirewallRulePager{ pages: []armsql.FirewallRulesClientListByServerResponse{ { FirewallRuleListResult: armsql.FirewallRuleListResult{ Value: []*armsql.FirewallRule{rule1, rule2}, }, }, }, } testClient := &testSqlServerFirewallRuleClient{ MockSqlServerFirewallRuleClient: mockClient, pager: pager, } wrapper := manual.NewSqlServerFirewallRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error from Search, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items from Search, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { rule1 := createAzureSqlServerFirewallRule(serverName, "rule1") mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) pager := &mockSqlServerFirewallRulePager{ pages: []armsql.FirewallRulesClientListByServerResponse{ { FirewallRuleListResult: armsql.FirewallRuleListResult{ Value: []*armsql.FirewallRule{rule1}, }, }, }, } testClient := &testSqlServerFirewallRuleClient{ MockSqlServerFirewallRuleClient: mockClient, pager: pager, } wrapper := manual.NewSqlServerFirewallRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) items := stream.GetItems() errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("Expected no errors from SearchStream, got: %v", errs) } if len(items) != 1 { t.Errorf("Expected 1 item from SearchStream, got %d", len(items)) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) wrapper := manual.NewSqlServerFirewallRule(&testSqlServerFirewallRuleClient{MockSqlServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("firewall rule not found") mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-rule").Return( armsql.FirewallRulesClientGetResponse{}, expectedErr) wrapper := manual.NewSqlServerFirewallRule(&testSqlServerFirewallRuleClient{MockSqlServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-rule") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent firewall rule, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) errorPager := &errorSqlServerFirewallRulePager{} testClient := &testSqlServerFirewallRuleClient{ MockSqlServerFirewallRuleClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlServerFirewallRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error from Search when pager returns error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlServerFirewallRuleClient(ctrl) wrapper := manual.NewSqlServerFirewallRule(&testSqlServerFirewallRuleClient{MockSqlServerFirewallRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/firewallRules/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } potentialLinks := w.PotentialLinks() if !potentialLinks[azureshared.SQLServer] { t.Error("Expected PotentialLinks to include SQLServer") } if !potentialLinks[stdlib.NetworkIP] { t.Error("Expected PotentialLinks to include stdlib.NetworkIP") } mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_mssql_firewall_rule.id" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_mssql_firewall_rule.id' mapping") } }) } func createAzureSqlServerFirewallRule(serverName, firewallRuleName string) *armsql.FirewallRule { ruleID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/" + serverName + "/firewallRules/" + firewallRuleName return &armsql.FirewallRule{ Name: new(firewallRuleName), ID: new(ruleID), Properties: &armsql.ServerFirewallRuleProperties{ StartIPAddress: new("0.0.0.0"), EndIPAddress: new("255.255.255.255"), }, } } ================================================ FILE: sources/azure/manual/sql-server-key.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var SQLServerKeyLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLServerKey) type sqlServerKeyWrapper struct { client clients.SqlServerKeysClient *azureshared.MultiResourceGroupBase } func NewSqlServerKey(client clients.SqlServerKeysClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlServerKeyWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLServerKey, ), } } // Get retrieves a single SQL Server Key by serverName and keyName // ref: https://learn.microsoft.com/en-us/rest/api/sql/server-keys/get func (c sqlServerKeyWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and keyName", Scope: scope, ItemType: c.Type(), } } serverName := queryParts[0] keyName := queryParts[1] if serverName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "serverName cannot be empty", Scope: scope, ItemType: c.Type(), } } if keyName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "keyName cannot be empty", Scope: scope, ItemType: c.Type(), } } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } resp, err := c.client.Get(ctx, rgScope.ResourceGroup, serverName, keyName) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } return c.azureSqlServerKeyToSDPItem(&resp.ServerKey, serverName, scope) } func (c sqlServerKeyWrapper) azureSqlServerKeyToSDPItem(serverKey *armsql.ServerKey, serverName, scope string) (*sdp.Item, *sdp.QueryError) { if serverKey.Name == nil { return nil, azureshared.QueryError(errors.New("server key name is nil"), scope, c.Type()) } keyName := *serverKey.Name attributes, err := shared.ToAttributesWithExclude(serverKey) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, keyName)) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLServerKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link back to parent SQL Server if serverName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } // Link to Key Vault Key if this is an Azure Key Vault type key // The URI field contains the Key Vault key URI for AzureKeyVault server key types // URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} if serverKey.Properties != nil && serverKey.Properties.URI != nil && *serverKey.Properties.URI != "" { keyURI := *serverKey.Properties.URI vaultName := azureshared.ExtractVaultNameFromURI(keyURI) keyVaultKeyName := azureshared.ExtractKeyNameFromURI(keyURI) if vaultName != "" && keyVaultKeyName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, keyVaultKeyName), Scope: scope, }, }) } } return sdpItem, nil } // Search retrieves all SQL Server Keys for a given server // ref: https://learn.microsoft.com/en-us/rest/api/sql/server-keys/list-by-server func (c sqlServerKeyWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: c.Type(), } } serverName := queryParts[0] if serverName == "" { return nil, azureshared.QueryError(errors.New("serverName cannot be empty"), scope, c.Type()) } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } pager := c.client.NewListByServerPager(rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, c.Type()) } for _, serverKey := range page.Value { if serverKey.Name == nil { continue } item, sdpErr := c.azureSqlServerKeyToSDPItem(serverKey, serverName, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (c sqlServerKeyWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, c.Type())) return } serverName := queryParts[0] if serverName == "" { stream.SendError(azureshared.QueryError(errors.New("serverName cannot be empty"), scope, c.Type())) return } rgScope, err := c.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } pager := c.client.NewListByServerPager(rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, c.Type())) return } for _, serverKey := range page.Value { if serverKey.Name == nil { continue } item, sdpErr := c.azureSqlServerKeyToSDPItem(serverKey, serverName, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (c sqlServerKeyWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLServerKeyLookupByName, } } func (c sqlServerKeyWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, }, } } func (c sqlServerKeyWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.SQLServer, azureshared.KeyVaultKey, ) } // IAMPermissions returns the required Azure RBAC permissions for reading SQL Server Keys // ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/resource-provider-operations#microsoftsql func (c sqlServerKeyWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/keys/read", } } func (c sqlServerKeyWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-server-key_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockSqlServerKeysPager is a simple mock implementation of SqlServerKeysPager type mockSqlServerKeysPager struct { pages []armsql.ServerKeysClientListByServerResponse index int } func (m *mockSqlServerKeysPager) More() bool { return m.index < len(m.pages) } func (m *mockSqlServerKeysPager) NextPage(ctx context.Context) (armsql.ServerKeysClientListByServerResponse, error) { if m.index >= len(m.pages) { return armsql.ServerKeysClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorSqlServerKeysPager is a mock pager that always returns an error type errorSqlServerKeysPager struct{} func (e *errorSqlServerKeysPager) More() bool { return true } func (e *errorSqlServerKeysPager) NextPage(ctx context.Context) (armsql.ServerKeysClientListByServerResponse, error) { return armsql.ServerKeysClientListByServerResponse{}, errors.New("pager error") } // testSqlServerKeysClient wraps the mock to implement the correct interface type testSqlServerKeysClient struct { *mocks.MockSqlServerKeysClient pager clients.SqlServerKeysPager } func (t *testSqlServerKeysClient) NewListByServerPager(resourceGroupName, serverName string) clients.SqlServerKeysPager { return t.pager } func TestSqlServerKey(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" keyName := "test-key" t.Run("Get", func(t *testing.T) { serverKey := createAzureSqlServerKey(serverName, keyName, "") mockClient := mocks.NewMockSqlServerKeysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, keyName).Return( armsql.ServerKeysClientGetResponse{ ServerKey: *serverKey, }, nil) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires serverName and keyName as query parts query := shared.CompositeLookupKey(serverName, keyName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLServerKey.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerKey, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, keyName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // SQLServer link (parent) ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithKeyVaultKey", func(t *testing.T) { keyVaultKeyURI := "https://my-vault.vault.azure.net/keys/my-key/12345" serverKey := createAzureSqlServerKey(serverName, keyName, keyVaultKeyURI) mockClient := mocks.NewMockSqlServerKeysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, keyName).Return( armsql.ServerKeysClientGetResponse{ ServerKey: *serverKey, }, nil) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, keyName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // SQLServer link (parent) ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // KeyVaultKey link ExpectedType: azureshared.KeyVaultKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("my-vault", "my-key"), ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlServerKeysClient(ctrl) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only server name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("GetWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockSqlServerKeysClient(ctrl) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty server name query := shared.CompositeLookupKey("", keyName) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("GetWithEmptyKeyName", func(t *testing.T) { mockClient := mocks.NewMockSqlServerKeysClient(ctrl) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with empty key name query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when providing empty key name, but got nil") } }) t.Run("Search", func(t *testing.T) { serverKey1 := createAzureSqlServerKey(serverName, "key-1", "") serverKey2 := createAzureSqlServerKey(serverName, "key-2", "") mockClient := mocks.NewMockSqlServerKeysClient(ctrl) mockPager := &mockSqlServerKeysPager{ pages: []armsql.ServerKeysClientListByServerResponse{ { ServerKeyListResult: armsql.ServerKeyListResult{ Value: []*armsql.ServerKey{serverKey1, serverKey2}, }, }, }, } testClient := &testSqlServerKeysClient{ MockSqlServerKeysClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.SQLServerKey.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerKey, item.GetType()) } } }) t.Run("SearchStream", func(t *testing.T) { serverKey1 := createAzureSqlServerKey(serverName, "key-1", "") serverKey2 := createAzureSqlServerKey(serverName, "key-2", "") mockClient := mocks.NewMockSqlServerKeysClient(ctrl) mockPager := &mockSqlServerKeysPager{ pages: []armsql.ServerKeysClientListByServerResponse{ { ServerKeyListResult: armsql.ServerKeyListResult{ Value: []*armsql.ServerKey{serverKey1, serverKey2}, }, }, }, } testClient := &testSqlServerKeysClient{ MockSqlServerKeysClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("Search_WithNilName", func(t *testing.T) { serverKey1 := createAzureSqlServerKey(serverName, "key-1", "") serverKey2 := &armsql.ServerKey{ Name: nil, // Key with nil name should be skipped Location: new("eastus"), ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/keys/key-2"), Properties: &armsql.ServerKeyProperties{ ServerKeyType: new(armsql.ServerKeyTypeServiceManaged), }, } mockClient := mocks.NewMockSqlServerKeysClient(ctrl) mockPager := &mockSqlServerKeysPager{ pages: []armsql.ServerKeysClientListByServerResponse{ { ServerKeyListResult: armsql.ServerKeyListResult{ Value: []*armsql.ServerKey{serverKey1, serverKey2}, }, }, }, } testClient := &testSqlServerKeysClient{ MockSqlServerKeysClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (key with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(serverName, "key-1") { t.Fatalf("Expected key name 'key-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlServerKeysClient(ctrl) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling NewListByServerPager _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("SearchWithEmptyServerName", func(t *testing.T) { mockClient := mocks.NewMockSqlServerKeysClient(ctrl) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search with empty server name _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], "") if qErr == nil { t.Error("Expected error when providing empty server name in Search, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("key not found") mockClient := mocks.NewMockSqlServerKeysClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-key").Return( armsql.ServerKeysClientGetResponse{}, expectedErr) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-key") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent key, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSqlServerKeysClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorSqlServerKeysPager{} testClient := &testSqlServerKeysClient{ MockSqlServerKeysClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) // The Search implementation should return an error when pager.NextPage returns an error if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlServerKeysClient(ctrl) testClient := &testSqlServerKeysClient{MockSqlServerKeysClient: mockClient} wrapper := manual.NewSqlServerKey(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/keys/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } if !potentialLinks[azureshared.SQLServer] { t.Error("Expected PotentialLinks to include SQLServer") } if !potentialLinks[azureshared.KeyVaultKey] { t.Error("Expected PotentialLinks to include KeyVaultKey") } // Verify PredefinedRole using type assertion to the searchable wrapper if sw, ok := wrapper.(interface{ PredefinedRole() string }); ok { role := sw.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } }) } // createAzureSqlServerKey creates a mock Azure SQL Server Key for testing func createAzureSqlServerKey(serverName, keyName, keyVaultKeyURI string) *armsql.ServerKey { keyID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/" + serverName + "/keys/" + keyName keyType := armsql.ServerKeyTypeServiceManaged if keyVaultKeyURI != "" { keyType = armsql.ServerKeyTypeAzureKeyVault } serverKey := &armsql.ServerKey{ Name: new(keyName), Location: new("eastus"), ID: new(keyID), Properties: &armsql.ServerKeyProperties{ ServerKeyType: new(keyType), }, } if keyVaultKeyURI != "" { serverKey.Properties.URI = new(keyVaultKeyURI) } return serverKey } ================================================ FILE: sources/azure/manual/sql-server-private-endpoint-connection.go ================================================ package manual import ( "context" "errors" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var SQLServerPrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLServerPrivateEndpointConnection) type sqlServerPrivateEndpointConnectionWrapper struct { client clients.SQLServerPrivateEndpointConnectionsClient *azureshared.MultiResourceGroupBase } // NewSQLServerPrivateEndpointConnection returns a SearchableWrapper for Azure SQL server private endpoint connections. func NewSQLServerPrivateEndpointConnection(client clients.SQLServerPrivateEndpointConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlServerPrivateEndpointConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLServerPrivateEndpointConnection, ), } } func (s sqlServerPrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and privateEndpointConnectionName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] connectionName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, connectionName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(&resp.PrivateEndpointConnection, serverName, connectionName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s sqlServerPrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLServerPrivateEndpointConnectionLookupByName, } } func (s sqlServerPrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, serverName, *conn.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s sqlServerPrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, conn := range page.Value { if conn == nil || conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, serverName, *conn.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s sqlServerPrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, }, } } func (s sqlServerPrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.SQLServer: true, azureshared.NetworkPrivateEndpoint: true, } } func (s sqlServerPrivateEndpointConnectionWrapper) azurePrivateEndpointConnectionToSDPItem(conn *armsql.PrivateEndpointConnection, serverName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(conn) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, connectionName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLServerPrivateEndpointConnection.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health from provisioning state (armsql uses PrivateEndpointProvisioningState enum) if conn.Properties != nil && conn.Properties.ProvisioningState != nil { state := strings.ToLower(string(*conn.Properties.ProvisioningState)) switch state { case "ready": sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case "approving", "dropping": sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case "failed", "rejecting": sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent SQL Server sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) // Link to Network Private Endpoint when present (may be in different resource group) if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { peID := *conn.Properties.PrivateEndpoint.ID peName := azureshared.ExtractResourceName(peID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (s sqlServerPrivateEndpointConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/privateEndpointConnections/read", } } func (s sqlServerPrivateEndpointConnectionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-server-private-endpoint-connection_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockSQLServerPrivateEndpointConnectionsPager struct { pages []armsql.PrivateEndpointConnectionsClientListByServerResponse index int } func (m *mockSQLServerPrivateEndpointConnectionsPager) More() bool { return m.index < len(m.pages) } func (m *mockSQLServerPrivateEndpointConnectionsPager) NextPage(ctx context.Context) (armsql.PrivateEndpointConnectionsClientListByServerResponse, error) { if m.index >= len(m.pages) { return armsql.PrivateEndpointConnectionsClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type testSQLServerPrivateEndpointConnectionsClient struct { *mocks.MockSQLServerPrivateEndpointConnectionsClient pager clients.SQLServerPrivateEndpointConnectionsPager } func (t *testSQLServerPrivateEndpointConnectionsClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SQLServerPrivateEndpointConnectionsPager { return t.pager } func TestSQLServerPrivateEndpointConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-sql-server" connectionName := "test-pec" t.Run("Get", func(t *testing.T) { conn := createAzureSQLServerPrivateEndpointConnection(connectionName, "") mockClient := mocks.NewMockSQLServerPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, connectionName).Return( armsql.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testSQLServerPrivateEndpointConnectionsClient{MockSQLServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewSQLServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLServerPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerPrivateEndpointConnection, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(serverName, connectionName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(serverName, connectionName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) < 1 { t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) } foundSQLServer := false for _, lq := range linkedQueries { if lq.GetQuery().GetType() == azureshared.SQLServer.String() { foundSQLServer = true if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected SQLServer link method GET, got %v", lq.GetQuery().GetMethod()) } if lq.GetQuery().GetQuery() != serverName { t.Errorf("Expected SQLServer query %s, got %s", serverName, lq.GetQuery().GetQuery()) } } } if !foundSQLServer { t.Error("Expected linked query to SQLServer") } }) }) t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { peID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe" conn := createAzureSQLServerPrivateEndpointConnection(connectionName, peID) mockClient := mocks.NewMockSQLServerPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, connectionName).Return( armsql.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testSQLServerPrivateEndpointConnectionsClient{MockSQLServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewSQLServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundPrivateEndpoint := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { foundPrivateEndpoint = true if lq.GetQuery().GetQuery() != "test-pe" { t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) } break } } if !foundPrivateEndpoint { t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSQLServerPrivateEndpointConnectionsClient(ctrl) testClient := &testSQLServerPrivateEndpointConnectionsClient{MockSQLServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewSQLServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { conn1 := createAzureSQLServerPrivateEndpointConnection("pec-1", "") conn2 := createAzureSQLServerPrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockSQLServerPrivateEndpointConnectionsClient(ctrl) mockPager := &mockSQLServerPrivateEndpointConnectionsPager{ pages: []armsql.PrivateEndpointConnectionsClientListByServerResponse{ { PrivateEndpointConnectionListResult: armsql.PrivateEndpointConnectionListResult{ Value: []*armsql.PrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testSQLServerPrivateEndpointConnectionsClient{ MockSQLServerPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewSQLServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.SQLServerPrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerPrivateEndpointConnection, item.GetType()) } } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validConn := createAzureSQLServerPrivateEndpointConnection("valid-pec", "") mockClient := mocks.NewMockSQLServerPrivateEndpointConnectionsClient(ctrl) mockPager := &mockSQLServerPrivateEndpointConnectionsPager{ pages: []armsql.PrivateEndpointConnectionsClientListByServerResponse{ { PrivateEndpointConnectionListResult: armsql.PrivateEndpointConnectionListResult{ Value: []*armsql.PrivateEndpointConnection{ {Name: nil}, validConn, }, }, }, }, } testClient := &testSQLServerPrivateEndpointConnectionsClient{ MockSQLServerPrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewSQLServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(serverName, "valid-pec") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(serverName, "valid-pec"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSQLServerPrivateEndpointConnectionsClient(ctrl) testClient := &testSQLServerPrivateEndpointConnectionsClient{MockSQLServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewSQLServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("private endpoint connection not found") mockClient := mocks.NewMockSQLServerPrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-pec").Return( armsql.PrivateEndpointConnectionsClientGetResponse{}, expectedErr) testClient := &testSQLServerPrivateEndpointConnectionsClient{MockSQLServerPrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewSQLServerPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-pec") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent private endpoint connection, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewSQLServerPrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.SQLServer] { t.Error("Expected SQLServer in PotentialLinks") } if !links[azureshared.NetworkPrivateEndpoint] { t.Error("Expected NetworkPrivateEndpoint in PotentialLinks") } }) } func createAzureSQLServerPrivateEndpointConnection(connectionName, privateEndpointID string) *armsql.PrivateEndpointConnection { ready := armsql.PrivateEndpointProvisioningStateReady approved := armsql.PrivateLinkServiceConnectionStateStatusApproved conn := &armsql.PrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-sql-server/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.Sql/servers/privateEndpointConnections"), Properties: &armsql.PrivateEndpointConnectionProperties{ ProvisioningState: &ready, PrivateLinkServiceConnectionState: &armsql.PrivateLinkServiceConnectionStateProperty{ Status: &approved, }, }, } if privateEndpointID != "" { conn.Properties.PrivateEndpoint = &armsql.PrivateEndpointProperty{ ID: new(privateEndpointID), } } return conn } ================================================ FILE: sources/azure/manual/sql-server-virtual-network-rule.go ================================================ package manual import ( "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var SQLServerVirtualNetworkRuleLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLServerVirtualNetworkRule) type sqlServerVirtualNetworkRuleWrapper struct { client clients.SqlServerVirtualNetworkRuleClient *azureshared.MultiResourceGroupBase } func NewSqlServerVirtualNetworkRule(client clients.SqlServerVirtualNetworkRuleClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &sqlServerVirtualNetworkRuleWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLServerVirtualNetworkRule, ), } } func (s sqlServerVirtualNetworkRuleWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: serverName and virtualNetworkRuleName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] ruleName := queryParts[1] if ruleName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "virtualNetworkRuleName cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, ruleName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureSqlServerVirtualNetworkRuleToSDPItem(&resp.VirtualNetworkRule, serverName, ruleName, scope) } func (s sqlServerVirtualNetworkRuleWrapper) azureSqlServerVirtualNetworkRuleToSDPItem(rule *armsql.VirtualNetworkRule, serverName, ruleName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(rule, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serverName, ruleName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.SQLServerVirtualNetworkRule.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: nil, // VirtualNetworkRule has no Tags in the Azure SDK } // Link to parent SQL Server (from resource ID or known server name) if rule.ID != nil { extractedServerName := azureshared.ExtractSQLServerNameFromDatabaseID(*rule.ID) if extractedServerName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: extractedServerName, Scope: scope, }, }) } } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServer.String(), Method: sdp.QueryMethod_GET, Query: serverName, Scope: scope, }, }) } // Link to Virtual Network and Subnet when VirtualNetworkSubnetID is set // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName} if rule.Properties != nil && rule.Properties.VirtualNetworkSubnetID != nil { subnetID := *rule.Properties.VirtualNetworkSubnetID scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subscriptionID := scopeParams[0] resourceGroupName := scopeParams[1] vnetName := subnetParams[0] subnetName := subnetParams[1] subnetScope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) // Link to Virtual Network sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkVirtualNetwork.String(), Method: sdp.QueryMethod_GET, Query: vnetName, Scope: subnetScope, }, }) // Link to Subnet sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vnetName, subnetName), Scope: subnetScope, }, }) } } return sdpItem, nil } func (s sqlServerVirtualNetworkRuleWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, SQLServerVirtualNetworkRuleLookupByName, } } func (s sqlServerVirtualNetworkRuleWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: serverName", Scope: scope, ItemType: s.Type(), } } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, rule := range page.Value { if rule.Name == nil { continue } item, sdpErr := s.azureSqlServerVirtualNetworkRuleToSDPItem(rule, serverName, *rule.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s sqlServerVirtualNetworkRuleWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: serverName"), scope, s.Type())) return } serverName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByServer(ctx, rgScope.ResourceGroup, serverName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, rule := range page.Value { if rule.Name == nil { continue } item, sdpErr := s.azureSqlServerVirtualNetworkRuleToSDPItem(rule, serverName, *rule.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s sqlServerVirtualNetworkRuleWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { SQLServerLookupByName, }, } } func (s sqlServerVirtualNetworkRuleWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.SQLServer: true, azureshared.NetworkSubnet: true, azureshared.NetworkVirtualNetwork: true, } } func (s sqlServerVirtualNetworkRuleWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_mssql_virtual_network_rule.id", }, } } func (s sqlServerVirtualNetworkRuleWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/virtualNetworkRules/read", } } func (s sqlServerVirtualNetworkRuleWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-server-virtual-network-rule_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockSqlServerVirtualNetworkRulePager struct { pages []armsql.VirtualNetworkRulesClientListByServerResponse index int } func (m *mockSqlServerVirtualNetworkRulePager) More() bool { return m.index < len(m.pages) } func (m *mockSqlServerVirtualNetworkRulePager) NextPage(ctx context.Context) (armsql.VirtualNetworkRulesClientListByServerResponse, error) { if m.index >= len(m.pages) { return armsql.VirtualNetworkRulesClientListByServerResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorSqlServerVirtualNetworkRulePager struct{} func (e *errorSqlServerVirtualNetworkRulePager) More() bool { return true } func (e *errorSqlServerVirtualNetworkRulePager) NextPage(ctx context.Context) (armsql.VirtualNetworkRulesClientListByServerResponse, error) { return armsql.VirtualNetworkRulesClientListByServerResponse{}, errors.New("pager error") } type testSqlServerVirtualNetworkRuleClient struct { *mocks.MockSqlServerVirtualNetworkRuleClient pager clients.SqlServerVirtualNetworkRulePager } func (t *testSqlServerVirtualNetworkRuleClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlServerVirtualNetworkRulePager { return t.pager } func TestSqlServerVirtualNetworkRule(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" ruleName := "test-vnet-rule" t.Run("Get", func(t *testing.T) { rule := createAzureSqlServerVirtualNetworkRule(serverName, ruleName, "") mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, ruleName).Return( armsql.VirtualNetworkRulesClientGetResponse{ VirtualNetworkRule: *rule, }, nil) wrapper := manual.NewSqlServerVirtualNetworkRule(&testSqlServerVirtualNetworkRuleClient{MockSqlServerVirtualNetworkRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, ruleName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLServerVirtualNetworkRule.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServerVirtualNetworkRule, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedUniqueAttrValue := shared.CompositeLookupKey(serverName, ruleName) if sdpItem.UniqueAttributeValue() != expectedUniqueAttrValue { t.Errorf("Expected unique attribute value %s, got %s", expectedUniqueAttrValue, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithSubnetLink", func(t *testing.T) { subnetID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" rule := createAzureSqlServerVirtualNetworkRule(serverName, ruleName, subnetID) mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, ruleName).Return( armsql.VirtualNetworkRulesClientGetResponse{ VirtualNetworkRule: *rule, }, nil) wrapper := manual.NewSqlServerVirtualNetworkRule(&testSqlServerVirtualNetworkRuleClient{MockSqlServerVirtualNetworkRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, ruleName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: azureshared.SQLServer.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkVirtualNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vnet", ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.NetworkSubnet.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-vnet", "test-subnet"), ExpectedScope: subscriptionID + "." + resourceGroup, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) wrapper := manual.NewSqlServerVirtualNetworkRule(&testSqlServerVirtualNetworkRuleClient{MockSqlServerVirtualNetworkRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr == nil { t.Error("Expected error when providing only serverName (1 query part), but got nil") } }) t.Run("GetWithEmptyName", func(t *testing.T) { mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) wrapper := manual.NewSqlServerVirtualNetworkRule(&testSqlServerVirtualNetworkRuleClient{MockSqlServerVirtualNetworkRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when virtual network rule name is empty, but got nil") } }) t.Run("Search", func(t *testing.T) { rule1 := createAzureSqlServerVirtualNetworkRule(serverName, "rule1", "") rule2 := createAzureSqlServerVirtualNetworkRule(serverName, "rule2", "") mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) pager := &mockSqlServerVirtualNetworkRulePager{ pages: []armsql.VirtualNetworkRulesClientListByServerResponse{ { VirtualNetworkRuleListResult: armsql.VirtualNetworkRuleListResult{ Value: []*armsql.VirtualNetworkRule{rule1, rule2}, }, }, }, } testClient := &testSqlServerVirtualNetworkRuleClient{ MockSqlServerVirtualNetworkRuleClient: mockClient, pager: pager, } wrapper := manual.NewSqlServerVirtualNetworkRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error from Search, got: %v", qErr) } if len(items) != 2 { t.Errorf("Expected 2 items from Search, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { rule1 := createAzureSqlServerVirtualNetworkRule(serverName, "rule1", "") mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) pager := &mockSqlServerVirtualNetworkRulePager{ pages: []armsql.VirtualNetworkRulesClientListByServerResponse{ { VirtualNetworkRuleListResult: armsql.VirtualNetworkRuleListResult{ Value: []*armsql.VirtualNetworkRule{rule1}, }, }, }, } testClient := &testSqlServerVirtualNetworkRuleClient{ MockSqlServerVirtualNetworkRuleClient: mockClient, pager: pager, } wrapper := manual.NewSqlServerVirtualNetworkRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } stream := discovery.NewRecordingQueryResultStream() searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], serverName, true, stream) items := stream.GetItems() errs := stream.GetErrors() if len(errs) > 0 { t.Fatalf("Expected no errors from SearchStream, got: %v", errs) } if len(items) != 1 { t.Errorf("Expected 1 item from SearchStream, got %d", len(items)) } }) t.Run("SearchWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) wrapper := manual.NewSqlServerVirtualNetworkRule(&testSqlServerVirtualNetworkRuleClient{MockSqlServerVirtualNetworkRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("virtual network rule not found") mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, "nonexistent-rule").Return( armsql.VirtualNetworkRulesClientGetResponse{}, expectedErr) wrapper := manual.NewSqlServerVirtualNetworkRule(&testSqlServerVirtualNetworkRuleClient{MockSqlServerVirtualNetworkRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(serverName, "nonexistent-rule") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent virtual network rule, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) errorPager := &errorSqlServerVirtualNetworkRulePager{} testClient := &testSqlServerVirtualNetworkRuleClient{ MockSqlServerVirtualNetworkRuleClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlServerVirtualNetworkRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0], serverName) if qErr == nil { t.Error("Expected error from Search when pager returns error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlServerVirtualNetworkRuleClient(ctrl) wrapper := manual.NewSqlServerVirtualNetworkRule(&testSqlServerVirtualNetworkRuleClient{MockSqlServerVirtualNetworkRuleClient: mockClient}, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) w := wrapper.(sources.Wrapper) permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/virtualNetworkRules/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } potentialLinks := w.PotentialLinks() if !potentialLinks[azureshared.SQLServer] { t.Error("Expected PotentialLinks to include SQLServer") } if !potentialLinks[azureshared.NetworkSubnet] { t.Error("Expected PotentialLinks to include NetworkSubnet") } if !potentialLinks[azureshared.NetworkVirtualNetwork] { t.Error("Expected PotentialLinks to include NetworkVirtualNetwork") } mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_mssql_virtual_network_rule.id" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_mssql_virtual_network_rule.id' mapping") } }) } func createAzureSqlServerVirtualNetworkRule(serverName, ruleName, subnetID string) *armsql.VirtualNetworkRule { ruleID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/" + serverName + "/virtualNetworkRules/" + ruleName rule := &armsql.VirtualNetworkRule{ Name: &ruleName, ID: &ruleID, Properties: &armsql.VirtualNetworkRuleProperties{}, } if subnetID != "" { rule.Properties.VirtualNetworkSubnetID = &subnetID } return rule } ================================================ FILE: sources/azure/manual/sql-server.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var SQLServerLookupByName = shared.NewItemTypeLookup("name", azureshared.SQLServer) func NewSqlServer(client clients.SqlServersClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &sqlServerWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, azureshared.SQLServer, ), } } type sqlServerWrapper struct { client clients.SqlServersClient *azureshared.MultiResourceGroupBase } func (s sqlServerWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.ListByResourceGroup(ctx, rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, server := range page.Value { if server.Name == nil { continue } item, sdpErr := s.azureSqlServerToSDPItem(server, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s sqlServerWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.ListByResourceGroup(ctx, rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, server := range page.Value { if server.Name == nil { continue } item, sdpErr := s.azureSqlServerToSDPItem(server, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s sqlServerWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, azureshared.QueryError(errors.New("Get requires 1 query part: serverName"), scope, s.Type()) } serverName := queryParts[0] if serverName == "" { return nil, azureshared.QueryError(errors.New("serverName is empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, serverName, nil) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureSqlServerToSDPItem(&resp.Server, scope) } func (s sqlServerWrapper) azureSqlServerToSDPItem(server *armsql.Server, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(server, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } serverName := "" if server.Name != nil { serverName = *server.Name } sdpItem := &sdp.Item{ Type: azureshared.SQLServer.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(server.Tags), } // Child resources - can be discovered via SEARCH using server name // These child resources have their own REST API endpoints under the SQL Server if serverName != "" { // Link to Databases (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/databases/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/databases sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLDatabase.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Elastic Pools (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/elastic-pools/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/elasticPools sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLElasticPool.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Firewall Rules (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/firewall-rules/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/firewallRules sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerFirewallRule.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Virtual Network Rules (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/virtual-network-rules/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/virtualNetworkRules sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerVirtualNetworkRule.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Server Keys (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-keys/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/keys sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerKey.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Failover Groups (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/failover-groups/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/failoverGroups sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerFailoverGroup.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Administrators (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-azure-ad-administrators/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/administrators/ActiveDirectory sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerAdministrator.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Sync Groups (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/sync-groups/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/syncGroups sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerSyncGroup.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Sync Agents (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/sync-agents/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/syncAgents sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerSyncAgent.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Private Endpoint Connections (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/private-endpoint-connections/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/privateEndpointConnections sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerPrivateEndpointConnection.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Network Private Endpoints (external resources) from PrivateEndpointConnections // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/privateEndpoints/{privateEndpointName} if server.Properties != nil && server.Properties.PrivateEndpointConnections != nil { for _, peConnection := range server.Properties.PrivateEndpointConnections { if peConnection.Properties != nil && peConnection.Properties.PrivateEndpoint != nil && peConnection.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *peConnection.Properties.PrivateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { // Extract scope from resource ID if it's in a different resource group extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: extractedScope, }, }) } } } } // Link to Auditing Settings (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-auditing-settings/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/auditingSettings/default sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerAuditingSetting.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Security Alert Policies (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-security-alert-policies/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/securityAlertPolicies/default sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerSecurityAlertPolicy.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Vulnerability Assessments (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-vulnerability-assessments/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/vulnerabilityAssessments/default sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerVulnerabilityAssessment.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Encryption Protector (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/encryption-protectors/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/encryptionProtector sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerEncryptionProtector.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Blob Auditing Policies (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-blob-auditing-policies/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/auditingSettings/default sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerBlobAuditingPolicy.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Automatic Tuning (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-automatic-tuning/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/automaticTuning sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerAutomaticTuning.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Advanced Threat Protection Settings (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-advanced-threat-protection-settings/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/advancedThreatProtectionSettings/default sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerAdvancedThreatProtectionSetting.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to DNS Aliases (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-dns-aliases/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/dnsAliases sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerDnsAlias.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Server Usages (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-usages/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/usages sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerUsage.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Server Operations (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-operations/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/operations sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerOperation.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Server Advisors (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-advisors/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/advisors sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerAdvisor.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Backup Long-Term Retention Policies (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/long-term-retention-backups/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/backupLongTermRetentionPolicies sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerBackupLongTermRetentionPolicy.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to DevOps Audit Settings (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-devops-auditing-settings/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/devOpsAuditSettings/default sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerDevOpsAuditSetting.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Server Trust Groups (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/server-trust-groups/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/serverTrustGroups sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerTrustGroup.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Outbound Firewall Rules (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/outbound-firewall-rules/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/outboundFirewallRules sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerOutboundFirewallRule.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Private Link Resources (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/private-link-resources/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/privateLinkResources sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLServerPrivateLinkResource.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) // Link to Long Term Retention Backups (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/sql/2021-11-01/long-term-retention-backups/list-by-server // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/longTermRetentionBackups sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.SQLLongTermRetentionBackup.String(), Method: sdp.QueryMethod_SEARCH, Query: serverName, Scope: scope, }, }) } // External resources - extracted from IDs in the server response if server.Properties != nil { // Track processed identity resource IDs to avoid duplicates processedIdentityIDs := make(map[string]bool) // Link to Primary Managed Identity (external resource) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} if server.Properties.PrimaryUserAssignedIdentityID != nil && *server.Properties.PrimaryUserAssignedIdentityID != "" { identityName := azureshared.ExtractResourceName(*server.Properties.PrimaryUserAssignedIdentityID) if identityName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(*server.Properties.PrimaryUserAssignedIdentityID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: extractedScope, }, }) processedIdentityIDs[*server.Properties.PrimaryUserAssignedIdentityID] = true } } // Link to all User Assigned Managed Identities (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} if server.Identity != nil && server.Identity.UserAssignedIdentities != nil { for identityResourceID := range server.Identity.UserAssignedIdentities { // Skip if we already processed this identity (e.g., as the primary identity) if processedIdentityIDs[identityResourceID] { continue } identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID) if extractedScope == "" { extractedScope = scope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: extractedScope, }, }) processedIdentityIDs[identityResourceID] = true } } } // Link to Key Vault (external resource) from KeyId encryption property // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName} // // NOTE: Key Vaults can be in a different resource group than the SQL Server. However, the Key Vault URI // format (https://{vaultName}.vault.azure.net/keys/{keyName}/{version}) does not contain resource group information. // Key Vault names are globally unique within a subscription, so we use the SQL Server's scope as a best-effort // approach. If the Key Vault is in a different resource group, the query may fail and would need to be manually corrected // or the Key Vault adapter would need to support subscription-level search. if server.Properties != nil && server.Properties.KeyID != nil && *server.Properties.KeyID != "" { keyID := *server.Properties.KeyID // Key Vault URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} vaultName := azureshared.ExtractVaultNameFromURI(keyID) if vaultName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } } // Link to DNS name (standard library) if FQDN is configured // SQL Server's fullyQualifiedDomainName represents the DNS name for the server if server.Properties.FullyQualifiedDomainName != nil && *server.Properties.FullyQualifiedDomainName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: *server.Properties.FullyQualifiedDomainName, Scope: "global", }, }) } } return sdpItem, nil } func (s sqlServerWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ SQLServerLookupByName, } } func (s sqlServerWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( // Child resources azureshared.SQLDatabase, azureshared.SQLElasticPool, azureshared.SQLServerFirewallRule, azureshared.SQLServerVirtualNetworkRule, azureshared.SQLServerKey, azureshared.SQLServerFailoverGroup, azureshared.SQLServerAdministrator, azureshared.SQLServerSyncGroup, azureshared.SQLServerSyncAgent, azureshared.SQLServerPrivateEndpointConnection, azureshared.SQLServerAuditingSetting, azureshared.SQLServerSecurityAlertPolicy, azureshared.SQLServerVulnerabilityAssessment, azureshared.SQLServerEncryptionProtector, azureshared.SQLServerBlobAuditingPolicy, azureshared.SQLServerAutomaticTuning, azureshared.SQLServerAdvancedThreatProtectionSetting, azureshared.SQLServerDnsAlias, azureshared.SQLServerUsage, azureshared.SQLServerOperation, azureshared.SQLServerAdvisor, azureshared.SQLServerBackupLongTermRetentionPolicy, azureshared.SQLServerDevOpsAuditSetting, azureshared.SQLServerTrustGroup, azureshared.SQLServerOutboundFirewallRule, azureshared.SQLServerPrivateLinkResource, azureshared.SQLLongTermRetentionBackup, // External resources azureshared.ManagedIdentityUserAssignedIdentity, azureshared.NetworkPrivateEndpoint, azureshared.KeyVaultVault, // Standard library types stdlib.NetworkDNS, ) } func (s sqlServerWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "azurerm_mssql_server.name", }, } } func (s sqlServerWrapper) IAMPermissions() []string { return []string{ "Microsoft.Sql/servers/read", } } func (s sqlServerWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/sql-server_test.go ================================================ package manual_test import ( "context" "errors" "slices" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // mockSqlServersPager is a simple mock implementation of SqlServersPager type mockSqlServersPager struct { pages []armsql.ServersClientListByResourceGroupResponse index int } func (m *mockSqlServersPager) More() bool { return m.index < len(m.pages) } func (m *mockSqlServersPager) NextPage(ctx context.Context) (armsql.ServersClientListByResourceGroupResponse, error) { if m.index >= len(m.pages) { return armsql.ServersClientListByResourceGroupResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorSqlServersPager is a mock pager that always returns an error type errorSqlServersPager struct{} func (e *errorSqlServersPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorSqlServersPager) NextPage(ctx context.Context) (armsql.ServersClientListByResourceGroupResponse, error) { return armsql.ServersClientListByResourceGroupResponse{}, errors.New("pager error") } // testSqlServersClient wraps the mock to implement the correct interface type testSqlServersClient struct { *mocks.MockSqlServersClient pager clients.SqlServersPager } func (t *testSqlServersClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armsql.ServersClientListByResourceGroupOptions) clients.SqlServersPager { return t.pager } func TestSqlServer(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" serverName := "test-server" t.Run("Get", func(t *testing.T) { server := createAzureSqlServer(serverName, "", "") mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armsql.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.SQLServer.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServer, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != serverName { t.Errorf("Expected unique attribute value %s, got %s", serverName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Child resources { ExpectedType: azureshared.SQLDatabase.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLElasticPool.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerFirewallRule.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerVirtualNetworkRule.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerKey.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerFailoverGroup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerAdministrator.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerSyncGroup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerSyncAgent.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerPrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerAuditingSetting.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerSecurityAlertPolicy.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerVulnerabilityAssessment.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerEncryptionProtector.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerBlobAuditingPolicy.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerAutomaticTuning.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerAdvancedThreatProtectionSetting.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerDnsAlias.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerUsage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerOperation.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerAdvisor.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerBackupLongTermRetentionPolicy.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerDevOpsAuditSetting.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerTrustGroup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerOutboundFirewallRule.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLServerPrivateLinkResource.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { ExpectedType: azureshared.SQLLongTermRetentionBackup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DNS name link (from FullyQualifiedDomainName) ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serverName + ".database.windows.net", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_WithManagedIdentity", func(t *testing.T) { identityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" server := createAzureSqlServer(serverName, identityID, "test-server.database.windows.net") mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armsql.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify Managed Identity link exists foundIdentityLink := false foundDNSLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.ManagedIdentityUserAssignedIdentity.String() { foundIdentityLink = true if link.GetQuery().GetQuery() != "test-identity" { t.Errorf("Expected identity name 'test-identity', got %s", link.GetQuery().GetQuery()) } if link.GetQuery().GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected identity scope %s, got %s", subscriptionID+"."+resourceGroup, link.GetQuery().GetScope()) } } if link.GetQuery().GetType() == stdlib.NetworkDNS.String() { foundDNSLink = true if link.GetQuery().GetQuery() != "test-server.database.windows.net" { t.Errorf("Expected DNS name 'test-server.database.windows.net', got %s", link.GetQuery().GetQuery()) } } } if !foundIdentityLink { t.Error("Expected to find Managed Identity link") } if !foundDNSLink { t.Error("Expected to find DNS link") } }) t.Run("Get_WithMultipleUserAssignedIdentities", func(t *testing.T) { primaryIdentityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/primary-identity" secondaryIdentityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/secondary-identity" tertiaryIdentityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/tertiary-identity" server := createAzureSqlServerWithUserAssignedIdentities( serverName, primaryIdentityID, "test-server.database.windows.net", map[string]*armsql.UserIdentity{ primaryIdentityID: {}, // Primary identity is also in the map (should be deduplicated) secondaryIdentityID: {}, tertiaryIdentityID: {}, }, ) mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armsql.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify all three managed identity links exist (primary, secondary, tertiary) // Primary should be included from PrimaryUserAssignedIdentityID // Secondary and tertiary should be included from Identity.UserAssignedIdentities identityLinks := make(map[string]bool) for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.ManagedIdentityUserAssignedIdentity.String() { identityLinks[link.GetQuery().GetQuery()] = true if link.GetQuery().GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected identity scope %s, got %s", subscriptionID+"."+resourceGroup, link.GetQuery().GetScope()) } } } expectedIdentities := []string{"primary-identity", "secondary-identity", "tertiary-identity"} if len(identityLinks) != len(expectedIdentities) { t.Errorf("Expected %d identity links, got %d: %v", len(expectedIdentities), len(identityLinks), identityLinks) } for _, expectedIdentity := range expectedIdentities { if !identityLinks[expectedIdentity] { t.Errorf("Expected to find identity link for '%s'", expectedIdentity) } } }) t.Run("Get_WithPrivateEndpointConnections", func(t *testing.T) { privateEndpointID1 := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-private-endpoint-1" privateEndpointID2 := "/subscriptions/" + subscriptionID + "/resourceGroups/different-rg/providers/Microsoft.Network/privateEndpoints/test-private-endpoint-2" server := createAzureSqlServerWithPrivateEndpointConnections( serverName, "", "test-server.database.windows.net", []*armsql.ServerPrivateEndpointConnection{ { Properties: &armsql.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armsql.PrivateEndpointProperty{ ID: new(privateEndpointID1), }, }, }, { Properties: &armsql.PrivateEndpointConnectionProperties{ PrivateEndpoint: &armsql.PrivateEndpointProperty{ ID: new(privateEndpointID2), }, }, }, }, ) mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armsql.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify PrivateEndpointConnection child resource link exists foundPrivateEndpointConnectionLink := false // Verify NetworkPrivateEndpoint links exist privateEndpointLinks := make(map[string]string) // name -> scope for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.SQLServerPrivateEndpointConnection.String() { foundPrivateEndpointConnectionLink = true if link.GetQuery().GetQuery() != serverName { t.Errorf("Expected PrivateEndpointConnection query '%s', got %s", serverName, link.GetQuery().GetQuery()) } } if link.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { privateEndpointLinks[link.GetQuery().GetQuery()] = link.GetQuery().GetScope() if link.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected NetworkPrivateEndpoint method GET, got %v", link.GetQuery().GetMethod()) } } } if !foundPrivateEndpointConnectionLink { t.Error("Expected to find PrivateEndpointConnection child resource link") } // Verify both private endpoints are linked expectedPrivateEndpoints := map[string]string{ "test-private-endpoint-1": subscriptionID + "." + resourceGroup, "test-private-endpoint-2": subscriptionID + ".different-rg", } if len(privateEndpointLinks) != len(expectedPrivateEndpoints) { t.Errorf("Expected %d NetworkPrivateEndpoint links, got %d: %v", len(expectedPrivateEndpoints), len(privateEndpointLinks), privateEndpointLinks) } for expectedName, expectedScope := range expectedPrivateEndpoints { if actualScope, found := privateEndpointLinks[expectedName]; !found { t.Errorf("Expected to find NetworkPrivateEndpoint link for '%s'", expectedName) } else if actualScope != expectedScope { t.Errorf("Expected NetworkPrivateEndpoint '%s' scope '%s', got '%s'", expectedName, expectedScope, actualScope) } } }) t.Run("Get_WithKeyVault", func(t *testing.T) { keyID := "https://test-keyvault.vault.azure.net/keys/test-key/version" server := createAzureSqlServerWithKeyId(serverName, "", "test-server.database.windows.net", keyID) mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armsql.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify KeyVault link exists foundKeyVaultLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.KeyVaultVault.String() { foundKeyVaultLink = true if link.GetQuery().GetQuery() != "test-keyvault" { t.Errorf("Expected KeyVault name 'test-keyvault', got %s", link.GetQuery().GetQuery()) } if link.GetQuery().GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected KeyVault scope %s, got %s", subscriptionID+"."+resourceGroup, link.GetQuery().GetScope()) } if link.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected KeyVault method GET, got %v", link.GetQuery().GetMethod()) } } } if !foundKeyVaultLink { t.Error("Expected to find KeyVault link") } }) t.Run("Get_WithCrossResourceGroupManagedIdentity", func(t *testing.T) { otherResourceGroup := "other-rg" identityID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + otherResourceGroup + "/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" server := createAzureSqlServer(serverName, identityID, "") mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armsql.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify that managed identity link uses the correct scope from different resource group foundIdentityLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == azureshared.ManagedIdentityUserAssignedIdentity.String() { foundIdentityLink = true expectedScope := subscriptionID + "." + otherResourceGroup if link.GetQuery().GetScope() != expectedScope { t.Errorf("Expected identity scope %s, got %s", expectedScope, link.GetQuery().GetScope()) } if link.GetQuery().GetQuery() != "test-identity" { t.Errorf("Expected identity name 'test-identity', got %s", link.GetQuery().GetQuery()) } break } } if !foundIdentityLink { t.Error("Expected to find Managed Identity link") } }) t.Run("Get_WithFQDNOnly", func(t *testing.T) { server := createAzureSqlServer(serverName, "", "test-server.database.windows.net") mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, serverName, nil).Return( armsql.ServersClientGetResponse{ Server: *server, }, nil) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], serverName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify DNS link exists foundDNSLink := false for _, link := range sdpItem.GetLinkedItemQueries() { if link.GetQuery().GetType() == stdlib.NetworkDNS.String() { foundDNSLink = true if link.GetQuery().GetQuery() != "test-server.database.windows.net" { t.Errorf("Expected DNS name 'test-server.database.windows.net', got %s", link.GetQuery().GetQuery()) } if link.GetQuery().GetScope() != "global" { t.Errorf("Expected DNS scope 'global', got %s", link.GetQuery().GetScope()) } break } } if !foundDNSLink { t.Error("Expected to find DNS link") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockSqlServersClient(ctrl) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (no server name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when providing empty server name, but got nil") } }) t.Run("List", func(t *testing.T) { server1 := createAzureSqlServer("server-1", "", "") server2 := createAzureSqlServer("server-2", "", "") mockClient := mocks.NewMockSqlServersClient(ctrl) mockPager := &mockSqlServersPager{ pages: []armsql.ServersClientListByResourceGroupResponse{ { ServerListResult: armsql.ServerListResult{ Value: []*armsql.Server{server1, server2}, }, }, }, } testClient := &testSqlServersClient{ MockSqlServersClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.SQLServer.String() { t.Errorf("Expected type %s, got %s", azureshared.SQLServer, item.GetType()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("List_WithNilName", func(t *testing.T) { server1 := createAzureSqlServer("server-1", "", "") server2 := &armsql.Server{ Name: nil, // Server with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armsql.ServerProperties{ Version: new("12.0"), }, } mockClient := mocks.NewMockSqlServersClient(ctrl) mockPager := &mockSqlServersPager{ pages: []armsql.ServersClientListByResourceGroupResponse{ { ServerListResult: armsql.ServerListResult{ Value: []*armsql.Server{server1, server2}, }, }, }, } testClient := &testSqlServersClient{ MockSqlServersClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (server with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "server-1" { t.Fatalf("Expected server name 'server-1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ListStream", func(t *testing.T) { server1 := createAzureSqlServer("server-1", "", "") server2 := createAzureSqlServer("server-2", "", "") mockClient := mocks.NewMockSqlServersClient(ctrl) mockPager := &mockSqlServersPager{ pages: []armsql.ServersClientListByResourceGroupResponse{ { ServerListResult: armsql.ServerListResult{ Value: []*armsql.Server{server1, server2}, }, }, }, } testClient := &testSqlServersClient{ MockSqlServersClient: mockClient, pager: mockPager, } wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } // Verify adapter doesn't support SearchStream _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("server not found") mockClient := mocks.NewMockSqlServersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-server", nil).Return( armsql.ServersClientGetResponse{}, expectedErr) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-server", true) if qErr == nil { t.Error("Expected error when getting non-existent server, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { mockClient := mocks.NewMockSqlServersClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorSqlServersPager{} testClient := &testSqlServersClient{ MockSqlServersClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) // The List implementation should return an error when pager.NextPage returns an error if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("ErrorHandling_ListStream", func(t *testing.T) { mockClient := mocks.NewMockSqlServersClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorSqlServersPager{} testClient := &testSqlServersClient{ MockSqlServersClient: mockClient, pager: errorPager, } wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(func(item *sdp.Item) {}, mockErrorHandler) listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) // Should have received an error if len(errs) == 0 { t.Error("Expected error from pager when NextPage returns an error, but got none") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockSqlServersClient(ctrl) testClient := &testSqlServersClient{MockSqlServersClient: mockClient} wrapper := manual.NewSqlServer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Cast to sources.Wrapper to access interface methods w := wrapper.(sources.Wrapper) // Verify IAMPermissions permissions := w.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to return at least one permission") } expectedPermission := "Microsoft.Sql/servers/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } // Verify PredefinedRole // PredefinedRole is available on the wrapper, not the adapter if roleInterface, ok := any(wrapper).(interface{ PredefinedRole() string }); ok { role := roleInterface.PredefinedRole() if role != "Reader" { t.Errorf("Expected PredefinedRole to be 'Reader', got %s", role) } } else { t.Error("Wrapper does not implement PredefinedRole method") } // Verify PotentialLinks potentialLinks := w.PotentialLinks() if len(potentialLinks) == 0 { t.Error("Expected PotentialLinks to return at least one link") } expectedLinks := []shared.ItemType{ azureshared.SQLDatabase, azureshared.SQLElasticPool, azureshared.ManagedIdentityUserAssignedIdentity, azureshared.NetworkPrivateEndpoint, azureshared.KeyVaultVault, stdlib.NetworkDNS, } for _, expectedLink := range expectedLinks { if !potentialLinks[expectedLink] { t.Errorf("Expected PotentialLinks to include %s", expectedLink) } } // Verify TerraformMappings mappings := w.TerraformMappings() if len(mappings) == 0 { t.Error("Expected TerraformMappings to return at least one mapping") } foundMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_mssql_server.name" { foundMapping = true break } } if !foundMapping { t.Error("Expected TerraformMappings to include 'azurerm_mssql_server.name' mapping") } }) } // createAzureSqlServer creates a mock Azure SQL Server for testing func createAzureSqlServer(serverName, primaryUserAssignedIdentityID, fullyQualifiedDomainName string) *armsql.Server { serverID := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Sql/servers/" + serverName server := &armsql.Server{ Name: new(serverName), Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, ID: new(serverID), Properties: &armsql.ServerProperties{ Version: new("12.0"), AdministratorLogin: new("admin"), FullyQualifiedDomainName: new(fullyQualifiedDomainName), }, } if primaryUserAssignedIdentityID != "" { server.Properties.PrimaryUserAssignedIdentityID = new(primaryUserAssignedIdentityID) } if fullyQualifiedDomainName == "" && serverName != "" { // Set a default FQDN if not provided but server name is set server.Properties.FullyQualifiedDomainName = new(serverName + ".database.windows.net") } return server } // createAzureSqlServerWithUserAssignedIdentities creates a mock Azure SQL Server with UserAssignedIdentities func createAzureSqlServerWithUserAssignedIdentities(serverName, primaryUserAssignedIdentityID, fullyQualifiedDomainName string, userAssignedIdentities map[string]*armsql.UserIdentity) *armsql.Server { server := createAzureSqlServer(serverName, primaryUserAssignedIdentityID, fullyQualifiedDomainName) if userAssignedIdentities != nil { server.Identity = &armsql.ResourceIdentity{ Type: new(armsql.IdentityTypeUserAssigned), UserAssignedIdentities: userAssignedIdentities, } } return server } // createAzureSqlServerWithPrivateEndpointConnections creates a mock Azure SQL Server with PrivateEndpointConnections func createAzureSqlServerWithPrivateEndpointConnections(serverName, primaryUserAssignedIdentityID, fullyQualifiedDomainName string, privateEndpointConnections []*armsql.ServerPrivateEndpointConnection) *armsql.Server { server := createAzureSqlServer(serverName, primaryUserAssignedIdentityID, fullyQualifiedDomainName) if privateEndpointConnections != nil { server.Properties.PrivateEndpointConnections = privateEndpointConnections } return server } // createAzureSqlServerWithKeyId creates a mock Azure SQL Server with KeyId encryption property func createAzureSqlServerWithKeyId(serverName, primaryUserAssignedIdentityID, fullyQualifiedDomainName, keyID string) *armsql.Server { server := createAzureSqlServer(serverName, primaryUserAssignedIdentityID, fullyQualifiedDomainName) if keyID != "" { server.Properties.KeyID = new(keyID) } return server } ================================================ FILE: sources/azure/manual/storage-account.go ================================================ package manual import ( "context" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var StorageAccountLookupByName = shared.NewItemTypeLookup("name", azureshared.StorageAccount) type storageAccountWrapper struct { client clients.StorageAccountsClient *azureshared.MultiResourceGroupBase } func NewStorageAccount(client clients.StorageAccountsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { return &storageAccountWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.StorageAccount, ), } } func (s storageAccountWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, account := range page.Value { if account.Name == nil { continue } item, sdpErr := s.azureStorageAccountToSDPItem(account, *account.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s storageAccountWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, account := range page.Value { if account.Name == nil { continue } item, sdpErr := s.azureStorageAccountToSDPItem(account, *account.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s storageAccountWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 1 query part: name", Scope: scope, ItemType: s.Type(), } } accountName := queryParts[0] if accountName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "name cannot be empty", Scope: scope, ItemType: s.Type(), } } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, accountName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureStorageAccountToSDPItem(&resp.Account, accountName, scope) } func (s storageAccountWrapper) azureStorageAccountToSDPItem(account *armstorage.Account, accountName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(account, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.StorageAccount.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, Tags: azureshared.ConvertAzureTags(account.Tags), } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageBlobContainer.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageFileShare.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageTable.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageQueue.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageEncryptionScope.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to Private Endpoint Connections (child resource) // Reference: https://learn.microsoft.com/en-us/rest/api/storagerp/private-endpoint-connections/list?view=rest-storagerp-2025-06-01 // Private endpoint connections can be listed using the storage account name sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StoragePrivateEndpointConnection.String(), Method: sdp.QueryMethod_SEARCH, Query: accountName, Scope: scope, }, }) // Link to User Assigned Managed Identities (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if account.Identity != nil && account.Identity.UserAssignedIdentities != nil { for identityResourceID := range account.Identity.UserAssignedIdentities { identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Link to Key Vault (external resource) from Encryption KeyVaultProperties // Reference: https://learn.microsoft.com/en-us/rest/api/keyvault/keyvault/vaults/get?view=rest-keyvault-keyvault-2024-11-01&tabs=HTTP // GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName} // // NOTE: Key Vaults can be in a different resource group than the Storage account. However, the Key Vault URI // format (https://{vaultName}.vault.azure.net/keys/{keyName}/{version}) does not contain resource group information. // Key Vault names are globally unique within a subscription, so we use the storage account's scope as a best-effort // approach. If the Key Vault is in a different resource group, the query may fail and would need to be manually corrected // or the Key Vault adapter would need to support subscription-level search. if account.Properties != nil && account.Properties.Encryption != nil && account.Properties.Encryption.KeyVaultProperties != nil { if account.Properties.Encryption.KeyVaultProperties.KeyVaultURI != nil { keyVaultURI := *account.Properties.Encryption.KeyVaultProperties.KeyVaultURI // Key Vault URI format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} vaultName := azureshared.ExtractVaultNameFromURI(keyVaultURI) if vaultName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, // Limitation: Key Vault URI doesn't contain resource group info }, }) } } } // Link to User Assigned Managed Identity (external resource) from Encryption EncryptionIdentity // Reference: https://learn.microsoft.com/en-us/rest/api/managedidentity/user-assigned-identities/get?view=rest-managedidentity-2024-11-30&tabs=HTTP if account.Properties != nil && account.Properties.Encryption != nil && account.Properties.Encryption.EncryptionIdentity != nil { if account.Properties.Encryption.EncryptionIdentity.EncryptionUserAssignedIdentity != nil { identityResourceID := *account.Properties.Encryption.EncryptionIdentity.EncryptionUserAssignedIdentity identityName := azureshared.ExtractResourceName(identityResourceID) if identityName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), Method: sdp.QueryMethod_GET, Query: identityName, Scope: linkedScope, }, }) } } } // Link to Subnets (external resources) from NetworkRuleSet VirtualNetworkRules // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get if account.Properties != nil && account.Properties.NetworkRuleSet != nil && account.Properties.NetworkRuleSet.VirtualNetworkRules != nil { for _, vnetRule := range account.Properties.NetworkRuleSet.VirtualNetworkRules { if vnetRule != nil && vnetRule.VirtualNetworkResourceID != nil { subnetID := *vnetRule.VirtualNetworkResourceID // Subnet ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName} // Extract subscription, resource group, virtual network name, and subnet name scopeParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"subscriptions", "resourceGroups"}) subnetParams := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) if len(scopeParams) >= 2 && len(subnetParams) >= 2 { subscriptionID := scopeParams[0] resourceGroupName := scopeParams[1] vnetName := subnetParams[0] subnetName := subnetParams[1] // Subnet adapter requires: resourceGroup, virtualNetworkName, subnetName // Use composite lookup key to join them query := shared.CompositeLookupKey(vnetName, subnetName) // Construct scope in format: {subscriptionID}.{resourceGroupName} // This ensures we query the correct resource group where the subnet actually exists scope := fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkSubnet.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, // Use the subnet's scope, not the storage account's scope }, }) } } } } // Link to IP addresses (standard library) from NetworkRuleSet IPRules if account.Properties != nil && account.Properties.NetworkRuleSet != nil && account.Properties.NetworkRuleSet.IPRules != nil { for _, ipRule := range account.Properties.NetworkRuleSet.IPRules { if ipRule != nil && ipRule.IPAddressOrRange != nil && *ipRule.IPAddressOrRange != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ipRule.IPAddressOrRange, Scope: "global", }, }) } } } // Link to IP addresses (standard library) from NetworkRuleSet IPv6Rules if account.Properties != nil && account.Properties.NetworkRuleSet != nil && account.Properties.NetworkRuleSet.IPv6Rules != nil { for _, ipRule := range account.Properties.NetworkRuleSet.IPv6Rules { if ipRule != nil && ipRule.IPAddressOrRange != nil && *ipRule.IPAddressOrRange != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: *ipRule.IPAddressOrRange, Scope: "global", }, }) } } } // Link to Private Endpoints (external resources) // Reference: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/private-endpoints/get if account.Properties != nil && account.Properties.PrivateEndpointConnections != nil { for _, peConnection := range account.Properties.PrivateEndpointConnections { if peConnection.Properties != nil && peConnection.Properties.PrivateEndpoint != nil && peConnection.Properties.PrivateEndpoint.ID != nil { privateEndpointID := *peConnection.Properties.PrivateEndpoint.ID privateEndpointName := azureshared.ExtractResourceName(privateEndpointID) if privateEndpointName != "" { // Extract scope from resource ID if it's in a different resource group linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(privateEndpointID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: privateEndpointName, Scope: linkedScope, }, }) } } } } // Link to DNS names (standard library) from PrimaryEndpoints if account.Properties != nil && account.Properties.PrimaryEndpoints != nil { endpoints := []struct { name string value *string }{ {"blob", account.Properties.PrimaryEndpoints.Blob}, {"queue", account.Properties.PrimaryEndpoints.Queue}, {"table", account.Properties.PrimaryEndpoints.Table}, {"file", account.Properties.PrimaryEndpoints.File}, {"dfs", account.Properties.PrimaryEndpoints.Dfs}, {"web", account.Properties.PrimaryEndpoints.Web}, } for _, endpoint := range endpoints { if endpoint.value != nil && *endpoint.value != "" { // Extract DNS name from URL (e.g., https://account.blob.core.windows.net/ -> account.blob.core.windows.net) dnsName := azureshared.ExtractDNSFromURL(*endpoint.value) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } // Link to DNS names (standard library) from SecondaryEndpoints if account.Properties != nil && account.Properties.SecondaryEndpoints != nil { endpoints := []struct { name string value *string }{ {"blob", account.Properties.SecondaryEndpoints.Blob}, {"queue", account.Properties.SecondaryEndpoints.Queue}, {"table", account.Properties.SecondaryEndpoints.Table}, {"file", account.Properties.SecondaryEndpoints.File}, {"dfs", account.Properties.SecondaryEndpoints.Dfs}, {"web", account.Properties.SecondaryEndpoints.Web}, } for _, endpoint := range endpoints { if endpoint.value != nil && *endpoint.value != "" { // Extract DNS name from URL (e.g., https://account-secondary.blob.core.windows.net/ -> account-secondary.blob.core.windows.net) dnsName := azureshared.ExtractDNSFromURL(*endpoint.value) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } } } // Link to DNS name (standard library) from CustomDomain if account.Properties != nil && account.Properties.CustomDomain != nil && account.Properties.CustomDomain.Name != nil && *account.Properties.CustomDomain.Name != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: *account.Properties.CustomDomain.Name, Scope: "global", }, }) } return sdpItem, nil } func (s storageAccountWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageAccountLookupByName, } } // PotentialLinks returns the potential links for the storage account wrapper func (s storageAccountWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ // Child resources azureshared.StorageBlobContainer: true, azureshared.StorageFileShare: true, azureshared.StorageTable: true, azureshared.StorageQueue: true, azureshared.StorageEncryptionScope: true, azureshared.StoragePrivateEndpointConnection: true, // External resources azureshared.ManagedIdentityUserAssignedIdentity: true, azureshared.KeyVaultVault: true, azureshared.NetworkSubnet: true, azureshared.NetworkPrivateEndpoint: true, // Standard library types stdlib.NetworkIP: true, stdlib.NetworkDNS: true, } } func (s storageAccountWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_account TerraformQueryMap: "azurerm_storage_account.name", }, } } func (s storageAccountWrapper) IAMPermissions() []string { return []string{ "Microsoft.Storage/storageAccounts/read", } } func (s storageAccountWrapper) PredefinedRole() string { return "Reader" // there is no predefined role for storage accounts, so we use the most restrictive role (Reader) } ================================================ FILE: sources/azure/manual/storage-account_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestStorageAccount(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" t.Run("Get", func(t *testing.T) { accountName := "teststorageaccount" account := createAzureStorageAccount(accountName, "Succeeded") mockClient := mocks.NewMockStorageAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName).Return( armstorage.AccountsClientGetPropertiesResponse{ Account: *account, }, nil) wrapper := manual.NewStorageAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.StorageAccount.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageAccount, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "name" { t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != accountName { t.Errorf("Expected unique attribute value %s, got %s", accountName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetTags()["env"] != "test" { t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // Storage blob container link ExpectedType: azureshared.StorageBlobContainer.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Storage file share link ExpectedType: azureshared.StorageFileShare.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Storage table link ExpectedType: azureshared.StorageTable.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Storage queue link ExpectedType: azureshared.StorageQueue.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Storage encryption scope link (child resource) ExpectedType: azureshared.StorageEncryptionScope.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // Storage private endpoint connection link (child resource) ExpectedType: azureshared.StoragePrivateEndpointConnection.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName, ExpectedScope: subscriptionID + "." + resourceGroup, }, { // DNS link from PrimaryEndpoints.Blob ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName + ".blob.core.windows.net", ExpectedScope: "global", }, { // DNS link from PrimaryEndpoints.Queue ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName + ".queue.core.windows.net", ExpectedScope: "global", }, { // DNS link from PrimaryEndpoints.Table ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName + ".table.core.windows.net", ExpectedScope: "global", }, { // DNS link from PrimaryEndpoints.File ExpectedType: "dns", ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: accountName + ".file.core.windows.net", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockStorageAccountsClient(ctrl) wrapper := manual.NewStorageAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (empty) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "", true) if qErr == nil { t.Error("Expected error when getting storage account with empty name, but got nil") } }) t.Run("List", func(t *testing.T) { account1 := createAzureStorageAccount("teststorageaccount1", "Succeeded") account2 := createAzureStorageAccount("teststorageaccount2", "Succeeded") mockClient := mocks.NewMockStorageAccountsClient(ctrl) mockPager := mocks.NewMockStorageAccountsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armstorage.AccountsClientListByResourceGroupResponse{ AccountListResult: armstorage.AccountListResult{ Value: []*armstorage.Account{account1, account2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewStorageAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } if item.GetType() != azureshared.StorageAccount.String() { t.Fatalf("Expected type %s, got: %s", azureshared.StorageAccount, item.GetType()) } } }) t.Run("List_WithNilName", func(t *testing.T) { // Create account with nil name to test filtering account1 := createAzureStorageAccount("teststorageaccount1", "Succeeded") account2 := &armstorage.Account{ Name: nil, // Account with nil name should be skipped Location: new("eastus"), Tags: map[string]*string{ "env": new("test"), }, Properties: &armstorage.AccountProperties{ ProvisioningState: new(armstorage.ProvisioningStateSucceeded), }, } mockClient := mocks.NewMockStorageAccountsClient(ctrl) mockPager := mocks.NewMockStorageAccountsPager(ctrl) // Setup pager expectations gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armstorage.AccountsClientListByResourceGroupResponse{ AccountListResult: armstorage.AccountListResult{ Value: []*armstorage.Account{account1, account2}, }, }, nil), mockPager.EXPECT().More().Return(false), ) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewStorageAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (account with nil name is skipped) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name filtered out), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != "teststorageaccount1" { t.Fatalf("Expected account name 'teststorageaccount1', got: %s", sdpItems[0].UniqueAttributeValue()) } }) // Note: ListStream test is not included as ListStream is not yet implemented // in the storage account adapter. When ListStream is implemented, add a test // following the pattern from compute-virtual-machine_test.go t.Run("ErrorHandling", func(t *testing.T) { expectedErr := errors.New("storage account not found") mockClient := mocks.NewMockStorageAccountsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent-account").Return( armstorage.AccountsClientGetPropertiesResponse{}, expectedErr) wrapper := manual.NewStorageAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "nonexistent-account", true) if qErr == nil { t.Error("Expected error when getting non-existent storage account, but got nil") } }) t.Run("ErrorHandling_List", func(t *testing.T) { expectedErr := errors.New("failed to list storage accounts") mockClient := mocks.NewMockStorageAccountsClient(ctrl) mockPager := mocks.NewMockStorageAccountsPager(ctrl) // Setup pager to return error on NextPage gomock.InOrder( mockPager.EXPECT().More().Return(true), mockPager.EXPECT().NextPage(ctx).Return( armstorage.AccountsClientListByResourceGroupResponse{}, expectedErr), ) mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) wrapper := manual.NewStorageAccount(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } _, err := listable.List(ctx, wrapper.Scopes()[0], true) if err == nil { t.Error("Expected error when listing storage accounts fails, but got nil") } }) } // createAzureStorageAccount creates a mock Azure storage account for testing func createAzureStorageAccount(accountName, provisioningState string) *armstorage.Account { state := armstorage.ProvisioningState(provisioningState) return &armstorage.Account{ Name: new(accountName), Location: new("eastus"), Kind: new(armstorage.KindStorageV2), Tags: map[string]*string{ "env": new("test"), "project": new("testing"), }, Properties: &armstorage.AccountProperties{ ProvisioningState: &state, PrimaryEndpoints: &armstorage.Endpoints{ Blob: new("https://" + accountName + ".blob.core.windows.net/"), Queue: new("https://" + accountName + ".queue.core.windows.net/"), Table: new("https://" + accountName + ".table.core.windows.net/"), File: new("https://" + accountName + ".file.core.windows.net/"), }, }, } } ================================================ FILE: sources/azure/manual/storage-blob-container.go ================================================ package manual import ( "context" "fmt" "strings" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var StorageBlobContainerLookupByName = shared.NewItemTypeLookup("name", azureshared.StorageBlobContainer) type storageBlobContainerWrapper struct { client clients.BlobContainersClient *azureshared.MultiResourceGroupBase } func NewStorageBlobContainer(client clients.BlobContainersClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &storageBlobContainerWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.StorageBlobContainer, ), } } func (s storageBlobContainerWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: storageAccountName and containerName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] containerName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, storageAccountName, containerName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = s.azureBlobContainerToSDPItem(&resp.BlobContainer, storageAccountName, containerName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s storageBlobContainerWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(fmt.Errorf("queryParts must be 1 query part: storageAccountName, got %d", len(queryParts)), scope, s.Type()) } storageAccountName := queryParts[0] if storageAccountName == "" { return nil, azureshared.QueryError(fmt.Errorf("storageAccountName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, container := range page.Value { if container.Name == nil { continue } item, sdpErr := s.azureBlobContainerToSDPItem(&armstorage.BlobContainer{ ID: container.ID, Name: container.Name, Type: container.Type, ContainerProperties: container.Properties, Etag: container.Etag, }, storageAccountName, *container.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s storageBlobContainerWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(fmt.Errorf("queryParts must be 1 query part: storageAccountName, got %d", len(queryParts)), scope, s.Type())) return } storageAccountName := queryParts[0] if storageAccountName == "" { stream.SendError(azureshared.QueryError(fmt.Errorf("storageAccountName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, container := range page.Value { if container.Name == nil { continue } item, sdpErr := s.azureBlobContainerToSDPItem(&armstorage.BlobContainer{ ID: container.ID, Name: container.Name, Type: container.Type, ContainerProperties: container.Properties, Etag: container.Etag, }, storageAccountName, *container.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s storageBlobContainerWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageAccountLookupByName, StorageBlobContainerLookupByName, } } func (s storageBlobContainerWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { StorageAccountLookupByName, // Search by storage account name }, } } // PotentialLinks returns the potential links for the blob container wrapper func (s storageBlobContainerWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( azureshared.StorageAccount, azureshared.StorageEncryptionScope, stdlib.NetworkHTTP, stdlib.NetworkDNS, ) } func (s storageBlobContainerWrapper) azureBlobContainerToSDPItem(container *armstorage.BlobContainer, storageAccountName, containerName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(container) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(storageAccountName, containerName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.StorageBlobContainer.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) // Link to DNS name (standard library) from blob container URI // Blob container URI format: https://{storageAccountName}.blob.core.windows.net/{containerName} // Any attribute containing a DNS name should create a LinkedItemQuery for dns type blobContainerURI := fmt.Sprintf("https://%s.blob.core.windows.net/%s", storageAccountName, containerName) dnsName := azureshared.ExtractDNSFromURL(blobContainerURI) if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } // Link to stdlib.NetworkHTTP for blob container URI if strings.HasPrefix(blobContainerURI, "https://") { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: blobContainerURI, Scope: "global", }, }) } // Link to Storage Encryption Scope when container uses a default encryption scope if container.ContainerProperties != nil && container.ContainerProperties.DefaultEncryptionScope != nil && *container.ContainerProperties.DefaultEncryptionScope != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageEncryptionScope.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(storageAccountName, *container.ContainerProperties.DefaultEncryptionScope), Scope: scope, }, }) } return sdpItem, nil } func (s storageBlobContainerWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_container // Terraform uses: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}/blobServices/default/containers/{container} TerraformQueryMap: "azurerm_storage_container.id", }, } } func (s storageBlobContainerWrapper) IAMPermissions() []string { return []string{ "Microsoft.Storage/storageAccounts/blobServices/containers/read", } } func (s storageBlobContainerWrapper) PredefinedRole() string { return "Storage Blob Data Reader" } ================================================ FILE: sources/azure/manual/storage-blob-container_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockBlobContainersPager is a simple mock implementation of BlobContainersPager type mockBlobContainersPager struct { pages []armstorage.BlobContainersClientListResponse index int } func (m *mockBlobContainersPager) More() bool { return m.index < len(m.pages) } func (m *mockBlobContainersPager) NextPage(ctx context.Context) (armstorage.BlobContainersClientListResponse, error) { if m.index >= len(m.pages) { return armstorage.BlobContainersClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorBlobContainersPager is a mock pager that always returns an error type errorBlobContainersPager struct{} func (e *errorBlobContainersPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorBlobContainersPager) NextPage(ctx context.Context) (armstorage.BlobContainersClientListResponse, error) { return armstorage.BlobContainersClientListResponse{}, errors.New("pager error") } // testBlobContainersClient wraps the mock to implement the correct interface type testBlobContainersClient struct { *mocks.MockBlobContainersClient pager clients.BlobContainersPager } func (t *testBlobContainersClient) List(ctx context.Context, resourceGroupName, accountName string) clients.BlobContainersPager { return t.pager } func TestStorageBlobContainer(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" storageAccountName := "teststorageaccount" containerName := "test-container" t.Run("Get", func(t *testing.T) { container := createAzureBlobContainer(containerName) mockClient := mocks.NewMockBlobContainersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, containerName).Return( armstorage.BlobContainersClientGetResponse{ BlobContainer: *container, }, nil) testClient := &testBlobContainersClient{MockBlobContainersClient: mockClient} wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and containerName as query parts query := storageAccountName + shared.QuerySeparator + containerName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.StorageBlobContainer.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageBlobContainer, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(storageAccountName, containerName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(storageAccountName, containerName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { // Verify linked item queries linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 3 { t.Fatalf("Expected 3 linked queries (StorageAccount, DNS, HTTP), got: %d", len(linkedQueries)) } var hasStorageAccountLink, hasDNSLink, hasHTTPLink bool for _, linkedQuery := range linkedQueries { switch linkedQuery.GetQuery().GetType() { case azureshared.StorageAccount.String(): hasStorageAccountLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected StorageAccount linked query method GET, got %s", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected StorageAccount linked query %s, got %s", storageAccountName, linkedQuery.GetQuery().GetQuery()) } case "dns": hasDNSLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected DNS linked query method SEARCH, got %s", linkedQuery.GetQuery().GetMethod()) } expectedDNS := fmt.Sprintf("%s.blob.core.windows.net", storageAccountName) if linkedQuery.GetQuery().GetQuery() != expectedDNS { t.Errorf("Expected DNS linked query %s, got %s", expectedDNS, linkedQuery.GetQuery().GetQuery()) } if linkedQuery.GetQuery().GetScope() != "global" { t.Errorf("Expected DNS linked query scope 'global', got %s", linkedQuery.GetQuery().GetScope()) } case "http": hasHTTPLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected HTTP linked query method SEARCH, got %s", linkedQuery.GetQuery().GetMethod()) } expectedHTTP := fmt.Sprintf("https://%s.blob.core.windows.net/%s", storageAccountName, containerName) if linkedQuery.GetQuery().GetQuery() != expectedHTTP { t.Errorf("Expected HTTP linked query %s, got %s", expectedHTTP, linkedQuery.GetQuery().GetQuery()) } if linkedQuery.GetQuery().GetScope() != "global" { t.Errorf("Expected HTTP linked query scope 'global', got %s", linkedQuery.GetQuery().GetScope()) } } } if !hasStorageAccountLink { t.Error("Expected StorageAccount linked query, but didn't find one") } if !hasDNSLink { t.Error("Expected DNS linked query, but didn't find one") } if !hasHTTPLink { t.Error("Expected HTTP linked query, but didn't find one") } }) }) t.Run("Get_WithDefaultEncryptionScope", func(t *testing.T) { container := createAzureBlobContainerWithEncryptionScope(containerName, "test-encryption-scope") mockClient := mocks.NewMockBlobContainersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, containerName).Return( armstorage.BlobContainersClientGetResponse{ BlobContainer: *container, }, nil) testClient := &testBlobContainersClient{MockBlobContainersClient: mockClient} wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + containerName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 4 { t.Fatalf("Expected 4 linked queries (StorageAccount, DNS, HTTP, EncryptionScope), got: %d", len(linkedQueries)) } var hasEncryptionScopeLink bool for _, linkedQuery := range linkedQueries { if linkedQuery.GetQuery().GetType() == azureshared.StorageEncryptionScope.String() { hasEncryptionScopeLink = true if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected StorageEncryptionScope linked query method GET, got %s", linkedQuery.GetQuery().GetMethod()) } expectedQuery := shared.CompositeLookupKey(storageAccountName, "test-encryption-scope") if linkedQuery.GetQuery().GetQuery() != expectedQuery { t.Errorf("Expected StorageEncryptionScope linked query %s, got %s", expectedQuery, linkedQuery.GetQuery().GetQuery()) } if linkedQuery.GetQuery().GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected StorageEncryptionScope scope %s, got %s", subscriptionID+"."+resourceGroup, linkedQuery.GetQuery().GetScope()) } break } } if !hasEncryptionScopeLink { t.Error("Expected StorageEncryptionScope linked query when DefaultEncryptionScope is set, but didn't find one") } }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockBlobContainersClient(ctrl) testClient := &testBlobContainersClient{MockBlobContainersClient: mockClient} wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only storage account name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], storageAccountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { container1 := createAzureBlobContainer("container-1") container2 := createAzureBlobContainer("container-2") mockClient := mocks.NewMockBlobContainersClient(ctrl) mockPager := &mockBlobContainersPager{ pages: []armstorage.BlobContainersClientListResponse{ { ListContainerItems: armstorage.ListContainerItems{ Value: []*armstorage.ListContainerItem{ { ID: container1.ID, Name: container1.Name, Type: container1.Type, Properties: &armstorage.ContainerProperties{ PublicAccess: container1.ContainerProperties.PublicAccess, }, Etag: container1.Etag, }, { ID: container2.ID, Name: container2.Name, Type: container2.Type, Properties: &armstorage.ContainerProperties{ PublicAccess: container2.ContainerProperties.PublicAccess, }, Etag: container2.Etag, }, }, }, }, }, } // The mock returns *runtime.Pager, but we need to work with BlobContainersPager // We'll use a type assertion approach - create a wrapper that implements the interface testClient := &testBlobContainersClient{ MockBlobContainersClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.StorageBlobContainer.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageBlobContainer, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { // This test verifies that the wrapper's Search method validates query parts // We test it directly on the wrapper since the adapter may handle empty queries differently mockClient := mocks.NewMockBlobContainersClient(ctrl) testClient := &testBlobContainersClient{MockBlobContainersClient: mockClient} wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling List _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_ContainerWithNilName", func(t *testing.T) { mockClient := mocks.NewMockBlobContainersClient(ctrl) mockPager := &mockBlobContainersPager{ pages: []armstorage.BlobContainersClientListResponse{ { ListContainerItems: armstorage.ListContainerItems{ Value: []*armstorage.ListContainerItem{ { // Container with nil name should be skipped Name: nil, }, { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/blobServices/default/containers/valid-container"), Name: new("valid-container"), Type: new("Microsoft.Storage/storageAccounts/blobServices/containers"), }, }, }, }, }, } testClient := &testBlobContainersClient{ MockBlobContainersClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a valid name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(storageAccountName, "valid-container") { t.Errorf("Expected unique attribute value '%s', got %s", shared.CompositeLookupKey(storageAccountName, "valid-container"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("container not found") mockClient := mocks.NewMockBlobContainersClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, "nonexistent-container").Return( armstorage.BlobContainersClientGetResponse{}, expectedErr) testClient := &testBlobContainersClient{MockBlobContainersClient: mockClient} wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + "nonexistent-container" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent container, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockBlobContainersClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorBlobContainersPager{} testClient := &testBlobContainersClient{ MockBlobContainersClient: mockClient, pager: errorPager, } wrapper := manual.NewStorageBlobContainer(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], "test-account", true) // The Search implementation should return an error when pager.NextPage returns an error // Errors from NextPage are converted to QueryError by the implementation if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } // createAzureBlobContainer creates a mock Azure blob container for testing func createAzureBlobContainer(containerName string) *armstorage.BlobContainer { return &armstorage.BlobContainer{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/blobServices/default/containers/" + containerName), Name: new(containerName), Type: new("Microsoft.Storage/storageAccounts/blobServices/containers"), ContainerProperties: &armstorage.ContainerProperties{ PublicAccess: new(armstorage.PublicAccessNone), }, Etag: new("\"0x8D1234567890ABC\""), } } // createAzureBlobContainerWithEncryptionScope creates a mock Azure blob container with a default encryption scope func createAzureBlobContainerWithEncryptionScope(containerName, encryptionScopeName string) *armstorage.BlobContainer { return &armstorage.BlobContainer{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/blobServices/default/containers/" + containerName), Name: new(containerName), Type: new("Microsoft.Storage/storageAccounts/blobServices/containers"), ContainerProperties: &armstorage.ContainerProperties{ PublicAccess: new(armstorage.PublicAccessNone), DefaultEncryptionScope: new(encryptionScopeName), DenyEncryptionScopeOverride: new(false), }, Etag: new("\"0x8D1234567890ABC\""), } } ================================================ FILE: sources/azure/manual/storage-encryption-scope.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var StorageEncryptionScopeLookupByName = shared.NewItemTypeLookup("name", azureshared.StorageEncryptionScope) type storageEncryptionScopeWrapper struct { client clients.EncryptionScopesClient *azureshared.MultiResourceGroupBase } func NewStorageEncryptionScope(client clients.EncryptionScopesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &storageEncryptionScopeWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.StorageEncryptionScope, ), } } func (s storageEncryptionScopeWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: storageAccountName and encryptionScopeName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] encryptionScopeName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, storageAccountName, encryptionScopeName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azureEncryptionScopeToSDPItem(&resp.EncryptionScope, storageAccountName, encryptionScopeName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s storageEncryptionScopeWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageAccountLookupByName, StorageEncryptionScopeLookupByName, } } func (s storageEncryptionScopeWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: storageAccountName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, encScope := range page.Value { if encScope.Name == nil { continue } item, sdpErr := s.azureEncryptionScopeToSDPItem(encScope, storageAccountName, *encScope.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s storageEncryptionScopeWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: storageAccountName"), scope, s.Type())) return } storageAccountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, encScope := range page.Value { if encScope.Name == nil { continue } item, sdpErr := s.azureEncryptionScopeToSDPItem(encScope, storageAccountName, *encScope.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s storageEncryptionScopeWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { StorageAccountLookupByName, }, } } func (s storageEncryptionScopeWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.StorageAccount: true, azureshared.KeyVaultVault: true, azureshared.KeyVaultKey: true, stdlib.NetworkDNS: true, } } func (s storageEncryptionScopeWrapper) azureEncryptionScopeToSDPItem(encScope *armstorage.EncryptionScope, storageAccountName, encryptionScopeName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(encScope, "tags") if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(storageAccountName, encryptionScopeName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item := &sdp.Item{ Type: azureshared.StorageEncryptionScope.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) // Link to Key Vault when encryption scope uses customer-managed keys (source Microsoft.KeyVault) if encScope.EncryptionScopeProperties != nil && encScope.EncryptionScopeProperties.KeyVaultProperties != nil && encScope.EncryptionScopeProperties.KeyVaultProperties.KeyURI != nil { keyURI := *encScope.EncryptionScopeProperties.KeyVaultProperties.KeyURI vaultName := azureshared.ExtractVaultNameFromURI(keyURI) keyName := azureshared.ExtractKeyNameFromURI(keyURI) if vaultName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultVault.String(), Method: sdp.QueryMethod_GET, Query: vaultName, Scope: scope, }, }) } if vaultName != "" && keyName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.KeyVaultKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(vaultName, keyName), Scope: scope, }, }) } if dnsName := azureshared.ExtractDNSFromURL(keyURI); dnsName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } if encScope.EncryptionScopeProperties != nil && encScope.EncryptionScopeProperties.State != nil { switch *encScope.EncryptionScopeProperties.State { case armstorage.EncryptionScopeStateEnabled: item.Health = sdp.Health_HEALTH_OK.Enum() case armstorage.EncryptionScopeStateDisabled: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() default: item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } return item, nil } func (s storageEncryptionScopeWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "azurerm_storage_encryption_scope.id", }, } } func (s storageEncryptionScopeWrapper) IAMPermissions() []string { return []string{ "Microsoft.Storage/storageAccounts/encryptionScopes/read", } } func (s storageEncryptionScopeWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/storage-encryption-scope_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockEncryptionScopesPager struct { pages []armstorage.EncryptionScopesClientListResponse index int } func (m *mockEncryptionScopesPager) More() bool { return m.index < len(m.pages) } func (m *mockEncryptionScopesPager) NextPage(ctx context.Context) (armstorage.EncryptionScopesClientListResponse, error) { if m.index >= len(m.pages) { return armstorage.EncryptionScopesClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type errorEncryptionScopesPager struct{} func (e *errorEncryptionScopesPager) More() bool { return true } func (e *errorEncryptionScopesPager) NextPage(ctx context.Context) (armstorage.EncryptionScopesClientListResponse, error) { return armstorage.EncryptionScopesClientListResponse{}, errors.New("pager error") } type testEncryptionScopesClient struct { *mocks.MockEncryptionScopesClient pager clients.EncryptionScopesPager } func (t *testEncryptionScopesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.EncryptionScopesPager { return t.pager } func TestStorageEncryptionScope(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" storageAccountName := "teststorageaccount" encryptionScopeName := "test-encryption-scope" t.Run("Get", func(t *testing.T) { encScope := createAzureEncryptionScope(encryptionScopeName) mockClient := mocks.NewMockEncryptionScopesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, encryptionScopeName).Return( armstorage.EncryptionScopesClientGetResponse{ EncryptionScope: *encScope, }, nil) testClient := &testEncryptionScopesClient{MockEncryptionScopesClient: mockClient} wrapper := manual.NewStorageEncryptionScope(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(storageAccountName, encryptionScopeName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.StorageEncryptionScope.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageEncryptionScope.String(), sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(storageAccountName, encryptionScopeName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(storageAccountName, encryptionScopeName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Fatalf("Expected 1 linked query, got: %d", len(linkedQueries)) } linkedQuery := linkedQueries[0] if linkedQuery.GetQuery().GetType() != azureshared.StorageAccount.String() { t.Errorf("Expected linked query type %s, got %s", azureshared.StorageAccount.String(), linkedQuery.GetQuery().GetType()) } if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query %s, got %s", storageAccountName, linkedQuery.GetQuery().GetQuery()) } }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockEncryptionScopesClient(ctrl) testClient := &testEncryptionScopesClient{MockEncryptionScopesClient: mockClient} wrapper := manual.NewStorageEncryptionScope(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], storageAccountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { scope1 := createAzureEncryptionScope("scope-1") scope2 := createAzureEncryptionScope("scope-2") mockClient := mocks.NewMockEncryptionScopesClient(ctrl) mockPager := &mockEncryptionScopesPager{ pages: []armstorage.EncryptionScopesClientListResponse{ { EncryptionScopeListResult: armstorage.EncryptionScopeListResult{ Value: []*armstorage.EncryptionScope{scope1, scope2}, }, }, }, } testClient := &testEncryptionScopesClient{ MockEncryptionScopesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageEncryptionScope(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.StorageEncryptionScope.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageEncryptionScope.String(), item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockEncryptionScopesClient(ctrl) testClient := &testEncryptionScopesClient{MockEncryptionScopesClient: mockClient} wrapper := manual.NewStorageEncryptionScope(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_ScopeWithNilName", func(t *testing.T) { mockClient := mocks.NewMockEncryptionScopesClient(ctrl) validScope := createAzureEncryptionScope("valid-scope") mockPager := &mockEncryptionScopesPager{ pages: []armstorage.EncryptionScopesClientListResponse{ { EncryptionScopeListResult: armstorage.EncryptionScopeListResult{ Value: []*armstorage.EncryptionScope{ {Name: nil}, validScope, }, }, }, }, } testClient := &testEncryptionScopesClient{ MockEncryptionScopesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageEncryptionScope(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(storageAccountName, "valid-scope") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(storageAccountName, "valid-scope"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("encryption scope not found") mockClient := mocks.NewMockEncryptionScopesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, "nonexistent-scope").Return( armstorage.EncryptionScopesClientGetResponse{}, expectedErr) testClient := &testEncryptionScopesClient{MockEncryptionScopesClient: mockClient} wrapper := manual.NewStorageEncryptionScope(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + "nonexistent-scope" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent encryption scope, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockEncryptionScopesClient(ctrl) errorPager := &errorEncryptionScopesPager{} testClient := &testEncryptionScopesClient{ MockEncryptionScopesClient: mockClient, pager: errorPager, } wrapper := manual.NewStorageEncryptionScope(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } func createAzureEncryptionScope(scopeName string) *armstorage.EncryptionScope { return &armstorage.EncryptionScope{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/encryptionScopes/" + scopeName), Name: new(scopeName), Type: new("Microsoft.Storage/storageAccounts/encryptionScopes"), EncryptionScopeProperties: &armstorage.EncryptionScopeProperties{ Source: to.Ptr(armstorage.EncryptionScopeSourceMicrosoftStorage), State: to.Ptr(armstorage.EncryptionScopeStateEnabled), }, } } ================================================ FILE: sources/azure/manual/storage-fileshare.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var StorageFileShareLookupByName = shared.NewItemTypeLookup("name", azureshared.StorageFileShare) type storageFileShareWrapper struct { client clients.FileSharesClient *azureshared.MultiResourceGroupBase } func NewStorageFileShare(client clients.FileSharesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &storageFileShareWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.StorageFileShare, ), } } func (s storageFileShareWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: storageAccountName and shareName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] shareName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, storageAccountName, shareName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } var sdpErr *sdp.QueryError var item *sdp.Item item, sdpErr = s.azureFileShareToSDPItem(&resp.FileShare, storageAccountName, shareName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s storageFileShareWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageAccountLookupByName, StorageFileShareLookupByName, } } func (s storageFileShareWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: storageAccountName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, fileShare := range page.Value { if fileShare.Name == nil { continue } item, sdpErr := s.azureFileShareToSDPItem(&armstorage.FileShare{ ID: fileShare.ID, Name: fileShare.Name, Type: fileShare.Type, FileShareProperties: fileShare.Properties, Etag: fileShare.Etag, }, storageAccountName, *fileShare.Name, scope, ) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s storageFileShareWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: storageAccountName"), scope, s.Type())) return } storageAccountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, fileShare := range page.Value { if fileShare.Name == nil { continue } item, sdpErr := s.azureFileShareToSDPItem(&armstorage.FileShare{ ID: fileShare.ID, Name: fileShare.Name, Type: fileShare.Type, FileShareProperties: fileShare.Properties, Etag: fileShare.Etag, }, storageAccountName, *fileShare.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s storageFileShareWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { StorageAccountLookupByName, }, } } func (s storageFileShareWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.StorageAccount: true, } } func (s storageFileShareWrapper) azureFileShareToSDPItem(fileShare *armstorage.FileShare, storageAccountName, shareName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(fileShare) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(storageAccountName, shareName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.StorageFileShare.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) return sdpItem, nil } func (s storageFileShareWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_share // Terraform uses: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}/fileServices/default/shares/{share} TerraformQueryMap: "azurerm_storage_share.id", }, } } func (s storageFileShareWrapper) IAMPermissions() []string { return []string{ "Microsoft.Storage/storageAccounts/fileServices/shares/read", } } func (s storageFileShareWrapper) PredefinedRole() string { return "Storage File Data Privileged Reader" } ================================================ FILE: sources/azure/manual/storage-fileshare_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockFileSharesPager is a simple mock implementation of FileSharesPager type mockFileSharesPager struct { pages []armstorage.FileSharesClientListResponse index int } func (m *mockFileSharesPager) More() bool { return m.index < len(m.pages) } func (m *mockFileSharesPager) NextPage(ctx context.Context) (armstorage.FileSharesClientListResponse, error) { if m.index >= len(m.pages) { return armstorage.FileSharesClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorFileSharesPager is a mock pager that always returns an error type errorFileSharesPager struct{} func (e *errorFileSharesPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorFileSharesPager) NextPage(ctx context.Context) (armstorage.FileSharesClientListResponse, error) { return armstorage.FileSharesClientListResponse{}, errors.New("pager error") } // testFileSharesClient wraps the mock to implement the correct interface type testFileSharesClient struct { *mocks.MockFileSharesClient pager clients.FileSharesPager } func (t *testFileSharesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.FileSharesPager { return t.pager } func TestStorageFileShare(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" storageAccountName := "teststorageaccount" shareName := "test-share" t.Run("Get", func(t *testing.T) { fileShare := createAzureFileShare(shareName) mockClient := mocks.NewMockFileSharesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, shareName).Return( armstorage.FileSharesClientGetResponse{ FileShare: *fileShare, }, nil) testClient := &testFileSharesClient{MockFileSharesClient: mockClient} wrapper := manual.NewStorageFileShare(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and shareName as query parts query := shared.CompositeLookupKey(storageAccountName, shareName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.StorageFileShare.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageFileShare, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(storageAccountName, shareName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(storageAccountName, shareName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { // Verify linked item queries linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Fatalf("Expected 1 linked query, got: %d", len(linkedQueries)) } linkedQuery := linkedQueries[0] if linkedQuery.GetQuery().GetType() != azureshared.StorageAccount.String() { t.Errorf("Expected linked query type %s, got %s", azureshared.StorageAccount, linkedQuery.GetQuery().GetType()) } if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query %s, got %s", storageAccountName, linkedQuery.GetQuery().GetQuery()) } }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockFileSharesClient(ctrl) testClient := &testFileSharesClient{MockFileSharesClient: mockClient} wrapper := manual.NewStorageFileShare(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only storage account name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], storageAccountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { share1 := createAzureFileShare("share-1") share2 := createAzureFileShare("share-2") mockClient := mocks.NewMockFileSharesClient(ctrl) mockPager := &mockFileSharesPager{ pages: []armstorage.FileSharesClientListResponse{ { FileShareItems: armstorage.FileShareItems{ Value: []*armstorage.FileShareItem{ { ID: share1.ID, Name: share1.Name, Type: share1.Type, Properties: &armstorage.FileShareProperties{ AccessTier: share1.FileShareProperties.AccessTier, ShareQuota: share1.FileShareProperties.ShareQuota, }, Etag: share1.Etag, }, { ID: share2.ID, Name: share2.Name, Type: share2.Type, Properties: &armstorage.FileShareProperties{ AccessTier: share2.FileShareProperties.AccessTier, ShareQuota: share2.FileShareProperties.ShareQuota, }, Etag: share2.Etag, }, }, }, }, }, } testClient := &testFileSharesClient{ MockFileSharesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageFileShare(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.StorageFileShare.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageFileShare, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { // This test verifies that the wrapper's Search method validates query parts // We test it directly on the wrapper since the adapter may handle empty queries differently mockClient := mocks.NewMockFileSharesClient(ctrl) testClient := &testFileSharesClient{MockFileSharesClient: mockClient} wrapper := manual.NewStorageFileShare(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling List _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_ShareWithNilName", func(t *testing.T) { mockClient := mocks.NewMockFileSharesClient(ctrl) mockPager := &mockFileSharesPager{ pages: []armstorage.FileSharesClientListResponse{ { FileShareItems: armstorage.FileShareItems{ Value: []*armstorage.FileShareItem{ { // Share with nil name should be skipped Name: nil, }, { ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/fileServices/default/shares/valid-share"), Name: new("valid-share"), Type: new("Microsoft.Storage/storageAccounts/fileServices/shares"), }, }, }, }, }, } testClient := &testFileSharesClient{ MockFileSharesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageFileShare(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a valid name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(storageAccountName, "valid-share") { t.Errorf("Expected share name 'teststorageaccount|valid-share', got %s", sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("file share not found") mockClient := mocks.NewMockFileSharesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, "nonexistent-share").Return( armstorage.FileSharesClientGetResponse{}, expectedErr) testClient := &testFileSharesClient{MockFileSharesClient: mockClient} wrapper := manual.NewStorageFileShare(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + "nonexistent-share" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent file share, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockFileSharesClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorFileSharesPager{} testClient := &testFileSharesClient{ MockFileSharesClient: mockClient, pager: errorPager, } wrapper := manual.NewStorageFileShare(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], "test-account", true) // The Search implementation should return an error when pager.NextPage returns an error // Errors from NextPage are converted to QueryError by the implementation if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) } // createAzureFileShare creates a mock Azure file share for testing func createAzureFileShare(shareName string) *armstorage.FileShare { return &armstorage.FileShare{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/fileServices/default/shares/" + shareName), Name: new(shareName), Type: new("Microsoft.Storage/storageAccounts/fileServices/shares"), FileShareProperties: &armstorage.FileShareProperties{ AccessTier: new(armstorage.ShareAccessTierHot), ShareQuota: new(int32(5120)), // 5GB }, Etag: new("\"0x8D1234567890ABC\""), } } ================================================ FILE: sources/azure/manual/storage-private-endpoint-connection.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var StoragePrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.StoragePrivateEndpointConnection) type storagePrivateEndpointConnectionWrapper struct { client clients.StoragePrivateEndpointConnectionsClient *azureshared.MultiResourceGroupBase } // NewStoragePrivateEndpointConnection returns a SearchableWrapper for Azure storage account private endpoint connections. func NewStoragePrivateEndpointConnection(client clients.StoragePrivateEndpointConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &storagePrivateEndpointConnectionWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.StoragePrivateEndpointConnection, ), } } func (s storagePrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: storageAccountName and privateEndpointConnectionName", Scope: scope, ItemType: s.Type(), } } accountName := queryParts[0] connectionName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, accountName, connectionName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(&resp.PrivateEndpointConnection, accountName, connectionName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s storagePrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageAccountLookupByName, StoragePrivateEndpointConnectionLookupByName, } } func (s storagePrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: storageAccountName", Scope: scope, ItemType: s.Type(), } } accountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.List(ctx, rgScope.ResourceGroup, accountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, conn := range page.Value { if conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, accountName, *conn.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s storagePrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: storageAccountName"), scope, s.Type())) return } accountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.List(ctx, rgScope.ResourceGroup, accountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, conn := range page.Value { if conn.Name == nil { continue } item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, accountName, *conn.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s storagePrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { StorageAccountLookupByName, }, } } func (s storagePrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.StorageAccount: true, azureshared.NetworkPrivateEndpoint: true, } } func (s storagePrivateEndpointConnectionWrapper) azurePrivateEndpointConnectionToSDPItem(conn *armstorage.PrivateEndpointConnection, accountName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(conn) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(accountName, connectionName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.StoragePrivateEndpointConnection.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Health from provisioning state if conn.Properties != nil && conn.Properties.ProvisioningState != nil { switch *conn.Properties.ProvisioningState { case armstorage.PrivateEndpointConnectionProvisioningStateSucceeded: sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case armstorage.PrivateEndpointConnectionProvisioningStateCreating, armstorage.PrivateEndpointConnectionProvisioningStateDeleting: sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case armstorage.PrivateEndpointConnectionProvisioningStateFailed: sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } } // Link to parent Storage Account sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: accountName, Scope: scope, }, }) // Link to Network Private Endpoint when present (may be in different resource group) if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { peID := *conn.Properties.PrivateEndpoint.ID peName := azureshared.ExtractResourceName(peID) if peName != "" { linkedScope := scope if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { linkedScope = extractedScope } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.NetworkPrivateEndpoint.String(), Method: sdp.QueryMethod_GET, Query: peName, Scope: linkedScope, }, }) } } return sdpItem, nil } func (s storagePrivateEndpointConnectionWrapper) IAMPermissions() []string { return []string{ "Microsoft.Storage/storageAccounts/privateEndpointConnections/read", } } func (s storagePrivateEndpointConnectionWrapper) PredefinedRole() string { return "Reader" } ================================================ FILE: sources/azure/manual/storage-private-endpoint-connection_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) type mockPrivateEndpointConnectionsPager struct { pages []armstorage.PrivateEndpointConnectionsClientListResponse index int } func (m *mockPrivateEndpointConnectionsPager) More() bool { return m.index < len(m.pages) } func (m *mockPrivateEndpointConnectionsPager) NextPage(ctx context.Context) (armstorage.PrivateEndpointConnectionsClientListResponse, error) { if m.index >= len(m.pages) { return armstorage.PrivateEndpointConnectionsClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } type testStoragePrivateEndpointConnectionsClient struct { *mocks.MockStoragePrivateEndpointConnectionsClient pager clients.PrivateEndpointConnectionsPager } func (t *testStoragePrivateEndpointConnectionsClient) List(ctx context.Context, resourceGroupName, accountName string) clients.PrivateEndpointConnectionsPager { return t.pager } func TestStoragePrivateEndpointConnection(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" accountName := "teststorageaccount" connectionName := "test-pec" t.Run("Get", func(t *testing.T) { conn := createAzureStoragePrivateEndpointConnection(connectionName, "") mockClient := mocks.NewMockStoragePrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, connectionName).Return( armstorage.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testStoragePrivateEndpointConnectionsClient{MockStoragePrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewStoragePrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.StoragePrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.StoragePrivateEndpointConnection, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(accountName, connectionName) { t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(accountName, connectionName), sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) < 1 { t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) } foundStorageAccount := false for _, lq := range linkedQueries { if lq.GetQuery().GetType() == azureshared.StorageAccount.String() { foundStorageAccount = true if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected StorageAccount link method GET, got %v", lq.GetQuery().GetMethod()) } if lq.GetQuery().GetQuery() != accountName { t.Errorf("Expected StorageAccount query %s, got %s", accountName, lq.GetQuery().GetQuery()) } } } if !foundStorageAccount { t.Error("Expected linked query to StorageAccount") } }) }) t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { peID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe" conn := createAzureStoragePrivateEndpointConnection(connectionName, peID) mockClient := mocks.NewMockStoragePrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, connectionName).Return( armstorage.PrivateEndpointConnectionsClientGetResponse{ PrivateEndpointConnection: *conn, }, nil) testClient := &testStoragePrivateEndpointConnectionsClient{MockStoragePrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewStoragePrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, connectionName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } foundPrivateEndpoint := false for _, lq := range sdpItem.GetLinkedItemQueries() { if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { foundPrivateEndpoint = true if lq.GetQuery().GetQuery() != "test-pe" { t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) } break } } if !foundPrivateEndpoint { t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") } }) t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { mockClient := mocks.NewMockStoragePrivateEndpointConnectionsClient(ctrl) testClient := &testStoragePrivateEndpointConnectionsClient{MockStoragePrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewStoragePrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], accountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { conn1 := createAzureStoragePrivateEndpointConnection("pec-1", "") conn2 := createAzureStoragePrivateEndpointConnection("pec-2", "") mockClient := mocks.NewMockStoragePrivateEndpointConnectionsClient(ctrl) mockPager := &mockPrivateEndpointConnectionsPager{ pages: []armstorage.PrivateEndpointConnectionsClientListResponse{ { PrivateEndpointConnectionListResult: armstorage.PrivateEndpointConnectionListResult{ Value: []*armstorage.PrivateEndpointConnection{conn1, conn2}, }, }, }, } testClient := &testStoragePrivateEndpointConnectionsClient{ MockStoragePrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewStoragePrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.StoragePrivateEndpointConnection.String() { t.Errorf("Expected type %s, got %s", azureshared.StoragePrivateEndpointConnection, item.GetType()) } } }) t.Run("Search_NilNameSkipped", func(t *testing.T) { validConn := createAzureStoragePrivateEndpointConnection("valid-pec", "") mockClient := mocks.NewMockStoragePrivateEndpointConnectionsClient(ctrl) mockPager := &mockPrivateEndpointConnectionsPager{ pages: []armstorage.PrivateEndpointConnectionsClientListResponse{ { PrivateEndpointConnectionListResult: armstorage.PrivateEndpointConnectionListResult{ Value: []*armstorage.PrivateEndpointConnection{ {Name: nil}, validConn, }, }, }, }, } testClient := &testStoragePrivateEndpointConnectionsClient{ MockStoragePrivateEndpointConnectionsClient: mockClient, pager: mockPager, } wrapper := manual.NewStoragePrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], accountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) } if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(accountName, "valid-pec") { t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(accountName, "valid-pec"), sdpItems[0].UniqueAttributeValue()) } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockStoragePrivateEndpointConnectionsClient(ctrl) testClient := &testStoragePrivateEndpointConnectionsClient{MockStoragePrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewStoragePrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("private endpoint connection not found") mockClient := mocks.NewMockStoragePrivateEndpointConnectionsClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, accountName, "nonexistent-pec").Return( armstorage.PrivateEndpointConnectionsClientGetResponse{}, expectedErr) testClient := &testStoragePrivateEndpointConnectionsClient{MockStoragePrivateEndpointConnectionsClient: mockClient} wrapper := manual.NewStoragePrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(accountName, "nonexistent-pec") _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent private endpoint connection, but got nil") } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewStoragePrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if !links[azureshared.StorageAccount] { t.Error("Expected StorageAccount in PotentialLinks") } if !links[azureshared.NetworkPrivateEndpoint] { t.Error("Expected NetworkPrivateEndpoint in PotentialLinks") } }) } func createAzureStoragePrivateEndpointConnection(connectionName, privateEndpointID string) *armstorage.PrivateEndpointConnection { conn := &armstorage.PrivateEndpointConnection{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/privateEndpointConnections/" + connectionName), Name: new(connectionName), Type: new("Microsoft.Storage/storageAccounts/privateEndpointConnections"), Properties: &armstorage.PrivateEndpointConnectionProperties{ ProvisioningState: to.Ptr(armstorage.PrivateEndpointConnectionProvisioningStateSucceeded), PrivateLinkServiceConnectionState: &armstorage.PrivateLinkServiceConnectionState{ Status: to.Ptr(armstorage.PrivateEndpointServiceConnectionStatusApproved), }, }, } if privateEndpointID != "" { conn.Properties.PrivateEndpoint = &armstorage.PrivateEndpoint{ ID: new(privateEndpointID), } } return conn } ================================================ FILE: sources/azure/manual/storage-queues.go ================================================ package manual import ( "context" "errors" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var StorageQueueLookupByName = shared.NewItemTypeLookup("name", azureshared.StorageQueue) type storageQueuesWrapper struct { client clients.QueuesClient *azureshared.MultiResourceGroupBase } func NewStorageQueues(client clients.QueuesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &storageQueuesWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.StorageQueue, ), } } func (s storageQueuesWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: storageAccountName and queueName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] queueName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, storageAccountName, queueName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } return s.azureQueueToSDPItem(&resp.Queue, storageAccountName, queueName, scope) } func (s storageQueuesWrapper) azureQueueToSDPItem(queue *armstorage.Queue, storageAccountName, queueName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(queue) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(storageAccountName, queueName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.StorageQueue.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Queue is a child of the storage account; queue is affected if account changes, account is not affected by queue changes. sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) return sdpItem, nil } func (s storageQueuesWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_queue // Terraform uses: /subscriptions/{{subscription}}/resourceGroups/{{resourceGroup}}/providers/Microsoft.Storage/storageAccounts/{{storageAccountName}}/queueServices/default/queues/{{queueName}} TerraformQueryMap: "azurerm_storage_queue.id", }, } } func (s storageQueuesWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageAccountLookupByName, StorageQueueLookupByName, } } func (s storageQueuesWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) < 1 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: storageAccountName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, queue := range page.Value { if queue.Name == nil || queue.QueueProperties == nil { continue } item, sdpErr := s.azureQueueToSDPItem(&armstorage.Queue{ ID: queue.ID, Name: queue.Name, Type: queue.Type, QueueProperties: &armstorage.QueueProperties{ Metadata: queue.QueueProperties.Metadata, }, }, storageAccountName, *queue.Name, scope) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s storageQueuesWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) < 1 { stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: storageAccountName"), scope, s.Type())) return } storageAccountName := queryParts[0] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, queue := range page.Value { if queue.Name == nil || queue.QueueProperties == nil { continue } item, sdpErr := s.azureQueueToSDPItem(&armstorage.Queue{ ID: queue.ID, Name: queue.Name, Type: queue.Type, QueueProperties: &armstorage.QueueProperties{ Metadata: queue.QueueProperties.Metadata, }, }, storageAccountName, *queue.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s storageQueuesWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { StorageAccountLookupByName, }, } } func (s storageQueuesWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.StorageAccount: true, } } func (s storageQueuesWrapper) IAMPermissions() []string { return []string{ // reference: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-queue-data-reader "Microsoft.Storage/storageAccounts/queueServices/queues/read", } } func (s storageQueuesWrapper) PredefinedRole() string { // reference: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-queue-data-reader return "Storage Queue Data Reader" } ================================================ FILE: sources/azure/manual/storage-queues_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockQueuesPager is a simple mock implementation of QueuesPager type mockQueuesPager struct { pages []armstorage.QueueClientListResponse index int } func (m *mockQueuesPager) More() bool { return m.index < len(m.pages) } func (m *mockQueuesPager) NextPage(ctx context.Context) (armstorage.QueueClientListResponse, error) { if m.index >= len(m.pages) { return armstorage.QueueClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorQueuesPager is a mock pager that always returns an error type errorQueuesPager struct{} func (e *errorQueuesPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorQueuesPager) NextPage(ctx context.Context) (armstorage.QueueClientListResponse, error) { return armstorage.QueueClientListResponse{}, errors.New("pager error") } // testQueuesClient wraps the mock to implement the correct interface type testQueuesClient struct { *mocks.MockQueuesClient pager clients.QueuesPager } func (t *testQueuesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.QueuesPager { return t.pager } func TestStorageQueues(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" storageAccountName := "teststorageaccount" queueName := "test-queue" t.Run("Get", func(t *testing.T) { queue := createAzureQueue(queueName) mockClient := mocks.NewMockQueuesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, queueName).Return( armstorage.QueueClientGetResponse{ Queue: *queue, }, nil) testClient := &testQueuesClient{MockQueuesClient: mockClient} wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and queueName as query parts query := storageAccountName + shared.QuerySeparator + queueName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.StorageQueue.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageQueue, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedID := shared.CompositeLookupKey(storageAccountName, queueName) if sdpItem.UniqueAttributeValue() != expectedID { t.Errorf("Expected unique attribute value %s, got %s", expectedID, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { // Verify linked item queries linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Fatalf("Expected 1 linked query, got: %d", len(linkedQueries)) } linkedQuery := linkedQueries[0] if linkedQuery.GetQuery().GetType() != azureshared.StorageAccount.String() { t.Errorf("Expected linked query type %s, got %s", azureshared.StorageAccount, linkedQuery.GetQuery().GetType()) } if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query %s, got %s", storageAccountName, linkedQuery.GetQuery().GetQuery()) } }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockQueuesClient(ctrl) testClient := &testQueuesClient{MockQueuesClient: mockClient} wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only storage account name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], storageAccountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { queue1 := createAzureQueue("queue-1") queue2 := createAzureQueue("queue-2") mockClient := mocks.NewMockQueuesClient(ctrl) mockPager := &mockQueuesPager{ pages: []armstorage.QueueClientListResponse{ { ListQueueResource: armstorage.ListQueueResource{ Value: []*armstorage.ListQueue{ { ID: queue1.ID, Name: queue1.Name, Type: queue1.Type, QueueProperties: &armstorage.ListQueueProperties{ Metadata: queue1.QueueProperties.Metadata, }, }, { ID: queue2.ID, Name: queue2.Name, Type: queue2.Type, QueueProperties: &armstorage.ListQueueProperties{ Metadata: queue2.QueueProperties.Metadata, }, }, }, }, }, }, } testClient := &testQueuesClient{ MockQueuesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.StorageQueue.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageQueue, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { // This test verifies that the wrapper's Search method validates query parts // We test it directly on the wrapper since the adapter may handle empty queries differently mockClient := mocks.NewMockQueuesClient(ctrl) testClient := &testQueuesClient{MockQueuesClient: mockClient} wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling List _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_QueueWithNilName", func(t *testing.T) { validQueue := createAzureQueue("valid-queue") mockClient := mocks.NewMockQueuesClient(ctrl) mockPager := &mockQueuesPager{ pages: []armstorage.QueueClientListResponse{ { ListQueueResource: armstorage.ListQueueResource{ Value: []*armstorage.ListQueue{ { // Queue with nil name should be skipped Name: nil, }, { ID: validQueue.ID, Name: validQueue.Name, Type: validQueue.Type, QueueProperties: &armstorage.ListQueueProperties{ Metadata: validQueue.QueueProperties.Metadata, }, }, }, }, }, }, } testClient := &testQueuesClient{ MockQueuesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a valid name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } expectedID := shared.CompositeLookupKey(storageAccountName, "valid-queue") if sdpItems[0].UniqueAttributeValue() != expectedID { t.Errorf("Expected queue ID %s, got %s", expectedID, sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("queue not found") mockClient := mocks.NewMockQueuesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, "nonexistent-queue").Return( armstorage.QueueClientGetResponse{}, expectedErr) testClient := &testQueuesClient{MockQueuesClient: mockClient} wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + "nonexistent-queue" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent queue, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockQueuesClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorQueuesPager{} testClient := &testQueuesClient{ MockQueuesClient: mockClient, pager: errorPager, } wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) // The Search implementation should return an error when pager.NextPage returns an error // Errors from NextPage are converted to QueryError by the implementation if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockQueuesClient(ctrl) testClient := &testQueuesClient{MockQueuesClient: mockClient} wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements SearchableWrapper (it's returned as this type) if wrapper == nil { t.Error("Wrapper should not be nil") } // Verify adapter implements SearchableAdapter adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockQueuesClient(ctrl) testClient := &testQueuesClient{MockQueuesClient: mockClient} wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if len(links) == 0 { t.Error("Expected potential links to be defined") } if !links[azureshared.StorageAccount] { t.Error("Expected StorageAccount to be in potential links") } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockQueuesClient(ctrl) testClient := &testQueuesClient{MockQueuesClient: mockClient} wrapper := manual.NewStorageQueues(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) == 0 { t.Fatal("Expected TerraformMappings to be defined") } // Verify we have the correct mapping for azurerm_storage_queue.id foundIDMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_storage_queue.id" { foundIDMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected TerraformMethod to be SEARCH for id mapping, got %s", mapping.GetTerraformMethod()) } } } if !foundIDMapping { t.Error("Expected TerraformMappings to include 'azurerm_storage_queue.id' mapping") } // Verify we only have one mapping (the id mapping) if len(mappings) != 1 { t.Errorf("Expected 1 TerraformMapping, got %d", len(mappings)) } }) } // createAzureQueue creates a mock Azure queue for testing func createAzureQueue(queueName string) *armstorage.Queue { return &armstorage.Queue{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/" + queueName), Name: new(queueName), Type: new("Microsoft.Storage/storageAccounts/queueServices/queues"), QueueProperties: &armstorage.QueueProperties{ Metadata: map[string]*string{ "env": new("test"), "project": new("testing"), }, }, } } ================================================ FILE: sources/azure/manual/storage-table.go ================================================ package manual import ( "context" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/shared" ) var StorageTableLookupByName = shared.NewItemTypeLookup("name", azureshared.StorageTable) type storageTablesWrapper struct { client clients.TablesClient *azureshared.MultiResourceGroupBase } func NewStorageTable(client clients.TablesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { return &storageTablesWrapper{ client: client, MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( resourceGroupScopes, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, azureshared.StorageTable, ), } } func (s storageTablesWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { if len(queryParts) < 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires 2 query parts: storageAccountName and tableName", Scope: scope, ItemType: s.Type(), } } storageAccountName := queryParts[0] tableName := queryParts[1] rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } resp, err := s.client.Get(ctx, rgScope.ResourceGroup, storageAccountName, tableName) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } item, sdpErr := s.azureTableToSDPItem(&resp.Table, storageAccountName, tableName, scope) if sdpErr != nil { return nil, sdpErr } return item, nil } func (s storageTablesWrapper) azureTableToSDPItem(table *armstorage.Table, storageAccountName, tableName, scope string) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(table) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(storageAccountName, tableName)) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } sdpItem := &sdp.Item{ Type: azureshared.StorageTable.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Link to parent Storage Account (table is a child under tableServices/default/tables). sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: azureshared.StorageAccount.String(), Method: sdp.QueryMethod_GET, Query: storageAccountName, Scope: scope, }, }) return sdpItem, nil } func (s storageTablesWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_table // Terraform uses: /subscriptions/{{sub}}/resourceGroups/{{rg}}/providers/Microsoft.Storage/storageAccounts/{{account}}/tableServices/default/tables/{{table}} TerraformQueryMap: "azurerm_storage_table.id", }, } } func (s storageTablesWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageAccountLookupByName, StorageTableLookupByName, } } func (s storageTablesWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { if len(queryParts) != 1 { return nil, azureshared.QueryError(fmt.Errorf("queryParts must be 1 query part: storageAccountName, got %d", len(queryParts)), scope, s.Type()) } storageAccountName := queryParts[0] if storageAccountName == "" { return nil, azureshared.QueryError(fmt.Errorf("storageAccountName cannot be empty"), scope, s.Type()) } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) var items []*sdp.Item for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, azureshared.QueryError(err, scope, s.Type()) } for _, table := range page.Value { if table.Name == nil { continue } item, sdpErr := s.azureTableToSDPItem(&armstorage.Table{ ID: table.ID, Name: table.Name, Type: table.Type, TableProperties: table.TableProperties, }, storageAccountName, *table.Name, scope, ) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } } return items, nil } func (s storageTablesWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { if len(queryParts) != 1 { stream.SendError(azureshared.QueryError(fmt.Errorf("queryParts must be 1 query part: storageAccountName, got %d", len(queryParts)), scope, s.Type())) return } storageAccountName := queryParts[0] if storageAccountName == "" { stream.SendError(azureshared.QueryError(fmt.Errorf("storageAccountName cannot be empty"), scope, s.Type())) return } rgScope, err := s.ResourceGroupScopeFromScope(scope) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } pager := s.client.List(ctx, rgScope.ResourceGroup, storageAccountName) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { stream.SendError(azureshared.QueryError(err, scope, s.Type())) return } for _, table := range page.Value { if table.Name == nil { continue } item, sdpErr := s.azureTableToSDPItem(&armstorage.Table{ ID: table.ID, Name: table.Name, Type: table.Type, TableProperties: table.TableProperties, }, storageAccountName, *table.Name, scope) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } } func (s storageTablesWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { StorageAccountLookupByName, }, } } func (s storageTablesWrapper) PotentialLinks() map[shared.ItemType]bool { return map[shared.ItemType]bool{ azureshared.StorageAccount: true, } } func (s storageTablesWrapper) IAMPermissions() []string { return []string{ // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-table-data-reader "Microsoft.Storage/storageAccounts/tableServices/tables/read", } } func (s storageTablesWrapper) PredefinedRole() string { // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-table-data-reader return "Storage Table Data Reader" } ================================================ FILE: sources/azure/manual/storage-table_test.go ================================================ package manual_test import ( "context" "errors" "slices" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/azure/clients" "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" "github.com/overmindtech/cli/sources/azure/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) // mockTablesPager is a simple mock implementation of TablesPager type mockTablesPager struct { pages []armstorage.TableClientListResponse index int } func (m *mockTablesPager) More() bool { return m.index < len(m.pages) } func (m *mockTablesPager) NextPage(ctx context.Context) (armstorage.TableClientListResponse, error) { if m.index >= len(m.pages) { return armstorage.TableClientListResponse{}, errors.New("no more pages") } page := m.pages[m.index] m.index++ return page, nil } // errorTablesPager is a mock pager that always returns an error type errorTablesPager struct{} func (e *errorTablesPager) More() bool { return true // Always return true so NextPage will be called } func (e *errorTablesPager) NextPage(ctx context.Context) (armstorage.TableClientListResponse, error) { return armstorage.TableClientListResponse{}, errors.New("pager error") } // testTablesClient wraps the mock to implement the correct interface type testTablesClient struct { *mocks.MockTablesClient pager clients.TablesPager } func (t *testTablesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.TablesPager { return t.pager } func TestStorageTables(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() subscriptionID := "test-subscription" resourceGroup := "test-rg" storageAccountName := "teststorageaccount" tableName := "test-table" t.Run("Get", func(t *testing.T) { table := createAzureTable(tableName) mockClient := mocks.NewMockTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, tableName).Return( armstorage.TableClientGetResponse{ Table: *table, }, nil) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get requires storageAccountName and tableName as query parts query := storageAccountName + shared.QuerySeparator + tableName sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != azureshared.StorageTable.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageTable, sdpItem.GetType()) } if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) } expectedID := shared.CompositeLookupKey(storageAccountName, tableName) if sdpItem.UniqueAttributeValue() != expectedID { t.Errorf("Expected unique attribute value %s, got %s", expectedID, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) } // Validate the item if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } t.Run("StaticTests", func(t *testing.T) { // Verify linked item queries linkedQueries := sdpItem.GetLinkedItemQueries() if len(linkedQueries) != 1 { t.Fatalf("Expected 1 linked query, got: %d", len(linkedQueries)) } linkedQuery := linkedQueries[0] if linkedQuery.GetQuery().GetType() != azureshared.StorageAccount.String() { t.Errorf("Expected linked query type %s, got %s", azureshared.StorageAccount, linkedQuery.GetQuery().GetType()) } if linkedQuery.GetQuery().GetMethod() != sdp.QueryMethod_GET { t.Errorf("Expected linked query method GET, got %s", linkedQuery.GetQuery().GetMethod()) } if linkedQuery.GetQuery().GetQuery() != storageAccountName { t.Errorf("Expected linked query %s, got %s", storageAccountName, linkedQuery.GetQuery().GetQuery()) } }) }) t.Run("Get_InvalidQueryParts", func(t *testing.T) { mockClient := mocks.NewMockTablesClient(ctrl) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Test with insufficient query parts (only storage account name) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], storageAccountName, true) if qErr == nil { t.Error("Expected error when providing insufficient query parts, but got nil") } }) t.Run("Search", func(t *testing.T) { table1 := createAzureTable("table-1") table2 := createAzureTable("table-2") mockClient := mocks.NewMockTablesClient(ctrl) mockPager := &mockTablesPager{ pages: []armstorage.TableClientListResponse{ { ListTableResource: armstorage.ListTableResource{ Value: []*armstorage.Table{ { ID: table1.ID, Name: table1.Name, Type: table1.Type, TableProperties: table1.TableProperties, }, { ID: table2.ID, Name: table2.Name, Type: table2.Type, TableProperties: table2.TableProperties, }, }, }, }, }, } testClient := &testTablesClient{ MockTablesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } if item.GetType() != azureshared.StorageTable.String() { t.Errorf("Expected type %s, got %s", azureshared.StorageTable, item.GetType()) } } }) t.Run("Search_InvalidQueryParts", func(t *testing.T) { // This test verifies that the wrapper's Search method validates query parts // We test it directly on the wrapper since the adapter may handle empty queries differently mockClient := mocks.NewMockTablesClient(ctrl) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Test Search directly with no query parts - should return error before calling List _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) if qErr == nil { t.Error("Expected error when providing no query parts, but got nil") } }) t.Run("Search_TableWithNilName", func(t *testing.T) { validTable := createAzureTable("valid-table") mockClient := mocks.NewMockTablesClient(ctrl) mockPager := &mockTablesPager{ pages: []armstorage.TableClientListResponse{ { ListTableResource: armstorage.ListTableResource{ Value: []*armstorage.Table{ { // Table with nil name should be skipped Name: nil, }, { ID: validTable.ID, Name: validTable.Name, Type: validTable.Type, TableProperties: validTable.TableProperties, }, }, }, }, }, } testClient := &testTablesClient{ MockTablesClient: mockClient, pager: mockPager, } wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should only return 1 item (the one with a valid name) if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } expectedID := shared.CompositeLookupKey(storageAccountName, "valid-table") if sdpItems[0].UniqueAttributeValue() != expectedID { t.Errorf("Expected table ID %s, got %s", expectedID, sdpItems[0].UniqueAttributeValue()) } }) t.Run("ErrorHandling_Get", func(t *testing.T) { expectedErr := errors.New("table not found") mockClient := mocks.NewMockTablesClient(ctrl) mockClient.EXPECT().Get(ctx, resourceGroup, storageAccountName, "nonexistent-table").Return( armstorage.TableClientGetResponse{}, expectedErr) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := storageAccountName + shared.QuerySeparator + "nonexistent-table" _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr == nil { t.Error("Expected error when getting non-existent table, but got nil") } }) t.Run("ErrorHandling_Search", func(t *testing.T) { mockClient := mocks.NewMockTablesClient(ctrl) // Create a pager that returns an error when NextPage is called errorPager := &errorTablesPager{} testClient := &testTablesClient{ MockTablesClient: mockClient, pager: errorPager, } wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], storageAccountName, true) // The Search implementation should return an error when pager.NextPage returns an error // Errors from NextPage are converted to QueryError by the implementation if err == nil { t.Error("Expected error from pager when NextPage returns an error, but got nil") } }) t.Run("InterfaceCompliance", func(t *testing.T) { mockClient := mocks.NewMockTablesClient(ctrl) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) // Verify wrapper implements SearchableWrapper (it's returned as this type) if wrapper == nil { t.Error("Wrapper should not be nil") } // Verify adapter implements SearchableAdapter adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("Adapter should implement SearchableAdapter interface") } }) t.Run("PotentialLinks", func(t *testing.T) { mockClient := mocks.NewMockTablesClient(ctrl) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) links := wrapper.PotentialLinks() if len(links) == 0 { t.Error("Expected potential links to be defined") } if !links[azureshared.StorageAccount] { t.Error("Expected StorageAccount to be in potential links") } }) t.Run("TerraformMappings", func(t *testing.T) { mockClient := mocks.NewMockTablesClient(ctrl) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) mappings := wrapper.TerraformMappings() if len(mappings) == 0 { t.Fatal("Expected TerraformMappings to be defined") } // Verify we have the correct mapping for azurerm_storage_table.id foundIDMapping := false for _, mapping := range mappings { if mapping.GetTerraformQueryMap() == "azurerm_storage_table.id" { foundIDMapping = true if mapping.GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected TerraformMethod to be SEARCH for id mapping, got %s", mapping.GetTerraformMethod()) } } } if !foundIDMapping { t.Error("Expected TerraformMappings to include 'azurerm_storage_table.id' mapping") } // Verify we only have one mapping (the id mapping) if len(mappings) != 1 { t.Errorf("Expected 1 TerraformMapping, got %d", len(mappings)) } }) t.Run("IAMPermissions", func(t *testing.T) { mockClient := mocks.NewMockTablesClient(ctrl) testClient := &testTablesClient{MockTablesClient: mockClient} wrapper := manual.NewStorageTable(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) permissions := wrapper.IAMPermissions() if len(permissions) == 0 { t.Error("Expected IAMPermissions to be defined") } expectedPermission := "Microsoft.Storage/storageAccounts/tableServices/tables/read" found := slices.Contains(permissions, expectedPermission) if !found { t.Errorf("Expected IAMPermissions to include %s", expectedPermission) } }) } // createAzureTable creates a mock Azure table for testing func createAzureTable(tableName string) *armstorage.Table { return &armstorage.Table{ ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/tableServices/default/tables/" + tableName), Name: new(tableName), Type: new("Microsoft.Storage/storageAccounts/tableServices/tables"), TableProperties: &armstorage.TableProperties{}, } } ================================================ FILE: sources/azure/proc/proc.go ================================================ package proc import ( "context" "fmt" "os" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" log "github.com/sirupsen/logrus" "github.com/spf13/viper" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" // TODO: Uncomment when Azure dynamic adapters are implemented // "github.com/overmindtech/cli/sources/azure/dynamic" // _ "github.com/overmindtech/cli/sources/azure/dynamic/adapters" // Import all adapters to register them "github.com/overmindtech/cli/sources/azure/manual" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) // Metadata contains the metadata for the Azure source var Metadata = sdp.AdapterMetadataList{} // AzureConfig holds configuration for Azure source. // The YAML tags match the keys used in the source.yaml config file. type AzureConfig struct { SubscriptionID string `yaml:"azure-subscription-id"` TenantID string `yaml:"azure-tenant-id"` ClientID string `yaml:"azure-client-id"` Regions []string `yaml:"azure-regions"` } func init() { // Register the Azure source metadata for documentation purposes ctx := context.Background() // subscription, regions are just placeholders here // They are not used in the metadata content discoveryAdapters, err := adapters( ctx, "subscription", "tenant", "client", []string{"region"}, nil, // No credentials needed for metadata registration nil, false, sdpcache.NewNoOpCache(), // no-op cache for metadata registration ) if err != nil { // docs generation should fail if there are errors creating adapters panic(fmt.Errorf("error creating adapters: %w", err)) } for _, adapter := range discoveryAdapters { Metadata.Register(adapter.Metadata()) } log.Debug("Registered Azure source metadata", " with ", len(Metadata.AllAdapterMetadata()), " adapters") } // InitializeAdapters adds Azure adapters to an existing engine. This is a single-attempt // function; retry logic is handled by the caller via Engine.InitialiseAdapters. // // cfg must not be nil — call ConfigFromViper() first for config validation. func InitializeAdapters(ctx context.Context, engine *discovery.Engine, cfg *AzureConfig) error { // ReadinessCheck verifies adapters are healthy by using a StorageAccount adapter // Timeout is handled by SendHeartbeat, HTTP handlers rely on request context engine.SetReadinessCheck(func(ctx context.Context) error { // Find a StorageAccount adapter to verify adapter health adapters := engine.AdaptersByType("azure-storage-account") if len(adapters) == 0 { return fmt.Errorf("readiness check failed: no azure-storage-account adapters available") } // Use first adapter and try to list from first scope adapter := adapters[0] scopes := adapter.Scopes() if len(scopes) == 0 { return fmt.Errorf("readiness check failed: no scopes available for azure-storage-account adapter") } listableAdapter, ok := adapter.(discovery.ListableAdapter) if !ok { return fmt.Errorf("readiness check failed: azure-storage-account adapter is not listable") } _, err := listableAdapter.List(ctx, scopes[0], true) if err != nil { return fmt.Errorf("readiness check (listing storage accounts) failed: %w", err) } return nil }) // Create a shared cache for all adapters in this source sharedCache := sdpcache.NewCache(ctx) log.WithFields(log.Fields{ "ovm.source.type": "azure", "ovm.source.subscription_id": cfg.SubscriptionID, "ovm.source.tenant_id": cfg.TenantID, "ovm.source.client_id": cfg.ClientID, "ovm.source.regions": cfg.Regions, }).Info("Got config") // Regions are optional for Azure, but subscription ID is required if cfg.SubscriptionID == "" { return fmt.Errorf("Azure source must specify subscription ID") } // Set Azure SDK environment variables from viper config if not already set. // The Azure SDK's DefaultAzureCredential reads AZURE_CLIENT_ID and AZURE_TENANT_ID // directly from environment variables for federated authentication. // // When using Azure Workload Identity webhook, these env vars are already injected // by the webhook, so we only set them if they're not present. This supports both: // 1. Azure Workload Identity webhook (env vars already injected) // 2. Manual configuration (env vars set from viper config) // // Reference: https://azure.github.io/azure-workload-identity/docs/ if os.Getenv("AZURE_CLIENT_ID") == "" && cfg.ClientID != "" { os.Setenv("AZURE_CLIENT_ID", cfg.ClientID) } if os.Getenv("AZURE_TENANT_ID") == "" && cfg.TenantID != "" { os.Setenv("AZURE_TENANT_ID", cfg.TenantID) } // Initialize Azure credentials cred, err := azureshared.NewAzureCredential(ctx) if err != nil { return fmt.Errorf("error creating Azure credentials: %w", err) } // TODO: Implement linker when Azure dynamic adapters are available var linker any = nil discoveryAdapters, err := adapters(ctx, cfg.SubscriptionID, cfg.TenantID, cfg.ClientID, cfg.Regions, cred, linker, true, sharedCache) if err != nil { return fmt.Errorf("error creating discovery adapters: %w", err) } // Verify subscription access before adding adapters err = checkSubscriptionAccess(ctx, cfg.SubscriptionID, cred) if err != nil { log.WithContext(ctx).WithError(err).WithFields(log.Fields{ "ovm.source.type": "azure", "ovm.source.subscription_id": cfg.SubscriptionID, }).Error("Permission check failed for subscription") } else { log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "azure", "ovm.source.subscription_id": cfg.SubscriptionID, }).Info("Permission check passed for subscription") } // Add the adapters to the engine err = engine.AddAdapters(discoveryAdapters...) if err != nil { return fmt.Errorf("error adding adapters to engine: %w", err) } log.Debug("Sources initialized") return nil } // ConfigFromViper reads and validates the Azure configuration from viper flags. // This performs local validation only (no API calls) and should be called // before InitializeAdapters to catch permanent config errors early. func ConfigFromViper() (*AzureConfig, error) { subscriptionID := viper.GetString("azure-subscription-id") if subscriptionID == "" { return nil, fmt.Errorf("azure-subscription-id not set") } tenantID := viper.GetString("azure-tenant-id") if tenantID == "" { return nil, fmt.Errorf("azure-tenant-id not set") } clientID := viper.GetString("azure-client-id") if clientID == "" { return nil, fmt.Errorf("azure-client-id not set") } l := &AzureConfig{ SubscriptionID: subscriptionID, TenantID: tenantID, ClientID: clientID, } // Regions are optional for Azure regions := viper.GetStringSlice("azure-regions") if len(regions) > 0 { l.Regions = regions } return l, nil } // adapters returns a list of discovery adapters for Azure // It includes both manual adapters and dynamic adapters. func adapters( ctx context.Context, subscriptionID string, tenantID string, clientID string, regions []string, cred *azidentity.DefaultAzureCredential, linker any, // TODO: Use *azureshared.Linker when azureshared package is fully implemented initAzureClients bool, cache sdpcache.Cache, ) ([]discovery.Adapter, error) { discoveryAdapters := make([]discovery.Adapter, 0) // Add manual adapters manualAdapters, err := manual.Adapters( ctx, subscriptionID, regions, cred, initAzureClients, cache, ) if err != nil { return nil, err } initiatedManualAdapters := make(map[string]bool) for _, adapter := range manualAdapters { initiatedManualAdapters[adapter.Type()] = true } discoveryAdapters = append(discoveryAdapters, manualAdapters...) // TODO: Add dynamic adapters when Azure dynamic adapter framework is implemented // dynamicAdapters, err := dynamic.Adapters( // subscriptionID, // tenantID, // clientID, // regions, // linker, // httpClient, // initiatedManualAdapters, // ) // if err != nil { // return nil, err // } // discoveryAdapters = append(discoveryAdapters, dynamicAdapters...) _ = tenantID // Used for metadata/logging _ = clientID // Used for metadata/logging return discoveryAdapters, nil } // checkSubscriptionAccess verifies that the credentials have access to the specified subscription func checkSubscriptionAccess(ctx context.Context, subscriptionID string, cred *azidentity.DefaultAzureCredential) error { // Create a resource groups client to test subscription access client, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) if err != nil { return fmt.Errorf("failed to create resource groups client: %w", err) } // Try to list resource groups to verify access pager := client.NewListPager(nil) if !pager.More() { // No resource groups, but that's okay - we just want to verify we can access the subscription log.WithField("ovm.source.subscription_id", subscriptionID).Info("Successfully verified subscription access (no resource groups found)") return nil } // Try to get the first page to verify we have access _, err = pager.NextPage(ctx) if err != nil { return fmt.Errorf("failed to verify subscription access: %w", err) } log.WithField("ovm.source.subscription_id", subscriptionID).Info("Successfully verified subscription access") return nil } ================================================ FILE: sources/azure/proc/proc_test.go ================================================ package proc import ( "context" "testing" // TODO: Uncomment when Azure dynamic adapters are implemented // _ "github.com/overmindtech/cli/sources/azure/dynamic" "github.com/overmindtech/cli/go/sdpcache" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) func Test_adapters(t *testing.T) { ctx := context.Background() discoveryAdapters, err := adapters( ctx, "subscription", "tenant", "client", []string{"region"}, nil, // No credentials needed for metadata registration nil, false, sdpcache.NewNoOpCache(), ) if err != nil { t.Fatalf("error creating adapters: %v", err) } numberOfAdapters := len(discoveryAdapters) if numberOfAdapters == 0 { t.Fatal("Expected at least one adapter, got none") } if len(Metadata.AllAdapterMetadata()) != numberOfAdapters { t.Fatalf("Expected %d adapters in metadata, got %d", numberOfAdapters, len(Metadata.AllAdapterMetadata())) } // Check if the Compute Virtual Machine adapter is present // This is a key Azure adapter that should be registered vmAdapterFound := false for _, adapter := range discoveryAdapters { if adapter.Type() == azureshared.ComputeVirtualMachine.String() { vmAdapterFound = true break } } if !vmAdapterFound { t.Fatal("Expected to find Compute Virtual Machine adapter in the list of adapters") } t.Logf("Azure Adapters found: %v", len(discoveryAdapters)) } func Test_ensureMandatoryFieldsInDynamicAdapters(t *testing.T) { // TODO: Implement this test when Azure dynamic adapters are available // This test validates that dynamic adapters have all required fields // For now, we skip it since Azure dynamic adapters may not be implemented yet t.Skip("Azure dynamic adapters not yet implemented") // TODO: Uncomment when SDPAssetTypeToAdapterMeta and PredefinedRoles are implemented for Azure /* predefinedRoles := make(map[string]bool, len(azureshared.SDPAssetTypeToAdapterMeta)) for sdpItemType, meta := range azureshared.SDPAssetTypeToAdapterMeta { t.Run(sdpItemType.String(), func(t *testing.T) { if meta.InDevelopment == true { t.Skipf("InDevelopment is true for %s", sdpItemType.String()) } if meta.GetEndpointFunc == nil { t.Errorf("GetEndpointFunc is nil for %s", sdpItemType) } if meta.LocationLevel == "" { t.Errorf("LocationLevel is empty for %s", sdpItemType) } if len(meta.UniqueAttributeKeys) == 0 { t.Errorf("UniqueAttributeKeys is empty for %s", sdpItemType) } if len(meta.IAMPermissions) == 0 { t.Errorf("IAMPermissions is empty for %s", sdpItemType) } if len(meta.PredefinedRole) == 0 { t.Errorf("PredefinedRoles is empty for %s", sdpItemType) } role, ok := azureshared.PredefinedRoles[meta.PredefinedRole] if !ok { t.Errorf("PredefinedRole %s is not in the PredefinedRoles map", meta.PredefinedRole) } foundPerm := false for _, perm := range role.IAMPermissions { for _, iamPerm := range meta.IAMPermissions { if perm == iamPerm { foundPerm = true break } } } if !foundPerm { t.Errorf("IAMPermissions %s is not in the PredefinedRole %s", meta.IAMPermissions, meta.PredefinedRole) } predefinedRoles[meta.PredefinedRole] = true }) } // roles := make([]string, 0, len(predefinedRoles)) // for r := range azureshared.PredefinedRoles { // roles = append(roles, r) // } // sort.Strings(roles) // for _, r := range roles { // fmt.Println("\"" + r + "\"") // } */ } ================================================ FILE: sources/azure/setup_helper_script.sh ================================================ #!/bin/bash set -e # Azure App Registration Setup for Overmind Azure Source # This script creates an Azure AD app with federated credentials for EKS OIDC authentication. # # Prerequisites: # - Azure CLI installed and logged in (az login) # - Appropriate permissions to create app registrations and role assignments # # Usage: # ./setup_helper_script.sh --customer-name --eks-oidc-issuer --azure-subscription-id [--namespace ] # # Arguments: # --customer-name Overmind account name/ID (required) # --eks-oidc-issuer EKS OIDC issuer URL (required) # --azure-subscription-id Azure subscription ID (required) # --namespace Kubernetes namespace (optional, default: default) # === DEFAULT VALUES === NAMESPACE="default" # === ARGUMENT PARSING === usage() { echo "Usage: $0 --customer-name --eks-oidc-issuer --azure-subscription-id [--namespace ]" echo "" echo "Arguments:" echo " --customer-name Overmind account name/ID (required)" echo " --eks-oidc-issuer EKS OIDC issuer URL (required)" echo " --azure-subscription-id Azure subscription ID (required)" echo " --namespace Kubernetes namespace (optional, default: default)" echo "" echo "Example:" echo " $0 --customer-name my-account --eks-oidc-issuer https://oidc.eks.eu-west-2.amazonaws.com/id/ABC123 --azure-subscription-id 12345678-1234-1234-1234-123456789abc" exit 1 } while [[ $# -gt 0 ]]; do case $1 in --customer-name) CUSTOMER_NAME="$2" shift 2 ;; --eks-oidc-issuer) EKS_OIDC_ISSUER="$2" shift 2 ;; --azure-subscription-id) AZURE_SUBSCRIPTION_ID="$2" shift 2 ;; --namespace) NAMESPACE="$2" shift 2 ;; -h|--help) usage ;; *) echo "Error: Unknown argument: $1" usage ;; esac done # === VALIDATION === MISSING_ARGS=() if [ -z "$CUSTOMER_NAME" ]; then MISSING_ARGS+=("--customer-name") fi if [ -z "$EKS_OIDC_ISSUER" ]; then MISSING_ARGS+=("--eks-oidc-issuer") fi if [ -z "$AZURE_SUBSCRIPTION_ID" ]; then MISSING_ARGS+=("--azure-subscription-id") fi if [ ${#MISSING_ARGS[@]} -ne 0 ]; then echo "Error: Missing required arguments: ${MISSING_ARGS[*]}" echo "" usage fi # === DERIVED VALUES === APP_NAME="overmind-azure-source-${CUSTOMER_NAME}" SERVICE_ACCOUNT_NAME="${CUSTOMER_NAME}-azure-source-pod-sa" # Federated credential name - unique within the app registration # Using a descriptive name that includes context about the EKS cluster FEDERATED_CRED_NAME="eks-federated-${CUSTOMER_NAME:0:8}" echo "=== Configuration ===" echo "Customer Name: $CUSTOMER_NAME" echo "App Name: $APP_NAME" echo "ServiceAccount: $SERVICE_ACCOUNT_NAME" echo "Namespace: $NAMESPACE" echo "EKS OIDC Issuer: $EKS_OIDC_ISSUER" echo "" # Check if app already exists EXISTING_APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv 2>/dev/null || true) if [ -n "$EXISTING_APP_ID" ]; then echo "App registration '$APP_NAME' already exists with ID: $EXISTING_APP_ID" echo "Using existing app..." APP_ID=$EXISTING_APP_ID else echo "Creating Azure AD App Registration..." az ad app create \ --display-name "$APP_NAME" \ --sign-in-audience "AzureADMyOrg" APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv) echo "Created app with ID: $APP_ID" fi TENANT_ID=$(az account show --query tenantId -o tsv) # Check if service principal exists SP_EXISTS=$(az ad sp show --id "$APP_ID" --query "appId" -o tsv 2>/dev/null || true) if [ -n "$SP_EXISTS" ]; then echo "Service Principal already exists" else echo "Creating Service Principal..." az ad sp create --id "$APP_ID" fi # Check if federated credential exists EXISTING_CRED=$(az ad app federated-credential list --id "$APP_ID" --query "[?name=='$FEDERATED_CRED_NAME'].name" -o tsv 2>/dev/null || true) if [ -n "$EXISTING_CRED" ]; then echo "Federated credential '$FEDERATED_CRED_NAME' already exists, updating..." az ad app federated-credential delete --id "$APP_ID" --federated-credential-id "$FEDERATED_CRED_NAME" fi echo "Creating Federated Credential..." # Note: The 'subject' must exactly match the Kubernetes ServiceAccount that will be created by srcman # Format: system:serviceaccount:: az ad app federated-credential create \ --id "$APP_ID" \ --parameters '{ "name": "'"$FEDERATED_CRED_NAME"'", "issuer": "'"$EKS_OIDC_ISSUER"'", "subject": "system:serviceaccount:'"$NAMESPACE"':'"$SERVICE_ACCOUNT_NAME"'", "audiences": ["api://AzureADTokenExchange"], "description": "Federated credential for Overmind Azure source running on EKS. Customer: '"$CUSTOMER_NAME"'" }' # Check if role assignment exists EXISTING_ROLE=$(az role assignment list --assignee "$APP_ID" --scope "/subscriptions/$AZURE_SUBSCRIPTION_ID" --query "[?roleDefinitionName=='Reader'].id" -o tsv 2>/dev/null || true) if [ -n "$EXISTING_ROLE" ]; then echo "Reader role assignment already exists" else echo "Assigning Reader role..." az role assignment create \ --role "Reader" \ --assignee "$APP_ID" \ --scope "/subscriptions/$AZURE_SUBSCRIPTION_ID" fi echo "" echo "==========================================" echo "=== Azure Source Configuration Values ===" echo "==========================================" echo "" echo "Use these values when creating the Azure source in Overmind:" echo "" echo " azure-subscription-id: $AZURE_SUBSCRIPTION_ID" echo " azure-tenant-id: $TENANT_ID" echo " azure-client-id: $APP_ID" echo "" echo "The following Kubernetes ServiceAccount will be created by srcman:" echo " Namespace: $NAMESPACE" echo " Name: $SERVICE_ACCOUNT_NAME" echo "" echo "Federated credential subject (must match exactly):" echo " system:serviceaccount:$NAMESPACE:$SERVICE_ACCOUNT_NAME" echo "" ================================================ FILE: sources/azure/shared/adapter-meta.go ================================================ package shared import ( "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) // LocationLevel defines the scope of an Azure resource. type LocationLevel string const ( SubscriptionLevel LocationLevel = "subscription" ResourceGroupLevel LocationLevel = "resource-group" RegionalLevel LocationLevel = "regional" ) type EndpointFunc func(query string) string // AdapterMeta contains metadata for an Azure dynamic adapter. type AdapterMeta struct { LocationLevel LocationLevel // We will normally generate the search description from the UniqueAttributeKeys // but we allow it to be overridden for specific adapters. SearchDescription string SDPAdapterCategory sdp.AdapterCategory UniqueAttributeKeys []string InDevelopment bool // If true, the adapter is in development and should not be used in production. IAMPermissions []string // List of IAM permissions required to access this resource. PredefinedRole string // Predefined role required to access this resource. NameSelector string // By default, it is `name`, but can be overridden for outlier cases // By default, we use the last item of the UniqueAttributeKeys. // Can be overridden for specific adapters if the API response structure differs. ListResponseSelector string } // We have group of functions that are similar in nature, however they cannot simplified into a generic function because // of the different number of query parts they accept. // Also, we want to keep the explicit logic for now for the sake of human readability. // TODO: fix subscription-level endpoint functions to use subscriptionID instead of projectID in https://linear.app/overmind/issue/ENG-1830/authenticate-to-azure-using-federated-credentials func SubscriptionLevelEndpointFuncWithSingleQuery(format string) func(queryParts ...string) (EndpointFunc, error) { // count number of `%s` in the format string if strings.Count(format, "%s") != 2 { // subscription ID and query panic(fmt.Sprintf("format string must contain 2 %%s placeholders: %s", format)) } return func(adapterInitParams ...string) (EndpointFunc, error) { if len(adapterInitParams) == 1 && adapterInitParams[0] != "" { return func(query string) string { if query != "" { // query must be an instance return fmt.Sprintf(format, adapterInitParams[0], query) } return "" }, nil } return nil, fmt.Errorf("subscriptionID cannot be empty: %v", adapterInitParams) } } // TODO: fix subscription-level endpoint functions to use subscriptionID and resourceGroup instead of projectID in https://linear.app/overmind/issue/ENG-1830/authenticate-to-azure-using-federated-credentials func ResourceGroupLevelEndpointFuncWithSingleQuery(format string) func(queryParts ...string) (EndpointFunc, error) { // count number of `%s` in the format string if strings.Count(format, "%s") != 3 { // subscription ID, resource group, and query panic(fmt.Sprintf("format string must contain 3 %%s placeholders: %s", format)) } return func(adapterInitParams ...string) (EndpointFunc, error) { if len(adapterInitParams) == 2 && adapterInitParams[0] != "" && adapterInitParams[1] != "" { return func(query string) string { if query != "" { // query must be an instance return fmt.Sprintf(format, adapterInitParams[0], adapterInitParams[1], query) } return "" }, nil } return nil, fmt.Errorf("subscriptionID and resourceGroup cannot be empty: %v", adapterInitParams) } } // TODO: fix remaining endpoint functions (ProjectLevel, ZoneLevel, RegionalLevel) to use Azure scopes (subscription, resourceGroup) instead of GCP scopes (project, zone) in https://linear.app/overmind/issue/ENG-1830/authenticate-to-azure-using-federated-credentials // These functions are currently GCP-specific and need to be refactored for Azure resource scoping // SDPAssetTypeToAdapterMeta maps Azure asset types to their corresponding adapter metadata. // This map is populated during source initiation by individual adapter files. var SDPAssetTypeToAdapterMeta = map[shared.ItemType]AdapterMeta{} ================================================ FILE: sources/azure/shared/azure-http-client.go ================================================ package shared import ( "context" "net/http" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // AzureHTTPClientWithOtel creates a new HTTP client for Azure with OpenTelemetry instrumentation. // Azure SDK clients handle authentication automatically via: // - Federated credentials (when running in Kubernetes/EKS with workload identity) // - Azure CLI (for local development) // - Environment variables (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) // - Managed Identity (when running in Azure) // // This function returns an HTTP client with OpenTelemetry instrumentation that can be used // with Azure SDK clients. The actual authentication is handled by the Azure SDK client options. func AzureHTTPClientWithOtel(ctx context.Context) *http.Client { // Azure SDK handles authentication automatically, so we just need to provide // an HTTP client with OpenTelemetry instrumentation return &http.Client{ Transport: otelhttp.NewTransport(nil), } } ================================================ FILE: sources/azure/shared/base.go ================================================ package shared import ( "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) // ResourceGroupBase customizes the sources.Base struct for Azure // It adds the subscription ID and resource group to the base struct // and makes them available to concrete wrapper implementations. type ResourceGroupBase struct { AzureBase resourceGroup string *shared.Base } // NewResourceGroupBase creates a new ResourceGroupBase struct func NewResourceGroupBase( subscriptionID string, resourceGroup string, category sdp.AdapterCategory, item shared.ItemType, ) *ResourceGroupBase { base := &ResourceGroupBase{ AzureBase: AzureBase{ subscriptionID: subscriptionID, }, resourceGroup: resourceGroup, } base.Base = shared.NewBase( category, item, []string{fmt.Sprintf("%s.%s", base.SubscriptionID(), resourceGroup)}, ) return base } // ResourceGroup returns the resource group func (m *ResourceGroupBase) ResourceGroup() string { return m.resourceGroup } // DefaultScope returns the default scope // Subscription ID and resource group are used to create the default scope. func (m *ResourceGroupBase) DefaultScope() string { return m.Scopes()[0] } // ResourceGroupFromScope returns the resource group from a scope string. // Scope format is "{subscriptionId}.{resourceGroup}". func ResourceGroupFromScope(scope string) string { if scope == "" { return "" } parts := strings.SplitN(scope, ".", 2) if len(parts) < 2 || parts[1] == "" { return "" } return parts[1] } // SubscriptionIDFromScope returns the subscription ID from a scope string. // Scope format is "{subscriptionId}.{resourceGroup}". func SubscriptionIDFromScope(scope string) string { if scope == "" { return "" } parts := strings.SplitN(scope, ".", 2) if len(parts) < 2 || parts[0] == "" { return "" } return parts[0] } // SubscriptionBase customizes the sources.Base struct for Azure // It adds the subscription ID to the base struct // and makes them available to concrete wrapper implementations. type SubscriptionBase struct { AzureBase *shared.Base } // NewSubscriptionBase creates a new SubscriptionBase struct func NewSubscriptionBase( subscriptionID string, category sdp.AdapterCategory, item shared.ItemType, ) *SubscriptionBase { base := &SubscriptionBase{ AzureBase: AzureBase{ subscriptionID: subscriptionID, }, } base.Base = shared.NewBase( category, item, []string{base.SubscriptionID()}, ) return base } // DefaultScope returns the default scope // Subscription ID is used to create the default scope. func (m *SubscriptionBase) DefaultScope() string { return m.Scopes()[0] } // AzureBase is the base struct for all Azure adapters. // It contains common fields and methods for Azure resources. type AzureBase struct { subscriptionID string } // SubscriptionID returns the subscription ID func (a *AzureBase) SubscriptionID() string { return a.subscriptionID } ================================================ FILE: sources/azure/shared/credentials.go ================================================ package shared import ( "context" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" log "github.com/sirupsen/logrus" ) // NewAzureCredential creates a new DefaultAzureCredential which automatically handles // multiple authentication methods in the following order: // 1. Environment variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE, etc.) // 2. Workload Identity (Kubernetes with OIDC federation) // 3. Managed Identity (when running in Azure) // 4. Azure CLI (for local development) // // Reference: https://learn.microsoft.com/en-us/azure/developer/go/sdk/authentication/credential-chains func NewAzureCredential(ctx context.Context) (*azidentity.DefaultAzureCredential, error) { log.Debug("Initializing Azure credentials using DefaultAzureCredential") cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { return nil, fmt.Errorf("failed to create Azure credential: %w", err) } log.WithFields(log.Fields{ "ovm.auth.method": "default-azure-credential", "ovm.auth.type": "federated-or-environment", }).Info("Successfully initialized Azure credentials") return cred, nil } ================================================ FILE: sources/azure/shared/errors.go ================================================ package shared import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/overmindtech/cli/go/sdp-go" ) // QueryError is a helper function to convert errors into sdp.QueryError // TODO: fix error handling to use Azure SDK error types instead of gRPC status codes in https://linear.app/overmind/issue/ENG-1830/authenticate-to-azure-using-federated-credentials func QueryError(err error, scope string, itemType string) *sdp.QueryError { // Check if the error is an Azure `not_found` error // TODO: Replace gRPC status check with Azure SDK error type check (e.g., *azcore.ResponseError with StatusCode 404) if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { return &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), SourceName: "azure-source", Scope: scope, ItemType: itemType, } } return &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), SourceName: "azure-source", Scope: scope, ItemType: itemType, } } ================================================ FILE: sources/azure/shared/item-types.go ================================================ package shared import "github.com/overmindtech/cli/sources/shared" // Item types for Azure resources // These combine the Azure source, API (resource provider), and resource type // to create unique item type identifiers following the pattern: azure-{api}-{resource} var ( // Compute item types ComputeVirtualMachine = shared.NewItemType(Azure, Compute, VirtualMachine) ComputeDisk = shared.NewItemType(Azure, Compute, Disk) ComputeAvailabilitySet = shared.NewItemType(Azure, Compute, AvailabilitySet) ComputeVirtualMachineExtension = shared.NewItemType(Azure, Compute, VirtualMachineExtension) ComputeVirtualMachineRunCommand = shared.NewItemType(Azure, Compute, VirtualMachineRunCommand) ComputeVirtualMachineScaleSet = shared.NewItemType(Azure, Compute, VirtualMachineScaleSet) ComputeDiskEncryptionSet = shared.NewItemType(Azure, Compute, DiskEncryptionSet) ComputeProximityPlacementGroup = shared.NewItemType(Azure, Compute, ProximityPlacementGroup) ComputeDedicatedHostGroup = shared.NewItemType(Azure, Compute, DedicatedHostGroup) ComputeDedicatedHost = shared.NewItemType(Azure, Compute, DedicatedHost) ComputeCapacityReservationGroup = shared.NewItemType(Azure, Compute, CapacityReservationGroup) ComputeCapacityReservation = shared.NewItemType(Azure, Compute, CapacityReservation) ComputeImage = shared.NewItemType(Azure, Compute, Image) ComputeSnapshot = shared.NewItemType(Azure, Compute, Snapshot) ComputeDiskAccess = shared.NewItemType(Azure, Compute, DiskAccess) ComputeDiskAccessPrivateEndpointConnection = shared.NewItemType(Azure, Compute, DiskAccessPrivateEndpointConnection) ComputeSharedGalleryImage = shared.NewItemType(Azure, Compute, SharedGalleryImage) ComputeSharedGallery = shared.NewItemType(Azure, Compute, SharedGallery) ComputeCommunityGalleryImage = shared.NewItemType(Azure, Compute, CommunityGalleryImage) ComputeGalleryApplication = shared.NewItemType(Azure, Compute, GalleryApplication) ComputeGalleryApplicationVersion = shared.NewItemType(Azure, Compute, GalleryApplicationVersion) ComputeGalleryImage = shared.NewItemType(Azure, Compute, GalleryImage) ComputeGallery = shared.NewItemType(Azure, Compute, Gallery) // Network item types NetworkVirtualNetwork = shared.NewItemType(Azure, Network, VirtualNetwork) NetworkSubnet = shared.NewItemType(Azure, Network, Subnet) NetworkNetworkInterface = shared.NewItemType(Azure, Network, NetworkInterface) NetworkPublicIPAddress = shared.NewItemType(Azure, Network, PublicIPAddress) NetworkNetworkSecurityGroup = shared.NewItemType(Azure, Network, NetworkSecurityGroup) NetworkVirtualNetworkPeering = shared.NewItemType(Azure, Network, VirtualNetworkPeering) NetworkNetworkInterfaceIPConfiguration = shared.NewItemType(Azure, Network, NetworkInterfaceIPConfiguration) NetworkPrivateEndpoint = shared.NewItemType(Azure, Network, PrivateEndpoint) NetworkLoadBalancer = shared.NewItemType(Azure, Network, LoadBalancer) NetworkLoadBalancerFrontendIPConfiguration = shared.NewItemType(Azure, Network, LoadBalancerFrontendIPConfiguration) NetworkLoadBalancerBackendAddressPool = shared.NewItemType(Azure, Network, LoadBalancerBackendAddressPool) NetworkLoadBalancerInboundNatRule = shared.NewItemType(Azure, Network, LoadBalancerInboundNatRule) NetworkLoadBalancerLoadBalancingRule = shared.NewItemType(Azure, Network, LoadBalancerLoadBalancingRule) NetworkLoadBalancerProbe = shared.NewItemType(Azure, Network, LoadBalancerProbe) NetworkLoadBalancerOutboundRule = shared.NewItemType(Azure, Network, LoadBalancerOutboundRule) NetworkLoadBalancerInboundNatPool = shared.NewItemType(Azure, Network, LoadBalancerInboundNatPool) NetworkPublicIPPrefix = shared.NewItemType(Azure, Network, PublicIPPrefix) NetworkCustomIPPrefix = shared.NewItemType(Azure, Network, CustomIPPrefix) NetworkNatGateway = shared.NewItemType(Azure, Network, NatGateway) NetworkDdosProtectionPlan = shared.NewItemType(Azure, Network, DdosProtectionPlan) NetworkApplicationGateway = shared.NewItemType(Azure, Network, ApplicationGateway) NetworkApplicationGatewayBackendAddressPool = shared.NewItemType(Azure, Network, ApplicationGatewayBackendAddressPool) NetworkApplicationGatewayFrontendIPConfiguration = shared.NewItemType(Azure, Network, ApplicationGatewayFrontendIPConfiguration) NetworkApplicationGatewayGatewayIPConfiguration = shared.NewItemType(Azure, Network, ApplicationGatewayGatewayIPConfiguration) NetworkApplicationGatewayHTTPListener = shared.NewItemType(Azure, Network, ApplicationGatewayHTTPListener) NetworkApplicationGatewayBackendHTTPSettings = shared.NewItemType(Azure, Network, ApplicationGatewayBackendHTTPSettings) NetworkApplicationGatewayRequestRoutingRule = shared.NewItemType(Azure, Network, ApplicationGatewayRequestRoutingRule) NetworkApplicationGatewayProbe = shared.NewItemType(Azure, Network, ApplicationGatewayProbe) NetworkApplicationGatewaySSLCertificate = shared.NewItemType(Azure, Network, ApplicationGatewaySSLCertificate) NetworkApplicationGatewayURLPathMap = shared.NewItemType(Azure, Network, ApplicationGatewayURLPathMap) NetworkApplicationGatewayAuthenticationCertificate = shared.NewItemType(Azure, Network, ApplicationGatewayAuthenticationCertificate) NetworkApplicationGatewayTrustedRootCertificate = shared.NewItemType(Azure, Network, ApplicationGatewayTrustedRootCertificate) NetworkApplicationGatewayRewriteRuleSet = shared.NewItemType(Azure, Network, ApplicationGatewayRewriteRuleSet) NetworkApplicationGatewayRedirectConfiguration = shared.NewItemType(Azure, Network, ApplicationGatewayRedirectConfiguration) NetworkApplicationGatewayWebApplicationFirewallPolicy = shared.NewItemType(Azure, Network, ApplicationGatewayWebApplicationFirewallPolicy) NetworkApplicationSecurityGroup = shared.NewItemType(Azure, Network, ApplicationSecurityGroup) NetworkSecurityRule = shared.NewItemType(Azure, Network, SecurityRule) NetworkDefaultSecurityRule = shared.NewItemType(Azure, Network, DefaultSecurityRule) NetworkIPGroup = shared.NewItemType(Azure, Network, IPGroup) NetworkFirewall = shared.NewItemType(Azure, Network, Firewall) NetworkFirewallPolicy = shared.NewItemType(Azure, Network, FirewallPolicy) NetworkRouteTable = shared.NewItemType(Azure, Network, RouteTable) NetworkRoute = shared.NewItemType(Azure, Network, Route) NetworkVirtualNetworkGateway = shared.NewItemType(Azure, Network, VirtualNetworkGateway) NetworkVirtualNetworkGatewayConnection = shared.NewItemType(Azure, Network, VirtualNetworkGatewayConnection) NetworkVirtualNetworkGatewayNatRule = shared.NewItemType(Azure, Network, VirtualNetworkGatewayNatRule) NetworkVirtualNetworkGatewayIPConfiguration = shared.NewItemType(Azure, Network, VirtualNetworkGatewayIPConfiguration) NetworkLocalNetworkGateway = shared.NewItemType(Azure, Network, LocalNetworkGateway) NetworkExpressRouteCircuitPeering = shared.NewItemType(Azure, Network, ExpressRouteCircuitPeering) NetworkPrivateDNSZone = shared.NewItemType(Azure, Network, PrivateDNSZone) NetworkZone = shared.NewItemType(Azure, Network, Zone) NetworkDNSRecordSet = shared.NewItemType(Azure, Network, DNSRecordSet) NetworkDNSVirtualNetworkLink = shared.NewItemType(Azure, Network, DNSVirtualNetworkLink) NetworkFlowLog = shared.NewItemType(Azure, Network, FlowLog) NetworkPrivateLinkService = shared.NewItemType(Azure, Network, PrivateLinkService) NetworkDscpConfiguration = shared.NewItemType(Azure, Network, DscpConfiguration) NetworkVirtualNetworkTap = shared.NewItemType(Azure, Network, VirtualNetworkTap) NetworkNetworkInterfaceTapConfiguration = shared.NewItemType(Azure, Network, NetworkInterfaceTapConfiguration) NetworkServiceEndpointPolicy = shared.NewItemType(Azure, Network, ServiceEndpointPolicy) NetworkIpAllocation = shared.NewItemType(Azure, Network, IpAllocation) NetworkNetworkWatcher = shared.NewItemType(Azure, Network, NetworkWatcher) // ExtendedLocation item types ExtendedLocationCustomLocation = shared.NewItemType(Azure, ExtendedLocation, CustomLocation) // Storage item types StorageAccount = shared.NewItemType(Azure, Storage, Account) StorageBlobContainer = shared.NewItemType(Azure, Storage, BlobContainer) StorageEncryptionScope = shared.NewItemType(Azure, Storage, EncryptionScope) StorageFileShare = shared.NewItemType(Azure, Storage, FileShare) StorageTable = shared.NewItemType(Azure, Storage, Table) StorageQueue = shared.NewItemType(Azure, Storage, Queue) StoragePrivateEndpointConnection = shared.NewItemType(Azure, Storage, StorageAccountPrivateEndpointConnection) // SQL item types SQLDatabase = shared.NewItemType(Azure, SQL, Database) SQLRecoverableDatabase = shared.NewItemType(Azure, SQL, RecoverableDatabase) SQLRecoveryServicesRecoveryPoint = shared.NewItemType(Azure, SQL, RecoveryServicesRecoveryPoint) SQLRestorableDroppedDatabase = shared.NewItemType(Azure, SQL, RestorableDroppedDatabase) SQLServer = shared.NewItemType(Azure, SQL, Server) SQLElasticPool = shared.NewItemType(Azure, SQL, ElasticPool) SQLServerFirewallRule = shared.NewItemType(Azure, SQL, ServerFirewallRule) SQLServerVirtualNetworkRule = shared.NewItemType(Azure, SQL, ServerVirtualNetworkRule) SQLServerKey = shared.NewItemType(Azure, SQL, ServerKey) SQLServerFailoverGroup = shared.NewItemType(Azure, SQL, ServerFailoverGroup) SQLServerAdministrator = shared.NewItemType(Azure, SQL, ServerAdministrator) SQLServerSyncGroup = shared.NewItemType(Azure, SQL, ServerSyncGroup) SQLServerSyncAgent = shared.NewItemType(Azure, SQL, ServerSyncAgent) SQLServerPrivateEndpointConnection = shared.NewItemType(Azure, SQL, ServerPrivateEndpointConnection) SQLServerAuditingSetting = shared.NewItemType(Azure, SQL, ServerAuditingSetting) SQLServerSecurityAlertPolicy = shared.NewItemType(Azure, SQL, ServerSecurityAlertPolicy) SQLServerVulnerabilityAssessment = shared.NewItemType(Azure, SQL, ServerVulnerabilityAssessment) SQLServerEncryptionProtector = shared.NewItemType(Azure, SQL, ServerEncryptionProtector) SQLServerBlobAuditingPolicy = shared.NewItemType(Azure, SQL, ServerBlobAuditingPolicy) SQLServerAutomaticTuning = shared.NewItemType(Azure, SQL, ServerAutomaticTuning) SQLServerAdvancedThreatProtectionSetting = shared.NewItemType(Azure, SQL, ServerAdvancedThreatProtectionSetting) SQLServerDnsAlias = shared.NewItemType(Azure, SQL, ServerDnsAlias) SQLServerUsage = shared.NewItemType(Azure, SQL, ServerUsage) SQLServerOperation = shared.NewItemType(Azure, SQL, ServerOperation) SQLServerAdvisor = shared.NewItemType(Azure, SQL, ServerAdvisor) SQLServerBackupLongTermRetentionPolicy = shared.NewItemType(Azure, SQL, ServerBackupLongTermRetentionPolicy) SQLServerDevOpsAuditSetting = shared.NewItemType(Azure, SQL, ServerDevOpsAuditSetting) SQLServerTrustGroup = shared.NewItemType(Azure, SQL, ServerTrustGroup) SQLServerOutboundFirewallRule = shared.NewItemType(Azure, SQL, ServerOutboundFirewallRule) SQLServerPrivateLinkResource = shared.NewItemType(Azure, SQL, ServerPrivateLinkResource) SQLLongTermRetentionBackup = shared.NewItemType(Azure, SQL, LongTermRetentionBackup) SQLDatabaseSchema = shared.NewItemType(Azure, SQL, DatabaseSchema) // Maintenance item types MaintenanceMaintenanceConfiguration = shared.NewItemType(Azure, Maintenance, MaintenanceConfiguration) // DBforPostgreSQL item types DBforPostgreSQLFlexibleServer = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServer) DBforPostgreSQLDatabase = shared.NewItemType(Azure, DBforPostgreSQL, Database) DBforPostgreSQLFlexibleServerFirewallRule = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerFirewallRule) DBforPostgreSQLFlexibleServerConfiguration = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerConfiguration) DBforPostgreSQLFlexibleServerAdministrator = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerAdministrator) DBforPostgreSQLFlexibleServerPrivateEndpointConnection = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerPrivateEndpointConnection) DBforPostgreSQLFlexibleServerPrivateLinkResource = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerPrivateLinkResource) DBforPostgreSQLFlexibleServerReplica = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerReplica) DBforPostgreSQLFlexibleServerMigration = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerMigration) DBforPostgreSQLFlexibleServerBackup = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerBackup) DBforPostgreSQLFlexibleServerVirtualEndpoint = shared.NewItemType(Azure, DBforPostgreSQL, FlexibleServerVirtualEndpoint) // DocumentDB item types DocumentDBDatabaseAccounts = shared.NewItemType(Azure, DocumentDB, DatabaseAccounts) DocumentDBPrivateEndpointConnection = shared.NewItemType(Azure, DocumentDB, PrivateEndpointConnection) // KeyVault item types KeyVaultVault = shared.NewItemType(Azure, KeyVault, Vault) KeyVaultSecret = shared.NewItemType(Azure, KeyVault, Secret) KeyVaultKey = shared.NewItemType(Azure, KeyVault, Key) KeyVaultManagedHSM = shared.NewItemType(Azure, KeyVault, ManagedHSM) KeyVaultManagedHSMPrivateEndpointConnection = shared.NewItemType(Azure, KeyVault, ManagedHSMPrivateEndpointConnection) // ManagedIdentity item types ManagedIdentityUserAssignedIdentity = shared.NewItemType(Azure, ManagedIdentity, UserAssignedIdentity) ManagedIdentityFederatedIdentityCredential = shared.NewItemType(Azure, ManagedIdentity, FederatedIdentityCredential) // Batch item types BatchBatchAccount = shared.NewItemType(Azure, Batch, BatchAccount) BatchBatchApplication = shared.NewItemType(Azure, Batch, BatchApplication) BatchBatchApplicationPackage = shared.NewItemType(Azure, Batch, BatchApplicationPackage) BatchBatchPool = shared.NewItemType(Azure, Batch, BatchPool) BatchBatchCertificate = shared.NewItemType(Azure, Batch, BatchCertificate) BatchBatchPrivateEndpointConnection = shared.NewItemType(Azure, Batch, BatchPrivateEndpointConnection) BatchBatchPrivateLinkResource = shared.NewItemType(Azure, Batch, BatchPrivateLinkResource) BatchBatchDetector = shared.NewItemType(Azure, Batch, BatchDetector) // ElasticSAN item types ElasticSan = shared.NewItemType(Azure, ElasticSAN, ElasticSanResource) ElasticSanVolumeGroup = shared.NewItemType(Azure, ElasticSAN, VolumeGroup) ElasticSanVolume = shared.NewItemType(Azure, ElasticSAN, Volume) ElasticSanVolumeSnapshot = shared.NewItemType(Azure, ElasticSAN, VolumeSnapshot) // OperationalInsights item types OperationalInsightsWorkspace = shared.NewItemType(Azure, OperationalInsights, Workspace) OperationalInsightsCluster = shared.NewItemType(Azure, OperationalInsights, Cluster) // Insights (Azure Monitor) item types InsightsPrivateLinkScopeScopedResource = shared.NewItemType(Azure, Insights, PrivateLinkScopeScopedResource) // Authorization item types AuthorizationRoleAssignment = shared.NewItemType(Azure, Authorization, RoleAssignment) AuthorizationRoleDefinition = shared.NewItemType(Azure, Authorization, RoleDefinition) // Resources item types ResourcesSubscription = shared.NewItemType(Azure, Resources, Subscription) ResourcesResourceGroup = shared.NewItemType(Azure, Resources, ResourceGroup) ) ================================================ FILE: sources/azure/shared/mocks/mock_application_gateways_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: application-gateways-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_application_gateways_client.go -package=mocks -source=application-gateways-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockApplicationGatewaysClient is a mock of ApplicationGatewaysClient interface. type MockApplicationGatewaysClient struct { ctrl *gomock.Controller recorder *MockApplicationGatewaysClientMockRecorder isgomock struct{} } // MockApplicationGatewaysClientMockRecorder is the mock recorder for MockApplicationGatewaysClient. type MockApplicationGatewaysClientMockRecorder struct { mock *MockApplicationGatewaysClient } // NewMockApplicationGatewaysClient creates a new mock instance. func NewMockApplicationGatewaysClient(ctrl *gomock.Controller) *MockApplicationGatewaysClient { mock := &MockApplicationGatewaysClient{ctrl: ctrl} mock.recorder = &MockApplicationGatewaysClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockApplicationGatewaysClient) EXPECT() *MockApplicationGatewaysClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockApplicationGatewaysClient) Get(ctx context.Context, resourceGroupName, applicationGatewayName string, options *armnetwork.ApplicationGatewaysClientGetOptions) (armnetwork.ApplicationGatewaysClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, applicationGatewayName, options) ret0, _ := ret[0].(armnetwork.ApplicationGatewaysClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockApplicationGatewaysClientMockRecorder) Get(ctx, resourceGroupName, applicationGatewayName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockApplicationGatewaysClient)(nil).Get), ctx, resourceGroupName, applicationGatewayName, options) } // List mocks base method. func (m *MockApplicationGatewaysClient) List(resourceGroupName string, options *armnetwork.ApplicationGatewaysClientListOptions) clients.ApplicationGatewaysPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", resourceGroupName, options) ret0, _ := ret[0].(clients.ApplicationGatewaysPager) return ret0 } // List indicates an expected call of List. func (mr *MockApplicationGatewaysClientMockRecorder) List(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockApplicationGatewaysClient)(nil).List), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_application_security_groups_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: application-security-groups-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_application_security_groups_client.go -package=mocks -source=application-security-groups-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockApplicationSecurityGroupsClient is a mock of ApplicationSecurityGroupsClient interface. type MockApplicationSecurityGroupsClient struct { ctrl *gomock.Controller recorder *MockApplicationSecurityGroupsClientMockRecorder isgomock struct{} } // MockApplicationSecurityGroupsClientMockRecorder is the mock recorder for MockApplicationSecurityGroupsClient. type MockApplicationSecurityGroupsClientMockRecorder struct { mock *MockApplicationSecurityGroupsClient } // NewMockApplicationSecurityGroupsClient creates a new mock instance. func NewMockApplicationSecurityGroupsClient(ctrl *gomock.Controller) *MockApplicationSecurityGroupsClient { mock := &MockApplicationSecurityGroupsClient{ctrl: ctrl} mock.recorder = &MockApplicationSecurityGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockApplicationSecurityGroupsClient) EXPECT() *MockApplicationSecurityGroupsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockApplicationSecurityGroupsClient) Get(ctx context.Context, resourceGroupName, applicationSecurityGroupName string, options *armnetwork.ApplicationSecurityGroupsClientGetOptions) (armnetwork.ApplicationSecurityGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, applicationSecurityGroupName, options) ret0, _ := ret[0].(armnetwork.ApplicationSecurityGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockApplicationSecurityGroupsClientMockRecorder) Get(ctx, resourceGroupName, applicationSecurityGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockApplicationSecurityGroupsClient)(nil).Get), ctx, resourceGroupName, applicationSecurityGroupName, options) } // NewListPager mocks base method. func (m *MockApplicationSecurityGroupsClient) NewListPager(resourceGroupName string, options *armnetwork.ApplicationSecurityGroupsClientListOptions) clients.ApplicationSecurityGroupsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.ApplicationSecurityGroupsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockApplicationSecurityGroupsClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockApplicationSecurityGroupsClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_availability_sets_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: availability-sets-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_availability_sets_client.go -package=mocks -source=availability-sets-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockAvailabilitySetsClient is a mock of AvailabilitySetsClient interface. type MockAvailabilitySetsClient struct { ctrl *gomock.Controller recorder *MockAvailabilitySetsClientMockRecorder isgomock struct{} } // MockAvailabilitySetsClientMockRecorder is the mock recorder for MockAvailabilitySetsClient. type MockAvailabilitySetsClientMockRecorder struct { mock *MockAvailabilitySetsClient } // NewMockAvailabilitySetsClient creates a new mock instance. func NewMockAvailabilitySetsClient(ctrl *gomock.Controller) *MockAvailabilitySetsClient { mock := &MockAvailabilitySetsClient{ctrl: ctrl} mock.recorder = &MockAvailabilitySetsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockAvailabilitySetsClient) EXPECT() *MockAvailabilitySetsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockAvailabilitySetsClient) Get(ctx context.Context, resourceGroupName, availabilitySetName string, options *armcompute.AvailabilitySetsClientGetOptions) (armcompute.AvailabilitySetsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, availabilitySetName, options) ret0, _ := ret[0].(armcompute.AvailabilitySetsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockAvailabilitySetsClientMockRecorder) Get(ctx, resourceGroupName, availabilitySetName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockAvailabilitySetsClient)(nil).Get), ctx, resourceGroupName, availabilitySetName, options) } // NewListPager mocks base method. func (m *MockAvailabilitySetsClient) NewListPager(resourceGroupName string, options *armcompute.AvailabilitySetsClientListOptions) clients.AvailabilitySetsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.AvailabilitySetsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockAvailabilitySetsClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockAvailabilitySetsClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_batch_accounts_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: batch-accounts-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_batch_accounts_client.go -package=mocks -source=batch-accounts-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armbatch "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockBatchAccountsClient is a mock of BatchAccountsClient interface. type MockBatchAccountsClient struct { ctrl *gomock.Controller recorder *MockBatchAccountsClientMockRecorder isgomock struct{} } // MockBatchAccountsClientMockRecorder is the mock recorder for MockBatchAccountsClient. type MockBatchAccountsClientMockRecorder struct { mock *MockBatchAccountsClient } // NewMockBatchAccountsClient creates a new mock instance. func NewMockBatchAccountsClient(ctrl *gomock.Controller) *MockBatchAccountsClient { mock := &MockBatchAccountsClient{ctrl: ctrl} mock.recorder = &MockBatchAccountsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBatchAccountsClient) EXPECT() *MockBatchAccountsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBatchAccountsClient) Get(ctx context.Context, resourceGroupName, accountName string) (armbatch.AccountClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(armbatch.AccountClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBatchAccountsClientMockRecorder) Get(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBatchAccountsClient)(nil).Get), ctx, resourceGroupName, accountName) } // ListByResourceGroup mocks base method. func (m *MockBatchAccountsClient) ListByResourceGroup(ctx context.Context, resourceGroupName string) clients.BatchAccountsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByResourceGroup", ctx, resourceGroupName) ret0, _ := ret[0].(clients.BatchAccountsPager) return ret0 } // ListByResourceGroup indicates an expected call of ListByResourceGroup. func (mr *MockBatchAccountsClientMockRecorder) ListByResourceGroup(ctx, resourceGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByResourceGroup", reflect.TypeOf((*MockBatchAccountsClient)(nil).ListByResourceGroup), ctx, resourceGroupName) } ================================================ FILE: sources/azure/shared/mocks/mock_batch_application_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: batch-application-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_batch_application_client.go -package=mocks -source=batch-application-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armbatch "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockBatchApplicationsClient is a mock of BatchApplicationsClient interface. type MockBatchApplicationsClient struct { ctrl *gomock.Controller recorder *MockBatchApplicationsClientMockRecorder isgomock struct{} } // MockBatchApplicationsClientMockRecorder is the mock recorder for MockBatchApplicationsClient. type MockBatchApplicationsClientMockRecorder struct { mock *MockBatchApplicationsClient } // NewMockBatchApplicationsClient creates a new mock instance. func NewMockBatchApplicationsClient(ctrl *gomock.Controller) *MockBatchApplicationsClient { mock := &MockBatchApplicationsClient{ctrl: ctrl} mock.recorder = &MockBatchApplicationsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBatchApplicationsClient) EXPECT() *MockBatchApplicationsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBatchApplicationsClient) Get(ctx context.Context, resourceGroupName, accountName, applicationName string) (armbatch.ApplicationClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, applicationName) ret0, _ := ret[0].(armbatch.ApplicationClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBatchApplicationsClientMockRecorder) Get(ctx, resourceGroupName, accountName, applicationName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBatchApplicationsClient)(nil).Get), ctx, resourceGroupName, accountName, applicationName) } // List mocks base method. func (m *MockBatchApplicationsClient) List(ctx context.Context, resourceGroupName, accountName string) clients.BatchApplicationsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.BatchApplicationsPager) return ret0 } // List indicates an expected call of List. func (mr *MockBatchApplicationsClientMockRecorder) List(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockBatchApplicationsClient)(nil).List), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_batch_application_package_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: batch-application-package-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_batch_application_package_client.go -package=mocks -source=batch-application-package-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armbatch "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockBatchApplicationPackagesClient is a mock of BatchApplicationPackagesClient interface. type MockBatchApplicationPackagesClient struct { ctrl *gomock.Controller recorder *MockBatchApplicationPackagesClientMockRecorder isgomock struct{} } // MockBatchApplicationPackagesClientMockRecorder is the mock recorder for MockBatchApplicationPackagesClient. type MockBatchApplicationPackagesClientMockRecorder struct { mock *MockBatchApplicationPackagesClient } // NewMockBatchApplicationPackagesClient creates a new mock instance. func NewMockBatchApplicationPackagesClient(ctrl *gomock.Controller) *MockBatchApplicationPackagesClient { mock := &MockBatchApplicationPackagesClient{ctrl: ctrl} mock.recorder = &MockBatchApplicationPackagesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBatchApplicationPackagesClient) EXPECT() *MockBatchApplicationPackagesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBatchApplicationPackagesClient) Get(ctx context.Context, resourceGroupName, accountName, applicationName, versionName string) (armbatch.ApplicationPackageClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, applicationName, versionName) ret0, _ := ret[0].(armbatch.ApplicationPackageClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBatchApplicationPackagesClientMockRecorder) Get(ctx, resourceGroupName, accountName, applicationName, versionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBatchApplicationPackagesClient)(nil).Get), ctx, resourceGroupName, accountName, applicationName, versionName) } // List mocks base method. func (m *MockBatchApplicationPackagesClient) List(ctx context.Context, resourceGroupName, accountName, applicationName string) clients.BatchApplicationPackagesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName, applicationName) ret0, _ := ret[0].(clients.BatchApplicationPackagesPager) return ret0 } // List indicates an expected call of List. func (mr *MockBatchApplicationPackagesClientMockRecorder) List(ctx, resourceGroupName, accountName, applicationName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockBatchApplicationPackagesClient)(nil).List), ctx, resourceGroupName, accountName, applicationName) } ================================================ FILE: sources/azure/shared/mocks/mock_batch_pool_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: batch-pool-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_batch_pool_client.go -package=mocks -source=batch-pool-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armbatch "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockBatchPoolsClient is a mock of BatchPoolsClient interface. type MockBatchPoolsClient struct { ctrl *gomock.Controller recorder *MockBatchPoolsClientMockRecorder isgomock struct{} } // MockBatchPoolsClientMockRecorder is the mock recorder for MockBatchPoolsClient. type MockBatchPoolsClientMockRecorder struct { mock *MockBatchPoolsClient } // NewMockBatchPoolsClient creates a new mock instance. func NewMockBatchPoolsClient(ctrl *gomock.Controller) *MockBatchPoolsClient { mock := &MockBatchPoolsClient{ctrl: ctrl} mock.recorder = &MockBatchPoolsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBatchPoolsClient) EXPECT() *MockBatchPoolsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBatchPoolsClient) Get(ctx context.Context, resourceGroupName, accountName, poolName string) (armbatch.PoolClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, poolName) ret0, _ := ret[0].(armbatch.PoolClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBatchPoolsClientMockRecorder) Get(ctx, resourceGroupName, accountName, poolName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBatchPoolsClient)(nil).Get), ctx, resourceGroupName, accountName, poolName) } // ListByBatchAccount mocks base method. func (m *MockBatchPoolsClient) ListByBatchAccount(ctx context.Context, resourceGroupName, accountName string) clients.BatchPoolsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByBatchAccount", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.BatchPoolsPager) return ret0 } // ListByBatchAccount indicates an expected call of ListByBatchAccount. func (mr *MockBatchPoolsClientMockRecorder) ListByBatchAccount(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByBatchAccount", reflect.TypeOf((*MockBatchPoolsClient)(nil).ListByBatchAccount), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_batch_private_endpoint_connection_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: batch-private-endpoint-connection-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_batch_private_endpoint_connection_client.go -package=mocks -source=batch-private-endpoint-connection-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armbatch "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/batch/armbatch/v4" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockBatchPrivateEndpointConnectionClient is a mock of BatchPrivateEndpointConnectionClient interface. type MockBatchPrivateEndpointConnectionClient struct { ctrl *gomock.Controller recorder *MockBatchPrivateEndpointConnectionClientMockRecorder isgomock struct{} } // MockBatchPrivateEndpointConnectionClientMockRecorder is the mock recorder for MockBatchPrivateEndpointConnectionClient. type MockBatchPrivateEndpointConnectionClientMockRecorder struct { mock *MockBatchPrivateEndpointConnectionClient } // NewMockBatchPrivateEndpointConnectionClient creates a new mock instance. func NewMockBatchPrivateEndpointConnectionClient(ctrl *gomock.Controller) *MockBatchPrivateEndpointConnectionClient { mock := &MockBatchPrivateEndpointConnectionClient{ctrl: ctrl} mock.recorder = &MockBatchPrivateEndpointConnectionClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBatchPrivateEndpointConnectionClient) EXPECT() *MockBatchPrivateEndpointConnectionClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBatchPrivateEndpointConnectionClient) Get(ctx context.Context, resourceGroupName, accountName, privateEndpointConnectionName string) (armbatch.PrivateEndpointConnectionClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, privateEndpointConnectionName) ret0, _ := ret[0].(armbatch.PrivateEndpointConnectionClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBatchPrivateEndpointConnectionClientMockRecorder) Get(ctx, resourceGroupName, accountName, privateEndpointConnectionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBatchPrivateEndpointConnectionClient)(nil).Get), ctx, resourceGroupName, accountName, privateEndpointConnectionName) } // ListByBatchAccount mocks base method. func (m *MockBatchPrivateEndpointConnectionClient) ListByBatchAccount(ctx context.Context, resourceGroupName, accountName string) clients.BatchPrivateEndpointConnectionPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByBatchAccount", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.BatchPrivateEndpointConnectionPager) return ret0 } // ListByBatchAccount indicates an expected call of ListByBatchAccount. func (mr *MockBatchPrivateEndpointConnectionClientMockRecorder) ListByBatchAccount(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByBatchAccount", reflect.TypeOf((*MockBatchPrivateEndpointConnectionClient)(nil).ListByBatchAccount), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_blob_containers_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: blob-containers-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_blob_containers_client.go -package=mocks -source=blob-containers-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockBlobContainersClient is a mock of BlobContainersClient interface. type MockBlobContainersClient struct { ctrl *gomock.Controller recorder *MockBlobContainersClientMockRecorder isgomock struct{} } // MockBlobContainersClientMockRecorder is the mock recorder for MockBlobContainersClient. type MockBlobContainersClientMockRecorder struct { mock *MockBlobContainersClient } // NewMockBlobContainersClient creates a new mock instance. func NewMockBlobContainersClient(ctrl *gomock.Controller) *MockBlobContainersClient { mock := &MockBlobContainersClient{ctrl: ctrl} mock.recorder = &MockBlobContainersClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBlobContainersClient) EXPECT() *MockBlobContainersClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBlobContainersClient) Get(ctx context.Context, resourceGroupName, accountName, containerName string) (armstorage.BlobContainersClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, containerName) ret0, _ := ret[0].(armstorage.BlobContainersClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBlobContainersClientMockRecorder) Get(ctx, resourceGroupName, accountName, containerName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBlobContainersClient)(nil).Get), ctx, resourceGroupName, accountName, containerName) } // List mocks base method. func (m *MockBlobContainersClient) List(ctx context.Context, resourceGroupName, accountName string) clients.BlobContainersPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.BlobContainersPager) return ret0 } // List indicates an expected call of List. func (mr *MockBlobContainersClientMockRecorder) List(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockBlobContainersClient)(nil).List), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_capacity_reservation_groups_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: capacity-reservation-groups-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_capacity_reservation_groups_client.go -package=mocks -source=capacity-reservation-groups-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockCapacityReservationGroupsClient is a mock of CapacityReservationGroupsClient interface. type MockCapacityReservationGroupsClient struct { ctrl *gomock.Controller recorder *MockCapacityReservationGroupsClientMockRecorder isgomock struct{} } // MockCapacityReservationGroupsClientMockRecorder is the mock recorder for MockCapacityReservationGroupsClient. type MockCapacityReservationGroupsClientMockRecorder struct { mock *MockCapacityReservationGroupsClient } // NewMockCapacityReservationGroupsClient creates a new mock instance. func NewMockCapacityReservationGroupsClient(ctrl *gomock.Controller) *MockCapacityReservationGroupsClient { mock := &MockCapacityReservationGroupsClient{ctrl: ctrl} mock.recorder = &MockCapacityReservationGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockCapacityReservationGroupsClient) EXPECT() *MockCapacityReservationGroupsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockCapacityReservationGroupsClient) Get(ctx context.Context, resourceGroupName, capacityReservationGroupName string, options *armcompute.CapacityReservationGroupsClientGetOptions) (armcompute.CapacityReservationGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, capacityReservationGroupName, options) ret0, _ := ret[0].(armcompute.CapacityReservationGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockCapacityReservationGroupsClientMockRecorder) Get(ctx, resourceGroupName, capacityReservationGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockCapacityReservationGroupsClient)(nil).Get), ctx, resourceGroupName, capacityReservationGroupName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockCapacityReservationGroupsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.CapacityReservationGroupsClientListByResourceGroupOptions) clients.CapacityReservationGroupsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.CapacityReservationGroupsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockCapacityReservationGroupsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockCapacityReservationGroupsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_capacity_reservations_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: capacity-reservations-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_capacity_reservations_client.go -package=mocks -source=capacity-reservations-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockCapacityReservationsClient is a mock of CapacityReservationsClient interface. type MockCapacityReservationsClient struct { ctrl *gomock.Controller recorder *MockCapacityReservationsClientMockRecorder isgomock struct{} } // MockCapacityReservationsClientMockRecorder is the mock recorder for MockCapacityReservationsClient. type MockCapacityReservationsClientMockRecorder struct { mock *MockCapacityReservationsClient } // NewMockCapacityReservationsClient creates a new mock instance. func NewMockCapacityReservationsClient(ctrl *gomock.Controller) *MockCapacityReservationsClient { mock := &MockCapacityReservationsClient{ctrl: ctrl} mock.recorder = &MockCapacityReservationsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockCapacityReservationsClient) EXPECT() *MockCapacityReservationsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockCapacityReservationsClient) Get(ctx context.Context, resourceGroupName, capacityReservationGroupName, capacityReservationName string, options *armcompute.CapacityReservationsClientGetOptions) (armcompute.CapacityReservationsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, capacityReservationGroupName, capacityReservationName, options) ret0, _ := ret[0].(armcompute.CapacityReservationsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockCapacityReservationsClientMockRecorder) Get(ctx, resourceGroupName, capacityReservationGroupName, capacityReservationName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockCapacityReservationsClient)(nil).Get), ctx, resourceGroupName, capacityReservationGroupName, capacityReservationName, options) } // NewListByCapacityReservationGroupPager mocks base method. func (m *MockCapacityReservationsClient) NewListByCapacityReservationGroupPager(resourceGroupName, capacityReservationGroupName string, options *armcompute.CapacityReservationsClientListByCapacityReservationGroupOptions) clients.CapacityReservationsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByCapacityReservationGroupPager", resourceGroupName, capacityReservationGroupName, options) ret0, _ := ret[0].(clients.CapacityReservationsPager) return ret0 } // NewListByCapacityReservationGroupPager indicates an expected call of NewListByCapacityReservationGroupPager. func (mr *MockCapacityReservationsClientMockRecorder) NewListByCapacityReservationGroupPager(resourceGroupName, capacityReservationGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByCapacityReservationGroupPager", reflect.TypeOf((*MockCapacityReservationsClient)(nil).NewListByCapacityReservationGroupPager), resourceGroupName, capacityReservationGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: compute-disk-access-private-endpoint-connection-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go -package=mocks -source=compute-disk-access-private-endpoint-connection-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockComputeDiskAccessPrivateEndpointConnectionsClient is a mock of ComputeDiskAccessPrivateEndpointConnectionsClient interface. type MockComputeDiskAccessPrivateEndpointConnectionsClient struct { ctrl *gomock.Controller recorder *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder isgomock struct{} } // MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder is the mock recorder for MockComputeDiskAccessPrivateEndpointConnectionsClient. type MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder struct { mock *MockComputeDiskAccessPrivateEndpointConnectionsClient } // NewMockComputeDiskAccessPrivateEndpointConnectionsClient creates a new mock instance. func NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl *gomock.Controller) *MockComputeDiskAccessPrivateEndpointConnectionsClient { mock := &MockComputeDiskAccessPrivateEndpointConnectionsClient{ctrl: ctrl} mock.recorder = &MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeDiskAccessPrivateEndpointConnectionsClient) EXPECT() *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockComputeDiskAccessPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName, diskAccessName, privateEndpointConnectionName string) (armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName) ret0, _ := ret[0].(armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder) Get(ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeDiskAccessPrivateEndpointConnectionsClient)(nil).Get), ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName) } // NewListPrivateEndpointConnectionsPager mocks base method. func (m *MockComputeDiskAccessPrivateEndpointConnectionsClient) NewListPrivateEndpointConnectionsPager(resourceGroupName, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) clients.ComputeDiskAccessPrivateEndpointConnectionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPrivateEndpointConnectionsPager", resourceGroupName, diskAccessName, options) ret0, _ := ret[0].(clients.ComputeDiskAccessPrivateEndpointConnectionsPager) return ret0 } // NewListPrivateEndpointConnectionsPager indicates an expected call of NewListPrivateEndpointConnectionsPager. func (mr *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder) NewListPrivateEndpointConnectionsPager(resourceGroupName, diskAccessName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPrivateEndpointConnectionsPager", reflect.TypeOf((*MockComputeDiskAccessPrivateEndpointConnectionsClient)(nil).NewListPrivateEndpointConnectionsPager), resourceGroupName, diskAccessName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_dbforpostgresql_configurations_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dbforpostgresql-configurations-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dbforpostgresql_configurations_client.go -package=mocks -source=dbforpostgresql-configurations-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPostgreSQLConfigurationsClient is a mock of PostgreSQLConfigurationsClient interface. type MockPostgreSQLConfigurationsClient struct { ctrl *gomock.Controller recorder *MockPostgreSQLConfigurationsClientMockRecorder isgomock struct{} } // MockPostgreSQLConfigurationsClientMockRecorder is the mock recorder for MockPostgreSQLConfigurationsClient. type MockPostgreSQLConfigurationsClientMockRecorder struct { mock *MockPostgreSQLConfigurationsClient } // NewMockPostgreSQLConfigurationsClient creates a new mock instance. func NewMockPostgreSQLConfigurationsClient(ctrl *gomock.Controller) *MockPostgreSQLConfigurationsClient { mock := &MockPostgreSQLConfigurationsClient{ctrl: ctrl} mock.recorder = &MockPostgreSQLConfigurationsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPostgreSQLConfigurationsClient) EXPECT() *MockPostgreSQLConfigurationsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPostgreSQLConfigurationsClient) Get(ctx context.Context, resourceGroupName, serverName, configurationName string, options *armpostgresqlflexibleservers.ConfigurationsClientGetOptions) (armpostgresqlflexibleservers.ConfigurationsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, configurationName, options) ret0, _ := ret[0].(armpostgresqlflexibleservers.ConfigurationsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPostgreSQLConfigurationsClientMockRecorder) Get(ctx, resourceGroupName, serverName, configurationName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPostgreSQLConfigurationsClient)(nil).Get), ctx, resourceGroupName, serverName, configurationName, options) } // NewListByServerPager mocks base method. func (m *MockPostgreSQLConfigurationsClient) NewListByServerPager(resourceGroupName, serverName string, options *armpostgresqlflexibleservers.ConfigurationsClientListByServerOptions) clients.PostgreSQLConfigurationsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByServerPager", resourceGroupName, serverName, options) ret0, _ := ret[0].(clients.PostgreSQLConfigurationsPager) return ret0 } // NewListByServerPager indicates an expected call of NewListByServerPager. func (mr *MockPostgreSQLConfigurationsClientMockRecorder) NewListByServerPager(resourceGroupName, serverName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByServerPager", reflect.TypeOf((*MockPostgreSQLConfigurationsClient)(nil).NewListByServerPager), resourceGroupName, serverName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_dbforpostgresql_flexible_server_administrator_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dbforpostgresql-flexible-server-administrator-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_administrator_client.go -package=mocks -source=dbforpostgresql-flexible-server-administrator-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDBforPostgreSQLFlexibleServerAdministratorClient is a mock of DBforPostgreSQLFlexibleServerAdministratorClient interface. type MockDBforPostgreSQLFlexibleServerAdministratorClient struct { ctrl *gomock.Controller recorder *MockDBforPostgreSQLFlexibleServerAdministratorClientMockRecorder isgomock struct{} } // MockDBforPostgreSQLFlexibleServerAdministratorClientMockRecorder is the mock recorder for MockDBforPostgreSQLFlexibleServerAdministratorClient. type MockDBforPostgreSQLFlexibleServerAdministratorClientMockRecorder struct { mock *MockDBforPostgreSQLFlexibleServerAdministratorClient } // NewMockDBforPostgreSQLFlexibleServerAdministratorClient creates a new mock instance. func NewMockDBforPostgreSQLFlexibleServerAdministratorClient(ctrl *gomock.Controller) *MockDBforPostgreSQLFlexibleServerAdministratorClient { mock := &MockDBforPostgreSQLFlexibleServerAdministratorClient{ctrl: ctrl} mock.recorder = &MockDBforPostgreSQLFlexibleServerAdministratorClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDBforPostgreSQLFlexibleServerAdministratorClient) EXPECT() *MockDBforPostgreSQLFlexibleServerAdministratorClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDBforPostgreSQLFlexibleServerAdministratorClient) Get(ctx context.Context, resourceGroupName, serverName, objectID string) (armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, objectID) ret0, _ := ret[0].(armpostgresqlflexibleservers.AdministratorsMicrosoftEntraClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDBforPostgreSQLFlexibleServerAdministratorClientMockRecorder) Get(ctx, resourceGroupName, serverName, objectID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerAdministratorClient)(nil).Get), ctx, resourceGroupName, serverName, objectID) } // ListByServer mocks base method. func (m *MockDBforPostgreSQLFlexibleServerAdministratorClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerAdministratorPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.DBforPostgreSQLFlexibleServerAdministratorPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockDBforPostgreSQLFlexibleServerAdministratorClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerAdministratorClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_dbforpostgresql_flexible_server_backup_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dbforpostgresql-flexible-server-backup-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_backup_client.go -package=mocks -source=dbforpostgresql-flexible-server-backup-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDBforPostgreSQLFlexibleServerBackupClient is a mock of DBforPostgreSQLFlexibleServerBackupClient interface. type MockDBforPostgreSQLFlexibleServerBackupClient struct { ctrl *gomock.Controller recorder *MockDBforPostgreSQLFlexibleServerBackupClientMockRecorder isgomock struct{} } // MockDBforPostgreSQLFlexibleServerBackupClientMockRecorder is the mock recorder for MockDBforPostgreSQLFlexibleServerBackupClient. type MockDBforPostgreSQLFlexibleServerBackupClientMockRecorder struct { mock *MockDBforPostgreSQLFlexibleServerBackupClient } // NewMockDBforPostgreSQLFlexibleServerBackupClient creates a new mock instance. func NewMockDBforPostgreSQLFlexibleServerBackupClient(ctrl *gomock.Controller) *MockDBforPostgreSQLFlexibleServerBackupClient { mock := &MockDBforPostgreSQLFlexibleServerBackupClient{ctrl: ctrl} mock.recorder = &MockDBforPostgreSQLFlexibleServerBackupClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDBforPostgreSQLFlexibleServerBackupClient) EXPECT() *MockDBforPostgreSQLFlexibleServerBackupClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDBforPostgreSQLFlexibleServerBackupClient) Get(ctx context.Context, resourceGroupName, serverName, backupName string) (armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, backupName) ret0, _ := ret[0].(armpostgresqlflexibleservers.BackupsAutomaticAndOnDemandClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDBforPostgreSQLFlexibleServerBackupClientMockRecorder) Get(ctx, resourceGroupName, serverName, backupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerBackupClient)(nil).Get), ctx, resourceGroupName, serverName, backupName) } // ListByServer mocks base method. func (m *MockDBforPostgreSQLFlexibleServerBackupClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerBackupPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.DBforPostgreSQLFlexibleServerBackupPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockDBforPostgreSQLFlexibleServerBackupClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerBackupClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_dbforpostgresql_flexible_server_private_endpoint_connection_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dbforpostgresql-flexible-server-private-endpoint-connection-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_private_endpoint_connection_client.go -package=mocks -source=dbforpostgresql-flexible-server-private-endpoint-connection-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient is a mock of DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient interface. type MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient struct { ctrl *gomock.Controller recorder *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClientMockRecorder isgomock struct{} } // MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClientMockRecorder is the mock recorder for MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient. type MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClientMockRecorder struct { mock *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient } // NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient creates a new mock instance. func NewMockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient(ctrl *gomock.Controller) *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient { mock := &MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient{ctrl: ctrl} mock.recorder = &MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient) EXPECT() *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName, serverName, privateEndpointConnectionName string) (armpostgresqlflexibleservers.PrivateEndpointConnectionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, privateEndpointConnectionName) ret0, _ := ret[0].(armpostgresqlflexibleservers.PrivateEndpointConnectionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClientMockRecorder) Get(ctx, resourceGroupName, serverName, privateEndpointConnectionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient)(nil).Get), ctx, resourceGroupName, serverName, privateEndpointConnectionName) } // ListByServer mocks base method. func (m *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.DBforPostgreSQLFlexibleServerPrivateEndpointConnectionsPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerPrivateEndpointConnectionsClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_dbforpostgresql_flexible_server_replica_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dbforpostgresql-flexible-server-replica-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_replica_client.go -package=mocks -source=dbforpostgresql-flexible-server-replica-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDBforPostgreSQLFlexibleServerReplicaClient is a mock of DBforPostgreSQLFlexibleServerReplicaClient interface. type MockDBforPostgreSQLFlexibleServerReplicaClient struct { ctrl *gomock.Controller recorder *MockDBforPostgreSQLFlexibleServerReplicaClientMockRecorder isgomock struct{} } // MockDBforPostgreSQLFlexibleServerReplicaClientMockRecorder is the mock recorder for MockDBforPostgreSQLFlexibleServerReplicaClient. type MockDBforPostgreSQLFlexibleServerReplicaClientMockRecorder struct { mock *MockDBforPostgreSQLFlexibleServerReplicaClient } // NewMockDBforPostgreSQLFlexibleServerReplicaClient creates a new mock instance. func NewMockDBforPostgreSQLFlexibleServerReplicaClient(ctrl *gomock.Controller) *MockDBforPostgreSQLFlexibleServerReplicaClient { mock := &MockDBforPostgreSQLFlexibleServerReplicaClient{ctrl: ctrl} mock.recorder = &MockDBforPostgreSQLFlexibleServerReplicaClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDBforPostgreSQLFlexibleServerReplicaClient) EXPECT() *MockDBforPostgreSQLFlexibleServerReplicaClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDBforPostgreSQLFlexibleServerReplicaClient) Get(ctx context.Context, resourceGroupName, replicaName string) (armpostgresqlflexibleservers.ServersClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, replicaName) ret0, _ := ret[0].(armpostgresqlflexibleservers.ServersClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDBforPostgreSQLFlexibleServerReplicaClientMockRecorder) Get(ctx, resourceGroupName, replicaName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerReplicaClient)(nil).Get), ctx, resourceGroupName, replicaName) } // ListByServer mocks base method. func (m *MockDBforPostgreSQLFlexibleServerReplicaClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerReplicaPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.DBforPostgreSQLFlexibleServerReplicaPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockDBforPostgreSQLFlexibleServerReplicaClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerReplicaClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_dbforpostgresql_flexible_server_virtual_endpoint_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dbforpostgresql-flexible-server-virtual-endpoint-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dbforpostgresql_flexible_server_virtual_endpoint_client.go -package=mocks -source=dbforpostgresql-flexible-server-virtual-endpoint-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDBforPostgreSQLFlexibleServerVirtualEndpointClient is a mock of DBforPostgreSQLFlexibleServerVirtualEndpointClient interface. type MockDBforPostgreSQLFlexibleServerVirtualEndpointClient struct { ctrl *gomock.Controller recorder *MockDBforPostgreSQLFlexibleServerVirtualEndpointClientMockRecorder isgomock struct{} } // MockDBforPostgreSQLFlexibleServerVirtualEndpointClientMockRecorder is the mock recorder for MockDBforPostgreSQLFlexibleServerVirtualEndpointClient. type MockDBforPostgreSQLFlexibleServerVirtualEndpointClientMockRecorder struct { mock *MockDBforPostgreSQLFlexibleServerVirtualEndpointClient } // NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient creates a new mock instance. func NewMockDBforPostgreSQLFlexibleServerVirtualEndpointClient(ctrl *gomock.Controller) *MockDBforPostgreSQLFlexibleServerVirtualEndpointClient { mock := &MockDBforPostgreSQLFlexibleServerVirtualEndpointClient{ctrl: ctrl} mock.recorder = &MockDBforPostgreSQLFlexibleServerVirtualEndpointClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDBforPostgreSQLFlexibleServerVirtualEndpointClient) EXPECT() *MockDBforPostgreSQLFlexibleServerVirtualEndpointClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDBforPostgreSQLFlexibleServerVirtualEndpointClient) Get(ctx context.Context, resourceGroupName, serverName, virtualEndpointName string) (armpostgresqlflexibleservers.VirtualEndpointsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, virtualEndpointName) ret0, _ := ret[0].(armpostgresqlflexibleservers.VirtualEndpointsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDBforPostgreSQLFlexibleServerVirtualEndpointClientMockRecorder) Get(ctx, resourceGroupName, serverName, virtualEndpointName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerVirtualEndpointClient)(nil).Get), ctx, resourceGroupName, serverName, virtualEndpointName) } // ListByServer mocks base method. func (m *MockDBforPostgreSQLFlexibleServerVirtualEndpointClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.DBforPostgreSQLFlexibleServerVirtualEndpointPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.DBforPostgreSQLFlexibleServerVirtualEndpointPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockDBforPostgreSQLFlexibleServerVirtualEndpointClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockDBforPostgreSQLFlexibleServerVirtualEndpointClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_ddos_protection_plans_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: ddos-protection-plans-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_ddos_protection_plans_client.go -package=mocks -source=ddos-protection-plans-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDdosProtectionPlansClient is a mock of DdosProtectionPlansClient interface. type MockDdosProtectionPlansClient struct { ctrl *gomock.Controller recorder *MockDdosProtectionPlansClientMockRecorder isgomock struct{} } // MockDdosProtectionPlansClientMockRecorder is the mock recorder for MockDdosProtectionPlansClient. type MockDdosProtectionPlansClientMockRecorder struct { mock *MockDdosProtectionPlansClient } // NewMockDdosProtectionPlansClient creates a new mock instance. func NewMockDdosProtectionPlansClient(ctrl *gomock.Controller) *MockDdosProtectionPlansClient { mock := &MockDdosProtectionPlansClient{ctrl: ctrl} mock.recorder = &MockDdosProtectionPlansClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDdosProtectionPlansClient) EXPECT() *MockDdosProtectionPlansClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDdosProtectionPlansClient) Get(ctx context.Context, resourceGroupName, ddosProtectionPlanName string, options *armnetwork.DdosProtectionPlansClientGetOptions) (armnetwork.DdosProtectionPlansClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, ddosProtectionPlanName, options) ret0, _ := ret[0].(armnetwork.DdosProtectionPlansClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDdosProtectionPlansClientMockRecorder) Get(ctx, resourceGroupName, ddosProtectionPlanName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDdosProtectionPlansClient)(nil).Get), ctx, resourceGroupName, ddosProtectionPlanName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockDdosProtectionPlansClient) NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.DdosProtectionPlansClientListByResourceGroupOptions) clients.DdosProtectionPlansPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.DdosProtectionPlansPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockDdosProtectionPlansClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockDdosProtectionPlansClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_dedicated_host_groups_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dedicated-host-groups-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dedicated_host_groups_client.go -package=mocks -source=dedicated-host-groups-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDedicatedHostGroupsClient is a mock of DedicatedHostGroupsClient interface. type MockDedicatedHostGroupsClient struct { ctrl *gomock.Controller recorder *MockDedicatedHostGroupsClientMockRecorder isgomock struct{} } // MockDedicatedHostGroupsClientMockRecorder is the mock recorder for MockDedicatedHostGroupsClient. type MockDedicatedHostGroupsClientMockRecorder struct { mock *MockDedicatedHostGroupsClient } // NewMockDedicatedHostGroupsClient creates a new mock instance. func NewMockDedicatedHostGroupsClient(ctrl *gomock.Controller) *MockDedicatedHostGroupsClient { mock := &MockDedicatedHostGroupsClient{ctrl: ctrl} mock.recorder = &MockDedicatedHostGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDedicatedHostGroupsClient) EXPECT() *MockDedicatedHostGroupsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDedicatedHostGroupsClient) Get(ctx context.Context, resourceGroupName, dedicatedHostGroupName string, options *armcompute.DedicatedHostGroupsClientGetOptions) (armcompute.DedicatedHostGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, dedicatedHostGroupName, options) ret0, _ := ret[0].(armcompute.DedicatedHostGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDedicatedHostGroupsClientMockRecorder) Get(ctx, resourceGroupName, dedicatedHostGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDedicatedHostGroupsClient)(nil).Get), ctx, resourceGroupName, dedicatedHostGroupName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockDedicatedHostGroupsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DedicatedHostGroupsClientListByResourceGroupOptions) clients.DedicatedHostGroupsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.DedicatedHostGroupsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockDedicatedHostGroupsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockDedicatedHostGroupsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_dedicated_hosts_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: dedicated-hosts-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_dedicated_hosts_client.go -package=mocks -source=dedicated-hosts-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDedicatedHostsClient is a mock of DedicatedHostsClient interface. type MockDedicatedHostsClient struct { ctrl *gomock.Controller recorder *MockDedicatedHostsClientMockRecorder isgomock struct{} } // MockDedicatedHostsClientMockRecorder is the mock recorder for MockDedicatedHostsClient. type MockDedicatedHostsClientMockRecorder struct { mock *MockDedicatedHostsClient } // NewMockDedicatedHostsClient creates a new mock instance. func NewMockDedicatedHostsClient(ctrl *gomock.Controller) *MockDedicatedHostsClient { mock := &MockDedicatedHostsClient{ctrl: ctrl} mock.recorder = &MockDedicatedHostsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDedicatedHostsClient) EXPECT() *MockDedicatedHostsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDedicatedHostsClient) Get(ctx context.Context, resourceGroupName, hostGroupName, hostName string, options *armcompute.DedicatedHostsClientGetOptions) (armcompute.DedicatedHostsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, hostGroupName, hostName, options) ret0, _ := ret[0].(armcompute.DedicatedHostsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDedicatedHostsClientMockRecorder) Get(ctx, resourceGroupName, hostGroupName, hostName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDedicatedHostsClient)(nil).Get), ctx, resourceGroupName, hostGroupName, hostName, options) } // NewListByHostGroupPager mocks base method. func (m *MockDedicatedHostsClient) NewListByHostGroupPager(resourceGroupName, hostGroupName string, options *armcompute.DedicatedHostsClientListByHostGroupOptions) clients.DedicatedHostsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByHostGroupPager", resourceGroupName, hostGroupName, options) ret0, _ := ret[0].(clients.DedicatedHostsPager) return ret0 } // NewListByHostGroupPager indicates an expected call of NewListByHostGroupPager. func (mr *MockDedicatedHostsClientMockRecorder) NewListByHostGroupPager(resourceGroupName, hostGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByHostGroupPager", reflect.TypeOf((*MockDedicatedHostsClient)(nil).NewListByHostGroupPager), resourceGroupName, hostGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_default_security_rules_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: default-security-rules-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_default_security_rules_client.go -package=mocks -source=default-security-rules-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDefaultSecurityRulesClient is a mock of DefaultSecurityRulesClient interface. type MockDefaultSecurityRulesClient struct { ctrl *gomock.Controller recorder *MockDefaultSecurityRulesClientMockRecorder isgomock struct{} } // MockDefaultSecurityRulesClientMockRecorder is the mock recorder for MockDefaultSecurityRulesClient. type MockDefaultSecurityRulesClientMockRecorder struct { mock *MockDefaultSecurityRulesClient } // NewMockDefaultSecurityRulesClient creates a new mock instance. func NewMockDefaultSecurityRulesClient(ctrl *gomock.Controller) *MockDefaultSecurityRulesClient { mock := &MockDefaultSecurityRulesClient{ctrl: ctrl} mock.recorder = &MockDefaultSecurityRulesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDefaultSecurityRulesClient) EXPECT() *MockDefaultSecurityRulesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDefaultSecurityRulesClient) Get(ctx context.Context, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName string, options *armnetwork.DefaultSecurityRulesClientGetOptions) (armnetwork.DefaultSecurityRulesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options) ret0, _ := ret[0].(armnetwork.DefaultSecurityRulesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDefaultSecurityRulesClientMockRecorder) Get(ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDefaultSecurityRulesClient)(nil).Get), ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options) } // NewListPager mocks base method. func (m *MockDefaultSecurityRulesClient) NewListPager(resourceGroupName, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) clients.DefaultSecurityRulesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, networkSecurityGroupName, options) ret0, _ := ret[0].(clients.DefaultSecurityRulesPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockDefaultSecurityRulesClientMockRecorder) NewListPager(resourceGroupName, networkSecurityGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockDefaultSecurityRulesClient)(nil).NewListPager), resourceGroupName, networkSecurityGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_disk_accesses_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: disk-accesses-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_disk_accesses_client.go -package=mocks -source=disk-accesses-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDiskAccessesClient is a mock of DiskAccessesClient interface. type MockDiskAccessesClient struct { ctrl *gomock.Controller recorder *MockDiskAccessesClientMockRecorder isgomock struct{} } // MockDiskAccessesClientMockRecorder is the mock recorder for MockDiskAccessesClient. type MockDiskAccessesClientMockRecorder struct { mock *MockDiskAccessesClient } // NewMockDiskAccessesClient creates a new mock instance. func NewMockDiskAccessesClient(ctrl *gomock.Controller) *MockDiskAccessesClient { mock := &MockDiskAccessesClient{ctrl: ctrl} mock.recorder = &MockDiskAccessesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDiskAccessesClient) EXPECT() *MockDiskAccessesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDiskAccessesClient) Get(ctx context.Context, resourceGroupName, diskAccessName string, options *armcompute.DiskAccessesClientGetOptions) (armcompute.DiskAccessesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, diskAccessName, options) ret0, _ := ret[0].(armcompute.DiskAccessesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDiskAccessesClientMockRecorder) Get(ctx, resourceGroupName, diskAccessName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDiskAccessesClient)(nil).Get), ctx, resourceGroupName, diskAccessName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockDiskAccessesClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DiskAccessesClientListByResourceGroupOptions) clients.DiskAccessesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.DiskAccessesPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockDiskAccessesClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockDiskAccessesClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_disk_encryption_sets_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: disk-encryption-sets-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_disk_encryption_sets_client.go -package=mocks -source=disk-encryption-sets-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDiskEncryptionSetsClient is a mock of DiskEncryptionSetsClient interface. type MockDiskEncryptionSetsClient struct { ctrl *gomock.Controller recorder *MockDiskEncryptionSetsClientMockRecorder isgomock struct{} } // MockDiskEncryptionSetsClientMockRecorder is the mock recorder for MockDiskEncryptionSetsClient. type MockDiskEncryptionSetsClientMockRecorder struct { mock *MockDiskEncryptionSetsClient } // NewMockDiskEncryptionSetsClient creates a new mock instance. func NewMockDiskEncryptionSetsClient(ctrl *gomock.Controller) *MockDiskEncryptionSetsClient { mock := &MockDiskEncryptionSetsClient{ctrl: ctrl} mock.recorder = &MockDiskEncryptionSetsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDiskEncryptionSetsClient) EXPECT() *MockDiskEncryptionSetsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDiskEncryptionSetsClient) Get(ctx context.Context, resourceGroupName, diskEncryptionSetName string, options *armcompute.DiskEncryptionSetsClientGetOptions) (armcompute.DiskEncryptionSetsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, diskEncryptionSetName, options) ret0, _ := ret[0].(armcompute.DiskEncryptionSetsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDiskEncryptionSetsClientMockRecorder) Get(ctx, resourceGroupName, diskEncryptionSetName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDiskEncryptionSetsClient)(nil).Get), ctx, resourceGroupName, diskEncryptionSetName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockDiskEncryptionSetsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DiskEncryptionSetsClientListByResourceGroupOptions) clients.DiskEncryptionSetsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.DiskEncryptionSetsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockDiskEncryptionSetsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockDiskEncryptionSetsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_disks_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: disks-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_disks_client.go -package=mocks -source=disks-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDisksClient is a mock of DisksClient interface. type MockDisksClient struct { ctrl *gomock.Controller recorder *MockDisksClientMockRecorder isgomock struct{} } // MockDisksClientMockRecorder is the mock recorder for MockDisksClient. type MockDisksClientMockRecorder struct { mock *MockDisksClient } // NewMockDisksClient creates a new mock instance. func NewMockDisksClient(ctrl *gomock.Controller) *MockDisksClient { mock := &MockDisksClient{ctrl: ctrl} mock.recorder = &MockDisksClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDisksClient) EXPECT() *MockDisksClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDisksClient) Get(ctx context.Context, resourceGroupName, diskName string, options *armcompute.DisksClientGetOptions) (armcompute.DisksClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, diskName, options) ret0, _ := ret[0].(armcompute.DisksClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDisksClientMockRecorder) Get(ctx, resourceGroupName, diskName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDisksClient)(nil).Get), ctx, resourceGroupName, diskName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockDisksClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.DisksClientListByResourceGroupOptions) clients.DisksPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.DisksPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockDisksClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockDisksClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_documentdb_database_accounts_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: documentdb-database-accounts-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_documentdb_database_accounts_client.go -package=mocks -source=documentdb-database-accounts-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcosmos "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDocumentDBDatabaseAccountsClient is a mock of DocumentDBDatabaseAccountsClient interface. type MockDocumentDBDatabaseAccountsClient struct { ctrl *gomock.Controller recorder *MockDocumentDBDatabaseAccountsClientMockRecorder isgomock struct{} } // MockDocumentDBDatabaseAccountsClientMockRecorder is the mock recorder for MockDocumentDBDatabaseAccountsClient. type MockDocumentDBDatabaseAccountsClientMockRecorder struct { mock *MockDocumentDBDatabaseAccountsClient } // NewMockDocumentDBDatabaseAccountsClient creates a new mock instance. func NewMockDocumentDBDatabaseAccountsClient(ctrl *gomock.Controller) *MockDocumentDBDatabaseAccountsClient { mock := &MockDocumentDBDatabaseAccountsClient{ctrl: ctrl} mock.recorder = &MockDocumentDBDatabaseAccountsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDocumentDBDatabaseAccountsClient) EXPECT() *MockDocumentDBDatabaseAccountsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDocumentDBDatabaseAccountsClient) Get(ctx context.Context, resourceGroupName, accountName string) (armcosmos.DatabaseAccountsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(armcosmos.DatabaseAccountsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDocumentDBDatabaseAccountsClientMockRecorder) Get(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDocumentDBDatabaseAccountsClient)(nil).Get), ctx, resourceGroupName, accountName) } // ListByResourceGroup mocks base method. func (m *MockDocumentDBDatabaseAccountsClient) ListByResourceGroup(resourceGroupName string) clients.DocumentDBDatabaseAccountsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByResourceGroup", resourceGroupName) ret0, _ := ret[0].(clients.DocumentDBDatabaseAccountsPager) return ret0 } // ListByResourceGroup indicates an expected call of ListByResourceGroup. func (mr *MockDocumentDBDatabaseAccountsClientMockRecorder) ListByResourceGroup(resourceGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByResourceGroup", reflect.TypeOf((*MockDocumentDBDatabaseAccountsClient)(nil).ListByResourceGroup), resourceGroupName) } ================================================ FILE: sources/azure/shared/mocks/mock_documentdb_private_endpoint_connection_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: documentdb-private-endpoint-connection-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_documentdb_private_endpoint_connection_client.go -package=mocks -source=documentdb-private-endpoint-connection-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcosmos "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockDocumentDBPrivateEndpointConnectionsClient is a mock of DocumentDBPrivateEndpointConnectionsClient interface. type MockDocumentDBPrivateEndpointConnectionsClient struct { ctrl *gomock.Controller recorder *MockDocumentDBPrivateEndpointConnectionsClientMockRecorder isgomock struct{} } // MockDocumentDBPrivateEndpointConnectionsClientMockRecorder is the mock recorder for MockDocumentDBPrivateEndpointConnectionsClient. type MockDocumentDBPrivateEndpointConnectionsClientMockRecorder struct { mock *MockDocumentDBPrivateEndpointConnectionsClient } // NewMockDocumentDBPrivateEndpointConnectionsClient creates a new mock instance. func NewMockDocumentDBPrivateEndpointConnectionsClient(ctrl *gomock.Controller) *MockDocumentDBPrivateEndpointConnectionsClient { mock := &MockDocumentDBPrivateEndpointConnectionsClient{ctrl: ctrl} mock.recorder = &MockDocumentDBPrivateEndpointConnectionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDocumentDBPrivateEndpointConnectionsClient) EXPECT() *MockDocumentDBPrivateEndpointConnectionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockDocumentDBPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName, accountName, privateEndpointConnectionName string) (armcosmos.PrivateEndpointConnectionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, privateEndpointConnectionName) ret0, _ := ret[0].(armcosmos.PrivateEndpointConnectionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockDocumentDBPrivateEndpointConnectionsClientMockRecorder) Get(ctx, resourceGroupName, accountName, privateEndpointConnectionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDocumentDBPrivateEndpointConnectionsClient)(nil).Get), ctx, resourceGroupName, accountName, privateEndpointConnectionName) } // ListByDatabaseAccount mocks base method. func (m *MockDocumentDBPrivateEndpointConnectionsClient) ListByDatabaseAccount(ctx context.Context, resourceGroupName, accountName string) clients.DocumentDBPrivateEndpointConnectionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByDatabaseAccount", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.DocumentDBPrivateEndpointConnectionsPager) return ret0 } // ListByDatabaseAccount indicates an expected call of ListByDatabaseAccount. func (mr *MockDocumentDBPrivateEndpointConnectionsClientMockRecorder) ListByDatabaseAccount(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByDatabaseAccount", reflect.TypeOf((*MockDocumentDBPrivateEndpointConnectionsClient)(nil).ListByDatabaseAccount), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_elastic_san_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: elastic-san-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_elastic_san_client.go -package=mocks -source=elastic-san-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armelasticsan "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockElasticSanClient is a mock of ElasticSanClient interface. type MockElasticSanClient struct { ctrl *gomock.Controller recorder *MockElasticSanClientMockRecorder isgomock struct{} } // MockElasticSanClientMockRecorder is the mock recorder for MockElasticSanClient. type MockElasticSanClientMockRecorder struct { mock *MockElasticSanClient } // NewMockElasticSanClient creates a new mock instance. func NewMockElasticSanClient(ctrl *gomock.Controller) *MockElasticSanClient { mock := &MockElasticSanClient{ctrl: ctrl} mock.recorder = &MockElasticSanClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockElasticSanClient) EXPECT() *MockElasticSanClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockElasticSanClient) Get(ctx context.Context, resourceGroupName, elasticSanName string, options *armelasticsan.ElasticSansClientGetOptions) (armelasticsan.ElasticSansClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, elasticSanName, options) ret0, _ := ret[0].(armelasticsan.ElasticSansClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockElasticSanClientMockRecorder) Get(ctx, resourceGroupName, elasticSanName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockElasticSanClient)(nil).Get), ctx, resourceGroupName, elasticSanName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockElasticSanClient) NewListByResourceGroupPager(resourceGroupName string, options *armelasticsan.ElasticSansClientListByResourceGroupOptions) clients.ElasticSanPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.ElasticSanPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockElasticSanClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockElasticSanClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_elastic_san_volume_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: elastic-san-volume-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_elastic_san_volume_client.go -package=mocks -source=elastic-san-volume-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armelasticsan "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockElasticSanVolumeClient is a mock of ElasticSanVolumeClient interface. type MockElasticSanVolumeClient struct { ctrl *gomock.Controller recorder *MockElasticSanVolumeClientMockRecorder isgomock struct{} } // MockElasticSanVolumeClientMockRecorder is the mock recorder for MockElasticSanVolumeClient. type MockElasticSanVolumeClientMockRecorder struct { mock *MockElasticSanVolumeClient } // NewMockElasticSanVolumeClient creates a new mock instance. func NewMockElasticSanVolumeClient(ctrl *gomock.Controller) *MockElasticSanVolumeClient { mock := &MockElasticSanVolumeClient{ctrl: ctrl} mock.recorder = &MockElasticSanVolumeClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockElasticSanVolumeClient) EXPECT() *MockElasticSanVolumeClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockElasticSanVolumeClient) Get(ctx context.Context, resourceGroupName, elasticSanName, volumeGroupName, volumeName string, options *armelasticsan.VolumesClientGetOptions) (armelasticsan.VolumesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, options) ret0, _ := ret[0].(armelasticsan.VolumesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockElasticSanVolumeClientMockRecorder) Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockElasticSanVolumeClient)(nil).Get), ctx, resourceGroupName, elasticSanName, volumeGroupName, volumeName, options) } // NewListByVolumeGroupPager mocks base method. func (m *MockElasticSanVolumeClient) NewListByVolumeGroupPager(resourceGroupName, elasticSanName, volumeGroupName string, options *armelasticsan.VolumesClientListByVolumeGroupOptions) clients.ElasticSanVolumePager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByVolumeGroupPager", resourceGroupName, elasticSanName, volumeGroupName, options) ret0, _ := ret[0].(clients.ElasticSanVolumePager) return ret0 } // NewListByVolumeGroupPager indicates an expected call of NewListByVolumeGroupPager. func (mr *MockElasticSanVolumeClientMockRecorder) NewListByVolumeGroupPager(resourceGroupName, elasticSanName, volumeGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByVolumeGroupPager", reflect.TypeOf((*MockElasticSanVolumeClient)(nil).NewListByVolumeGroupPager), resourceGroupName, elasticSanName, volumeGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_elastic_san_volume_group_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: elastic-san-volume-group-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_elastic_san_volume_group_client.go -package=mocks -source=elastic-san-volume-group-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armelasticsan "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockElasticSanVolumeGroupClient is a mock of ElasticSanVolumeGroupClient interface. type MockElasticSanVolumeGroupClient struct { ctrl *gomock.Controller recorder *MockElasticSanVolumeGroupClientMockRecorder isgomock struct{} } // MockElasticSanVolumeGroupClientMockRecorder is the mock recorder for MockElasticSanVolumeGroupClient. type MockElasticSanVolumeGroupClientMockRecorder struct { mock *MockElasticSanVolumeGroupClient } // NewMockElasticSanVolumeGroupClient creates a new mock instance. func NewMockElasticSanVolumeGroupClient(ctrl *gomock.Controller) *MockElasticSanVolumeGroupClient { mock := &MockElasticSanVolumeGroupClient{ctrl: ctrl} mock.recorder = &MockElasticSanVolumeGroupClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockElasticSanVolumeGroupClient) EXPECT() *MockElasticSanVolumeGroupClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockElasticSanVolumeGroupClient) Get(ctx context.Context, resourceGroupName, elasticSanName, volumeGroupName string, options *armelasticsan.VolumeGroupsClientGetOptions) (armelasticsan.VolumeGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, elasticSanName, volumeGroupName, options) ret0, _ := ret[0].(armelasticsan.VolumeGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockElasticSanVolumeGroupClientMockRecorder) Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockElasticSanVolumeGroupClient)(nil).Get), ctx, resourceGroupName, elasticSanName, volumeGroupName, options) } // NewListByElasticSanPager mocks base method. func (m *MockElasticSanVolumeGroupClient) NewListByElasticSanPager(resourceGroupName, elasticSanName string, options *armelasticsan.VolumeGroupsClientListByElasticSanOptions) clients.ElasticSanVolumeGroupPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByElasticSanPager", resourceGroupName, elasticSanName, options) ret0, _ := ret[0].(clients.ElasticSanVolumeGroupPager) return ret0 } // NewListByElasticSanPager indicates an expected call of NewListByElasticSanPager. func (mr *MockElasticSanVolumeGroupClientMockRecorder) NewListByElasticSanPager(resourceGroupName, elasticSanName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByElasticSanPager", reflect.TypeOf((*MockElasticSanVolumeGroupClient)(nil).NewListByElasticSanPager), resourceGroupName, elasticSanName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_elastic_san_volume_snapshot_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: elastic-san-volume-snapshot-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_elastic_san_volume_snapshot_client.go -package=mocks -source=elastic-san-volume-snapshot-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armelasticsan "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockElasticSanVolumeSnapshotClient is a mock of ElasticSanVolumeSnapshotClient interface. type MockElasticSanVolumeSnapshotClient struct { ctrl *gomock.Controller recorder *MockElasticSanVolumeSnapshotClientMockRecorder isgomock struct{} } // MockElasticSanVolumeSnapshotClientMockRecorder is the mock recorder for MockElasticSanVolumeSnapshotClient. type MockElasticSanVolumeSnapshotClientMockRecorder struct { mock *MockElasticSanVolumeSnapshotClient } // NewMockElasticSanVolumeSnapshotClient creates a new mock instance. func NewMockElasticSanVolumeSnapshotClient(ctrl *gomock.Controller) *MockElasticSanVolumeSnapshotClient { mock := &MockElasticSanVolumeSnapshotClient{ctrl: ctrl} mock.recorder = &MockElasticSanVolumeSnapshotClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockElasticSanVolumeSnapshotClient) EXPECT() *MockElasticSanVolumeSnapshotClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockElasticSanVolumeSnapshotClient) Get(ctx context.Context, resourceGroupName, elasticSanName, volumeGroupName, snapshotName string, options *armelasticsan.VolumeSnapshotsClientGetOptions) (armelasticsan.VolumeSnapshotsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options) ret0, _ := ret[0].(armelasticsan.VolumeSnapshotsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockElasticSanVolumeSnapshotClientMockRecorder) Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockElasticSanVolumeSnapshotClient)(nil).Get), ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options) } // ListByVolumeGroup mocks base method. func (m *MockElasticSanVolumeSnapshotClient) ListByVolumeGroup(ctx context.Context, resourceGroupName, elasticSanName, volumeGroupName string, options *armelasticsan.VolumeSnapshotsClientListByVolumeGroupOptions) clients.ElasticSanVolumeSnapshotPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByVolumeGroup", ctx, resourceGroupName, elasticSanName, volumeGroupName, options) ret0, _ := ret[0].(clients.ElasticSanVolumeSnapshotPager) return ret0 } // ListByVolumeGroup indicates an expected call of ListByVolumeGroup. func (mr *MockElasticSanVolumeSnapshotClientMockRecorder) ListByVolumeGroup(ctx, resourceGroupName, elasticSanName, volumeGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByVolumeGroup", reflect.TypeOf((*MockElasticSanVolumeSnapshotClient)(nil).ListByVolumeGroup), ctx, resourceGroupName, elasticSanName, volumeGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_encryption_scopes_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: encryption-scopes-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_encryption_scopes_client.go -package=mocks -source=encryption-scopes-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockEncryptionScopesClient is a mock of EncryptionScopesClient interface. type MockEncryptionScopesClient struct { ctrl *gomock.Controller recorder *MockEncryptionScopesClientMockRecorder isgomock struct{} } // MockEncryptionScopesClientMockRecorder is the mock recorder for MockEncryptionScopesClient. type MockEncryptionScopesClientMockRecorder struct { mock *MockEncryptionScopesClient } // NewMockEncryptionScopesClient creates a new mock instance. func NewMockEncryptionScopesClient(ctrl *gomock.Controller) *MockEncryptionScopesClient { mock := &MockEncryptionScopesClient{ctrl: ctrl} mock.recorder = &MockEncryptionScopesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockEncryptionScopesClient) EXPECT() *MockEncryptionScopesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockEncryptionScopesClient) Get(ctx context.Context, resourceGroupName, accountName, encryptionScopeName string) (armstorage.EncryptionScopesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, encryptionScopeName) ret0, _ := ret[0].(armstorage.EncryptionScopesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockEncryptionScopesClientMockRecorder) Get(ctx, resourceGroupName, accountName, encryptionScopeName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockEncryptionScopesClient)(nil).Get), ctx, resourceGroupName, accountName, encryptionScopeName) } // List mocks base method. func (m *MockEncryptionScopesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.EncryptionScopesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.EncryptionScopesPager) return ret0 } // List indicates an expected call of List. func (mr *MockEncryptionScopesClientMockRecorder) List(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockEncryptionScopesClient)(nil).List), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_federated_identity_credentials_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: federated-identity-credentials-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_federated_identity_credentials_client.go -package=mocks -source=federated-identity-credentials-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armmsi "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockFederatedIdentityCredentialsClient is a mock of FederatedIdentityCredentialsClient interface. type MockFederatedIdentityCredentialsClient struct { ctrl *gomock.Controller recorder *MockFederatedIdentityCredentialsClientMockRecorder isgomock struct{} } // MockFederatedIdentityCredentialsClientMockRecorder is the mock recorder for MockFederatedIdentityCredentialsClient. type MockFederatedIdentityCredentialsClientMockRecorder struct { mock *MockFederatedIdentityCredentialsClient } // NewMockFederatedIdentityCredentialsClient creates a new mock instance. func NewMockFederatedIdentityCredentialsClient(ctrl *gomock.Controller) *MockFederatedIdentityCredentialsClient { mock := &MockFederatedIdentityCredentialsClient{ctrl: ctrl} mock.recorder = &MockFederatedIdentityCredentialsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockFederatedIdentityCredentialsClient) EXPECT() *MockFederatedIdentityCredentialsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockFederatedIdentityCredentialsClient) Get(ctx context.Context, resourceGroupName, resourceName, federatedIdentityCredentialResourceName string, options *armmsi.FederatedIdentityCredentialsClientGetOptions) (armmsi.FederatedIdentityCredentialsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, resourceName, federatedIdentityCredentialResourceName, options) ret0, _ := ret[0].(armmsi.FederatedIdentityCredentialsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockFederatedIdentityCredentialsClientMockRecorder) Get(ctx, resourceGroupName, resourceName, federatedIdentityCredentialResourceName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockFederatedIdentityCredentialsClient)(nil).Get), ctx, resourceGroupName, resourceName, federatedIdentityCredentialResourceName, options) } // NewListPager mocks base method. func (m *MockFederatedIdentityCredentialsClient) NewListPager(resourceGroupName, resourceName string, options *armmsi.FederatedIdentityCredentialsClientListOptions) clients.FederatedIdentityCredentialsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, resourceName, options) ret0, _ := ret[0].(clients.FederatedIdentityCredentialsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockFederatedIdentityCredentialsClientMockRecorder) NewListPager(resourceGroupName, resourceName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockFederatedIdentityCredentialsClient)(nil).NewListPager), resourceGroupName, resourceName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_file_shares_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: fileshares-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_file_shares_client.go -package=mocks -source=fileshares-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockFileSharesClient is a mock of FileSharesClient interface. type MockFileSharesClient struct { ctrl *gomock.Controller recorder *MockFileSharesClientMockRecorder isgomock struct{} } // MockFileSharesClientMockRecorder is the mock recorder for MockFileSharesClient. type MockFileSharesClientMockRecorder struct { mock *MockFileSharesClient } // NewMockFileSharesClient creates a new mock instance. func NewMockFileSharesClient(ctrl *gomock.Controller) *MockFileSharesClient { mock := &MockFileSharesClient{ctrl: ctrl} mock.recorder = &MockFileSharesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockFileSharesClient) EXPECT() *MockFileSharesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockFileSharesClient) Get(ctx context.Context, resourceGroupName, accountName, shareName string) (armstorage.FileSharesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, shareName) ret0, _ := ret[0].(armstorage.FileSharesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockFileSharesClientMockRecorder) Get(ctx, resourceGroupName, accountName, shareName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockFileSharesClient)(nil).Get), ctx, resourceGroupName, accountName, shareName) } // List mocks base method. func (m *MockFileSharesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.FileSharesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.FileSharesPager) return ret0 } // List indicates an expected call of List. func (mr *MockFileSharesClientMockRecorder) List(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFileSharesClient)(nil).List), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_flow_logs_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: flow-logs-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_flow_logs_client.go -package=mocks -source=flow-logs-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockFlowLogsClient is a mock of FlowLogsClient interface. type MockFlowLogsClient struct { ctrl *gomock.Controller recorder *MockFlowLogsClientMockRecorder isgomock struct{} } // MockFlowLogsClientMockRecorder is the mock recorder for MockFlowLogsClient. type MockFlowLogsClientMockRecorder struct { mock *MockFlowLogsClient } // NewMockFlowLogsClient creates a new mock instance. func NewMockFlowLogsClient(ctrl *gomock.Controller) *MockFlowLogsClient { mock := &MockFlowLogsClient{ctrl: ctrl} mock.recorder = &MockFlowLogsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockFlowLogsClient) EXPECT() *MockFlowLogsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockFlowLogsClient) Get(ctx context.Context, resourceGroupName, networkWatcherName, flowLogName string, options *armnetwork.FlowLogsClientGetOptions) (armnetwork.FlowLogsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkWatcherName, flowLogName, options) ret0, _ := ret[0].(armnetwork.FlowLogsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockFlowLogsClientMockRecorder) Get(ctx, resourceGroupName, networkWatcherName, flowLogName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockFlowLogsClient)(nil).Get), ctx, resourceGroupName, networkWatcherName, flowLogName, options) } // NewListPager mocks base method. func (m *MockFlowLogsClient) NewListPager(resourceGroupName, networkWatcherName string, options *armnetwork.FlowLogsClientListOptions) clients.FlowLogsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, networkWatcherName, options) ret0, _ := ret[0].(clients.FlowLogsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockFlowLogsClientMockRecorder) NewListPager(resourceGroupName, networkWatcherName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockFlowLogsClient)(nil).NewListPager), resourceGroupName, networkWatcherName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_galleries_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: galleries-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_galleries_client.go -package=mocks -source=galleries-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockGalleriesClient is a mock of GalleriesClient interface. type MockGalleriesClient struct { ctrl *gomock.Controller recorder *MockGalleriesClientMockRecorder isgomock struct{} } // MockGalleriesClientMockRecorder is the mock recorder for MockGalleriesClient. type MockGalleriesClientMockRecorder struct { mock *MockGalleriesClient } // NewMockGalleriesClient creates a new mock instance. func NewMockGalleriesClient(ctrl *gomock.Controller) *MockGalleriesClient { mock := &MockGalleriesClient{ctrl: ctrl} mock.recorder = &MockGalleriesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockGalleriesClient) EXPECT() *MockGalleriesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockGalleriesClient) Get(ctx context.Context, resourceGroupName, galleryName string, options *armcompute.GalleriesClientGetOptions) (armcompute.GalleriesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, galleryName, options) ret0, _ := ret[0].(armcompute.GalleriesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockGalleriesClientMockRecorder) Get(ctx, resourceGroupName, galleryName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockGalleriesClient)(nil).Get), ctx, resourceGroupName, galleryName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockGalleriesClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.GalleriesClientListByResourceGroupOptions) clients.GalleriesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.GalleriesPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockGalleriesClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockGalleriesClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_gallery_application_versions_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: gallery-application-versions-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_gallery_application_versions_client.go -package=mocks -source=gallery-application-versions-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockGalleryApplicationVersionsClient is a mock of GalleryApplicationVersionsClient interface. type MockGalleryApplicationVersionsClient struct { ctrl *gomock.Controller recorder *MockGalleryApplicationVersionsClientMockRecorder isgomock struct{} } // MockGalleryApplicationVersionsClientMockRecorder is the mock recorder for MockGalleryApplicationVersionsClient. type MockGalleryApplicationVersionsClientMockRecorder struct { mock *MockGalleryApplicationVersionsClient } // NewMockGalleryApplicationVersionsClient creates a new mock instance. func NewMockGalleryApplicationVersionsClient(ctrl *gomock.Controller) *MockGalleryApplicationVersionsClient { mock := &MockGalleryApplicationVersionsClient{ctrl: ctrl} mock.recorder = &MockGalleryApplicationVersionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockGalleryApplicationVersionsClient) EXPECT() *MockGalleryApplicationVersionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockGalleryApplicationVersionsClient) Get(ctx context.Context, resourceGroupName, galleryName, galleryApplicationName, galleryApplicationVersionName string, options *armcompute.GalleryApplicationVersionsClientGetOptions) (armcompute.GalleryApplicationVersionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, galleryName, galleryApplicationName, galleryApplicationVersionName, options) ret0, _ := ret[0].(armcompute.GalleryApplicationVersionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockGalleryApplicationVersionsClientMockRecorder) Get(ctx, resourceGroupName, galleryName, galleryApplicationName, galleryApplicationVersionName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockGalleryApplicationVersionsClient)(nil).Get), ctx, resourceGroupName, galleryName, galleryApplicationName, galleryApplicationVersionName, options) } // NewListByGalleryApplicationPager mocks base method. func (m *MockGalleryApplicationVersionsClient) NewListByGalleryApplicationPager(resourceGroupName, galleryName, galleryApplicationName string, options *armcompute.GalleryApplicationVersionsClientListByGalleryApplicationOptions) clients.GalleryApplicationVersionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByGalleryApplicationPager", resourceGroupName, galleryName, galleryApplicationName, options) ret0, _ := ret[0].(clients.GalleryApplicationVersionsPager) return ret0 } // NewListByGalleryApplicationPager indicates an expected call of NewListByGalleryApplicationPager. func (mr *MockGalleryApplicationVersionsClientMockRecorder) NewListByGalleryApplicationPager(resourceGroupName, galleryName, galleryApplicationName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByGalleryApplicationPager", reflect.TypeOf((*MockGalleryApplicationVersionsClient)(nil).NewListByGalleryApplicationPager), resourceGroupName, galleryName, galleryApplicationName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_gallery_applications_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: gallery-applications-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_gallery_applications_client.go -package=mocks -source=gallery-applications-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockGalleryApplicationsClient is a mock of GalleryApplicationsClient interface. type MockGalleryApplicationsClient struct { ctrl *gomock.Controller recorder *MockGalleryApplicationsClientMockRecorder isgomock struct{} } // MockGalleryApplicationsClientMockRecorder is the mock recorder for MockGalleryApplicationsClient. type MockGalleryApplicationsClientMockRecorder struct { mock *MockGalleryApplicationsClient } // NewMockGalleryApplicationsClient creates a new mock instance. func NewMockGalleryApplicationsClient(ctrl *gomock.Controller) *MockGalleryApplicationsClient { mock := &MockGalleryApplicationsClient{ctrl: ctrl} mock.recorder = &MockGalleryApplicationsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockGalleryApplicationsClient) EXPECT() *MockGalleryApplicationsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockGalleryApplicationsClient) Get(ctx context.Context, resourceGroupName, galleryName, galleryApplicationName string, options *armcompute.GalleryApplicationsClientGetOptions) (armcompute.GalleryApplicationsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, galleryName, galleryApplicationName, options) ret0, _ := ret[0].(armcompute.GalleryApplicationsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockGalleryApplicationsClientMockRecorder) Get(ctx, resourceGroupName, galleryName, galleryApplicationName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockGalleryApplicationsClient)(nil).Get), ctx, resourceGroupName, galleryName, galleryApplicationName, options) } // NewListByGalleryPager mocks base method. func (m *MockGalleryApplicationsClient) NewListByGalleryPager(resourceGroupName, galleryName string, options *armcompute.GalleryApplicationsClientListByGalleryOptions) clients.GalleryApplicationsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByGalleryPager", resourceGroupName, galleryName, options) ret0, _ := ret[0].(clients.GalleryApplicationsPager) return ret0 } // NewListByGalleryPager indicates an expected call of NewListByGalleryPager. func (mr *MockGalleryApplicationsClientMockRecorder) NewListByGalleryPager(resourceGroupName, galleryName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByGalleryPager", reflect.TypeOf((*MockGalleryApplicationsClient)(nil).NewListByGalleryPager), resourceGroupName, galleryName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_gallery_images_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: gallery-images-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_gallery_images_client.go -package=mocks -source=gallery-images-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockGalleryImagesClient is a mock of GalleryImagesClient interface. type MockGalleryImagesClient struct { ctrl *gomock.Controller recorder *MockGalleryImagesClientMockRecorder isgomock struct{} } // MockGalleryImagesClientMockRecorder is the mock recorder for MockGalleryImagesClient. type MockGalleryImagesClientMockRecorder struct { mock *MockGalleryImagesClient } // NewMockGalleryImagesClient creates a new mock instance. func NewMockGalleryImagesClient(ctrl *gomock.Controller) *MockGalleryImagesClient { mock := &MockGalleryImagesClient{ctrl: ctrl} mock.recorder = &MockGalleryImagesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockGalleryImagesClient) EXPECT() *MockGalleryImagesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockGalleryImagesClient) Get(ctx context.Context, resourceGroupName, galleryName, galleryImageName string, options *armcompute.GalleryImagesClientGetOptions) (armcompute.GalleryImagesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, galleryName, galleryImageName, options) ret0, _ := ret[0].(armcompute.GalleryImagesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockGalleryImagesClientMockRecorder) Get(ctx, resourceGroupName, galleryName, galleryImageName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockGalleryImagesClient)(nil).Get), ctx, resourceGroupName, galleryName, galleryImageName, options) } // NewListByGalleryPager mocks base method. func (m *MockGalleryImagesClient) NewListByGalleryPager(resourceGroupName, galleryName string, options *armcompute.GalleryImagesClientListByGalleryOptions) clients.GalleryImagesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByGalleryPager", resourceGroupName, galleryName, options) ret0, _ := ret[0].(clients.GalleryImagesPager) return ret0 } // NewListByGalleryPager indicates an expected call of NewListByGalleryPager. func (mr *MockGalleryImagesClientMockRecorder) NewListByGalleryPager(resourceGroupName, galleryName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByGalleryPager", reflect.TypeOf((*MockGalleryImagesClient)(nil).NewListByGalleryPager), resourceGroupName, galleryName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_images_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: images-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_images_client.go -package=mocks -source=images-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockImagesClient is a mock of ImagesClient interface. type MockImagesClient struct { ctrl *gomock.Controller recorder *MockImagesClientMockRecorder isgomock struct{} } // MockImagesClientMockRecorder is the mock recorder for MockImagesClient. type MockImagesClientMockRecorder struct { mock *MockImagesClient } // NewMockImagesClient creates a new mock instance. func NewMockImagesClient(ctrl *gomock.Controller) *MockImagesClient { mock := &MockImagesClient{ctrl: ctrl} mock.recorder = &MockImagesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockImagesClient) EXPECT() *MockImagesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockImagesClient) Get(ctx context.Context, resourceGroupName, imageName string, options *armcompute.ImagesClientGetOptions) (armcompute.ImagesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, imageName, options) ret0, _ := ret[0].(armcompute.ImagesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockImagesClientMockRecorder) Get(ctx, resourceGroupName, imageName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockImagesClient)(nil).Get), ctx, resourceGroupName, imageName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockImagesClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.ImagesClientListByResourceGroupOptions) clients.ImagesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.ImagesPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockImagesClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockImagesClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_interface_ip_configurations_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: interface-ip-configurations-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_interface_ip_configurations_client.go -package=mocks -source=interface-ip-configurations-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockInterfaceIPConfigurationsClient is a mock of InterfaceIPConfigurationsClient interface. type MockInterfaceIPConfigurationsClient struct { ctrl *gomock.Controller recorder *MockInterfaceIPConfigurationsClientMockRecorder isgomock struct{} } // MockInterfaceIPConfigurationsClientMockRecorder is the mock recorder for MockInterfaceIPConfigurationsClient. type MockInterfaceIPConfigurationsClientMockRecorder struct { mock *MockInterfaceIPConfigurationsClient } // NewMockInterfaceIPConfigurationsClient creates a new mock instance. func NewMockInterfaceIPConfigurationsClient(ctrl *gomock.Controller) *MockInterfaceIPConfigurationsClient { mock := &MockInterfaceIPConfigurationsClient{ctrl: ctrl} mock.recorder = &MockInterfaceIPConfigurationsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockInterfaceIPConfigurationsClient) EXPECT() *MockInterfaceIPConfigurationsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockInterfaceIPConfigurationsClient) Get(ctx context.Context, resourceGroupName, networkInterfaceName, ipConfigurationName string) (armnetwork.InterfaceIPConfigurationsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkInterfaceName, ipConfigurationName) ret0, _ := ret[0].(armnetwork.InterfaceIPConfigurationsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockInterfaceIPConfigurationsClientMockRecorder) Get(ctx, resourceGroupName, networkInterfaceName, ipConfigurationName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockInterfaceIPConfigurationsClient)(nil).Get), ctx, resourceGroupName, networkInterfaceName, ipConfigurationName) } // List mocks base method. func (m *MockInterfaceIPConfigurationsClient) List(ctx context.Context, resourceGroupName, networkInterfaceName string) clients.InterfaceIPConfigurationsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, networkInterfaceName) ret0, _ := ret[0].(clients.InterfaceIPConfigurationsPager) return ret0 } // List indicates an expected call of List. func (mr *MockInterfaceIPConfigurationsClientMockRecorder) List(ctx, resourceGroupName, networkInterfaceName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockInterfaceIPConfigurationsClient)(nil).List), ctx, resourceGroupName, networkInterfaceName) } ================================================ FILE: sources/azure/shared/mocks/mock_ip_groups_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: ip-groups-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_ip_groups_client.go -package=mocks -source=ip-groups-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockIPGroupsClient is a mock of IPGroupsClient interface. type MockIPGroupsClient struct { ctrl *gomock.Controller recorder *MockIPGroupsClientMockRecorder isgomock struct{} } // MockIPGroupsClientMockRecorder is the mock recorder for MockIPGroupsClient. type MockIPGroupsClientMockRecorder struct { mock *MockIPGroupsClient } // NewMockIPGroupsClient creates a new mock instance. func NewMockIPGroupsClient(ctrl *gomock.Controller) *MockIPGroupsClient { mock := &MockIPGroupsClient{ctrl: ctrl} mock.recorder = &MockIPGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockIPGroupsClient) EXPECT() *MockIPGroupsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockIPGroupsClient) Get(ctx context.Context, resourceGroupName, ipGroupsName string, options *armnetwork.IPGroupsClientGetOptions) (armnetwork.IPGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, ipGroupsName, options) ret0, _ := ret[0].(armnetwork.IPGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockIPGroupsClientMockRecorder) Get(ctx, resourceGroupName, ipGroupsName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIPGroupsClient)(nil).Get), ctx, resourceGroupName, ipGroupsName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockIPGroupsClient) NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.IPGroupsClientListByResourceGroupOptions) clients.IPGroupsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.IPGroupsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockIPGroupsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockIPGroupsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_keyvault_key_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: keyvault-key-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_keyvault_key_client.go -package=mocks -source=keyvault-key-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armkeyvault "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockKeysClient is a mock of KeysClient interface. type MockKeysClient struct { ctrl *gomock.Controller recorder *MockKeysClientMockRecorder isgomock struct{} } // MockKeysClientMockRecorder is the mock recorder for MockKeysClient. type MockKeysClientMockRecorder struct { mock *MockKeysClient } // NewMockKeysClient creates a new mock instance. func NewMockKeysClient(ctrl *gomock.Controller) *MockKeysClient { mock := &MockKeysClient{ctrl: ctrl} mock.recorder = &MockKeysClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockKeysClient) EXPECT() *MockKeysClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockKeysClient) Get(ctx context.Context, resourceGroupName, vaultName, keyName string, options *armkeyvault.KeysClientGetOptions) (armkeyvault.KeysClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, vaultName, keyName, options) ret0, _ := ret[0].(armkeyvault.KeysClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockKeysClientMockRecorder) Get(ctx, resourceGroupName, vaultName, keyName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockKeysClient)(nil).Get), ctx, resourceGroupName, vaultName, keyName, options) } // NewListPager mocks base method. func (m *MockKeysClient) NewListPager(resourceGroupName, vaultName string, options *armkeyvault.KeysClientListOptions) clients.KeysPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, vaultName, options) ret0, _ := ret[0].(clients.KeysPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockKeysClientMockRecorder) NewListPager(resourceGroupName, vaultName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockKeysClient)(nil).NewListPager), resourceGroupName, vaultName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_keyvault_managed_hsm_private_endpoint_connection_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: keyvault-managed-hsm-private-endpoint-connection-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_keyvault_managed_hsm_private_endpoint_connection_client.go -package=mocks -source=keyvault-managed-hsm-private-endpoint-connection-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armkeyvault "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockKeyVaultManagedHSMPrivateEndpointConnectionsClient is a mock of KeyVaultManagedHSMPrivateEndpointConnectionsClient interface. type MockKeyVaultManagedHSMPrivateEndpointConnectionsClient struct { ctrl *gomock.Controller recorder *MockKeyVaultManagedHSMPrivateEndpointConnectionsClientMockRecorder isgomock struct{} } // MockKeyVaultManagedHSMPrivateEndpointConnectionsClientMockRecorder is the mock recorder for MockKeyVaultManagedHSMPrivateEndpointConnectionsClient. type MockKeyVaultManagedHSMPrivateEndpointConnectionsClientMockRecorder struct { mock *MockKeyVaultManagedHSMPrivateEndpointConnectionsClient } // NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient creates a new mock instance. func NewMockKeyVaultManagedHSMPrivateEndpointConnectionsClient(ctrl *gomock.Controller) *MockKeyVaultManagedHSMPrivateEndpointConnectionsClient { mock := &MockKeyVaultManagedHSMPrivateEndpointConnectionsClient{ctrl: ctrl} mock.recorder = &MockKeyVaultManagedHSMPrivateEndpointConnectionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockKeyVaultManagedHSMPrivateEndpointConnectionsClient) EXPECT() *MockKeyVaultManagedHSMPrivateEndpointConnectionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockKeyVaultManagedHSMPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName, hsmName, privateEndpointConnectionName string) (armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, hsmName, privateEndpointConnectionName) ret0, _ := ret[0].(armkeyvault.MHSMPrivateEndpointConnectionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockKeyVaultManagedHSMPrivateEndpointConnectionsClientMockRecorder) Get(ctx, resourceGroupName, hsmName, privateEndpointConnectionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockKeyVaultManagedHSMPrivateEndpointConnectionsClient)(nil).Get), ctx, resourceGroupName, hsmName, privateEndpointConnectionName) } // ListByResource mocks base method. func (m *MockKeyVaultManagedHSMPrivateEndpointConnectionsClient) ListByResource(ctx context.Context, resourceGroupName, hsmName string) clients.KeyVaultManagedHSMPrivateEndpointConnectionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByResource", ctx, resourceGroupName, hsmName) ret0, _ := ret[0].(clients.KeyVaultManagedHSMPrivateEndpointConnectionsPager) return ret0 } // ListByResource indicates an expected call of ListByResource. func (mr *MockKeyVaultManagedHSMPrivateEndpointConnectionsClientMockRecorder) ListByResource(ctx, resourceGroupName, hsmName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByResource", reflect.TypeOf((*MockKeyVaultManagedHSMPrivateEndpointConnectionsClient)(nil).ListByResource), ctx, resourceGroupName, hsmName) } ================================================ FILE: sources/azure/shared/mocks/mock_load_balancer_backend_address_pools_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: load-balancer-backend-address-pools-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_load_balancer_backend_address_pools_client.go -package=mocks -source=load-balancer-backend-address-pools-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockLoadBalancerBackendAddressPoolsClient is a mock of LoadBalancerBackendAddressPoolsClient interface. type MockLoadBalancerBackendAddressPoolsClient struct { ctrl *gomock.Controller recorder *MockLoadBalancerBackendAddressPoolsClientMockRecorder isgomock struct{} } // MockLoadBalancerBackendAddressPoolsClientMockRecorder is the mock recorder for MockLoadBalancerBackendAddressPoolsClient. type MockLoadBalancerBackendAddressPoolsClientMockRecorder struct { mock *MockLoadBalancerBackendAddressPoolsClient } // NewMockLoadBalancerBackendAddressPoolsClient creates a new mock instance. func NewMockLoadBalancerBackendAddressPoolsClient(ctrl *gomock.Controller) *MockLoadBalancerBackendAddressPoolsClient { mock := &MockLoadBalancerBackendAddressPoolsClient{ctrl: ctrl} mock.recorder = &MockLoadBalancerBackendAddressPoolsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLoadBalancerBackendAddressPoolsClient) EXPECT() *MockLoadBalancerBackendAddressPoolsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockLoadBalancerBackendAddressPoolsClient) Get(ctx context.Context, resourceGroupName, loadBalancerName, backendAddressPoolName string) (armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, loadBalancerName, backendAddressPoolName) ret0, _ := ret[0].(armnetwork.LoadBalancerBackendAddressPoolsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockLoadBalancerBackendAddressPoolsClientMockRecorder) Get(ctx, resourceGroupName, loadBalancerName, backendAddressPoolName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockLoadBalancerBackendAddressPoolsClient)(nil).Get), ctx, resourceGroupName, loadBalancerName, backendAddressPoolName) } // NewListPager mocks base method. func (m *MockLoadBalancerBackendAddressPoolsClient) NewListPager(resourceGroupName, loadBalancerName string) clients.LoadBalancerBackendAddressPoolsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, loadBalancerName) ret0, _ := ret[0].(clients.LoadBalancerBackendAddressPoolsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockLoadBalancerBackendAddressPoolsClientMockRecorder) NewListPager(resourceGroupName, loadBalancerName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockLoadBalancerBackendAddressPoolsClient)(nil).NewListPager), resourceGroupName, loadBalancerName) } ================================================ FILE: sources/azure/shared/mocks/mock_load_balancer_frontend_ip_configurations_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: load-balancer-frontend-ip-configurations-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_load_balancer_frontend_ip_configurations_client.go -package=mocks -source=load-balancer-frontend-ip-configurations-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockLoadBalancerFrontendIPConfigurationsClient is a mock of LoadBalancerFrontendIPConfigurationsClient interface. type MockLoadBalancerFrontendIPConfigurationsClient struct { ctrl *gomock.Controller recorder *MockLoadBalancerFrontendIPConfigurationsClientMockRecorder isgomock struct{} } // MockLoadBalancerFrontendIPConfigurationsClientMockRecorder is the mock recorder for MockLoadBalancerFrontendIPConfigurationsClient. type MockLoadBalancerFrontendIPConfigurationsClientMockRecorder struct { mock *MockLoadBalancerFrontendIPConfigurationsClient } // NewMockLoadBalancerFrontendIPConfigurationsClient creates a new mock instance. func NewMockLoadBalancerFrontendIPConfigurationsClient(ctrl *gomock.Controller) *MockLoadBalancerFrontendIPConfigurationsClient { mock := &MockLoadBalancerFrontendIPConfigurationsClient{ctrl: ctrl} mock.recorder = &MockLoadBalancerFrontendIPConfigurationsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLoadBalancerFrontendIPConfigurationsClient) EXPECT() *MockLoadBalancerFrontendIPConfigurationsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockLoadBalancerFrontendIPConfigurationsClient) Get(ctx context.Context, resourceGroupName, loadBalancerName, frontendIPConfigurationName string) (armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, loadBalancerName, frontendIPConfigurationName) ret0, _ := ret[0].(armnetwork.LoadBalancerFrontendIPConfigurationsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockLoadBalancerFrontendIPConfigurationsClientMockRecorder) Get(ctx, resourceGroupName, loadBalancerName, frontendIPConfigurationName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockLoadBalancerFrontendIPConfigurationsClient)(nil).Get), ctx, resourceGroupName, loadBalancerName, frontendIPConfigurationName) } // NewListPager mocks base method. func (m *MockLoadBalancerFrontendIPConfigurationsClient) NewListPager(resourceGroupName, loadBalancerName string) clients.LoadBalancerFrontendIPConfigurationsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, loadBalancerName) ret0, _ := ret[0].(clients.LoadBalancerFrontendIPConfigurationsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockLoadBalancerFrontendIPConfigurationsClientMockRecorder) NewListPager(resourceGroupName, loadBalancerName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockLoadBalancerFrontendIPConfigurationsClient)(nil).NewListPager), resourceGroupName, loadBalancerName) } ================================================ FILE: sources/azure/shared/mocks/mock_load_balancer_probes_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: load-balancer-probes-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_load_balancer_probes_client.go -package=mocks -source=load-balancer-probes-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockLoadBalancerProbesClient is a mock of LoadBalancerProbesClient interface. type MockLoadBalancerProbesClient struct { ctrl *gomock.Controller recorder *MockLoadBalancerProbesClientMockRecorder isgomock struct{} } // MockLoadBalancerProbesClientMockRecorder is the mock recorder for MockLoadBalancerProbesClient. type MockLoadBalancerProbesClientMockRecorder struct { mock *MockLoadBalancerProbesClient } // NewMockLoadBalancerProbesClient creates a new mock instance. func NewMockLoadBalancerProbesClient(ctrl *gomock.Controller) *MockLoadBalancerProbesClient { mock := &MockLoadBalancerProbesClient{ctrl: ctrl} mock.recorder = &MockLoadBalancerProbesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLoadBalancerProbesClient) EXPECT() *MockLoadBalancerProbesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockLoadBalancerProbesClient) Get(ctx context.Context, resourceGroupName, loadBalancerName, probeName string) (armnetwork.LoadBalancerProbesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, loadBalancerName, probeName) ret0, _ := ret[0].(armnetwork.LoadBalancerProbesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockLoadBalancerProbesClientMockRecorder) Get(ctx, resourceGroupName, loadBalancerName, probeName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockLoadBalancerProbesClient)(nil).Get), ctx, resourceGroupName, loadBalancerName, probeName) } // NewListPager mocks base method. func (m *MockLoadBalancerProbesClient) NewListPager(resourceGroupName, loadBalancerName string) clients.LoadBalancerProbesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, loadBalancerName) ret0, _ := ret[0].(clients.LoadBalancerProbesPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockLoadBalancerProbesClientMockRecorder) NewListPager(resourceGroupName, loadBalancerName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockLoadBalancerProbesClient)(nil).NewListPager), resourceGroupName, loadBalancerName) } ================================================ FILE: sources/azure/shared/mocks/mock_load_balancers_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: load-balancers-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_load_balancers_client.go -package=mocks -source=load-balancers-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockLoadBalancersClient is a mock of LoadBalancersClient interface. type MockLoadBalancersClient struct { ctrl *gomock.Controller recorder *MockLoadBalancersClientMockRecorder isgomock struct{} } // MockLoadBalancersClientMockRecorder is the mock recorder for MockLoadBalancersClient. type MockLoadBalancersClientMockRecorder struct { mock *MockLoadBalancersClient } // NewMockLoadBalancersClient creates a new mock instance. func NewMockLoadBalancersClient(ctrl *gomock.Controller) *MockLoadBalancersClient { mock := &MockLoadBalancersClient{ctrl: ctrl} mock.recorder = &MockLoadBalancersClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLoadBalancersClient) EXPECT() *MockLoadBalancersClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockLoadBalancersClient) Get(ctx context.Context, resourceGroupName, loadBalancerName string) (armnetwork.LoadBalancersClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, loadBalancerName) ret0, _ := ret[0].(armnetwork.LoadBalancersClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockLoadBalancersClientMockRecorder) Get(ctx, resourceGroupName, loadBalancerName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockLoadBalancersClient)(nil).Get), ctx, resourceGroupName, loadBalancerName) } // List mocks base method. func (m *MockLoadBalancersClient) List(resourceGroupName string) clients.LoadBalancersPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", resourceGroupName) ret0, _ := ret[0].(clients.LoadBalancersPager) return ret0 } // List indicates an expected call of List. func (mr *MockLoadBalancersClientMockRecorder) List(resourceGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLoadBalancersClient)(nil).List), resourceGroupName) } ================================================ FILE: sources/azure/shared/mocks/mock_local_network_gateways_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: local-network-gateways-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_local_network_gateways_client.go -package=mocks -source=local-network-gateways-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockLocalNetworkGatewaysClient is a mock of LocalNetworkGatewaysClient interface. type MockLocalNetworkGatewaysClient struct { ctrl *gomock.Controller recorder *MockLocalNetworkGatewaysClientMockRecorder isgomock struct{} } // MockLocalNetworkGatewaysClientMockRecorder is the mock recorder for MockLocalNetworkGatewaysClient. type MockLocalNetworkGatewaysClientMockRecorder struct { mock *MockLocalNetworkGatewaysClient } // NewMockLocalNetworkGatewaysClient creates a new mock instance. func NewMockLocalNetworkGatewaysClient(ctrl *gomock.Controller) *MockLocalNetworkGatewaysClient { mock := &MockLocalNetworkGatewaysClient{ctrl: ctrl} mock.recorder = &MockLocalNetworkGatewaysClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLocalNetworkGatewaysClient) EXPECT() *MockLocalNetworkGatewaysClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockLocalNetworkGatewaysClient) Get(ctx context.Context, resourceGroupName, localNetworkGatewayName string, options *armnetwork.LocalNetworkGatewaysClientGetOptions) (armnetwork.LocalNetworkGatewaysClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, localNetworkGatewayName, options) ret0, _ := ret[0].(armnetwork.LocalNetworkGatewaysClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockLocalNetworkGatewaysClientMockRecorder) Get(ctx, resourceGroupName, localNetworkGatewayName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockLocalNetworkGatewaysClient)(nil).Get), ctx, resourceGroupName, localNetworkGatewayName, options) } // NewListPager mocks base method. func (m *MockLocalNetworkGatewaysClient) NewListPager(resourceGroupName string, options *armnetwork.LocalNetworkGatewaysClientListOptions) clients.LocalNetworkGatewaysPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.LocalNetworkGatewaysPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockLocalNetworkGatewaysClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockLocalNetworkGatewaysClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_maintenance_configuration_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: maintenance-configuration-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_maintenance_configuration_client.go -package=mocks -source=maintenance-configuration-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armmaintenance "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/maintenance/armmaintenance" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockMaintenanceConfigurationClient is a mock of MaintenanceConfigurationClient interface. type MockMaintenanceConfigurationClient struct { ctrl *gomock.Controller recorder *MockMaintenanceConfigurationClientMockRecorder isgomock struct{} } // MockMaintenanceConfigurationClientMockRecorder is the mock recorder for MockMaintenanceConfigurationClient. type MockMaintenanceConfigurationClientMockRecorder struct { mock *MockMaintenanceConfigurationClient } // NewMockMaintenanceConfigurationClient creates a new mock instance. func NewMockMaintenanceConfigurationClient(ctrl *gomock.Controller) *MockMaintenanceConfigurationClient { mock := &MockMaintenanceConfigurationClient{ctrl: ctrl} mock.recorder = &MockMaintenanceConfigurationClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockMaintenanceConfigurationClient) EXPECT() *MockMaintenanceConfigurationClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockMaintenanceConfigurationClient) Get(ctx context.Context, resourceGroupName, resourceName string, options *armmaintenance.ConfigurationsClientGetOptions) (armmaintenance.ConfigurationsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, resourceName, options) ret0, _ := ret[0].(armmaintenance.ConfigurationsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockMaintenanceConfigurationClientMockRecorder) Get(ctx, resourceGroupName, resourceName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockMaintenanceConfigurationClient)(nil).Get), ctx, resourceGroupName, resourceName, options) } // NewListPager mocks base method. func (m *MockMaintenanceConfigurationClient) NewListPager(resourceGroupName string, options *armmaintenance.ConfigurationsForResourceGroupClientListOptions) clients.MaintenanceConfigurationPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.MaintenanceConfigurationPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockMaintenanceConfigurationClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockMaintenanceConfigurationClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_managed_hsms_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: managed-hsms-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_managed_hsms_client.go -package=mocks -source=managed-hsms-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armkeyvault "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockManagedHSMsClient is a mock of ManagedHSMsClient interface. type MockManagedHSMsClient struct { ctrl *gomock.Controller recorder *MockManagedHSMsClientMockRecorder isgomock struct{} } // MockManagedHSMsClientMockRecorder is the mock recorder for MockManagedHSMsClient. type MockManagedHSMsClientMockRecorder struct { mock *MockManagedHSMsClient } // NewMockManagedHSMsClient creates a new mock instance. func NewMockManagedHSMsClient(ctrl *gomock.Controller) *MockManagedHSMsClient { mock := &MockManagedHSMsClient{ctrl: ctrl} mock.recorder = &MockManagedHSMsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockManagedHSMsClient) EXPECT() *MockManagedHSMsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockManagedHSMsClient) Get(ctx context.Context, resourceGroupName, name string, options *armkeyvault.ManagedHsmsClientGetOptions) (armkeyvault.ManagedHsmsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, name, options) ret0, _ := ret[0].(armkeyvault.ManagedHsmsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockManagedHSMsClientMockRecorder) Get(ctx, resourceGroupName, name, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockManagedHSMsClient)(nil).Get), ctx, resourceGroupName, name, options) } // NewListByResourceGroupPager mocks base method. func (m *MockManagedHSMsClient) NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.ManagedHsmsClientListByResourceGroupOptions) clients.ManagedHSMsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.ManagedHSMsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockManagedHSMsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockManagedHSMsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_nat_gateways_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: nat-gateways-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_nat_gateways_client.go -package=mocks -source=nat-gateways-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockNatGatewaysClient is a mock of NatGatewaysClient interface. type MockNatGatewaysClient struct { ctrl *gomock.Controller recorder *MockNatGatewaysClientMockRecorder isgomock struct{} } // MockNatGatewaysClientMockRecorder is the mock recorder for MockNatGatewaysClient. type MockNatGatewaysClientMockRecorder struct { mock *MockNatGatewaysClient } // NewMockNatGatewaysClient creates a new mock instance. func NewMockNatGatewaysClient(ctrl *gomock.Controller) *MockNatGatewaysClient { mock := &MockNatGatewaysClient{ctrl: ctrl} mock.recorder = &MockNatGatewaysClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockNatGatewaysClient) EXPECT() *MockNatGatewaysClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockNatGatewaysClient) Get(ctx context.Context, resourceGroupName, natGatewayName string, options *armnetwork.NatGatewaysClientGetOptions) (armnetwork.NatGatewaysClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, natGatewayName, options) ret0, _ := ret[0].(armnetwork.NatGatewaysClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockNatGatewaysClientMockRecorder) Get(ctx, resourceGroupName, natGatewayName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockNatGatewaysClient)(nil).Get), ctx, resourceGroupName, natGatewayName, options) } // NewListPager mocks base method. func (m *MockNatGatewaysClient) NewListPager(resourceGroupName string, options *armnetwork.NatGatewaysClientListOptions) clients.NatGatewaysPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.NatGatewaysPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockNatGatewaysClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockNatGatewaysClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_network_interfaces_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: network-interfaces-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_network_interfaces_client.go -package=mocks -source=network-interfaces-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockNetworkInterfacesClient is a mock of NetworkInterfacesClient interface. type MockNetworkInterfacesClient struct { ctrl *gomock.Controller recorder *MockNetworkInterfacesClientMockRecorder isgomock struct{} } // MockNetworkInterfacesClientMockRecorder is the mock recorder for MockNetworkInterfacesClient. type MockNetworkInterfacesClientMockRecorder struct { mock *MockNetworkInterfacesClient } // NewMockNetworkInterfacesClient creates a new mock instance. func NewMockNetworkInterfacesClient(ctrl *gomock.Controller) *MockNetworkInterfacesClient { mock := &MockNetworkInterfacesClient{ctrl: ctrl} mock.recorder = &MockNetworkInterfacesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockNetworkInterfacesClient) EXPECT() *MockNetworkInterfacesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockNetworkInterfacesClient) Get(ctx context.Context, resourceGroupName, networkInterfaceName string) (armnetwork.InterfacesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkInterfaceName) ret0, _ := ret[0].(armnetwork.InterfacesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockNetworkInterfacesClientMockRecorder) Get(ctx, resourceGroupName, networkInterfaceName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockNetworkInterfacesClient)(nil).Get), ctx, resourceGroupName, networkInterfaceName) } // List mocks base method. func (m *MockNetworkInterfacesClient) List(ctx context.Context, resourceGroupName string) clients.NetworkInterfacesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName) ret0, _ := ret[0].(clients.NetworkInterfacesPager) return ret0 } // List indicates an expected call of List. func (mr *MockNetworkInterfacesClientMockRecorder) List(ctx, resourceGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockNetworkInterfacesClient)(nil).List), ctx, resourceGroupName) } ================================================ FILE: sources/azure/shared/mocks/mock_network_private_endpoint_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: network-private-endpoint-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_network_private_endpoint_client.go -package=mocks -source=network-private-endpoint-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPrivateEndpointsClient is a mock of PrivateEndpointsClient interface. type MockPrivateEndpointsClient struct { ctrl *gomock.Controller recorder *MockPrivateEndpointsClientMockRecorder isgomock struct{} } // MockPrivateEndpointsClientMockRecorder is the mock recorder for MockPrivateEndpointsClient. type MockPrivateEndpointsClientMockRecorder struct { mock *MockPrivateEndpointsClient } // NewMockPrivateEndpointsClient creates a new mock instance. func NewMockPrivateEndpointsClient(ctrl *gomock.Controller) *MockPrivateEndpointsClient { mock := &MockPrivateEndpointsClient{ctrl: ctrl} mock.recorder = &MockPrivateEndpointsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPrivateEndpointsClient) EXPECT() *MockPrivateEndpointsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPrivateEndpointsClient) Get(ctx context.Context, resourceGroupName, privateEndpointName string) (armnetwork.PrivateEndpointsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, privateEndpointName) ret0, _ := ret[0].(armnetwork.PrivateEndpointsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPrivateEndpointsClientMockRecorder) Get(ctx, resourceGroupName, privateEndpointName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPrivateEndpointsClient)(nil).Get), ctx, resourceGroupName, privateEndpointName) } // List mocks base method. func (m *MockPrivateEndpointsClient) List(resourceGroupName string) clients.PrivateEndpointsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", resourceGroupName) ret0, _ := ret[0].(clients.PrivateEndpointsPager) return ret0 } // List indicates an expected call of List. func (mr *MockPrivateEndpointsClientMockRecorder) List(resourceGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockPrivateEndpointsClient)(nil).List), resourceGroupName) } ================================================ FILE: sources/azure/shared/mocks/mock_network_security_groups_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: network-security-groups-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_network_security_groups_client.go -package=mocks -source=network-security-groups-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockNetworkSecurityGroupsClient is a mock of NetworkSecurityGroupsClient interface. type MockNetworkSecurityGroupsClient struct { ctrl *gomock.Controller recorder *MockNetworkSecurityGroupsClientMockRecorder isgomock struct{} } // MockNetworkSecurityGroupsClientMockRecorder is the mock recorder for MockNetworkSecurityGroupsClient. type MockNetworkSecurityGroupsClientMockRecorder struct { mock *MockNetworkSecurityGroupsClient } // NewMockNetworkSecurityGroupsClient creates a new mock instance. func NewMockNetworkSecurityGroupsClient(ctrl *gomock.Controller) *MockNetworkSecurityGroupsClient { mock := &MockNetworkSecurityGroupsClient{ctrl: ctrl} mock.recorder = &MockNetworkSecurityGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockNetworkSecurityGroupsClient) EXPECT() *MockNetworkSecurityGroupsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockNetworkSecurityGroupsClient) Get(ctx context.Context, resourceGroupName, networkSecurityGroupName string, options *armnetwork.SecurityGroupsClientGetOptions) (armnetwork.SecurityGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkSecurityGroupName, options) ret0, _ := ret[0].(armnetwork.SecurityGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockNetworkSecurityGroupsClientMockRecorder) Get(ctx, resourceGroupName, networkSecurityGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockNetworkSecurityGroupsClient)(nil).Get), ctx, resourceGroupName, networkSecurityGroupName, options) } // List mocks base method. func (m *MockNetworkSecurityGroupsClient) List(ctx context.Context, resourceGroupName string, options *armnetwork.SecurityGroupsClientListOptions) clients.NetworkSecurityGroupsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, options) ret0, _ := ret[0].(clients.NetworkSecurityGroupsPager) return ret0 } // List indicates an expected call of List. func (mr *MockNetworkSecurityGroupsClientMockRecorder) List(ctx, resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockNetworkSecurityGroupsClient)(nil).List), ctx, resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_network_watchers_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: network-watchers-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_network_watchers_client.go -package=mocks -source=network-watchers-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockNetworkWatchersClient is a mock of NetworkWatchersClient interface. type MockNetworkWatchersClient struct { ctrl *gomock.Controller recorder *MockNetworkWatchersClientMockRecorder isgomock struct{} } // MockNetworkWatchersClientMockRecorder is the mock recorder for MockNetworkWatchersClient. type MockNetworkWatchersClientMockRecorder struct { mock *MockNetworkWatchersClient } // NewMockNetworkWatchersClient creates a new mock instance. func NewMockNetworkWatchersClient(ctrl *gomock.Controller) *MockNetworkWatchersClient { mock := &MockNetworkWatchersClient{ctrl: ctrl} mock.recorder = &MockNetworkWatchersClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockNetworkWatchersClient) EXPECT() *MockNetworkWatchersClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockNetworkWatchersClient) Get(ctx context.Context, resourceGroupName, networkWatcherName string, options *armnetwork.WatchersClientGetOptions) (armnetwork.WatchersClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkWatcherName, options) ret0, _ := ret[0].(armnetwork.WatchersClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockNetworkWatchersClientMockRecorder) Get(ctx, resourceGroupName, networkWatcherName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockNetworkWatchersClient)(nil).Get), ctx, resourceGroupName, networkWatcherName, options) } // NewListPager mocks base method. func (m *MockNetworkWatchersClient) NewListPager(resourceGroupName string, options *armnetwork.WatchersClientListOptions) clients.NetworkWatchersPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.NetworkWatchersPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockNetworkWatchersClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockNetworkWatchersClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_operational_insights_workspace_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: operational-insights-workspace-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_operational_insights_workspace_client.go -package=mocks -source=operational-insights-workspace-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armoperationalinsights "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockOperationalInsightsWorkspaceClient is a mock of OperationalInsightsWorkspaceClient interface. type MockOperationalInsightsWorkspaceClient struct { ctrl *gomock.Controller recorder *MockOperationalInsightsWorkspaceClientMockRecorder isgomock struct{} } // MockOperationalInsightsWorkspaceClientMockRecorder is the mock recorder for MockOperationalInsightsWorkspaceClient. type MockOperationalInsightsWorkspaceClientMockRecorder struct { mock *MockOperationalInsightsWorkspaceClient } // NewMockOperationalInsightsWorkspaceClient creates a new mock instance. func NewMockOperationalInsightsWorkspaceClient(ctrl *gomock.Controller) *MockOperationalInsightsWorkspaceClient { mock := &MockOperationalInsightsWorkspaceClient{ctrl: ctrl} mock.recorder = &MockOperationalInsightsWorkspaceClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockOperationalInsightsWorkspaceClient) EXPECT() *MockOperationalInsightsWorkspaceClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockOperationalInsightsWorkspaceClient) Get(ctx context.Context, resourceGroupName, workspaceName string, options *armoperationalinsights.WorkspacesClientGetOptions) (armoperationalinsights.WorkspacesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, workspaceName, options) ret0, _ := ret[0].(armoperationalinsights.WorkspacesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockOperationalInsightsWorkspaceClientMockRecorder) Get(ctx, resourceGroupName, workspaceName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockOperationalInsightsWorkspaceClient)(nil).Get), ctx, resourceGroupName, workspaceName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockOperationalInsightsWorkspaceClient) NewListByResourceGroupPager(resourceGroupName string, options *armoperationalinsights.WorkspacesClientListByResourceGroupOptions) clients.OperationalInsightsWorkspacePager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.OperationalInsightsWorkspacePager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockOperationalInsightsWorkspaceClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockOperationalInsightsWorkspaceClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_postgresql_databases_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: postgresql-databases-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_postgresql_databases_client.go -package=mocks -source=postgresql-databases-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPostgreSQLDatabasesClient is a mock of PostgreSQLDatabasesClient interface. type MockPostgreSQLDatabasesClient struct { ctrl *gomock.Controller recorder *MockPostgreSQLDatabasesClientMockRecorder isgomock struct{} } // MockPostgreSQLDatabasesClientMockRecorder is the mock recorder for MockPostgreSQLDatabasesClient. type MockPostgreSQLDatabasesClientMockRecorder struct { mock *MockPostgreSQLDatabasesClient } // NewMockPostgreSQLDatabasesClient creates a new mock instance. func NewMockPostgreSQLDatabasesClient(ctrl *gomock.Controller) *MockPostgreSQLDatabasesClient { mock := &MockPostgreSQLDatabasesClient{ctrl: ctrl} mock.recorder = &MockPostgreSQLDatabasesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPostgreSQLDatabasesClient) EXPECT() *MockPostgreSQLDatabasesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPostgreSQLDatabasesClient) Get(ctx context.Context, resourceGroupName, serverName, databaseName string) (armpostgresqlflexibleservers.DatabasesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, databaseName) ret0, _ := ret[0].(armpostgresqlflexibleservers.DatabasesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPostgreSQLDatabasesClientMockRecorder) Get(ctx, resourceGroupName, serverName, databaseName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPostgreSQLDatabasesClient)(nil).Get), ctx, resourceGroupName, serverName, databaseName) } // ListByServer mocks base method. func (m *MockPostgreSQLDatabasesClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.PostgreSQLDatabasesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.PostgreSQLDatabasesPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockPostgreSQLDatabasesClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockPostgreSQLDatabasesClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_postgresql_flexible_server_firewall_rule_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: postgresql-flexible-server-firewall-rule-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_postgresql_flexible_server_firewall_rule_client.go -package=mocks -source=postgresql-flexible-server-firewall-rule-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPostgreSQLFlexibleServerFirewallRuleClient is a mock of PostgreSQLFlexibleServerFirewallRuleClient interface. type MockPostgreSQLFlexibleServerFirewallRuleClient struct { ctrl *gomock.Controller recorder *MockPostgreSQLFlexibleServerFirewallRuleClientMockRecorder isgomock struct{} } // MockPostgreSQLFlexibleServerFirewallRuleClientMockRecorder is the mock recorder for MockPostgreSQLFlexibleServerFirewallRuleClient. type MockPostgreSQLFlexibleServerFirewallRuleClientMockRecorder struct { mock *MockPostgreSQLFlexibleServerFirewallRuleClient } // NewMockPostgreSQLFlexibleServerFirewallRuleClient creates a new mock instance. func NewMockPostgreSQLFlexibleServerFirewallRuleClient(ctrl *gomock.Controller) *MockPostgreSQLFlexibleServerFirewallRuleClient { mock := &MockPostgreSQLFlexibleServerFirewallRuleClient{ctrl: ctrl} mock.recorder = &MockPostgreSQLFlexibleServerFirewallRuleClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPostgreSQLFlexibleServerFirewallRuleClient) EXPECT() *MockPostgreSQLFlexibleServerFirewallRuleClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPostgreSQLFlexibleServerFirewallRuleClient) Get(ctx context.Context, resourceGroupName, serverName, firewallRuleName string) (armpostgresqlflexibleservers.FirewallRulesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, firewallRuleName) ret0, _ := ret[0].(armpostgresqlflexibleservers.FirewallRulesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPostgreSQLFlexibleServerFirewallRuleClientMockRecorder) Get(ctx, resourceGroupName, serverName, firewallRuleName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPostgreSQLFlexibleServerFirewallRuleClient)(nil).Get), ctx, resourceGroupName, serverName, firewallRuleName) } // ListByServer mocks base method. func (m *MockPostgreSQLFlexibleServerFirewallRuleClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.PostgreSQLFlexibleServerFirewallRulePager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.PostgreSQLFlexibleServerFirewallRulePager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockPostgreSQLFlexibleServerFirewallRuleClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockPostgreSQLFlexibleServerFirewallRuleClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_postgresql_flexible_servers_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: postgresql-flexible-servers-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_postgresql_flexible_servers_client.go -package=mocks -source=postgresql-flexible-servers-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armpostgresqlflexibleservers "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPostgreSQLFlexibleServersClient is a mock of PostgreSQLFlexibleServersClient interface. type MockPostgreSQLFlexibleServersClient struct { ctrl *gomock.Controller recorder *MockPostgreSQLFlexibleServersClientMockRecorder isgomock struct{} } // MockPostgreSQLFlexibleServersClientMockRecorder is the mock recorder for MockPostgreSQLFlexibleServersClient. type MockPostgreSQLFlexibleServersClientMockRecorder struct { mock *MockPostgreSQLFlexibleServersClient } // NewMockPostgreSQLFlexibleServersClient creates a new mock instance. func NewMockPostgreSQLFlexibleServersClient(ctrl *gomock.Controller) *MockPostgreSQLFlexibleServersClient { mock := &MockPostgreSQLFlexibleServersClient{ctrl: ctrl} mock.recorder = &MockPostgreSQLFlexibleServersClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPostgreSQLFlexibleServersClient) EXPECT() *MockPostgreSQLFlexibleServersClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPostgreSQLFlexibleServersClient) Get(ctx context.Context, resourceGroupName, serverName string, options *armpostgresqlflexibleservers.ServersClientGetOptions) (armpostgresqlflexibleservers.ServersClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, options) ret0, _ := ret[0].(armpostgresqlflexibleservers.ServersClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPostgreSQLFlexibleServersClientMockRecorder) Get(ctx, resourceGroupName, serverName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPostgreSQLFlexibleServersClient)(nil).Get), ctx, resourceGroupName, serverName, options) } // ListByResourceGroup mocks base method. func (m *MockPostgreSQLFlexibleServersClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armpostgresqlflexibleservers.ServersClientListByResourceGroupOptions) clients.PostgreSQLFlexibleServersPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByResourceGroup", ctx, resourceGroupName, options) ret0, _ := ret[0].(clients.PostgreSQLFlexibleServersPager) return ret0 } // ListByResourceGroup indicates an expected call of ListByResourceGroup. func (mr *MockPostgreSQLFlexibleServersClientMockRecorder) ListByResourceGroup(ctx, resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByResourceGroup", reflect.TypeOf((*MockPostgreSQLFlexibleServersClient)(nil).ListByResourceGroup), ctx, resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_private_dns_zones_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: private-dns-zones-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_private_dns_zones_client.go -package=mocks -source=private-dns-zones-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armprivatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPrivateDNSZonesClient is a mock of PrivateDNSZonesClient interface. type MockPrivateDNSZonesClient struct { ctrl *gomock.Controller recorder *MockPrivateDNSZonesClientMockRecorder isgomock struct{} } // MockPrivateDNSZonesClientMockRecorder is the mock recorder for MockPrivateDNSZonesClient. type MockPrivateDNSZonesClientMockRecorder struct { mock *MockPrivateDNSZonesClient } // NewMockPrivateDNSZonesClient creates a new mock instance. func NewMockPrivateDNSZonesClient(ctrl *gomock.Controller) *MockPrivateDNSZonesClient { mock := &MockPrivateDNSZonesClient{ctrl: ctrl} mock.recorder = &MockPrivateDNSZonesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPrivateDNSZonesClient) EXPECT() *MockPrivateDNSZonesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPrivateDNSZonesClient) Get(ctx context.Context, resourceGroupName, privateZoneName string, options *armprivatedns.PrivateZonesClientGetOptions) (armprivatedns.PrivateZonesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, privateZoneName, options) ret0, _ := ret[0].(armprivatedns.PrivateZonesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPrivateDNSZonesClientMockRecorder) Get(ctx, resourceGroupName, privateZoneName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPrivateDNSZonesClient)(nil).Get), ctx, resourceGroupName, privateZoneName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockPrivateDNSZonesClient) NewListByResourceGroupPager(resourceGroupName string, options *armprivatedns.PrivateZonesClientListByResourceGroupOptions) clients.PrivateDNSZonesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.PrivateDNSZonesPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockPrivateDNSZonesClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockPrivateDNSZonesClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_private_link_services_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: private-link-services-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_private_link_services_client.go -package=mocks -source=private-link-services-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPrivateLinkServicesClient is a mock of PrivateLinkServicesClient interface. type MockPrivateLinkServicesClient struct { ctrl *gomock.Controller recorder *MockPrivateLinkServicesClientMockRecorder isgomock struct{} } // MockPrivateLinkServicesClientMockRecorder is the mock recorder for MockPrivateLinkServicesClient. type MockPrivateLinkServicesClientMockRecorder struct { mock *MockPrivateLinkServicesClient } // NewMockPrivateLinkServicesClient creates a new mock instance. func NewMockPrivateLinkServicesClient(ctrl *gomock.Controller) *MockPrivateLinkServicesClient { mock := &MockPrivateLinkServicesClient{ctrl: ctrl} mock.recorder = &MockPrivateLinkServicesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPrivateLinkServicesClient) EXPECT() *MockPrivateLinkServicesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPrivateLinkServicesClient) Get(ctx context.Context, resourceGroupName, serviceName string) (armnetwork.PrivateLinkServicesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serviceName) ret0, _ := ret[0].(armnetwork.PrivateLinkServicesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPrivateLinkServicesClientMockRecorder) Get(ctx, resourceGroupName, serviceName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPrivateLinkServicesClient)(nil).Get), ctx, resourceGroupName, serviceName) } // List mocks base method. func (m *MockPrivateLinkServicesClient) List(resourceGroupName string) clients.PrivateLinkServicesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", resourceGroupName) ret0, _ := ret[0].(clients.PrivateLinkServicesPager) return ret0 } // List indicates an expected call of List. func (mr *MockPrivateLinkServicesClientMockRecorder) List(resourceGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockPrivateLinkServicesClient)(nil).List), resourceGroupName) } ================================================ FILE: sources/azure/shared/mocks/mock_proximity_placement_groups_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: proximity-placement-groups-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_proximity_placement_groups_client.go -package=mocks -source=proximity-placement-groups-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockProximityPlacementGroupsClient is a mock of ProximityPlacementGroupsClient interface. type MockProximityPlacementGroupsClient struct { ctrl *gomock.Controller recorder *MockProximityPlacementGroupsClientMockRecorder isgomock struct{} } // MockProximityPlacementGroupsClientMockRecorder is the mock recorder for MockProximityPlacementGroupsClient. type MockProximityPlacementGroupsClientMockRecorder struct { mock *MockProximityPlacementGroupsClient } // NewMockProximityPlacementGroupsClient creates a new mock instance. func NewMockProximityPlacementGroupsClient(ctrl *gomock.Controller) *MockProximityPlacementGroupsClient { mock := &MockProximityPlacementGroupsClient{ctrl: ctrl} mock.recorder = &MockProximityPlacementGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProximityPlacementGroupsClient) EXPECT() *MockProximityPlacementGroupsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockProximityPlacementGroupsClient) Get(ctx context.Context, resourceGroupName, proximityPlacementGroupName string, options *armcompute.ProximityPlacementGroupsClientGetOptions) (armcompute.ProximityPlacementGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, proximityPlacementGroupName, options) ret0, _ := ret[0].(armcompute.ProximityPlacementGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockProximityPlacementGroupsClientMockRecorder) Get(ctx, resourceGroupName, proximityPlacementGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockProximityPlacementGroupsClient)(nil).Get), ctx, resourceGroupName, proximityPlacementGroupName, options) } // ListByResourceGroup mocks base method. func (m *MockProximityPlacementGroupsClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armcompute.ProximityPlacementGroupsClientListByResourceGroupOptions) clients.ProximityPlacementGroupsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByResourceGroup", ctx, resourceGroupName, options) ret0, _ := ret[0].(clients.ProximityPlacementGroupsPager) return ret0 } // ListByResourceGroup indicates an expected call of ListByResourceGroup. func (mr *MockProximityPlacementGroupsClientMockRecorder) ListByResourceGroup(ctx, resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByResourceGroup", reflect.TypeOf((*MockProximityPlacementGroupsClient)(nil).ListByResourceGroup), ctx, resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_public_ip_addresses_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: public-ip-addresses.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_public_ip_addresses_client.go -package=mocks -source=public-ip-addresses.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPublicIPAddressesClient is a mock of PublicIPAddressesClient interface. type MockPublicIPAddressesClient struct { ctrl *gomock.Controller recorder *MockPublicIPAddressesClientMockRecorder isgomock struct{} } // MockPublicIPAddressesClientMockRecorder is the mock recorder for MockPublicIPAddressesClient. type MockPublicIPAddressesClientMockRecorder struct { mock *MockPublicIPAddressesClient } // NewMockPublicIPAddressesClient creates a new mock instance. func NewMockPublicIPAddressesClient(ctrl *gomock.Controller) *MockPublicIPAddressesClient { mock := &MockPublicIPAddressesClient{ctrl: ctrl} mock.recorder = &MockPublicIPAddressesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPublicIPAddressesClient) EXPECT() *MockPublicIPAddressesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPublicIPAddressesClient) Get(ctx context.Context, resourceGroupName, publicIPAddressName string) (armnetwork.PublicIPAddressesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, publicIPAddressName) ret0, _ := ret[0].(armnetwork.PublicIPAddressesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPublicIPAddressesClientMockRecorder) Get(ctx, resourceGroupName, publicIPAddressName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPublicIPAddressesClient)(nil).Get), ctx, resourceGroupName, publicIPAddressName) } // List mocks base method. func (m *MockPublicIPAddressesClient) List(ctx context.Context, resourceGroupName string) clients.PublicIPAddressesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName) ret0, _ := ret[0].(clients.PublicIPAddressesPager) return ret0 } // List indicates an expected call of List. func (mr *MockPublicIPAddressesClientMockRecorder) List(ctx, resourceGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockPublicIPAddressesClient)(nil).List), ctx, resourceGroupName) } ================================================ FILE: sources/azure/shared/mocks/mock_public_ip_prefixes_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: public-ip-prefixes-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_public_ip_prefixes_client.go -package=mocks -source=public-ip-prefixes-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockPublicIPPrefixesClient is a mock of PublicIPPrefixesClient interface. type MockPublicIPPrefixesClient struct { ctrl *gomock.Controller recorder *MockPublicIPPrefixesClientMockRecorder isgomock struct{} } // MockPublicIPPrefixesClientMockRecorder is the mock recorder for MockPublicIPPrefixesClient. type MockPublicIPPrefixesClientMockRecorder struct { mock *MockPublicIPPrefixesClient } // NewMockPublicIPPrefixesClient creates a new mock instance. func NewMockPublicIPPrefixesClient(ctrl *gomock.Controller) *MockPublicIPPrefixesClient { mock := &MockPublicIPPrefixesClient{ctrl: ctrl} mock.recorder = &MockPublicIPPrefixesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPublicIPPrefixesClient) EXPECT() *MockPublicIPPrefixesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockPublicIPPrefixesClient) Get(ctx context.Context, resourceGroupName, publicIPPrefixName string, options *armnetwork.PublicIPPrefixesClientGetOptions) (armnetwork.PublicIPPrefixesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, publicIPPrefixName, options) ret0, _ := ret[0].(armnetwork.PublicIPPrefixesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockPublicIPPrefixesClientMockRecorder) Get(ctx, resourceGroupName, publicIPPrefixName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPublicIPPrefixesClient)(nil).Get), ctx, resourceGroupName, publicIPPrefixName, options) } // NewListPager mocks base method. func (m *MockPublicIPPrefixesClient) NewListPager(resourceGroupName string, options *armnetwork.PublicIPPrefixesClientListOptions) clients.PublicIPPrefixesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.PublicIPPrefixesPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockPublicIPPrefixesClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockPublicIPPrefixesClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_queues_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: queues-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_queues_client.go -package=mocks -source=queues-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockQueuesClient is a mock of QueuesClient interface. type MockQueuesClient struct { ctrl *gomock.Controller recorder *MockQueuesClientMockRecorder isgomock struct{} } // MockQueuesClientMockRecorder is the mock recorder for MockQueuesClient. type MockQueuesClientMockRecorder struct { mock *MockQueuesClient } // NewMockQueuesClient creates a new mock instance. func NewMockQueuesClient(ctrl *gomock.Controller) *MockQueuesClient { mock := &MockQueuesClient{ctrl: ctrl} mock.recorder = &MockQueuesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockQueuesClient) EXPECT() *MockQueuesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockQueuesClient) Get(ctx context.Context, resourceGroupName, accountName, queueName string) (armstorage.QueueClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, queueName) ret0, _ := ret[0].(armstorage.QueueClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockQueuesClientMockRecorder) Get(ctx, resourceGroupName, accountName, queueName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockQueuesClient)(nil).Get), ctx, resourceGroupName, accountName, queueName) } // List mocks base method. func (m *MockQueuesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.QueuesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.QueuesPager) return ret0 } // List indicates an expected call of List. func (mr *MockQueuesClientMockRecorder) List(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockQueuesClient)(nil).List), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_record_sets_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: record-sets-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_record_sets_client.go -package=mocks -source=record-sets-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armdns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockRecordSetsClient is a mock of RecordSetsClient interface. type MockRecordSetsClient struct { ctrl *gomock.Controller recorder *MockRecordSetsClientMockRecorder isgomock struct{} } // MockRecordSetsClientMockRecorder is the mock recorder for MockRecordSetsClient. type MockRecordSetsClientMockRecorder struct { mock *MockRecordSetsClient } // NewMockRecordSetsClient creates a new mock instance. func NewMockRecordSetsClient(ctrl *gomock.Controller) *MockRecordSetsClient { mock := &MockRecordSetsClient{ctrl: ctrl} mock.recorder = &MockRecordSetsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockRecordSetsClient) EXPECT() *MockRecordSetsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockRecordSetsClient) Get(ctx context.Context, resourceGroupName, zoneName, relativeRecordSetName string, recordType armdns.RecordType, options *armdns.RecordSetsClientGetOptions) (armdns.RecordSetsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, zoneName, relativeRecordSetName, recordType, options) ret0, _ := ret[0].(armdns.RecordSetsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockRecordSetsClientMockRecorder) Get(ctx, resourceGroupName, zoneName, relativeRecordSetName, recordType, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRecordSetsClient)(nil).Get), ctx, resourceGroupName, zoneName, relativeRecordSetName, recordType, options) } // NewListAllByDNSZonePager mocks base method. func (m *MockRecordSetsClient) NewListAllByDNSZonePager(resourceGroupName, zoneName string, options *armdns.RecordSetsClientListAllByDNSZoneOptions) clients.RecordSetsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListAllByDNSZonePager", resourceGroupName, zoneName, options) ret0, _ := ret[0].(clients.RecordSetsPager) return ret0 } // NewListAllByDNSZonePager indicates an expected call of NewListAllByDNSZonePager. func (mr *MockRecordSetsClientMockRecorder) NewListAllByDNSZonePager(resourceGroupName, zoneName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListAllByDNSZonePager", reflect.TypeOf((*MockRecordSetsClient)(nil).NewListAllByDNSZonePager), resourceGroupName, zoneName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_role_assignments_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: role-assignments-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_role_assignments_client.go -package=mocks -source=role-assignments-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armauthorization "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockRoleAssignmentsClient is a mock of RoleAssignmentsClient interface. type MockRoleAssignmentsClient struct { ctrl *gomock.Controller recorder *MockRoleAssignmentsClientMockRecorder isgomock struct{} } // MockRoleAssignmentsClientMockRecorder is the mock recorder for MockRoleAssignmentsClient. type MockRoleAssignmentsClientMockRecorder struct { mock *MockRoleAssignmentsClient } // NewMockRoleAssignmentsClient creates a new mock instance. func NewMockRoleAssignmentsClient(ctrl *gomock.Controller) *MockRoleAssignmentsClient { mock := &MockRoleAssignmentsClient{ctrl: ctrl} mock.recorder = &MockRoleAssignmentsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockRoleAssignmentsClient) EXPECT() *MockRoleAssignmentsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockRoleAssignmentsClient) Get(ctx context.Context, scope, roleAssignmentName string, options *armauthorization.RoleAssignmentsClientGetOptions) (armauthorization.RoleAssignmentsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, scope, roleAssignmentName, options) ret0, _ := ret[0].(armauthorization.RoleAssignmentsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockRoleAssignmentsClientMockRecorder) Get(ctx, scope, roleAssignmentName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRoleAssignmentsClient)(nil).Get), ctx, scope, roleAssignmentName, options) } // ListForResourceGroup mocks base method. func (m *MockRoleAssignmentsClient) ListForResourceGroup(resourceGroupName string, options *armauthorization.RoleAssignmentsClientListForResourceGroupOptions) clients.RoleAssignmentsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListForResourceGroup", resourceGroupName, options) ret0, _ := ret[0].(clients.RoleAssignmentsPager) return ret0 } // ListForResourceGroup indicates an expected call of ListForResourceGroup. func (mr *MockRoleAssignmentsClientMockRecorder) ListForResourceGroup(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListForResourceGroup", reflect.TypeOf((*MockRoleAssignmentsClient)(nil).ListForResourceGroup), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_role_definitions_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: role-definitions-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_role_definitions_client.go -package=mocks -source=role-definitions-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armauthorization "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockRoleDefinitionsClient is a mock of RoleDefinitionsClient interface. type MockRoleDefinitionsClient struct { ctrl *gomock.Controller recorder *MockRoleDefinitionsClientMockRecorder isgomock struct{} } // MockRoleDefinitionsClientMockRecorder is the mock recorder for MockRoleDefinitionsClient. type MockRoleDefinitionsClientMockRecorder struct { mock *MockRoleDefinitionsClient } // NewMockRoleDefinitionsClient creates a new mock instance. func NewMockRoleDefinitionsClient(ctrl *gomock.Controller) *MockRoleDefinitionsClient { mock := &MockRoleDefinitionsClient{ctrl: ctrl} mock.recorder = &MockRoleDefinitionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockRoleDefinitionsClient) EXPECT() *MockRoleDefinitionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockRoleDefinitionsClient) Get(ctx context.Context, scope, roleDefinitionID string, options *armauthorization.RoleDefinitionsClientGetOptions) (armauthorization.RoleDefinitionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, scope, roleDefinitionID, options) ret0, _ := ret[0].(armauthorization.RoleDefinitionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockRoleDefinitionsClientMockRecorder) Get(ctx, scope, roleDefinitionID, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRoleDefinitionsClient)(nil).Get), ctx, scope, roleDefinitionID, options) } // NewListPager mocks base method. func (m *MockRoleDefinitionsClient) NewListPager(scope string, options *armauthorization.RoleDefinitionsClientListOptions) clients.RoleDefinitionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", scope, options) ret0, _ := ret[0].(clients.RoleDefinitionsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockRoleDefinitionsClientMockRecorder) NewListPager(scope, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockRoleDefinitionsClient)(nil).NewListPager), scope, options) } ================================================ FILE: sources/azure/shared/mocks/mock_route_tables_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: route-tables-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_route_tables_client.go -package=mocks -source=route-tables-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockRouteTablesClient is a mock of RouteTablesClient interface. type MockRouteTablesClient struct { ctrl *gomock.Controller recorder *MockRouteTablesClientMockRecorder isgomock struct{} } // MockRouteTablesClientMockRecorder is the mock recorder for MockRouteTablesClient. type MockRouteTablesClientMockRecorder struct { mock *MockRouteTablesClient } // NewMockRouteTablesClient creates a new mock instance. func NewMockRouteTablesClient(ctrl *gomock.Controller) *MockRouteTablesClient { mock := &MockRouteTablesClient{ctrl: ctrl} mock.recorder = &MockRouteTablesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockRouteTablesClient) EXPECT() *MockRouteTablesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockRouteTablesClient) Get(ctx context.Context, resourceGroupName, routeTableName string, options *armnetwork.RouteTablesClientGetOptions) (armnetwork.RouteTablesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, routeTableName, options) ret0, _ := ret[0].(armnetwork.RouteTablesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockRouteTablesClientMockRecorder) Get(ctx, resourceGroupName, routeTableName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRouteTablesClient)(nil).Get), ctx, resourceGroupName, routeTableName, options) } // List mocks base method. func (m *MockRouteTablesClient) List(resourceGroupName string, options *armnetwork.RouteTablesClientListOptions) clients.RouteTablesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", resourceGroupName, options) ret0, _ := ret[0].(clients.RouteTablesPager) return ret0 } // List indicates an expected call of List. func (mr *MockRouteTablesClientMockRecorder) List(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRouteTablesClient)(nil).List), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_routes_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: routes-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_routes_client.go -package=mocks -source=routes-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockRoutesClient is a mock of RoutesClient interface. type MockRoutesClient struct { ctrl *gomock.Controller recorder *MockRoutesClientMockRecorder isgomock struct{} } // MockRoutesClientMockRecorder is the mock recorder for MockRoutesClient. type MockRoutesClientMockRecorder struct { mock *MockRoutesClient } // NewMockRoutesClient creates a new mock instance. func NewMockRoutesClient(ctrl *gomock.Controller) *MockRoutesClient { mock := &MockRoutesClient{ctrl: ctrl} mock.recorder = &MockRoutesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockRoutesClient) EXPECT() *MockRoutesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockRoutesClient) Get(ctx context.Context, resourceGroupName, routeTableName, routeName string, options *armnetwork.RoutesClientGetOptions) (armnetwork.RoutesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, routeTableName, routeName, options) ret0, _ := ret[0].(armnetwork.RoutesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockRoutesClientMockRecorder) Get(ctx, resourceGroupName, routeTableName, routeName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRoutesClient)(nil).Get), ctx, resourceGroupName, routeTableName, routeName, options) } // NewListPager mocks base method. func (m *MockRoutesClient) NewListPager(resourceGroupName, routeTableName string, options *armnetwork.RoutesClientListOptions) clients.RoutesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, routeTableName, options) ret0, _ := ret[0].(clients.RoutesPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockRoutesClientMockRecorder) NewListPager(resourceGroupName, routeTableName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockRoutesClient)(nil).NewListPager), resourceGroupName, routeTableName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_secrets_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: secrets-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_secrets_client.go -package=mocks -source=secrets-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armkeyvault "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSecretsClient is a mock of SecretsClient interface. type MockSecretsClient struct { ctrl *gomock.Controller recorder *MockSecretsClientMockRecorder isgomock struct{} } // MockSecretsClientMockRecorder is the mock recorder for MockSecretsClient. type MockSecretsClientMockRecorder struct { mock *MockSecretsClient } // NewMockSecretsClient creates a new mock instance. func NewMockSecretsClient(ctrl *gomock.Controller) *MockSecretsClient { mock := &MockSecretsClient{ctrl: ctrl} mock.recorder = &MockSecretsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSecretsClient) EXPECT() *MockSecretsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSecretsClient) Get(ctx context.Context, resourceGroupName, vaultName, secretName string, options *armkeyvault.SecretsClientGetOptions) (armkeyvault.SecretsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, vaultName, secretName, options) ret0, _ := ret[0].(armkeyvault.SecretsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSecretsClientMockRecorder) Get(ctx, resourceGroupName, vaultName, secretName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSecretsClient)(nil).Get), ctx, resourceGroupName, vaultName, secretName, options) } // NewListPager mocks base method. func (m *MockSecretsClient) NewListPager(resourceGroupName, vaultName string, options *armkeyvault.SecretsClientListOptions) clients.SecretsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, vaultName, options) ret0, _ := ret[0].(clients.SecretsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockSecretsClientMockRecorder) NewListPager(resourceGroupName, vaultName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockSecretsClient)(nil).NewListPager), resourceGroupName, vaultName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_security_rules_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: security-rules-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_security_rules_client.go -package=mocks -source=security-rules-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSecurityRulesClient is a mock of SecurityRulesClient interface. type MockSecurityRulesClient struct { ctrl *gomock.Controller recorder *MockSecurityRulesClientMockRecorder isgomock struct{} } // MockSecurityRulesClientMockRecorder is the mock recorder for MockSecurityRulesClient. type MockSecurityRulesClientMockRecorder struct { mock *MockSecurityRulesClient } // NewMockSecurityRulesClient creates a new mock instance. func NewMockSecurityRulesClient(ctrl *gomock.Controller) *MockSecurityRulesClient { mock := &MockSecurityRulesClient{ctrl: ctrl} mock.recorder = &MockSecurityRulesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSecurityRulesClient) EXPECT() *MockSecurityRulesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSecurityRulesClient) Get(ctx context.Context, resourceGroupName, networkSecurityGroupName, securityRuleName string, options *armnetwork.SecurityRulesClientGetOptions) (armnetwork.SecurityRulesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkSecurityGroupName, securityRuleName, options) ret0, _ := ret[0].(armnetwork.SecurityRulesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSecurityRulesClientMockRecorder) Get(ctx, resourceGroupName, networkSecurityGroupName, securityRuleName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSecurityRulesClient)(nil).Get), ctx, resourceGroupName, networkSecurityGroupName, securityRuleName, options) } // NewListPager mocks base method. func (m *MockSecurityRulesClient) NewListPager(resourceGroupName, networkSecurityGroupName string, options *armnetwork.SecurityRulesClientListOptions) clients.SecurityRulesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, networkSecurityGroupName, options) ret0, _ := ret[0].(clients.SecurityRulesPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockSecurityRulesClientMockRecorder) NewListPager(resourceGroupName, networkSecurityGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockSecurityRulesClient)(nil).NewListPager), resourceGroupName, networkSecurityGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_shared_gallery_images_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: shared-gallery-images-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_shared_gallery_images_client.go -package=mocks -source=shared-gallery-images-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSharedGalleryImagesClient is a mock of SharedGalleryImagesClient interface. type MockSharedGalleryImagesClient struct { ctrl *gomock.Controller recorder *MockSharedGalleryImagesClientMockRecorder isgomock struct{} } // MockSharedGalleryImagesClientMockRecorder is the mock recorder for MockSharedGalleryImagesClient. type MockSharedGalleryImagesClientMockRecorder struct { mock *MockSharedGalleryImagesClient } // NewMockSharedGalleryImagesClient creates a new mock instance. func NewMockSharedGalleryImagesClient(ctrl *gomock.Controller) *MockSharedGalleryImagesClient { mock := &MockSharedGalleryImagesClient{ctrl: ctrl} mock.recorder = &MockSharedGalleryImagesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSharedGalleryImagesClient) EXPECT() *MockSharedGalleryImagesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSharedGalleryImagesClient) Get(ctx context.Context, location, galleryUniqueName, galleryImageName string, options *armcompute.SharedGalleryImagesClientGetOptions) (armcompute.SharedGalleryImagesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, location, galleryUniqueName, galleryImageName, options) ret0, _ := ret[0].(armcompute.SharedGalleryImagesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSharedGalleryImagesClientMockRecorder) Get(ctx, location, galleryUniqueName, galleryImageName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSharedGalleryImagesClient)(nil).Get), ctx, location, galleryUniqueName, galleryImageName, options) } // NewListPager mocks base method. func (m *MockSharedGalleryImagesClient) NewListPager(location, galleryUniqueName string, options *armcompute.SharedGalleryImagesClientListOptions) clients.SharedGalleryImagesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", location, galleryUniqueName, options) ret0, _ := ret[0].(clients.SharedGalleryImagesPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockSharedGalleryImagesClientMockRecorder) NewListPager(location, galleryUniqueName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockSharedGalleryImagesClient)(nil).NewListPager), location, galleryUniqueName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_snapshots_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: snapshots-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_snapshots_client.go -package=mocks -source=snapshots-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSnapshotsClient is a mock of SnapshotsClient interface. type MockSnapshotsClient struct { ctrl *gomock.Controller recorder *MockSnapshotsClientMockRecorder isgomock struct{} } // MockSnapshotsClientMockRecorder is the mock recorder for MockSnapshotsClient. type MockSnapshotsClientMockRecorder struct { mock *MockSnapshotsClient } // NewMockSnapshotsClient creates a new mock instance. func NewMockSnapshotsClient(ctrl *gomock.Controller) *MockSnapshotsClient { mock := &MockSnapshotsClient{ctrl: ctrl} mock.recorder = &MockSnapshotsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSnapshotsClient) EXPECT() *MockSnapshotsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSnapshotsClient) Get(ctx context.Context, resourceGroupName, snapshotName string, options *armcompute.SnapshotsClientGetOptions) (armcompute.SnapshotsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, snapshotName, options) ret0, _ := ret[0].(armcompute.SnapshotsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSnapshotsClientMockRecorder) Get(ctx, resourceGroupName, snapshotName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSnapshotsClient)(nil).Get), ctx, resourceGroupName, snapshotName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockSnapshotsClient) NewListByResourceGroupPager(resourceGroupName string, options *armcompute.SnapshotsClientListByResourceGroupOptions) clients.SnapshotsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.SnapshotsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockSnapshotsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockSnapshotsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_database_schemas_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-database-schemas-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_database_schemas_client.go -package=mocks -source=sql-database-schemas-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlDatabaseSchemasClient is a mock of SqlDatabaseSchemasClient interface. type MockSqlDatabaseSchemasClient struct { ctrl *gomock.Controller recorder *MockSqlDatabaseSchemasClientMockRecorder isgomock struct{} } // MockSqlDatabaseSchemasClientMockRecorder is the mock recorder for MockSqlDatabaseSchemasClient. type MockSqlDatabaseSchemasClientMockRecorder struct { mock *MockSqlDatabaseSchemasClient } // NewMockSqlDatabaseSchemasClient creates a new mock instance. func NewMockSqlDatabaseSchemasClient(ctrl *gomock.Controller) *MockSqlDatabaseSchemasClient { mock := &MockSqlDatabaseSchemasClient{ctrl: ctrl} mock.recorder = &MockSqlDatabaseSchemasClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlDatabaseSchemasClient) EXPECT() *MockSqlDatabaseSchemasClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlDatabaseSchemasClient) Get(ctx context.Context, resourceGroupName, serverName, databaseName, schemaName string) (armsql.DatabaseSchemasClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, databaseName, schemaName) ret0, _ := ret[0].(armsql.DatabaseSchemasClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlDatabaseSchemasClientMockRecorder) Get(ctx, resourceGroupName, serverName, databaseName, schemaName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlDatabaseSchemasClient)(nil).Get), ctx, resourceGroupName, serverName, databaseName, schemaName) } // ListByDatabase mocks base method. func (m *MockSqlDatabaseSchemasClient) ListByDatabase(ctx context.Context, resourceGroupName, serverName, databaseName string) clients.SqlDatabaseSchemasPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByDatabase", ctx, resourceGroupName, serverName, databaseName) ret0, _ := ret[0].(clients.SqlDatabaseSchemasPager) return ret0 } // ListByDatabase indicates an expected call of ListByDatabase. func (mr *MockSqlDatabaseSchemasClientMockRecorder) ListByDatabase(ctx, resourceGroupName, serverName, databaseName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByDatabase", reflect.TypeOf((*MockSqlDatabaseSchemasClient)(nil).ListByDatabase), ctx, resourceGroupName, serverName, databaseName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_databases_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-databases-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_databases_client.go -package=mocks -source=sql-databases-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlDatabasesClient is a mock of SqlDatabasesClient interface. type MockSqlDatabasesClient struct { ctrl *gomock.Controller recorder *MockSqlDatabasesClientMockRecorder isgomock struct{} } // MockSqlDatabasesClientMockRecorder is the mock recorder for MockSqlDatabasesClient. type MockSqlDatabasesClientMockRecorder struct { mock *MockSqlDatabasesClient } // NewMockSqlDatabasesClient creates a new mock instance. func NewMockSqlDatabasesClient(ctrl *gomock.Controller) *MockSqlDatabasesClient { mock := &MockSqlDatabasesClient{ctrl: ctrl} mock.recorder = &MockSqlDatabasesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlDatabasesClient) EXPECT() *MockSqlDatabasesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlDatabasesClient) Get(ctx context.Context, resourceGroupName, serverName, databaseName string) (armsql.DatabasesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, databaseName) ret0, _ := ret[0].(armsql.DatabasesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlDatabasesClientMockRecorder) Get(ctx, resourceGroupName, serverName, databaseName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlDatabasesClient)(nil).Get), ctx, resourceGroupName, serverName, databaseName) } // ListByServer mocks base method. func (m *MockSqlDatabasesClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlDatabasesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.SqlDatabasesPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockSqlDatabasesClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockSqlDatabasesClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_elastic_pool_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-elastic-pool-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_elastic_pool_client.go -package=mocks -source=sql-elastic-pool-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlElasticPoolClient is a mock of SqlElasticPoolClient interface. type MockSqlElasticPoolClient struct { ctrl *gomock.Controller recorder *MockSqlElasticPoolClientMockRecorder isgomock struct{} } // MockSqlElasticPoolClientMockRecorder is the mock recorder for MockSqlElasticPoolClient. type MockSqlElasticPoolClientMockRecorder struct { mock *MockSqlElasticPoolClient } // NewMockSqlElasticPoolClient creates a new mock instance. func NewMockSqlElasticPoolClient(ctrl *gomock.Controller) *MockSqlElasticPoolClient { mock := &MockSqlElasticPoolClient{ctrl: ctrl} mock.recorder = &MockSqlElasticPoolClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlElasticPoolClient) EXPECT() *MockSqlElasticPoolClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlElasticPoolClient) Get(ctx context.Context, resourceGroupName, serverName, elasticPoolName string) (armsql.ElasticPoolsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, elasticPoolName) ret0, _ := ret[0].(armsql.ElasticPoolsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlElasticPoolClientMockRecorder) Get(ctx, resourceGroupName, serverName, elasticPoolName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlElasticPoolClient)(nil).Get), ctx, resourceGroupName, serverName, elasticPoolName) } // ListByServer mocks base method. func (m *MockSqlElasticPoolClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlElasticPoolPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.SqlElasticPoolPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockSqlElasticPoolClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockSqlElasticPoolClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_failover_groups_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-failover-groups-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_failover_groups_client.go -package=mocks -source=sql-failover-groups-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlFailoverGroupsClient is a mock of SqlFailoverGroupsClient interface. type MockSqlFailoverGroupsClient struct { ctrl *gomock.Controller recorder *MockSqlFailoverGroupsClientMockRecorder isgomock struct{} } // MockSqlFailoverGroupsClientMockRecorder is the mock recorder for MockSqlFailoverGroupsClient. type MockSqlFailoverGroupsClientMockRecorder struct { mock *MockSqlFailoverGroupsClient } // NewMockSqlFailoverGroupsClient creates a new mock instance. func NewMockSqlFailoverGroupsClient(ctrl *gomock.Controller) *MockSqlFailoverGroupsClient { mock := &MockSqlFailoverGroupsClient{ctrl: ctrl} mock.recorder = &MockSqlFailoverGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlFailoverGroupsClient) EXPECT() *MockSqlFailoverGroupsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlFailoverGroupsClient) Get(ctx context.Context, resourceGroupName, serverName, failoverGroupName string) (armsql.FailoverGroupsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, failoverGroupName) ret0, _ := ret[0].(armsql.FailoverGroupsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlFailoverGroupsClientMockRecorder) Get(ctx, resourceGroupName, serverName, failoverGroupName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlFailoverGroupsClient)(nil).Get), ctx, resourceGroupName, serverName, failoverGroupName) } // ListByServer mocks base method. func (m *MockSqlFailoverGroupsClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlFailoverGroupsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.SqlFailoverGroupsPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockSqlFailoverGroupsClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockSqlFailoverGroupsClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_server_firewall_rule_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-server-firewall-rule-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_server_firewall_rule_client.go -package=mocks -source=sql-server-firewall-rule-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlServerFirewallRuleClient is a mock of SqlServerFirewallRuleClient interface. type MockSqlServerFirewallRuleClient struct { ctrl *gomock.Controller recorder *MockSqlServerFirewallRuleClientMockRecorder isgomock struct{} } // MockSqlServerFirewallRuleClientMockRecorder is the mock recorder for MockSqlServerFirewallRuleClient. type MockSqlServerFirewallRuleClientMockRecorder struct { mock *MockSqlServerFirewallRuleClient } // NewMockSqlServerFirewallRuleClient creates a new mock instance. func NewMockSqlServerFirewallRuleClient(ctrl *gomock.Controller) *MockSqlServerFirewallRuleClient { mock := &MockSqlServerFirewallRuleClient{ctrl: ctrl} mock.recorder = &MockSqlServerFirewallRuleClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlServerFirewallRuleClient) EXPECT() *MockSqlServerFirewallRuleClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlServerFirewallRuleClient) Get(ctx context.Context, resourceGroupName, serverName, firewallRuleName string) (armsql.FirewallRulesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, firewallRuleName) ret0, _ := ret[0].(armsql.FirewallRulesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlServerFirewallRuleClientMockRecorder) Get(ctx, resourceGroupName, serverName, firewallRuleName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlServerFirewallRuleClient)(nil).Get), ctx, resourceGroupName, serverName, firewallRuleName) } // ListByServer mocks base method. func (m *MockSqlServerFirewallRuleClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlServerFirewallRulePager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.SqlServerFirewallRulePager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockSqlServerFirewallRuleClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockSqlServerFirewallRuleClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_server_keys_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-server-keys-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_server_keys_client.go -package=mocks -source=sql-server-keys-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlServerKeysClient is a mock of SqlServerKeysClient interface. type MockSqlServerKeysClient struct { ctrl *gomock.Controller recorder *MockSqlServerKeysClientMockRecorder isgomock struct{} } // MockSqlServerKeysClientMockRecorder is the mock recorder for MockSqlServerKeysClient. type MockSqlServerKeysClientMockRecorder struct { mock *MockSqlServerKeysClient } // NewMockSqlServerKeysClient creates a new mock instance. func NewMockSqlServerKeysClient(ctrl *gomock.Controller) *MockSqlServerKeysClient { mock := &MockSqlServerKeysClient{ctrl: ctrl} mock.recorder = &MockSqlServerKeysClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlServerKeysClient) EXPECT() *MockSqlServerKeysClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlServerKeysClient) Get(ctx context.Context, resourceGroupName, serverName, keyName string) (armsql.ServerKeysClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, keyName) ret0, _ := ret[0].(armsql.ServerKeysClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlServerKeysClientMockRecorder) Get(ctx, resourceGroupName, serverName, keyName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlServerKeysClient)(nil).Get), ctx, resourceGroupName, serverName, keyName) } // NewListByServerPager mocks base method. func (m *MockSqlServerKeysClient) NewListByServerPager(resourceGroupName, serverName string) clients.SqlServerKeysPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByServerPager", resourceGroupName, serverName) ret0, _ := ret[0].(clients.SqlServerKeysPager) return ret0 } // NewListByServerPager indicates an expected call of NewListByServerPager. func (mr *MockSqlServerKeysClientMockRecorder) NewListByServerPager(resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByServerPager", reflect.TypeOf((*MockSqlServerKeysClient)(nil).NewListByServerPager), resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_server_private_endpoint_connection_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-server-private-endpoint-connection-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_server_private_endpoint_connection_client.go -package=mocks -source=sql-server-private-endpoint-connection-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSQLServerPrivateEndpointConnectionsClient is a mock of SQLServerPrivateEndpointConnectionsClient interface. type MockSQLServerPrivateEndpointConnectionsClient struct { ctrl *gomock.Controller recorder *MockSQLServerPrivateEndpointConnectionsClientMockRecorder isgomock struct{} } // MockSQLServerPrivateEndpointConnectionsClientMockRecorder is the mock recorder for MockSQLServerPrivateEndpointConnectionsClient. type MockSQLServerPrivateEndpointConnectionsClientMockRecorder struct { mock *MockSQLServerPrivateEndpointConnectionsClient } // NewMockSQLServerPrivateEndpointConnectionsClient creates a new mock instance. func NewMockSQLServerPrivateEndpointConnectionsClient(ctrl *gomock.Controller) *MockSQLServerPrivateEndpointConnectionsClient { mock := &MockSQLServerPrivateEndpointConnectionsClient{ctrl: ctrl} mock.recorder = &MockSQLServerPrivateEndpointConnectionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSQLServerPrivateEndpointConnectionsClient) EXPECT() *MockSQLServerPrivateEndpointConnectionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSQLServerPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName, serverName, privateEndpointConnectionName string) (armsql.PrivateEndpointConnectionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, privateEndpointConnectionName) ret0, _ := ret[0].(armsql.PrivateEndpointConnectionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSQLServerPrivateEndpointConnectionsClientMockRecorder) Get(ctx, resourceGroupName, serverName, privateEndpointConnectionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSQLServerPrivateEndpointConnectionsClient)(nil).Get), ctx, resourceGroupName, serverName, privateEndpointConnectionName) } // ListByServer mocks base method. func (m *MockSQLServerPrivateEndpointConnectionsClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SQLServerPrivateEndpointConnectionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.SQLServerPrivateEndpointConnectionsPager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockSQLServerPrivateEndpointConnectionsClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockSQLServerPrivateEndpointConnectionsClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_server_virtual_network_rule_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-server-virtual-network-rule-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_server_virtual_network_rule_client.go -package=mocks -source=sql-server-virtual-network-rule-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlServerVirtualNetworkRuleClient is a mock of SqlServerVirtualNetworkRuleClient interface. type MockSqlServerVirtualNetworkRuleClient struct { ctrl *gomock.Controller recorder *MockSqlServerVirtualNetworkRuleClientMockRecorder isgomock struct{} } // MockSqlServerVirtualNetworkRuleClientMockRecorder is the mock recorder for MockSqlServerVirtualNetworkRuleClient. type MockSqlServerVirtualNetworkRuleClientMockRecorder struct { mock *MockSqlServerVirtualNetworkRuleClient } // NewMockSqlServerVirtualNetworkRuleClient creates a new mock instance. func NewMockSqlServerVirtualNetworkRuleClient(ctrl *gomock.Controller) *MockSqlServerVirtualNetworkRuleClient { mock := &MockSqlServerVirtualNetworkRuleClient{ctrl: ctrl} mock.recorder = &MockSqlServerVirtualNetworkRuleClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlServerVirtualNetworkRuleClient) EXPECT() *MockSqlServerVirtualNetworkRuleClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlServerVirtualNetworkRuleClient) Get(ctx context.Context, resourceGroupName, serverName, virtualNetworkRuleName string) (armsql.VirtualNetworkRulesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, virtualNetworkRuleName) ret0, _ := ret[0].(armsql.VirtualNetworkRulesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlServerVirtualNetworkRuleClientMockRecorder) Get(ctx, resourceGroupName, serverName, virtualNetworkRuleName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlServerVirtualNetworkRuleClient)(nil).Get), ctx, resourceGroupName, serverName, virtualNetworkRuleName) } // ListByServer mocks base method. func (m *MockSqlServerVirtualNetworkRuleClient) ListByServer(ctx context.Context, resourceGroupName, serverName string) clients.SqlServerVirtualNetworkRulePager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByServer", ctx, resourceGroupName, serverName) ret0, _ := ret[0].(clients.SqlServerVirtualNetworkRulePager) return ret0 } // ListByServer indicates an expected call of ListByServer. func (mr *MockSqlServerVirtualNetworkRuleClientMockRecorder) ListByServer(ctx, resourceGroupName, serverName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByServer", reflect.TypeOf((*MockSqlServerVirtualNetworkRuleClient)(nil).ListByServer), ctx, resourceGroupName, serverName) } ================================================ FILE: sources/azure/shared/mocks/mock_sql_servers_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: sql-servers-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_sql_servers_client.go -package=mocks -source=sql-servers-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armsql "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSqlServersClient is a mock of SqlServersClient interface. type MockSqlServersClient struct { ctrl *gomock.Controller recorder *MockSqlServersClientMockRecorder isgomock struct{} } // MockSqlServersClientMockRecorder is the mock recorder for MockSqlServersClient. type MockSqlServersClientMockRecorder struct { mock *MockSqlServersClient } // NewMockSqlServersClient creates a new mock instance. func NewMockSqlServersClient(ctrl *gomock.Controller) *MockSqlServersClient { mock := &MockSqlServersClient{ctrl: ctrl} mock.recorder = &MockSqlServersClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSqlServersClient) EXPECT() *MockSqlServersClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSqlServersClient) Get(ctx context.Context, resourceGroupName, serverName string, options *armsql.ServersClientGetOptions) (armsql.ServersClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, serverName, options) ret0, _ := ret[0].(armsql.ServersClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSqlServersClientMockRecorder) Get(ctx, resourceGroupName, serverName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSqlServersClient)(nil).Get), ctx, resourceGroupName, serverName, options) } // ListByResourceGroup mocks base method. func (m *MockSqlServersClient) ListByResourceGroup(ctx context.Context, resourceGroupName string, options *armsql.ServersClientListByResourceGroupOptions) clients.SqlServersPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByResourceGroup", ctx, resourceGroupName, options) ret0, _ := ret[0].(clients.SqlServersPager) return ret0 } // ListByResourceGroup indicates an expected call of ListByResourceGroup. func (mr *MockSqlServersClientMockRecorder) ListByResourceGroup(ctx, resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByResourceGroup", reflect.TypeOf((*MockSqlServersClient)(nil).ListByResourceGroup), ctx, resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_storage_accounts_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: storage-accounts-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_storage_accounts_client.go -package=mocks -source=storage-accounts-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockStorageAccountsClient is a mock of StorageAccountsClient interface. type MockStorageAccountsClient struct { ctrl *gomock.Controller recorder *MockStorageAccountsClientMockRecorder isgomock struct{} } // MockStorageAccountsClientMockRecorder is the mock recorder for MockStorageAccountsClient. type MockStorageAccountsClientMockRecorder struct { mock *MockStorageAccountsClient } // NewMockStorageAccountsClient creates a new mock instance. func NewMockStorageAccountsClient(ctrl *gomock.Controller) *MockStorageAccountsClient { mock := &MockStorageAccountsClient{ctrl: ctrl} mock.recorder = &MockStorageAccountsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStorageAccountsClient) EXPECT() *MockStorageAccountsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockStorageAccountsClient) Get(ctx context.Context, resourceGroupName, accountName string) (armstorage.AccountsClientGetPropertiesResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(armstorage.AccountsClientGetPropertiesResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockStorageAccountsClientMockRecorder) Get(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStorageAccountsClient)(nil).Get), ctx, resourceGroupName, accountName) } // NewListByResourceGroupPager mocks base method. func (m *MockStorageAccountsClient) NewListByResourceGroupPager(resourceGroupName string, options *armstorage.AccountsClientListByResourceGroupOptions) clients.StorageAccountsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.StorageAccountsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockStorageAccountsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockStorageAccountsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_storage_accounts_pager.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/overmindtech/cli/sources/azure/clients (interfaces: StorageAccountsPagerInterface) // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_storage_accounts_pager.go -package=mocks github.com/overmindtech/cli/sources/azure/clients StorageAccountsPagerInterface // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" gomock "go.uber.org/mock/gomock" ) // MockStorageAccountsPagerInterface is a mock of StorageAccountsPagerInterface interface. type MockStorageAccountsPagerInterface struct { ctrl *gomock.Controller recorder *MockStorageAccountsPagerInterfaceMockRecorder isgomock struct{} } // MockStorageAccountsPagerInterfaceMockRecorder is the mock recorder for MockStorageAccountsPagerInterface. type MockStorageAccountsPagerInterfaceMockRecorder struct { mock *MockStorageAccountsPagerInterface } // NewMockStorageAccountsPagerInterface creates a new mock instance. func NewMockStorageAccountsPagerInterface(ctrl *gomock.Controller) *MockStorageAccountsPagerInterface { mock := &MockStorageAccountsPagerInterface{ctrl: ctrl} mock.recorder = &MockStorageAccountsPagerInterfaceMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStorageAccountsPagerInterface) EXPECT() *MockStorageAccountsPagerInterfaceMockRecorder { return m.recorder } // More mocks base method. func (m *MockStorageAccountsPagerInterface) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } // More indicates an expected call of More. func (mr *MockStorageAccountsPagerInterfaceMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeOf((*MockStorageAccountsPagerInterface)(nil).More)) } // NextPage mocks base method. func (m *MockStorageAccountsPagerInterface) NextPage(ctx context.Context) (armstorage.AccountsClientListByResourceGroupResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armstorage.AccountsClientListByResourceGroupResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // NextPage indicates an expected call of NextPage. func (mr *MockStorageAccountsPagerInterfaceMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeOf((*MockStorageAccountsPagerInterface)(nil).NextPage), ctx) } ================================================ FILE: sources/azure/shared/mocks/mock_storage_private_endpoint_connection_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: storage-private-endpoint-connection-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_storage_private_endpoint_connection_client.go -package=mocks -source=storage-private-endpoint-connection-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockStoragePrivateEndpointConnectionsClient is a mock of StoragePrivateEndpointConnectionsClient interface. type MockStoragePrivateEndpointConnectionsClient struct { ctrl *gomock.Controller recorder *MockStoragePrivateEndpointConnectionsClientMockRecorder isgomock struct{} } // MockStoragePrivateEndpointConnectionsClientMockRecorder is the mock recorder for MockStoragePrivateEndpointConnectionsClient. type MockStoragePrivateEndpointConnectionsClientMockRecorder struct { mock *MockStoragePrivateEndpointConnectionsClient } // NewMockStoragePrivateEndpointConnectionsClient creates a new mock instance. func NewMockStoragePrivateEndpointConnectionsClient(ctrl *gomock.Controller) *MockStoragePrivateEndpointConnectionsClient { mock := &MockStoragePrivateEndpointConnectionsClient{ctrl: ctrl} mock.recorder = &MockStoragePrivateEndpointConnectionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStoragePrivateEndpointConnectionsClient) EXPECT() *MockStoragePrivateEndpointConnectionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockStoragePrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName, accountName, privateEndpointConnectionName string) (armstorage.PrivateEndpointConnectionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, privateEndpointConnectionName) ret0, _ := ret[0].(armstorage.PrivateEndpointConnectionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockStoragePrivateEndpointConnectionsClientMockRecorder) Get(ctx, resourceGroupName, accountName, privateEndpointConnectionName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStoragePrivateEndpointConnectionsClient)(nil).Get), ctx, resourceGroupName, accountName, privateEndpointConnectionName) } // List mocks base method. func (m *MockStoragePrivateEndpointConnectionsClient) List(ctx context.Context, resourceGroupName, accountName string) clients.PrivateEndpointConnectionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.PrivateEndpointConnectionsPager) return ret0 } // List indicates an expected call of List. func (mr *MockStoragePrivateEndpointConnectionsClientMockRecorder) List(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockStoragePrivateEndpointConnectionsClient)(nil).List), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_subnets_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: subnets-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_subnets_client.go -package=mocks -source=subnets-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockSubnetsClient is a mock of SubnetsClient interface. type MockSubnetsClient struct { ctrl *gomock.Controller recorder *MockSubnetsClientMockRecorder isgomock struct{} } // MockSubnetsClientMockRecorder is the mock recorder for MockSubnetsClient. type MockSubnetsClientMockRecorder struct { mock *MockSubnetsClient } // NewMockSubnetsClient creates a new mock instance. func NewMockSubnetsClient(ctrl *gomock.Controller) *MockSubnetsClient { mock := &MockSubnetsClient{ctrl: ctrl} mock.recorder = &MockSubnetsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockSubnetsClient) EXPECT() *MockSubnetsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockSubnetsClient) Get(ctx context.Context, resourceGroupName, virtualNetworkName, subnetName string, options *armnetwork.SubnetsClientGetOptions) (armnetwork.SubnetsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, virtualNetworkName, subnetName, options) ret0, _ := ret[0].(armnetwork.SubnetsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockSubnetsClientMockRecorder) Get(ctx, resourceGroupName, virtualNetworkName, subnetName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSubnetsClient)(nil).Get), ctx, resourceGroupName, virtualNetworkName, subnetName, options) } // NewListPager mocks base method. func (m *MockSubnetsClient) NewListPager(resourceGroupName, virtualNetworkName string, options *armnetwork.SubnetsClientListOptions) clients.SubnetsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, virtualNetworkName, options) ret0, _ := ret[0].(clients.SubnetsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockSubnetsClientMockRecorder) NewListPager(resourceGroupName, virtualNetworkName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockSubnetsClient)(nil).NewListPager), resourceGroupName, virtualNetworkName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_tables_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: tables-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_tables_client.go -package=mocks -source=tables-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armstorage "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockTablesClient is a mock of TablesClient interface. type MockTablesClient struct { ctrl *gomock.Controller recorder *MockTablesClientMockRecorder isgomock struct{} } // MockTablesClientMockRecorder is the mock recorder for MockTablesClient. type MockTablesClientMockRecorder struct { mock *MockTablesClient } // NewMockTablesClient creates a new mock instance. func NewMockTablesClient(ctrl *gomock.Controller) *MockTablesClient { mock := &MockTablesClient{ctrl: ctrl} mock.recorder = &MockTablesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockTablesClient) EXPECT() *MockTablesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockTablesClient) Get(ctx context.Context, resourceGroupName, accountName, tableName string) (armstorage.TableClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, accountName, tableName) ret0, _ := ret[0].(armstorage.TableClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockTablesClientMockRecorder) Get(ctx, resourceGroupName, accountName, tableName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockTablesClient)(nil).Get), ctx, resourceGroupName, accountName, tableName) } // List mocks base method. func (m *MockTablesClient) List(ctx context.Context, resourceGroupName, accountName string) clients.TablesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, accountName) ret0, _ := ret[0].(clients.TablesPager) return ret0 } // List indicates an expected call of List. func (mr *MockTablesClientMockRecorder) List(ctx, resourceGroupName, accountName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockTablesClient)(nil).List), ctx, resourceGroupName, accountName) } ================================================ FILE: sources/azure/shared/mocks/mock_user_assigned_identities_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: user-assigned-identities-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_user_assigned_identities_client.go -package=mocks -source=user-assigned-identities-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armmsi "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockUserAssignedIdentitiesClient is a mock of UserAssignedIdentitiesClient interface. type MockUserAssignedIdentitiesClient struct { ctrl *gomock.Controller recorder *MockUserAssignedIdentitiesClientMockRecorder isgomock struct{} } // MockUserAssignedIdentitiesClientMockRecorder is the mock recorder for MockUserAssignedIdentitiesClient. type MockUserAssignedIdentitiesClientMockRecorder struct { mock *MockUserAssignedIdentitiesClient } // NewMockUserAssignedIdentitiesClient creates a new mock instance. func NewMockUserAssignedIdentitiesClient(ctrl *gomock.Controller) *MockUserAssignedIdentitiesClient { mock := &MockUserAssignedIdentitiesClient{ctrl: ctrl} mock.recorder = &MockUserAssignedIdentitiesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockUserAssignedIdentitiesClient) EXPECT() *MockUserAssignedIdentitiesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockUserAssignedIdentitiesClient) Get(ctx context.Context, resourceGroupName, resourceName string, options *armmsi.UserAssignedIdentitiesClientGetOptions) (armmsi.UserAssignedIdentitiesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, resourceName, options) ret0, _ := ret[0].(armmsi.UserAssignedIdentitiesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockUserAssignedIdentitiesClientMockRecorder) Get(ctx, resourceGroupName, resourceName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockUserAssignedIdentitiesClient)(nil).Get), ctx, resourceGroupName, resourceName, options) } // ListByResourceGroup mocks base method. func (m *MockUserAssignedIdentitiesClient) ListByResourceGroup(resourceGroupName string, options *armmsi.UserAssignedIdentitiesClientListByResourceGroupOptions) clients.UserAssignedIdentitiesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListByResourceGroup", resourceGroupName, options) ret0, _ := ret[0].(clients.UserAssignedIdentitiesPager) return ret0 } // ListByResourceGroup indicates an expected call of ListByResourceGroup. func (mr *MockUserAssignedIdentitiesClientMockRecorder) ListByResourceGroup(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByResourceGroup", reflect.TypeOf((*MockUserAssignedIdentitiesClient)(nil).ListByResourceGroup), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_vaults_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: vaults-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_vaults_client.go -package=mocks -source=vaults-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armkeyvault "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVaultsClient is a mock of VaultsClient interface. type MockVaultsClient struct { ctrl *gomock.Controller recorder *MockVaultsClientMockRecorder isgomock struct{} } // MockVaultsClientMockRecorder is the mock recorder for MockVaultsClient. type MockVaultsClientMockRecorder struct { mock *MockVaultsClient } // NewMockVaultsClient creates a new mock instance. func NewMockVaultsClient(ctrl *gomock.Controller) *MockVaultsClient { mock := &MockVaultsClient{ctrl: ctrl} mock.recorder = &MockVaultsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVaultsClient) EXPECT() *MockVaultsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVaultsClient) Get(ctx context.Context, resourceGroupName, vaultName string, options *armkeyvault.VaultsClientGetOptions) (armkeyvault.VaultsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, vaultName, options) ret0, _ := ret[0].(armkeyvault.VaultsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVaultsClientMockRecorder) Get(ctx, resourceGroupName, vaultName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVaultsClient)(nil).Get), ctx, resourceGroupName, vaultName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockVaultsClient) NewListByResourceGroupPager(resourceGroupName string, options *armkeyvault.VaultsClientListByResourceGroupOptions) clients.VaultsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.VaultsPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockVaultsClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockVaultsClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_machine_extensions_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-machine-extensions-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_machine_extensions_client.go -package=mocks -source=virtual-machine-extensions-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" gomock "go.uber.org/mock/gomock" ) // MockVirtualMachineExtensionsClient is a mock of VirtualMachineExtensionsClient interface. type MockVirtualMachineExtensionsClient struct { ctrl *gomock.Controller recorder *MockVirtualMachineExtensionsClientMockRecorder isgomock struct{} } // MockVirtualMachineExtensionsClientMockRecorder is the mock recorder for MockVirtualMachineExtensionsClient. type MockVirtualMachineExtensionsClientMockRecorder struct { mock *MockVirtualMachineExtensionsClient } // NewMockVirtualMachineExtensionsClient creates a new mock instance. func NewMockVirtualMachineExtensionsClient(ctrl *gomock.Controller) *MockVirtualMachineExtensionsClient { mock := &MockVirtualMachineExtensionsClient{ctrl: ctrl} mock.recorder = &MockVirtualMachineExtensionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualMachineExtensionsClient) EXPECT() *MockVirtualMachineExtensionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualMachineExtensionsClient) Get(ctx context.Context, resourceGroupName, virtualMachineName, vmExtensionName string, options *armcompute.VirtualMachineExtensionsClientGetOptions) (armcompute.VirtualMachineExtensionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, virtualMachineName, vmExtensionName, options) ret0, _ := ret[0].(armcompute.VirtualMachineExtensionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualMachineExtensionsClientMockRecorder) Get(ctx, resourceGroupName, virtualMachineName, vmExtensionName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualMachineExtensionsClient)(nil).Get), ctx, resourceGroupName, virtualMachineName, vmExtensionName, options) } // List mocks base method. func (m *MockVirtualMachineExtensionsClient) List(ctx context.Context, resourceGroupName, virtualMachineName string, options *armcompute.VirtualMachineExtensionsClientListOptions) (armcompute.VirtualMachineExtensionsClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, resourceGroupName, virtualMachineName, options) ret0, _ := ret[0].(armcompute.VirtualMachineExtensionsClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockVirtualMachineExtensionsClientMockRecorder) List(ctx, resourceGroupName, virtualMachineName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockVirtualMachineExtensionsClient)(nil).List), ctx, resourceGroupName, virtualMachineName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_machine_run_commands_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-machine-run-commands-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_machine_run_commands_client.go -package=mocks -source=virtual-machine-run-commands-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualMachineRunCommandsClient is a mock of VirtualMachineRunCommandsClient interface. type MockVirtualMachineRunCommandsClient struct { ctrl *gomock.Controller recorder *MockVirtualMachineRunCommandsClientMockRecorder isgomock struct{} } // MockVirtualMachineRunCommandsClientMockRecorder is the mock recorder for MockVirtualMachineRunCommandsClient. type MockVirtualMachineRunCommandsClientMockRecorder struct { mock *MockVirtualMachineRunCommandsClient } // NewMockVirtualMachineRunCommandsClient creates a new mock instance. func NewMockVirtualMachineRunCommandsClient(ctrl *gomock.Controller) *MockVirtualMachineRunCommandsClient { mock := &MockVirtualMachineRunCommandsClient{ctrl: ctrl} mock.recorder = &MockVirtualMachineRunCommandsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualMachineRunCommandsClient) EXPECT() *MockVirtualMachineRunCommandsClientMockRecorder { return m.recorder } // GetByVirtualMachine mocks base method. func (m *MockVirtualMachineRunCommandsClient) GetByVirtualMachine(ctx context.Context, resourceGroupName, virtualMachineName, runCommandName string, options *armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineOptions) (armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetByVirtualMachine", ctx, resourceGroupName, virtualMachineName, runCommandName, options) ret0, _ := ret[0].(armcompute.VirtualMachineRunCommandsClientGetByVirtualMachineResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // GetByVirtualMachine indicates an expected call of GetByVirtualMachine. func (mr *MockVirtualMachineRunCommandsClientMockRecorder) GetByVirtualMachine(ctx, resourceGroupName, virtualMachineName, runCommandName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByVirtualMachine", reflect.TypeOf((*MockVirtualMachineRunCommandsClient)(nil).GetByVirtualMachine), ctx, resourceGroupName, virtualMachineName, runCommandName, options) } // NewListByVirtualMachinePager mocks base method. func (m *MockVirtualMachineRunCommandsClient) NewListByVirtualMachinePager(resourceGroupName, virtualMachineName string, options *armcompute.VirtualMachineRunCommandsClientListByVirtualMachineOptions) clients.VirtualMachineRunCommandsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByVirtualMachinePager", resourceGroupName, virtualMachineName, options) ret0, _ := ret[0].(clients.VirtualMachineRunCommandsPager) return ret0 } // NewListByVirtualMachinePager indicates an expected call of NewListByVirtualMachinePager. func (mr *MockVirtualMachineRunCommandsClientMockRecorder) NewListByVirtualMachinePager(resourceGroupName, virtualMachineName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByVirtualMachinePager", reflect.TypeOf((*MockVirtualMachineRunCommandsClient)(nil).NewListByVirtualMachinePager), resourceGroupName, virtualMachineName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_machine_scale_sets_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-machine-scale-sets-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_machine_scale_sets_client.go -package=mocks -source=virtual-machine-scale-sets-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualMachineScaleSetsClient is a mock of VirtualMachineScaleSetsClient interface. type MockVirtualMachineScaleSetsClient struct { ctrl *gomock.Controller recorder *MockVirtualMachineScaleSetsClientMockRecorder isgomock struct{} } // MockVirtualMachineScaleSetsClientMockRecorder is the mock recorder for MockVirtualMachineScaleSetsClient. type MockVirtualMachineScaleSetsClientMockRecorder struct { mock *MockVirtualMachineScaleSetsClient } // NewMockVirtualMachineScaleSetsClient creates a new mock instance. func NewMockVirtualMachineScaleSetsClient(ctrl *gomock.Controller) *MockVirtualMachineScaleSetsClient { mock := &MockVirtualMachineScaleSetsClient{ctrl: ctrl} mock.recorder = &MockVirtualMachineScaleSetsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualMachineScaleSetsClient) EXPECT() *MockVirtualMachineScaleSetsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualMachineScaleSetsClient) Get(ctx context.Context, resourceGroupName, virtualMachineScaleSetName string, options *armcompute.VirtualMachineScaleSetsClientGetOptions) (armcompute.VirtualMachineScaleSetsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, virtualMachineScaleSetName, options) ret0, _ := ret[0].(armcompute.VirtualMachineScaleSetsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualMachineScaleSetsClientMockRecorder) Get(ctx, resourceGroupName, virtualMachineScaleSetName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualMachineScaleSetsClient)(nil).Get), ctx, resourceGroupName, virtualMachineScaleSetName, options) } // NewListPager mocks base method. func (m *MockVirtualMachineScaleSetsClient) NewListPager(resourceGroupName string, options *armcompute.VirtualMachineScaleSetsClientListOptions) clients.VirtualMachineScaleSetsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.VirtualMachineScaleSetsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockVirtualMachineScaleSetsClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockVirtualMachineScaleSetsClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_machines_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-machines-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_machines_client.go -package=mocks -source=virtual-machines-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualMachinesClient is a mock of VirtualMachinesClient interface. type MockVirtualMachinesClient struct { ctrl *gomock.Controller recorder *MockVirtualMachinesClientMockRecorder isgomock struct{} } // MockVirtualMachinesClientMockRecorder is the mock recorder for MockVirtualMachinesClient. type MockVirtualMachinesClientMockRecorder struct { mock *MockVirtualMachinesClient } // NewMockVirtualMachinesClient creates a new mock instance. func NewMockVirtualMachinesClient(ctrl *gomock.Controller) *MockVirtualMachinesClient { mock := &MockVirtualMachinesClient{ctrl: ctrl} mock.recorder = &MockVirtualMachinesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualMachinesClient) EXPECT() *MockVirtualMachinesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualMachinesClient) Get(ctx context.Context, resourceGroupName, vmName string, options *armcompute.VirtualMachinesClientGetOptions) (armcompute.VirtualMachinesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, vmName, options) ret0, _ := ret[0].(armcompute.VirtualMachinesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualMachinesClientMockRecorder) Get(ctx, resourceGroupName, vmName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualMachinesClient)(nil).Get), ctx, resourceGroupName, vmName, options) } // NewListPager mocks base method. func (m *MockVirtualMachinesClient) NewListPager(resourceGroupName string, options *armcompute.VirtualMachinesClientListOptions) clients.VirtualMachinesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.VirtualMachinesPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockVirtualMachinesClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockVirtualMachinesClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_machines_pager.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/overmindtech/cli/sources/azure/clients (interfaces: VirtualMachinesPagerInterface) // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_machines_pager.go -package=mocks github.com/overmindtech/cli/sources/azure/clients VirtualMachinesPagerInterface // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" gomock "go.uber.org/mock/gomock" ) // MockVirtualMachinesPagerInterface is a mock of VirtualMachinesPagerInterface interface. type MockVirtualMachinesPagerInterface struct { ctrl *gomock.Controller recorder *MockVirtualMachinesPagerInterfaceMockRecorder isgomock struct{} } // MockVirtualMachinesPagerInterfaceMockRecorder is the mock recorder for MockVirtualMachinesPagerInterface. type MockVirtualMachinesPagerInterfaceMockRecorder struct { mock *MockVirtualMachinesPagerInterface } // NewMockVirtualMachinesPagerInterface creates a new mock instance. func NewMockVirtualMachinesPagerInterface(ctrl *gomock.Controller) *MockVirtualMachinesPagerInterface { mock := &MockVirtualMachinesPagerInterface{ctrl: ctrl} mock.recorder = &MockVirtualMachinesPagerInterfaceMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualMachinesPagerInterface) EXPECT() *MockVirtualMachinesPagerInterfaceMockRecorder { return m.recorder } // More mocks base method. func (m *MockVirtualMachinesPagerInterface) More() bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "More") ret0, _ := ret[0].(bool) return ret0 } // More indicates an expected call of More. func (mr *MockVirtualMachinesPagerInterfaceMockRecorder) More() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "More", reflect.TypeOf((*MockVirtualMachinesPagerInterface)(nil).More)) } // NextPage mocks base method. func (m *MockVirtualMachinesPagerInterface) NextPage(ctx context.Context) (armcompute.VirtualMachinesClientListResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NextPage", ctx) ret0, _ := ret[0].(armcompute.VirtualMachinesClientListResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // NextPage indicates an expected call of NextPage. func (mr *MockVirtualMachinesPagerInterfaceMockRecorder) NextPage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextPage", reflect.TypeOf((*MockVirtualMachinesPagerInterface)(nil).NextPage), ctx) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_network_gateway_connections_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-network-gateway-connections-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_network_gateway_connections_client.go -package=mocks -source=virtual-network-gateway-connections-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualNetworkGatewayConnectionsClient is a mock of VirtualNetworkGatewayConnectionsClient interface. type MockVirtualNetworkGatewayConnectionsClient struct { ctrl *gomock.Controller recorder *MockVirtualNetworkGatewayConnectionsClientMockRecorder isgomock struct{} } // MockVirtualNetworkGatewayConnectionsClientMockRecorder is the mock recorder for MockVirtualNetworkGatewayConnectionsClient. type MockVirtualNetworkGatewayConnectionsClientMockRecorder struct { mock *MockVirtualNetworkGatewayConnectionsClient } // NewMockVirtualNetworkGatewayConnectionsClient creates a new mock instance. func NewMockVirtualNetworkGatewayConnectionsClient(ctrl *gomock.Controller) *MockVirtualNetworkGatewayConnectionsClient { mock := &MockVirtualNetworkGatewayConnectionsClient{ctrl: ctrl} mock.recorder = &MockVirtualNetworkGatewayConnectionsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualNetworkGatewayConnectionsClient) EXPECT() *MockVirtualNetworkGatewayConnectionsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualNetworkGatewayConnectionsClient) Get(ctx context.Context, resourceGroupName, virtualNetworkGatewayConnectionName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, virtualNetworkGatewayConnectionName, options) ret0, _ := ret[0].(armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualNetworkGatewayConnectionsClientMockRecorder) Get(ctx, resourceGroupName, virtualNetworkGatewayConnectionName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualNetworkGatewayConnectionsClient)(nil).Get), ctx, resourceGroupName, virtualNetworkGatewayConnectionName, options) } // NewListPager mocks base method. func (m *MockVirtualNetworkGatewayConnectionsClient) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientListOptions) clients.VirtualNetworkGatewayConnectionsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.VirtualNetworkGatewayConnectionsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockVirtualNetworkGatewayConnectionsClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockVirtualNetworkGatewayConnectionsClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_network_gateways_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-network-gateways-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_network_gateways_client.go -package=mocks -source=virtual-network-gateways-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualNetworkGatewaysClient is a mock of VirtualNetworkGatewaysClient interface. type MockVirtualNetworkGatewaysClient struct { ctrl *gomock.Controller recorder *MockVirtualNetworkGatewaysClientMockRecorder isgomock struct{} } // MockVirtualNetworkGatewaysClientMockRecorder is the mock recorder for MockVirtualNetworkGatewaysClient. type MockVirtualNetworkGatewaysClientMockRecorder struct { mock *MockVirtualNetworkGatewaysClient } // NewMockVirtualNetworkGatewaysClient creates a new mock instance. func NewMockVirtualNetworkGatewaysClient(ctrl *gomock.Controller) *MockVirtualNetworkGatewaysClient { mock := &MockVirtualNetworkGatewaysClient{ctrl: ctrl} mock.recorder = &MockVirtualNetworkGatewaysClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualNetworkGatewaysClient) EXPECT() *MockVirtualNetworkGatewaysClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualNetworkGatewaysClient) Get(ctx context.Context, resourceGroupName, virtualNetworkGatewayName string, options *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, virtualNetworkGatewayName, options) ret0, _ := ret[0].(armnetwork.VirtualNetworkGatewaysClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualNetworkGatewaysClientMockRecorder) Get(ctx, resourceGroupName, virtualNetworkGatewayName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualNetworkGatewaysClient)(nil).Get), ctx, resourceGroupName, virtualNetworkGatewayName, options) } // NewListPager mocks base method. func (m *MockVirtualNetworkGatewaysClient) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewaysClientListOptions) clients.VirtualNetworkGatewaysPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.VirtualNetworkGatewaysPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockVirtualNetworkGatewaysClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockVirtualNetworkGatewaysClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_network_links_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-network-links-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_network_links_client.go -package=mocks -source=virtual-network-links-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armprivatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualNetworkLinksClient is a mock of VirtualNetworkLinksClient interface. type MockVirtualNetworkLinksClient struct { ctrl *gomock.Controller recorder *MockVirtualNetworkLinksClientMockRecorder isgomock struct{} } // MockVirtualNetworkLinksClientMockRecorder is the mock recorder for MockVirtualNetworkLinksClient. type MockVirtualNetworkLinksClientMockRecorder struct { mock *MockVirtualNetworkLinksClient } // NewMockVirtualNetworkLinksClient creates a new mock instance. func NewMockVirtualNetworkLinksClient(ctrl *gomock.Controller) *MockVirtualNetworkLinksClient { mock := &MockVirtualNetworkLinksClient{ctrl: ctrl} mock.recorder = &MockVirtualNetworkLinksClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualNetworkLinksClient) EXPECT() *MockVirtualNetworkLinksClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualNetworkLinksClient) Get(ctx context.Context, resourceGroupName, privateZoneName, virtualNetworkLinkName string, options *armprivatedns.VirtualNetworkLinksClientGetOptions) (armprivatedns.VirtualNetworkLinksClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, privateZoneName, virtualNetworkLinkName, options) ret0, _ := ret[0].(armprivatedns.VirtualNetworkLinksClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualNetworkLinksClientMockRecorder) Get(ctx, resourceGroupName, privateZoneName, virtualNetworkLinkName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualNetworkLinksClient)(nil).Get), ctx, resourceGroupName, privateZoneName, virtualNetworkLinkName, options) } // NewListPager mocks base method. func (m *MockVirtualNetworkLinksClient) NewListPager(resourceGroupName, privateZoneName string, options *armprivatedns.VirtualNetworkLinksClientListOptions) clients.VirtualNetworkLinksPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, privateZoneName, options) ret0, _ := ret[0].(clients.VirtualNetworkLinksPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockVirtualNetworkLinksClientMockRecorder) NewListPager(resourceGroupName, privateZoneName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockVirtualNetworkLinksClient)(nil).NewListPager), resourceGroupName, privateZoneName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_network_peerings_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-network-peerings-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_network_peerings_client.go -package=mocks -source=virtual-network-peerings-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualNetworkPeeringsClient is a mock of VirtualNetworkPeeringsClient interface. type MockVirtualNetworkPeeringsClient struct { ctrl *gomock.Controller recorder *MockVirtualNetworkPeeringsClientMockRecorder isgomock struct{} } // MockVirtualNetworkPeeringsClientMockRecorder is the mock recorder for MockVirtualNetworkPeeringsClient. type MockVirtualNetworkPeeringsClientMockRecorder struct { mock *MockVirtualNetworkPeeringsClient } // NewMockVirtualNetworkPeeringsClient creates a new mock instance. func NewMockVirtualNetworkPeeringsClient(ctrl *gomock.Controller) *MockVirtualNetworkPeeringsClient { mock := &MockVirtualNetworkPeeringsClient{ctrl: ctrl} mock.recorder = &MockVirtualNetworkPeeringsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualNetworkPeeringsClient) EXPECT() *MockVirtualNetworkPeeringsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualNetworkPeeringsClient) Get(ctx context.Context, resourceGroupName, virtualNetworkName, peeringName string, options *armnetwork.VirtualNetworkPeeringsClientGetOptions) (armnetwork.VirtualNetworkPeeringsClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, virtualNetworkName, peeringName, options) ret0, _ := ret[0].(armnetwork.VirtualNetworkPeeringsClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualNetworkPeeringsClientMockRecorder) Get(ctx, resourceGroupName, virtualNetworkName, peeringName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualNetworkPeeringsClient)(nil).Get), ctx, resourceGroupName, virtualNetworkName, peeringName, options) } // NewListPager mocks base method. func (m *MockVirtualNetworkPeeringsClient) NewListPager(resourceGroupName, virtualNetworkName string, options *armnetwork.VirtualNetworkPeeringsClientListOptions) clients.VirtualNetworkPeeringsPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, virtualNetworkName, options) ret0, _ := ret[0].(clients.VirtualNetworkPeeringsPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockVirtualNetworkPeeringsClientMockRecorder) NewListPager(resourceGroupName, virtualNetworkName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockVirtualNetworkPeeringsClient)(nil).NewListPager), resourceGroupName, virtualNetworkName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_virtual_networks_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: virtual-networks-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_virtual_networks_client.go -package=mocks -source=virtual-networks-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockVirtualNetworksClient is a mock of VirtualNetworksClient interface. type MockVirtualNetworksClient struct { ctrl *gomock.Controller recorder *MockVirtualNetworksClientMockRecorder isgomock struct{} } // MockVirtualNetworksClientMockRecorder is the mock recorder for MockVirtualNetworksClient. type MockVirtualNetworksClientMockRecorder struct { mock *MockVirtualNetworksClient } // NewMockVirtualNetworksClient creates a new mock instance. func NewMockVirtualNetworksClient(ctrl *gomock.Controller) *MockVirtualNetworksClient { mock := &MockVirtualNetworksClient{ctrl: ctrl} mock.recorder = &MockVirtualNetworksClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockVirtualNetworksClient) EXPECT() *MockVirtualNetworksClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockVirtualNetworksClient) Get(ctx context.Context, resourceGroupName, virtualNetworkName string, options *armnetwork.VirtualNetworksClientGetOptions) (armnetwork.VirtualNetworksClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, virtualNetworkName, options) ret0, _ := ret[0].(armnetwork.VirtualNetworksClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockVirtualNetworksClientMockRecorder) Get(ctx, resourceGroupName, virtualNetworkName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVirtualNetworksClient)(nil).Get), ctx, resourceGroupName, virtualNetworkName, options) } // NewListPager mocks base method. func (m *MockVirtualNetworksClient) NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworksClientListOptions) clients.VirtualNetworksPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, options) ret0, _ := ret[0].(clients.VirtualNetworksPager) return ret0 } // NewListPager indicates an expected call of NewListPager. func (mr *MockVirtualNetworksClientMockRecorder) NewListPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockVirtualNetworksClient)(nil).NewListPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/mock_zones_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: zones-client.go // // Generated by this command: // // mockgen -destination=../shared/mocks/mock_zones_client.go -package=mocks -source=zones-client.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" armdns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" clients "github.com/overmindtech/cli/sources/azure/clients" gomock "go.uber.org/mock/gomock" ) // MockZonesClient is a mock of ZonesClient interface. type MockZonesClient struct { ctrl *gomock.Controller recorder *MockZonesClientMockRecorder isgomock struct{} } // MockZonesClientMockRecorder is the mock recorder for MockZonesClient. type MockZonesClientMockRecorder struct { mock *MockZonesClient } // NewMockZonesClient creates a new mock instance. func NewMockZonesClient(ctrl *gomock.Controller) *MockZonesClient { mock := &MockZonesClient{ctrl: ctrl} mock.recorder = &MockZonesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockZonesClient) EXPECT() *MockZonesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockZonesClient) Get(ctx context.Context, resourceGroupName, zoneName string, options *armdns.ZonesClientGetOptions) (armdns.ZonesClientGetResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, zoneName, options) ret0, _ := ret[0].(armdns.ZonesClientGetResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockZonesClientMockRecorder) Get(ctx, resourceGroupName, zoneName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockZonesClient)(nil).Get), ctx, resourceGroupName, zoneName, options) } // NewListByResourceGroupPager mocks base method. func (m *MockZonesClient) NewListByResourceGroupPager(resourceGroupName string, options *armdns.ZonesClientListByResourceGroupOptions) clients.ZonesPager { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) ret0, _ := ret[0].(clients.ZonesPager) return ret0 } // NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. func (mr *MockZonesClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockZonesClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) } ================================================ FILE: sources/azure/shared/mocks/pager_helpers.go ================================================ package mocks import ( "go.uber.org/mock/gomock" ) // Type aliases and helper functions to match the expected mock names in tests. // These allow tests to use the shorter names while the actual mocks implement // the concrete interfaces. // MockVirtualMachinesPager is a type alias for MockVirtualMachinesPagerInterface // to match the expected name in tests. type MockVirtualMachinesPager = MockVirtualMachinesPagerInterface // NewMockVirtualMachinesPager creates a new mock instance of VirtualMachinesPager. // This is a convenience function that matches the expected name in tests. // Returns the concrete mock type so tests can call .EXPECT() on it. func NewMockVirtualMachinesPager(ctrl *gomock.Controller) *MockVirtualMachinesPager { return NewMockVirtualMachinesPagerInterface(ctrl) } // MockStorageAccountsPager is a type alias for MockStorageAccountsPagerInterface // to match the expected name in tests. type MockStorageAccountsPager = MockStorageAccountsPagerInterface // NewMockStorageAccountsPager creates a new mock instance of StorageAccountsPager. // This is a convenience function that matches the expected name in tests. // Returns the concrete mock type so tests can call .EXPECT() on it. func NewMockStorageAccountsPager(ctrl *gomock.Controller) *MockStorageAccountsPager { return NewMockStorageAccountsPagerInterface(ctrl) } ================================================ FILE: sources/azure/shared/models.go ================================================ package shared import ( "github.com/overmindtech/cli/sources/shared" ) const Azure shared.Source = "azure" // APIs (Azure Resource Provider namespaces) // Azure organizes resources by resource providers (e.g., Microsoft.Compute, Microsoft.Network) // We use simplified names following the same pattern as GCP // Reference: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-services-resource-providers const ( // Compute Compute shared.API = "compute" // Microsoft.Compute // Networking Network shared.API = "network" // Microsoft.Network // Storage Storage shared.API = "storage" // Microsoft.Storage // SQL SQL shared.API = "sql" // Microsoft.Sql // DocumentDB DocumentDB shared.API = "documentdb" // Microsoft.DocumentDB // KeyVault KeyVault shared.API = "keyvault" // Microsoft.KeyVault // ManagedIdentity ManagedIdentity shared.API = "managedidentity" // Microsoft.ManagedIdentity // Batch Batch shared.API = "batch" // Microsoft.Batch // DBforPostgreSQL DBforPostgreSQL shared.API = "dbforpostgresql" // Microsoft.DBforPostgreSQL // ElasticSAN ElasticSAN shared.API = "elasticsan" // Microsoft.ElasticSan // Authorization Authorization shared.API = "authorization" // Microsoft.Authorization // Maintenance Maintenance shared.API = "maintenance" // Microsoft.Maintenance // Resources (subscriptions, resource groups) Resources shared.API = "resources" // Microsoft.Resources // OperationalInsights OperationalInsights shared.API = "operationalinsights" // Microsoft.OperationalInsights // Insights (Azure Monitor) Insights shared.API = "insights" // microsoft.insights // ExtendedLocation (custom locations, edge zones) ExtendedLocation shared.API = "extendedlocation" // Microsoft.ExtendedLocation ) // Resources // These represent the actual resource types within each Azure resource provider const ( // Compute resources VirtualMachine shared.Resource = "virtual-machine" Disk shared.Resource = "disk" AvailabilitySet shared.Resource = "availability-set" VirtualMachineExtension shared.Resource = "virtual-machine-extension" VirtualMachineRunCommand shared.Resource = "virtual-machine-run-command" VirtualMachineScaleSet shared.Resource = "virtual-machine-scale-set" DiskEncryptionSet shared.Resource = "disk-encryption-set" ProximityPlacementGroup shared.Resource = "proximity-placement-group" DedicatedHostGroup shared.Resource = "dedicated-host-group" DedicatedHost shared.Resource = "dedicated-host" CapacityReservationGroup shared.Resource = "capacity-reservation-group" CapacityReservation shared.Resource = "capacity-reservation" Image shared.Resource = "image" Snapshot shared.Resource = "snapshot" DiskAccess shared.Resource = "disk-access" DiskAccessPrivateEndpointConnection shared.Resource = "disk-access-private-endpoint-connection" SharedGalleryImage shared.Resource = "shared-gallery-image" SharedGallery shared.Resource = "shared-gallery" CommunityGalleryImage shared.Resource = "community-gallery-image" GalleryApplicationVersion shared.Resource = "gallery-application-version" GalleryApplication shared.Resource = "gallery-application" GalleryImage shared.Resource = "gallery-image" Gallery shared.Resource = "gallery" // Network resources VirtualNetwork shared.Resource = "virtual-network" Subnet shared.Resource = "subnet" NetworkInterface shared.Resource = "network-interface" PublicIPAddress shared.Resource = "public-ip-address" NetworkSecurityGroup shared.Resource = "network-security-group" VirtualNetworkPeering shared.Resource = "virtual-network-peering" NetworkInterfaceIPConfiguration shared.Resource = "network-interface-ip-configuration" PrivateEndpoint shared.Resource = "private-endpoint" LoadBalancer shared.Resource = "load-balancer" LoadBalancerFrontendIPConfiguration shared.Resource = "load-balancer-frontend-ip-configuration" LoadBalancerBackendAddressPool shared.Resource = "load-balancer-backend-address-pool" LoadBalancerInboundNatRule shared.Resource = "load-balancer-inbound-nat-rule" LoadBalancerLoadBalancingRule shared.Resource = "load-balancer-load-balancing-rule" LoadBalancerProbe shared.Resource = "load-balancer-probe" LoadBalancerOutboundRule shared.Resource = "load-balancer-outbound-rule" LoadBalancerInboundNatPool shared.Resource = "load-balancer-inbound-nat-pool" PublicIPPrefix shared.Resource = "public-ip-prefix" CustomIPPrefix shared.Resource = "custom-ip-prefix" NatGateway shared.Resource = "nat-gateway" DdosProtectionPlan shared.Resource = "ddos-protection-plan" ApplicationGateway shared.Resource = "application-gateway" ApplicationGatewayBackendAddressPool shared.Resource = "application-gateway-backend-address-pool" ApplicationGatewayFrontendIPConfiguration shared.Resource = "application-gateway-frontend-ip-configuration" ApplicationGatewayGatewayIPConfiguration shared.Resource = "application-gateway-gateway-ip-configuration" ApplicationGatewayHTTPListener shared.Resource = "application-gateway-http-listener" ApplicationGatewayBackendHTTPSettings shared.Resource = "application-gateway-backend-http-settings" ApplicationGatewayRequestRoutingRule shared.Resource = "application-gateway-request-routing-rule" ApplicationGatewayProbe shared.Resource = "application-gateway-probe" ApplicationGatewaySSLCertificate shared.Resource = "application-gateway-ssl-certificate" ApplicationGatewayURLPathMap shared.Resource = "application-gateway-url-path-map" ApplicationGatewayAuthenticationCertificate shared.Resource = "application-gateway-authentication-certificate" ApplicationGatewayTrustedRootCertificate shared.Resource = "application-gateway-trusted-root-certificate" ApplicationGatewayRewriteRuleSet shared.Resource = "application-gateway-rewrite-rule-set" ApplicationGatewayRedirectConfiguration shared.Resource = "application-gateway-redirect-configuration" ApplicationGatewayWebApplicationFirewallPolicy shared.Resource = "application-gateway-web-application-firewall-policy" ApplicationSecurityGroup shared.Resource = "application-security-group" SecurityRule shared.Resource = "security-rule" DefaultSecurityRule shared.Resource = "default-security-rule" IPGroup shared.Resource = "ip-group" Firewall shared.Resource = "firewall" FirewallPolicy shared.Resource = "firewall-policy" RouteTable shared.Resource = "route-table" Route shared.Resource = "route" VirtualNetworkGateway shared.Resource = "virtual-network-gateway" VirtualNetworkGatewayConnection shared.Resource = "virtual-network-gateway-connection" VirtualNetworkGatewayNatRule shared.Resource = "virtual-network-gateway-nat-rule" VirtualNetworkGatewayIPConfiguration shared.Resource = "virtual-network-gateway-ip-configuration" LocalNetworkGateway shared.Resource = "local-network-gateway" ExpressRouteCircuitPeering shared.Resource = "express-route-circuit-peering" PrivateDNSZone shared.Resource = "private-dns-zone" Zone shared.Resource = "zone" DNSRecordSet shared.Resource = "dns-record-set" DNSVirtualNetworkLink shared.Resource = "dns-virtual-network-link" FlowLog shared.Resource = "flow-log" PrivateLinkService shared.Resource = "private-link-service" DscpConfiguration shared.Resource = "dscp-configuration" VirtualNetworkTap shared.Resource = "virtual-network-tap" NetworkInterfaceTapConfiguration shared.Resource = "network-interface-tap-configuration" ServiceEndpointPolicy shared.Resource = "service-endpoint-policy" IpAllocation shared.Resource = "ip-allocation" NetworkWatcher shared.Resource = "network-watcher" // Storage resources Account shared.Resource = "account" BlobContainer shared.Resource = "blob-container" EncryptionScope shared.Resource = "encryption-scope" FileShare shared.Resource = "file-share" Table shared.Resource = "table" Queue shared.Resource = "queue" StorageAccountPrivateEndpointConnection shared.Resource = "storage-account-private-endpoint-connection" // SQL resources Database shared.Resource = "database" RecoverableDatabase shared.Resource = "recoverable-database" RestorableDroppedDatabase shared.Resource = "restorable-dropped-database" RecoveryServicesRecoveryPoint shared.Resource = "recovery-services-recovery-point" Server shared.Resource = "server" ElasticPool shared.Resource = "elastic-pool" ServerFirewallRule shared.Resource = "server-firewall-rule" ServerVirtualNetworkRule shared.Resource = "server-virtual-network-rule" ServerKey shared.Resource = "server-key" ServerFailoverGroup shared.Resource = "server-failover-group" ServerAdministrator shared.Resource = "server-administrator" ServerSyncGroup shared.Resource = "server-sync-group" ServerSyncAgent shared.Resource = "server-sync-agent" ServerPrivateEndpointConnection shared.Resource = "server-private-endpoint-connection" ServerAuditingSetting shared.Resource = "server-auditing-setting" ServerSecurityAlertPolicy shared.Resource = "server-security-alert-policy" ServerVulnerabilityAssessment shared.Resource = "server-vulnerability-assessment" ServerEncryptionProtector shared.Resource = "server-encryption-protector" ServerBlobAuditingPolicy shared.Resource = "server-blob-auditing-policy" ServerAutomaticTuning shared.Resource = "server-automatic-tuning" ServerAdvancedThreatProtectionSetting shared.Resource = "server-advanced-threat-protection-setting" ServerDnsAlias shared.Resource = "server-dns-alias" ServerUsage shared.Resource = "server-usage" ServerOperation shared.Resource = "server-operation" ServerAdvisor shared.Resource = "server-advisor" ServerBackupLongTermRetentionPolicy shared.Resource = "server-backup-long-term-retention-policy" ServerDevOpsAuditSetting shared.Resource = "server-devops-audit-setting" ServerTrustGroup shared.Resource = "server-trust-group" ServerOutboundFirewallRule shared.Resource = "server-outbound-firewall-rule" ServerPrivateLinkResource shared.Resource = "server-private-link-resource" LongTermRetentionBackup shared.Resource = "long-term-retention-backup" DatabaseSchema shared.Resource = "database-schema" // Maintenance resources MaintenanceConfiguration shared.Resource = "maintenance-configuration" // DBforPostgreSQL resources FlexibleServer shared.Resource = "flexible-server" FlexibleServerFirewallRule shared.Resource = "flexible-server-firewall-rule" FlexibleServerConfiguration shared.Resource = "flexible-server-configuration" FlexibleServerAdministrator shared.Resource = "flexible-server-administrator" FlexibleServerPrivateEndpointConnection shared.Resource = "flexible-server-private-endpoint-connection" FlexibleServerPrivateLinkResource shared.Resource = "flexible-server-private-link-resource" FlexibleServerReplica shared.Resource = "flexible-server-replica" FlexibleServerMigration shared.Resource = "flexible-server-migration" FlexibleServerBackup shared.Resource = "flexible-server-backup" FlexibleServerVirtualEndpoint shared.Resource = "flexible-server-virtual-endpoint" // DocumentDB resources DatabaseAccounts shared.Resource = "database-accounts" PrivateEndpointConnection shared.Resource = "private-endpoint-connection" // KeyVault resources Vault shared.Resource = "vault" Secret shared.Resource = "secret" Key shared.Resource = "key" ManagedHSM shared.Resource = "managed-hsm" ManagedHSMPrivateEndpointConnection shared.Resource = "managed-hsm-private-endpoint-connection" // ManagedIdentity resources UserAssignedIdentity shared.Resource = "user-assigned-identity" FederatedIdentityCredential shared.Resource = "federated-identity-credential" // Batch resources BatchAccount shared.Resource = "batch-account" BatchApplication shared.Resource = "batch-application" BatchApplicationPackage shared.Resource = "batch-application-package" BatchPool shared.Resource = "batch-pool" BatchCertificate shared.Resource = "batch-certificate" BatchPrivateEndpointConnection shared.Resource = "batch-private-endpoint-connection" BatchPrivateLinkResource shared.Resource = "batch-private-link-resource" BatchDetector shared.Resource = "batch-detector" // ElasticSAN resources ElasticSanResource shared.Resource = "elastic-san" VolumeGroup shared.Resource = "volume-group" Volume shared.Resource = "volume" VolumeSnapshot shared.Resource = "elastic-san-volume-snapshot" // Authorization resources RoleAssignment shared.Resource = "role-assignment" RoleDefinition shared.Resource = "role-definition" // Resources (subscriptions, resource groups) Subscription shared.Resource = "subscription" ResourceGroup shared.Resource = "resource-group" // OperationalInsights resources Workspace shared.Resource = "workspace" Cluster shared.Resource = "cluster" // Insights (Azure Monitor) resources PrivateLinkScopeScopedResource shared.Resource = "private-link-scope-scoped-resource" // ExtendedLocation resources CustomLocation shared.Resource = "custom-location" ) ================================================ FILE: sources/azure/shared/resource_id_item_type.go ================================================ package shared import ( "strings" "unicode" ) // azureProviderToAPI maps Azure resource provider namespaces to the short API names used in // item types (see models.go). Enables generated linked queries to match existing adapter // naming: azure-{api}-{resource} with kebab-case resource. var azureProviderToAPI = map[string]string{ "microsoft.compute": "compute", "microsoft.network": "network", "microsoft.storage": "storage", "microsoft.sql": "sql", "microsoft.documentdb": "documentdb", "microsoft.keyvault": "keyvault", "microsoft.managedidentity": "managedidentity", "microsoft.batch": "batch", "microsoft.dbforpostgresql": "dbforpostgresql", "microsoft.elasticsan": "elasticsan", "microsoft.authorization": "authorization", "microsoft.maintenance": "maintenance", "microsoft.resources": "resources", } // CamelCaseToKebab converts Azure camelCase resource type (e.g. virtualNetworks, publicIPAddresses) // to kebab-case (e.g. virtual-networks, public-ip-addresses) to match project convention in models.go. // Consecutive uppercase letters are treated as a single acronym (e.g. IP stays together). func CamelCaseToKebab(s string) string { if s == "" { return "" } var b strings.Builder runes := []rune(s) for i, r := range runes { if unicode.IsUpper(r) { prevLower := i > 0 && unicode.IsLower(runes[i-1]) nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) // Insert hyphen before uppercase when: after a lowercase letter, or when this uppercase starts a word (next is lower) if i > 0 && (prevLower || (unicode.IsUpper(runes[i-1]) && nextLower)) { b.WriteByte('-') } b.WriteRune(unicode.ToLower(r)) } else { b.WriteRune(unicode.ToLower(r)) } } return b.String() } // SingularizeResourceType converts Azure plural resource type to singular form to match // models.go (e.g. virtual-networks -> virtual-network, galleries -> gallery, identities -> identity). func SingularizeResourceType(kebab string) string { if kebab == "" { return kebab } // -ies -> -y (e.g. galleries -> gallery, user-assigned-identities -> user-assigned-identity) if before, ok := strings.CutSuffix(kebab, "ies"); ok { return before + "y" } // -addresses -> -address (e.g. public-ip-addresses -> public-ip-address) if before, ok := strings.CutSuffix(kebab, "addresses"); ok { return before + "address" } if before, ok := strings.CutSuffix(kebab, "s"); ok { return before } return kebab } // ItemTypeFromLinkedResourceID derives an item type string from an Azure resource ID for use in // LinkedItemQueries (e.g. ResourceNavigationLink, ServiceAssociationLink). Uses short API names // and kebab-case singular resource types so generated types match existing adapter naming // (e.g. azure-network-virtual-network). For unknown providers, returns empty so callers can // fall back to a generic type such as "azure-resource". func ItemTypeFromLinkedResourceID(resourceID string) string { if resourceID == "" { return "" } parts := strings.Split(strings.Trim(resourceID, "/"), "/") for i, part := range parts { if strings.EqualFold(part, "providers") && i+2 < len(parts) { provider := strings.ToLower(parts[i+1]) resourceTypeRaw := parts[i+2] api, ok := azureProviderToAPI[provider] if !ok { return "" } resourceType := SingularizeResourceType(CamelCaseToKebab(resourceTypeRaw)) if resourceType == "" { return "" } return "azure-" + api + "-" + resourceType } } return "" } ================================================ FILE: sources/azure/shared/resource_id_item_type_test.go ================================================ package shared_test import ( "testing" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) func TestCamelCaseToKebab(t *testing.T) { tests := []struct { name string input string expected string }{ {"virtualNetworks", "virtualNetworks", "virtual-networks"}, {"managedInstances", "managedInstances", "managed-instances"}, {"applicationGateways", "applicationGateways", "application-gateways"}, {"publicIPAddresses (acronym)", "publicIPAddresses", "public-ip-addresses"}, {"empty", "", ""}, {"single word lowercase", "subnet", "subnet"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := azureshared.CamelCaseToKebab(tc.input) if got != tc.expected { t.Errorf("CamelCaseToKebab(%q) = %q; want %q", tc.input, got, tc.expected) } }) } } func TestSingularizeResourceType(t *testing.T) { tests := []struct { name string input string expected string }{ {"virtual-networks", "virtual-networks", "virtual-network"}, {"managed-instances", "managed-instances", "managed-instance"}, {"galleries -> gallery", "galleries", "gallery"}, {"user-assigned-identities -> user-assigned-identity", "user-assigned-identities", "user-assigned-identity"}, {"public-ip-addresses -> public-ip-address", "public-ip-addresses", "public-ip-address"}, {"no trailing s", "virtual-network", "virtual-network"}, {"empty", "", ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := azureshared.SingularizeResourceType(tc.input) if got != tc.expected { t.Errorf("SingularizeResourceType(%q) = %q; want %q", tc.input, got, tc.expected) } }) } } func TestItemTypeFromLinkedResourceID(t *testing.T) { tests := []struct { name string resourceID string expected string }{ { name: "Microsoft.Network virtualNetworks", resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Network/virtualNetworks/myVnet", expected: "azure-network-virtual-network", }, { name: "Microsoft.Sql managedInstances", resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Sql/managedInstances/myMI", expected: "azure-sql-managed-instance", }, { name: "Microsoft.Compute virtualMachines", resourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm1", expected: "azure-compute-virtual-machine", }, { name: "unknown provider returns empty", resourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Unknown/fooBars/name", expected: "", }, { name: "empty ID returns empty", resourceID: "", expected: "", }, { name: "no providers segment returns empty", resourceID: "/not/a/valid/resource/id", expected: "", }, { name: "Microsoft.Compute galleries", resourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/galleries/myGallery", expected: "azure-compute-gallery", }, { name: "Microsoft.ManagedIdentity userAssignedIdentities", resourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity", expected: "azure-managedidentity-user-assigned-identity", }, { name: "Microsoft.Network publicIPAddresses (acronym)", resourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/publicIPAddresses/myPublicIP", expected: "azure-network-public-ip-address", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := azureshared.ItemTypeFromLinkedResourceID(tc.resourceID) if got != tc.expected { t.Errorf("ItemTypeFromLinkedResourceID(%q) = %q; want %q", tc.resourceID, got, tc.expected) } }) } } ================================================ FILE: sources/azure/shared/scope.go ================================================ package shared import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) // ResourceGroupScope represents a subscription and resource group pair. // It is used by multi-scope adapters to handle multiple resource groups. type ResourceGroupScope struct { SubscriptionID string ResourceGroup string } // NewResourceGroupScope creates a ResourceGroupScope for the given subscription and resource group. func NewResourceGroupScope(subscriptionID, resourceGroup string) ResourceGroupScope { return ResourceGroupScope{ SubscriptionID: subscriptionID, ResourceGroup: resourceGroup, } } // ToScope returns the scope string in format "{subscriptionId}.{resourceGroup}". func (r ResourceGroupScope) ToScope() string { return fmt.Sprintf("%s.%s", r.SubscriptionID, r.ResourceGroup) } // MultiResourceGroupBase provides shared multi-scope behavior for resource-group-scoped adapters. // One adapter instance handles all resource groups in resourceGroupScopes. type MultiResourceGroupBase struct { resourceGroupScopes []ResourceGroupScope *shared.Base } // NewMultiResourceGroupBase creates a MultiResourceGroupBase that supports multiple resource group scopes. func NewMultiResourceGroupBase( resourceGroupScopes []ResourceGroupScope, category sdp.AdapterCategory, item shared.ItemType, ) *MultiResourceGroupBase { if len(resourceGroupScopes) == 0 { panic("NewMultiResourceGroupBase: resourceGroupScopes cannot be empty") } scopeStrings := make([]string, 0, len(resourceGroupScopes)) for _, rgScope := range resourceGroupScopes { scopeStrings = append(scopeStrings, rgScope.ToScope()) } return &MultiResourceGroupBase{ resourceGroupScopes: resourceGroupScopes, Base: shared.NewBase(category, item, scopeStrings), } } // ResourceGroupScopeFromScope parses a scope string and returns the matching ResourceGroupScope // if it is one of the adapter's configured scopes. func (m *MultiResourceGroupBase) ResourceGroupScopeFromScope(scope string) (ResourceGroupScope, error) { subscriptionID := SubscriptionIDFromScope(scope) resourceGroup := ResourceGroupFromScope(scope) if subscriptionID == "" || resourceGroup == "" { return ResourceGroupScope{}, fmt.Errorf("invalid scope format %q: expected subscriptionId.resourceGroup", scope) } rgScope := NewResourceGroupScope(subscriptionID, resourceGroup) for _, s := range m.resourceGroupScopes { if s.SubscriptionID == rgScope.SubscriptionID && s.ResourceGroup == rgScope.ResourceGroup { return rgScope, nil } } return ResourceGroupScope{}, fmt.Errorf("scope %s not found in adapter resource group scopes", scope) } // ResourceGroupScopes returns the configured resource group scopes for this adapter. func (m *MultiResourceGroupBase) ResourceGroupScopes() []ResourceGroupScope { return m.resourceGroupScopes } // DefaultScope returns the first scope (for compatibility where a single default is needed). func (m *MultiResourceGroupBase) DefaultScope() string { return m.Scopes()[0] } ================================================ FILE: sources/azure/shared/utils.go ================================================ package shared import ( "fmt" "net/url" "regexp" "strings" ) // GetResourceIDPathKeys returns the path keys to extract from an Azure resource ID // for a given resource type. These keys are used to extract the necessary parameters // from the resource ID to match the adapter's GetLookups() order. // // For example, for storage queues: // Resource ID: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}/queueServices/default/queues/{queue} // Path keys: ["storageAccounts", "queues"] // Returns: ["{account}", "{queue}"] func GetResourceIDPathKeys(resourceType string) []string { // Map of resource types to their path keys in the order they appear in GetLookups() pathKeysMap := map[string][]string{ "azure-storage-queue": {"storageAccounts", "queues"}, "azure-storage-blob-container": {"storageAccounts", "containers"}, "azure-storage-encryption-scope": {"storageAccounts", "encryptionScopes"}, "azure-storage-file-share": {"storageAccounts", "shares"}, "azure-storage-storage-account-private-endpoint-connection": {"storageAccounts", "privateEndpointConnections"}, "azure-documentdb-private-endpoint-connection": {"databaseAccounts", "privateEndpointConnections"}, "azure-storage-table": {"storageAccounts", "tables"}, "azure-sql-database": {"servers", "databases"}, // "/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/Default-SQL-SouthEastAsia/providers/Microsoft.Sql/servers/testsvr/databases/testdb", "azure-sql-elastic-pool": {"servers", "elasticPools"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/elasticPools/{elasticPoolName}", "azure-sql-server-firewall-rule": {"servers", "firewallRules"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/firewallRules/{ruleName}", "azure-sql-server-virtual-network-rule": {"servers", "virtualNetworkRules"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/virtualNetworkRules/{ruleName}", "azure-sql-server-private-endpoint-connection": {"servers", "privateEndpointConnections"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/servers/{serverName}/privateEndpointConnections/{connectionName}", "azure-dbforpostgresql-database": {"flexibleServers", "databases"}, // "/subscriptions/.../Microsoft.DBforPostgreSQL/flexibleServers/{server}/databases/{db}", "azure-dbforpostgresql-flexible-server-firewall-rule": {"flexibleServers", "firewallRules"}, // "/subscriptions/.../Microsoft.DBforPostgreSQL/flexibleServers/{server}/firewallRules/{rule}", "azure-dbforpostgresql-flexible-server-private-endpoint-connection": {"flexibleServers", "privateEndpointConnections"}, // "/subscriptions/.../Microsoft.DBforPostgreSQL/flexibleServers/{server}/privateEndpointConnections/{connectionName}", "azure-dbforpostgresql-flexible-server-backup": {"flexibleServers", "backups"}, // "/subscriptions/.../Microsoft.DBforPostgreSQL/flexibleServers/{server}/backups/{backupName}", "azure-keyvault-secret": {"vaults", "secrets"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/vaults/{vaultName}/secrets/{secretName}", "azure-keyvault-key": {"vaults", "keys"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/vaults/{vaultName}/keys/{keyName}", "azure-keyvault-managed-hsm-private-endpoint-connection": {"managedHSMs", "privateEndpointConnections"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/managedHSMs/{name}/privateEndpointConnections/{connectionName}", "azure-authorization-role-assignment": {"roleAssignments"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Authorization/roleAssignments/{roleAssignmentName}", "azure-compute-virtual-machine-run-command": {"virtualMachines", "runCommands"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{virtualMachineName}/runCommands/{runCommandName}", "azure-compute-virtual-machine-extension": {"virtualMachines", "extensions"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{virtualMachineName}/extensions/{extensionName}", "azure-compute-gallery-application-version": {"galleries", "applications", "versions"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/applications/{applicationName}/versions/{versionName}", "azure-compute-gallery-application": {"galleries", "applications"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/applications/{applicationName}", "azure-compute-gallery-image": {"galleries", "images"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/galleries/{galleryName}/images/{imageName}", "azure-compute-dedicated-host": {"hostGroups", "hosts"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/hostGroups/{hostGroupName}/hosts/{hostName}", "azure-compute-capacity-reservation": {"capacityReservationGroups", "capacityReservations"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/capacityReservationGroups/{groupName}/capacityReservations/{reservationName}", "azure-network-subnet": {"virtualNetworks", "subnets"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName}", "azure-network-virtual-network-peering": {"virtualNetworks", "virtualNetworkPeerings"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/virtualNetworkPeerings/{peeringName}", "azure-network-route": {"routeTables", "routes"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/routeTables/{routeTableName}/routes/{routeName}", "azure-network-security-rule": {"networkSecurityGroups", "securityRules"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/networkSecurityGroups/{nsgName}/securityRules/{ruleName}", "azure-network-default-security-rule": {"networkSecurityGroups", "defaultSecurityRules"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/networkSecurityGroups/{nsgName}/defaultSecurityRules/{ruleName}", "azure-batch-batch-application": {"batchAccounts", "applications"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Batch/batchAccounts/{accountName}/applications/{applicationName}", "azure-batch-batch-application-package": {"batchAccounts", "applications", "versions"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Batch/batchAccounts/{accountName}/applications/{applicationName}/versions/{versionName}", "azure-batch-batch-pool": {"batchAccounts", "pools"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Batch/batchAccounts/{accountName}/pools/{poolName}", "azure-network-dns-record-set": {"dnszones"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/dnszones/{zoneName}/{recordType}/{relativeRecordSetName}" "azure-elasticsan-elastic-san-volume-group": {"elasticSans", "volumegroups"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ElasticSan/elasticSans/{elasticSanName}/volumegroups/{volumeGroupName}" "azure-elasticsan-volume": {"elasticSans", "volumegroups", "volumes"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ElasticSan/elasticSans/{elasticSanName}/volumegroups/{volumeGroupName}/volumes/{volumeName}" "azure-elasticsan-elastic-san-volume-snapshot": {"elasticSans", "volumegroups", "snapshots"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ElasticSan/elasticSans/{elasticSanName}/volumegroups/{volumeGroupName}/snapshots/{snapshotName}" "azure-compute-disk-access-private-endpoint-connection": {"diskAccesses", "privateEndpointConnections"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/diskAccesses/{diskAccessName}/privateEndpointConnections/{connectionName}" "azure-network-dns-virtual-network-link": {"privateDnsZones", "virtualNetworkLinks"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateDnsZones/{zoneName}/virtualNetworkLinks/{linkName}" "azure-network-load-balancer-frontend-ip-configuration": {"loadBalancers", "frontendIPConfigurations"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/loadBalancers/{lbName}/frontendIPConfigurations/{frontendIPConfigName}" "azure-network-flow-log": {"networkWatchers", "flowLogs"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/networkWatchers/{networkWatcherName}/flowLogs/{flowLogName}" } if keys, ok := pathKeysMap[resourceType]; ok { return keys } return nil } // ExtractResourceName extracts the resource name from an Azure resource ID // Azure resource IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProvider}/{resourceType}/{resourceName} // This function returns the last segment of the path, which is typically the resource name func ExtractResourceName(resourceID string) string { if resourceID == "" { return "" } // Split by "/" and get the last part (resource name) parts := strings.Split(resourceID, "/") if len(parts) > 0 { return parts[len(parts)-1] } return "" } // ExtractPathParamsFromResourceID extracts values following specified path keys from an Azure resource ID. // It returns a slice of values in the order of the keys provided. // // For example, for input="/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}/queueServices/default/queues/{queue}" // and keys=["storageAccounts", "queues"], it will return ["{account}", "{queue}"]. // // Key matching is case-insensitive (Azure resource IDs are case-insensitive) but // only matches at even-indexed path positions (structural key slots) to avoid // misidentifying a resource name that happens to equal a key. // // If a key is not found, the function will return nil. func ExtractPathParamsFromResourceID(resourceID string, keys []string) []string { if resourceID == "" || len(keys) == 0 { return nil } parts := strings.Split(strings.Trim(resourceID, "/"), "/") results := make([]string, 0, len(keys)) for _, key := range keys { found := false for i, part := range parts { // Azure resource IDs alternate key/value segments after trimming: // key0/value0/key1/value1/... Keys are at even indices (0, 2, 4, ...), // values at odd indices. Only match at key positions to prevent a // resource name like "images" from being treated as a path key. if i%2 == 0 && strings.EqualFold(part, key) && i+1 < len(parts) { results = append(results, parts[i+1]) found = true break } } if !found { return nil } } if len(results) != len(keys) { return nil } return results } // ExtractDNSRecordSetParamsFromResourceID extracts zone name, record type, and relative record set name // from an Azure DNS record set resource ID. The path format is non-standard: after "dnszones" the next // three segments are zoneName, recordType (e.g. "A", "AAAA"), and relativeRecordSetName—recordType is // a value, not a path key, so ExtractPathParamsFromResourceID cannot be used. // // Example: .../dnszones/example.com/A/www returns ["example.com", "A", "www"]. // Returns nil if the path does not match the expected structure. func ExtractDNSRecordSetParamsFromResourceID(resourceID string) []string { if resourceID == "" { return nil } parts := strings.Split(strings.Trim(resourceID, "/"), "/") for i, part := range parts { if i%2 == 0 && strings.EqualFold(part, "dnszones") && i+3 < len(parts) { return []string{parts[i+1], parts[i+2], parts[i+3]} } } return nil } // ExtractPathParamsFromResourceIDByType extracts query parts from an Azure resource ID for the given // resource type. For azure-network-dns-record-set it uses ExtractDNSRecordSetParamsFromResourceID // because the DNS path format (dnszones/zone/recordType/name) does not follow the usual key/value // pattern. For all other types it uses GetResourceIDPathKeys and ExtractPathParamsFromResourceID. // Returns nil if the type is unknown or extraction fails. func ExtractPathParamsFromResourceIDByType(resourceType string, resourceID string) []string { if resourceType == "azure-network-dns-record-set" { return ExtractDNSRecordSetParamsFromResourceID(resourceID) } pathKeys := GetResourceIDPathKeys(resourceType) if pathKeys == nil { return nil } return ExtractPathParamsFromResourceID(resourceID, pathKeys) } // ExtractSQLServerNameFromDatabaseID extracts the SQL server name from a SQL database resource ID. // Azure SQL database IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/databases/{databaseName} // This function returns the server name segment. func ExtractSQLServerNameFromDatabaseID(databaseID string) string { if databaseID == "" { return "" } params := ExtractPathParamsFromResourceID(databaseID, []string{"servers"}) if len(params) > 0 { return params[0] } return "" } // ExtractSQLElasticPoolNameFromID extracts the SQL elastic pool name from an elastic pool resource ID. // Azure SQL elastic pool IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/elasticPools/{elasticPoolName} // This function returns the elastic pool name segment. func ExtractSQLElasticPoolNameFromID(elasticPoolID string) string { if elasticPoolID == "" { return "" } params := ExtractPathParamsFromResourceID(elasticPoolID, []string{"elasticPools"}) if len(params) > 0 { return params[0] } return "" } // ExtractSQLDatabaseInfoFromResourceID extracts SQL server name and database name from a SQL database resource ID. // Azure SQL database IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/databases/{databaseName} // Returns serverName and databaseName if the resource ID is a SQL database, otherwise returns empty strings. func ExtractSQLDatabaseInfoFromResourceID(resourceID string) (serverName, databaseName string) { if resourceID == "" { return "", "" } params := ExtractPathParamsFromResourceID(resourceID, []string{"servers", "databases"}) if len(params) >= 2 { return params[0], params[1] } return "", "" } // ExtractSQLRecoverableDatabaseInfoFromResourceID extracts SQL server name and database name from a recoverable database resource ID. // Azure SQL recoverable database IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/recoverableDatabases/{databaseName} // Returns serverName and databaseName if the resource ID is a recoverable database, otherwise returns empty strings. func ExtractSQLRecoverableDatabaseInfoFromResourceID(resourceID string) (serverName, databaseName string) { if resourceID == "" { return "", "" } params := ExtractPathParamsFromResourceID(resourceID, []string{"servers", "recoverableDatabases"}) if len(params) >= 2 { return params[0], params[1] } return "", "" } // ExtractSQLRestorableDroppedDatabaseInfoFromResourceID extracts SQL server name and database name from a restorable dropped database resource ID. // Azure SQL restorable dropped database IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/restorableDroppedDatabases/{databaseName} // Returns serverName and databaseName if the resource ID is a restorable dropped database, otherwise returns empty strings. func ExtractSQLRestorableDroppedDatabaseInfoFromResourceID(resourceID string) (serverName, databaseName string) { if resourceID == "" { return "", "" } params := ExtractPathParamsFromResourceID(resourceID, []string{"servers", "restorableDroppedDatabases"}) if len(params) >= 2 { return params[0], params[1] } return "", "" } // ExtractSQLLongTermRetentionBackupInfoFromResourceID extracts parameters from a long term retention backup resource ID. // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Sql/locations/{location}/longTermRetentionServers/{server}/longTermRetentionDatabases/{db}/longTermRetentionBackups/{backupName} // Returns locationName, serverName, databaseName, backupName if valid, otherwise empty strings. func ExtractSQLLongTermRetentionBackupInfoFromResourceID(resourceID string) (locationName, serverName, databaseName, backupName string) { if resourceID == "" { return "", "", "", "" } params := ExtractPathParamsFromResourceID(resourceID, []string{"locations", "longTermRetentionServers", "longTermRetentionDatabases", "longTermRetentionBackups"}) if len(params) >= 4 { return params[0], params[1], params[2], params[3] } return "", "", "", "" } // ExtractSQLElasticPoolInfoFromResourceID extracts SQL server name and elastic pool name from a SQL elastic pool resource ID. // Azure SQL elastic pool IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Sql/servers/{serverName}/elasticPools/{elasticPoolName} // Returns serverName and elasticPoolName if the resource ID is a SQL elastic pool, otherwise returns empty strings. func ExtractSQLElasticPoolInfoFromResourceID(resourceID string) (serverName, elasticPoolName string) { if resourceID == "" { return "", "" } params := ExtractPathParamsFromResourceID(resourceID, []string{"servers", "elasticPools"}) if len(params) >= 2 { return params[0], params[1] } return "", "" } // SourceResourceType represents the type of resource referenced by SourceResourceID type SourceResourceType int const ( SourceResourceTypeUnknown SourceResourceType = iota SourceResourceTypeSQLDatabase SourceResourceTypeSQLElasticPool // SourceResourceTypeSynapseSQLPool - not yet supported (requires Synapse item types) ) // DetermineSourceResourceType determines the type of resource from a SourceResourceID. // Returns the resource type and extracted parameters for SQL resources. func DetermineSourceResourceType(resourceID string) (SourceResourceType, map[string]string) { if resourceID == "" { return SourceResourceTypeUnknown, nil } // Check for SQL Database if serverName, databaseName := ExtractSQLDatabaseInfoFromResourceID(resourceID); serverName != "" && databaseName != "" { return SourceResourceTypeSQLDatabase, map[string]string{ "serverName": serverName, "databaseName": databaseName, } } // Check for SQL Elastic Pool if serverName, poolName := ExtractSQLElasticPoolInfoFromResourceID(resourceID); serverName != "" && poolName != "" { return SourceResourceTypeSQLElasticPool, map[string]string{ "serverName": serverName, "elasticPoolName": poolName, } } // Check for Synapse SQL Pool (for future support) // Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Synapse/workspaces/{workspaceName}/sqlPools/{poolName} params := ExtractPathParamsFromResourceID(resourceID, []string{"workspaces", "sqlPools"}) if len(params) >= 2 { // Synapse not yet supported - return unknown for now return SourceResourceTypeUnknown, nil } return SourceResourceTypeUnknown, nil } // convertAzureTags converts Azure tags (map[string]*string) to SDP tags (map[string]string) func ConvertAzureTags(azureTags map[string]*string) map[string]string { if azureTags == nil { return nil } tags := make(map[string]string, len(azureTags)) for k, v := range azureTags { if v != nil { tags[k] = *v } } return tags } // ExtractVaultNameFromURI extracts the vault name from a Key Vault URI // Format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} func ExtractVaultNameFromURI(uri string) string { parsedURL, err := url.Parse(uri) if err != nil { return "" } host := parsedURL.Host // Extract vault name from hostname: {vaultName}.vault.azure.net parts := strings.Split(host, ".") if len(parts) > 0 { return parts[0] } return "" } // ExtractKeyNameFromURI extracts the key name from a Key Vault key URI // Format: https://{vaultName}.vault.azure.net/keys/{keyName}/{version} func ExtractKeyNameFromURI(uri string) string { parsedURL, err := url.Parse(uri) if err != nil { return "" } path := strings.Trim(parsedURL.Path, "/") parts := strings.Split(path, "/") // Path format: keys/{keyName}/{version} if len(parts) >= 2 && parts[0] == "keys" { return parts[1] } return "" } // ExtractSecretNameFromURI extracts the secret name from a Key Vault secret URI // Format: https://{vaultName}.vault.azure.net/secrets/{secretName}/{version} func ExtractSecretNameFromURI(uri string) string { parsedURL, err := url.Parse(uri) if err != nil { return "" } path := strings.Trim(parsedURL.Path, "/") parts := strings.Split(path, "/") // Path format: secrets/{secretName}/{version} if len(parts) >= 2 && parts[0] == "secrets" { return parts[1] } return "" } // ExtractSubscriptionIDFromResourceID extracts the subscription ID from an Azure resource ID // Azure resource IDs follow the format: // /subscriptions/{subscriptionId}/providers/... or // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/... // This function returns just the subscription ID // Returns empty string if the subscription ID cannot be found func ExtractSubscriptionIDFromResourceID(resourceID string) string { if resourceID == "" { return "" } parts := strings.Split(strings.Trim(resourceID, "/"), "/") for i, part := range parts { if part == "subscriptions" && i+1 < len(parts) { return parts[i+1] } } return "" } // ExtractResourceGroupFromResourceID extracts the resource group name from an Azure resource ID // Azure resource IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/... // Returns empty string if the resource ID doesn't contain a resource group func ExtractResourceGroupFromResourceID(resourceID string) string { if resourceID == "" { return "" } parts := strings.Split(strings.Trim(resourceID, "/"), "/") for i, part := range parts { if strings.EqualFold(part, "resourceGroups") && i+1 < len(parts) { return parts[i+1] } } return "" } // ExtractScopeFromResourceID extracts the scope (subscription.resourceGroup) from an Azure resource ID // Azure resource IDs follow the format: // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/... // This function returns the scope in the format: "{subscriptionId}.{resourceGroupName}" // Returns empty string if the resource ID doesn't contain both subscription and resource group func ExtractScopeFromResourceID(resourceID string) string { if resourceID == "" { return "" } parts := strings.Split(strings.Trim(resourceID, "/"), "/") if len(parts) < 4 { return "" } // Find subscription ID (should be at index 1 after splitting) subscriptionID := "" resourceGroupName := "" for i, part := range parts { if part == "subscriptions" && i+1 < len(parts) { subscriptionID = parts[i+1] } if part == "resourceGroups" && i+1 < len(parts) { resourceGroupName = parts[i+1] } } if subscriptionID != "" && resourceGroupName != "" { return fmt.Sprintf("%s.%s", subscriptionID, resourceGroupName) } return "" } // ExtractDNSFromURL extracts the DNS name from a URL // Example: https://account.blob.core.windows.net/ -> account.blob.core.windows.net func ExtractDNSFromURL(urlStr string) string { if urlStr == "" { return "" } // Remove protocol prefix (http:// or https://) if idx := len("https://"); len(urlStr) > idx && urlStr[:idx] == "https://" { urlStr = urlStr[idx:] } else if idx := len("http://"); len(urlStr) > idx && urlStr[:idx] == "http://" { urlStr = urlStr[idx:] } // Remove trailing slash and path if idx := len(urlStr); idx > 0 && urlStr[idx-1] == '/' { urlStr = urlStr[:idx-1] } // Extract hostname (everything before the first /) if idx := len(urlStr); idx > 0 { for i := range idx { if urlStr[i] == '/' { urlStr = urlStr[:i] break } } } return urlStr } // ExtractStorageAccountNameFromBlobURI extracts the storage account name from an Azure blob URI. // Blob URIs use the host format {accountName}.blob.core.{suffix} in all Azure clouds, e.g.: // - Public: https://{accountName}.blob.core.windows.net/{container}/{blob} // - China: https://{accountName}.blob.core.chinacloudapi.cn/... // - US Government: https://{accountName}.blob.core.usgovcloudapi.net/... func ExtractStorageAccountNameFromBlobURI(blobURI string) string { if blobURI == "" { return "" } parsedURL, err := url.Parse(blobURI) if err != nil { return "" } host := parsedURL.Host // Accept any Azure blob endpoint (public and sovereign clouds); check host only to avoid matching path/query if !strings.Contains(host, ".blob.core.") { return "" } // Account name is the first label of the host in all Azure blob endpoints parts := strings.Split(host, ".") if len(parts) > 0 && parts[0] != "" { return parts[0] } return "" } // ExtractContainerNameFromBlobURI extracts the container name from an Azure blob URI. // Blob URIs use the same path layout in all Azure clouds; the first path segment is the container. // Returns the first path segment which is the container name. func ExtractContainerNameFromBlobURI(blobURI string) string { if blobURI == "" { return "" } parsedURL, err := url.Parse(blobURI) if err != nil { return "" } // Ensure this is an Azure blob host (public or sovereign cloud); check host only to avoid matching path/query if !strings.Contains(parsedURL.Host, ".blob.core.") { return "" } path := strings.Trim(parsedURL.Path, "/") if path == "" { return "" } // Split path and get the first segment (container name) parts := strings.Split(path, "/") if len(parts) > 0 && parts[0] != "" { return parts[0] } return "" } // ref: https://learn.microsoft.com/en-us/rest/api/authorization/role-assignments/get?view=rest-authorization-2022-04-01&tabs=HTTP // subscriptionIDPattern matches Azure subscription IDs (UUID format: 8-4-4-4-12 hex digits) var subscriptionIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) // ConstructRoleAssignmentScope constructs an Azure role assignment scope path from a scope input. // The scopeInput is usually in the format "{subscriptionId}.{resourceGroup}". // If the input contains a dot, it's split into subscription ID and resource group name. // If the input matches a UUID pattern (no dot), it's treated as a subscription ID. // Otherwise, it's treated as a resource group name and uses the provided subscriptionID parameter. // // Parameters: // - scopeInput: Usually in format "{subscriptionId}.{resourceGroup}", or a subscription ID (UUID), or a resource group name // - subscriptionID: The subscription ID to use when constructing resource group scopes (fallback when scopeInput is just a resource group name) // // Returns: // - The Azure scope path in the format expected by the Azure SDK func ConstructRoleAssignmentScope(scopeInput, subscriptionID string) string { if scopeInput == "" { return "" } // Check if scopeInput is in the format "{subscriptionId}.{resourceGroup}" if strings.Contains(scopeInput, ".") { parts := strings.SplitN(scopeInput, ".", 2) if len(parts) == 2 && parts[0] != "" && parts[1] != "" { // It's in the format subscriptionId.resourceGroup - construct resource group scope return fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", parts[0], parts[1]) } } // Check if scopeInput is a subscription ID (UUID format) if subscriptionIDPattern.MatchString(scopeInput) { // It's a subscription ID - construct subscription scope return "/subscriptions/" + scopeInput } // It's a resource group name - construct resource group scope using provided subscriptionID return fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", subscriptionID, scopeInput) } ================================================ FILE: sources/azure/shared/utils_test.go ================================================ package shared_test import ( "reflect" "testing" azureshared "github.com/overmindtech/cli/sources/azure/shared" ) func TestGetResourceIDPathKeys(t *testing.T) { tests := []struct { name string resourceType string expected []string }{ { name: "storage queue", resourceType: "azure-storage-queue", expected: []string{"storageAccounts", "queues"}, }, { name: "storage blob container", resourceType: "azure-storage-blob-container", expected: []string{"storageAccounts", "containers"}, }, { name: "storage file share", resourceType: "azure-storage-file-share", expected: []string{"storageAccounts", "shares"}, }, { name: "storage table", resourceType: "azure-storage-table", expected: []string{"storageAccounts", "tables"}, }, { name: "unknown resource type", resourceType: "azure-unknown-resource", expected: nil, }, { name: "empty resource type", resourceType: "", expected: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.GetResourceIDPathKeys(tc.resourceType) if !reflect.DeepEqual(actual, tc.expected) { t.Errorf("GetResourceIDPathKeys(%q) = %v; want %v", tc.resourceType, actual, tc.expected) } }) } } func TestExtractResourceName(t *testing.T) { tests := []struct { name string resourceID string expected string }{ { name: "valid storage account resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount", expected: "teststorageaccount", }, { name: "valid storage queue resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue", expected: "test-queue", }, { name: "valid compute disk resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/disks/test-disk", expected: "test-disk", }, { name: "resource ID with trailing slash", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/", expected: "", }, { name: "empty resource ID", resourceID: "", expected: "", }, { name: "single segment", resourceID: "resource-name", expected: "resource-name", }, { name: "resource ID starting with slash", resourceID: "/resource-name", expected: "resource-name", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractResourceName(tc.resourceID) if actual != tc.expected { t.Errorf("ExtractResourceName(%q) = %q; want %q", tc.resourceID, actual, tc.expected) } }) } } func TestExtractPathParamsFromResourceID(t *testing.T) { tests := []struct { name string resourceID string keys []string expected []string }{ { name: "storage queue - extract storage account and queue", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue", keys: []string{"storageAccounts", "queues"}, expected: []string{"teststorageaccount", "test-queue"}, }, { name: "storage blob container - extract storage account and container", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/blobServices/default/containers/my-container", keys: []string{"storageAccounts", "containers"}, expected: []string{"teststorageaccount", "my-container"}, }, { name: "storage file share - extract storage account and share", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/fileServices/default/shares/my-share", keys: []string{"storageAccounts", "shares"}, expected: []string{"teststorageaccount", "my-share"}, }, { name: "storage table - extract storage account and table", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/tableServices/default/tables/my-table", keys: []string{"storageAccounts", "tables"}, expected: []string{"teststorageaccount", "my-table"}, }, { name: "single key extraction", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount", keys: []string{"storageAccounts"}, expected: []string{"teststorageaccount"}, }, { name: "resource ID without leading slash", resourceID: "subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue", keys: []string{"storageAccounts", "queues"}, expected: []string{"teststorageaccount", "test-queue"}, }, { name: "keys not in order", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue", keys: []string{"queues", "storageAccounts"}, expected: []string{"test-queue", "teststorageaccount"}, }, { name: "missing key", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue", keys: []string{"storageAccounts", "missingKey"}, expected: nil, }, { name: "key exists but no value after it", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts", keys: []string{"storageAccounts"}, expected: nil, }, { name: "empty resource ID", resourceID: "", keys: []string{"storageAccounts", "queues"}, expected: nil, }, { name: "empty keys", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue", keys: []string{}, expected: nil, }, { name: "nil keys", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue", keys: nil, expected: nil, }, { name: "resource ID with trailing slash", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorageaccount/queueServices/default/queues/test-queue/", keys: []string{"storageAccounts", "queues"}, expected: []string{"teststorageaccount", "test-queue"}, }, { name: "duplicate keys - returns first occurrence", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/first-account/queueServices/default/storageAccounts/second-account/queues/test-queue", keys: []string{"storageAccounts", "queues"}, expected: []string{"first-account", "test-queue"}, }, { name: "keys with special characters in values", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-storage-account_123/queueServices/default/queues/test_queue-name", keys: []string{"storageAccounts", "queues"}, expected: []string{"test-storage-account_123", "test_queue-name"}, }, { name: "case-insensitive key matching - lowercase keys match uppercase segments", resourceID: "/CommunityGalleries/test-gallery/Images/test-image/Versions/1.0.0", keys: []string{"communitygalleries", "images", "versions"}, expected: []string{"test-gallery", "test-image", "1.0.0"}, }, { name: "case-insensitive key matching - uppercase keys match lowercase segments", resourceID: "/communitygalleries/test-gallery/images/test-image/versions/1.0.0", keys: []string{"CommunityGalleries", "Images", "Versions"}, expected: []string{"test-gallery", "test-image", "1.0.0"}, }, { name: "case-insensitive key matching - mixed case", resourceID: "/subscriptions/12345678/resourcegroups/test-rg/providers/Microsoft.Storage/storageaccounts/myaccount", keys: []string{"storageAccounts"}, expected: []string{"myaccount"}, }, { name: "value matching key name is not misidentified - gallery named 'images'", resourceID: "/galleries/images/images/real-image/versions/1.0.0", keys: []string{"images", "versions"}, expected: []string{"real-image", "1.0.0"}, }, { name: "value matching key name is not misidentified - gallery named 'versions'", resourceID: "/galleries/versions/images/real-image/versions/1.0.0", keys: []string{"images", "versions"}, expected: []string{"real-image", "1.0.0"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractPathParamsFromResourceID(tc.resourceID, tc.keys) if !reflect.DeepEqual(actual, tc.expected) { t.Errorf("ExtractPathParamsFromResourceID(%q, %v) = %v; want %v", tc.resourceID, tc.keys, actual, tc.expected) } }) } } func TestExtractDNSRecordSetParamsFromResourceID(t *testing.T) { tests := []struct { name string resourceID string expected []string }{ { name: "valid DNS record set ID", resourceID: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Network/dnszones/example.com/A/www", expected: []string{"example.com", "A", "www"}, }, { name: "valid DNS record set ID - AAAA", resourceID: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Network/dnszones/zone.net/AAAA/mail", expected: []string{"zone.net", "AAAA", "mail"}, }, { name: "empty resource ID", resourceID: "", expected: nil, }, { name: "no dnszones segment", resourceID: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet", expected: nil, }, { name: "dnszones but not enough segments after", resourceID: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Network/dnszones/example.com", expected: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractDNSRecordSetParamsFromResourceID(tc.resourceID) if !reflect.DeepEqual(actual, tc.expected) { t.Errorf("ExtractDNSRecordSetParamsFromResourceID(%q) = %v; want %v", tc.resourceID, actual, tc.expected) } }) } } func TestExtractPathParamsFromResourceIDByType(t *testing.T) { t.Run("azure-network-dns-record-set uses DNS extractor", func(t *testing.T) { resourceID := "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Network/dnszones/example.com/A/www" actual := azureshared.ExtractPathParamsFromResourceIDByType("azure-network-dns-record-set", resourceID) expected := []string{"example.com", "A", "www"} if !reflect.DeepEqual(actual, expected) { t.Errorf("ExtractPathParamsFromResourceIDByType(azure-network-dns-record-set, ...) = %v; want %v", actual, expected) } }) t.Run("other type uses path keys", func(t *testing.T) { resourceID := "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/myaccount/queueServices/default/queues/myqueue" actual := azureshared.ExtractPathParamsFromResourceIDByType("azure-storage-queue", resourceID) expected := []string{"myaccount", "myqueue"} if !reflect.DeepEqual(actual, expected) { t.Errorf("ExtractPathParamsFromResourceIDByType(azure-storage-queue, ...) = %v; want %v", actual, expected) } }) t.Run("unknown type returns nil", func(t *testing.T) { actual := azureshared.ExtractPathParamsFromResourceIDByType("azure-unknown-type", "/some/id") if actual != nil { t.Errorf("ExtractPathParamsFromResourceIDByType(unknown) = %v; want nil", actual) } }) } func TestConvertAzureTags(t *testing.T) { tests := []struct { name string azureTags map[string]*string expected map[string]string }{ { name: "valid tags with values", azureTags: map[string]*string{ "env": new("production"), "project": new("overmind"), "team": new("platform"), }, expected: map[string]string{ "env": "production", "project": "overmind", "team": "platform", }, }, { name: "nil tags", azureTags: nil, expected: nil, }, { name: "empty tags", azureTags: map[string]*string{}, expected: map[string]string{}, }, { name: "tags with nil values - should be skipped", azureTags: map[string]*string{ "env": new("production"), "project": nil, "team": new("platform"), }, expected: map[string]string{ "env": "production", "team": "platform", }, }, { name: "all nil values", azureTags: map[string]*string{ "env": nil, "project": nil, "team": nil, }, expected: map[string]string{}, }, { name: "single tag", azureTags: map[string]*string{ "env": new("test"), }, expected: map[string]string{ "env": "test", }, }, { name: "tags with empty string values", azureTags: map[string]*string{ "env": new(""), "project": new("overmind"), }, expected: map[string]string{ "env": "", "project": "overmind", }, }, { name: "tags with special characters", azureTags: map[string]*string{ "tag-with-dashes": new("value_with_underscores"), "tag.with.dots": new("value with spaces"), }, expected: map[string]string{ "tag-with-dashes": "value_with_underscores", "tag.with.dots": "value with spaces", }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ConvertAzureTags(tc.azureTags) if !reflect.DeepEqual(actual, tc.expected) { t.Errorf("ConvertAzureTags(%v) = %v; want %v", tc.azureTags, actual, tc.expected) } }) } } func TestExtractSQLServerNameFromDatabaseID(t *testing.T) { tests := []struct { name string databaseID string expected string }{ { name: "valid SQL database resource ID", databaseID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-db", expected: "test-server", }, { name: "empty database ID", databaseID: "", expected: "", }, { name: "invalid resource ID format", databaseID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expected: "", }, { name: "resource ID without servers segment", databaseID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/databases/test-db", expected: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractSQLServerNameFromDatabaseID(tc.databaseID) if actual != tc.expected { t.Errorf("ExtractSQLServerNameFromDatabaseID(%q) = %q; want %q", tc.databaseID, actual, tc.expected) } }) } } func TestExtractSQLElasticPoolNameFromID(t *testing.T) { tests := []struct { name string elasticPoolID string expected string }{ { name: "valid SQL elastic pool resource ID", elasticPoolID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/elasticPools/test-pool", expected: "test-pool", }, { name: "empty elastic pool ID", elasticPoolID: "", expected: "", }, { name: "invalid resource ID format", elasticPoolID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expected: "", }, { name: "resource ID without elasticPools segment", elasticPoolID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", expected: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractSQLElasticPoolNameFromID(tc.elasticPoolID) if actual != tc.expected { t.Errorf("ExtractSQLElasticPoolNameFromID(%q) = %q; want %q", tc.elasticPoolID, actual, tc.expected) } }) } } func TestExtractSQLDatabaseInfoFromResourceID(t *testing.T) { tests := []struct { name string resourceID string expectedServer string expectedDB string }{ { name: "valid SQL database resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-db", expectedServer: "test-server", expectedDB: "test-db", }, { name: "empty resource ID", resourceID: "", expectedServer: "", expectedDB: "", }, { name: "invalid resource ID format", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expectedServer: "", expectedDB: "", }, { name: "resource ID missing databases segment", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", expectedServer: "", expectedDB: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualServer, actualDB := azureshared.ExtractSQLDatabaseInfoFromResourceID(tc.resourceID) if actualServer != tc.expectedServer { t.Errorf("ExtractSQLDatabaseInfoFromResourceID(%q) server = %q; want %q", tc.resourceID, actualServer, tc.expectedServer) } if actualDB != tc.expectedDB { t.Errorf("ExtractSQLDatabaseInfoFromResourceID(%q) database = %q; want %q", tc.resourceID, actualDB, tc.expectedDB) } }) } } func TestExtractSQLRecoverableDatabaseInfoFromResourceID(t *testing.T) { tests := []struct { name string resourceID string expectedServer string expectedDB string }{ { name: "valid recoverable database resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/recoverableDatabases/test-db", expectedServer: "test-server", expectedDB: "test-db", }, { name: "empty resource ID", resourceID: "", expectedServer: "", expectedDB: "", }, { name: "invalid resource ID format", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expectedServer: "", expectedDB: "", }, { name: "resource ID missing recoverableDatabases segment", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", expectedServer: "", expectedDB: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualServer, actualDB := azureshared.ExtractSQLRecoverableDatabaseInfoFromResourceID(tc.resourceID) if actualServer != tc.expectedServer { t.Errorf("ExtractSQLRecoverableDatabaseInfoFromResourceID(%q) server = %q; want %q", tc.resourceID, actualServer, tc.expectedServer) } if actualDB != tc.expectedDB { t.Errorf("ExtractSQLRecoverableDatabaseInfoFromResourceID(%q) database = %q; want %q", tc.resourceID, actualDB, tc.expectedDB) } }) } } func TestExtractSQLRestorableDroppedDatabaseInfoFromResourceID(t *testing.T) { tests := []struct { name string resourceID string expectedServer string expectedDB string }{ { name: "valid restorable dropped database resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/restorableDroppedDatabases/test-db", expectedServer: "test-server", expectedDB: "test-db", }, { name: "empty resource ID", resourceID: "", expectedServer: "", expectedDB: "", }, { name: "invalid resource ID format", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expectedServer: "", expectedDB: "", }, { name: "resource ID missing restorableDroppedDatabases segment", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", expectedServer: "", expectedDB: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualServer, actualDB := azureshared.ExtractSQLRestorableDroppedDatabaseInfoFromResourceID(tc.resourceID) if actualServer != tc.expectedServer { t.Errorf("ExtractSQLRestorableDroppedDatabaseInfoFromResourceID(%q) server = %q; want %q", tc.resourceID, actualServer, tc.expectedServer) } if actualDB != tc.expectedDB { t.Errorf("ExtractSQLRestorableDroppedDatabaseInfoFromResourceID(%q) database = %q; want %q", tc.resourceID, actualDB, tc.expectedDB) } }) } } func TestExtractSQLElasticPoolInfoFromResourceID(t *testing.T) { tests := []struct { name string resourceID string expectedServer string expectedPool string }{ { name: "valid SQL elastic pool resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/elasticPools/test-pool", expectedServer: "test-server", expectedPool: "test-pool", }, { name: "empty resource ID", resourceID: "", expectedServer: "", expectedPool: "", }, { name: "invalid resource ID format", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expectedServer: "", expectedPool: "", }, { name: "resource ID missing elasticPools segment", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", expectedServer: "", expectedPool: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualServer, actualPool := azureshared.ExtractSQLElasticPoolInfoFromResourceID(tc.resourceID) if actualServer != tc.expectedServer { t.Errorf("ExtractSQLElasticPoolInfoFromResourceID(%q) server = %q; want %q", tc.resourceID, actualServer, tc.expectedServer) } if actualPool != tc.expectedPool { t.Errorf("ExtractSQLElasticPoolInfoFromResourceID(%q) pool = %q; want %q", tc.resourceID, actualPool, tc.expectedPool) } }) } } func TestExtractSQLLongTermRetentionBackupInfoFromResourceID(t *testing.T) { tests := []struct { name string resourceID string expectedLocation string expectedServer string expectedDatabase string expectedBackupName string }{ { name: "valid SQL long term retention backup resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/locations/eastus/longTermRetentionServers/test-server/longTermRetentionDatabases/test-db/longTermRetentionBackups/1234567890;1234567890", expectedLocation: "eastus", expectedServer: "test-server", expectedDatabase: "test-db", expectedBackupName: "1234567890;1234567890", }, { name: "empty resource ID", resourceID: "", expectedLocation: "", expectedServer: "", expectedDatabase: "", expectedBackupName: "", }, { name: "invalid resource ID format", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-db", expectedLocation: "", expectedServer: "", expectedDatabase: "", expectedBackupName: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualLocation, actualServer, actualDatabase, actualBackupName := azureshared.ExtractSQLLongTermRetentionBackupInfoFromResourceID(tc.resourceID) if actualLocation != tc.expectedLocation { t.Errorf("ExtractSQLLongTermRetentionBackupInfoFromResourceID(%q) location = %q; want %q", tc.resourceID, actualLocation, tc.expectedLocation) } if actualServer != tc.expectedServer { t.Errorf("ExtractSQLLongTermRetentionBackupInfoFromResourceID(%q) server = %q; want %q", tc.resourceID, actualServer, tc.expectedServer) } if actualDatabase != tc.expectedDatabase { t.Errorf("ExtractSQLLongTermRetentionBackupInfoFromResourceID(%q) database = %q; want %q", tc.resourceID, actualDatabase, tc.expectedDatabase) } if actualBackupName != tc.expectedBackupName { t.Errorf("ExtractSQLLongTermRetentionBackupInfoFromResourceID(%q) backupName = %q; want %q", tc.resourceID, actualBackupName, tc.expectedBackupName) } }) } } func TestDetermineSourceResourceType(t *testing.T) { tests := []struct { name string resourceID string expectedType azureshared.SourceResourceType expectedParams map[string]string }{ { name: "SQL database resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-db", expectedType: azureshared.SourceResourceTypeSQLDatabase, expectedParams: map[string]string{ "serverName": "test-server", "databaseName": "test-db", }, }, { name: "SQL elastic pool resource ID", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/elasticPools/test-pool", expectedType: azureshared.SourceResourceTypeSQLElasticPool, expectedParams: map[string]string{ "serverName": "test-server", "elasticPoolName": "test-pool", }, }, { name: "empty resource ID", resourceID: "", expectedType: azureshared.SourceResourceTypeUnknown, expectedParams: nil, }, { name: "unknown resource type", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expectedType: azureshared.SourceResourceTypeUnknown, expectedParams: nil, }, { name: "Synapse SQL pool (not yet supported)", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Synapse/workspaces/test-workspace/sqlPools/test-pool", expectedType: azureshared.SourceResourceTypeUnknown, expectedParams: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualType, actualParams := azureshared.DetermineSourceResourceType(tc.resourceID) if actualType != tc.expectedType { t.Errorf("DetermineSourceResourceType(%q) type = %v; want %v", tc.resourceID, actualType, tc.expectedType) } if !reflect.DeepEqual(actualParams, tc.expectedParams) { t.Errorf("DetermineSourceResourceType(%q) params = %v; want %v", tc.resourceID, actualParams, tc.expectedParams) } }) } } func TestExtractVaultNameFromURI(t *testing.T) { tests := []struct { name string uri string expected string }{ { name: "valid Key Vault key URI", uri: "https://test-vault.vault.azure.net/keys/test-key/version", expected: "test-vault", }, { name: "valid Key Vault secret URI", uri: "https://my-vault.vault.azure.net/secrets/my-secret/version", expected: "my-vault", }, { name: "vault name with hyphens", uri: "https://test-vault-name.vault.azure.net/keys/test-key/version", expected: "test-vault-name", }, { name: "empty URI", uri: "", expected: "", }, { name: "invalid URI format", uri: "not-a-valid-uri", expected: "", }, { name: "URI without vault domain", uri: "https://example.com/path", expected: "example", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractVaultNameFromURI(tc.uri) if actual != tc.expected { t.Errorf("ExtractVaultNameFromURI(%q) = %q; want %q", tc.uri, actual, tc.expected) } }) } } func TestExtractKeyNameFromURI(t *testing.T) { tests := []struct { name string uri string expected string }{ { name: "valid Key Vault key URI", uri: "https://test-vault.vault.azure.net/keys/test-key/version", expected: "test-key", }, { name: "key name with hyphens", uri: "https://test-vault.vault.azure.net/keys/my-test-key-name/version", expected: "my-test-key-name", }, { name: "key URI without version", uri: "https://test-vault.vault.azure.net/keys/test-key", expected: "test-key", }, { name: "empty URI", uri: "", expected: "", }, { name: "invalid URI format", uri: "not-a-valid-uri", expected: "", }, { name: "URI for secret (not key)", uri: "https://test-vault.vault.azure.net/secrets/test-secret/version", expected: "", }, { name: "URI without keys path", uri: "https://test-vault.vault.azure.net/", expected: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractKeyNameFromURI(tc.uri) if actual != tc.expected { t.Errorf("ExtractKeyNameFromURI(%q) = %q; want %q", tc.uri, actual, tc.expected) } }) } } func TestExtractSecretNameFromURI(t *testing.T) { tests := []struct { name string uri string expected string }{ { name: "valid Key Vault secret URI", uri: "https://test-vault.vault.azure.net/secrets/test-secret/version", expected: "test-secret", }, { name: "secret name with hyphens", uri: "https://test-vault.vault.azure.net/secrets/my-test-secret-name/version", expected: "my-test-secret-name", }, { name: "secret URI without version", uri: "https://test-vault.vault.azure.net/secrets/test-secret", expected: "test-secret", }, { name: "empty URI", uri: "", expected: "", }, { name: "invalid URI format", uri: "not-a-valid-uri", expected: "", }, { name: "URI for key (not secret)", uri: "https://test-vault.vault.azure.net/keys/test-key/version", expected: "", }, { name: "URI without secrets path", uri: "https://test-vault.vault.azure.net/", expected: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractSecretNameFromURI(tc.uri) if actual != tc.expected { t.Errorf("ExtractSecretNameFromURI(%q) = %q; want %q", tc.uri, actual, tc.expected) } }) } } func TestExtractScopeFromResourceID(t *testing.T) { tests := []struct { name string resourceID string expected string }{ { name: "valid resource ID with subscription and resource group", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expected: "12345678-1234-1234-1234-123456789012.test-rg", }, { name: "resource ID with nested resources", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account/queueServices/default/queues/test-queue", expected: "12345678-1234-1234-1234-123456789012.test-rg", }, { name: "resource ID without leading slash", resourceID: "subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expected: "12345678-1234-1234-1234-123456789012.test-rg", }, { name: "empty resource ID", resourceID: "", expected: "", }, { name: "resource ID missing subscription", resourceID: "/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/test-account", expected: "", }, { name: "resource ID missing resource group", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.Storage/storageAccounts/test-account", expected: "", }, { name: "resource ID too short", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012", expected: "", }, { name: "resource ID with subscription but no resource group value (malformed - would not occur in practice)", resourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/providers/Microsoft.Storage/storageAccounts/test-account", expected: "12345678-1234-1234-1234-123456789012.providers", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractScopeFromResourceID(tc.resourceID) if actual != tc.expected { t.Errorf("ExtractScopeFromResourceID(%q) = %q; want %q", tc.resourceID, actual, tc.expected) } }) } } func TestExtractDNSFromURL(t *testing.T) { tests := []struct { name string urlStr string expected string }{ { name: "HTTPS URL with path", urlStr: "https://account.blob.core.windows.net/container/blob", expected: "account.blob.core.windows.net", }, { name: "HTTPS URL without path", urlStr: "https://account.blob.core.windows.net", expected: "account.blob.core.windows.net", }, { name: "HTTPS URL with trailing slash", urlStr: "https://account.blob.core.windows.net/", expected: "account.blob.core.windows.net", }, { name: "HTTP URL", urlStr: "http://example.com/path/to/resource", expected: "example.com", }, { name: "HTTP URL without path", urlStr: "http://example.com", expected: "example.com", }, { name: "URL with port", urlStr: "https://example.com:8080/path", expected: "example.com:8080", }, { name: "empty URL", urlStr: "", expected: "", }, { name: "URL without protocol", urlStr: "example.com/path", expected: "example.com", }, { name: "URL with query parameters", urlStr: "https://example.com/path?param=value", expected: "example.com", }, { name: "URL with fragment", urlStr: "https://example.com/path#fragment", expected: "example.com", }, { name: "complex storage account URL", urlStr: "https://mystorageaccount.blob.core.windows.net/mycontainer/myblob.txt", expected: "mystorageaccount.blob.core.windows.net", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractDNSFromURL(tc.urlStr) if actual != tc.expected { t.Errorf("ExtractDNSFromURL(%q) = %q; want %q", tc.urlStr, actual, tc.expected) } }) } } func TestExtractStorageAccountNameFromBlobURI(t *testing.T) { tests := []struct { name string blobURI string expected string }{ { name: "valid blob URI", blobURI: "https://mystorageaccount.blob.core.windows.net/container/blob", expected: "mystorageaccount", }, { name: "blob URI with path only", blobURI: "https://account.blob.core.windows.net/packages/app.zip", expected: "account", }, { name: "sovereign cloud China blob URI", blobURI: "https://myaccount.blob.core.chinacloudapi.cn/container/blob", expected: "myaccount", }, { name: "sovereign cloud US Government blob URI", blobURI: "https://myaccount.blob.core.usgovcloudapi.net/container/blob", expected: "myaccount", }, { name: "non-blob HTTPS URL must return empty", blobURI: "https://example.com/artifacts/app.zip", expected: "", }, { name: "non-blob HTTP URL must return empty", blobURI: "http://cdn.example.com/foo", expected: "", }, { name: "empty URI", blobURI: "", expected: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := azureshared.ExtractStorageAccountNameFromBlobURI(tc.blobURI) if actual != tc.expected { t.Errorf("ExtractStorageAccountNameFromBlobURI(%q) = %q; want %q", tc.blobURI, actual, tc.expected) } }) } } ================================================ FILE: sources/example/base.go ================================================ package example import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) // Base customizes the sources.Base struct // It adds the project ID and zone to the base struct // and makes them available to concrete wrapper implementations. type Base struct { projectID string zone string *shared.Base } // NewBase creates a new Base struct func NewBase( projectID string, zone string, category sdp.AdapterCategory, item shared.ItemType, ) *Base { return &Base{ projectID: projectID, zone: zone, Base: shared.NewBase( category, item, []string{fmt.Sprintf("%s.%s", projectID, zone)}, ), } } // ProjectID returns the project ID func (m *Base) ProjectID() string { return m.projectID } // Zone returns the zone func (m *Base) Zone() string { return m.zone } ================================================ FILE: sources/example/custom_searchable_listable.go ================================================ package example import ( "context" "fmt" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/shared" ) type customComputeInstanceWrapper struct { client ExternalAPIClient *shared.Base } // NewCustomSearchableListable creates a new customComputeInstanceWrapper instance func NewCustomSearchableListable(client ExternalAPIClient, projectID, zone string) sources.SearchableListableWrapper { return &customComputeInstanceWrapper{ client: client, Base: shared.NewBase( sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, ComputeInstance, []string{projectID, fmt.Sprintf("%s.%s", projectID, zone)}, // example custom scopes ), } } // AdapterMetadata returns the adapter metadata for the ExternalType. // This method allows providing custom metadata for the adapter. func (d *customComputeInstanceWrapper) AdapterMetadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: ComputeInstance.String(), Category: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, PotentialLinks: []string{ComputeDisk.String()}, DescriptiveName: "Custom descriptive name", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get a compute instance by ID", List: true, ListDescription: "List all compute instances", Search: true, SearchDescription: "Search for compute instances by {compute status id} or {compute disk name|compute status id}", }, TerraformMappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "example_resource.name", }, }, } } // PotentialLinks returns the potential links for the ExternalType. // This should include all the item types that are added as linked items in the externalTypeToSDPItem method func (d *customComputeInstanceWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet(ComputeDisk) } // GetLookups returns the sources.ItemTypeLookups for the Get operation // This is used for input validation and constructing the human readable get query description. func (d *customComputeInstanceWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeInstanceLookupByID, } } // Get retrieves a specific ExternalType by unique attribute and converts it to a sdp.Item func (d *customComputeInstanceWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { external, err := d.client.Get(ctx, queryParts[0]) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.externalTypeToSDPItem(external) } // List retrieves all ExternalType and converts them to sdp.Items func (d *customComputeInstanceWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { externals, err := d.client.List(ctx) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.mapper(externals) } // SearchLookups returns the ItemTypeLookups for the Search operation // This is used for input validation and constructing the human-readable search query description. // An item can be searched via multiple lookups. // Each variant should be added as a separate sources.ItemTypeLookups // In this example, we have two lookups: // 1. Simple Key: ComputeDiskLookupByName: searching this item type by compute disk name // 2. Composite Key: ComputeDiskLookupByName|ComputeStatusLookupByID: searching this item type by // compute disk name and compute status ID func (d *customComputeInstanceWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeStatusLookupByID, }, { ComputeDiskLookupByName, ComputeStatusLookupByID, }, } } // Search retrieves ExternalType by a search query and converts them to sdp.Items func (d *customComputeInstanceWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { var err error var externals []*ExternalType switch len(queryParts) { case 1: externals, err = d.client.Search(ctx, queryParts[0]) case 2: externals, err = d.client.Search(ctx, queryParts[0], queryParts[1]) } if err != nil { return nil, queryError(err, scope, d.Type()) } // We don't need to check if the length of the query is different from 1 or 2. // This is validated in the backend when converting this to an adapter. return d.mapper(externals) } // externalTypeToSDPItem converts an ExternalType to a sdp.Item // This is where we define the linked items and how they are linked. // All the linked items should be added to the PotentialLinks method! func (d *customComputeInstanceWrapper) externalTypeToSDPItem(external *ExternalType) (*sdp.Item, *sdp.QueryError) { sdpItem := &sdp.Item{ Type: external.Type, UniqueAttribute: external.UniqueAttribute, Tags: external.Tags, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: external.LinkedItemID, Scope: d.Scopes()[0], }, }, }, } return sdpItem, nil } // mapper converts a slice of ExternalType to a slice of sdp.Item func (d *customComputeInstanceWrapper) mapper(externalItems []*ExternalType) ([]*sdp.Item, *sdp.QueryError) { sdpItems := make([]*sdp.Item, len(externalItems)) for i, item := range externalItems { var err *sdp.QueryError sdpItems[i], err = d.externalTypeToSDPItem(item) if err != nil { return nil, err } } return sdpItems, nil } func (d *customComputeInstanceWrapper) IAMPermissions() []string { return []string{ "compute.instances.get", "compute.instances.list", } } ================================================ FILE: sources/example/errors.go ================================================ package example import ( "errors" "github.com/overmindtech/cli/go/sdp-go" ) // queryError is a helper function to convert errors into sdp.QueryError func queryError(err error, scope string, itemType string) *sdp.QueryError { if errors.As(err, new(NotFoundError)) { return &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), SourceName: "example-source", Scope: scope, ItemType: itemType, } } return &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), SourceName: "example-source", Scope: scope, ItemType: itemType, } } ================================================ FILE: sources/example/metadata_test.go ================================================ package example import ( "fmt" "strings" "testing" "golang.org/x/text/cases" "golang.org/x/text/language" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/example/shared" ) func TestStaticData(t *testing.T) { projectID := "test-project-id" zone := "us-central1-a" t.Run("Standard Wrapper", func(t *testing.T) { standardSearchableListable := NewStandardSearchableListable(nil, projectID, zone) adapter := sources.WrapperToAdapter(standardSearchableListable, sdpcache.NewNoOpCache()) if adapter.Type() != fmt.Sprintf("%s-%s-%s", shared.Source, shared.Compute, shared.Instance, ) { t.Fatalf("Unexpected adapter type: %s", adapter.Type()) } t.Logf("Adapter Type: type=%s", adapter.Type()) if adapter.Name() != adapter.Type()+"-adapter" { t.Fatalf("Unexpected adapter name: %s", adapter.Name()) } t.Logf("Adapter Name: name=%s", adapter.Name()) if adapter.Scopes()[0] != fmt.Sprintf("%s.%s", projectID, zone) { t.Fatalf("Unexpected adapter scope: %s", adapter.Scopes()[0]) } t.Logf("Adapter Scopes: scopes=%v", adapter.Scopes()) metadata := adapter.Metadata() if metadata == nil { t.Fatalf("Adapter metadata is nil") } expectedDescriptiveName := fmt.Sprintf( "%s %s %s", strings.ToUpper(string(shared.Source)), cases.Title(language.English).String(string(shared.Compute)), cases.Title(language.English).String(string(shared.Instance)), ) if metadata.GetDescriptiveName() != expectedDescriptiveName { t.Fatalf( "Unexpected adapter metadata descriptive name: %s, expected: %s", metadata.GetDescriptiveName(), expectedDescriptiveName, ) } t.Logf("Metadata Descriptive Name: name=%s", metadata.GetDescriptiveName()) if metadata.GetType() != adapter.Type() { t.Fatalf("Unexpected adapter metadata type: %s, expected: %s", metadata.GetType(), adapter.Type()) } t.Logf("Metadata Type: type=%s", metadata.GetType()) if metadata.GetCategory() != sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION { t.Fatalf("Unexpected adapter metadata category: %s", metadata.GetCategory()) } t.Logf("Metadata Category: category=%s", metadata.GetCategory()) tfMapping := metadata.GetTerraformMappings()[0] if tfMapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Fatalf("Expected TerraformMethod to be %s, but got: %s", sdp.QueryMethod_GET, tfMapping.GetTerraformMethod()) } if tfMapping.GetTerraformQueryMap() != "example_resource.name" { t.Fatalf("Expected TerraformQueryMap to be 'example_resource.name', but got: %s", tfMapping.GetTerraformQueryMap()) } t.Logf("Terraform QueryMap: mappings=%s", tfMapping.GetTerraformQueryMap()) if !metadata.GetSupportedQueryMethods().GetGet() { t.Fatalf("Expected to support Get method") } expectedGetDescription := "Get GCP Compute Instance by \"GCP-compute-instance-id\"" if metadata.GetSupportedQueryMethods().GetGetDescription() != expectedGetDescription { t.Fatalf("Expected GetDescription to be '%s', but got: %s", expectedGetDescription, metadata.GetSupportedQueryMethods().GetGetDescription()) } t.Logf("Metadata GetDescription: description=%s", metadata.GetSupportedQueryMethods().GetGetDescription()) if !metadata.GetSupportedQueryMethods().GetList() { t.Fatalf("Expected to support List method") } expectedListDescription := "List all GCP Compute Instance items" if metadata.GetSupportedQueryMethods().GetListDescription() != expectedListDescription { t.Fatalf("Expected ListDescription to be '%s', but got: %s", expectedListDescription, metadata.GetSupportedQueryMethods().GetListDescription()) } t.Logf("Metadata ListDescription: description=%s", metadata.GetSupportedQueryMethods().GetListDescription()) if !metadata.GetSupportedQueryMethods().GetSearch() { t.Fatalf("Expected to support Search method") } expectedSearchDescription := "Search for GCP Compute Instance by \"GCP-compute-status-id\" or \"GCP-compute-disk-name|GCP-compute-status-id\"" if metadata.GetSupportedQueryMethods().GetSearchDescription() != expectedSearchDescription { t.Fatalf("Expected SearchDescription to be '%s', but got: %s", expectedSearchDescription, metadata.GetSupportedQueryMethods().GetSearchDescription()) } t.Logf("Metadata SearchDescription: description=%s", metadata.GetSupportedQueryMethods().GetGetDescription()) expectedPotentialLink := "GCP-compute-disk" potentialLink := metadata.GetPotentialLinks()[0] if potentialLink != expectedPotentialLink { t.Fatalf("Expected potential link to be %s, but got: %s", expectedPotentialLink, potentialLink) } t.Logf("Potential Links: links=%v", metadata.GetPotentialLinks()) }) t.Run("Custom Wrapper", func(t *testing.T) { customSearchableListable := NewCustomSearchableListable(nil, projectID, zone) adapter := sources.WrapperToAdapter(customSearchableListable, sdpcache.NewNoOpCache()) if adapter.Type() != fmt.Sprintf("%s-%s-%s", shared.Source, shared.Compute, shared.Instance, ) { t.Fatalf("Unexpected adapter type: %s", adapter.Type()) } t.Logf("Adapter Type: type=%s", adapter.Type()) if adapter.Name() != adapter.Type()+"-adapter" { t.Fatalf("Unexpected adapter name: %s", adapter.Name()) } t.Logf("Adapter Name: name=%s", adapter.Name()) if adapter.Scopes()[0] != projectID { t.Fatalf("Unexpected adapter scope: %s", adapter.Scopes()[0]) } if adapter.Scopes()[1] != fmt.Sprintf("%s.%s", projectID, zone) { t.Fatalf("Unexpected adapter scope: %s", adapter.Scopes()[0]) } t.Logf("Adapter Scopes: scopes=%v", adapter.Scopes()) metadata := adapter.Metadata() if metadata == nil { t.Fatalf("Adapter metadata is nil") } expectedDescriptiveName := "Custom descriptive name" if metadata.GetDescriptiveName() != expectedDescriptiveName { t.Fatalf( "Unexpected adapter metadata descriptive name: %s, expected: %s", metadata.GetDescriptiveName(), expectedDescriptiveName, ) } t.Logf("Metadata Descriptive Name: name=%s", metadata.GetDescriptiveName()) expectedGetDescription := "Get a compute instance by ID" if metadata.GetSupportedQueryMethods().GetGetDescription() != expectedGetDescription { t.Fatalf("Expected GetDescription to be '%s', but got: %s", expectedGetDescription, metadata.GetSupportedQueryMethods().GetGetDescription()) } t.Logf("Metadata GetDescription: description=%s", metadata.GetSupportedQueryMethods().GetGetDescription()) expectedListDescription := "List all compute instances" if metadata.GetSupportedQueryMethods().GetListDescription() != expectedListDescription { t.Fatalf("Expected ListDescription to be '%s', but got: %s", expectedListDescription, metadata.GetSupportedQueryMethods().GetListDescription()) } t.Logf("Metadata ListDescription: description=%s", metadata.GetSupportedQueryMethods().GetListDescription()) expectedSearchDescription := "Search for compute instances by {compute status id} or {compute disk name|compute status id}" if metadata.GetSupportedQueryMethods().GetSearchDescription() != expectedSearchDescription { t.Fatalf("Expected SearchDescription to be '%s', but got: %s", expectedSearchDescription, metadata.GetSupportedQueryMethods().GetSearchDescription()) } t.Logf("Metadata SearchDescription: description=%s", metadata.GetSupportedQueryMethods().GetSearchDescription()) if metadata.GetType() != adapter.Type() { t.Fatalf("Unexpected adapter metadata type: %s, expected: %s", metadata.GetType(), adapter.Type()) } t.Logf("Metadata Type: type=%s", metadata.GetType()) if metadata.GetCategory() != sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION { t.Fatalf("Unexpected adapter metadata category: %s", metadata.GetCategory()) } t.Logf("Metadata Category: category=%s", metadata.GetCategory()) tfMapping := metadata.GetTerraformMappings()[0] if tfMapping.GetTerraformMethod() != sdp.QueryMethod_GET { t.Fatalf("Expected TerraformMethod to be %s, but got: %s", sdp.QueryMethod_GET, tfMapping.GetTerraformMethod()) } }) } ================================================ FILE: sources/example/mocks/mock_external_api_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: standard_searchable_listable.go // // Generated by this command: // // mockgen -destination=./mocks/mock_external_api_client.go -package=mocks -source=standard_searchable_listable.go ExternalAPIClient // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" example "github.com/overmindtech/cli/sources/example" gomock "go.uber.org/mock/gomock" ) // MockExternalAPIClient is a mock of ExternalAPIClient interface. type MockExternalAPIClient struct { ctrl *gomock.Controller recorder *MockExternalAPIClientMockRecorder isgomock struct{} } // MockExternalAPIClientMockRecorder is the mock recorder for MockExternalAPIClient. type MockExternalAPIClientMockRecorder struct { mock *MockExternalAPIClient } // NewMockExternalAPIClient creates a new mock instance. func NewMockExternalAPIClient(ctrl *gomock.Controller) *MockExternalAPIClient { mock := &MockExternalAPIClient{ctrl: ctrl} mock.recorder = &MockExternalAPIClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockExternalAPIClient) EXPECT() *MockExternalAPIClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockExternalAPIClient) Get(ctx context.Context, query string) (*example.ExternalType, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, query) ret0, _ := ret[0].(*example.ExternalType) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockExternalAPIClientMockRecorder) Get(ctx, query any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockExternalAPIClient)(nil).Get), ctx, query) } // List mocks base method. func (m *MockExternalAPIClient) List(ctx context.Context) ([]*example.ExternalType, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx) ret0, _ := ret[0].([]*example.ExternalType) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockExternalAPIClientMockRecorder) List(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockExternalAPIClient)(nil).List), ctx) } // Search mocks base method. func (m *MockExternalAPIClient) Search(ctx context.Context, query ...string) ([]*example.ExternalType, error) { m.ctrl.T.Helper() varargs := []any{ctx} for _, a := range query { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Search", varargs...) ret0, _ := ret[0].([]*example.ExternalType) ret1, _ := ret[1].(error) return ret0, ret1 } // Search indicates an expected call of Search. func (mr *MockExternalAPIClientMockRecorder) Search(ctx any, query ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx}, query...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockExternalAPIClient)(nil).Search), varargs...) } ================================================ FILE: sources/example/shared/models.go ================================================ package shared import ( "github.com/overmindtech/cli/sources/shared" ) const ( Source shared.Source = "GCP" ) // APIs const ( Compute shared.API = "compute" ) // Resources const ( Instance shared.Resource = "instance" Disk shared.Resource = "disk" Status shared.Resource = "status" ) ================================================ FILE: sources/example/standard_searchable_listable.go ================================================ package example import ( "context" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources" exampleshared "github.com/overmindtech/cli/sources/example/shared" "github.com/overmindtech/cli/sources/shared" ) var ( ComputeInstance = shared.NewItemType(exampleshared.Source, exampleshared.Compute, exampleshared.Instance) ComputeDisk = shared.NewItemType(exampleshared.Source, exampleshared.Compute, exampleshared.Disk) ComputeStatus = shared.NewItemType(exampleshared.Source, exampleshared.Compute, exampleshared.Status) ComputeInstanceLookupByID = shared.NewItemTypeLookup("id", ComputeInstance) ComputeStatusLookupByID = shared.NewItemTypeLookup("id", ComputeStatus) ComputeDiskLookupByName = shared.NewItemTypeLookup("name", ComputeDisk) ) // ExternalType is a placeholder for the external API type // For example, this could be a struct that represents a compute instance from GCP type ExternalType struct { Type string UniqueAttribute string Tags map[string]string LinkedItemID string } // NotFoundError is a placeholder for external API error codes type NotFoundError struct{} func (e NotFoundError) Error() string { return "not found" } // ExternalAPIClient is an interface for the external API client // //go:generate mockgen -destination=./mocks/mock_external_api_client.go -package=mocks -source=standard_searchable_listable.go ExternalAPIClient type ExternalAPIClient interface { Get(ctx context.Context, query string) (*ExternalType, error) List(ctx context.Context) ([]*ExternalType, error) Search(ctx context.Context, query ...string) ([]*ExternalType, error) } type computeInstanceWrapper struct { client ExternalAPIClient *Base } // NewStandardSearchableListable creates a new computeInstanceWrapper instance func NewStandardSearchableListable(client ExternalAPIClient, projectID, zone string) sources.SearchableListableWrapper { return &computeInstanceWrapper{ client: client, Base: NewBase( projectID, zone, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, ComputeInstance, ), } } // TerraformMappings returns the Terraform mappings for the ExternalType func (d *computeInstanceWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "example_resource.name", }, } } // PotentialLinks returns the potential links for the ExternalType. // This should include all the item types that are added as linked items in the externalTypeToSDPItem method func (d *computeInstanceWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet(ComputeDisk) } // GetLookups returns the sources.ItemTypeLookups for the Get operation // This is used for input validation and constructing the human readable get query description. func (d *computeInstanceWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeInstanceLookupByID, } } // Get retrieves a specific ExternalType by unique attribute and converts it to a sdp.Item func (d *computeInstanceWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { external, err := d.client.Get(ctx, queryParts[0]) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.externalTypeToSDPItem(external) } // List retrieves all ExternalType and converts them to sdp.Items func (d *computeInstanceWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { externals, err := d.client.List(ctx) if err != nil { return nil, queryError(err, scope, d.Type()) } return d.mapper(externals) } // SearchLookups returns the ItemTypeLookups for the Search operation // This is used for input validation and constructing the human-readable search query description. // An item can be searched via multiple lookups. // Each variant should be added as a separate sources.ItemTypeLookups // In this example, we have two lookups: // 1. Simple Key: ComputeDiskLookupByName: searching this item type by compute disk name // 2. Composite Key: ComputeDiskLookupByName|ComputeStatusLookupByID: searching this item type by // compute disk name and compute status ID func (d *computeInstanceWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeStatusLookupByID, }, { ComputeDiskLookupByName, ComputeStatusLookupByID, }, } } // Search retrieves ExternalType by a search query and converts them to sdp.Items func (d *computeInstanceWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { var err error var externals []*ExternalType switch len(queryParts) { case 1: externals, err = d.client.Search(ctx, queryParts[0]) case 2: externals, err = d.client.Search(ctx, queryParts[0], queryParts[1]) } if err != nil { return nil, queryError(err, scope, d.Type()) } // We don't need to check if the length of the query is different from 1 or 2. // This is validated in the backend when converting this to an adapter. return d.mapper(externals) } // externalTypeToSDPItem converts an ExternalType to a sdp.Item // This is where we define the linked items and how they are linked. // All the linked items should be added to the PotentialLinks method! func (d *computeInstanceWrapper) externalTypeToSDPItem(external *ExternalType) (*sdp.Item, *sdp.QueryError) { sdpItem := &sdp.Item{ Type: external.Type, UniqueAttribute: external.UniqueAttribute, Tags: external.Tags, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: external.LinkedItemID, Scope: d.Scopes()[0], }, }, }, } return sdpItem, nil } // mapper converts a slice of ExternalType to a slice of sdp.Item func (d *computeInstanceWrapper) mapper(externalItems []*ExternalType) ([]*sdp.Item, *sdp.QueryError) { sdpItems := make([]*sdp.Item, len(externalItems)) for i, item := range externalItems { var err *sdp.QueryError sdpItems[i], err = d.externalTypeToSDPItem(item) if err != nil { return nil, err } } return sdpItems, nil } func (d *computeInstanceWrapper) IAMPermissions() []string { return []string{ "compute.instances.get", "compute.instances.list", } } ================================================ FILE: sources/example/standard_searchable_listable_test.go ================================================ package example_test import ( "context" "testing" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/example" "github.com/overmindtech/cli/sources/example/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestStandardSearchableListable(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockExternalAPIClient := mocks.NewMockExternalAPIClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" t.Run("Get", func(t *testing.T) { searchableListable := example.NewStandardSearchableListable(mockExternalAPIClient, projectID, zone) // Mock the Get method to return a specific ExternalType mockExternalAPIClient.EXPECT().Get(ctx, "test-id").Return(&example.ExternalType{ Type: "test-type", UniqueAttribute: "test-unique-attribute", Tags: map[string]string{"address": "test-address"}, LinkedItemID: "test-link-me", }, nil) item, err := searchableListable.Get(ctx, searchableListable.Scopes()[0], "test-id") if err != nil { t.Fatalf("Expected no error, got: %v", err) } if item == nil { t.Fatalf("Expected item, got nil") } if item.GetType() != "test-type" { t.Fatalf("Expected type 'test-type', got: %s", item.GetType()) } if item.GetUniqueAttribute() != "test-unique-attribute" { t.Fatalf("Expected unique attribute 'test-unique-attribute', got: %s", item.GetUniqueAttribute()) } if item.GetTags()["address"] != "test-address" { t.Fatalf("Expected address 'test-address', got: %s", item.GetTags()["address"]) } linkedItemQuery := item.GetLinkedItemQueries()[0].GetQuery() var potentialLinkedItem shared.ItemType for v := range searchableListable.PotentialLinks() { potentialLinkedItem = v } if linkedItemQuery.GetType() != potentialLinkedItem.String() { t.Fatalf("Expected linked item type '%s', got: %s", potentialLinkedItem.String(), linkedItemQuery.GetType()) } }) t.Run("GetNotFound", func(t *testing.T) { searchableListable := example.NewStandardSearchableListable(mockExternalAPIClient, projectID, zone) // Mock the Get method to return a NotFoundError mockExternalAPIClient.EXPECT().Get(ctx, "test-id").Return(nil, example.NotFoundError{}) item, err := searchableListable.Get(ctx, searchableListable.Scopes()[0], "test-id") if err == nil { t.Fatalf("Expected error, got: %v", item) } if err.GetErrorString() != new(example.NotFoundError).Error() { t.Fatalf("Expected NotFoundError, got: %v", err) } if err.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("Expected error type NOT_FOUND, got: %v", err.GetErrorType()) } if item != nil { t.Fatalf("Expected nil item, got: %v", item) } }) t.Run("List", func(t *testing.T) { searchableListable := example.NewStandardSearchableListable(mockExternalAPIClient, projectID, zone) // Mock the List method to return a list of ExternalType mockExternalAPIClient.EXPECT().List(ctx).Return([]*example.ExternalType{ { Type: "test-type", UniqueAttribute: "test-unique-attribute", Tags: map[string]string{"address": "test-address"}, LinkedItemID: "test-link-me", }, }, nil) items, err := searchableListable.List(ctx, searchableListable.Scopes()[0]) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) == 0 { t.Fatalf("Expected items, got empty list") } if items[0].GetType() != "test-type" { t.Fatalf("Expected type 'test-type', got: %s", items[0].GetType()) } if items[0].GetUniqueAttribute() != "test-unique-attribute" { t.Fatalf("Expected unique attribute 'test-unique-attribute', got: %s", items[0].GetUniqueAttribute()) } if items[0].GetTags()["address"] != "test-address" { t.Fatalf("Expected address 'test-address', got: %s", items[0].GetTags()["address"]) } }) t.Run("ListNotFound", func(t *testing.T) { searchableListable := example.NewStandardSearchableListable(mockExternalAPIClient, projectID, zone) // Mock the List method to return a NotFoundError mockExternalAPIClient.EXPECT().List(ctx).Return(nil, example.NotFoundError{}) items, err := searchableListable.List(ctx, searchableListable.Scopes()[0]) if err == nil { t.Fatalf("Expected error, got: %v", items) } if err.GetErrorString() != new(example.NotFoundError).Error() { t.Fatalf("Expected NotFoundError, got: %v", err) } if err.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("Expected error type NOT_FOUND, got: %v", err.GetErrorType()) } if items != nil { t.Fatalf("Expected nil items, got: %v", items) } }) t.Run("Search", func(t *testing.T) { searchableListable := example.NewStandardSearchableListable(mockExternalAPIClient, projectID, zone) // Mock the Search method to return a list of ExternalType mockExternalAPIClient.EXPECT().Search(ctx, "test-query").Return([]*example.ExternalType{ { Type: "test-type", UniqueAttribute: "test-unique-attribute", Tags: map[string]string{"address": "test-address"}, LinkedItemID: "test-link-me", }, }, nil) items, err := searchableListable.Search(ctx, searchableListable.Scopes()[0], "test-query") if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(items) == 0 { t.Fatalf("Expected items, got empty list") } if items[0].GetType() != "test-type" { t.Fatalf("Expected type 'test-type', got: %s", items[0].GetType()) } if items[0].GetUniqueAttribute() != "test-unique-attribute" { t.Fatalf("Expected unique attribute 'test-unique-attribute', got: %s", items[0].GetUniqueAttribute()) } if items[0].GetTags()["address"] != "test-address" { t.Fatalf("Expected address 'test-address', got: %s", items[0].GetTags()["address"]) } }) t.Run("SearchNotFound", func(t *testing.T) { searchableListable := example.NewStandardSearchableListable(mockExternalAPIClient, projectID, zone) // Mock the Search method to return a NotFoundError mockExternalAPIClient.EXPECT().Search(ctx, "test-query").Return(nil, example.NotFoundError{}) items, err := searchableListable.Search(ctx, searchableListable.Scopes()[0], "test-query") if err == nil { t.Fatalf("Expected error, got: %v", items) } if err.GetErrorString() != new(example.NotFoundError).Error() { t.Fatalf("Expected NotFoundError, got: %v", err) } if err.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("Expected error type NOT_FOUND, got: %v", err.GetErrorType()) } if items != nil { t.Fatalf("Expected nil items, got: %v", items) } }) } ================================================ FILE: sources/example/validation_test.go ================================================ package example import ( "encoding/json" "os" "strings" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" ) type Validate interface { Validate() error } func TestAdaptersValidation(t *testing.T) { projectID := "123456789012" zone := "us-east-1" var adapters []discovery.Adapter adapters = append(adapters, sources.WrapperToAdapter(NewStandardSearchableListable(nil, projectID, zone), sdpcache.NewNoOpCache()), sources.WrapperToAdapter(NewCustomSearchableListable(nil, projectID, zone), sdpcache.NewNoOpCache()), ) for _, adapter := range adapters { t.Run(adapter.Name(), func(t *testing.T) { // Test the adapter a, ok := adapter.(Validate) if !ok { t.Fatalf("Adapter %s does not implement Validate", adapter.Name()) } if err := a.Validate(); err != nil { t.Fatalf("Adapter %s failed validation: %v", adapter.Name(), err) } if strings.EqualFold(os.Getenv("LOG_LEVEL"), "debug") { // Pretty print the adapter metadata via json jsonData, err := json.MarshalIndent(adapter.Metadata(), "", " ") if err != nil { t.Fatalf("Failed to marshal adapter metadata: %v", err) } t.Logf("Adapter %s metadata: %s", adapter.Name(), string(jsonData)) } }) } } ================================================ FILE: sources/gcp/README.md ================================================ # Further Information for GCP Adapter Creation Please refer to the [generic adapter creation documentation](../README.md) to learn about the generic adapter framework. This document is to highlight the specific implementation details for the GCP adapters. ## Configuration ### Multi-Project Discovery The GCP source supports automatic discovery of all accessible projects. This allows you to discover resources across your entire GCP organization without manually specifying each project. #### Single Project Mode (Legacy) To discover resources in a specific project: ```bash gcp-source --gcp-project-id=my-project-id --gcp-regions=us-central1,us-east1 ``` #### Multi-Project Mode (Automatic Discovery) To automatically discover and monitor all accessible projects: ```bash gcp-source --gcp-regions=us-central1,us-east1 ``` When no `--gcp-project-id` is specified, the source will: 1. Call the Cloud Resource Manager API's `projects.list` endpoint 2. Discover all ACTIVE projects the authenticated service account has access to 3. Create adapters for each discovered project 4. Monitor resources across all projects #### Required Permissions For multi-project discovery, the service account must have the following permission: - `resourcemanager.projects.list` (included in the `roles/browser` predefined role) For single-project mode, the service account needs: - `resourcemanager.projects.get` (included in the `roles/browser` predefined role) All other resource-specific permissions remain the same as documented in each adapter's metadata. ### Service Account Impersonation Both single-project and multi-project modes support service account impersonation: ```bash gcp-source --gcp-impersonation-service-account-email=sa@project.iam.gserviceaccount.com --gcp-regions=us-central1 ``` ## Naming Conventions To construct the name of the adapter, we need to identify the following elements: - Source: Currently we defined this as `gcp`. - API: The API name, e.g. `compute`, `storage`, `bigquery`, etc. - Resource: The resource name, e.g. `instance`, `bucket`, `dataset`, etc. Let's take the GCP Compute Instance as an example. We can use the API explorer to get the correct API endpoint documentation: [API Explorer](https://developers.google.com/apis-explorer). From the Compute API, the service BASE URL is `https://compute.googleapis.com`. So, the API name is `compute`: The API name is the first part of the URL after the `https://` and before the `googleapis.com`. Then we can navigate to the section for the [Instances](https://cloud.google.com/compute/docs/reference/rest/v1#rest-resource:-v1.instances). It is in plural form, but in our adapter we use the singular form `instance`. We define all these elements as constants. The API and Resource type definitions are in the [gcp shared models file](./shared/models.go). Then we define the type itself in the relevant adapter file: `sources/gcp/compute-instance.go`. ```go var ComputeInstance = shared.NewItemType(gcpshared.GCP, gcpshared.Compute, gcpshared.Instance) ``` ## Scopes Every adapter has a scope that guarantees that the connected external resource can be identified within that scope uniquely. For more see [Scope](../../sdp/README.md). For GCP, we define the following scopes: - Project: If the connected GCP resource requires the `project_id` for retrieving the resource along with its unique identifier, we define the scope as `project_id`. For example [Compute Network](https://cloud.google.com/compute/docs/reference/rest/v1/networks/get). - Region: If the connected GCP resource requires the `project_id` and `region` for retrieving the resource along with its unique identifier, we define the scope as `project_id.region`. For example [Compute Subnetwork](https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/get). - Zone: If the connected GCP resource requires the `project_id` and `zone` for retrieving the resource along with its unique identifier, we define the scope as `project_id.zone`. For example [Compute Instance](https://cloud.google.com/compute/docs/reference/rest/v1/instances/get). After deciding which scope to use, we can create the adapter by using the relevant Base struct which will construct the correct scope for us. ```go // NewComputeInstance creates a new computeInstanceWrapper instance func NewComputeInstance(client gcpshared.ComputeInstanceClient, projectID, zone string) sources.ListableWrapper { return &computeInstanceWrapper{ client: client, ZonalBase: gcpshared.NewZoneBase( // <-- Use the ZoneBase struct projectID, zone, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, ComputeInstance, ), } } ``` ## Linked Item Queries ### Simple Queries for the Same API When defining a relation between two adapters, we need to answer the following questions: - What is the type of the related item? - What is the method to use to get the related item?: `sdp.QueryMethod_GET`, `sdp.QueryMethod_SEARCH`, `sdp.QueryMethod_LIST` - What is the query string to pass to the selected method? - What is the scope of the related item?: `project`, `region`, `zone` In the following example, we define a relation between the `ComputeInstance` and `ComputeSubnetwork` adapters. - We identify the `ComputeSubnetwork` adapter as the related item. - We use the `sdp.QueryMethod_GET` method to get the related item. Because the attribute `subnetwork_name` can be used to get the `ComputeSubnetwork` resource. If it was an attribute that can be used for searching, we would use the `sdp.QueryMethod_SEARCH` method. By the time we are developing the adapter, the linked adapter may not be present. In that case, we have to research the linked adapter and make the correct judgement. - We use the `subnetworkName` as the query string to pass to the `GET` method. Because its [SDK documentation](https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/get) states that we need to pass its `name` to get the resource. - We define the scope as `region` via the `gcpshared.RegionalScope(c.ProjectID(), region)` helper function. Because the `ComputeSubnetwork` resource is a regional resource. It requires the `project_id` and `region` along with its `name` to get the resource. ```go &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeSubnetwork.String(), Method: sdp.QueryMethod_GET, Query: subnetworkName, // This is a regional resource Scope: gcpshared.RegionalScope(c.ProjectID(), region), }, } ``` ### Composite Queries for Different APIs When the related item is not in the same API as the adapter, we need to investigate how to get the related item. In the case of creating a link to a crypto key version, first we need to find the [relevant API](https://cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys.cryptoKeyVersions/get?rep_location=global#path-parameters). It gives us the GET method to use, the `https://cloudkms.googleapis.com/v1/{name=projects/*/locations/*/keyRings/*/cryptoKeys/*/cryptoKeyVersions/*}`. Now, we need to decide how the linked item will look like: - API: `cloudkms`, first item of the domain after `https://` and before `googleapis.com`. - Name: `cryptoKeyVersion`, which is the single version of the identifier for the resource. - Scope: `Project` level. Because the `locations` in the url can be a region or zone, so it will be dynamically required from the query. Putting together all this information: - Linked Item Type: `CloudKMSCryptoKeyVersion.String()`: assuming that we defined this type in its own file for future. - Linked Item Query: What we need to construct the full URL? ProjectID will come from the scope. We need to pass: `location`, `keyring`, `cryptoKey` and `cryptoKeyVersion`. So we need a helper function to extract these information and compose a query by constructing a string simply joining all these variables by our default query separator `|`. We can use the helper function from shared: `shared.CompositeLookupKey(location, keyring, cryptoKey, cryptoKeyVersion)`. - Linked Item Scope: Project level, because the adapter for this type will have a project level scope. ================================================ FILE: sources/gcp/build/package/Dockerfile ================================================ # Build the source binary FROM golang:1.26.2-alpine3.23 AS builder ARG TARGETOS ARG TARGETARCH ARG BUILD_VERSION ARG BUILD_COMMIT # required for generating the version descriptor RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg \ go mod download COPY go/ go/ COPY sources/ sources/ # Build RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source sources/gcp/main.go FROM alpine:3.23.4 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 ENTRYPOINT ["/source"] ================================================ FILE: sources/gcp/cmd/root.go ================================================ package cmd import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/logging" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/sources/gcp/proc" "github.com/overmindtech/cli/go/tracing" ) var cfgFile string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "gcp-source", Short: "Remote primary source for GCP", SilenceUsage: true, Long: `This sources looks for GCP resources in your account. `, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() defer tracing.LogRecoverToReturn(ctx, "gcp-source.root") healthCheckPort := viper.GetInt("health-check-port") engineConfig, err := discovery.EngineConfigFromViper("gcp", tracing.Version()) if err != nil { log.WithError(err).Error("Could not create engine config") return fmt.Errorf("could not create engine config: %w", err) } // Create a basic engine first so we can serve health probes and heartbeats even if init fails e, err := discovery.NewEngine(engineConfig) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not create engine") return fmt.Errorf("could not create engine: %w", err) } // Serve health probes before initialization so they're available even on failure e.ServeHealthProbes(healthCheckPort) // Start the engine (NATS connection) before adapter init so heartbeats work err = e.Start(ctx) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not start engine") return fmt.Errorf("could not start engine: %w", err) } // Config validation (permanent errors — no retry, just idle with error) gcpCfg, cfgErr := proc.ConfigFromViper() if cfgErr != nil { log.WithError(cfgErr).Error("GCP source config error - pod will stay running with error status") e.SetInitError(cfgErr) sentry.CaptureException(cfgErr) } else { // Adapter init (retryable errors — backoff capped at 5 min) e.InitialiseAdapters(ctx, func(ctx context.Context) error { return proc.InitializeAdapters(ctx, e, gcpCfg) }) } <-ctx.Done() log.Info("Stopping engine") err = e.Stop() if err != nil { log.WithError(err).Error("Could not stop engine") return fmt.Errorf("could not stop engine: %w", err) } log.Info("Stopped") return nil }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } func init() { cobra.OnInitialize(initConfig) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. var logLevel string // add engine flags discovery.AddEngineFlags(rootCmd) // General config options rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "/etc/srcman/config/source.yaml", "config file path") rootCmd.PersistentFlags().StringVar(&logLevel, "log", "info", "Set the log level. Valid values: panic, fatal, error, warn, info, debug, trace") // Custom flags for this source rootCmd.PersistentFlags().IntP("health-check-port", "", 8080, "The port that the health check should run on") rootCmd.PersistentFlags().String("gcp-parent", "", "GCP parent resource to discover from. Can be an organization (organizations/{org_id}), folder (folders/{folder_id}), or project (project-id or projects/{project_id}). If not specified, all accessible projects will be discovered automatically. Format examples: 'organizations/123456789012', 'folders/123456789012', 'my-project-id', 'projects/my-project-id'") rootCmd.PersistentFlags().String("gcp-project-id", "", "(Deprecated: use --gcp-parent instead) GCP Project ID that this source should operate in. If not specified, all accessible projects will be discovered automatically using the Cloud Resource Manager API. Requires 'resourcemanager.projects.list' permission (included in 'roles/browser' role).") rootCmd.PersistentFlags().String("gcp-regions", "", "Comma-separated list of GCP regions that this source should operate in") rootCmd.PersistentFlags().String("gcp-zones", "", "Comma-separated list of GCP zones that this source should operate in") rootCmd.PersistentFlags().String("gcp-impersonation-service-account-email", "", "The email of the service account to impersonate. Leave empty for direct access using Application Default Credentials.") // tracing rootCmd.PersistentFlags().String("honeycomb-api-key", "", "If specified, configures opentelemetry libraries to submit traces to honeycomb") rootCmd.PersistentFlags().String("sentry-dsn", "", "If specified, configures sentry libraries to capture errors") rootCmd.PersistentFlags().String("run-mode", "release", "Set the run mode for this service, 'release', 'debug' or 'test'. Defaults to 'release'.") rootCmd.PersistentFlags().Bool("json-log", true, "Set to false to emit logs as text for easier reading in development.") cobra.CheckErr(viper.BindEnv("json-log", "GCP_SOURCE_JSON_LOG", "JSON_LOG")) // Bind these to viper cobra.CheckErr(viper.BindPFlags(rootCmd.PersistentFlags())) // Run this before we do anything to set up the loglevel rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if lvl, err := log.ParseLevel(logLevel); err == nil { log.SetLevel(lvl) } else { log.SetLevel(log.InfoLevel) log.WithFields(log.Fields{ "error": err, }).Error("Could not parse log level") } log.AddHook(TerminationLogHook{}) // Bind flags that haven't been set to the values from viper of we have them var bindErr error cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { // Bind the flag to viper only if it has a non-empty default if f.DefValue != "" || f.Changed { if err := viper.BindPFlag(f.Name, f); err != nil { bindErr = err } } }) if bindErr != nil { log.WithError(bindErr).Error("could not bind flag to viper") return fmt.Errorf("could not bind flag to viper: %w", bindErr) } if viper.GetBool("json-log") { logging.ConfigureLogrusJSON(log.StandardLogger()) } if err := tracing.InitTracerWithUpstreams("gcp-source", viper.GetString("honeycomb-api-key"), viper.GetString("sentry-dsn")); err != nil { log.WithError(err).Error("could not init tracer") return fmt.Errorf("could not init tracer: %w", err) } return nil } // shut down tracing at the end of the process rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { tracing.ShutdownTracer(context.Background()) } } // initConfig reads in config file and ENV variables if set. func initConfig() { viper.SetConfigFile(cfgFile) replacer := strings.NewReplacer("-", "_") viper.SetEnvKeyReplacer(replacer) viper.AutomaticEnv() // read in environment variables that match // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil { log.Infof("Using config file: %v", viper.ConfigFileUsed()) } } // TerminationLogHook A hook that logs fatal errors to the termination log type TerminationLogHook struct{} func (t TerminationLogHook) Levels() []log.Level { return []log.Level{log.FatalLevel} } func (t TerminationLogHook) Fire(e *log.Entry) error { // shutdown tracing first to ensure all spans are flushed tracing.ShutdownTracer(context.Background()) tLog, err := os.OpenFile("/dev/termination-log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } var message string message = e.Message for k, v := range e.Data { message = fmt.Sprintf("%v %v=%v", message, k, v) } _, err = tLog.WriteString(message) return err } ================================================ FILE: sources/gcp/cmd/root_test.go ================================================ package cmd import ( "bytes" "strings" "testing" ) func TestRootCommand_ShowsUsageWithoutOptions(t *testing.T) { // Capture stdout and stderr var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) // Execute the command with --help flag to simulate usage request rootCmd.SetArgs([]string{"--help"}) err := rootCmd.Execute() // Get the output output := buf.String() // Verify that usage information is present in the output usageIndicators := []string{ "gcp-source", "This sources looks for GCP resources in your account", "Usage:", "Flags:", } for _, indicator := range usageIndicators { if !strings.Contains(output, indicator) { t.Errorf("Expected usage output to contain %q, but it didn't. Output: %s", indicator, output) } } // --help should not produce an error if err != nil { t.Errorf("Expected Execute() with --help to return nil, but got error: %v", err) } } ================================================ FILE: sources/gcp/dynamic/README.md ================================================ # GCP Dynamic Adapter Framework The GCP Dynamic Adapter Framework is a powerful system for automatically generating GCP resource adapters by making simple HTTP requests to GCP APIs instead of using versioned SDKs. This framework eliminates the need to manually implement GET/SEARCH/LIST methods and handles all the complex wiring, validation, and error handling automatically. ## What is a Dynamic Adapter? Instead of using versioned SDKs for GCP, we make simple HTTP requests and generate resource adapters dynamically. The framework provides several key advantages: - **No Manual Method Implementation**: Instead of creating GET/SEARCH/LIST methods manually, we define only the endpoints in the adapter type definition - **Automatic Link Detection**: We identify the linked items, but the framework handles all the wiring depending on the adapter metadata - **Centralized Framework Logic**: All adapter metadata, query validations, error handling, iterations, and caching are handled by the framework - **AI-Assisted Development**: With Cursor instructions, all we need to do is provide links for the resource type definition and GET endpoint. Cursor does a good job generating the code, but the output should be thoroughly inspected. The author should not allow good-looking verbose unnecessary code since every line of code is a liability. Focus on concise, essential implementations and comprehensive test coverage ## Why Dynamic? We don't use fixed SDKs. We always use the dynamic API response. With comprehensive logging in place, we can identify potential links even after creating adapters, which was not possible before. We do this by checking the structure of an attribute - if it looks like a resource name but we don't have a link for it, then we log it as a potential adapter. This approach provides several benefits: - **Future-Proof**: No dependency on SDK versions that may change - **Consistent**: All adapters follow the same patterns and behaviors - **Discoverable**: Automatic detection of new potential links from API responses - **Maintainable**: Centralized logic means updates apply to all adapters ## Resource Requirements For a resource to be compatible with the dynamic adapter framework, it should follow standard naming conventions and API response types. See BigQuery as an example of a non-standard adapter that required a manual implementation due to its unique API response format and naming conventions. **Standard Requirements:** - Consistent resource naming in API responses - Standard REST API patterns (GET, LIST endpoints) - Predictable response structures - Standard GCP resource URL patterns **Non-Standard Examples (Require Manual Adapters):** - BigQuery resources with composite IDs (`projectID:datasetID.tableID`) - Resources with attributes referencing multiple resource types - APIs with non-standard response formats ## Linker: How It Works The linker is a critical component that finds the adapter metadata for linked items and creates linked item queries by their definition. This standardizes how a certain adapter is linked across the entire source and prevents code duplication. **Key Benefits:** - **Standardization**: Ensures consistent linking patterns across all adapters - **Centralized Updates**: If a linked item adapter changes, the update applies to all existing adapters automatically - **No Find/Replace**: Eliminates the need to manually update multiple files when linked item logic changes - **Manual Adapter Compatibility**: It's possible to link manual adapters to dynamic adapters seamlessly ## Flow: GET Request to SDP Adapter The complete flow from making a GET request to creating an SDP adapter follows these steps: 1. **Adapter Definition**: Define the adapter metadata in the adapter file (see [dynamic-adapter-creation.mdc](adapters/.cursor/rules/dynamic-adapter-creation.mdc)) 2. **Adapter Creation**: Framework creates the appropriate adapter type based on metadata configuration 3. **GET Request Processing**: Validate scope, check cache, construct URL, make HTTP request, convert to SDP item 4. **External Response to SDP Conversion**: Extract attributes, apply link rules, generate linked item queries 5. **Unit Test Coverage**: Test GET functionality and static tests for link rules For detailed implementation patterns and code examples, refer to the [dynamic adapter creation rules](adapters/.cursor/rules/dynamic-adapter-creation.mdc). ## AI Tools Available We have helper scripts that benefit from Linear and Cursor integration to streamline adapter development: ### Generate Adapter Ticket ```bash # Generate implementation ticket for new adapter go run ai-tools/generate-adapter-ticket-cmd/main.go -name compute-subnetwork -api-ref "https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/get" ``` ### Generate Test Ticket ```bash # Generate test ticket for existing adapter go run ai-tools/generate-test-ticket-cmd/main.go compute-global-address ``` **Benefits:** - **Automated Ticket Creation**: Generates Linear tickets with proper context and requirements - **Cursor Integration**: Works seamlessly with Cursor rules for consistent implementation - **Comprehensive Context**: Includes API references, implementation checklists, and testing requirements For detailed usage instructions, see the [AI Tools README](ai-tools/README.md). ## Cursor Integration It is highly recommended to use Cursor for creating adapters. There are comprehensive rules available that guide the implementation process. After creating an adapter, the author MUST perform the following checks: ### Adapter Validation 1. **Terraform Mappings GET/Search**: Check from Terraform registry that the mappings are correct 2. **Link Rules**: Verify they are comprehensive and attribute values follow standards 3. **Item Selector**: If the item identifier in the API response is something other than `name`, define it properly 4. **Unique Attribute Keys**: Investigate the GET endpoint format and ensure it's correct ### Test Completeness 1. **Linked Item Queries**: Verify they work as expected 2. **Unique Attribute**: Ensure it matches the GET call response 3. **Terraform Mapping for Search**: Confirm it exists if search is supported ## Post-Implementation Steps After adding a new adapter, follow the comprehensive post-implementation checklist in the [main adapter documentation](../README.md#post-implementation-steps). This includes updating documentation, IAM permissions, and enabling required APIs. ## Adapter Types The framework supports four types of adapters based on their capabilities: - **Standard**: GET only - **Listable**: GET + LIST - **Searchable**: GET + SEARCH - **SearchableListable**: GET + LIST + SEARCH The adapter type is automatically determined based on the metadata configuration: ```go func adapterType(meta gcpshared.AdapterMeta) typeOfAdapter { if meta.ListEndpointFunc != nil && meta.SearchEndpointFunc == nil { return Listable } if meta.SearchEndpointFunc != nil && meta.ListEndpointFunc == nil { return Searchable } if meta.ListEndpointFunc != nil && meta.SearchEndpointFunc != nil { return SearchableListable } return Standard } ``` ## Benefits of Dynamic Adapters 1. **Consistency**: All adapters follow the same patterns and behaviors 2. **Efficiency**: Reduces boilerplate code and speeds up development 3. **Maintainability**: Centralized logic makes updates and bug fixes easier 4. **Scalability**: Simplifies the process of adding new resources 5. **Quality**: Automatic validation and error handling ensure reliability 6. **Discoverability**: Automatic detection of potential new links from API responses ## Getting Started 1. **Use AI Tools**: Generate tickets using the helper scripts 2. **Follow Cursor Rules**: Apply the comprehensive rules for consistent implementation 3. **Review Thoroughly**: Check all validation points before considering complete 4. **Update Documentation**: Ensure all related documentation is updated 5. **Test Extensively**: Verify all functionality works as expected The dynamic adapter framework represents a significant advancement in how we handle GCP resource discovery, providing a robust, scalable, and maintainable solution for infrastructure mapping. ================================================ FILE: sources/gcp/dynamic/adapter-listable.go ================================================ package dynamic import ( "context" "fmt" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // ListableAdapter implements discovery.ListableAdapter for GCP dynamic adapters. type ListableAdapter struct { listEndpointFunc gcpshared.ListEndpointFunc listFilterFunc gcpshared.ListFilterFunc Adapter } // NewListableAdapter creates a new GCP dynamic adapter. func NewListableAdapter(listEndpointFunc gcpshared.ListEndpointFunc, config *AdapterConfig, cache sdpcache.Cache) discovery.ListableAdapter { return ListableAdapter{ listEndpointFunc: listEndpointFunc, listFilterFunc: config.ListFilterFunc, Adapter: Adapter{ locations: config.Locations, httpCli: config.HTTPClient, cache: cache, getURLFunc: config.GetURLFunc, sdpAssetType: config.SDPAssetType, sdpAdapterCategory: config.SDPAdapterCategory, terraformMappings: config.TerraformMappings, linker: config.Linker, potentialLinks: potentialLinksFromLinkRules(config.SDPAssetType, gcpshared.LinkRules), uniqueAttributeKeys: config.UniqueAttributeKeys, iamPermissions: config.IAMPermissions, nameSelector: config.NameSelector, listResponseSelector: config.ListResponseSelector, }, } } func (g ListableAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: g.sdpAssetType.String(), Category: g.sdpAdapterCategory, DescriptiveName: g.sdpAssetType.Readable(), SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: getDescription(g.sdpAssetType, g.uniqueAttributeKeys), List: true, ListDescription: listDescription(g.sdpAssetType), }, TerraformMappings: g.terraformMappings, PotentialLinks: g.potentialLinks, } } func (g ListableAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { location, err := g.validateScope(scope) if err != nil { return nil, err } cacheHit, ck, cachedItems, qErr, done := g.cache.Lookup( ctx, g.Name(), sdp.QueryMethod_LIST, scope, g.Type(), "", ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.adapter": g.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_LIST.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") return nil, qErr } if cacheHit { return cachedItems, nil } listURL, err := g.listEndpointFunc(location) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to construct list endpoint: %v", err), } } items, err := aggregateSDPItems(ctx, g.Adapter, listURL, location) if err != nil { if sources.IsNotFound(err) { g.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) return []*sdp.Item{}, nil } return nil, err } if g.listFilterFunc != nil { filtered := make([]*sdp.Item, 0, len(items)) for _, item := range items { if g.listFilterFunc(item) { filtered = append(filtered, item) } } items = filtered } if len(items) == 0 { // Cache not-found when no items were found notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found in scope %s", g.Type(), scope), Scope: scope, SourceName: g.Name(), ItemType: g.Type(), ResponderName: g.Name(), } g.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, ck) return items, nil } for _, item := range items { g.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) } return items, nil } func (g ListableAdapter) ListStream(ctx context.Context, scope string, ignoreCache bool, stream discovery.QueryResultStream) { // When a post-filter is configured, fall back to the non-streaming List // so we can filter before sending items to the stream. if g.listFilterFunc != nil { items, err := g.List(ctx, scope, ignoreCache) if err != nil { stream.SendError(err) return } for _, item := range items { stream.SendItem(item) } return } location, err := g.validateScope(scope) if err != nil { stream.SendError(err) return } cacheHit, ck, cachedItems, qErr, done := g.cache.Lookup( ctx, g.Name(), sdp.QueryMethod_LIST, scope, g.Type(), "", ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.adapter": g.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_LIST.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } listURL, err := g.listEndpointFunc(location) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to construct list endpoint: %v", err), }) return } streamSDPItems(ctx, g.Adapter, listURL, location, stream, g.cache, ck) } ================================================ FILE: sources/gcp/dynamic/adapter-searchable-listable.go ================================================ package dynamic import ( "context" "fmt" "strings" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) type SearchableListableDiscoveryAdapter interface { discovery.SearchableAdapter discovery.ListableAdapter } // SearchableListableAdapter implements discovery.SearchableAdapter for GCP dynamic adapters. type SearchableListableAdapter struct { customSearchMethodDescription string searchEndpointFunc gcpshared.EndpointFunc searchFilterFunc gcpshared.SearchFilterFunc ListableAdapter } // NewSearchableListableAdapter creates a new GCP dynamic adapter. func NewSearchableListableAdapter(searchURLFunc gcpshared.EndpointFunc, listEndpointFunc gcpshared.ListEndpointFunc, config *AdapterConfig, customSearchMethodDesc string, cache sdpcache.Cache) SearchableListableDiscoveryAdapter { return SearchableListableAdapter{ customSearchMethodDescription: customSearchMethodDesc, searchEndpointFunc: searchURLFunc, searchFilterFunc: config.SearchFilterFunc, ListableAdapter: ListableAdapter{ listEndpointFunc: listEndpointFunc, listFilterFunc: config.ListFilterFunc, Adapter: Adapter{ locations: config.Locations, httpCli: config.HTTPClient, cache: cache, getURLFunc: config.GetURLFunc, sdpAssetType: config.SDPAssetType, sdpAdapterCategory: config.SDPAdapterCategory, terraformMappings: config.TerraformMappings, linker: config.Linker, potentialLinks: potentialLinksFromLinkRules(config.SDPAssetType, gcpshared.LinkRules), uniqueAttributeKeys: config.UniqueAttributeKeys, iamPermissions: config.IAMPermissions, nameSelector: config.NameSelector, listResponseSelector: config.ListResponseSelector, }, }, } } func (g SearchableListableAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: g.sdpAssetType.String(), Category: g.sdpAdapterCategory, DescriptiveName: g.sdpAssetType.Readable(), SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: getDescription(g.sdpAssetType, g.uniqueAttributeKeys), Search: true, SearchDescription: searchDescription(g.sdpAssetType, g.uniqueAttributeKeys, g.customSearchMethodDescription), List: true, ListDescription: listDescription(g.sdpAssetType), }, TerraformMappings: g.terraformMappings, PotentialLinks: g.potentialLinks, } } func (g SearchableListableAdapter) Search(ctx context.Context, scope, query string, ignoreCache bool) ([]*sdp.Item, error) { location, err := g.validateScope(scope) if err != nil { return nil, err } cacheHit, ck, cachedItems, qErr, done := g.cache.Lookup( ctx, g.Name(), sdp.QueryMethod_SEARCH, scope, g.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.adapter": g.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_SEARCH.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") return nil, qErr } if cacheHit { return cachedItems, nil } if strings.HasPrefix(query, "projects/") { // This must be a terraform query in the format of: // projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} return terraformMappingViaSearch(ctx, g.Adapter, query, location, g.cache, ck) } searchEndpoint := g.searchEndpointFunc(query, location) if searchEndpoint == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("no search endpoint found for query \"%s\". %s", query, g.Metadata().GetSupportedQueryMethods().GetSearchDescription()), } } items, err := aggregateSDPItems(ctx, g.Adapter, searchEndpoint, location) if err != nil { if sources.IsNotFound(err) { g.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) return []*sdp.Item{}, nil } return nil, err } if g.searchFilterFunc != nil { filtered := make([]*sdp.Item, 0, len(items)) for _, item := range items { if g.searchFilterFunc(query, item) { filtered = append(filtered, item) } } items = filtered } if len(items) == 0 { // Cache not-found when no items were found notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found for search query '%s'", g.Type(), query), Scope: scope, SourceName: g.Name(), ItemType: g.Type(), ResponderName: g.Name(), } g.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, ck) return items, nil } for _, item := range items { g.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) } return items, nil } func (g SearchableListableAdapter) SearchStream(ctx context.Context, scope, query string, ignoreCache bool, stream discovery.QueryResultStream) { // When a post-filter is configured, fall back to the non-streaming Search // so we can filter before sending items to the stream. if g.searchFilterFunc != nil { items, err := g.Search(ctx, scope, query, ignoreCache) if err != nil { stream.SendError(err) return } for _, item := range items { stream.SendItem(item) } return } location, err := g.validateScope(scope) if err != nil { stream.SendError(err) return } cacheHit, ck, cachedItems, qErr, done := g.cache.Lookup( ctx, g.Name(), sdp.QueryMethod_SEARCH, scope, g.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.adapter": g.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_SEARCH.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } if strings.HasPrefix(query, "projects/") { // This must be a terraform query in the format of: // projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} items, err := terraformMappingViaSearch(ctx, g.Adapter, query, location, g.cache, ck) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to execute terraform mapping search for query \"%s\": %v", query, err), }) return } if len(items) == 0 { // NOTFOUND: terraformMappingViaSearch returns ([], nil); send nothing (matches cached NOTFOUND behaviour) return } g.cache.StoreItem(ctx, items[0], shared.DefaultCacheDuration, ck) // There should only be one item in the result, so we can send it directly stream.SendItem(items[0]) return } searchURL := g.searchEndpointFunc(query, location) if searchURL == "" { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to construct the URL for the query \"%s\". SEARCH method description: %s", query, g.Metadata().GetSupportedQueryMethods().GetSearchDescription(), ), }) return } streamSDPItems(ctx, g.Adapter, searchURL, location, stream, g.cache, ck) } ================================================ FILE: sources/gcp/dynamic/adapter-searchable.go ================================================ package dynamic import ( "context" "fmt" "strings" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // SearchableAdapter implements discovery.SearchableAdapter for GCP dynamic adapters. type SearchableAdapter struct { customSearchMethodDesc string searchEndpointFunc gcpshared.EndpointFunc Adapter } // NewSearchableAdapter creates a new GCP dynamic adapter. func NewSearchableAdapter(searchEndpointFunc gcpshared.EndpointFunc, config *AdapterConfig, customSearchMethodDesc string, cache sdpcache.Cache) discovery.SearchableAdapter { return SearchableAdapter{ customSearchMethodDesc: customSearchMethodDesc, searchEndpointFunc: searchEndpointFunc, Adapter: Adapter{ locations: config.Locations, httpCli: config.HTTPClient, cache: cache, getURLFunc: config.GetURLFunc, sdpAssetType: config.SDPAssetType, sdpAdapterCategory: config.SDPAdapterCategory, terraformMappings: config.TerraformMappings, linker: config.Linker, potentialLinks: potentialLinksFromLinkRules(config.SDPAssetType, gcpshared.LinkRules), uniqueAttributeKeys: config.UniqueAttributeKeys, iamPermissions: config.IAMPermissions, nameSelector: config.NameSelector, listResponseSelector: config.ListResponseSelector, }, } } func (g SearchableAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: g.sdpAssetType.String(), Category: g.sdpAdapterCategory, DescriptiveName: g.sdpAssetType.Readable(), SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: getDescription(g.sdpAssetType, g.uniqueAttributeKeys), Search: true, SearchDescription: searchDescription(g.sdpAssetType, g.uniqueAttributeKeys, g.customSearchMethodDesc), }, TerraformMappings: g.terraformMappings, PotentialLinks: g.potentialLinks, } } func (g SearchableAdapter) Search(ctx context.Context, scope, query string, ignoreCache bool) ([]*sdp.Item, error) { location, err := g.validateScope(scope) if err != nil { return nil, err } cacheHit, ck, cachedItems, qErr, done := g.cache.Lookup( ctx, g.Name(), sdp.QueryMethod_SEARCH, scope, g.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.adapter": g.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_SEARCH.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") return nil, qErr } if cacheHit { return cachedItems, nil } if strings.HasPrefix(query, "projects/") { // This must be a terraform query in the format of: // projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} return terraformMappingViaSearch(ctx, g.Adapter, query, location, g.cache, ck) } // This is a regular SEARCH call searchEndpoint := g.searchEndpointFunc(query, location) if searchEndpoint == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("no search endpoint found for query \"%s\". %s", query, g.Metadata().GetSupportedQueryMethods().GetSearchDescription()), } } items, err := aggregateSDPItems(ctx, g.Adapter, searchEndpoint, location) if err != nil { if sources.IsNotFound(err) { g.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) return []*sdp.Item{}, nil } return nil, err } if len(items) == 0 { // Cache not-found when no items were found notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found for search query '%s'", g.Type(), query), Scope: scope, SourceName: g.Name(), ItemType: g.Type(), ResponderName: g.Name(), } g.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, ck) return items, nil } for _, item := range items { g.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) } return items, nil } func (g SearchableAdapter) SearchStream(ctx context.Context, scope, query string, ignoreCache bool, stream discovery.QueryResultStream) { location, err := g.validateScope(scope) if err != nil { stream.SendError(err) return } cacheHit, ck, cachedItems, qErr, done := g.cache.Lookup( ctx, g.Name(), sdp.QueryMethod_SEARCH, scope, g.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.adapter": g.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_SEARCH.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } if strings.HasPrefix(query, "projects/") { // This must be a terraform query in the format of: // projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} items, err := terraformMappingViaSearch(ctx, g.Adapter, query, location, g.cache, ck) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to execute terraform mapping search for query \"%s\": %v", query, err), }) return } if len(items) == 0 { // NOTFOUND: terraformMappingViaSearch returns ([], nil); send nothing (matches cached NOTFOUND behaviour) return } g.cache.StoreItem(ctx, items[0], shared.DefaultCacheDuration, ck) // There should only be one item in the result, so we can send it directly stream.SendItem(items[0]) return } searchURL := g.searchEndpointFunc(query, location) if searchURL == "" { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to construct the URL for the query \"%s\". SEARCH method description: %s", query, g.Metadata().GetSupportedQueryMethods().GetSearchDescription(), ), }) return } streamSDPItems(ctx, g.Adapter, searchURL, location, stream, g.cache, ck) } ================================================ FILE: sources/gcp/dynamic/adapter.go ================================================ package dynamic import ( "context" "fmt" "net/http" "slices" "buf.build/go/protovalidate" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // AdapterConfig holds the configuration for a GCP dynamic adapter. type AdapterConfig struct { Locations []gcpshared.LocationInfo GetURLFunc gcpshared.EndpointFunc SDPAssetType shared.ItemType SDPAdapterCategory sdp.AdapterCategory TerraformMappings []*sdp.TerraformMapping Linker *gcpshared.Linker HTTPClient *http.Client UniqueAttributeKeys []string IAMPermissions []string // List of IAM permissions required by the adapter NameSelector string // By default, it is `name`, but can be overridden for outlier cases ListResponseSelector string SearchFilterFunc gcpshared.SearchFilterFunc ListFilterFunc gcpshared.ListFilterFunc } // Adapter implements discovery.ListableAdapter for GCP dynamic adapters. type Adapter struct { locations []gcpshared.LocationInfo httpCli *http.Client cache sdpcache.Cache getURLFunc gcpshared.EndpointFunc sdpAssetType shared.ItemType sdpAdapterCategory sdp.AdapterCategory terraformMappings []*sdp.TerraformMapping potentialLinks []string linker *gcpshared.Linker uniqueAttributeKeys []string iamPermissions []string nameSelector string // By default, it is `name`, but can be overridden for outlier cases listResponseSelector string } // NewAdapter creates a new GCP dynamic adapter. func NewAdapter(config *AdapterConfig, cache sdpcache.Cache) discovery.Adapter { return Adapter{ locations: config.Locations, httpCli: config.HTTPClient, cache: cache, getURLFunc: config.GetURLFunc, sdpAssetType: config.SDPAssetType, sdpAdapterCategory: config.SDPAdapterCategory, terraformMappings: config.TerraformMappings, linker: config.Linker, potentialLinks: potentialLinksFromLinkRules(config.SDPAssetType, gcpshared.LinkRules), uniqueAttributeKeys: config.UniqueAttributeKeys, iamPermissions: config.IAMPermissions, nameSelector: config.NameSelector, listResponseSelector: config.ListResponseSelector, } } func (g Adapter) Type() string { return g.sdpAssetType.String() } func (g Adapter) Name() string { return fmt.Sprintf("%s-adapter", g.sdpAssetType.String()) } func (g Adapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: g.sdpAssetType.String(), Category: g.sdpAdapterCategory, DescriptiveName: g.sdpAssetType.Readable(), SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: getDescription(g.sdpAssetType, g.uniqueAttributeKeys), }, TerraformMappings: g.terraformMappings, PotentialLinks: g.potentialLinks, } } func (g Adapter) Scopes() []string { return gcpshared.LocationsToScopes(g.locations) } // validateScope checks if the requested scope matches one of the adapter's locations. func (g Adapter) validateScope(scope string) (gcpshared.LocationInfo, error) { requestedLoc, err := gcpshared.LocationFromScope(scope) if err != nil { return gcpshared.LocationInfo{}, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("invalid scope format: %v", err), } } if slices.ContainsFunc(g.locations, requestedLoc.Equals) { return requestedLoc, nil } return gcpshared.LocationInfo{}, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match any adapter scope %v", scope, g.Scopes()), } } func (g Adapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { location, err := g.validateScope(scope) if err != nil { return nil, err } cacheHit, ck, cachedItem, qErr, done := g.cache.Lookup( ctx, g.Name(), sdp.QueryMethod_GET, scope, g.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into nil result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return nil, qErr } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.adapter": g.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_GET.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") return nil, qErr } if cacheHit && len(cachedItem) > 0 { return cachedItem[0], nil } url := g.getURLFunc(query, location) if url == "" { err := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to construct the URL for the query \"%s\". GET method description: %s", query, g.Metadata().GetSupportedQueryMethods().GetGetDescription(), ), } return nil, err } resp, err := externalCallSingle(ctx, g.httpCli, url) if err != nil { enrichNOTFOUNDQueryError(err, scope, g.Name(), g.Type()) if sources.IsNotFound(err) { g.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) } return nil, err } item, err := externalToSDP(ctx, location, g.uniqueAttributeKeys, resp, g.sdpAssetType, g.linker, g.nameSelector) if err != nil { enrichNOTFOUNDQueryError(err, scope, g.Name(), g.Type()) if sources.IsNotFound(err) { g.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) } return nil, err } g.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) return item, nil } func (g Adapter) Validate() error { return protovalidate.Validate(g.Metadata()) } ================================================ FILE: sources/gcp/dynamic/adapter_test.go ================================================ package dynamic_test import ( "context" "testing" _ "github.com/overmindtech/cli/sources/gcp/dynamic/adapters" // Import all adapters to register them gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // TODO: Possible improvements: // - Create a helper function that does some of the common assertions for the adapter tests func TestAdapter(t *testing.T) { _ = context.Background() _ = "test-project" _ = gcpshared.NewLinker() // All adapter tests have been moved to individual test files // This file now only serves to import all adapters to register them } ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-batch-prediction-job.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // AI Platform Batch Prediction Job allows you to get inferences for large datasets using trained machine learning models // GCP Ref (GET): https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.batchPredictionJobs/get // GCP Ref (Schema): https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.batchPredictionJobs#BatchPredictionJob // GET https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/batchPredictionJobs/{batchPredictionJob} // LIST https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/batchPredictionJobs var _ = registerableAdapter{ sdpType: gcpshared.AIPlatformBatchPredictionJob, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_AI, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/batchPredictionJobs/%s", ), SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/batchPredictionJobs", ), SearchDescription: "Search Batch Prediction Jobs within a location. Use the location name e.g., 'us-central1'", UniqueAttributeKeys: []string{"locations", "batchPredictionJobs"}, IAMPermissions: []string{ "aiplatform.batchPredictionJobs.get", "aiplatform.batchPredictionJobs.list", }, PredefinedRole: "roles/aiplatform.viewer", // TODO: https://linear.app/overmind/issue/ENG-631 state // https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.batchPredictionJobs#JobState }, linkRules: map[string]*gcpshared.Impact{ "encryptionSpec.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "model": { ToSDPItemType: gcpshared.AIPlatformModel, Description: "If the Model is deleted or updated: The batch prediction job may fail. If the batch prediction job is updated: The model remains unaffected.", }, "endpoint": { ToSDPItemType: gcpshared.AIPlatformEndpoint, Description: "If the Endpoint is deleted or updated: The batch prediction job may fail. If the batch prediction job is updated: The endpoint remains unaffected.", }, // TODO: https://linear.app/overmind/issue/ENG-1446/investigate-creating-a-manual-linker-for-cloud-storage "inputConfig.gcsSource.uris": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the GCS source bucket is deleted or inaccessible: The batch prediction job will fail to read input data. If the batch prediction job is updated: The bucket remains unaffected.", }, // TODO: // BigQuery path. For example: bq://projectId.bqDatasetId.bqTableId. // Related: https://linear.app/overmind/issue/ENG-1281/add-big-query-adapters-to-manual-links "inputConfig.bigquerySource.inputUri": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery table is deleted or inaccessible: The batch prediction job will fail to read input data. If the batch prediction job is updated: The table remains unaffected.", }, // TODO: https://linear.app/overmind/issue/ENG-1446/investigate-creating-a-manual-linker-for-cloud-storage "outputConfig.gcsDestination.outputUriPrefix": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the output GCS bucket is deleted or inaccessible: The batch prediction job will fail to write results. If the batch prediction job is updated: The bucket remains unaffected.", }, // TODO: // BigQuery path. For example: bq://projectId.bqDatasetId.bqTableId. // Related: https://linear.app/overmind/issue/ENG-1281/add-big-query-adapters-to-manual-links "outputConfig.bigqueryDestination.outputUri": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery output table is deleted or inaccessible: The batch prediction job will fail to write results. If the batch prediction job is updated: The table remains unaffected.", }, "serviceAccount": { ToSDPItemType: gcpshared.IAMServiceAccount, Description: "If the Service Account is deleted or permissions are revoked: The batch prediction job may fail to access required resources. If the batch prediction job is updated: The service account remains unaffected.", }, "network": gcpshared.ComputeNetworkImpactInOnly, // TODO: https://linear.app/overmind/issue/ENG-1446/investigate-creating-a-manual-linker-for-cloud-storage "unmanagedContainerModel.artifactUri": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the GCS bucket containing the model artifacts is deleted or inaccessible: The batch prediction job will fail to access the model. If the batch prediction job is updated: The bucket remains unaffected.", }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-batch-prediction-job_test.go ================================================ package adapters_test // This test file demonstrates the use of protobuf types from the Go SDK for mocking HTTP responses // as requested in the user feedback. It uses cloud.google.com/go/aiplatform/apiv1/aiplatformpb // types instead of generic map[string]interface{} structures. // // Note: There are some limitations when using protobuf types with the current dynamic adapter // implementation: // 1. Protobuf serializes field names to snake_case (e.g., "batch_prediction_jobs") while the // adapter configuration expects camelCase (e.g., "batchPredictionJobs"), affecting list operations // 2. Link rule paths in the adapter expect JSON field names but get protobuf field names, // limiting automatic link generation for nested fields like GCS sources and KMS keys // // These limitations don't affect the core functionality testing but are noted for future improvements. import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/aiplatform/apiv1/aiplatformpb" "google.golang.org/genproto/googleapis/rpc/status" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestAIPlatformBatchPredictionJob(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() jobName := "test-batch-prediction-job" // Mock response for a batch prediction job batchPredictionJob := &aiplatformpb.BatchPredictionJob{ Name: fmt.Sprintf("projects/%s/locations/%s/batchPredictionJobs/%s", projectID, location, jobName), DisplayName: "Test Batch Prediction Job", Model: fmt.Sprintf("projects/%s/locations/%s/models/test-model", projectID, location), ModelVersionId: "1", InputConfig: &aiplatformpb.BatchPredictionJob_InputConfig{ InstancesFormat: "jsonl", Source: &aiplatformpb.BatchPredictionJob_InputConfig_GcsSource{ GcsSource: &aiplatformpb.GcsSource{ Uris: []string{ fmt.Sprintf("gs://%s-input-bucket/input-data.jsonl", projectID), }, }, }, }, OutputConfig: &aiplatformpb.BatchPredictionJob_OutputConfig{ PredictionsFormat: "jsonl", Destination: &aiplatformpb.BatchPredictionJob_OutputConfig_GcsDestination{ GcsDestination: &aiplatformpb.GcsDestination{ OutputUriPrefix: fmt.Sprintf("gs://%s-output-bucket/predictions/", projectID), }, }, }, DedicatedResources: &aiplatformpb.BatchDedicatedResources{ MachineSpec: &aiplatformpb.MachineSpec{ MachineType: "n1-standard-2", }, StartingReplicaCount: 1, MaxReplicaCount: 5, }, ServiceAccount: fmt.Sprintf("batch-prediction@%s.iam.gserviceaccount.com", projectID), State: aiplatformpb.JobState_JOB_STATE_SUCCEEDED, Error: &status.Status{ Code: 0, Message: "", }, PartialFailures: []*status.Status{}, ResourcesConsumed: &aiplatformpb.ResourcesConsumed{ ReplicaHours: 2.5, }, CompletionStats: &aiplatformpb.CompletionStats{ SuccessfulCount: 1000, FailedCount: 0, IncompleteCount: 0, SuccessfulForecastPointCount: 0, }, EncryptionSpec: &aiplatformpb.EncryptionSpec{ KmsKeyName: fmt.Sprintf("projects/%s/locations/%s/keyRings/test-ring/cryptoKeys/test-key", projectID, location), }, Labels: map[string]string{ "env": "test", "team": "ml", }, CreateTime: nil, // Will be set to proper timestamp if needed StartTime: nil, EndTime: nil, UpdateTime: nil, DisableContainerLogging: false, } // Create a second batch prediction job for list testing jobName2 := "test-batch-prediction-job-2" batchPredictionJob2 := &aiplatformpb.BatchPredictionJob{ Name: fmt.Sprintf("projects/%s/locations/%s/batchPredictionJobs/%s", projectID, location, jobName2), DisplayName: "Second Test Batch Prediction Job", Model: fmt.Sprintf("projects/%s/locations/%s/models/test-model-2", projectID, location), ModelVersionId: "2", InputConfig: &aiplatformpb.BatchPredictionJob_InputConfig{ InstancesFormat: "csv", Source: &aiplatformpb.BatchPredictionJob_InputConfig_BigquerySource{ BigquerySource: &aiplatformpb.BigQuerySource{ InputUri: fmt.Sprintf("bq://%s.test_dataset.input_table", projectID), }, }, }, OutputConfig: &aiplatformpb.BatchPredictionJob_OutputConfig{ PredictionsFormat: "csv", Destination: &aiplatformpb.BatchPredictionJob_OutputConfig_BigqueryDestination{ BigqueryDestination: &aiplatformpb.BigQueryDestination{ OutputUri: fmt.Sprintf("bq://%s.test_dataset.predictions_table", projectID), }, }, }, ManualBatchTuningParameters: &aiplatformpb.ManualBatchTuningParameters{ BatchSize: 64, }, ServiceAccount: fmt.Sprintf("batch-prediction-2@%s.iam.gserviceaccount.com", projectID), State: aiplatformpb.JobState_JOB_STATE_RUNNING, Error: &status.Status{ Code: 0, Message: "", }, PartialFailures: []*status.Status{}, ResourcesConsumed: &aiplatformpb.ResourcesConsumed{ ReplicaHours: 1.2, }, CompletionStats: &aiplatformpb.CompletionStats{ SuccessfulCount: 500, FailedCount: 2, IncompleteCount: 100, SuccessfulForecastPointCount: 0, }, Labels: map[string]string{ "env": "prod", "service": "recommendation", }, CreateTime: nil, StartTime: nil, UpdateTime: nil, DisableContainerLogging: true, } // Mock response for list operation batchPredictionJobsList := &aiplatformpb.ListBatchPredictionJobsResponse{ BatchPredictionJobs: []*aiplatformpb.BatchPredictionJob{ batchPredictionJob, batchPredictionJob2, }, NextPageToken: "", } sdpItemType := gcpshared.AIPlatformBatchPredictionJob expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/batchPredictionJobs/%s", projectID, location, jobName): { StatusCode: http.StatusOK, Body: batchPredictionJob, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/batchPredictionJobs", projectID, location): { StatusCode: http.StatusOK, Body: batchPredictionJobsList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := fmt.Sprintf("%s|%s", location, jobName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get batch prediction job: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", getQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Test specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/batchPredictionJobs/%s", projectID, location, jobName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } val, err = sdpItem.GetAttributes().Get("displayName") if err != nil { t.Fatalf("Failed to get 'displayName' attribute: %v", err) } if val != "Test Batch Prediction Job" { t.Errorf("Expected displayName field to be 'Test Batch Prediction Job', got %s", val) } val, err = sdpItem.GetAttributes().Get("model") if err != nil { t.Fatalf("Failed to get 'model' attribute: %v", err) } expectedModel := fmt.Sprintf("projects/%s/locations/%s/models/test-model", projectID, location) if val != expectedModel { t.Errorf("Expected model field to be '%s', got %s", expectedModel, val) } val, err = sdpItem.GetAttributes().Get("modelVersionId") if err != nil { t.Fatalf("Failed to get 'modelVersionId' attribute: %v", err) } if val != "1" { t.Errorf("Expected modelVersionId field to be '1', got %s", val) } val, err = sdpItem.GetAttributes().Get("state") if err != nil { t.Fatalf("Failed to get 'state' attribute: %v", err) } // The state is returned as a string stateValue, ok := val.(string) if !ok { t.Fatalf("Expected state to be a string, got %T", val) } if stateValue != "JOB_STATE_SUCCEEDED" { t.Errorf("Expected state field to be 'JOB_STATE_SUCCEEDED', got %s", stateValue) } val, err = sdpItem.GetAttributes().Get("serviceAccount") if err != nil { t.Fatalf("Failed to get 'serviceAccount' attribute: %v", err) } expectedServiceAccount := fmt.Sprintf("batch-prediction@%s.iam.gserviceaccount.com", projectID) if val != expectedServiceAccount { t.Errorf("Expected serviceAccount field to be '%s', got %s", expectedServiceAccount, val) } // Test nested inputConfig inputConfig, err := sdpItem.GetAttributes().Get("inputConfig") if err != nil { t.Fatalf("Failed to get 'inputConfig' attribute: %v", err) } inputConfigMap, ok := inputConfig.(map[string]any) if !ok { t.Fatalf("Expected inputConfig to be a map[string]interface{}, got %T", inputConfig) } if inputConfigMap["instancesFormat"] != "jsonl" { t.Errorf("Expected inputConfig.instancesFormat to be 'jsonl', got %s", inputConfigMap["instancesFormat"]) } // Test nested outputConfig outputConfig, err := sdpItem.GetAttributes().Get("outputConfig") if err != nil { t.Fatalf("Failed to get 'outputConfig' attribute: %v", err) } outputConfigMap, ok := outputConfig.(map[string]any) if !ok { t.Fatalf("Expected outputConfig to be a map[string]interface{}, got %T", outputConfig) } if outputConfigMap["predictionsFormat"] != "jsonl" { t.Errorf("Expected outputConfig.predictionsFormat to be 'jsonl', got %s", outputConfigMap["predictionsFormat"]) } // Test encryptionSpec encryptionSpec, err := sdpItem.GetAttributes().Get("encryptionSpec") if err != nil { t.Fatalf("Failed to get 'encryptionSpec' attribute: %v", err) } encryptionSpecMap, ok := encryptionSpec.(map[string]any) if !ok { t.Fatalf("Expected encryptionSpec to be a map[string]interface{}, got %T", encryptionSpec) } expectedKmsKey := fmt.Sprintf("projects/%s/locations/%s/keyRings/test-ring/cryptoKeys/test-key", projectID, location) if encryptionSpecMap["kmsKeyName"] != expectedKmsKey { t.Errorf("Expected encryptionSpec.kmsKeyName to be '%s', got %s", expectedKmsKey, encryptionSpecMap["kmsKeyName"]) } t.Run("StaticTests", func(t *testing.T) { // Only test link rule paths that are currently working // (GCS and BigQuery paths have TODOs and require manual linkers) queryTests := shared.QueryTests{ { ExpectedType: gcpshared.AIPlatformModel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-model", ExpectedScope: projectID, }, { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("batch-prediction@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, "test-ring", "test-key"), ExpectedScope: projectID, }, // Input GCS bucket link { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("%s-input-bucket", projectID), ExpectedScope: projectID, }, // Output GCS bucket link { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("%s-output-bucket", projectID), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter is not a SearchableAdapter") } // Test search functionality with location sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search batch prediction jobs: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 batch prediction jobs, got %d", len(sdpItems)) } // Test first item item1 := sdpItems[0] if item1.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item1.GetType()) } expectedUniqueAttr1 := fmt.Sprintf("%s|%s", location, jobName) if item1.UniqueAttributeValue() != expectedUniqueAttr1 { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr1, item1.UniqueAttributeValue()) } // Test second item item2 := sdpItems[1] if item2.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item2.GetType()) } expectedUniqueAttr2 := fmt.Sprintf("%s|%s", location, jobName2) if item2.UniqueAttributeValue() != expectedUniqueAttr2 { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr2, item2.UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with 404 response to simulate job not found errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/batchPredictionJobs/%s", projectID, location, jobName): { StatusCode: http.StatusNotFound, Body: &status.Status{Code: 404, Message: "Batch prediction job not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := fmt.Sprintf("%s|%s", location, jobName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent batch prediction job, but got nil") } }) t.Run("InvalidQuery", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // Test with invalid query format (missing location) _, err = adapter.Get(ctx, projectID, "invalid-query-format", true) if err == nil { t.Error("Expected error when using invalid query format, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-custom-job.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // AI Platform Custom Job adapter for Vertex AI custom training jobs // There are multiple service endpoints: https://cloud.google.com/vertex-ai/docs/reference/rest#rest_endpoints // We stick to the default one for now: https://aiplatform.googleapis.com // Other endpoints are in the form of https://{region}-aiplatform.googleapis.com // If we use the default endpoint the location must be set to `global`. // So, for simplicity, we can get custom jobs by their name globally, list globally, // otherwise we have to check the validity of the location and use the regional endpoint. var _ = registerableAdapter{ sdpType: gcpshared.AIPlatformCustomJob, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_AI, LocationLevel: gcpshared.ProjectLevel, // Vertex AI API must be enabled for the project! // Reference: https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.customJobs/get // https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/customJobs/{customJob} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/customJobs/%s"), // Reference: https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.customJobs/list // https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/customJobs // Expected location is `global` for the default endpoint. ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/customJobs"), UniqueAttributeKeys: []string{"customJobs"}, IAMPermissions: []string{"aiplatform.customJobs.get", "aiplatform.customJobs.list"}, PredefinedRole: "roles/aiplatform.viewer", }, linkRules: map[string]*gcpshared.Impact{ // The Cloud KMS key that will be used to encrypt the output artifacts. "encryptionSpec.kmsKeyName": { Description: "If the Cloud KMS CryptoKey is updated: The CustomJob may not be able to access encrypted output artifacts. If the CustomJob is updated: The CryptoKey remains unaffected.", ToSDPItemType: gcpshared.CloudKMSCryptoKey, }, // The full name of the network to which the job should be peered. "jobSpec.network": { Description: "If the Compute Network is deleted or updated: The CustomJob may lose connectivity or fail to run as expected. If the CustomJob is updated: The network remains unaffected.", ToSDPItemType: gcpshared.ComputeNetwork, }, // The service account that the job runs as. "jobSpec.serviceAccount": { Description: "If the IAM Service Account is deleted or updated: The CustomJob may fail to run or lose permissions. If the CustomJob is updated: The service account remains unaffected.", ToSDPItemType: gcpshared.IAMServiceAccount, }, // The Cloud Storage location to store the output of this CustomJob. "jobSpec.baseOutputDirectory.gcsOutputDirectory": { Description: "If the Storage Bucket is deleted or updated: The CustomJob may fail to write outputs. If the CustomJob is updated: The bucket remains unaffected.", ToSDPItemType: gcpshared.StorageBucket, }, // Optional. The name of a Vertex AI Tensorboard resource to which this CustomJob will upload Tensorboard logs. "jobSpec.tensorboard": { Description: "If the Vertex AI Tensorboard is deleted or updated: The CustomJob may fail to upload logs or lose access to previous logs. If the CustomJob is updated: The tensorboard remains unaffected.", ToSDPItemType: gcpshared.AIPlatformTensorBoard, }, // Optional. The name of an experiment to associate with the CustomJob. "jobSpec.experiment": { Description: "If the Vertex AI Experiment is deleted or updated: The CustomJob may lose experiment tracking or association. If the CustomJob is updated: The experiment remains unaffected.", ToSDPItemType: gcpshared.AIPlatformExperiment, }, // Optional. The name of an experiment run to associate with the CustomJob. "jobSpec.experimentRun": { Description: "If the Vertex AI ExperimentRun is deleted or updated: The CustomJob may lose run tracking or association. If the CustomJob is updated: The experiment run remains unaffected.", ToSDPItemType: gcpshared.AIPlatformExperimentRun, }, // Optional. The name of a model to upload the trained Model to upon job completion. "jobSpec.models": { Description: "If the Vertex AI Model is deleted or updated: The CustomJob may fail to upload or associate the trained model. If the CustomJob is updated: The model remains unaffected.", ToSDPItemType: gcpshared.AIPlatformModel, }, // Optional. The ID of a PersistentResource to run the job on existing machines. "jobSpec.persistentResourceId": { Description: "If the Vertex AI PersistentResource is deleted or updated: The CustomJob may fail to run or lose access to the persistent resources. If the CustomJob is updated: The PersistentResource remains unaffected.", ToSDPItemType: gcpshared.AIPlatformPersistentResource, }, // Container image URI used in worker pool specs (for containerSpec). "jobSpec.workerPoolSpecs.containerSpec.imageUri": { Description: "If the Artifact Registry Docker Image is updated or deleted: The CustomJob may fail to run or use an incorrect container image. If the CustomJob is updated: The Docker image remains unaffected.", ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, }, // Executor container image URI used in worker pool specs (for pythonPackageSpec). "jobSpec.workerPoolSpecs.pythonPackageSpec.executorImageUri": { Description: "If the Artifact Registry Docker Image is updated or deleted: The CustomJob may fail to run or use an incorrect executor image. If the CustomJob is updated: The Docker image remains unaffected.", ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, }, // GCS URIs of Python package files used in worker pool specs. "jobSpec.workerPoolSpecs.pythonPackageSpec.packageUris": { Description: "If the Storage Bucket containing the Python packages is deleted or updated: The CustomJob may fail to access required package files. If the CustomJob is updated: The bucket remains unaffected.", ToSDPItemType: gcpshared.StorageBucket, }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-custom-job_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/aiplatform/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestAIPlatformCustomJob(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() jobID := "test-job" customJob := &aiplatform.GoogleCloudAiplatformV1CustomJob{ Name: fmt.Sprintf("projects/%s/locations/global/customJobs/%s", projectID, jobID), JobSpec: &aiplatform.GoogleCloudAiplatformV1CustomJobSpec{ ServiceAccount: "aiplatform-sa@test-project.iam.gserviceaccount.com", Network: fmt.Sprintf("projects/%s/global/networks/default", projectID), }, EncryptionSpec: &aiplatform.GoogleCloudAiplatformV1EncryptionSpec{ KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", }, } jobList := &aiplatform.GoogleCloudAiplatformV1ListCustomJobsResponse{ CustomJobs: []*aiplatform.GoogleCloudAiplatformV1CustomJob{customJob}, } sdpItemType := gcpshared.AIPlatformCustomJob expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/customJobs/%s", projectID, jobID): { StatusCode: http.StatusOK, Body: customJob, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/customJobs", projectID): { StatusCode: http.StatusOK, Body: jobList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, jobID, true) if err != nil { t.Fatalf("Failed to get custom job: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // encryptionSpec.kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, { // jobSpec.network ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { // jobSpec.serviceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "aiplatform-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list custom jobs: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 custom job, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/customJobs/%s", projectID, jobID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Custom job not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, jobID, true) if err == nil { t.Error("Expected error when getting non-existent custom job, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-endpoint.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // AI Platform Endpoint adapter. // GCP Ref (GET): https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.endpoints/get // GCP Ref (Endpoint schema): https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.endpoints#Endpoint // GET https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/{endpoint} // LIST https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints // NOTE: We use "global" for the location in the URL, because we use the global service endpoint. var _ = registerableAdapter{ sdpType: gcpshared.AIPlatformEndpoint, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_AI, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/endpoints/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/endpoints", ), UniqueAttributeKeys: []string{"endpoints"}, IAMPermissions: []string{"aiplatform.endpoints.get", "aiplatform.endpoints.list"}, PredefinedRole: "roles/aiplatform.viewer", }, linkRules: map[string]*gcpshared.Impact{ "encryptionSpec.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "network": gcpshared.ComputeNetworkImpactInOnly, "deployedModels.model": { ToSDPItemType: gcpshared.AIPlatformModel, Description: "They are tightly coupled.", }, "deployedModels.serviceAccount": { ToSDPItemType: gcpshared.IAMServiceAccount, Description: "If the service account is deleted or its permissions are updated: The DeployedModel may fail to run or access required resources. If the DeployedModel is updated: The service account remains unaffected.", }, "deployedModels.sharedResources": { ToSDPItemType: gcpshared.AIPlatformDeploymentResourcePool, Description: "If the DeploymentResourcePool is deleted or updated: The DeployedModel may fail to run or lose access to shared resources. If the DeployedModel is updated: The DeploymentResourcePool remains unaffected.", }, "deployedModels.privateEndpoints.serviceAttachment": { ToSDPItemType: gcpshared.ComputeServiceAttachment, Description: "If the Service Attachment is deleted or updated: The DeployedModel's private endpoint connectivity may be disrupted. If the DeployedModel is updated: The Service Attachment remains unaffected.", }, "modelDeploymentMonitoringJob": { ToSDPItemType: gcpshared.AIPlatformModelDeploymentMonitoringJob, Description: "They are tightly coupled.", }, "dedicatedEndpointDns": { ToSDPItemType: stdlib.NetworkDNS, Description: "The DNS name for the dedicated endpoint. If the Endpoint is deleted, this DNS name will no longer resolve.", }, "predictRequestResponseLoggingConfig.bigqueryDestination.outputUri": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery Table is deleted or updated, the Endpoint's logging configuration may be affected.", }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-endpoint_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/aiplatform/apiv1/aiplatformpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestAIPlatformEndpoint(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() endpointName := "test-endpoint" // Create mock protobuf object endpoint := &aiplatformpb.Endpoint{ Name: fmt.Sprintf("projects/%s/locations/global/endpoints/%s", projectID, endpointName), DisplayName: "Test Endpoint", Description: "Test AI Platform Endpoint", Network: "projects/test-project/global/networks/default", EncryptionSpec: &aiplatformpb.EncryptionSpec{ KmsKeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, DeployedModels: []*aiplatformpb.DeployedModel{ { Model: "projects/test-project/locations/global/models/test-model", }, }, ModelDeploymentMonitoringJob: "projects/test-project/locations/global/modelDeploymentMonitoringJobs/test-job", DedicatedEndpointDns: "test-endpoint.aiplatform.googleapis.com", PredictRequestResponseLoggingConfig: &aiplatformpb.PredictRequestResponseLoggingConfig{ BigqueryDestination: &aiplatformpb.BigQueryDestination{ OutputUri: "bq://test-project.test_dataset.test_table", }, }, } // Create second endpoint for list testing endpointName2 := "test-endpoint-2" endpoint2 := &aiplatformpb.Endpoint{ Name: fmt.Sprintf("projects/%s/locations/global/endpoints/%s", projectID, endpointName2), DisplayName: "Test Endpoint 2", Description: "Test AI Platform Endpoint 2", } // Create list response with multiple items endpointList := &aiplatformpb.ListEndpointsResponse{ Endpoints: []*aiplatformpb.Endpoint{endpoint, endpoint2}, } sdpItemType := gcpshared.AIPlatformEndpoint // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/endpoints/%s", projectID, endpointName): { StatusCode: http.StatusOK, Body: endpoint, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/endpoints/%s", projectID, endpointName2): { StatusCode: http.StatusOK, Body: endpoint2, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/endpoints", projectID): { StatusCode: http.StatusOK, Body: endpointList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, endpointName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != endpointName { t.Errorf("Expected unique attribute value '%s', got %s", endpointName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/global/endpoints/%s", projectID, endpointName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // KMS key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // Deployed model link { ExpectedType: gcpshared.AIPlatformModel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-model", ExpectedScope: projectID, }, // Model deployment monitoring job link { ExpectedType: gcpshared.AIPlatformModelDeploymentMonitoringJob.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-job"), ExpectedScope: projectID, }, // Dedicated endpoint DNS link { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-endpoint.aiplatform.googleapis.com", ExpectedScope: "global", }, // BigQuery table link { ExpectedType: gcpshared.BigQueryTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test_dataset", "test_table"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/endpoints/%s", projectID, endpointName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Endpoint not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, endpointName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-model-deployment-monitoring-job.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // AI Platform Model Deployment Monitoring Job monitors deployed models for data drift and performance degradation // GCP Ref (GET): https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.modelDeploymentMonitoringJobs/get // GCP Ref (Schema): https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.modelDeploymentMonitoringJobs#ModelDeploymentMonitoringJob // GET https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/modelDeploymentMonitoringJobs/{modelDeploymentMonitoringJob} // LIST https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/modelDeploymentMonitoringJobs var _ = registerableAdapter{ sdpType: gcpshared.AIPlatformModelDeploymentMonitoringJob, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_AI, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/modelDeploymentMonitoringJobs/%s", ), SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/modelDeploymentMonitoringJobs", ), SearchDescription: "Search Model Deployment Monitoring Jobs within a location. Use the location name e.g., 'us-central1'", UniqueAttributeKeys: []string{"locations", "modelDeploymentMonitoringJobs"}, IAMPermissions: []string{ "aiplatform.modelDeploymentMonitoringJobs.get", "aiplatform.modelDeploymentMonitoringJobs.list", }, PredefinedRole: "roles/aiplatform.viewer", // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.modelDeploymentMonitoringJobs#JobState }, linkRules: map[string]*gcpshared.Impact{ "encryptionSpec.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "endpoint": { ToSDPItemType: gcpshared.AIPlatformEndpoint, Description: "They are tightly coupled - monitoring job monitors the endpoint's deployed models.", }, "modelDeploymentMonitoringObjectiveConfigs.deployedModelId": { ToSDPItemType: gcpshared.AIPlatformModel, Description: "If the Model is deleted or updated: The monitoring job may fail to monitor. If the monitoring job is updated: The model remains unaffected.", }, "modelMonitoringAlertConfig.notificationChannels": { ToSDPItemType: gcpshared.MonitoringNotificationChannel, Description: "If the Notification Channel is deleted or updated: The monitoring job may fail to send alerts. If the monitoring job is updated: The notification channel remains unaffected.", }, "bigqueryTables.bigqueryTablePath": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery table storing monitoring logs is deleted or inaccessible: The monitoring job may fail to write logs. If the monitoring job is updated: The table remains unaffected.", }, "modelDeploymentMonitoringObjectiveConfigs.objectiveConfig.trainingDataset.gcsSource.uris": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the GCS bucket containing training data is deleted or inaccessible: The monitoring job may fail to compare predictions against training data. If the monitoring job is updated: The bucket remains unaffected.", }, "modelDeploymentMonitoringObjectiveConfigs.objectiveConfig.trainingDataset.bigquerySource.inputUri": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery table containing training data is deleted or inaccessible: The monitoring job may fail to compare predictions against training data. If the monitoring job is updated: The table remains unaffected.", }, "predictInstanceSchemaUri": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the GCS bucket containing the prediction instance schema is deleted or inaccessible: The monitoring job may fail to validate prediction requests. If the monitoring job is updated: The bucket remains unaffected.", }, "analysisInstanceSchemaUri": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the GCS bucket containing the analysis instance schema is deleted or inaccessible: The monitoring job may fail to perform analysis. If the monitoring job is updated: The bucket remains unaffected.", }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-model-deployment-monitoring-job_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/aiplatform/apiv1/aiplatformpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestAIPlatformModelDeploymentMonitoringJob(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() jobName := "test-monitoring-job" job := &aiplatformpb.ModelDeploymentMonitoringJob{ Name: fmt.Sprintf("projects/%s/locations/%s/modelDeploymentMonitoringJobs/%s", projectID, location, jobName), EncryptionSpec: &aiplatformpb.EncryptionSpec{ KmsKeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, Endpoint: fmt.Sprintf("projects/%s/locations/%s/endpoints/test-endpoint", projectID, location), ModelDeploymentMonitoringObjectiveConfigs: []*aiplatformpb.ModelDeploymentMonitoringObjectiveConfig{ { DeployedModelId: "deployed-model-123", ObjectiveConfig: &aiplatformpb.ModelMonitoringObjectiveConfig{ TrainingDataset: &aiplatformpb.ModelMonitoringObjectiveConfig_TrainingDataset{ DataFormat: "csv", DataSource: &aiplatformpb.ModelMonitoringObjectiveConfig_TrainingDataset_GcsSource{ GcsSource: &aiplatformpb.GcsSource{ Uris: []string{ "gs://training-bucket/training-data.csv", "gs://training-bucket-2/additional-data.csv", }, }, }, }, }, }, { DeployedModelId: "deployed-model-456", ObjectiveConfig: &aiplatformpb.ModelMonitoringObjectiveConfig{ TrainingDataset: &aiplatformpb.ModelMonitoringObjectiveConfig_TrainingDataset{ DataFormat: "tf-record", DataSource: &aiplatformpb.ModelMonitoringObjectiveConfig_TrainingDataset_BigquerySource{ BigquerySource: &aiplatformpb.BigQuerySource{ InputUri: "bq://test-project.training_dataset.training_table", }, }, }, }, }, }, ModelMonitoringAlertConfig: &aiplatformpb.ModelMonitoringAlertConfig{ NotificationChannels: []string{ fmt.Sprintf("projects/%s/notificationChannels/alert-channel-1", projectID), fmt.Sprintf("projects/%s/notificationChannels/alert-channel-2", projectID), }, }, PredictInstanceSchemaUri: "gs://schema-bucket/predict-schema.yaml", AnalysisInstanceSchemaUri: "gs://schema-bucket-2/analysis-schema.yaml", BigqueryTables: []*aiplatformpb.ModelDeploymentMonitoringBigQueryTable{ { LogSource: aiplatformpb.ModelDeploymentMonitoringBigQueryTable_TRAINING, LogType: aiplatformpb.ModelDeploymentMonitoringBigQueryTable_PREDICT, BigqueryTablePath: "bq://test-project.monitoring_dataset.training_predict_log", }, { LogSource: aiplatformpb.ModelDeploymentMonitoringBigQueryTable_SERVING, LogType: aiplatformpb.ModelDeploymentMonitoringBigQueryTable_PREDICT, BigqueryTablePath: "bq://test-project.monitoring_dataset.serving_predict_log", }, }, } jobName2 := "test-monitoring-job-2" job2 := &aiplatformpb.ModelDeploymentMonitoringJob{ Name: fmt.Sprintf("projects/%s/locations/%s/modelDeploymentMonitoringJobs/%s", projectID, location, jobName2), } jobList := &aiplatformpb.ListModelDeploymentMonitoringJobsResponse{ ModelDeploymentMonitoringJobs: []*aiplatformpb.ModelDeploymentMonitoringJob{job, job2}, } sdpItemType := gcpshared.AIPlatformModelDeploymentMonitoringJob expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/modelDeploymentMonitoringJobs/%s", projectID, location, jobName): { StatusCode: http.StatusOK, Body: job, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/modelDeploymentMonitoringJobs/%s", projectID, location, jobName2): { StatusCode: http.StatusOK, Body: job2, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/modelDeploymentMonitoringJobs", projectID, location): { StatusCode: http.StatusOK, Body: jobList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, jobName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // KMS encryption key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // AI Platform Endpoint link (bidirectional) { ExpectedType: gcpshared.AIPlatformEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-endpoint", ExpectedScope: projectID, }, // Deployed Model ID link (AI Platform Model) { ExpectedType: gcpshared.AIPlatformModel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "deployed-model-123", ExpectedScope: projectID, }, // Notification Channel 1 link { ExpectedType: gcpshared.MonitoringNotificationChannel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "alert-channel-1", ExpectedScope: projectID, }, // Notification Channel 2 link { ExpectedType: gcpshared.MonitoringNotificationChannel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "alert-channel-2", ExpectedScope: projectID, }, // BigQuery table 1 link (training predict log) { ExpectedType: gcpshared.BigQueryTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("monitoring_dataset", "training_predict_log"), ExpectedScope: projectID, }, // BigQuery table 2 link (serving predict log) { ExpectedType: gcpshared.BigQueryTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("monitoring_dataset", "serving_predict_log"), ExpectedScope: projectID, }, // Training dataset GCS source bucket links { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "training-bucket", ExpectedScope: projectID, }, { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "training-bucket-2", ExpectedScope: projectID, }, // Training dataset BigQuery source link { ExpectedType: gcpshared.BigQueryTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("training_dataset", "training_table"), ExpectedScope: projectID, }, // Deployed Model ID link (second model) { ExpectedType: gcpshared.AIPlatformModel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "deployed-model-456", ExpectedScope: projectID, }, // Schema bucket link for predict instance schema { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "schema-bucket", ExpectedScope: projectID, }, // Schema bucket link for analysis instance schema { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "schema-bucket-2", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/modelDeploymentMonitoringJobs/%s", projectID, location, jobName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Monitoring job not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, jobName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-model.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // AI Platform Model adapter. // GCP Ref: https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.models/get // GET https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/models/{model} // LIST https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/models // NOTE: We use "global" for the location in the URL, because we use the global service endpoint. var _ = registerableAdapter{ sdpType: gcpshared.AIPlatformModel, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_AI, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/models/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/models", ), UniqueAttributeKeys: []string{"models"}, IAMPermissions: []string{"aiplatform.models.get", "aiplatform.models.list"}, PredefinedRole: "roles/aiplatform.viewer", }, linkRules: map[string]*gcpshared.Impact{ "encryptionSpec.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // Container image used for prediction (containerSpec.imageUri). "containerSpec.imageUri": { ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, Description: "If the Artifact Registry Docker Image is updated or deleted: The Model may fail to serve predictions. If the Model is updated: The Docker image remains unaffected.", }, "pipelineJob": { ToSDPItemType: gcpshared.AIPlatformPipelineJob, Description: "If the Pipeline Job is deleted: The Model may not be retrievable. If the Model is updated: The Pipeline Job remains unaffected.", }, "deployedModels.endpoint": { ToSDPItemType: gcpshared.AIPlatformEndpoint, Description: "They are tightly coupled.", }, // GCS bucket containing the Model artifact and supporting files (artifactUri). "artifactUri": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket containing model artifacts is deleted or its permissions are changed: The Model may fail to load artifacts and serve predictions. If the Model is updated: The Storage Bucket remains unaffected.", }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-model_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/aiplatform/apiv1/aiplatformpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestAIPlatformModel(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() modelName := "test-model" // Create mock protobuf object model := &aiplatformpb.Model{ Name: fmt.Sprintf("projects/%s/locations/global/models/%s", projectID, modelName), DisplayName: "Test Model", Description: "Test AI Platform Model", EncryptionSpec: &aiplatformpb.EncryptionSpec{ KmsKeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, ContainerSpec: &aiplatformpb.ModelContainerSpec{ ImageUri: "us-central1-docker.pkg.dev/test-project/test-repo/test-image:latest", }, PipelineJob: "projects/test-project/locations/global/pipelineJobs/test-pipeline", ArtifactUri: fmt.Sprintf("gs://%s-model-artifacts/model/", projectID), DeployedModels: []*aiplatformpb.DeployedModelRef{ { Endpoint: "projects/test-project/locations/global/endpoints/test-endpoint", }, }, } // Create second model for list testing modelName2 := "test-model-2" model2 := &aiplatformpb.Model{ Name: fmt.Sprintf("projects/%s/locations/global/models/%s", projectID, modelName2), DisplayName: "Test Model 2", Description: "Test AI Platform Model 2", } // Create list response with multiple items modelList := &aiplatformpb.ListModelsResponse{ Models: []*aiplatformpb.Model{model, model2}, } sdpItemType := gcpshared.AIPlatformModel // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/models/%s", projectID, modelName): { StatusCode: http.StatusOK, Body: model, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/models/%s", projectID, modelName2): { StatusCode: http.StatusOK, Body: model2, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/models", projectID): { StatusCode: http.StatusOK, Body: modelList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, modelName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != modelName { t.Errorf("Expected unique attribute value '%s', got %s", modelName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/global/models/%s", projectID, modelName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // KMS key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // Pipeline job link { ExpectedType: gcpshared.AIPlatformPipelineJob.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pipeline", ExpectedScope: projectID, }, // Deployed model endpoint link { ExpectedType: gcpshared.AIPlatformEndpoint.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-endpoint", ExpectedScope: projectID, }, // Storage bucket link (artifactUri) { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("%s-model-artifacts", projectID), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/models/%s", projectID, modelName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Model not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, modelName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-pipeline-job.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // AI Platform Pipeline Job adapter for Vertex AI pipeline jobs var _ = registerableAdapter{ sdpType: gcpshared.AIPlatformPipelineJob, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_AI, LocationLevel: gcpshared.ProjectLevel, // When using the default endpoint, the location must be set to `global`. // Format: projects/{project}/locations/{location}/pipelineJobs/{pipelineJob} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/pipelineJobs/%s"), // Reference: https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.pipelineJobs/list ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/pipelineJobs"), UniqueAttributeKeys: []string{"pipelineJobs"}, IAMPermissions: []string{"aiplatform.pipelineJobs.get", "aiplatform.pipelineJobs.list"}, PredefinedRole: "roles/aiplatform.viewer", }, linkRules: map[string]*gcpshared.Impact{ // The service account that the pipeline workload runs as (root-level). "serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, // The full name of the network to which the job should be peered (root-level). "network": gcpshared.ComputeNetworkImpactInOnly, // The Cloud KMS key used to encrypt PipelineJob outputs. "encryptionSpec.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // The Cloud Storage location to store the output of this PipelineJob. "runtimeConfig.gcsOutputDirectory": { Description: "If the Storage Bucket is deleted or updated: The PipelineJob may fail to write outputs. If the PipelineJob is updated: The bucket remains unaffected.", ToSDPItemType: gcpshared.StorageBucket, }, // The network attachment resource that the pipeline job will use for Private Service Connect. "pscInterfaceConfig.networkAttachment": { Description: "If the Compute Network Attachment is deleted or updated: The PipelineJob may lose access to network services via Private Service Connect. If the PipelineJob is updated: The network attachment remains unaffected.", ToSDPItemType: gcpshared.ComputeNetworkAttachment, }, // The schedule resource name, returned if the pipeline is created by the Schedule API. "scheduleName": { Description: "If the Vertex AI Schedule is deleted or updated: The PipelineJob may stop being triggered or may be triggered incorrectly. If the PipelineJob is updated: The schedule remains unaffected.", ToSDPItemType: gcpshared.AIPlatformSchedule, }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/ai-platform-pipeline-job_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/aiplatform/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestAIPlatformPipelineJob(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() jobID := "test-pipeline-job" pipelineJob := &aiplatform.GoogleCloudAiplatformV1PipelineJob{ Name: fmt.Sprintf("projects/%s/locations/global/pipelineJobs/%s", projectID, jobID), ServiceAccount: "aiplatform-sa@test-project.iam.gserviceaccount.com", Network: fmt.Sprintf("projects/%s/global/networks/default", projectID), EncryptionSpec: &aiplatform.GoogleCloudAiplatformV1EncryptionSpec{ KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", }, } jobList := &aiplatform.GoogleCloudAiplatformV1ListPipelineJobsResponse{ PipelineJobs: []*aiplatform.GoogleCloudAiplatformV1PipelineJob{pipelineJob}, } sdpItemType := gcpshared.AIPlatformPipelineJob expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/pipelineJobs/%s", projectID, jobID): { StatusCode: http.StatusOK, Body: pipelineJob, }, fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/pipelineJobs", projectID): { StatusCode: http.StatusOK, Body: jobList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, jobID, true) if err != nil { t.Fatalf("Failed to get pipeline job: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // serviceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "aiplatform-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, { // network ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { // encryptionSpec.kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list pipeline jobs: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 pipeline job, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/pipelineJobs/%s", projectID, jobID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Pipeline job not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, jobID, true) if err == nil { t.Error("Expected error when getting non-existent pipeline job, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/artifact-registry-docker-image.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Artifact Registry Docker Image adapter for container images in Artifact Registry var _ = registerableAdapter{ sdpType: gcpshared.ArtifactRegistryDockerImage, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.dockerImages/get?rep_location=global // GET https://artifactregistry.googleapis.com/v1/{name=projects/*/locations/*/repositories/*/dockerImages/*} // IAM permissions: artifactregistry.dockerImages.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithThreeQueries("https://artifactregistry.googleapis.com/v1/projects/%s/locations/%s/repositories/%s/dockerImages/%s"), // Reference: https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.dockerImages/list?rep_location=global // GET https://artifactregistry.googleapis.com/v1/{parent=projects/*/locations/*/repositories/*}/dockerImages // IAM permissions: artifactregistry.dockerImages.list SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://artifactregistry.googleapis.com/v1/projects/%s/locations/%s/repositories/%s/dockerImages"), SearchDescription: "Search for Docker images in Artifact Registry. Use the format \"location|repository_id\" or \"projects/[project]/locations/[location]/repository/[repository_id]/dockerImages/[docker_image]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "repositories", "dockerImages"}, IAMPermissions: []string{"artifactregistry.dockerimages.get", "artifactregistry.dockerimages.list"}, PredefinedRole: "roles/artifactregistry.reader", }, linkRules: map[string]*gcpshared.Impact{ // This is a link to its parent resource: ArtifactRegistryRepository // Linker will extract the repository name from the image name. "name": { ToSDPItemType: gcpshared.ArtifactRegistryRepository, Description: "If the Artifact Registry Repository is deleted or updated: The Docker Image may become invalid or inaccessible. If the Docker Image is updated: The repository remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/artifact_registry_docker_image", Description: "name => projects/{{project}}/locations/{{location}}/repository/{{repository_id}}/dockerImages/{{docker_image}}. We should use search to extract relevant fields.", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_artifact_registry_docker_image.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/artifact-registry-docker-image_test.go ================================================ package adapters import ( "context" "fmt" "net/http" "strings" "testing" "time" "github.com/stretchr/testify/assert" "google.golang.org/api/artifactregistry/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestArtifactRegistryDockerImage(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() imageName := "nginx@sha256:e9954c1fc875017be1c3e36eca16be2d9e9bccc4bf072163515467d6a823c7cf" location := "us-central1-a" repository := "my-repo" dockerImage := &artifactregistry.DockerImage{ Name: fmt.Sprintf("projects/test-project/locations/%s/repositories/%s/dockerImages/%s", location, repository, imageName), Uri: fmt.Sprintf("%s-docker.pkg.dev/%s/%s/%s", strings.TrimSuffix(location, "-a"), projectID, repository, imageName), Tags: []string{"latest", "v1.2.3", "stable"}, MediaType: "application/vnd.docker.distribution.manifest.v2+json", BuildTime: "2023-06-15T10:30:00Z", UpdateTime: "2023-06-15T10:35:00Z", UploadTime: "2023-06-15T10:32:00Z", ImageSizeBytes: 75849324, } sizeOfFirstPage := 100 sizeOfLastPage := 1 dockerImagesWithNextPageToken := &artifactregistry.ListDockerImagesResponse{ DockerImages: dynamic.Multiply(dockerImage, sizeOfFirstPage), NextPageToken: "next-page-token", } dockerImages := &artifactregistry.ListDockerImagesResponse{ DockerImages: dynamic.Multiply(dockerImage, sizeOfLastPage), } sdpItemType := gcpshared.ArtifactRegistryDockerImage expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf( "https://artifactregistry.googleapis.com/v1/projects/test-project/locations/%s/repositories/%s/dockerImages/%s", location, repository, imageName, ): { StatusCode: http.StatusOK, Body: dockerImage, }, fmt.Sprintf( "https://artifactregistry.googleapis.com/v1/projects/test-project/locations/%s/repositories/%s/dockerImages", location, repository, ): { StatusCode: http.StatusOK, Body: dockerImagesWithNextPageToken, }, fmt.Sprintf( "https://artifactregistry.googleapis.com/v1/projects/test-project/locations/%s/repositories/%s/dockerImages?pageToken=next-page-token", location, repository, ): { StatusCode: http.StatusOK, Body: dockerImages, }, } t.Run("Get", func(t *testing.T) { // This is a project level adapter, so we pass the project ID httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, repository, imageName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get docker image: %v", err) } // Verify the returned item if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", imageName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ArtifactRegistryRepository.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, repository), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { // This is a project level adapter, so we pass the project httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, shared.CompositeLookupKey(location, repository), true) if err != nil { t.Fatalf("Failed to list docker images: %v", err) } expectedItemCount := sizeOfFirstPage + sizeOfLastPage if len(sdpItems) != expectedItemCount { t.Errorf("Expected %d docker images, got %d", expectedItemCount, len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { // This is a project level adapter, so we pass the project ID httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/locations/[location]/repositories/[repository]/dockerImages/[docker_image] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/repositories/%s/dockerImages/%s", projectID, location, repository, imageName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("SearchStream", func(t *testing.T) { // This is a project level adapter, so we pass the project httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, shared.CompositeLookupKey(location, repository), true) if err != nil { t.Fatalf("Failed to list docker images: %v", err) } expectedItemCount := sizeOfFirstPage + sizeOfLastPage if len(sdpItems) != expectedItemCount { t.Errorf("Expected %d docker images, got %d", expectedItemCount, len(sdpItems)) } }) t.Run("SearchStream", func(t *testing.T) { // This is a project level adapter, so we pass the project httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } streaming, ok := adapter.(SearchStreamAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchStreamableAdapter", sdpItemType) } expectedItemCount := sizeOfFirstPage + sizeOfLastPage items := make(chan *sdp.Item, expectedItemCount) t.Cleanup(func() { close(items) }) itemHandler := func(item *sdp.Item) { time.Sleep(10 * time.Millisecond) items <- item } errHandler := func(err error) { if err != nil { t.Fatalf("Unexpected error in stream: %v", err) } } stream := discovery.NewQueryResultStream(itemHandler, errHandler) streaming.SearchStream(ctx, projectID, shared.CompositeLookupKey(location, repository), true, stream) assert.Eventually(t, func() bool { return len(items) == expectedItemCount }, 5*time.Second, 100*time.Millisecond, "Expected to receive all items in the stream") }) } ================================================ FILE: sources/gcp/dynamic/adapters/artifact-registry-repository.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var _ = registerableAdapter{ sdpType: gcpshared.ArtifactRegistryRepository, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories/get?rep_location=global InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, LocationLevel: gcpshared.ProjectLevel, // GET: https://artifactregistry.googleapis.com/v1/projects/*/locations/*/repositories/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://artifactregistry.googleapis.com/v1/projects/%s/locations/%s/repositories/%s"), // LIST: https://artifactregistry.googleapis.com/v1/{parent=projects/*/locations/*}/repositories SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://artifactregistry.googleapis.com/v1/projects/%s/locations/%s/repositories"), UniqueAttributeKeys: []string{"locations", "repositories"}, IAMPermissions: []string{"artifactregistry.repositories.get", "artifactregistry.repositories.list"}, PredefinedRole: "roles/artifactregistry.reader", // HEALTH: Not currently exposed on the Repository resource (no status field providing operational state) }, linkRules: map[string]*gcpshared.Impact{ "kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // Forward link from parent to child via SEARCH // Link to all docker images in this repository "name": { ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, Description: "If the Artifact Registry Repository is deleted or updated: All associated Docker Images may become invalid or inaccessible. If a Docker Image is updated: The repository remains unaffected.", IsParentToChild: true, }, // Link to upstream repositories in virtual repository configuration "virtualRepositoryConfig.upstreamPolicies.repository": { ToSDPItemType: gcpshared.ArtifactRegistryRepository, Description: "If an upstream Artifact Registry Repository is deleted or updated: The virtual repository may fail to serve artifacts from that upstream. If the virtual repository is updated: The upstream repositories remain unaffected.", }, // Link to Secret Manager Secret Version used for remote repository authentication "remoteRepositoryConfig.upstreamCredentials.passwordSecretVersion": { ToSDPItemType: gcpshared.SecretManagerSecretVersion, Description: "If the Secret Manager Secret Version is deleted or its access is revoked: The remote repository may fail to authenticate with upstream sources. If the remote repository is updated: The secret version remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/artifact_registry_repository#attributes-reference", Description: "The id is in the format `projects/{project}/locations/{location}/repositories/{repository_id}`. We will use SEARCH to find the repository by this ID.", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_artifact_registry_repository.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/big-query-data-transfer-transfer-config.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // BigQuery Data Transfer transfer config adapter // Manages scheduled queries and data transfer configurations for BigQuery var _ = registerableAdapter{ sdpType: gcpshared.BigQueryDataTransferTransferConfig, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/bigquery/docs/reference/datatransfer/rest/v1/projects.transferConfigs/get SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // GET https://bigquerydatatransfer.googleapis.com/v1/projects/{projectId}/locations/{locationId}/transferConfigs/{transferConfigId} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://bigquerydatatransfer.googleapis.com/v1/projects/%s/locations/%s/transferConfigs/%s"), // Reference: https://cloud.google.com/bigquery/docs/reference/datatransfer/rest/v1/projects.locations.transferConfigs/list // GET https://bigquerydatatransfer.googleapis.com/v1/projects/{projectId}/locations/{locationId}/transferConfigs SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://bigquerydatatransfer.googleapis.com/v1/projects/%s/locations/%s/transferConfigs"), SearchDescription: "Search for BigQuery Data Transfer transfer configs in a location. Use the format \"location\" or \"projects/project_id/locations/location/transferConfigs/transfer_config_id\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "transferConfigs"}, IAMPermissions: []string{"bigquery.transfers.get"}, PredefinedRole: "overmind_custom_role", // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // state: https://cloud.google.com/bigquery/docs/reference/datatransfer/rest/v1/projects.locations.transferConfigs#TransferState }, linkRules: map[string]*gcpshared.Impact{ "destinationDatasetId": { ToSDPItemType: gcpshared.BigQueryDataset, Description: "If the BigQuery Dataset is deleted or updated: The transfer config may fail to write data. If the transfer config is updated: The dataset remains unaffected.", }, "dataSourceId": { ToSDPItemType: gcpshared.BigQueryDataTransferDataSource, Description: "If the Data Source is deleted or updated: The transfer config may fail to function. If the transfer config is updated: The data source remains unaffected.", }, "notificationPubsubTopic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or updated: Notifications may fail to be sent. If the transfer config is updated: The Pub/Sub topic remains unaffected.", }, "encryptionConfiguration.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "serviceAccountName": gcpshared.IAMServiceAccountImpactInOnly, // Link to child Transfer Runs using SEARCH // NOTE: BigQueryDataTransferTransferRun adapter does not exist yet // When created, it must support SEARCH with transfer config identifier as query parameter // API endpoint: GET https://bigquerydatatransfer.googleapis.com/v1/{parent=projects/*/locations/*/transferConfigs/*}/runs "name": { ToSDPItemType: gcpshared.BigQueryDataTransferTransferRun, Description: "If the Transfer Config is deleted or updated: All associated transfer runs may become invalid or inaccessible. If a transfer run is updated: The transfer config remains unaffected.", IsParentToChild: true, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_data_transfer_config", Description: "id => projects/{projectId}/locations/{location}/transferConfigs/{configId}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigquery_data_transfer_config.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/big-query-data-transfer-transfer-config_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/bigquery/datatransfer/apiv1/datatransferpb" "google.golang.org/protobuf/types/known/wrapperspb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // Helper functions for creating pointers func stringValuePtr(s string) *wrapperspb.StringValue { return &wrapperspb.StringValue{Value: s} } func TestBigQueryDataTransferTransferConfig(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() location := "us-central1" transferConfigName := "test-transfer-config" transferConfigName2 := "test-transfer-config-2" destinationDatasetId := "test-dataset" dataSourceId := "test-data-source" notificationPubsubTopic := "projects/test-project/topics/test-topic" kmsKeyName := "projects/test-project/locations/us-central1/keyRings/test-ring/cryptoKeys/test-key" // Create mock protobuf objects transferConfig := &datatransferpb.TransferConfig{ Name: fmt.Sprintf("projects/%s/locations/%s/transferConfigs/%s", projectID, location, transferConfigName), DisplayName: "Test Transfer Config", DataSourceId: dataSourceId, Destination: &datatransferpb.TransferConfig_DestinationDatasetId{ DestinationDatasetId: destinationDatasetId, }, Schedule: "0 9 * * *", // Daily at 9 AM NotificationPubsubTopic: notificationPubsubTopic, EncryptionConfiguration: &datatransferpb.EncryptionConfiguration{ KmsKeyName: stringValuePtr(kmsKeyName), }, } transferConfig2 := &datatransferpb.TransferConfig{ Name: fmt.Sprintf("projects/%s/locations/%s/transferConfigs/%s", projectID, location, transferConfigName2), DisplayName: "Test Transfer Config 2", DataSourceId: dataSourceId, Destination: &datatransferpb.TransferConfig_DestinationDatasetId{ DestinationDatasetId: destinationDatasetId, }, Schedule: "0 12 * * *", // Daily at 12 PM NotificationPubsubTopic: notificationPubsubTopic, } // Create list response with multiple items transferConfigList := &datatransferpb.ListTransferConfigsResponse{ TransferConfigs: []*datatransferpb.TransferConfig{transferConfig, transferConfig2}, } sdpItemType := gcpshared.BigQueryDataTransferTransferConfig // Mock HTTP responses for location-based resources expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigquerydatatransfer.googleapis.com/v1/projects/%s/locations/%s/transferConfigs/%s", projectID, location, transferConfigName): { StatusCode: http.StatusOK, Body: transferConfig, }, fmt.Sprintf("https://bigquerydatatransfer.googleapis.com/v1/projects/%s/locations/%s/transferConfigs/%s", projectID, location, transferConfigName2): { StatusCode: http.StatusOK, Body: transferConfig2, }, fmt.Sprintf("https://bigquerydatatransfer.googleapis.com/v1/projects/%s/locations/%s/transferConfigs", projectID, location): { StatusCode: http.StatusOK, Body: transferConfigList, }, } // Test Get with location + resource name t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // For multiple query parameters, use the combined query format combinedQuery := fmt.Sprintf("%s|%s", location, transferConfigName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes name, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } if name != transferConfig.GetName() { t.Errorf("Expected name field to be '%s', got %s", transferConfig.GetName(), name) } displayName, err := sdpItem.GetAttributes().Get("displayName") if err != nil { t.Fatalf("Failed to get 'displayName' attribute: %v", err) } if displayName != transferConfig.GetDisplayName() { t.Errorf("Expected displayName field to be '%s', got %s", transferConfig.GetDisplayName(), displayName) } dataSourceIdAttr, err := sdpItem.GetAttributes().Get("dataSourceId") if err != nil { t.Fatalf("Failed to get 'dataSourceId' attribute: %v", err) } if dataSourceIdAttr != dataSourceId { t.Errorf("Expected dataSourceId field to be '%s', got %s", dataSourceId, dataSourceIdAttr) } destinationDatasetIdAttr, err := sdpItem.GetAttributes().Get("destinationDatasetId") if err != nil { t.Fatalf("Failed to get 'destinationDatasetId' attribute: %v", err) } if destinationDatasetIdAttr != destinationDatasetId { t.Errorf("Expected destinationDatasetId field to be '%s', got %s", destinationDatasetId, destinationDatasetIdAttr) } notificationTopic, err := sdpItem.GetAttributes().Get("notificationPubsubTopic") if err != nil { t.Fatalf("Failed to get 'notificationPubsubTopic' attribute: %v", err) } if notificationTopic != notificationPubsubTopic { t.Errorf("Expected notificationPubsubTopic field to be '%s', got %s", notificationPubsubTopic, notificationTopic) } // Include static tests - MUST cover ALL link rule links t.Run("StaticTests", func(t *testing.T) { // CRITICAL: Review the adapter's link rules configuration and create // test cases for EVERY linked resource defined in the adapter's link rules map queryTests := shared.QueryTests{ // destinationDatasetId link { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: destinationDatasetId, ExpectedScope: projectID, }, // dataSourceId link - NOTE: BigQueryDataTransferDataSource adapter doesn't exist yet // TODO: Add test case when BigQueryDataTransferDataSource adapter is created // notificationPubsubTopic link { ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-topic", ExpectedScope: projectID, }, // encryptionConfiguration.kmsKeyName link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us-central1", "test-ring", "test-key"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) // Test Search (location-based resources typically use Search instead of List) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test location-based search sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Verify first item firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } // Verify second item secondItem := sdpItems[1] if secondItem.GetType() != sdpItemType.String() { t.Errorf("Expected second item type %s, got %s", sdpItemType.String(), secondItem.GetType()) } if secondItem.GetScope() != projectID { t.Errorf("Expected second item scope '%s', got %s", projectID, secondItem.GetScope()) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project_id]/locations/[location]/transferConfigs/[transfer_config_id] // The adapter should extract the location from this format and search in that location terraformQuery := fmt.Sprintf("projects/%s/locations/%s/transferConfigs/%s", projectID, location, transferConfigName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return all resources in the location extracted from the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify first item firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigquerydatatransfer.googleapis.com/v1/projects/%s/locations/%s/transferConfigs/%s", projectID, location, transferConfigName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Resource not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := fmt.Sprintf("%s|%s", location, transferConfigName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-app-profile.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // BigTable Admin App Profile adapter for Cloud Bigtable application profiles var _ = registerableAdapter{ sdpType: gcpshared.BigTableAdminAppProfile, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.appProfiles/get // GET https://bigtableadmin.googleapis.com/v2/{name=projects/*/instances/*/appProfiles/*} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/appProfiles/%s"), // Reference: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.appProfiles/list SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/appProfiles"), SearchDescription: "Search for BigTable App Profiles in an instance. Use the format \"instance\" or \"projects/[project_id]/instances/[instance_name]/appProfiles/[app_profile_id]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"instances", "appProfiles"}, IAMPermissions: []string{"bigtable.appProfiles.get", "bigtable.appProfiles.list"}, PredefinedRole: "roles/bigtable.viewer", }, linkRules: map[string]*gcpshared.Impact{ "name": { ToSDPItemType: gcpshared.BigTableAdminInstance, Description: "If the BigTableAdmin Instance is deleted or updated: The AppProfile may become invalid or inaccessible. If the AppProfile is updated: The instance remains unaffected.", }, "multiClusterRoutingUseAny.clusterIds": { ToSDPItemType: gcpshared.BigTableAdminCluster, Description: "If the BigTableAdmin Cluster is deleted or updated: The AppProfile may lose routing capabilities or fail to access data. If the AppProfile is updated: The cluster remains unaffected.", }, "singleClusterRouting.clusterId": { ToSDPItemType: gcpshared.BigTableAdminCluster, Description: "If the BigTableAdmin Cluster is deleted or updated: The AppProfile may lose routing capabilities or fail to access data. If the AppProfile is updated: The cluster remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigtable_app_profile", Description: "id => projects/{{project}}/instances/{{instance}}/appProfiles/{{app_profile_id}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigtable_app_profile.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-app-profile_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/bigtableadmin/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestBigTableAdminAppProfile(t *testing.T) { ctx := context.Background() projectID := "test-project" instanceName := "test-instance" linker := gcpshared.NewLinker() appProfileID := "test-app-profile" appProfile := &bigtableadmin.AppProfile{ Name: fmt.Sprintf("projects/%s/instances/%s/appProfiles/%s", projectID, instanceName, appProfileID), SingleClusterRouting: &bigtableadmin.SingleClusterRouting{ ClusterId: "test-cluster", }, } // Second app profile with multi-cluster routing appProfileID2 := "test-app-profile-multi" appProfile2 := &bigtableadmin.AppProfile{ Name: fmt.Sprintf("projects/%s/instances/%s/appProfiles/%s", projectID, instanceName, appProfileID2), MultiClusterRoutingUseAny: &bigtableadmin.MultiClusterRoutingUseAny{ ClusterIds: []string{"cluster-1", "cluster-2"}, }, } appProfileList := &bigtableadmin.ListAppProfilesResponse{ AppProfiles: []*bigtableadmin.AppProfile{appProfile}, } sdpItemType := gcpshared.BigTableAdminAppProfile expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/appProfiles/%s", projectID, instanceName, appProfileID): { StatusCode: http.StatusOK, Body: appProfile, }, fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/appProfiles/%s", projectID, instanceName, appProfileID2): { StatusCode: http.StatusOK, Body: appProfile2, }, fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/appProfiles", projectID, instanceName): { StatusCode: http.StatusOK, Body: appProfileList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, appProfileID) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get app profile: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // name (parent instance) { ExpectedType: gcpshared.BigTableAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceName, ExpectedScope: projectID, }, // TODO: Add test for singleClusterRouting.clusterId → BigTableAdminCluster // Requires manual linker to combine instance name with cluster ID } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get with multi-cluster routing", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, appProfileID2) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get app profile: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // name (parent instance) { ExpectedType: gcpshared.BigTableAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceName, ExpectedScope: projectID, }, // TODO: Add tests for multiClusterRoutingUseAny.clusterIds → BigTableAdminCluster // Requires manual linker to combine instance name with cluster IDs } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, instanceName, true) if err != nil { t.Fatalf("Failed to search app profiles: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 app profile, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/instances/[instance]/appProfiles/[app_profile] terraformQuery := fmt.Sprintf("projects/%s/instances/%s/appProfiles/%s", projectID, instanceName, appProfileID) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/appProfiles/%s", projectID, instanceName, appProfileID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "App profile not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, appProfileID) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent app profile, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-backup.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // BigTable Admin Backup adapter for Cloud Bigtable backups var _ = registerableAdapter{ sdpType: gcpshared.BigTableAdminBackup, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OTHER, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.clusters.backups/get // GET https://bigtableadmin.googleapis.com/v2/{name=projects/*/instances/*/clusters/*/backups/*} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithThreeQueries("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s/backups/%s"), // GET https://bigtableadmin.googleapis.com/v2/{parent=projects/*/instances/*/clusters/*}/backups SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s/backups"), UniqueAttributeKeys: []string{"instances", "clusters", "backups"}, // HEALTH: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.clusters.backups#state // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"bigtable.backups.get", "bigtable.backups.list"}, PredefinedRole: "roles/bigtable.viewer", }, linkRules: map[string]*gcpshared.Impact{ "name": { ToSDPItemType: gcpshared.BigTableAdminCluster, Description: "If the BigTableAdmin Cluster is deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The cluster remains unaffected.", }, "sourceTable": { ToSDPItemType: gcpshared.BigTableAdminTable, Description: "If the BigTableAdmin Table is deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The table remains unaffected.", }, "sourceBackup": { ToSDPItemType: gcpshared.BigTableAdminBackup, Description: "If the source Backup is deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The source backup remains unaffected.", }, "encryptionInfo.kmsKeyVersion": gcpshared.CryptoKeyVersionImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-backup_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/bigtableadmin/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestBigTableAdminBackup(t *testing.T) { ctx := context.Background() projectID := "test-project" instanceName := "test-instance" clusterName := "test-cluster" linker := gcpshared.NewLinker() backupID := "test-backup" backup := &bigtableadmin.Backup{ Name: fmt.Sprintf("projects/%s/instances/%s/clusters/%s/backups/%s", projectID, instanceName, clusterName, backupID), SourceTable: fmt.Sprintf("projects/%s/instances/%s/tables/source-table", projectID, instanceName), SourceBackup: fmt.Sprintf("projects/%s/instances/%s/clusters/%s/backups/source-backup", projectID, instanceName, clusterName), EncryptionInfo: &bigtableadmin.EncryptionInfo{ KmsKeyVersion: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", }, } backupList := &bigtableadmin.ListBackupsResponse{ Backups: []*bigtableadmin.Backup{backup}, } sdpItemType := gcpshared.BigTableAdminBackup expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s/backups/%s", projectID, instanceName, clusterName, backupID): { StatusCode: http.StatusOK, Body: backup, }, fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s/backups", projectID, instanceName, clusterName): { StatusCode: http.StatusOK, Body: backupList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, clusterName, backupID) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get backup: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // name (BigTableAdminCluster) ExpectedType: gcpshared.BigTableAdminCluster.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(instanceName, clusterName), ExpectedScope: projectID, }, { // sourceTable ExpectedType: gcpshared.BigTableAdminTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(instanceName, "source-table"), ExpectedScope: projectID, }, { // sourceBackup ExpectedType: gcpshared.BigTableAdminBackup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(instanceName, clusterName, "source-backup"), ExpectedScope: projectID, }, { // encryptionInfo.kmsKeyVersion ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key", "1"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } searchQuery := shared.CompositeLookupKey(instanceName, clusterName) sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search backups: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 backup, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s/backups/%s", projectID, instanceName, clusterName, backupID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Backup not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, clusterName, backupID) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent backup, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-cluster.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var bigTableAdminClusterAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.BigTableAdminCluster, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.clusters/get SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // https://bigtableadmin.googleapis.com/v2/projects/*/instances/*/clusters/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s"), // https://bigtableadmin.googleapis.com/v2/projects/*/instances/*/clusters SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters"), UniqueAttributeKeys: []string{"instances", "clusters"}, IAMPermissions: []string{"bigtable.clusters.get", "bigtable.clusters.list"}, PredefinedRole: "roles/bigtable.viewer", // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.clusters#State }, linkRules: map[string]*gcpshared.Impact{ // Customer-managed encryption key protecting data in this cluster. "encryptionConfig.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // This is a backlink to instance. // Framework will extract the instance name and create the linked item query with GET // NOTE: We prioritize the backlink over a forward link to BigTableAdminBackup // because the backlink is more critical for understanding the cluster's dependencies. "name": { ToSDPItemType: gcpshared.BigTableAdminInstance, Description: "If the BigTableAdmin Instance is deleted or updated: The Cluster may become invalid or inaccessible. If the Cluster is updated: The instance remains unaffected.", }, }, // No Terraform mapping }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-cluster_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/bigtable/admin/apiv2/adminpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestBigTableAdminCluster(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() instanceName := "test-instance" clusterName := "test-cluster" // Create mock protobuf cluster object bigTableCluster := &adminpb.Cluster{ Name: fmt.Sprintf("projects/%s/instances/%s/clusters/%s", projectID, instanceName, clusterName), Location: fmt.Sprintf("projects/%s/locations/us-central1-a", projectID), State: adminpb.Cluster_READY, ServeNodes: 3, DefaultStorageType: adminpb.StorageType_SSD, Config: &adminpb.Cluster_ClusterConfig_{ ClusterConfig: &adminpb.Cluster_ClusterConfig{ ClusterAutoscalingConfig: &adminpb.Cluster_ClusterAutoscalingConfig{ AutoscalingLimits: &adminpb.AutoscalingLimits{ MinServeNodes: 1, MaxServeNodes: 10, }, AutoscalingTargets: &adminpb.AutoscalingTargets{ CpuUtilizationPercent: 70, StorageUtilizationGibPerNode: 2500, }, }, }, }, EncryptionConfig: &adminpb.Cluster_EncryptionConfig{ KmsKeyName: fmt.Sprintf("projects/%s/locations/us-central1/keyRings/test-keyring/cryptoKeys/test-key", projectID), }, } // Create a second cluster for search testing clusterName2 := "test-cluster-2" bigTableCluster2 := &adminpb.Cluster{ Name: fmt.Sprintf("projects/%s/instances/%s/clusters/%s", projectID, instanceName, clusterName2), Location: fmt.Sprintf("projects/%s/locations/us-east1-b", projectID), State: adminpb.Cluster_CREATING, ServeNodes: 5, DefaultStorageType: adminpb.StorageType_HDD, // No encryption config for this cluster } // Mock response for search operation (list clusters in an instance) bigTableClustersList := &adminpb.ListClustersResponse{ Clusters: []*adminpb.Cluster{bigTableCluster, bigTableCluster2}, } sdpItemType := gcpshared.BigTableAdminCluster expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s", projectID, instanceName, clusterName): { StatusCode: http.StatusOK, Body: bigTableCluster, }, fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters", projectID, instanceName): { StatusCode: http.StatusOK, Body: bigTableClustersList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // Use composite query helper for BigTable Admin Cluster getQuery := shared.CompositeLookupKey(instanceName, clusterName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get BigTable Admin Cluster: %v", err) } // Basic item validation if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", getQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Test specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/instances/%s/clusters/%s", projectID, instanceName, clusterName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } val, err = sdpItem.GetAttributes().Get("location") if err != nil { t.Fatalf("Failed to get 'location' attribute: %v", err) } expectedLocation := fmt.Sprintf("projects/%s/locations/us-central1-a", projectID) if val != expectedLocation { t.Errorf("Expected location field to be '%s', got %s", expectedLocation, val) } val, err = sdpItem.GetAttributes().Get("state") if err != nil { t.Fatalf("Failed to get 'state' attribute: %v", err) } if val != "READY" { t.Errorf("Expected state field to be 'READY', got %s", val) } val, err = sdpItem.GetAttributes().Get("serveNodes") if err != nil { t.Fatalf("Failed to get 'serveNodes' attribute: %v", err) } // serveNodes comes back as float64 from protojson.Marshal converted, ok := val.(float64) if !ok { t.Fatalf("Expected serveNodes to be a float64, got %T", val) } if converted != 3 { t.Errorf("Expected serveNodes field to be 3, got %f", converted) } val, err = sdpItem.GetAttributes().Get("defaultStorageType") if err != nil { t.Fatalf("Failed to get 'defaultStorageType' attribute: %v", err) } if val != "SSD" { t.Errorf("Expected defaultStorageType field to be 'SSD', got %s", val) } // Test nested attributes from protobuf val, err = sdpItem.GetAttributes().Get("encryptionConfig") if err != nil { t.Fatalf("Failed to get 'encryptionConfig' attribute: %v", err) } encryptionConfig, ok := val.(map[string]any) if !ok { t.Fatalf("Expected encryptionConfig to be a map[string]interface{}, got %T", val) } expectedKmsKey := fmt.Sprintf("projects/%s/locations/us-central1/keyRings/test-keyring/cryptoKeys/test-key", projectID) if encryptionConfig["kmsKeyName"] != expectedKmsKey { t.Errorf("Expected encryptionConfig.kmsKeyName to be '%s', got %s", expectedKmsKey, encryptionConfig["kmsKeyName"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us-central1", "test-keyring", "test-key"), ExpectedScope: projectID, }, { // name field creates a backlink to the BigTable instance ExpectedType: gcpshared.BigTableAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceName, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter is not a SearchableAdapter") } // Search by instance name to get all clusters in that instance searchQuery := instanceName sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search BigTable Admin Clusters: %v", err) } // Assert there are exactly 2 items if len(sdpItems) != 2 { t.Fatalf("Expected exactly 2 BigTable Admin Clusters, got %d", len(sdpItems)) } // Validate first cluster item1 := sdpItems[0] if item1.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), item1.GetType()) } if item1.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, item1.GetScope()) } expectedUAV1 := shared.CompositeLookupKey(instanceName, clusterName) if item1.UniqueAttributeValue() != expectedUAV1 { t.Errorf("Expected first item unique attribute value '%s', got %s", expectedUAV1, item1.UniqueAttributeValue()) } // Validate second cluster item2 := sdpItems[1] if item2.GetType() != sdpItemType.String() { t.Errorf("Expected second item type %s, got %s", sdpItemType.String(), item2.GetType()) } if item2.GetScope() != projectID { t.Errorf("Expected second item scope '%s', got %s", projectID, item2.GetScope()) } expectedUAV2 := shared.CompositeLookupKey(instanceName, clusterName2) if item2.UniqueAttributeValue() != expectedUAV2 { t.Errorf("Expected second item unique attribute value '%s', got %s", expectedUAV2, item2.UniqueAttributeValue()) } // Validate specific attributes to ensure we have the correct items val, err := item2.GetAttributes().Get("state") if err != nil { t.Fatalf("Failed to get 'state' attribute from second item: %v", err) } if val != "CREATING" { t.Errorf("Expected second cluster state to be 'CREATING', got %s", val) } val, err = item2.GetAttributes().Get("defaultStorageType") if err != nil { t.Fatalf("Failed to get 'defaultStorageType' attribute from second item: %v", err) } if val != "HDD" { t.Errorf("Expected second cluster defaultStorageType to be 'HDD', got %s", val) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/clusters/%s", projectID, instanceName, clusterName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Cluster not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, clusterName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent BigTable Admin Cluster, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-instance.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var _ = registerableAdapter{ sdpType: gcpshared.BigTableAdminInstance, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances/get SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // https://bigtableadmin.googleapis.com/v2/projects/*/instances/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s"), // https://bigtableadmin.googleapis.com/v2/projects/*/instances ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://bigtableadmin.googleapis.com/v2/projects/%s/instances"), UniqueAttributeKeys: []string{"instances"}, IAMPermissions: []string{"bigtable.instances.get", "bigtable.instances.list"}, PredefinedRole: "roles/bigtable.viewer", // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // state: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances#State }, linkRules: map[string]*gcpshared.Impact{ // Forward link from parent to child via SEARCH // Link to all clusters in this instance (most fundamental infrastructure component) "name": { ToSDPItemType: gcpshared.BigTableAdminCluster, Description: "If the BigTableAdmin Instance is deleted or updated: All associated Clusters may become invalid or inaccessible. If a Cluster is updated: The instance remains unaffected.", IsParentToChild: true, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigtable_instance", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigtable_instance.name", }, // IAM resources for Bigtable Instances. These are Terraform-only constructs // (no standalone GCP API resource exists). When an IAM binding/member/policy // changes, we resolve it to the parent instance for blast radius analysis. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigtable_instance_iam { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigtable_instance_iam_binding.instance", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigtable_instance_iam_member.instance", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigtable_instance_iam_policy.instance", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-instance_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/bigtable/admin/apiv2/adminpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestBigTableAdminInstance(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() instanceName := "test-instance" instance := &adminpb.Instance{ Name: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName), } instanceName2 := "test-instance-2" instance2 := &adminpb.Instance{ Name: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName2), } instanceList := &adminpb.ListInstancesResponse{ Instances: []*adminpb.Instance{instance, instance2}, } sdpItemType := gcpshared.BigTableAdminInstance expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s", projectID, instanceName): { StatusCode: http.StatusOK, Body: instance, }, fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s", projectID, instanceName2): { StatusCode: http.StatusOK, Body: instance2, }, fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances", projectID): { StatusCode: http.StatusOK, Body: instanceList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, instanceName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != instanceName { t.Errorf("Expected unique attribute value '%s', got %s", instanceName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.BigTableAdminCluster.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: instanceName, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s", projectID, instanceName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Instance not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, instanceName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-table.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // BigTable Admin Table adapter for Cloud Bigtable tables var _ = registerableAdapter{ sdpType: gcpshared.BigTableAdminTable, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.tables/get // GET https://bigtableadmin.googleapis.com/v2/{name=projects/*/instances/*/tables/*} // IAM permissions: bigtable.tables.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/tables/%s"), // Reference: https://cloud.google.com/bigtable/docs/reference/admin/rest/v2/projects.instances.tables/list // GET https://bigtableadmin.googleapis.com/v2/{parent=projects/*/instances/*}/tables SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/tables"), SearchDescription: "Search for BigTable tables in an instance. Use the format \"instance_name\" or \"projects/[project_id]/instances/[instance_name]/tables/[table_name]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"instances", "tables"}, IAMPermissions: []string{"bigtable.tables.get", "bigtable.tables.list"}, PredefinedRole: "roles/bigtable.viewer", }, linkRules: map[string]*gcpshared.Impact{ "name": { ToSDPItemType: gcpshared.BigTableAdminInstance, Description: "If the BigTableAdmin Instance is deleted or updated: The Table may become invalid or inaccessible. If the Table is updated: The instance remains unaffected.", }, // If this table was restored from another data source (e.g. a backup), this field, restoreInfo, will be populated with information about the restore. "restoreInfo.backupInfo.sourceTable": { ToSDPItemType: gcpshared.BigTableAdminTable, Description: "If the source BigTableAdmin Table is deleted or updated: The restored table may become invalid or inaccessible. If the restored table is updated: The source table remains unaffected.", }, "restoreInfo.backupInfo.sourceBackup": { ToSDPItemType: gcpshared.BigTableAdminBackup, Description: "If the source BigTableAdmin Backup is deleted or updated: The restored table may become invalid or inaccessible. If the restored table is updated: The source backup remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigtable_table", Description: "id => projects/{{project}}/instances/{{instance_name}}/tables/{{name}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigtable_table.id", }, // IAM resources for Bigtable Tables. These are Terraform-only constructs // (no standalone GCP API resource exists). We use the instance_name // attribute because the table attribute is a bare name that the SEARCH // handler would misinterpret as an instance name. Using instance_name // lists all tables in the affected instance, providing instance-level // blast radius for table IAM changes. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigtable_table_iam { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigtable_table_iam_binding.instance_name", }, { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigtable_table_iam_member.instance_name", }, { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigtable_table_iam_policy.instance_name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/big-table-admin-table_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/bigtableadmin/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestBigTableAdminTable(t *testing.T) { ctx := context.Background() projectID := "test-project" instanceName := "test-instance" linker := gcpshared.NewLinker() tableName := "test-table" table := &bigtableadmin.Table{ Name: fmt.Sprintf("projects/%s/instances/%s/tables/%s", projectID, instanceName, tableName), RestoreInfo: &bigtableadmin.RestoreInfo{ BackupInfo: &bigtableadmin.BackupInfo{ SourceTable: fmt.Sprintf("projects/%s/instances/%s/tables/source-table", projectID, instanceName), SourceBackup: fmt.Sprintf("projects/%s/instances/%s/clusters/test-cluster/backups/test-backup", projectID, instanceName), }, }, } tableList := &bigtableadmin.ListTablesResponse{ Tables: []*bigtableadmin.Table{table}, } sdpItemType := gcpshared.BigTableAdminTable expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/tables/%s", projectID, instanceName, tableName): { StatusCode: http.StatusOK, Body: table, }, fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/tables", projectID, instanceName): { StatusCode: http.StatusOK, Body: tableList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, tableName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get table: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // name (parent instance) { ExpectedType: gcpshared.BigTableAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceName, ExpectedScope: projectID, }, // restoreInfo.backupInfo.sourceTable { ExpectedType: gcpshared.BigTableAdminTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(instanceName, "source-table"), ExpectedScope: projectID, }, // restoreInfo.backupInfo.sourceBackup { ExpectedType: gcpshared.BigTableAdminBackup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(instanceName, "test-cluster", "test-backup"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, instanceName, true) if err != nil { t.Fatalf("Failed to search tables: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 table, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/instances/[instance]/tables/[table] terraformQuery := fmt.Sprintf("projects/%s/instances/%s/tables/%s", projectID, instanceName, tableName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://bigtableadmin.googleapis.com/v2/projects/%s/instances/%s/tables/%s", projectID, instanceName, tableName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Table not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, tableName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent table, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/cloud-billing-billing-info.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Cloud Billing Billing Info adapter for project billing information var _ = registerableAdapter{ sdpType: gcpshared.CloudBillingBillingInfo, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/billing/docs/reference/rest/v1/projects/getBillingInfo // Gets the billing information for a project. // GET https://cloudbilling.googleapis.com/v1/{name=projects/*}/billingInfo // IAM permissions: resourcemanager.projects.get // Note: This adapter uses the query as the project ID, and validates it // against the adapter's configured project via location.ProjectID. GetEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" { return "" } if query != location.ProjectID { return "" } return fmt.Sprintf("https://cloudbilling.googleapis.com/v1/projects/%s/billingInfo", query) }, UniqueAttributeKeys: []string{"billingInfo"}, IAMPermissions: []string{"resourcemanager.projects.get"}, // This role is required via ai adapters and it gives this exact permission. PredefinedRole: "roles/aiplatform.viewer", }, linkRules: map[string]*gcpshared.Impact{ "projectId": { ToSDPItemType: gcpshared.CloudResourceManagerProject, Description: "If the Cloud Resource Manager Project is deleted or updated: The billing information may become invalid or inaccessible. If the billing info is updated: The project remains unaffected.", }, "billingAccountName": { ToSDPItemType: gcpshared.CloudBillingBillingAccount, Description: "If the Cloud Billing Billing Account is deleted or updated: The billing information may become invalid or inaccessible. If the billing info is updated: The billing account is impacted as well.", }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/cloud-billing-billing-info_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/cloudbilling/v1" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudBillingBillingInfo(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() billingInfo := &cloudbilling.ProjectBillingInfo{ Name: fmt.Sprintf("projects/%s/billingInfo", projectID), ProjectId: projectID, BillingAccountName: "billingAccounts/012345-ABCDEF-678901", BillingEnabled: true, } sdpItemType := gcpshared.CloudBillingBillingInfo expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudbilling.googleapis.com/v1/projects/%s/billingInfo", projectID): { StatusCode: http.StatusOK, Body: billingInfo, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, projectID, true) if err != nil { t.Fatalf("Failed to get billing info: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } // Note: StaticTests skipped because ProjectBillingInfo doesn't expose proper unique attribute // This is a limitation of the API response structure }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudbilling.googleapis.com/v1/projects/%s/billingInfo", projectID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Billing info not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, projectID, true) if err == nil { t.Error("Expected error when getting non-existent billing info, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/cloud-build-build.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Cloud Build Build adapter for Cloud Build builds var _ = registerableAdapter{ sdpType: gcpshared.CloudBuildBuild, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/build/docs/api/reference/rest/v1/projects.builds/get // GET https://cloudbuild.googleapis.com/v1/projects/{projectId}/builds/{id} // IAM permissions: cloudbuild.builds.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://cloudbuild.googleapis.com/v1/projects/%s/builds/%s"), // Reference: https://cloud.google.com/build/docs/api/reference/rest/v1/projects.builds/list // GET https://cloudbuild.googleapis.com/v1/projects/{projectId}/builds ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://cloudbuild.googleapis.com/v1/projects/%s/builds"), UniqueAttributeKeys: []string{"builds"}, // HEALTH: https://cloud.google.com/build/docs/api/reference/rest/v1/projects.builds#Build.Status // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"cloudbuild.builds.get", "cloudbuild.builds.list"}, PredefinedRole: "roles/cloudbuild.builds.viewer", }, linkRules: map[string]*gcpshared.Impact{ "source.storageSource.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket is deleted or updated: The Cloud Build may fail to access source files. If the Cloud Build is updated: The bucket remains unaffected.", }, "steps.name": { ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, Description: "If the Artifact Registry Docker Image is deleted or updated: The Cloud Build may fail to pull the image. If the Cloud Build is updated: The Docker image remains unaffected.", }, "results.images": { ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, Description: "If the Cloud Build is updated or deleted: The Artifact Registry Docker Images may no longer be valid or accessible. If the Docker Images are updated: The Cloud Build remains unaffected.", }, "images": { ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, Description: "If any of the images fail to be pushed, the build status is marked FAILURE.", }, "logsBucket": { ToSDPItemType: gcpshared.LoggingBucket, Description: "If the Logging Bucket is deleted or updated: The Cloud Build may fail to write logs. If the Cloud Build is updated: The bucket remains unaffected.", }, "serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, "buildTriggerId": { // The ID of the BuildTrigger that triggered this build, if it was triggered automatically. ToSDPItemType: gcpshared.CloudBuildTrigger, Description: "If the Cloud Build Trigger is deleted or updated: The Cloud Build may not be retriggered as expected. If the Cloud Build is updated: The trigger remains unaffected.", }, // Artifacts storage location (Cloud Storage bucket for build artifacts) "artifacts.objects.location": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket for artifacts is deleted or updated: The Cloud Build may fail to store build artifacts. If the Cloud Build is updated: The bucket remains unaffected.", }, // Maven artifacts repository in Artifact Registry "artifacts.mavenArtifacts.repository": { ToSDPItemType: gcpshared.ArtifactRegistryRepository, Description: "If the Artifact Registry Repository for Maven artifacts is deleted or updated: The Cloud Build may fail to store Maven artifacts. If the Cloud Build is updated: The repository remains unaffected.", }, // NPM packages repository in Artifact Registry "artifacts.npmPackages.repository": { ToSDPItemType: gcpshared.ArtifactRegistryRepository, Description: "If the Artifact Registry Repository for NPM packages is deleted or updated: The Cloud Build may fail to store NPM packages. If the Cloud Build is updated: The repository remains unaffected.", }, // Python packages repository in Artifact Registry "artifacts.pythonPackages.repository": { ToSDPItemType: gcpshared.ArtifactRegistryRepository, Description: "If the Artifact Registry Repository for Python packages is deleted or updated: The Cloud Build may fail to store Python packages. If the Cloud Build is updated: The repository remains unaffected.", }, // Secret Manager secrets used in the build (availableSecrets.secretManager[].version) // The version field contains the full path: projects/{project}/secrets/{secret}/versions/{version} // The framework will automatically extract the secret name from this path and handle array elements "availableSecrets.secretManager.version": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager Secret is deleted or its access is revoked: The Cloud Build may fail to access required secrets during execution. If the Cloud Build is updated: The secret remains unaffected.", }, // Worker pool used for the build (same as Cloud Functions - Run Worker Pool) "options.pool.name": { ToSDPItemType: gcpshared.RunWorkerPool, Description: "If the Cloud Run Worker Pool is deleted or misconfigured: The Cloud Build may fail to execute. If the Cloud Build is updated: The worker pool remains unaffected.", }, // KMS key for encrypting build logs (if using CMEK) "options.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/cloud-build-build_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/cloudbuild/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudBuildBuild(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() buildID := "test-build-id" build := &cloudbuild.Build{ Id: buildID, Name: fmt.Sprintf("projects/%s/locations/global/builds/%s", projectID, buildID), Source: &cloudbuild.Source{ StorageSource: &cloudbuild.StorageSource{ Bucket: "source-bucket", }, }, ServiceAccount: "cloudbuild-sa@test-project.iam.gserviceaccount.com", } buildList := &cloudbuild.ListBuildsResponse{ Builds: []*cloudbuild.Build{build}, } sdpItemType := gcpshared.CloudBuildBuild expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudbuild.googleapis.com/v1/projects/%s/builds/%s", projectID, buildID): { StatusCode: http.StatusOK, Body: build, }, fmt.Sprintf("https://cloudbuild.googleapis.com/v1/projects/%s/builds", projectID): { StatusCode: http.StatusOK, Body: buildList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, buildID, true) if err != nil { t.Fatalf("Failed to get build: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // source.storageSource.bucket ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-bucket", ExpectedScope: projectID, }, { // serviceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cloudbuild-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list builds: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 build, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudbuild.googleapis.com/v1/projects/%s/builds/%s", projectID, buildID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Build not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, buildID, true) if err == nil { t.Error("Expected error when getting non-existent build, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/cloud-resource-manager-project.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Cloud Resource Manager Project adapter for GCP projects var _ = registerableAdapter{ sdpType: gcpshared.CloudResourceManagerProject, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/resource-manager/reference/rest/v3/projects/get // GET https://cloudresourcemanager.googleapis.com/v3/projects/* // IAM permissions: resourcemanager.projects.get // Note: This adapter uses the query as the project ID, and validates it // against the adapter's configured project via location.ProjectID. GetEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" { return "" } if query != location.ProjectID { return "" } return fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/projects/%s", query) }, UniqueAttributeKeys: []string{"projects"}, // HEALTH: https://cloud.google.com/resource-manager/reference/rest/v3/projects#State // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"resourcemanager.projects.get"}, PredefinedRole: "roles/resourcemanager.tagViewer", }, linkRules: map[string]*gcpshared.Impact{ // There are no links for this item type. // TODO: Currently our highest level of scope is the project. // This item has `parent` attribute that refers to organization or folder which are higher level scopes that we don't support yet. // If we support those scopes in the future, we can add links to them. // https://cloud.google.com/resource-manager/reference/rest/v3/projects#Project }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/cloud-resource-manager-project_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/cloudresourcemanager/v3" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudResourceManagerProject(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() project := &cloudresourcemanager.Project{ Name: fmt.Sprintf("projects/%s", projectID), ProjectId: projectID, DisplayName: "Test Project", State: "ACTIVE", } sdpItemType := gcpshared.CloudResourceManagerProject expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/projects/%s", projectID): { StatusCode: http.StatusOK, Body: project, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, projectID, true) if err != nil { t.Fatalf("Failed to get project: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/projects/%s", projectID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Project not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, projectID, true) if err == nil { t.Error("Expected error when getting non-existent project, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/cloud-resource-manager-tag-key.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Cloud Resource Manager TagKey adapter (IN DEVELOPMENT) // Reference: https://cloud.google.com/resource-manager/reference/rest/v3/tagKeys/get // GET https://cloudresourcemanager.googleapis.com/v3/tagKeys/{TAG_KEY_ID} // LIST https://cloudresourcemanager.googleapis.com/v3/tagKeys?parent=projects/{project_id} var cloudResourceManagerTagKeyAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.CloudResourceManagerTagKey, meta: gcpshared.AdapterMeta{ InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" { // require TagKey identifier (e.g. 123) return "" } return fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagKeys/%s", query) }, // List TagKeys requires a parent. We accept an organization ID (e.g. 123456789) and construct organizations/{ID} ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://cloudresourcemanager.googleapis.com/v3/tagKeys?parent=projects/%s"), UniqueAttributeKeys: []string{"tagKeys"}, IAMPermissions: []string{ "resourcemanager.tagKeys.get", "resourcemanager.tagKeys.list", }, PredefinedRole: "roles/resourcemanager.tagViewer", }, // No link rules yet. TagValue already links back to TagKey via parent attribute. linkRules: map[string]*gcpshared.Impact{}, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/cloud-resource-manager-tag-key_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" resourcemanagerpb "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudResourceManagerTagKey(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() tagKeyID := "123456789" // Mock TagKey response using protobuf types from GCP Go SDK // Reference: https://cloud.google.com/resource-manager/reference/rest/v3/tagKeys#TagKey tagKey := &resourcemanagerpb.TagKey{ Name: fmt.Sprintf("tagKeys/%s", tagKeyID), Parent: fmt.Sprintf("projects/%s", projectID), ShortName: "environment", NamespacedName: fmt.Sprintf("%s/environment", projectID), Description: "Environment classification for resources", CreateTime: timestamppb.New(mustParseTime("2023-01-15T10:30:00.000Z")), UpdateTime: timestamppb.New(mustParseTime("2023-01-15T10:30:00.000Z")), Etag: "BwXhqhCKJvM=", Purpose: resourcemanagerpb.Purpose_GCE_FIREWALL, PurposeData: map[string]string{ "network": fmt.Sprintf("projects/%s/global/networks/default", projectID), }, } // Create a second TagKey for list testing tagKeyID2 := "987654321" tagKey2 := &resourcemanagerpb.TagKey{ Name: fmt.Sprintf("tagKeys/%s", tagKeyID2), Parent: fmt.Sprintf("projects/%s", projectID), ShortName: "team", NamespacedName: fmt.Sprintf("%s/team", projectID), Description: "Team ownership for resources", CreateTime: timestamppb.New(mustParseTime("2023-01-16T11:45:00.000Z")), UpdateTime: timestamppb.New(mustParseTime("2023-01-16T11:45:00.000Z")), Etag: "BwXhqhCKJvN=", } // Mock list response structure using protobuf types tagKeys := &resourcemanagerpb.ListTagKeysResponse{ TagKeys: []*resourcemanagerpb.TagKey{tagKey, tagKey2}, } sdpItemType := gcpshared.CloudResourceManagerTagKey expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagKeys/%s", tagKeyID): { StatusCode: http.StatusOK, Body: tagKey, }, fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagKeys?parent=projects/%s", projectID): { StatusCode: http.StatusOK, Body: tagKeys, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := tagKeyID sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get TagKey: %v", err) } // Validate basic SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", tagKeyID, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific TagKey attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("tagKeys/%s", tagKeyID) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } val, err = sdpItem.GetAttributes().Get("parent") if err != nil { t.Fatalf("Failed to get 'parent' attribute: %v", err) } expectedParent := fmt.Sprintf("projects/%s", projectID) if val != expectedParent { t.Errorf("Expected parent field to be '%s', got %s", expectedParent, val) } val, err = sdpItem.GetAttributes().Get("shortName") if err != nil { t.Fatalf("Failed to get 'shortName' attribute: %v", err) } if val != "environment" { t.Errorf("Expected shortName field to be 'environment', got %s", val) } val, err = sdpItem.GetAttributes().Get("namespacedName") if err != nil { t.Fatalf("Failed to get 'namespacedName' attribute: %v", err) } expectedNamespacedName := fmt.Sprintf("%s/environment", projectID) if val != expectedNamespacedName { t.Errorf("Expected namespacedName field to be '%s', got %s", expectedNamespacedName, val) } val, err = sdpItem.GetAttributes().Get("description") if err != nil { t.Fatalf("Failed to get 'description' attribute: %v", err) } if val != "Environment classification for resources" { t.Errorf("Expected description field to be 'Environment classification for resources', got %s", val) } val, err = sdpItem.GetAttributes().Get("createTime") if err != nil { t.Fatalf("Failed to get 'createTime' attribute: %v", err) } if val != "2023-01-15T10:30:00Z" { t.Errorf("Expected createTime field to be '2023-01-15T10:30:00Z', got %s", val) } val, err = sdpItem.GetAttributes().Get("updateTime") if err != nil { t.Fatalf("Failed to get 'updateTime' attribute: %v", err) } if val != "2023-01-15T10:30:00Z" { t.Errorf("Expected updateTime field to be '2023-01-15T10:30:00Z', got %s", val) } val, err = sdpItem.GetAttributes().Get("etag") if err != nil { t.Fatalf("Failed to get 'etag' attribute: %v", err) } if val != "BwXhqhCKJvM=" { t.Errorf("Expected etag field to be 'BwXhqhCKJvM=', got %s", val) } val, err = sdpItem.GetAttributes().Get("purpose") if err != nil { t.Fatalf("Failed to get 'purpose' attribute: %v", err) } if val != "GCE_FIREWALL" { t.Errorf("Expected purpose field to be 'GCE_FIREWALL', got %s", val) } // Test nested purposeData structure val, err = sdpItem.GetAttributes().Get("purposeData") if err != nil { t.Fatalf("Failed to get 'purposeData' attribute: %v", err) } purposeData, ok := val.(map[string]any) if !ok { t.Fatalf("Expected purposeData to be a map, got %T", val) } networkVal, exists := purposeData["network"] if !exists { t.Errorf("Expected purposeData to contain 'network' field") } else { expectedNetwork := fmt.Sprintf("projects/%s/global/networks/default", projectID) if networkVal != expectedNetwork { t.Errorf("Expected purposeData.network to be '%s', got %s", expectedNetwork, networkVal) } } // Note: Since this adapter doesn't define link rule relationships, // we don't run StaticTests here. The adapter's link rules map is empty, // which is correct as TagKeys are configuration resources rather than runtime resources. }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(gcpshared.CloudResourceManagerTagKey, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter is not a ListableAdapter") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list TagKeys: %v", err) } // Verify the first item firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } // Verify the second item secondItem := sdpItems[1] if secondItem.GetType() != sdpItemType.String() { t.Errorf("Expected second item type %s, got %s", sdpItemType.String(), secondItem.GetType()) } if secondItem.GetScope() != projectID { t.Errorf("Expected second item scope '%s', got %s", projectID, secondItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test error handling for HTTP errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagKeys/%s", tagKeyID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": map[string]any{"code": 404, "message": "TagKey not found"}}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, tagKeyID, true) if err == nil { t.Errorf("Expected error for 404 response, got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/cloud-resource-manager-tag-value.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var _ = registerableAdapter{ sdpType: gcpshared.CloudResourceManagerTagValue, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/resource-manager/reference/rest/v3/tagValues/get // GET https://cloudresourcemanager.googleapis.com/v3/tagValues/{TAG_VALUE_ID} GetEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" { return "" } return fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues/%s", query) }, // Reference: https://cloud.google.com/resource-manager/reference/rest/v3/tagValues/list // LIST https://cloudresourcemanager.googleapis.com/v3/tagValues?parent=tagKeys/{TAG_KEY_ID} SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" { // require a parent TagKey identifier return "" } return fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues?parent=tagKeys/%s", query) }, SearchDescription: "Search for TagValues by TagKey.", UniqueAttributeKeys: []string{"tagValues"}, IAMPermissions: []string{ "resourcemanager.tagValues.get", "resourcemanager.tagValues.list", }, PredefinedRole: "roles/resourcemanager.tagViewer", }, linkRules: map[string]*gcpshared.Impact{ "parent": { ToSDPItemType: gcpshared.CloudResourceManagerTagKey, Description: "They are tightly coupled", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/tags_tag_value", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_tags_tag_value.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/cloud-resource-manager-tag-value_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudResourceManagerTagValue(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() tagValueID := "123456789" tagKeyID := "987654321" tagValue := &resourcemanagerpb.TagValue{ Name: fmt.Sprintf("tagValues/%s", tagValueID), Parent: fmt.Sprintf("tagKeys/%s", tagKeyID), } tagValueID2 := "123456790" tagValue2 := &resourcemanagerpb.TagValue{ Name: fmt.Sprintf("tagValues/%s", tagValueID2), Parent: fmt.Sprintf("tagKeys/%s", tagKeyID), } tagValueList := &resourcemanagerpb.ListTagValuesResponse{ TagValues: []*resourcemanagerpb.TagValue{tagValue, tagValue2}, } sdpItemType := gcpshared.CloudResourceManagerTagValue expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues/%s", tagValueID): { StatusCode: http.StatusOK, Body: tagValue, }, fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues/%s", tagValueID2): { StatusCode: http.StatusOK, Body: tagValue2, }, fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues?parent=tagKeys/%s", tagKeyID): { StatusCode: http.StatusOK, Body: tagValueList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, tagValueID, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != tagValueID { t.Errorf("Expected unique attribute value '%s', got %s", tagValueID, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.CloudResourceManagerTagKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: tagKeyID, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, tagKeyID, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues/%s", tagValueID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Tag value not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, tagValueID, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/cloudfunctions-function.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Cloud Function (1st/2nd gen) resource. // Reference: https://cloud.google.com/functions/docs/reference/rest/v2/projects.locations.functions#Function // GET: https://cloudfunctions.googleapis.com/v2/projects/{project}/locations/{location}/functions/{function} // LIST: https://cloudfunctions.googleapis.com/v2/projects/{project}/locations/{location}/functions // We treat this similar to other location-scoped project resources (e.g. DataformRepository) using Search semantics. var cloudFunctionAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.CloudFunctionsFunction, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://cloudfunctions.googleapis.com/v2/projects/%s/locations/%s/functions/%s", ), // LIST all functions across all locations using wildcard ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://cloudfunctions.googleapis.com/v2/projects/%s/locations/-/functions", ), // Use SearchEndpointFunc since caller supplies a location to enumerate functions SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://cloudfunctions.googleapis.com/v2/projects/%s/locations/%s/functions", ), UniqueAttributeKeys: []string{"locations", "functions"}, IAMPermissions: []string{"cloudfunctions.functions.get", "cloudfunctions.functions.list"}, PredefinedRole: "roles/cloudfunctions.viewer", // HEALTH: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules#Status => state // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items }, linkRules: map[string]*gcpshared.Impact{ "kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "buildConfig.source.storageSource.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage bucket is deleted or misconfigured: Function deployment may fail. If the function changes: The bucket remains unaffected.", }, "buildConfig.sourceProvenance.resolvedStorageSource.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage bucket is deleted or misconfigured: Function deployment may fail. If the function changes: The bucket remains unaffected.", }, "buildConfig.workerPool": { ToSDPItemType: gcpshared.RunWorkerPool, Description: "If the Cloud Run Worker Pool is deleted or misconfigured: Function deployment may fail. If the function changes: The worker pool remains unaffected.", }, "buildConfig.dockerRepository": { ToSDPItemType: gcpshared.ArtifactRegistryRepository, Description: "If the Container Repository is deleted or misconfigured: Function deployment may fail. If the function changes: The repository remains unaffected.", }, "buildConfig.serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, "serviceConfig.vpcConnector": { ToSDPItemType: gcpshared.VPCAccessConnector, Description: "If the VPC Access Connector is deleted or misconfigured: Function outbound networking may fail. If the function changes: The connector remains unaffected.", }, "serviceConfig.service": { ToSDPItemType: gcpshared.RunService, Description: "If the Cloud Run Service is deleted or misconfigured: Function execution may fail. If the function changes: The service remains unaffected.", }, "serviceConfig.serviceAccountEmail": gcpshared.IAMServiceAccountImpactInOnly, "eventTrigger.trigger": { ToSDPItemType: gcpshared.EventarcTrigger, Description: "If the Eventarc Trigger is deleted or misconfigured: Function event handling may fail. If the function changes: The trigger remains unaffected.", }, "eventTrigger.pubsubTopic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or misconfigured: Function event handling may fail. If the function changes: The topic remains unaffected.", }, "eventTrigger.serviceAccountEmail": gcpshared.IAMServiceAccountImpactInOnly, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/cloudfunctions-function_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/functions/apiv2/functionspb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudFunctionsFunction(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() location := "us-central1" functionName := "test-function" // Mock response for a Cloud Function cloudFunction := &functionspb.Function{ Name: fmt.Sprintf("projects/%s/locations/%s/functions/%s", projectID, location, functionName), Description: "Test Cloud Function for HTTP requests", UpdateTime: ×tamppb.Timestamp{ Seconds: 1673784600, // 2023-01-15T10:30:00Z }, Labels: map[string]string{ "env": "test", "team": "backend", }, State: functionspb.Function_ACTIVE, Url: fmt.Sprintf("https://%s-%s.cloudfunctions.net/test-function", location, projectID), KmsKeyName: fmt.Sprintf("projects/%s/locations/%s/keyRings/test-ring/cryptoKeys/test-key", projectID, location), ServiceConfig: &functionspb.ServiceConfig{ ServiceAccountEmail: fmt.Sprintf("test-function@%s.iam.gserviceaccount.com", projectID), VpcConnector: fmt.Sprintf("projects/%s/locations/%s/connectors/test-connector", projectID, location), Service: fmt.Sprintf("projects/%s/locations/%s/services/test-function-service", projectID, location), EnvironmentVariables: map[string]string{ "ENV": "test", }, }, BuildConfig: &functionspb.BuildConfig{ Runtime: "python39", EntryPoint: "main", DockerRepository: fmt.Sprintf("projects/%s/locations/%s/repositories/test-docker-repo", projectID, location), WorkerPool: fmt.Sprintf("projects/%s/locations/%s/workerPools/test-worker-pool", projectID, location), Source: &functionspb.Source{ Source: &functionspb.Source_StorageSource{ StorageSource: &functionspb.StorageSource{ Bucket: "test-bucket", Object: "function-source.zip", }, }, }, SourceProvenance: &functionspb.SourceProvenance{ ResolvedStorageSource: &functionspb.StorageSource{ Bucket: "test-resolved-bucket", Object: "resolved-function-source.zip", }, }, }, EventTrigger: &functionspb.EventTrigger{ PubsubTopic: fmt.Sprintf("projects/%s/topics/test-topic", projectID), ServiceAccountEmail: fmt.Sprintf("event-trigger@%s.iam.gserviceaccount.com", projectID), Trigger: fmt.Sprintf("projects/%s/locations/%s/triggers/test-trigger", projectID, location), }, } // Mock response for a second Cloud Function functionName2 := "test-function-2" cloudFunction2 := &functionspb.Function{ Name: fmt.Sprintf("projects/%s/locations/%s/functions/%s", projectID, location, functionName2), Description: "Second test Cloud Function for Pub/Sub events", UpdateTime: ×tamppb.Timestamp{ Seconds: 1673871900, // 2023-01-16T11:45:00Z }, Labels: map[string]string{ "env": "prod", "service": "event-processor", }, State: functionspb.Function_ACTIVE, BuildConfig: &functionspb.BuildConfig{ Runtime: "nodejs18", EntryPoint: "handler", Source: &functionspb.Source{ Source: &functionspb.Source_StorageSource{ StorageSource: &functionspb.StorageSource{ Bucket: "test-bucket-2", Object: "function-source-2.zip", }, }, }, }, } // Mock response for list operation cloudFunctionsList := &functionspb.ListFunctionsResponse{ Functions: []*functionspb.Function{ cloudFunction, cloudFunction2, }, NextPageToken: "", } sdpItemType := gcpshared.CloudFunctionsFunction expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudfunctions.googleapis.com/v2/projects/%s/locations/%s/functions/%s", projectID, location, functionName): { StatusCode: http.StatusOK, Body: cloudFunction, }, fmt.Sprintf("https://cloudfunctions.googleapis.com/v2/projects/%s/locations/%s/functions", projectID, location): { StatusCode: http.StatusOK, Body: cloudFunctionsList, }, fmt.Sprintf("https://cloudfunctions.googleapis.com/v2/projects/%s/locations/-/functions", projectID): { StatusCode: http.StatusOK, Body: cloudFunctionsList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, functionName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get Cloud Function: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", getQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Test specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/functions/%s", projectID, location, functionName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } val, err = sdpItem.GetAttributes().Get("description") if err != nil { t.Fatalf("Failed to get 'description' attribute: %v", err) } if val != "Test Cloud Function for HTTP requests" { t.Errorf("Expected description field to be 'Test Cloud Function for HTTP requests', got %s", val) } val, err = sdpItem.GetAttributes().Get("state") if err != nil { t.Fatalf("Failed to get 'state' attribute: %v", err) } if val != "ACTIVE" { t.Errorf("Expected state field to be 'ACTIVE', got %s", val) } // Test buildConfig.runtime attribute (nested in v2 API) buildConfig, err := sdpItem.GetAttributes().Get("buildConfig") if err != nil { t.Fatalf("Failed to get 'buildConfig' attribute: %v", err) } buildConfigMap, ok := buildConfig.(map[string]any) if !ok { t.Fatalf("Expected buildConfig to be a map, got %T", buildConfig) } if buildConfigMap["runtime"] != "python39" { t.Errorf("Expected buildConfig.runtime to be 'python39', got %s", buildConfigMap["runtime"]) } if buildConfigMap["entryPoint"] != "main" { t.Errorf("Expected buildConfig.entryPoint to be 'main', got %s", buildConfigMap["entryPoint"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Test KMS key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, "test-ring", "test-key"), ExpectedScope: projectID, }, // Test storage bucket link (buildConfig.source.storageSource.bucket) { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-bucket", ExpectedScope: projectID, }, // Test service account link (serviceConfig.serviceAccountEmail) { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("test-function@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, // Test Pub/Sub topic link (eventTrigger.pubsubTopic) { ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-topic", ExpectedScope: projectID, }, // Test event trigger service account link (eventTrigger.serviceAccountEmail) { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("event-trigger@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, // Test Eventarc trigger link (eventTrigger.trigger) { ExpectedType: gcpshared.EventarcTrigger.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, "test-trigger"), ExpectedScope: projectID, }, // Test Cloud Run service link (serviceConfig.service) { ExpectedType: gcpshared.RunService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, "test-function-service"), ExpectedScope: projectID, }, // Test Artifact Registry repository link (buildConfig.dockerRepository) { ExpectedType: gcpshared.ArtifactRegistryRepository.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, "test-docker-repo"), ExpectedScope: projectID, }, // Test Cloud Run Worker Pool link (buildConfig.workerPool) { ExpectedType: gcpshared.RunWorkerPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, "test-worker-pool"), ExpectedScope: projectID, }, // Test resolved storage bucket link (buildConfig.sourceProvenance.resolvedStorageSource.bucket) { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-resolved-bucket", ExpectedScope: projectID, }, // Note: serviceConfig.vpcConnector test case omitted because gcp-vpc-access-connector adapter doesn't exist } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter is not a SearchableAdapter") } searchQuery := location sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search Cloud Functions: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Cloud Functions, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } expectedUniqueAttr := shared.CompositeLookupKey(location, functionName) if item.UniqueAttributeValue() != expectedUniqueAttr { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr, item.UniqueAttributeValue()) } } if len(sdpItems) >= 2 { item := sdpItems[1] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } expectedUniqueAttr2 := shared.CompositeLookupKey(location, functionName2) if item.UniqueAttributeValue() != expectedUniqueAttr2 { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr2, item.UniqueAttributeValue()) } } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list Cloud Functions: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Cloud Functions, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } expectedUniqueAttr := shared.CompositeLookupKey(location, functionName) if item.UniqueAttributeValue() != expectedUniqueAttr { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr, item.UniqueAttributeValue()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://cloudfunctions.googleapis.com/v2/projects/%s/locations/%s/functions/%s", projectID, location, functionName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Function not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, functionName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent Cloud Function, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-accelerator-type.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Accelerator Type adapter for GPU/TPU accelerator types var _ = registerableAdapter{ sdpType: gcpshared.ComputeAcceleratorType, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/acceleratorTypes/get InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.ZonalLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/acceleratorTypes/{acceleratorType} GetEndpointFunc: gcpshared.ZoneLevelEndpointFunc("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/acceleratorTypes/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/acceleratorTypes ListEndpointFunc: gcpshared.ZoneLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/acceleratorTypes"), UniqueAttributeKeys: []string{"acceleratorTypes"}, IAMPermissions: []string{"compute.acceleratorTypes.get", "compute.acceleratorTypes.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{}, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-disk-type.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Disk Type adapter for persistent disk types var _ = registerableAdapter{ sdpType: gcpshared.ComputeDiskType, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/diskTypes/get InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, LocationLevel: gcpshared.ZonalLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/diskTypes/{diskType} GetEndpointFunc: gcpshared.ZoneLevelEndpointFunc("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/diskTypes/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/diskTypes ListEndpointFunc: gcpshared.ZoneLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/diskTypes"), UniqueAttributeKeys: []string{"diskTypes"}, IAMPermissions: []string{"compute.diskTypes.get", "compute.diskTypes.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{}, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-external-vpn-gateway.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // External VPN Gateway (project-level, global) resource representing an on-premises VPN device for Classic/HA VPN. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/externalVpnGateways/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/global/externalVpnGateways/{externalVpnGateway} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/global/externalVpnGateways var _ = registerableAdapter{ sdpType: gcpshared.ComputeExternalVpnGateway, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/externalVpnGateways/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/externalVpnGateways", ), UniqueAttributeKeys: []string{"externalVpnGateways"}, IAMPermissions: []string{ "compute.externalVpnGateways.get", "compute.externalVpnGateways.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "interfaces.ipAddress": gcpshared.IPImpactBothWays, "interfaces.ipv6Address": gcpshared.IPImpactBothWays, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_external_vpn_gateway", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_external_vpn_gateway.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-external-vpn-gateway_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeExternalVpnGateway(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() gatewayName := "test-external-vpn-gateway" ipAddress := "203.0.113.1" gateway := &computepb.ExternalVpnGateway{ Name: &gatewayName, Interfaces: []*computepb.ExternalVpnGatewayInterface{ { IpAddress: &ipAddress, }, }, } gatewayName2 := "test-external-vpn-gateway-2" gateway2 := &computepb.ExternalVpnGateway{ Name: &gatewayName2, } gatewayList := &computepb.ExternalVpnGatewayList{ Items: []*computepb.ExternalVpnGateway{gateway, gateway2}, } sdpItemType := gcpshared.ComputeExternalVpnGateway expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/externalVpnGateways/%s", projectID, gatewayName): { StatusCode: http.StatusOK, Body: gateway, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/externalVpnGateways/%s", projectID, gatewayName2): { StatusCode: http.StatusOK, Body: gateway2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/externalVpnGateways", projectID): { StatusCode: http.StatusOK, Body: gatewayList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, gatewayName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != gatewayName { t.Errorf("Expected unique attribute value '%s', got %s", gatewayName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/externalVpnGateways/%s", projectID, gatewayName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Gateway not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, gatewayName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-firewall.go ================================================ package adapters import ( "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Firewall adapter for VPC firewall rules var _ = registerableAdapter{ sdpType: gcpshared.ComputeFirewall, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/global/firewalls/{firewall} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://compute.googleapis.com/compute/v1/projects/%s/global/firewalls/%s"), // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/list // https://compute.googleapis.com/compute/v1/projects/{project}/global/firewalls ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/global/firewalls"), UniqueAttributeKeys: []string{"firewalls"}, IAMPermissions: []string{"compute.firewalls.get", "compute.firewalls.list"}, PredefinedRole: "roles/compute.viewer", // Tag-based SEARCH: list all firewalls then filter by tag. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" || strings.Contains(query, "/") { return "" } return fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/firewalls", location.ProjectID) }, SearchDescription: "Search for firewalls by network tag. The query is a plain network tag name.", SearchFilterFunc: firewallTagFilter, }, linkRules: map[string]*gcpshared.Impact{ "network": { Description: "If the Compute Network is updated: The firewall rules may no longer apply correctly. If the firewall is updated: The network remains unaffected, but its security posture may change.", ToSDPItemType: gcpshared.ComputeNetwork, }, "sourceServiceAccounts": gcpshared.IAMServiceAccountImpactInOnly, "targetServiceAccounts": gcpshared.IAMServiceAccountImpactInOnly, "targetTags": { Description: "Firewall rule specifies target_tags to control traffic to VM instances and instance templates with those tags. Overmind automatically discovers these relationships by searching for instances and templates with matching network tags, enabling accurate blast radius analysis when tags change on either firewalls or instances.", ToSDPItemType: gcpshared.ComputeInstance, }, "sourceTags": { Description: "Firewall rule specifies source_tags to control traffic from VM instances with those tags. Overmind automatically discovers these relationships by searching for instances with matching network tags, enabling accurate blast radius analysis when tags change on either firewalls or instances.", ToSDPItemType: gcpshared.ComputeInstance, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_firewall", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_firewall.name", }, }, }, }.Register() // firewallTagFilter keeps firewalls whose targetTags or sourceTags contain the query tag. func firewallTagFilter(query string, item *sdp.Item) bool { return itemAttributeContainsTag(item, "targetTags", query) || itemAttributeContainsTag(item, "sourceTags", query) } // itemAttributeContainsTag checks whether an item attribute (expected to be a // list of strings) contains the given tag value. func itemAttributeContainsTag(item *sdp.Item, attrKey, tag string) bool { val, err := item.GetAttributes().Get(attrKey) if err != nil { return false } list, ok := val.([]any) if !ok { return false } for _, elem := range list { if s, ok := elem.(string); ok && s == tag { return true } } return false } ================================================ FILE: sources/gcp/dynamic/adapters/compute-firewall_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/compute/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeFirewall(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() firewallName := "test-firewall" firewall := &compute.Firewall{ Name: firewallName, Network: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", SourceServiceAccounts: []string{ "test-sa@test-project.iam.gserviceaccount.com", }, TargetServiceAccounts: []string{ "target-sa@test-project.iam.gserviceaccount.com", }, Allowed: []*compute.FirewallAllowed{ { IPProtocol: "tcp", Ports: []string{"80", "443"}, }, }, } firewallList := &compute.FirewallList{ Items: []*compute.Firewall{firewall}, } sdpItemType := gcpshared.ComputeFirewall expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/firewalls/%s", projectID, firewallName): { StatusCode: http.StatusOK, Body: firewall, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/firewalls", projectID): { StatusCode: http.StatusOK, Body: firewallList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, firewallName, true) if err != nil { t.Fatalf("Failed to get firewall: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != firewallName { t.Errorf("Expected unique attribute value '%s', got %s", firewallName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // network ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { // sourceServiceAccounts ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, { // targetServiceAccounts ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "target-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list firewalls: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 firewall, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/firewalls/%s", projectID, firewallName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Firewall not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, firewallName, true) if err == nil { t.Error("Expected error when getting non-existent firewall, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-global-address.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Global (external) IP address allocated at the project level. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/global/addresses/{address} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/global/addresses var computeGlobalAddressAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.ComputeGlobalAddress, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/addresses/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/addresses", ), // The list response uses the key "addresses" for items. UniqueAttributeKeys: []string{"addresses"}, IAMPermissions: []string{ // Permissions required to read global addresses (Compute Engine) "compute.addresses.get", "compute.addresses.list", }, PredefinedRole: "roles/compute.viewer", // HEALTH: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses#Status => status // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items }, linkRules: map[string]*gcpshared.Impact{ "subnetwork": gcpshared.ComputeSubnetworkImpactInOnly, "network": gcpshared.ComputeNetworkImpactInOnly, "address": gcpshared.IPImpactBothWays, "ipCollection": { ToSDPItemType: gcpshared.ComputePublicDelegatedPrefix, Description: "If the Public Delegated Prefix is deleted or updated: The Global Address may fail to reserve IP addresses from the prefix. If the Global Address is updated: The Public Delegated Prefix remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_global_address", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_global_address.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-global-address_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeGlobalAddress(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() addressName := "test-global-address" globalAddress := &computepb.Address{ Name: &addressName, Description: new("Test global address for load balancer"), Address: new("203.0.113.12"), AddressType: new("EXTERNAL"), Status: new("RESERVED"), Network: new("global/networks/test-network"), Labels: map[string]string{ "env": "test", "team": "networking", }, Region: new("global"), NetworkTier: new("PREMIUM"), CreationTimestamp: new("2023-01-15T10:30:00.000-08:00"), Id: new(uint64(1234567890123456789)), Kind: new("compute#globalAddress"), SelfLink: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/addresses/%s", projectID, addressName)), } // Create a second global address for list testing addressName2 := "test-global-address-2" globalAddress2 := &computepb.Address{ Name: &addressName2, Description: new("Second test global address"), Address: new("203.0.113.13"), AddressType: new("EXTERNAL"), Status: new("RESERVED"), Network: new("global/networks/test-network-2"), Labels: map[string]string{ "env": "prod", "team": "networking", }, Region: new("global"), NetworkTier: new("PREMIUM"), CreationTimestamp: new("2023-01-16T11:45:00.000-08:00"), Id: new(uint64(1234567890123456790)), Kind: new("compute#globalAddress"), SelfLink: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/addresses/%s", projectID, addressName2)), } globalAddresses := &computepb.AddressList{ Items: []*computepb.Address{globalAddress, globalAddress2}, } sdpItemType := gcpshared.ComputeGlobalAddress expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/addresses/%s", projectID, addressName): { StatusCode: http.StatusOK, Body: globalAddress, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/addresses", projectID): { StatusCode: http.StatusOK, Body: globalAddresses, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := addressName sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get global address: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", addressName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } if val != addressName { t.Errorf("Expected name field to be '%s', got %s", addressName, val) } val, err = sdpItem.GetAttributes().Get("description") if err != nil { t.Fatalf("Failed to get 'description' attribute: %v", err) } if val != "Test global address for load balancer" { t.Errorf("Expected description field to be 'Test global address for load balancer', got %s", val) } val, err = sdpItem.GetAttributes().Get("address") if err != nil { t.Fatalf("Failed to get 'address' attribute: %v", err) } if val != "203.0.113.12" { t.Errorf("Expected address field to be '203.0.113.12', got %s", val) } val, err = sdpItem.GetAttributes().Get("addressType") if err != nil { t.Fatalf("Failed to get 'addressType' attribute: %v", err) } if val != "EXTERNAL" { t.Errorf("Expected addressType field to be 'EXTERNAL', got %s", val) } val, err = sdpItem.GetAttributes().Get("status") if err != nil { t.Fatalf("Failed to get 'status' attribute: %v", err) } if val != "RESERVED" { t.Errorf("Expected status field to be 'RESERVED', got %s", val) } val, err = sdpItem.GetAttributes().Get("network") if err != nil { t.Fatalf("Failed to get 'network' attribute: %v", err) } if val != "global/networks/test-network" { t.Errorf("Expected network field to be 'global/networks/test-network', got %s", val) } val, err = sdpItem.GetAttributes().Get("networkTier") if err != nil { t.Fatalf("Failed to get 'networkTier' attribute: %v", err) } if val != "PREMIUM" { t.Errorf("Expected networkTier field to be 'PREMIUM', got %s", val) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.12", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(gcpshared.ComputeGlobalAddress, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter is not a ListableAdapter") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list global addresses: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 global addresses, got %d", len(sdpItems)) } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-global-forwarding-rule.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Global Forwarding Rule (project-level) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/global/forwardingRules/{forwardingRule} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/global/forwardingRules var computeGlobalForwardingRuleAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.ComputeGlobalForwardingRule, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/forwardingRules/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/forwardingRules", ), // Path segment used for lookups: forwardingRules UniqueAttributeKeys: []string{"forwardingRules"}, IAMPermissions: []string{ // Same permission set as regional forwarding rules "compute.forwardingRules.get", "compute.forwardingRules.list", }, PredefinedRole: "roles/compute.viewer", // HEALTH: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules#Status => pscConnectionStatus // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items }, linkRules: map[string]*gcpshared.Impact{ // Network reference (global). If the network is changed it may impact the forwarding rule; forwarding rule updates don't impact the network. "network": gcpshared.ComputeNetworkImpactInOnly, "subnetwork": gcpshared.ComputeSubnetworkImpactInOnly, // IP address assigned to the forwarding rule (may be ephemeral or static). "IPAddress": gcpshared.IPImpactBothWays, // Backend service (global) tightly coupled for traffic delivery. "backendService": { ToSDPItemType: gcpshared.ComputeBackendService, Description: "If the Backend Service is updated or deleted: The forwarding rule routing behavior changes or breaks. If the forwarding rule is updated or deleted: Traffic will stop or be re-routed affecting the backend service load.", }, // Target resource (polymorphic - can be TargetHttpProxy, TargetHttpsProxy, TargetTcpProxy, TargetSslProxy, TargetPool, TargetVpnGateway, or TargetInstance). // The ForwardingRuleTargetLinker function determines the actual target type from the URI. "target": { ToSDPItemType: gcpshared.ComputeTargetHttpProxy, // Default type, but ForwardingRuleTargetLinker will determine actual type from URI Description: "If the target resource (proxy, pool, gateway, or instance) is updated or deleted: The forwarding rule routing behavior changes or breaks. If the forwarding rule is updated or deleted: Traffic will stop or be re-routed affecting the target resource.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_global_forwarding_rule", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_global_forwarding_rule.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-global-forwarding-rule_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeGlobalForwardingRule(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() forwardingRuleName := "test-global-forwarding-rule" // Mock response for a global forwarding rule using protobuf types globalForwardingRule := &computepb.ForwardingRule{ Id: new(uint64(1234567890123456789)), CreationTimestamp: new("2023-01-01T00:00:00.000-08:00"), Name: new(forwardingRuleName), Description: new("Test global forwarding rule"), Region: new(""), IPAddress: new("203.0.113.1"), IPProtocol: new("TCP"), PortRange: new("80"), Target: new(fmt.Sprintf("projects/%s/global/targetHttpProxies/test-target-proxy", projectID)), SelfLink: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/forwardingRules/%s", projectID, forwardingRuleName)), LoadBalancingScheme: new("EXTERNAL"), Subnetwork: new(fmt.Sprintf("projects/%s/regions/us-central1/subnetworks/test-subnet", projectID)), Network: new(fmt.Sprintf("projects/%s/global/networks/default", projectID)), BackendService: new(fmt.Sprintf("projects/%s/global/backendServices/test-backend-service", projectID)), ServiceLabel: new("test-service"), ServiceName: new(fmt.Sprintf("%s-test-service.c.%s.internal", forwardingRuleName, projectID)), Kind: new("compute#forwardingRule"), LabelFingerprint: new("42WmSpB8rSM="), Labels: map[string]string{ "env": "test", "team": "devops", }, NetworkTier: new("PREMIUM"), AllowGlobalAccess: new(false), AllowPscGlobalAccess: new(false), PscConnectionId: nil, PscConnectionStatus: new("ACCEPTED"), Fingerprint: new("abcd1234efgh5678"), } // Mock response for a second global forwarding rule using protobuf types globalForwardingRule2 := &computepb.ForwardingRule{ Id: new(uint64(9876543210987654321)), CreationTimestamp: new("2023-01-02T00:00:00.000-08:00"), Name: new("test-global-forwarding-rule-2"), Description: new("Second test global forwarding rule"), Region: new(""), IPAddress: new("203.0.113.2"), IPProtocol: new("TCP"), PortRange: new("443"), Target: new(fmt.Sprintf("projects/%s/global/targetHttpsProxies/test-target-proxy-2", projectID)), SelfLink: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/forwardingRules/test-global-forwarding-rule-2", projectID)), LoadBalancingScheme: new("EXTERNAL"), Subnetwork: new(fmt.Sprintf("projects/%s/regions/us-west1/subnetworks/test-subnet-2", projectID)), Network: new(fmt.Sprintf("projects/%s/global/networks/custom-network", projectID)), BackendService: new(fmt.Sprintf("projects/%s/global/backendServices/test-backend-service-2", projectID)), ServiceLabel: new("test-service-2"), ServiceName: new("test-global-forwarding-rule-2-test-service-2.c." + projectID + ".internal"), Kind: new("compute#forwardingRule"), LabelFingerprint: new("xyz789abc123def="), Labels: map[string]string{ "env": "prod", "service": "web", }, NetworkTier: new("PREMIUM"), AllowGlobalAccess: new(true), AllowPscGlobalAccess: new(true), PscConnectionId: new(uint64(123)), PscConnectionStatus: new("ACCEPTED"), Fingerprint: new("xyz789abc123def456"), } // Mock response for list operation using protobuf types globalForwardingRulesList := &computepb.ForwardingRuleList{ Kind: new("compute#forwardingRuleList"), Id: new("projects/" + projectID + "/global/forwardingRules"), Items: []*computepb.ForwardingRule{globalForwardingRule, globalForwardingRule2}, SelfLink: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/forwardingRules", projectID)), } sdpItemType := gcpshared.ComputeGlobalForwardingRule expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/forwardingRules/%s", projectID, forwardingRuleName): { StatusCode: http.StatusOK, Body: globalForwardingRule, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/forwardingRules", projectID): { StatusCode: http.StatusOK, Body: globalForwardingRulesList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := forwardingRuleName sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get global forwarding rule: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", forwardingRuleName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Test specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } if val != forwardingRuleName { t.Errorf("Expected name field to be '%s', got %s", forwardingRuleName, val) } val, err = sdpItem.GetAttributes().Get("description") if err != nil { t.Fatalf("Failed to get 'description' attribute: %v", err) } if val != "Test global forwarding rule" { t.Errorf("Expected description field to be 'Test global forwarding rule', got %s", val) } val, err = sdpItem.GetAttributes().Get("IPAddress") if err != nil { t.Fatalf("Failed to get 'ipAddress' attribute: %v", err) } if val != "203.0.113.1" { t.Errorf("Expected ipAddress field to be '203.0.113.1', got %s", val) } val, err = sdpItem.GetAttributes().Get("IPProtocol") if err != nil { t.Fatalf("Failed to get 'ipProtocol' attribute: %v", err) } if val != "TCP" { t.Errorf("Expected ipProtocol field to be 'TCP', got %s", val) } val, err = sdpItem.GetAttributes().Get("loadBalancingScheme") if err != nil { t.Fatalf("Failed to get 'loadBalancingScheme' attribute: %v", err) } if val != "EXTERNAL" { t.Errorf("Expected loadBalancingScheme field to be 'EXTERNAL', got %s", val) } val, err = sdpItem.GetAttributes().Get("network") if err != nil { t.Fatalf("Failed to get 'network' attribute: %v", err) } expectedNetwork := fmt.Sprintf("projects/%s/global/networks/default", projectID) if val != expectedNetwork { t.Errorf("Expected network field to be '%s', got %s", expectedNetwork, val) } val, err = sdpItem.GetAttributes().Get("backendService") if err != nil { t.Fatalf("Failed to get 'backendService' attribute: %v", err) } expectedBackendService := fmt.Sprintf("projects/%s/global/backendServices/test-backend-service", projectID) if val != expectedBackendService { t.Errorf("Expected backendService field to be '%s', got %s", expectedBackendService, val) } val, err = sdpItem.GetAttributes().Get("subnetwork") if err != nil { t.Fatalf("Failed to get 'subnetwork' attribute: %v", err) } expectedSubnetwork := fmt.Sprintf("projects/%s/regions/us-central1/subnetworks/test-subnet", projectID) if val != expectedSubnetwork { t.Errorf("Expected subnetwork field to be '%s', got %s", expectedSubnetwork, val) } // Test labels - check if labels exist before testing labels, err := sdpItem.GetAttributes().Get("labels") if err == nil { labelsMap, ok := labels.(map[string]any) if !ok { t.Fatalf("Expected labels to be a map[string]interface{}, got %T", labels) } if labelsMap["env"] != "test" { t.Errorf("Expected labels.env to be 'test', got %s", labelsMap["env"]) } if labelsMap["team"] != "devops" { t.Errorf("Expected labels.team to be 'devops', got %s", labelsMap["team"]) } } else { // Labels might be optional, so just log it's not present t.Logf("Labels attribute not found, which is acceptable for this test") } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-backend-service", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnet", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeTargetHttpProxy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-target-proxy", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(gcpshared.ComputeGlobalForwardingRule, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter is not a ListableAdapter") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list global forwarding rules: %v", err) } // Verify the first item firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.UniqueAttributeValue() != forwardingRuleName { t.Errorf("Expected first item unique attribute value '%s', got %s", forwardingRuleName, firstItem.UniqueAttributeValue()) } // Verify the second item secondItem := sdpItems[1] if secondItem.GetType() != sdpItemType.String() { t.Errorf("Expected second item type %s, got %s", sdpItemType.String(), secondItem.GetType()) } if secondItem.UniqueAttributeValue() != "test-global-forwarding-rule-2" { t.Errorf("Expected second item unique attribute value 'test-global-forwarding-rule-2', got %s", secondItem.UniqueAttributeValue()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with empty responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/forwardingRules/%s", projectID, forwardingRuleName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, forwardingRuleName, true) if err == nil { t.Error("Expected error when getting non-existent global forwarding rule, but got nil") } }) t.Run("EmptyList", func(t *testing.T) { // Test with empty list response using protobuf types emptyListResponse := &computepb.ForwardingRuleList{ Kind: new("compute#forwardingRuleList"), Id: new("projects/" + projectID + "/global/forwardingRules"), Items: []*computepb.ForwardingRule{}, } emptyResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/forwardingRules", projectID): { StatusCode: http.StatusOK, Body: emptyListResponse, }, } httpCli := shared.NewMockHTTPClientProvider(emptyResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter is not a ListableAdapter") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list global forwarding rules: %v", err) } if len(sdpItems) != 0 { t.Errorf("Expected 0 global forwarding rules, got %d", len(sdpItems)) } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-http-health-check.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // HTTP Health Check (global, project-level) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/httpHealthChecks/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/global/httpHealthChecks/{httpHealthCheck} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/global/httpHealthChecks var _ = registerableAdapter{ sdpType: gcpshared.ComputeHttpHealthCheck, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/httpHealthChecks/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/httpHealthChecks", ), // The list response uses the key "httpHealthChecks" for items. UniqueAttributeKeys: []string{"httpHealthChecks"}, IAMPermissions: []string{ "compute.httpHealthChecks.get", "compute.httpHealthChecks.list", }, PredefinedRole: "roles/compute.viewer", }, // HTTP health checks are referenced by backend services and target pools for health monitoring. // Updates to health checks can affect traffic distribution and service availability. linkRules: map[string]*gcpshared.Impact{ "host": gcpshared.IPImpactBothWays, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_http_health_check", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_http_health_check.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-http-health-check_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeHttpHealthCheck(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() healthCheckName := "test-health-check" // Use map since HTTPHealthCheck protobuf doesn't have Name field healthCheck := map[string]any{ "name": healthCheckName, "host": "example.com", } healthCheckName2 := "test-health-check-2" healthCheck2 := map[string]any{ "name": healthCheckName2, } healthCheckList := map[string]any{ "items": []any{healthCheck, healthCheck2}, } sdpItemType := gcpshared.ComputeHttpHealthCheck expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/httpHealthChecks/%s", projectID, healthCheckName): { StatusCode: http.StatusOK, Body: healthCheck, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/httpHealthChecks/%s", projectID, healthCheckName2): { StatusCode: http.StatusOK, Body: healthCheck2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/httpHealthChecks", projectID): { StatusCode: http.StatusOK, Body: healthCheckList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, healthCheckName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != healthCheckName { t.Errorf("Expected unique attribute value '%s', got %s", healthCheckName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { // Test that DNS names are correctly detected when using IPImpactBothWays // Even though the link rule uses stdlib.NetworkIP, it should detect // that "example.com" is a DNS name and create a DNS link queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "example.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) // Test with IP address - verify bidirectional detection works // Even though the link rule uses stdlib.NetworkIP, it should detect // that "192.168.1.1" is an IP address and create an IP link t.Run("StaticTestsWithIP", func(t *testing.T) { healthCheckWithIP := map[string]any{ "name": "test-health-check-ip", "host": "192.168.1.1", } expectedCallAndResponsesIP := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/httpHealthChecks/%s", projectID, "test-health-check-ip"): { StatusCode: http.StatusOK, Body: healthCheckWithIP, }, } httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponsesIP) adapter, err := dynamic.MakeAdapter( sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, ) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, "test-health-check-ip", true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) // Test bidirectional IP/DNS detection - verify that potential links include both t.Run("PotentialLinksBidirectional", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter( sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, ) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } metadata := adapter.Metadata() if metadata == nil { t.Fatal("Adapter metadata is nil") } // Verify that both IP and DNS are in potential links when using IPImpactBothWays // This demonstrates bidirectional behavior: even though we specify stdlib.NetworkIP // in the link rules, both IP and DNS are included in potential links potentialLinksMap := make(map[string]bool) for _, link := range metadata.GetPotentialLinks() { potentialLinksMap[link] = true } if !potentialLinksMap["ip"] { t.Error("Expected 'ip' in potential links when using IPImpactBothWays") } if !potentialLinksMap["dns"] { t.Error("Expected 'dns' in potential links when using IPImpactBothWays (bidirectional detection)") } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/httpHealthChecks/%s", projectID, healthCheckName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Health check not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, healthCheckName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-instance-template.go ================================================ package adapters import ( "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Compute Instance Template adapter for VM instance templates var _ = registerableAdapter{ sdpType: gcpshared.ComputeInstanceTemplate, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.ProjectLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/global/instanceTemplates/{instanceTemplate} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://compute.googleapis.com/compute/v1/projects/%s/global/instanceTemplates/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/global/instanceTemplates ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/global/instanceTemplates"), UniqueAttributeKeys: []string{"instanceTemplates"}, IAMPermissions: []string{"compute.instanceTemplates.get", "compute.instanceTemplates.list"}, PredefinedRole: "roles/compute.viewer", // Tag-based SEARCH: list all instance templates then filter by tag. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" || strings.Contains(query, "/") { return "" } return fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/instanceTemplates", location.ProjectID) }, SearchDescription: "Search for instance templates by network tag. The query is a plain network tag name.", SearchFilterFunc: instanceTemplateTagFilter, }, linkRules: map[string]*gcpshared.Impact{ // https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/get "properties.networkInterfaces.network": { Description: "If the network is deleted: Resources may experience connectivity changes or disruptions. If the template is deleted: Network itself is not affected.", ToSDPItemType: gcpshared.ComputeNetwork, }, "properties.networkInterfaces.subnetwork": { Description: "If the (sub)network is deleted: Resources may experience connectivity changes or disruptions. If the template is updated: Subnetwork itself is not affected.", ToSDPItemType: gcpshared.ComputeSubnetwork, }, "properties.networkInterfaces.networkIP": { Description: "IP address are always tightly coupled with the Compute Instance Template.", ToSDPItemType: stdlib.NetworkIP, }, "properties.networkInterfaces.ipv6Address": gcpshared.IPImpactBothWays, "properties.networkInterfaces.accessConfigs.natIP": gcpshared.IPImpactBothWays, "properties.networkInterfaces.accessConfigs.externalIpv6": gcpshared.IPImpactBothWays, "properties.networkInterfaces.accessConfigs.securityPolicy": gcpshared.SecurityPolicyImpactInOnly, "properties.networkInterfaces.ipv6AccessConfigs.natIP": gcpshared.IPImpactBothWays, "properties.networkInterfaces.ipv6AccessConfigs.externalIpv6": gcpshared.IPImpactBothWays, "properties.networkInterfaces.ipv6AccessConfigs.securityPolicy": gcpshared.SecurityPolicyImpactInOnly, "properties.disks.source": { Description: "If the Compute Disk is updated: Instance creation may fail or behave unexpectedly. If the template is deleted: Existing disks can be deleted.", ToSDPItemType: gcpshared.ComputeDisk, }, "properties.disks.initializeParams.diskName": { Description: "If the Compute Disk is updated: Instance creation may fail or behave unexpectedly. If the template is deleted: Existing disks can be deleted.", ToSDPItemType: gcpshared.ComputeDisk, }, "properties.disks.initializeParams.sourceImage": { Description: "If the Compute Image is updated: Instances created from this template may not boot correctly. If the template is updated: Image is not affected.", ToSDPItemType: gcpshared.ComputeImage, }, "properties.disks.initializeParams.diskType": { Description: "If the Compute Disk Type is updated: New instances may fail to provision disks properly. If the template is updated: Disk type is not affected.", ToSDPItemType: gcpshared.ComputeDiskType, }, "properties.disks.initializeParams.sourceImageEncryptionKey.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "properties.disks.initializeParams.sourceImageEncryptionKey.kmsKeyServiceAccount": gcpshared.IAMServiceAccountImpactInOnly, "properties.disks.initializeParams.sourceSnapshot": { Description: "If the Compute Snapshot is updated: The template may reference an invalid or incompatible snapshot. If the template is updated: no impact on snapshots.", ToSDPItemType: gcpshared.ComputeSnapshot, }, "properties.disks.initializeParams.sourceSnapshotEncryptionKey.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "properties.disks.initializeParams.sourceSnapshotEncryptionKey.kmsKeyServiceAccount": gcpshared.IAMServiceAccountImpactInOnly, "properties.disks.initializeParams.resourcePolicies": gcpshared.ResourcePolicyImpactInOnly, "properties.disks.initializeParams.storagePool": { Description: "If the Compute Storage Pool is deleted: Disk provisioning for new instances may fail. If the template is updated: Pool is not affected.", ToSDPItemType: gcpshared.ComputeStoragePool, }, "properties.disks.diskEncryptionKey.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "properties.disks.diskEncryptionKey.kmsKeyServiceAccount": gcpshared.IAMServiceAccountImpactInOnly, "properties.guestAccelerators.acceleratorType": { Description: "If the Compute Accelerator Type is updated: New instances may misconfigure or fail hardware initialization. If the template is updated: Accelerator is not affected.", ToSDPItemType: gcpshared.ComputeAcceleratorType, }, "sourceInstance": { Description: "If the Compute Instance is updated: The template may reference an invalid or incompatible instance. If the template is deleted: The instance remains unaffected.", ToSDPItemType: gcpshared.ComputeInstance, }, "sourceInstanceParams.diskConfigs.customImage": { Description: "If the Compute Image is updated: Instances created from this template may not boot correctly. If the template is updated: Image is not affected.", ToSDPItemType: gcpshared.ComputeImage, }, "properties.networkInterfaces.networkAttachment": { Description: "If the Compute Network Attachment is updated: Instances using the template may lose access to the network services. If the template is deleted: Attachment is not affected.", ToSDPItemType: gcpshared.ComputeNetworkAttachment, }, "properties.disks.initializeParams.licenses": { Description: "If the Compute License is updated: New instances may violate license agreements or lose functionality. If the template is updated: License remains unaffected.", ToSDPItemType: gcpshared.ComputeLicense, }, "properties.disks.licenses": { Description: "If the Compute License is updated: New instances may violate license agreements or lose functionality. If the template is updated: License remains unaffected.", ToSDPItemType: gcpshared.ComputeLicense, }, "properties.reservationAffinity.values": { Description: "If the Compute Reservation is updated: new instances created using it may fail to launch. If the template is updated: no impacts on reservation.", ToSDPItemType: gcpshared.ComputeReservation, }, "properties.scheduling.nodeAffinities.values": { Description: "If the Compute Node Group is updated: Placement policies may break for new VMs. If the template is updated: Node affinity rules may change. Changing the affinity might cause new VMs to stop using that Node Group", ToSDPItemType: gcpshared.ComputeNodeGroup, }, "properties.serviceAccounts.email": { Description: "If the IAM Service Account is deleted or updated: Instances created from this template may fail to authenticate or access required resources. If the template is updated: The service account remains unaffected.", ToSDPItemType: gcpshared.IAMServiceAccount, }, "properties.tags.items": { Description: "Instance templates define network tags that will be applied to instances created from the template. Overmind discovers firewall rules and routes with matching tags, showing how firewall and route changes will affect instances created from this template.", ToSDPItemType: gcpshared.ComputeFirewall, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_instance_template", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_instance_template.name", }, }, }, }.Register() // instanceTemplateTagFilter keeps instance templates whose properties.tags.items contain the query tag. func instanceTemplateTagFilter(query string, item *sdp.Item) bool { return itemAttributeContainsTag(item, "properties.tags.items", query) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-instance-template_test.go ================================================ package adapters import ( "context" "net/http" "testing" "time" "github.com/stretchr/testify/assert" "google.golang.org/api/compute/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type SearchStreamAdapter interface { SearchStream(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) } type ListStreamAdapter interface { ListStream(ctx context.Context, scope string, ignoreCache bool, stream discovery.QueryResultStream) } func TestComputeInstanceTemplate(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() // Create a template object template := &compute.InstanceTemplate{ Id: 123456789, Name: "test-instance-template", Description: "Test instance template", Properties: &compute.InstanceProperties{ MachineType: "e2-medium", Disks: []*compute.AttachedDisk{ { Boot: true, DeviceName: "boot-disk", InitializeParams: &compute.AttachedDiskInitializeParams{ DiskName: "projects/test-project/zones/us-central1-a/disks/disk-name", DiskType: "projects/test-project/zones/us-central1-a/diskTypes/pd-standard", SourceImage: "projects/debian-cloud/global/images/family/debian-11", SourceSnapshot: "projects/test-project/global/snapshots/my-snapshot", ResourcePolicies: []string{"projects/test-project/regions/us-central1/resourcePolicies/my-resource-policy"}, StoragePool: "projects/test-project/zones/us-central1-a/storagePools/my-storage-pool", Licenses: []string{"https://www.googleapis.com/compute/v1/projects/test-project/global/licenses/debian-11-bullseye-init-param"}, SourceImageEncryptionKey: &compute.CustomerEncryptionKey{ KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/source-image-encryption-key", KmsKeyServiceAccount: "source-image-encryption-key-service-account@test-project.iam.gserviceaccount.com", }, SourceSnapshotEncryptionKey: &compute.CustomerEncryptionKey{ KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/source-snapshot-encryption-key", KmsKeyServiceAccount: "source-snapshot-encryption-key-service-account@test-project.iam.gserviceaccount.com", }, }, Source: "projects/test-project/zones/us-central1-a/disks/source", Licenses: []string{"https://www.googleapis.com/compute/v1/projects/test-project/global/licenses/debian-11-bullseye-disk"}, DiskEncryptionKey: &compute.CustomerEncryptionKey{ KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/disk-encryption-key", KmsKeyServiceAccount: "disk-encryption-key-service-account@test-project.iam.gserviceaccount.com", }, }, }, NetworkInterfaces: []*compute.NetworkInterface{ { Network: "global/networks/default", Subnetwork: "regions/us-central1/subnetworks/default", NetworkIP: "10.240.17.92", Ipv6Address: "2600:1901:0:1234::1", AccessConfigs: []*compute.AccessConfig{ { NatIP: "10.240.17.93", ExternalIpv6: "2600:1901:0:1234::2", SecurityPolicy: "projects/test-project/global/securityPolicies/test-security-policy", }, }, Ipv6AccessConfigs: []*compute.AccessConfig{ { NatIP: "10.240.17.94", ExternalIpv6: "2600:1901:0:1234::3", SecurityPolicy: "projects/test-project/global/securityPolicies/test-security-policy-ipv6", }, }, }, }, GuestAccelerators: []*compute.AcceleratorConfig{ { AcceleratorType: "projects/test-project/zones/us-central1-a/acceleratorTypes/nvidia-tesla-t4", AcceleratorCount: 1, }, }, Scheduling: &compute.Scheduling{ NodeAffinities: []*compute.SchedulingNodeAffinity{ { Key: "compute.googleapis.com/node-group-name", Operator: "IN", Values: []string{"projects/test-project/zones/us-central1-a/nodeGroups/my-node-group"}, }, }, }, ReservationAffinity: &compute.ReservationAffinity{ ConsumeReservationType: "SPECIFIC_RESERVATION", Key: "compute.googleapis.com/reservation-name", Values: []string{"my-reservation"}, }, }, SelfLink: "https://compute.googleapis.com/compute/v1/projects/test-project/global/instanceTemplates/test-instance-template", } sizeOfFirstPage := 100 sizeOfLastPage := 1 templatesWithNextPage := &compute.InstanceTemplateList{ Items: dynamic.Multiply(template, sizeOfFirstPage), NextPageToken: "next-page-token", } templates := &compute.InstanceTemplateList{ Items: dynamic.Multiply(template, sizeOfLastPage), } expectedCallAndResponses := map[string]shared.MockResponse{ "https://compute.googleapis.com/compute/v1/projects/test-project/global/instanceTemplates/test-instance-template": { StatusCode: http.StatusOK, Body: template, }, "https://compute.googleapis.com/compute/v1/projects/test-project/global/instanceTemplates": { StatusCode: http.StatusOK, Body: templatesWithNextPage, }, "https://compute.googleapis.com/compute/v1/projects/test-project/global/instanceTemplates?pageToken=next-page-token": { StatusCode: http.StatusOK, Body: templates, }, } t.Run("Get", func(t *testing.T) { adapter, err := dynamic.MakeAdapter(gcpshared.ComputeInstanceTemplate, linker, shared.NewMockHTTPClientProvider(expectedCallAndResponses), sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for ComputeInstanceTemplate: %v", err) } sdpItem, err := adapter.Get(ctx, projectID, "test-instance-template", true) if err != nil { t.Fatalf("Failed to get instance template: %v", err) } // Verify the returned item if sdpItem.GetType() != gcpshared.ComputeInstanceTemplate.String() { t.Errorf("Expected type %s, got %s", gcpshared.ComputeInstanceTemplate.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != "test-instance-template" { t.Errorf("Expected unique attribute value 'test-instance-template', got %s", sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { // properties.disks.initializeParams.diskName ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "disk-name", ExpectedScope: "test-project.us-central1-a", }, { // properties.disks.source ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source", ExpectedScope: "test-project.us-central1-a", }, { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "projects/debian-cloud/global/images/family/debian-11", ExpectedScope: "debian-cloud", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: "test-project.us-central1", }, { // properties.networkInterfaces.networkIP ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.240.17.92", ExpectedScope: "global", }, { // properties.networkInterfaces.ipv6Address ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2600:1901:0:1234::1", ExpectedScope: "global", }, { // properties.networkInterfaces.accessConfigs.natIP ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.240.17.93", ExpectedScope: "global", }, { // properties.networkInterfaces.accessConfigs.externalIpv6 ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2600:1901:0:1234::2", ExpectedScope: "global", }, { // properties.networkInterfaces.accessConfigs.securityPolicy ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy", ExpectedScope: projectID, }, { // properties.networkInterfaces.ipv6AccessConfigs.natIP ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.240.17.94", ExpectedScope: "global", }, { // properties.networkInterfaces.ipv6AccessConfigs.externalIpv6 ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2600:1901:0:1234::3", ExpectedScope: "global", }, { // properties.networkInterfaces.ipv6AccessConfigs.securityPolicy ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy-ipv6", ExpectedScope: projectID, }, { // properties.disks.initializeParams.sourceSnapshot ExpectedType: gcpshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-snapshot", ExpectedScope: projectID, }, { // properties.disks.initializeParams.resourcePolicies ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-resource-policy", ExpectedScope: "test-project.us-central1", }, { // properties.disks.initializeParams.storagePool ExpectedType: gcpshared.ComputeStoragePool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-storage-pool", ExpectedScope: "test-project.us-central1-a", }, { // properties.disks.initializeParams.licenses ExpectedType: gcpshared.ComputeLicense.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "debian-11-bullseye-init-param", ExpectedScope: projectID, }, { // properties.disks.initializeParams.licenses ExpectedType: gcpshared.ComputeLicense.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "debian-11-bullseye-disk", ExpectedScope: projectID, }, { // properties.disks.initializeParams.sourceImageEncryptionKey.kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|my-keyring|source-image-encryption-key", ExpectedScope: projectID, }, { // properties.disks.initializeParams.sourceImageEncryptionKey.kmsKeyServiceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-image-encryption-key-service-account@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, { // properties.guestAccelerators.acceleratorType ExpectedType: gcpshared.ComputeAcceleratorType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "nvidia-tesla-t4", ExpectedScope: "test-project.us-central1-a", }, { // properties.scheduling.nodeAffinities.values ExpectedType: gcpshared.ComputeNodeGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-node-group", ExpectedScope: "test-project.us-central1-a", }, { // properties.reservationAffinity.values ExpectedType: gcpshared.ComputeReservation.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my-reservation", ExpectedScope: projectID, }, { // properties.disks.initializeParams.diskType ExpectedType: gcpshared.ComputeDiskType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pd-standard", ExpectedScope: "test-project.us-central1-a", }, { // properties.disks.initializeParams.sourceSnapshotEncryptionKey.kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|my-keyring|source-snapshot-encryption-key", ExpectedScope: projectID, }, { // properties.disks.initializeParams.sourceSnapshotEncryptionKey.kmsKeyServiceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-snapshot-encryption-key-service-account@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, { // properties.disks.diskEncryptionKey.kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|my-keyring|disk-encryption-key", ExpectedScope: projectID, }, { // properties.disks.diskEncryptionKey.kmsKeyServiceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "disk-encryption-key-service-account@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { adapter, err := dynamic.MakeAdapter(gcpshared.ComputeInstanceTemplate, linker, shared.NewMockHTTPClientProvider(expectedCallAndResponses), sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for ComputeInstanceTemplate: %v", err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter is not a ListableAdapter") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list instance templatesWithNextPage: %v", err) } expectedItemCount := sizeOfFirstPage + sizeOfLastPage if len(sdpItems) != expectedItemCount { t.Errorf("Expected %d instance template, got %d", expectedItemCount, len(sdpItems)) } }) t.Run("ListStream", func(t *testing.T) { adapter, err := dynamic.MakeAdapter(gcpshared.ComputeInstanceTemplate, linker, shared.NewMockHTTPClientProvider(expectedCallAndResponses), sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for ComputeInstanceTemplate: %v", err) } expectedItemCount := sizeOfFirstPage + sizeOfLastPage items := make(chan *sdp.Item, expectedItemCount) t.Cleanup(func() { close(items) }) itemHandler := func(item *sdp.Item) { time.Sleep(10 * time.Millisecond) items <- item } errHandler := func(err error) { if err != nil { t.Fatalf("Unexpected error in stream: %v", err) } } listStreamable, ok := adapter.(ListStreamAdapter) if !ok { t.Fatalf("Adapter is not a ListStreamAdapter") } stream := discovery.NewQueryResultStream(itemHandler, errHandler) listStreamable.ListStream(ctx, projectID, true, stream) assert.Eventually(t, func() bool { return len(items) == expectedItemCount }, 5*time.Second, 100*time.Millisecond, "Expected to receive all items in the stream") }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-license.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute License adapter for software licenses var _ = registerableAdapter{ sdpType: gcpshared.ComputeLicense, meta: gcpshared.AdapterMeta{ InDevelopment: true, // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/licenses/get SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/global/licenses/{license} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://compute.googleapis.com/compute/v1/projects/%s/global/licenses/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/global/licenses ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/global/licenses"), UniqueAttributeKeys: []string{"licenses"}, // compute.licenses.list is only supported at TESTING stage. // Which means it can behave unexpectedly, and not recommended for production use. // https://cloud.google.com/iam/docs/custom-roles-permissions-support // TODO: Decide whether to support this officially or not. IAMPermissions: []string{"compute.licenses.get", "compute.licenses.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{}, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-network-endpoint-group.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Network Endpoint Group (NEG) zonal resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/networkEndpointGroups/{networkEndpointGroup} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/networkEndpointGroups var _ = registerableAdapter{ sdpType: gcpshared.ComputeNetworkEndpointGroup, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ZonalLevel, GetEndpointFunc: gcpshared.ZoneLevelEndpointFunc( "https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups/%s", ), ListEndpointFunc: gcpshared.ZoneLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups", ), // The list response uses the key "networkEndpointGroups" for items. UniqueAttributeKeys: []string{"networkEndpointGroups"}, IAMPermissions: []string{ "compute.networkEndpointGroups.get", "compute.networkEndpointGroups.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Parent VPC network reference (changes to network can impact NEG reachability; NEG changes do not impact network) "network": gcpshared.ComputeNetworkImpactInOnly, // Subnetwork reference (regional) – subnetwork changes can affect endpoints, NEG changes do not affect subnetwork "subnetwork": { ToSDPItemType: gcpshared.ComputeSubnetwork, Description: "If the Compute Subnetwork is updated: Endpoint reachability or configuration for the NEG may change. If the NEG is updated: The subnetwork remains unaffected.", }, // Serverless NEG referencing a Cloud Run Service "cloudRun.service": { ToSDPItemType: gcpshared.RunService, Description: "If the Cloud Run Service is updated or deleted: Requests routed via the NEG may fail or change behavior. If the NEG changes: The Cloud Run service remains unaffected.", }, // Serverless NEG referencing an App Engine service "appEngine.service": { ToSDPItemType: gcpshared.AppEngineService, Description: "If the App Engine Service is updated or deleted: Requests routed via the NEG may fail or change behavior. If the NEG changes: The App Engine service remains unaffected.", }, // Serverless NEG referencing a Cloud Function "cloudFunction.function": { ToSDPItemType: gcpshared.CloudFunctionsFunction, Description: "If the Cloud Function is updated or deleted: Requests routed via the NEG may fail or change behavior. If the NEG changes: The Cloud Function remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_network_endpoint_group", Mappings: []*sdp.TerraformMapping{{ TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_network_endpoint_group.name", }}, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-network-endpoint-group_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeNetworkEndpointGroup(t *testing.T) { ctx := context.Background() projectID := "test-project" zone := "us-central1-a" linker := gcpshared.NewLinker() negName := "test-neg" networkURL := fmt.Sprintf("projects/%s/global/networks/default", projectID) subnetworkURL := fmt.Sprintf("projects/%s/regions/us-central1/subnetworks/default", projectID) cloudRunService := fmt.Sprintf("projects/%s/locations/us-central1/services/test-cloud-run-service", projectID) appEngineService := "test-app-engine-service" cloudFunctionName := fmt.Sprintf("projects/%s/locations/us-central1/functions/test-cloud-function", projectID) neg := &computepb.NetworkEndpointGroup{ Name: &negName, Network: &networkURL, Subnetwork: &subnetworkURL, CloudRun: &computepb.NetworkEndpointGroupCloudRun{ Service: &cloudRunService, }, AppEngine: &computepb.NetworkEndpointGroupAppEngine{ Service: &appEngineService, }, CloudFunction: &computepb.NetworkEndpointGroupCloudFunction{ Function: &cloudFunctionName, }, } negName2 := "test-neg-2" neg2 := &computepb.NetworkEndpointGroup{ Name: &negName2, } negList := &computepb.NetworkEndpointGroupList{ Items: []*computepb.NetworkEndpointGroup{neg, neg2}, } sdpItemType := gcpshared.ComputeNetworkEndpointGroup expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups/%s", projectID, zone, negName): { StatusCode: http.StatusOK, Body: neg, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups/%s", projectID, zone, negName2): { StatusCode: http.StatusOK, Body: neg2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups", projectID, zone): { StatusCode: http.StatusOK, Body: negList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, zone), negName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != negName { t.Errorf("Expected unique attribute value '%s', got %s", negName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // Subnetwork link { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, // Cloud Run service link { ExpectedType: gcpshared.RunService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us-central1", "test-cloud-run-service"), ExpectedScope: projectID, }, // Note: App Engine service link test omitted because gcp-app-engine-service adapter doesn't exist yet // Cloud Function link { ExpectedType: gcpshared.CloudFunctionsFunction.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us-central1", "test-cloud-function"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, zone), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups/%s", projectID, zone, negName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "NEG not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, zone), negName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-network.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Network adapter for VPC networks var _ = registerableAdapter{ sdpType: gcpshared.ComputeNetwork, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/global/networks/{network} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://compute.googleapis.com/compute/v1/projects/%s/global/networks/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/global/networks ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/global/networks"), UniqueAttributeKeys: []string{"networks"}, IAMPermissions: []string{"compute.networks.get", "compute.networks.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "gatewayIPv4": gcpshared.IPImpactBothWays, "subnetworks": { Description: "If the Compute Subnetwork is deleted: The network remains unaffected, but its subnetwork configuration may change. If the network is deleted: All associated subnetworks are also deleted.", ToSDPItemType: gcpshared.ComputeSubnetwork, }, "peerings.network": { Description: "If the Compute Network Peering is deleted: The network remains unaffected, but its peering configuration may change. If the network is deleted: All associated peerings are also deleted.", ToSDPItemType: gcpshared.ComputeNetwork, }, "firewallPolicy": { Description: "If the Compute Firewall Policy is updated: The network's security posture may change. If the network is updated: The firewall policy remains unaffected, but its application to the network may change.", ToSDPItemType: gcpshared.ComputeFirewallPolicy, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_network", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_network.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-network_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/compute/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeNetwork(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() networkName := "test-network" network := &compute.Network{ Name: networkName, GatewayIPv4: "10.0.0.1", Subnetworks: []string{ "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-central1/subnetworks/default", }, Peerings: []*compute.NetworkPeering{ { Network: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/peer-network", }, }, } networkList := &compute.NetworkList{ Items: []*compute.Network{network}, } sdpItemType := gcpshared.ComputeNetwork expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/networks/%s", projectID, networkName): { StatusCode: http.StatusOK, Body: network, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/networks", projectID): { StatusCode: http.StatusOK, Body: networkList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, networkName, true) if err != nil { t.Fatalf("Failed to get network: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != networkName { t.Errorf("Expected unique attribute value '%s', got %s", networkName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // gatewayIPv4 ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { // subnetworks ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: "test-project.us-central1", }, { // peerings.network ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "peer-network", ExpectedScope: projectID, }, // TODO: Add test for firewallPolicy → ComputeFirewallPolicy // Requires ComputeFirewallPolicy adapter to be implemented first } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list networks: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 network, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/networks/%s", projectID, networkName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Network not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, networkName, true) if err == nil { t.Error("Expected error when getting non-existent network, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-project.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Project adapter for Compute Engine project metadata var _ = registerableAdapter{ sdpType: gcpshared.ComputeProject, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/projects/get SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // https://compute.googleapis.com/compute/v1/projects/{project} /* https://cloud.google.com/compute/docs/reference/rest/v1/projects/get To decrease latency for this method, you can optionally omit any unneeded information from the response by using a field mask. This practice is especially recommended for unused quota information (the quotas field). To exclude one or more fields, set your request's fields query parameter to only include the fields you need. For example, to only include the id and selfLink fields, add the query parameter ?fields=id,selfLink to your request. */ // We only need the name field for this adapter // This resource won't carry any attributes to link it to other resources. // It will always be a linked item from the other resources by its name. // Note: This adapter uses the query as the project ID, and validates it // against the adapter's configured project via location.ProjectID. GetEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" { return "" } if query != location.ProjectID { return "" } return fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s?fields=name", query) }, UniqueAttributeKeys: []string{"projects"}, IAMPermissions: []string{"compute.projects.get"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "defaultServiceAccount": { Description: "If the IAM Service Account is deleted: Project resources may fail to work as before. If the project is deleted: service account is deleted.", ToSDPItemType: gcpshared.IAMServiceAccount, }, "usageExportLocation.bucketName": { Description: "If the Compute Bucket is deleted: Project usage export may fail. If the project is deleted: bucket is deleted.", ToSDPItemType: gcpshared.StorageBucket, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project", Description: "Maps google_project, Shared VPC, and project IAM resources to the Compute Project adapter.", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_project.project_id", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_shared_vpc_host_project.project", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_shared_vpc_service_project.service_project", }, { // Host project is also affected when the attachment is created/destroyed. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_shared_vpc_service_project.host_project", }, // IAM resources for Projects. These are Terraform-only constructs // (no standalone GCP API resource exists). When an IAM binding/member/policy // changes, we resolve it to the parent project for blast radius analysis. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project_iam { // Authoritative for a given role — grants the role to a list of members. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_project_iam_binding.project", }, { // Non-authoritative — grants a single member a single role. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_project_iam_member.project", }, { // Authoritative for the entire IAM policy on the project. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_project_iam_policy.project", }, { // Configures which services and log types are audited for the project. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_project_iam_audit_config.project", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-project_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/compute/v1" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeProject(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() project := &compute.Project{ Name: projectID, DefaultServiceAccount: "default-sa@test-project.iam.gserviceaccount.com", UsageExportLocation: &compute.UsageExportLocation{ BucketName: "usage-export-bucket", }, } sdpItemType := gcpshared.ComputeProject expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s?fields=name", projectID): { StatusCode: http.StatusOK, Body: project, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, projectID, true) if err != nil { t.Fatalf("Failed to get project: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // defaultServiceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, { // usageExportLocation.bucketName ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "usage-export-bucket", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s?fields=name", projectID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Project not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, projectID, true) if err == nil { t.Error("Expected error when getting non-existent project, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-public-delegated-prefix.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Public Delegated Prefix (regional) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/publicDelegatedPrefixes/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/publicDelegatedPrefixes/{publicDelegatedPrefix} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/publicDelegatedPrefixes var _ = registerableAdapter{ sdpType: gcpshared.ComputePublicDelegatedPrefix, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.RegionalLevel, GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/publicDelegatedPrefixes/%s", ), ListEndpointFunc: gcpshared.RegionLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/publicDelegatedPrefixes", ), // Provide a no-op search for terraform mapping support with full resource ID. // Expected search query: projects/{project}/regions/{region}/publicDelegatedPrefixes/{name} // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, SearchDescription: "Search with full ID: projects/[project]/regions/[region]/publicDelegatedPrefixes/[name] (used for terraform mapping).", UniqueAttributeKeys: []string{"publicDelegatedPrefixes"}, IAMPermissions: []string{ "compute.publicDelegatedPrefixes.get", "compute.publicDelegatedPrefixes.list", }, PredefinedRole: "roles/compute.viewer", // HEALTH: status (e.g., LIVE/TO_BE_DELETED) may be present on the resource // TODO: https://linear.app/overmind/issue/ENG-631 }, linkRules: map[string]*gcpshared.Impact{ // Parent Public Advertised Prefix from which this delegated prefix is allocated. "parentPrefix": { ToSDPItemType: gcpshared.ComputePublicAdvertisedPrefix, Description: "If the Public Advertised Prefix is updated or deleted: the delegated prefix may become invalid or withdrawn. If the delegated prefix changes: the parent advertised prefix remains structurally unaffected.", }, // Each sub-prefix may be delegated to a specific project. "publicDelegatedSubPrefixs.delegateeProject": { ToSDPItemType: gcpshared.CloudResourceManagerProject, Description: "If the delegatee Project is deleted or disabled: usage of the delegated sub-prefix may stop working. If the delegated prefix changes: the project resource remains unaffected.", }, "publicDelegatedSubPrefixs.name": { ToSDPItemType: gcpshared.ComputePublicDelegatedPrefix, Description: "If the delegated sub-prefix is updated or deleted: usage of the sub-prefix may stop working. If the parent delegated prefix changes: the sub-prefix remains structurally unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_public_delegated_prefix", Description: "id => projects/{{project}}/regions/{{region}}/publicDelegatedPrefixes/{{name}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_compute_public_delegated_prefix.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-public-delegated-prefix_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputePublicDelegatedPrefix(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() prefixName := "test-prefix" parentPrefixURL := fmt.Sprintf("projects/%s/global/publicAdvertisedPrefixes/test-parent-prefix", projectID) subPrefixName1 := fmt.Sprintf("projects/%s/regions/%s/publicDelegatedPrefixes/test-sub-prefix-1", projectID, region) subPrefixName2 := fmt.Sprintf("projects/%s/regions/%s/publicDelegatedPrefixes/test-sub-prefix-2", projectID, region) delegateeProject1 := "projects/delegatee-project-1" delegateeProject2 := "projects/delegatee-project-2" prefix := &computepb.PublicDelegatedPrefix{ Name: &prefixName, ParentPrefix: &parentPrefixURL, PublicDelegatedSubPrefixs: []*computepb.PublicDelegatedPrefixPublicDelegatedSubPrefix{ { Name: &subPrefixName1, DelegateeProject: &delegateeProject1, }, { Name: &subPrefixName2, DelegateeProject: &delegateeProject2, }, }, } prefixName2 := "test-prefix-2" prefix2 := &computepb.PublicDelegatedPrefix{ Name: &prefixName2, } prefixList := &computepb.PublicDelegatedPrefixList{ Items: []*computepb.PublicDelegatedPrefix{prefix, prefix2}, } sdpItemType := gcpshared.ComputePublicDelegatedPrefix expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/publicDelegatedPrefixes/%s", projectID, region, prefixName): { StatusCode: http.StatusOK, Body: prefix, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/publicDelegatedPrefixes/%s", projectID, region, prefixName2): { StatusCode: http.StatusOK, Body: prefix2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/publicDelegatedPrefixes", projectID, region): { StatusCode: http.StatusOK, Body: prefixList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), prefixName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != prefixName { t.Errorf("Expected unique attribute value '%s', got %s", prefixName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Note: Parent prefix link test omitted because gcp-compute-public-advertised-prefix adapter doesn't exist yet // Delegatee project 1 link { ExpectedType: gcpshared.CloudResourceManagerProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "delegatee-project-1", ExpectedScope: projectID, }, // Delegatee project 2 link { ExpectedType: gcpshared.CloudResourceManagerProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "delegatee-project-2", ExpectedScope: projectID, }, // Sub-prefix 1 link { ExpectedType: gcpshared.ComputePublicDelegatedPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sub-prefix-1", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // Sub-prefix 2 link { ExpectedType: gcpshared.ComputePublicDelegatedPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sub-prefix-2", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/regions/[region]/publicDelegatedPrefixes/[name] terraformQuery := fmt.Sprintf("projects/%s/regions/%s/publicDelegatedPrefixes/%s", projectID, region, prefixName) sdpItems, err := searchable.Search(ctx, fmt.Sprintf("%s.%s", projectID, region), terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != fmt.Sprintf("%s.%s", projectID, region) { t.Errorf("Expected first item scope '%s.%s', got %s", projectID, region, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/publicDelegatedPrefixes/%s", projectID, region, prefixName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Prefix not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), prefixName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-region-commitment.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var _ = registerableAdapter{ sdpType: gcpshared.ComputeRegionCommitment, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OTHER, LocationLevel: gcpshared.RegionalLevel, // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/regionCommitments/get // https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/commitments/{commitment} GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/commitments/%s"), // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/regionCommitments/list // https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/commitments ListEndpointFunc: gcpshared.RegionLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/commitments"), UniqueAttributeKeys: []string{"commitments"}, IAMPermissions: []string{"compute.commitments.get", "compute.commitments.list"}, PredefinedRole: "roles/compute.viewer", // HEALTH: https://cloud.google.com/compute/docs/reference/rest/v1/regionCommitments#Status // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items }, linkRules: map[string]*gcpshared.Impact{ "reservations.name": { ToSDPItemType: gcpshared.ComputeReservation, Description: "If the Region Commitment is deleted or updated: Reservations that reference this commitment may lose associated discounts or resource guarantees. If the Reservation is updated or deleted: The commitment remains unaffected.", }, "licenseResource.license": { ToSDPItemType: gcpshared.ComputeLicense, Description: "If the Region Commitment is deleted or updated: Licenses that reference this commitment won't be affected. If the License is updated or deleted: The commitment may lose associated discounts or resource guarantees.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_region_commitment", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_region_commitment.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-region-commitment_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeRegionCommitment(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() commitmentName := "test-commitment" reservationName := "test-reservation" licenseURL := fmt.Sprintf("projects/%s/global/licenses/test-license", projectID) commitment := &computepb.Commitment{ Name: &commitmentName, Reservations: []*computepb.Reservation{ { Name: &reservationName, }, }, LicenseResource: &computepb.LicenseResourceCommitment{ License: &licenseURL, }, } commitmentName2 := "test-commitment-2" commitment2 := &computepb.Commitment{ Name: &commitmentName2, } commitmentList := &computepb.CommitmentList{ Items: []*computepb.Commitment{commitment, commitment2}, } sdpItemType := gcpshared.ComputeRegionCommitment expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/commitments/%s", projectID, region, commitmentName): { StatusCode: http.StatusOK, Body: commitment, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/commitments/%s", projectID, region, commitmentName2): { StatusCode: http.StatusOK, Body: commitment2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/commitments", projectID, region): { StatusCode: http.StatusOK, Body: commitmentList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), commitmentName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != commitmentName { t.Errorf("Expected unique attribute value '%s', got %s", commitmentName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeReservation.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-reservation", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, { ExpectedType: gcpshared.ComputeLicense.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-license", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/commitments/%s", projectID, region, commitmentName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Commitment not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), commitmentName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-resource-policy.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Resource Policy adapter for resource policies var _ = registerableAdapter{ sdpType: gcpshared.ComputeResourcePolicy, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/resourcePolicies/get InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.RegionalLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/resourcePolicies/{resourcePolicy} GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/resourcePolicies/%s"), // https://cloud.google.com/compute/docs/reference/rest/v1/resourcePolicies/list ListEndpointFunc: gcpshared.RegionLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/resourcePolicies"), UniqueAttributeKeys: []string{"resourcePolicies"}, IAMPermissions: []string{"compute.resourcePolicies.get", "compute.resourcePolicies.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Cloud Storage bucket storage location where snapshots created by this policy are stored. // The storageLocations field can contain bucket names, gs:// URIs, or region identifiers. // The manual adapter linker will handle extraction of bucket names from various formats. "snapshotSchedulePolicy.snapshotProperties.storageLocations": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket is deleted or updated: The Resource Policy may fail to create snapshots. If the Resource Policy is updated: The Storage Bucket remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-route.go ================================================ package adapters import ( "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Compute Route adapter for VPC routes var _ = registerableAdapter{ sdpType: gcpshared.ComputeRoute, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/global/routes/{route} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://compute.googleapis.com/compute/v1/projects/%s/global/routes/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/global/routes ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/global/routes"), UniqueAttributeKeys: []string{"routes"}, IAMPermissions: []string{"compute.routes.get", "compute.routes.list"}, PredefinedRole: "roles/compute.viewer", // Tag-based SEARCH: list all routes then filter by tag. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query == "" || strings.Contains(query, "/") { return "" } return fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/routes", location.ProjectID) }, SearchDescription: "Search for routes by network tag. The query is a plain network tag name.", SearchFilterFunc: routeTagFilter, }, linkRules: map[string]*gcpshared.Impact{ // https://cloud.google.com/compute/docs/reference/rest/v1/routes/get // Network that the route belongs to "network": { Description: "If the Compute Network is updated: The route may no longer be valid or correctly associated. If the route is updated: The network remains unaffected, but its routing behavior may change.", ToSDPItemType: gcpshared.ComputeNetwork, }, // Network that the route forwards traffic to, so the relationship will/may be different "nextHopNetwork": { Description: "If the Compute Network is updated: The route may no longer forward traffic properly. If the route is updated: The network remains unaffected but traffic routed through it may be affected.", ToSDPItemType: gcpshared.ComputeNetwork, }, "nextHopIp": { Description: "The network IP address of an instance that should handle matching packets. Tightly coupled with the Compute Route.", ToSDPItemType: stdlib.NetworkIP, }, "nextHopInstance": { Description: "If the Compute Instance is updated: Routes using it as a next hop may break or change behavior. If the route is deleted: The instance remains unaffected but traffic that was previously using that route will be impacted.", ToSDPItemType: gcpshared.ComputeInstance, }, "nextHopVpnTunnel": { Description: "If the VPN Tunnel is updated: The route may no longer forward traffic properly. If the route is updated: The VPN tunnel remains unaffected but traffic routed through it may be affected.", ToSDPItemType: gcpshared.ComputeVpnTunnel, }, "nextHopGateway": { Description: "If the Compute Gateway is updated: The route may no longer forward traffic properly. If the route is updated: The gateway remains unaffected but traffic routed through it may be affected.", ToSDPItemType: gcpshared.ComputeGateway, }, "nextHopHub": { // https://cloud.google.com/network-connectivity/docs/reference/networkconnectivity/rest/v1/projects.locations.global.hubs/get Description: "The full resource name of the Network Connectivity Center hub that will handle matching packets. If the hub is updated: The route may no longer forward traffic properly. If the route is updated: The hub remains unaffected but traffic routed through it may be affected.", ToSDPItemType: gcpshared.NetworkConnectivityHub, }, "nextHopIlb": { // https://cloud.google.com/compute/docs/reference/rest/v1/routes/get // Can be either a URL to a forwarding rule (loadBalancingScheme=INTERNAL) or an IP address // When it's a URL, it references the ForwardingRule. When it's an IP, it's the IP address of the forwarding rule. Description: "The URL to a forwarding rule of type loadBalancingScheme=INTERNAL that should handle matching packets, or the IP address of the forwarding rule. If the Forwarding Rule is updated or deleted: The route may no longer forward traffic properly. If the route is updated: The forwarding rule remains unaffected but traffic routed through it may be affected.", ToSDPItemType: gcpshared.ComputeForwardingRule, }, "nextHopInterconnectAttachment": { // https://cloud.google.com/compute/docs/reference/rest/v1/routes/get Description: "The URL to an InterconnectAttachment which is the next hop for the route. If the Interconnect Attachment is updated or deleted: The route may no longer forward traffic properly. If the route is updated: The interconnect attachment remains unaffected but traffic routed through it may be affected.", ToSDPItemType: gcpshared.ComputeInterconnectAttachment, }, "tags": { Description: "Route specifies network tags to apply routing rules only to instances and instance templates with matching tags. Overmind automatically discovers instances and templates with these tags, enabling blast radius analysis to show which resources will be affected when you modify a route's tags.", ToSDPItemType: gcpshared.ComputeInstance, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_route", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_route.name", }, }, }, }.Register() // routeTagFilter keeps routes whose tags array contains the query tag. func routeTagFilter(query string, item *sdp.Item) bool { return itemAttributeContainsTag(item, "tags", query) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-route_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/compute/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeRoute(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() routeName := "test-route" route := &compute.Route{ Name: routeName, Network: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", NextHopNetwork: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/peer-network", NextHopIp: "10.0.0.1", NextHopInstance: "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a/instances/test-instance", NextHopVpnTunnel: "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-central1/vpnTunnels/test-tunnel", } routeList := &compute.RouteList{ Items: []*compute.Route{route}, } sdpItemType := gcpshared.ComputeRoute expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/routes/%s", projectID, routeName): { StatusCode: http.StatusOK, Body: route, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/routes", projectID): { StatusCode: http.StatusOK, Body: routeList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, routeName, true) if err != nil { t.Fatalf("Failed to get route: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != routeName { t.Errorf("Expected unique attribute value '%s', got %s", routeName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // network ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { // nextHopNetwork ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "peer-network", ExpectedScope: projectID, }, { // nextHopIp ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, { // nextHopVpnTunnel ExpectedType: gcpshared.ComputeVpnTunnel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-tunnel", ExpectedScope: "test-project.us-central1", }, { // nextHopInstance ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: "test-project.us-central1-a", }, // TODO: Add test for nextHopGateway → ComputeGateway // Requires ComputeGateway adapter to be implemented first // TODO: Add test for nextHopHub → NetworkConnectivityHub // Requires NetworkConnectivityHub adapter to be implemented first } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list routes: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 route, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/routes/%s", projectID, routeName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Route not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, routeName, true) if err == nil { t.Error("Expected error when getting non-existent route, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-router.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) var _ = registerableAdapter{ sdpType: gcpshared.ComputeRouter, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.RegionalLevel, // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/routers/get // https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/routers/{router} GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/routers/%s"), // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/routers/list // https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/routers ListEndpointFunc: gcpshared.RegionLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/routers"), // Provide a no-op search for terraform mapping support with full resource ID. // Expected search query: projects/{project}/regions/{region}/routers/{router} // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, SearchDescription: "Search with full ID: projects/[project]/regions/[region]/routers/[router] (used for terraform mapping).", UniqueAttributeKeys: []string{"routers"}, IAMPermissions: []string{"compute.routers.get", "compute.routers.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "network": gcpshared.ComputeNetworkImpactInOnly, "interfaces.linkedInterconnectAttachment": { ToSDPItemType: gcpshared.ComputeInterconnectAttachment, Description: "They are tightly coupled.", }, "interfaces.privateIpAddress": gcpshared.IPImpactBothWays, "interfaces.subnetwork": gcpshared.ComputeSubnetworkImpactInOnly, "bgpPeers.peerIpAddress": gcpshared.IPImpactBothWays, "bgpPeers.ipAddress": gcpshared.IPImpactBothWays, "bgpPeers.ipv4NexthopAddress": gcpshared.IPImpactBothWays, "bgpPeers.peerIpv4NexthopAddress": gcpshared.IPImpactBothWays, "nats.natIps": { ToSDPItemType: stdlib.NetworkIP, Description: "If the NAT IP address is deleted or updated: The Router NAT may fail to function correctly. If the Router NAT is updated: The IP address remains unaffected.", }, "nats.drainNatIps": { ToSDPItemType: stdlib.NetworkIP, Description: "If the draining NAT IP address is deleted or updated: The Router NAT may fail to drain correctly. If the Router NAT is updated: The IP address remains unaffected.", }, "nats.subnetworks.name": gcpshared.ComputeSubnetworkImpactInOnly, "nats.nat64Subnetworks.name": gcpshared.ComputeSubnetworkImpactInOnly, "interfaces.linkedVpnTunnel": { ToSDPItemType: gcpshared.ComputeVpnTunnel, Description: "They are tightly coupled.", }, // Child resource: RoutePolicy - Router can list all its route policies via listRoutePolicies // This is a link from parent to child via SEARCH // The child adapter must support SEARCH method that accepts router name as a parameter "name": { ToSDPItemType: gcpshared.ComputeRoutePolicy, Description: "If the Router is deleted or updated: All associated Route Policies may become invalid or inaccessible. If a Route Policy is updated: The router remains unaffected.", IsParentToChild: true, // Router discovers all its Route Policies via SEARCH }, // Note: BgpRoute is also a child resource with listBgpRoutes endpoint, but we can only use "name" // once in the link rules map. When BgpRoute adapter is created with SEARCH support, // we can consider using a different field or handling it separately. }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_router", Description: "id => projects/{{project}}/regions/{{region}}/routers/{{router}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_compute_router.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-router_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeRouter(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() routerName := "test-router" // Create mock protobuf object router := &computepb.Router{ Name: new(routerName), Description: new("Test Router"), Network: new(fmt.Sprintf("projects/%s/global/networks/default", projectID)), Region: new(fmt.Sprintf("projects/%s/regions/%s", projectID, region)), Interfaces: []*computepb.RouterInterface{ { Name: new("interface-1"), LinkedInterconnectAttachment: new(fmt.Sprintf("projects/%s/regions/%s/interconnectAttachments/test-attachment", projectID, region)), PrivateIpAddress: new("10.0.0.1"), Subnetwork: new(fmt.Sprintf("projects/%s/regions/%s/subnetworks/test-subnet", projectID, region)), LinkedVpnTunnel: new(fmt.Sprintf("projects/%s/regions/%s/vpnTunnels/test-tunnel", projectID, region)), }, }, BgpPeers: []*computepb.RouterBgpPeer{ { Name: new("bgp-peer-1"), PeerIpAddress: new("192.168.1.1"), IpAddress: new("192.168.1.2"), Ipv4NexthopAddress: new("192.168.1.3"), PeerIpv4NexthopAddress: new("192.168.1.4"), }, }, Nats: []*computepb.RouterNat{ { Name: new("nat-1"), NatIps: []string{"203.0.113.1", "203.0.113.2"}, DrainNatIps: []string{"203.0.113.3"}, Subnetworks: []*computepb.RouterNatSubnetworkToNat{ { Name: new(fmt.Sprintf("projects/%s/regions/%s/subnetworks/nat-subnet", projectID, region)), }, }, Nat64Subnetworks: []*computepb.RouterNatSubnetworkToNat64{ { Name: new(fmt.Sprintf("projects/%s/regions/%s/subnetworks/nat64-subnet", projectID, region)), }, }, }, }, } // Create second router for list testing routerName2 := "test-router-2" router2 := &computepb.Router{ Name: new(routerName2), Description: new("Test Router 2"), Network: new(fmt.Sprintf("projects/%s/global/networks/default", projectID)), Region: new(fmt.Sprintf("projects/%s/regions/%s", projectID, region)), } // Create list response with multiple items routerList := &computepb.RouterList{ Items: []*computepb.Router{router, router2}, } sdpItemType := gcpshared.ComputeRouter // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/routers/%s", projectID, region, routerName): { StatusCode: http.StatusOK, Body: router, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/routers/%s", projectID, region, routerName2): { StatusCode: http.StatusOK, Body: router2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/routers", projectID, region): { StatusCode: http.StatusOK, Body: routerList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), routerName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != routerName { t.Errorf("Expected unique attribute value '%s', got %s", routerName, sdpItem.UniqueAttributeValue()) } expectedScope := fmt.Sprintf("%s.%s", projectID, region) if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope '%s', got %s", expectedScope, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } if val != routerName { t.Errorf("Expected name field to be '%s', got %s", routerName, val) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // Interface private IP address { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, // Interface subnetwork { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnet", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // Interconnect attachment link { ExpectedType: gcpshared.ComputeInterconnectAttachment.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-attachment", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // VPN tunnel link { ExpectedType: gcpshared.ComputeVpnTunnel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-tunnel", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // BGP peer IP addresses { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.2", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.4", ExpectedScope: "global", }, // NAT IP addresses { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.2", ExpectedScope: "global", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.3", ExpectedScope: "global", }, // NAT subnetworks { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "nat-subnet", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "nat64-subnet", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } expectedScope := fmt.Sprintf("%s.%s", projectID, region) sdpItems, err := listable.List(ctx, expectedScope, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != expectedScope { t.Errorf("Expected first item scope '%s', got %s", expectedScope, firstItem.GetScope()) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/regions/[region]/routers/[router] terraformQuery := fmt.Sprintf("projects/%s/regions/%s/routers/%s", projectID, region, routerName) expectedScope := fmt.Sprintf("%s.%s", projectID, region) sdpItems, err := searchable.Search(ctx, expectedScope, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != expectedScope { t.Errorf("Expected first item scope '%s', got %s", expectedScope, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/routers/%s", projectID, region, routerName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Router not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } expectedScope := fmt.Sprintf("%s.%s", projectID, region) _, err = adapter.Get(ctx, expectedScope, routerName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-ssl-certificate.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var computeSSLCertificateAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.ComputeSSLCertificate, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/get // https://compute.googleapis.com/compute/v1/projects/{project}/global/sslCertificates/{sslCertificate} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://compute.googleapis.com/compute/v1/projects/%s/global/sslCertificates/%s"), // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/list // https://compute.googleapis.com/compute/v1/projects/{project}/global/sslCertificates ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/global/sslCertificates"), UniqueAttributeKeys: []string{"sslCertificates"}, IAMPermissions: []string{"compute.sslCertificates.get", "compute.sslCertificates.list"}, PredefinedRole: "roles/compute.viewer", }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_ssl_certificate", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_ssl_certificate.name", }, }, }, linkRules: map[string]*gcpshared.Impact{ // There are no link rules originating from Compute SSL Certificates }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-ssl-certificate_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeSSLCertificate(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() certificateName := "test-ssl-certificate" // Create mock protobuf object certificate := &computepb.SslCertificate{ Name: new(certificateName), Description: new("Test SSL Certificate"), Certificate: new("-----BEGIN CERTIFICATE-----\nMIIC...test certificate data...\n-----END CERTIFICATE-----"), PrivateKey: new("-----BEGIN PRIVATE KEY-----\nMIIE...test private key data...\n-----END PRIVATE KEY-----"), SelfLink: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/sslCertificates/%s", projectID, certificateName)), } // Create second certificate for list testing certificateName2 := "test-ssl-certificate-2" certificate2 := &computepb.SslCertificate{ Name: new(certificateName2), Description: new("Test SSL Certificate 2"), Certificate: new("-----BEGIN CERTIFICATE-----\nMIIC...test certificate data 2...\n-----END CERTIFICATE-----"), SelfLink: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/sslCertificates/%s", projectID, certificateName2)), } // Create list response with multiple items certificateList := &computepb.SslCertificateList{ Items: []*computepb.SslCertificate{certificate, certificate2}, } sdpItemType := gcpshared.ComputeSSLCertificate // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslCertificates/%s", projectID, certificateName): { StatusCode: http.StatusOK, Body: certificate, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslCertificates/%s", projectID, certificateName2): { StatusCode: http.StatusOK, Body: certificate2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslCertificates", projectID): { StatusCode: http.StatusOK, Body: certificateList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, certificateName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != certificateName { t.Errorf("Expected unique attribute value '%s', got %s", certificateName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } if val != certificateName { t.Errorf("Expected name field to be '%s', got %s", certificateName, val) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslCertificates/%s", projectID, certificateName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "SSL Certificate not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, certificateName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-ssl-policy.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // SSL Policy (global, project-level) defines SSL/TLS connection settings for secure network communications in Google Cloud Load Balancers // GCP Ref (GET): https://cloud.google.com/compute/docs/reference/rest/v1/sslPolicies/get // GET https://compute.googleapis.com/compute/v1/projects/{project}/global/sslPolicies/{sslPolicy} // LIST https://compute.googleapis.com/compute/v1/projects/{project}/global/sslPolicies var _ = registerableAdapter{ sdpType: gcpshared.ComputeSSLPolicy, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/sslPolicies/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/sslPolicies", ), UniqueAttributeKeys: []string{"sslPolicies"}, IAMPermissions: []string{ "compute.sslPolicies.get", "compute.sslPolicies.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ // SSL Policies are configuration-only resources that define TLS/SSL parameters // They don't have dependencies on other GCP resources, but are referenced by: // - Target HTTPS Proxies (via sslPolicy field) // - Target SSL Proxies (via sslPolicy field) }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_ssl_policy", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_ssl_policy.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-ssl-policy_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeSSLPolicy(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() policyName := "test-ssl-policy" policy := &computepb.SslPolicy{ Name: &policyName, } policyName2 := "test-ssl-policy-2" policy2 := &computepb.SslPolicy{ Name: &policyName2, } policyList := &computepb.SslPoliciesList{ Items: []*computepb.SslPolicy{policy, policy2}, } sdpItemType := gcpshared.ComputeSSLPolicy expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslPolicies/%s", projectID, policyName): { StatusCode: http.StatusOK, Body: policy, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslPolicies/%s", projectID, policyName2): { StatusCode: http.StatusOK, Body: policy2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslPolicies", projectID): { StatusCode: http.StatusOK, Body: policyList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, policyName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != policyName { t.Errorf("Expected unique attribute value '%s', got %s", policyName, sdpItem.UniqueAttributeValue()) } // Skip static tests - no link rules for this adapter }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/sslPolicies/%s", projectID, policyName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "SSL policy not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, policyName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-storage-pool.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Storage Pool adapter for storage pools var _ = registerableAdapter{ sdpType: gcpshared.ComputeStoragePool, meta: gcpshared.AdapterMeta{ // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/storagePools/get InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, LocationLevel: gcpshared.ZonalLevel, // https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/storagePools/{storagePool} GetEndpointFunc: gcpshared.ZoneLevelEndpointFunc("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/storagePools/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/storagePools ListEndpointFunc: gcpshared.ZoneLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/storagePools"), UniqueAttributeKeys: []string{"storagePools"}, IAMPermissions: []string{"compute.storagePools.get", "compute.storagePools.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Link to the storage pool type that defines the characteristics of this storage pool "storagePoolType": { ToSDPItemType: gcpshared.ComputeStoragePoolType, Description: "If the Storage Pool Type is deleted or updated: The Storage Pool may fail to operate correctly or become invalid. If the Storage Pool is updated: The Storage Pool Type remains unaffected.", }, // Link to the zone where the storage pool resides "zone": { ToSDPItemType: gcpshared.ComputeZone, Description: "If the Zone is deleted or becomes unavailable: The Storage Pool may become inaccessible. If the Storage Pool is updated: The Zone remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-subnetwork.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Compute Subnetwork adapter for VPC subnetworks var _ = registerableAdapter{ sdpType: gcpshared.ComputeSubnetwork, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.RegionalLevel, // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/get // https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/subnetworks/{subnetwork} GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/subnetworks/%s"), // https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/subnetworks ListEndpointFunc: gcpshared.RegionLevelListFunc("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/subnetworks"), UniqueAttributeKeys: []string{"subnetworks"}, IAMPermissions: []string{"compute.subnetworks.get", "compute.subnetworks.list"}, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "network": { Description: "If the Compute Network is updated: The firewall rules may no longer apply correctly. If the firewall is updated: The network remains unaffected, but its security posture may change.", ToSDPItemType: gcpshared.ComputeNetwork, }, "gatewayAddress": gcpshared.IPImpactBothWays, "secondaryIpRanges.reservedInternalRange": { Description: "If the Reserved Internal Range is deleted or updated: The subnetwork's secondary IP range configuration may become invalid. If the subnetwork is updated: The internal range remains unaffected.", ToSDPItemType: gcpshared.NetworkConnectivityInternalRange, }, "ipCollection": { Description: "If the Public Delegated Prefix is deleted or updated: The subnetwork may lose its IP allocation source (BYOIP). If the subnetwork is updated: The prefix remains unaffected.", ToSDPItemType: gcpshared.ComputePublicDelegatedPrefix, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_subnetwork", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_subnetwork.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-subnetwork_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/compute/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeSubnetwork(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() subnetworkName := "test-subnetwork" subnetwork := &compute.Subnetwork{ Name: subnetworkName, Network: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", GatewayAddress: "10.0.0.1", IpCidrRange: "10.0.0.0/24", Region: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/regions/%s", projectID, region), } subnetworkList := &compute.SubnetworkList{ Items: []*compute.Subnetwork{subnetwork}, } sdpItemType := gcpshared.ComputeSubnetwork expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/subnetworks/%s", projectID, region, subnetworkName): { StatusCode: http.StatusOK, Body: subnetwork, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/subnetworks", projectID, region): { StatusCode: http.StatusOK, Body: subnetworkList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), subnetworkName, true) if err != nil { t.Fatalf("Failed to get subnetwork: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != subnetworkName { t.Errorf("Expected unique attribute value '%s', got %s", subnetworkName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // network ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { // gatewayAddress ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list subnetworks: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 subnetwork, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/subnetworks/%s", projectID, region, subnetworkName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Subnetwork not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), subnetworkName, true) if err == nil { t.Error("Expected error when getting non-existent subnetwork, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-target-http-proxy.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Target HTTP Proxy (global, project-level) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/global/targetHttpProxies/{targetHttpProxy} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/global/targetHttpProxies var _ = registerableAdapter{ sdpType: gcpshared.ComputeTargetHttpProxy, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpProxies/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpProxies", ), UniqueAttributeKeys: []string{"targetHttpProxies"}, IAMPermissions: []string{ "compute.targetHttpProxies.get", "compute.targetHttpProxies.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "urlMap": { ToSDPItemType: gcpshared.ComputeUrlMap, Description: "If the URL Map is updated or deleted: The HTTP proxy routing behavior may change or break. If the proxy changes: The URL map remains structurally unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_target_http_proxy", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_target_http_proxy.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-target-http-proxy_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeTargetHttpProxy(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() proxyName := "test-http-proxy" urlMapURL := fmt.Sprintf("projects/%s/global/urlMaps/test-url-map", projectID) proxy := &computepb.TargetHttpProxy{ Name: &proxyName, UrlMap: &urlMapURL, } proxyName2 := "test-http-proxy-2" proxy2 := &computepb.TargetHttpProxy{ Name: &proxyName2, } proxyList := &computepb.TargetHttpProxyList{ Items: []*computepb.TargetHttpProxy{proxy, proxy2}, } sdpItemType := gcpshared.ComputeTargetHttpProxy expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpProxies/%s", projectID, proxyName): { StatusCode: http.StatusOK, Body: proxy, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpProxies/%s", projectID, proxyName2): { StatusCode: http.StatusOK, Body: proxy2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpProxies", projectID): { StatusCode: http.StatusOK, Body: proxyList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, proxyName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != proxyName { t.Errorf("Expected unique attribute value '%s', got %s", proxyName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeUrlMap.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-url-map", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpProxies/%s", projectID, proxyName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Proxy not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, proxyName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-target-https-proxy.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Target HTTPS Proxy (global, project-level) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/global/targetHttpsProxies/{targetHttpsProxy} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/global/targetHttpsProxies var _ = registerableAdapter{ sdpType: gcpshared.ComputeTargetHttpsProxy, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpsProxies/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpsProxies", ), UniqueAttributeKeys: []string{"targetHttpsProxies"}, IAMPermissions: []string{ "compute.targetHttpsProxies.get", "compute.targetHttpsProxies.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "urlMap": { ToSDPItemType: gcpshared.ComputeUrlMap, Description: "If the URL Map is updated or deleted: The HTTPS proxy routing behavior may change or break. If the proxy changes: The URL map remains structurally unaffected.", }, "sslCertificates": { ToSDPItemType: gcpshared.ComputeSSLCertificate, Description: "If the SSL Certificate is updated or deleted: TLS handshakes may fail for the HTTPS proxy. If the proxy changes: The certificate resource remains unaffected.", }, "sslPolicy": { ToSDPItemType: gcpshared.ComputeSSLPolicy, Description: "If the SSL Policy is updated or deleted: TLS handshakes may fail for the HTTPS proxy. If the proxy changes: The SSL policy resource remains unaffected.", }, "certificateMap": { ToSDPItemType: gcpshared.CertificateManagerCertificateMap, Description: "If the Certificate Map is updated or deleted: TLS handshakes may fail for the HTTPS proxy. If the proxy changes: The certificate map resource remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_target_https_proxy", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_target_https_proxy.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-target-https-proxy_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeTargetHttpsProxy(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() proxyName := "test-https-proxy" urlMapURL := fmt.Sprintf("projects/%s/global/urlMaps/test-url-map", projectID) sslCertURL := fmt.Sprintf("projects/%s/global/sslCertificates/test-cert", projectID) sslPolicyURL := fmt.Sprintf("projects/%s/global/sslPolicies/test-policy", projectID) proxy := &computepb.TargetHttpsProxy{ Name: &proxyName, UrlMap: &urlMapURL, SslCertificates: []string{sslCertURL}, SslPolicy: &sslPolicyURL, } proxyName2 := "test-https-proxy-2" proxy2 := &computepb.TargetHttpsProxy{ Name: &proxyName2, } proxyList := &computepb.TargetHttpsProxyList{ Items: []*computepb.TargetHttpsProxy{proxy, proxy2}, } sdpItemType := gcpshared.ComputeTargetHttpsProxy expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpsProxies/%s", projectID, proxyName): { StatusCode: http.StatusOK, Body: proxy, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpsProxies/%s", projectID, proxyName2): { StatusCode: http.StatusOK, Body: proxy2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpsProxies", projectID): { StatusCode: http.StatusOK, Body: proxyList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, proxyName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != proxyName { t.Errorf("Expected unique attribute value '%s', got %s", proxyName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeUrlMap.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-url-map", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSSLCertificate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-cert", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSSLPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpsProxies/%s", projectID, proxyName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Proxy not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, proxyName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-target-pool.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Target Pool (regional) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/targetPools/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/targetPools/{targetPool} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/targetPools var _ = registerableAdapter{ sdpType: gcpshared.ComputeTargetPool, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.RegionalLevel, GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/targetPools/%s", ), ListEndpointFunc: gcpshared.RegionLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/targetPools", ), // Provide a no-op search for terraform mapping support with full resource ID. // Expected search query: projects/{project}/regions/{region}/targetPools/{name} // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, SearchDescription: "Search with full ID: projects/[project]/regions/[region]/targetPools/[name] (used for terraform mapping).", // The list response key for items is "targetPools". UniqueAttributeKeys: []string{"targetPools"}, IAMPermissions: []string{ "compute.targetPools.get", "compute.targetPools.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "instances": { ToSDPItemType: gcpshared.ComputeInstance, Description: "If the Compute Instance is deleted or updated: the pool membership becomes invalid or traffic may fail to reach it. If the pool is updated: the instance remains structurally unaffected.", }, "healthChecks": { ToSDPItemType: gcpshared.ComputeHealthCheck, Description: "If the Health Check is updated or deleted: health status and traffic distribution may be affected. If the pool is updated: the health check remains unaffected.", }, "backupPool": { ToSDPItemType: gcpshared.ComputeTargetPool, Description: "If the backup Target Pool is updated or deleted: failover behavior may change. If this pool is updated: the backup pool remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_target_pool", Description: "id => projects/{{project}}/regions/{{region}}/targetPools/{{name}}. We need to use SEARCH.", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_compute_target_pool.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-target-pool_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeTargetPool(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() poolName := "test-target-pool" zone := "us-central1-a" instance1URL := fmt.Sprintf("projects/%s/zones/%s/instances/instance-1", projectID, zone) instance2URL := fmt.Sprintf("projects/%s/zones/%s/instances/instance-2", projectID, zone) healthCheck1URL := fmt.Sprintf("projects/%s/global/healthChecks/health-check-1", projectID) healthCheck2URL := fmt.Sprintf("projects/%s/global/healthChecks/health-check-2", projectID) backupPoolURL := fmt.Sprintf("projects/%s/regions/%s/targetPools/backup-pool", projectID, region) pool := &computepb.TargetPool{ Name: &poolName, Instances: []string{ instance1URL, instance2URL, }, HealthChecks: []string{ healthCheck1URL, healthCheck2URL, }, BackupPool: &backupPoolURL, } poolName2 := "test-target-pool-2" pool2 := &computepb.TargetPool{ Name: &poolName2, } poolList := &computepb.TargetPoolList{ Items: []*computepb.TargetPool{pool, pool2}, } sdpItemType := gcpshared.ComputeTargetPool expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/targetPools/%s", projectID, region, poolName): { StatusCode: http.StatusOK, Body: pool, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/targetPools/%s", projectID, region, poolName2): { StatusCode: http.StatusOK, Body: pool2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/targetPools", projectID, region): { StatusCode: http.StatusOK, Body: poolList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), poolName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != poolName { t.Errorf("Expected unique attribute value '%s', got %s", poolName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Instance 1 link { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "instance-1", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, // Instance 2 link { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "instance-2", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, // Health check 1 link { ExpectedType: gcpshared.ComputeHealthCheck.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "health-check-1", ExpectedScope: projectID, }, // Health check 2 link { ExpectedType: gcpshared.ComputeHealthCheck.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "health-check-2", ExpectedScope: projectID, }, // Backup pool link { ExpectedType: gcpshared.ComputeTargetPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "backup-pool", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/regions/[region]/targetPools/[name] terraformQuery := fmt.Sprintf("projects/%s/regions/%s/targetPools/%s", projectID, region, poolName) sdpItems, err := searchable.Search(ctx, fmt.Sprintf("%s.%s", projectID, region), terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != fmt.Sprintf("%s.%s", projectID, region) { t.Errorf("Expected first item scope '%s.%s', got %s", projectID, region, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/targetPools/%s", projectID, region, poolName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Target pool not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), poolName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-url-map.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var computeBackendImpact = &gcpshared.Impact{ ToSDPItemType: gcpshared.ComputeBackendService, Description: "If the Backend Service or Backend Bucket is updated or deleted: The URL Map's routing behavior may change or break. If the URL Map changes: The backend service or bucket remains structurally unaffected.", } // URL Map (global, project-level) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/global/urlMaps/{urlMap} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/global/urlMaps var _ = registerableAdapter{ sdpType: gcpshared.ComputeUrlMap, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://compute.googleapis.com/compute/v1/projects/%s/global/urlMaps/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/global/urlMaps", ), // The list response key and path segment for URL maps. UniqueAttributeKeys: []string{"urlMaps"}, IAMPermissions: []string{ "compute.urlMaps.get", "compute.urlMaps.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ "defaultService": computeBackendImpact, "defaultRouteAction.weightedBackendServices.backendService": computeBackendImpact, "defaultRouteAction.requestMirrorPolicy.backendService": computeBackendImpact, "pathMatchers.defaultService": computeBackendImpact, "pathMatchers.pathRules.service": computeBackendImpact, "pathMatchers.routeRules.service": computeBackendImpact, "pathMatchers.defaultRouteAction.weightedBackendServices.backendService": computeBackendImpact, "pathMatchers.defaultRouteAction.requestMirrorPolicy.backendService": computeBackendImpact, "pathMatchers.pathRules.routeAction.weightedBackendServices.backendService": computeBackendImpact, "pathMatchers.pathRules.routeAction.requestMirrorPolicy.backendService": computeBackendImpact, "pathMatchers.routeRules.routeAction.weightedBackendServices.backendService": computeBackendImpact, "pathMatchers.routeRules.routeAction.requestMirrorPolicy.backendService": computeBackendImpact, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_url_map", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_url_map.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-url-map_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestComputeUrlMap(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() urlMapName := "test-url-map" defaultService := fmt.Sprintf("projects/%s/global/backendServices/test-backend", projectID) pathMatcherDefaultService := fmt.Sprintf("projects/%s/global/backendServices/test-path-matcher-backend", projectID) pathRuleService := fmt.Sprintf("projects/%s/global/backendServices/test-path-rule-backend", projectID) weightedBackendService := fmt.Sprintf("projects/%s/global/backendServices/test-weighted-backend", projectID) mirrorBackendService := fmt.Sprintf("projects/%s/global/backendServices/test-mirror-backend", projectID) routeRuleService := fmt.Sprintf("projects/%s/global/backendServices/test-route-rule-backend", projectID) pathMatcherWeightedBackend := fmt.Sprintf("projects/%s/global/backendServices/test-pm-weighted-backend", projectID) pathMatcherMirrorBackend := fmt.Sprintf("projects/%s/global/backendServices/test-pm-mirror-backend", projectID) pathRuleWeightedBackend := fmt.Sprintf("projects/%s/global/backendServices/test-pr-weighted-backend", projectID) pathRuleMirrorBackend := fmt.Sprintf("projects/%s/global/backendServices/test-pr-mirror-backend", projectID) routeRuleWeightedBackend := fmt.Sprintf("projects/%s/global/backendServices/test-rr-weighted-backend", projectID) routeRuleMirrorBackend := fmt.Sprintf("projects/%s/global/backendServices/test-rr-mirror-backend", projectID) pathMatcherName := "path-matcher-1" pathPattern := "/api/*" priority := int32(100) weight := uint32(100) urlMap := &computepb.UrlMap{ Name: &urlMapName, DefaultService: &defaultService, DefaultRouteAction: &computepb.HttpRouteAction{ WeightedBackendServices: []*computepb.WeightedBackendService{ { BackendService: &weightedBackendService, Weight: &weight, }, }, RequestMirrorPolicy: &computepb.RequestMirrorPolicy{ BackendService: &mirrorBackendService, }, }, PathMatchers: []*computepb.PathMatcher{ { Name: &pathMatcherName, DefaultService: &pathMatcherDefaultService, DefaultRouteAction: &computepb.HttpRouteAction{ WeightedBackendServices: []*computepb.WeightedBackendService{ { BackendService: &pathMatcherWeightedBackend, Weight: &weight, }, }, RequestMirrorPolicy: &computepb.RequestMirrorPolicy{ BackendService: &pathMatcherMirrorBackend, }, }, PathRules: []*computepb.PathRule{ { Paths: []string{pathPattern}, Service: &pathRuleService, RouteAction: &computepb.HttpRouteAction{ WeightedBackendServices: []*computepb.WeightedBackendService{ { BackendService: &pathRuleWeightedBackend, Weight: &weight, }, }, RequestMirrorPolicy: &computepb.RequestMirrorPolicy{ BackendService: &pathRuleMirrorBackend, }, }, }, }, RouteRules: []*computepb.HttpRouteRule{ { Priority: &priority, Service: &routeRuleService, RouteAction: &computepb.HttpRouteAction{ WeightedBackendServices: []*computepb.WeightedBackendService{ { BackendService: &routeRuleWeightedBackend, Weight: &weight, }, }, RequestMirrorPolicy: &computepb.RequestMirrorPolicy{ BackendService: &routeRuleMirrorBackend, }, }, }, }, }, }, } urlMapName2 := "test-url-map-2" urlMap2 := &computepb.UrlMap{ Name: &urlMapName2, } urlMapList := &computepb.UrlMapList{ Items: []*computepb.UrlMap{urlMap, urlMap2}, } sdpItemType := gcpshared.ComputeUrlMap expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/urlMaps/%s", projectID, urlMapName): { StatusCode: http.StatusOK, Body: urlMap, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/urlMaps/%s", projectID, urlMapName2): { StatusCode: http.StatusOK, Body: urlMap2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/urlMaps", projectID): { StatusCode: http.StatusOK, Body: urlMapList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, urlMapName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != urlMapName { t.Errorf("Expected unique attribute value '%s', got %s", urlMapName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Default service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-backend", ExpectedScope: projectID, }, // Path matcher default service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-path-matcher-backend", ExpectedScope: projectID, }, // Path rule service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-path-rule-backend", ExpectedScope: projectID, }, // Default route action weighted backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-weighted-backend", ExpectedScope: projectID, }, // Default route action request mirror backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-mirror-backend", ExpectedScope: projectID, }, // Path matcher default route action weighted backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pm-weighted-backend", ExpectedScope: projectID, }, // Path matcher default route action request mirror backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pm-mirror-backend", ExpectedScope: projectID, }, // Path rule route action weighted backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pr-weighted-backend", ExpectedScope: projectID, }, // Path rule route action request mirror backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pr-mirror-backend", ExpectedScope: projectID, }, // Route rule service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-route-rule-backend", ExpectedScope: projectID, }, // Route rule route action weighted backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-rr-weighted-backend", ExpectedScope: projectID, }, // Route rule route action request mirror backend service link { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-rr-mirror-backend", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/urlMaps/%s", projectID, urlMapName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "URL map not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, urlMapName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-vpn-gateway.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // HA VPN Gateway (regional) resource. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/vpnGateways/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/vpnGateways/{vpnGateway} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/vpnGateways var _ = registerableAdapter{ sdpType: gcpshared.ComputeVpnGateway, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.RegionalLevel, GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnGateways/%s", ), ListEndpointFunc: gcpshared.RegionLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnGateways", ), // The list response uses the key "vpnGateways" for items. UniqueAttributeKeys: []string{"vpnGateways"}, IAMPermissions: []string{ "compute.vpnGateways.get", "compute.vpnGateways.list", }, PredefinedRole: "roles/compute.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Network associated with the VPN gateway. "network": gcpshared.ComputeNetworkImpactInOnly, // IP addresses assigned to VPN interfaces (each interface may have an external IP). "vpnInterfaces.ipAddress": gcpshared.IPImpactBothWays, "vpnInterfaces.ipv6Address": gcpshared.IPImpactBothWays, // Interconnect attachment used for HA VPN over Cloud Interconnect. "vpnInterfaces.interconnectAttachment": { ToSDPItemType: gcpshared.ComputeInterconnectAttachment, Description: "If the Interconnect Attachment is deleted or updated: The VPN gateway interface may fail to operate correctly. If the VPN gateway is deleted or updated: The interconnect attachment may become disconnected or unusable. They are tightly coupled.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_ha_vpn_gateway", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_ha_vpn_gateway.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-vpn-gateway_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeVpnGateway(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() gatewayName := "test-vpn-gateway" networkURL := fmt.Sprintf("projects/%s/global/networks/default", projectID) ipAddress := "203.0.113.1" interconnectAttachmentURL := fmt.Sprintf("projects/%s/regions/%s/interconnectAttachments/test-attachment", projectID, region) gateway := &computepb.VpnGateway{ Name: &gatewayName, Network: &networkURL, VpnInterfaces: []*computepb.VpnGatewayVpnGatewayInterface{ { IpAddress: &ipAddress, InterconnectAttachment: &interconnectAttachmentURL, }, }, } gatewayName2 := "test-vpn-gateway-2" gateway2 := &computepb.VpnGateway{ Name: &gatewayName2, } gatewayList := &computepb.VpnGatewayList{ Items: []*computepb.VpnGateway{gateway, gateway2}, } sdpItemType := gcpshared.ComputeVpnGateway expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnGateways/%s", projectID, region, gatewayName): { StatusCode: http.StatusOK, Body: gateway, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnGateways/%s", projectID, region, gatewayName2): { StatusCode: http.StatusOK, Body: gateway2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnGateways", projectID, region): { StatusCode: http.StatusOK, Body: gatewayList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), gatewayName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != gatewayName { t.Errorf("Expected unique attribute value '%s', got %s", gatewayName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeInterconnectAttachment.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-attachment", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnGateways/%s", projectID, region, gatewayName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "VPN gateway not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), gatewayName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/compute-vpn-tunnel.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // VPN Tunnel resource (Classic or HA VPN) scoped to a region. // Reference: https://cloud.google.com/compute/docs/reference/rest/v1/vpnTunnels/get // GET: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/vpnTunnels/{vpnTunnel} // LIST: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/vpnTunnels var _ = registerableAdapter{ sdpType: gcpshared.ComputeVpnTunnel, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.RegionalLevel, GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnTunnels/%s", ), ListEndpointFunc: gcpshared.RegionLevelListFunc( "https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnTunnels", ), // The list response uses the key "vpnTunnels" for items. UniqueAttributeKeys: []string{"vpnTunnels"}, IAMPermissions: []string{ "compute.vpnTunnels.get", "compute.vpnTunnels.list", }, PredefinedRole: "roles/compute.viewer", // HEALTH: https://cloud.google.com/compute/docs/reference/rest/v1/vpnTunnels#Status => status // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items }, linkRules: map[string]*gcpshared.Impact{ // The peer IP address of the remote VPN gateway. "peerIp": gcpshared.IPImpactBothWays, "targetVpnGateway": { ToSDPItemType: gcpshared.ComputeTargetVpnGateway, Description: "If the Target VPN Gateway (Classic VPN) is deleted or updated: The VPN Tunnel may become invalid or fail to establish connections. If the VPN Tunnel is updated or deleted: The Target VPN Gateway may be affected as tunnels are tightly coupled to their gateway.", }, "vpnGateway": { ToSDPItemType: gcpshared.ComputeVpnGateway, Description: "If the HA VPN Gateway is deleted or updated: The VPN Tunnel may become invalid or fail to establish connections. If the VPN Tunnel is updated or deleted: The HA VPN Gateway may be affected as tunnels are tightly coupled to their gateway.", }, "peerExternalGateway": { ToSDPItemType: gcpshared.ComputeExternalVpnGateway, Description: "If the External VPN Gateway is deleted or updated: The VPN Tunnel may fail to establish connections with the peer. If the VPN Tunnel is updated or deleted: The External VPN Gateway remains unaffected, but the tunnel endpoint becomes inactive.", }, "peerGcpGateway": { ToSDPItemType: gcpshared.ComputeVpnGateway, Description: "If the peer HA VPN Gateway is deleted or updated: The VPN Tunnel may fail to establish VPC-to-VPC connections. If the VPN Tunnel is updated or deleted: The peer HA VPN Gateway remains unaffected, but the tunnel endpoint becomes inactive.", }, "router": { ToSDPItemType: gcpshared.ComputeRouter, Description: "If the Cloud Router is deleted or updated: The VPN Tunnel may lose dynamic routing capabilities (BGP). If the VPN Tunnel is updated or deleted: The Cloud Router may lose routes advertised through this tunnel.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_vpn_tunnel", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_vpn_tunnel.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/compute-vpn-tunnel_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeVpnTunnel(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() tunnelName := "test-vpn-tunnel" peerIP := "203.0.113.1" targetVpnGatewayURL := fmt.Sprintf("projects/%s/regions/%s/targetVpnGateways/test-target-gateway", projectID, region) vpnGatewayURL := fmt.Sprintf("projects/%s/regions/%s/vpnGateways/test-gateway", projectID, region) peerExternalGatewayURL := fmt.Sprintf("projects/%s/global/externalVpnGateways/test-external-gateway", projectID) peerGcpGatewayURL := fmt.Sprintf("projects/%s/regions/%s/vpnGateways/test-peer-gcp-gateway", projectID, region) routerURL := fmt.Sprintf("projects/%s/regions/%s/routers/test-router", projectID, region) tunnel := &computepb.VpnTunnel{ Name: &tunnelName, PeerIp: &peerIP, TargetVpnGateway: &targetVpnGatewayURL, VpnGateway: &vpnGatewayURL, PeerExternalGateway: &peerExternalGatewayURL, PeerGcpGateway: &peerGcpGatewayURL, Router: &routerURL, } tunnelName2 := "test-vpn-tunnel-2" tunnel2 := &computepb.VpnTunnel{ Name: &tunnelName2, } tunnelList := &computepb.VpnTunnelList{ Items: []*computepb.VpnTunnel{tunnel, tunnel2}, } sdpItemType := gcpshared.ComputeVpnTunnel expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnTunnels/%s", projectID, region, tunnelName): { StatusCode: http.StatusOK, Body: tunnel, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnTunnels/%s", projectID, region, tunnelName2): { StatusCode: http.StatusOK, Body: tunnel2, }, fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnTunnels", projectID, region): { StatusCode: http.StatusOK, Body: tunnelList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), tunnelName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != tunnelName { t.Errorf("Expected unique attribute value '%s', got %s", tunnelName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Peer IP link { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, // Target VPN Gateway link (Classic VPN) { ExpectedType: gcpshared.ComputeTargetVpnGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-target-gateway", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // VPN Gateway link (HA VPN) { ExpectedType: gcpshared.ComputeVpnGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-gateway", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // Peer External Gateway link { ExpectedType: gcpshared.ComputeExternalVpnGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-external-gateway", ExpectedScope: projectID, }, // Peer GCP Gateway link { ExpectedType: gcpshared.ComputeVpnGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-peer-gcp-gateway", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // Router link { ExpectedType: gcpshared.ComputeRouter.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-router", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/vpnTunnels/%s", projectID, region, tunnelName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "VPN tunnel not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), tunnelName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/container-cluster.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // GKE Container Cluster represents a managed Kubernetes cluster in GCP. // It provides a scalable, secure, and fully managed Kubernetes service for running containerized applications. // // Adapter for GCP GKE Container Cluster // API Get: https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters/get // API List: https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters/list var _ = registerableAdapter{ sdpType: gcpshared.ContainerCluster, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.ProjectLevel, // GET https://container.googleapis.com/v1/projects/{project}/locations/{location}/clusters/{cluster} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s", ), // LIST all clusters across all locations using wildcard ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://container.googleapis.com/v1/projects/%s/locations/-/clusters", ), // LIST https://container.googleapis.com/v1/projects/{project}/locations/{location}/clusters SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://container.googleapis.com/v1/projects/%s/locations/%s/clusters", ), SearchDescription: "Search for GKE clusters in a location. Use the format \"location\" or the full resource name supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "clusters"}, IAMPermissions: []string{ "container.clusters.get", "container.clusters.list", }, PredefinedRole: "roles/container.viewer", }, linkRules: map[string]*gcpshared.Impact{ "network": gcpshared.ComputeNetworkImpactInOnly, "subnetwork": gcpshared.ComputeSubnetworkImpactInOnly, "nodePools.config.serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, "nodePools.config.bootDiskKmsKey": gcpshared.CryptoKeyImpactInOnly, "nodePools.config.nodeGroup": { ToSDPItemType: gcpshared.ComputeNodeGroup, Description: "If the referenced Node Group is deleted or updated: Node pools backed by it may fail to create or manage nodes. Updates to the node pool will not affect the node group.", }, "notificationConfig.pubsub.topic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the referenced Pub/Sub topic is deleted or updated: Notifications may fail to be sent. Updates to the cluster will not affect the topic.", }, // The Cloud KMS cryptoKeyVersions to use for signing service account JWTs issued by this cluster. // Format: projects/{project}/locations/{location}/keyRings/{keyring}/cryptoKeys/{cryptoKey}/cryptoKeyVersions/{cryptoKeyVersion} "userManagedKeysConfig.serviceAccountSigningKeys": gcpshared.CryptoKeyVersionImpactInOnly, // The Cloud KMS cryptoKeyVersions to use for verifying service account JWTs issued by this cluster. "userManagedKeysConfig.serviceAccountVerificationKeys": gcpshared.CryptoKeyVersionImpactInOnly, // The Cloud KMS cryptoKey to use for Confidential Hyperdisk on the control plane nodes. "userManagedKeysConfig.controlPlaneDiskEncryptionKey": gcpshared.CryptoKeyImpactInOnly, // Resource path of the Cloud KMS cryptoKey to use for encryption of internal etcd backups. "userManagedKeysConfig.gkeopsEtcdBackupEncryptionKey": gcpshared.CryptoKeyImpactInOnly, // The Cloud KMS cryptoKey to use for encrypting secrets in etcd. // Format: projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey} "databaseEncryption.keyName": gcpshared.CryptoKeyImpactInOnly, // The BigQuery dataset ID where cluster resource usage will be exported. "resourceUsageExportConfig.bigqueryDestination.datasetId": { ToSDPItemType: gcpshared.BigQueryDataset, Description: "If the referenced BigQuery dataset is deleted or updated: Resource usage export may fail. Updates to the cluster will not affect the dataset.", }, // The IP address of this cluster's master endpoint. "endpoint": gcpshared.IPImpactBothWays, // Forward link from parent to child via SEARCH // Link to all node pools in this cluster "name": { ToSDPItemType: gcpshared.ContainerNodePool, Description: "If the Container Cluster is deleted or updated: All associated Node Pools may become invalid or inaccessible. If a Node Pool is updated: The cluster remains unaffected.", IsParentToChild: true, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/container_cluster", Description: "id => projects/{{project}}/locations/{{zone}}/clusters/{{name}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_container_cluster.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/container-cluster_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/container/apiv1/containerpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestContainerCluster(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1-a" linker := gcpshared.NewLinker() clusterName := "test-cluster" // Create mock protobuf object cluster := &containerpb.Cluster{ Name: fmt.Sprintf("projects/%s/locations/%s/clusters/%s", projectID, location, clusterName), Description: "Test GKE Cluster", Network: fmt.Sprintf("projects/%s/global/networks/default", projectID), Subnetwork: fmt.Sprintf("projects/%s/regions/us-central1/subnetworks/default", projectID), Location: location, NodePools: []*containerpb.NodePool{ { Name: "default-pool", Config: &containerpb.NodeConfig{ ServiceAccount: fmt.Sprintf("test-service-account@%s.iam.gserviceaccount.com", projectID), BootDiskKmsKey: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", // https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/nodeGroups/{nodeGroup} NodeGroup: "projects/test-project/zones/us-central1-a/nodeGroups/test-node-group", }, }, }, NotificationConfig: &containerpb.NotificationConfig{ Pubsub: &containerpb.NotificationConfig_PubSub{ Topic: fmt.Sprintf("projects/%s/topics/test-topic", projectID), }, }, UserManagedKeysConfig: &containerpb.UserManagedKeysConfig{ ServiceAccountSigningKeys: []string{ "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1", }, ServiceAccountVerificationKeys: []string{ "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/2", }, ControlPlaneDiskEncryptionKey: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/control-plane-key", GkeopsEtcdBackupEncryptionKey: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/etcd-backup-key", }, DatabaseEncryption: &containerpb.DatabaseEncryption{ KeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/database-encryption-key", State: containerpb.DatabaseEncryption_ENCRYPTED, }, ResourceUsageExportConfig: &containerpb.ResourceUsageExportConfig{ BigqueryDestination: &containerpb.ResourceUsageExportConfig_BigQueryDestination{ DatasetId: "gke_usage_export", }, EnableNetworkEgressMetering: true, }, Endpoint: "35.123.45.67", } // Create second cluster for list testing clusterName2 := fmt.Sprintf("projects/%s/locations/%s/clusters/%s", projectID, location, "test-cluster-2") cluster2 := &containerpb.Cluster{ Name: fmt.Sprintf("projects/%s/locations/%s/clusters/%s", projectID, location, "test-cluster-2"), Description: "Test GKE Cluster 2", Network: fmt.Sprintf("projects/%s/global/networks/default", projectID), Location: location, } // Create list response with multiple items clusterList := &containerpb.ListClustersResponse{ Clusters: []*containerpb.Cluster{cluster, cluster2}, } sdpItemType := gcpshared.ContainerCluster // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s", projectID, location, clusterName): { StatusCode: http.StatusOK, Body: cluster, }, fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s", projectID, location, clusterName2): { StatusCode: http.StatusOK, Body: cluster2, }, fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters", projectID, location): { StatusCode: http.StatusOK, Body: clusterList, }, fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/-/clusters", projectID): { StatusCode: http.StatusOK, Body: clusterList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // For multiple query parameters, use the combined query format combinedQuery := shared.CompositeLookupKey(location, clusterName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } if val != fmt.Sprintf("projects/%s/locations/%s/clusters/%s", projectID, location, clusterName) { t.Errorf("Expected name field to be '%s', got %s", clusterName, val) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // Subnetwork link { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, // Service account link { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("test-service-account@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, // Boot disk KMS key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // Node group link { ExpectedType: gcpshared.ComputeNodeGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-node-group", ExpectedScope: fmt.Sprintf("%s.%s", projectID, location), }, // Pub/Sub topic link { ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-topic", ExpectedScope: projectID, }, // Service account signing key version link { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key", "1"), ExpectedScope: projectID, }, // Service account verification key version link { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key", "2"), ExpectedScope: projectID, }, // Control plane disk encryption key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "control-plane-key"), ExpectedScope: projectID, }, // ETCD backup encryption key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "etcd-backup-key"), ExpectedScope: projectID, }, // Database encryption key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "database-encryption-key"), ExpectedScope: projectID, }, // BigQuery dataset link { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "gke_usage_export", ExpectedScope: projectID, }, // Master endpoint IP address link { ExpectedType: "ip", ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "35.123.45.67", ExpectedScope: "global", }, // Forward link to node pools (parent to child) { ExpectedType: gcpshared.ContainerNodePool.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: combinedQuery, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test location-based search sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/locations/[location]/clusters/[cluster] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/clusters/%s", projectID, location, clusterName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list clusters: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 clusters, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } if item.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, item.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s", projectID, location, clusterName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Cluster not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, clusterName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/container-node-pool.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // GKE Container Node Pool adapter. // GCP Ref: // - API Call structure (GET): https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters.nodePools/get // GET https://container.googleapis.com/v1/projects/{project}/locations/{location}/clusters/{cluster}/nodePools/{node_pool} // - Type Definition (NodePool): https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters.nodePools#NodePool // - LIST (per-cluster): https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters.nodePools/list // // Scope: Project-level (uses locations path parameter; unique attributes include location+cluster+nodePool). var _ = registerableAdapter{ sdpType: gcpshared.ContainerNodePool, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithThreeQueries( "https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s/nodePools/%s", ), // Listing node pools requires location and cluster, so we support Search rather than List. SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s/nodePools", ), SearchDescription: "Search GKE Node Pools within a cluster. Use \"[location]|[cluster]\" or the full resource name supported by Terraform mappings: \"[project]/[location]/[cluster]/[node_pool_name]\"", UniqueAttributeKeys: []string{"locations", "clusters", "nodePools"}, IAMPermissions: []string{ "container.clusters.get", "container.clusters.list", }, PredefinedRole: "roles/container.viewer", // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters.nodePools#NodePool.Status }, linkRules: map[string]*gcpshared.Impact{ // This is a backlink to cluster. // Framework will extract the cluster name and create the linked item query with GET "name": { ToSDPItemType: gcpshared.ContainerCluster, Description: "If the Container Cluster is deleted or updated: The Node Pool may become invalid or inaccessible. If the Node Pool is updated: The cluster remains unaffected.", }, "config.bootDiskKmsKey": gcpshared.CryptoKeyImpactInOnly, "config.serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, "config.nodeGroup": { ToSDPItemType: gcpshared.ComputeNodeGroup, Description: "If the node pool is backed by a node group, then changes to the node group may affect the node pool. Changes to the node pool will not affect the node group.", }, "config.network": gcpshared.ComputeNetworkImpactInOnly, "config.subnetwork": gcpshared.ComputeSubnetworkImpactInOnly, "instanceGroupUrls": { ToSDPItemType: gcpshared.ComputeInstanceGroupManager, Description: "If the Instance Group Manager is deleted or updated: The Node Pool may fail to create new nodes or become invalid. If the Node Pool is updated: The instance group manager remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/container_node_pool", Description: "id => {project}/{location}/{cluster}/{node_pool_name}", // TODO: https://linear.app/overmind/issue/ENG-1258/support-terraform-mapping-for-queries-without-keys // There is no code change required for he adapter itself, just the framework to support Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_container_node_pool.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/container-node-pool_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/container/apiv1/containerpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestContainerNodePool(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" clusterName := "test-cluster" linker := gcpshared.NewLinker() nodePoolName := "test-node-pool" // Create mock protobuf object nodePool := &containerpb.NodePool{ Name: fmt.Sprintf("projects/%s/locations/%s/clusters/%s/nodePools/%s", projectID, location, clusterName, nodePoolName), Config: &containerpb.NodeConfig{ BootDiskKmsKey: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", ServiceAccount: "test-sa@test-project.iam.gserviceaccount.com", NodeGroup: fmt.Sprintf("projects/%s/zones/%s-a/nodeGroups/test-group", projectID, location), }, } // Create second node pool for search testing nodePoolName2 := "test-node-pool-2" nodePool2 := &containerpb.NodePool{ Name: fmt.Sprintf("projects/%s/locations/%s/clusters/%s/nodePools/%s", projectID, location, clusterName, nodePoolName2), } // Create list response with multiple items nodePoolList := &containerpb.ListNodePoolsResponse{ NodePools: []*containerpb.NodePool{nodePool, nodePool2}, } sdpItemType := gcpshared.ContainerNodePool // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s/nodePools/%s", projectID, location, clusterName, nodePoolName): { StatusCode: http.StatusOK, Body: nodePool, }, fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s/nodePools/%s", projectID, location, clusterName, nodePoolName2): { StatusCode: http.StatusOK, Body: nodePool2, }, fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s/nodePools", projectID, location, clusterName): { StatusCode: http.StatusOK, Body: nodePoolList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // For three query parameters, use the combined query format combinedQuery := shared.CompositeLookupKey(location, clusterName, nodePoolName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/clusters/%s/nodePools/%s", projectID, location, clusterName, nodePoolName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Cluster backlink { ExpectedType: gcpshared.ContainerCluster.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, clusterName), ExpectedScope: projectID, }, // KMS encryption key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // Service account link { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, // Node group link { ExpectedType: gcpshared.ComputeNodeGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-group", ExpectedScope: fmt.Sprintf("%s.%s-a", projectID, location), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test cluster-based search (location + cluster) searchQuery := shared.CompositeLookupKey(location, clusterName) sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("Search with Terraform format", func(t *testing.T) { t.Skip("Terraform format search not yet supported - see ENG-1258") httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: [project]/[location]/[cluster]/[node_pool_name] terraformQuery := fmt.Sprintf("%s/%s/%s/%s", projectID, location, clusterName, nodePoolName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s/nodePools/%s", projectID, location, clusterName, nodePoolName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Node pool not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, clusterName, nodePoolName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/dataflow-job.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Dataflow Job adapter for Google Cloud Dataflow jobs. // Reference: https://cloud.google.com/dataflow/docs/reference/rest/v1b3/projects.locations.jobs#Job // GET: https://dataflow.googleapis.com/v1b3/projects/{project}/locations/{location}/jobs/{jobId} // LIST: https://dataflow.googleapis.com/v1b3/projects/{project}/jobs:aggregated // SEARCH: https://dataflow.googleapis.com/v1b3/projects/{project}/locations/{location}/jobs var _ = registerableAdapter{ sdpType: gcpshared.DataflowJob, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://dataflow.googleapis.com/v1b3/projects/%s/locations/%s/jobs/%s", ), SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://dataflow.googleapis.com/v1b3/projects/%s/locations/%s/jobs", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://dataflow.googleapis.com/v1b3/projects/%s/jobs:aggregated", ), UniqueAttributeKeys: []string{"locations", "jobs"}, IAMPermissions: []string{"dataflow.jobs.get", "dataflow.jobs.list"}, PredefinedRole: "roles/dataflow.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Pub/Sub links (critical for ENG-3217 outage detection) "jobMetadata.pubsubDetails.topic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or misconfigured: The Dataflow job may fail to read/write messages. If the Dataflow job changes: The topic remains unaffected.", }, "jobMetadata.pubsubDetails.subscription": { ToSDPItemType: gcpshared.PubSubSubscription, Description: "If the Pub/Sub Subscription is deleted or misconfigured: The Dataflow job may fail to consume messages. If the Dataflow job changes: The subscription remains unaffected.", }, // BigQuery links "jobMetadata.bigqueryDetails.table": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery Table is deleted or misconfigured: The Dataflow job may fail to read/write data. If the Dataflow job changes: The table remains unaffected.", }, "jobMetadata.bigqueryDetails.dataset": { ToSDPItemType: gcpshared.BigQueryDataset, Description: "If the BigQuery Dataset is deleted or misconfigured: The Dataflow job may fail to access tables. If the Dataflow job changes: The dataset remains unaffected.", }, // Spanner links "jobMetadata.spannerDetails.instanceId": { ToSDPItemType: gcpshared.SpannerInstance, Description: "If the Spanner Instance is deleted or misconfigured: The Dataflow job may fail to read/write data. If the Dataflow job changes: The instance remains unaffected.", }, // Bigtable links "jobMetadata.bigTableDetails.instanceId": { ToSDPItemType: gcpshared.BigTableAdminInstance, Description: "If the Bigtable Instance is deleted or misconfigured: The Dataflow job may fail to read/write data. If the Dataflow job changes: The instance remains unaffected.", }, // Environment/infra links "environment.serviceAccountEmail": gcpshared.IAMServiceAccountImpactInOnly, "environment.serviceKmsKeyName": gcpshared.CryptoKeyImpactInOnly, "environment.workerPools.network": gcpshared.ComputeNetworkImpactInOnly, "environment.workerPools.subnetwork": { ToSDPItemType: gcpshared.ComputeSubnetwork, Description: "If the Compute Subnetwork is deleted or misconfigured: Dataflow workers may lose connectivity or fail to start. If the Dataflow job changes: The subnetwork remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dataflow_job", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_dataflow_job.job_id", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_dataflow_flex_template_job.job_id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dataflow-job_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestDataflowJob(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() location := "us-central1" jobID := "2024-01-15_test-job-id-123" dataflowJob := map[string]any{ "id": jobID, "name": fmt.Sprintf("projects/%s/locations/%s/jobs/%s", projectID, location, jobID), "type": "JOB_TYPE_STREAMING", "currentState": "JOB_STATE_RUNNING", "currentStateTime": "2024-01-15T10:30:00Z", "environment": map[string]any{ "serviceAccountEmail": fmt.Sprintf("dataflow-sa@%s.iam.gserviceaccount.com", projectID), "serviceKmsKeyName": fmt.Sprintf("projects/%s/locations/%s/keyRings/dataflow-ring/cryptoKeys/dataflow-key", projectID, location), "workerPools": []any{ map[string]any{ "network": fmt.Sprintf("projects/%s/global/networks/dataflow-network", projectID), "subnetwork": fmt.Sprintf("projects/%s/regions/%s/subnetworks/dataflow-subnet", projectID, location), "machineType": "n1-standard-4", "numWorkers": float64(3), }, }, }, "jobMetadata": map[string]any{ "pubsubDetails": []any{ map[string]any{ "topic": fmt.Sprintf("projects/%s/topics/input-topic", projectID), "subscription": fmt.Sprintf("projects/%s/subscriptions/input-subscription", projectID), }, map[string]any{ "topic": fmt.Sprintf("projects/%s/topics/output-topic", projectID), "subscription": fmt.Sprintf("projects/%s/subscriptions/output-subscription", projectID), }, }, "bigqueryDetails": []any{ map[string]any{ "table": fmt.Sprintf("projects/%s/datasets/analytics/tables/events", projectID), "dataset": fmt.Sprintf("projects/%s/datasets/analytics", projectID), }, }, "spannerDetails": []any{ map[string]any{ "instanceId": "spanner-instance-1", }, }, "bigTableDetails": []any{ map[string]any{ "instanceId": "bigtable-instance-1", }, }, }, } jobID2 := "2024-01-15_test-job-id-456" dataflowJob2 := map[string]any{ "id": jobID2, "name": fmt.Sprintf("projects/%s/locations/%s/jobs/%s", projectID, location, jobID2), "type": "JOB_TYPE_BATCH", "currentState": "JOB_STATE_DONE", } dataflowJobsList := map[string]any{ "jobs": []any{dataflowJob, dataflowJob2}, } sdpItemType := gcpshared.DataflowJob expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataflow.googleapis.com/v1b3/projects/%s/locations/%s/jobs/%s", projectID, location, jobID): { StatusCode: http.StatusOK, Body: dataflowJob, }, fmt.Sprintf("https://dataflow.googleapis.com/v1b3/projects/%s/locations/%s/jobs", projectID, location): { StatusCode: http.StatusOK, Body: dataflowJobsList, }, fmt.Sprintf("https://dataflow.googleapis.com/v1b3/projects/%s/jobs:aggregated", projectID): { StatusCode: http.StatusOK, Body: dataflowJobsList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, jobID) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get Dataflow Job: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", getQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/jobs/%s", projectID, location, jobID) if val != expectedName { t.Errorf("Expected name '%s', got %s", expectedName, val) } val, err = sdpItem.GetAttributes().Get("currentState") if err != nil { t.Fatalf("Failed to get 'currentState' attribute: %v", err) } if val != "JOB_STATE_RUNNING" { t.Errorf("Expected currentState 'JOB_STATE_RUNNING', got %s", val) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Pub/Sub topic links (from pubsubDetails array) { ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "input-topic", ExpectedScope: projectID, }, { ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "output-topic", ExpectedScope: projectID, }, // Pub/Sub subscription links (from pubsubDetails array) { ExpectedType: gcpshared.PubSubSubscription.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "input-subscription", ExpectedScope: projectID, }, { ExpectedType: gcpshared.PubSubSubscription.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "output-subscription", ExpectedScope: projectID, }, // BigQuery links { ExpectedType: gcpshared.BigQueryTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("analytics", "events"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "analytics", ExpectedScope: projectID, }, // Spanner instance link (plain name resolves for single-key types) { ExpectedType: gcpshared.SpannerInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "spanner-instance-1", ExpectedScope: projectID, }, // Bigtable instance link (plain name resolves for single-key types) { ExpectedType: gcpshared.BigTableAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "bigtable-instance-1", ExpectedScope: projectID, }, // IAM service account link { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("dataflow-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, // KMS crypto key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, "dataflow-ring", "dataflow-key"), ExpectedScope: projectID, }, // Compute network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dataflow-network", ExpectedScope: projectID, }, // Compute subnetwork link (regional — scope includes region) { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dataflow-subnet", ExpectedScope: fmt.Sprintf("%s.%s", projectID, location), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter is not a SearchableAdapter") } searchQuery := location sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search Dataflow Jobs: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Dataflow Jobs, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } expectedUniqueAttr := shared.CompositeLookupKey(location, jobID) if item.UniqueAttributeValue() != expectedUniqueAttr { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr, item.UniqueAttributeValue()) } } if len(sdpItems) >= 2 { item := sdpItems[1] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } expectedUniqueAttr2 := shared.CompositeLookupKey(location, jobID2) if item.UniqueAttributeValue() != expectedUniqueAttr2 { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr2, item.UniqueAttributeValue()) } } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataflow.googleapis.com/v1b3/projects/%s/locations/%s/jobs/%s", projectID, location, jobID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Job not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, jobID) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent Dataflow Job, but got nil") } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter is not a ListableAdapter") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list Dataflow Jobs: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Dataflow Jobs, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] expectedUniqueAttr := shared.CompositeLookupKey(location, jobID) if item.UniqueAttributeValue() != expectedUniqueAttr { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr, item.UniqueAttributeValue()) } } if len(sdpItems) >= 2 { item := sdpItems[1] expectedUniqueAttr2 := shared.CompositeLookupKey(location, jobID2) if item.UniqueAttributeValue() != expectedUniqueAttr2 { t.Errorf("Expected unique attribute value '%s', got %s", expectedUniqueAttr2, item.UniqueAttributeValue()) } } }) } ================================================ FILE: sources/gcp/dynamic/adapters/dataform-repository.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Dataform Repository adapter for Dataform repositories var _ = registerableAdapter{ sdpType: gcpshared.DataformRepository, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/dataform/reference/rest/v1/projects.locations.repositories/get // GET https://dataform.googleapis.com/v1/projects/*/locations/*/repositories/* // IAM permissions: dataform.repositories.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://dataform.googleapis.com/v1/projects/%s/locations/%s/repositories/%s"), // Reference: https://cloud.google.com/dataform/reference/rest/v1/projects.locations.repositories/list // GET https://dataform.googleapis.com/v1/projects/*/locations/*/repositories SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://dataform.googleapis.com/v1/projects/%s/locations/%s/repositories"), SearchDescription: "Search for Dataform repositories in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/repositories/[repository_name]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "repositories"}, IAMPermissions: []string{"dataform.repositories.get", "dataform.repositories.list"}, PredefinedRole: "roles/dataform.viewer", }, linkRules: map[string]*gcpshared.Impact{ // The name of the Secret Manager secret version to use as an authentication token for Git operations. Must be in the format projects/*/secrets/*/versions/*. "gitRemoteSettings.authenticationTokenSecretVersion": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager Secret is deleted or updated: The Dataform Repository may fail to authenticate with the Git remote. If the Dataform Repository is updated: The secret remains unaffected.", }, // The name of the Secret Manager secret version to use as a ssh private key for Git operations. Must be in the format projects/*/secrets/*/versions/*. "gitRemoteSettings.sshAuthenticationConfig.userPrivateKeySecretVersion": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager Secret is deleted or updated: The Dataform Repository may fail to authenticate with the Git remote. If the Dataform Repository is updated: The secret remains unaffected.", }, // Name of the Secret Manager secret version used to interpolate variables into the .npmrc file for package installation operations. "npmrcEnvironmentVariablesSecretVersion": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager Secret is deleted or updated: The Dataform Repository may fail to install npm packages. If the Dataform Repository is updated: The secret remains unaffected.", }, // The URL of the Git remote repository. Can be HTTPS (e.g., https://github.com/user/repo.git) or SSH (e.g., git@github.com:user/repo.git). "gitRemoteSettings.url": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the Git remote URL becomes inaccessible: The Dataform Repository may fail to sync with the remote. If the Dataform Repository is updated: The Git remote remains unaffected.", }, // The service account to run workflow invocations under. "serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, // The reference to a KMS encryption key. // If provided, it will be used to encrypt user data in the repository and all child resources. // It is not possible to add or update the encryption key after the repository is created. // Example: projects/{kms_project}/locations/{location}/keyRings/{key_location}/cryptoKeys/{key} "kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // A data encryption state of a Git repository if this Repository is protected by a KMS key. "dataEncryptionState.kmsKeyVersionName": gcpshared.CryptoKeyVersionImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dataform_repository", Description: "id => projects/{{project}}/locations/{{region}}/repositories/{{name}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_dataform_repository.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dataform-repository_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" dataform "google.golang.org/api/dataform/v1beta1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestDataformRepository(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() repositoryName := "test-repo" repository := &dataform.Repository{ Name: fmt.Sprintf("projects/%s/locations/%s/repositories/%s", projectID, location, repositoryName), ServiceAccount: "dataform-sa@test-project.iam.gserviceaccount.com", KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", } repositoryList := &dataform.ListRepositoriesResponse{ Repositories: []*dataform.Repository{repository}, } sdpItemType := gcpshared.DataformRepository expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataform.googleapis.com/v1/projects/%s/locations/%s/repositories/%s", projectID, location, repositoryName): { StatusCode: http.StatusOK, Body: repository, }, fmt.Sprintf("https://dataform.googleapis.com/v1/projects/%s/locations/%s/repositories", projectID, location): { StatusCode: http.StatusOK, Body: repositoryList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, repositoryName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get dataform repository: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // serviceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dataform-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, { // kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search dataform repositories: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 dataform repository, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/locations/[location]/repositories/[repository] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/repositories/%s", projectID, location, repositoryName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataform.googleapis.com/v1/projects/%s/locations/%s/repositories/%s", projectID, location, repositoryName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Repository not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, repositoryName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent dataform repository, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/dataplex-aspect-type.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Google Cloud Dataplex Aspect Type defines the structure and metadata schema for aspects that can be attached to assets in Dataplex. // It's part of Google Cloud's data governance and catalog capabilities, allowing users to define custom metadata schemas // for their data assets within Dataplex lakes and zones. // Reference: https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.aspectTypes/get // GET https://dataplex.googleapis.com/v1/projects/{project}/locations/{location}/aspectTypes/{aspectType} // LIST https://dataplex.googleapis.com/v1/projects/{project}/locations/{location}/aspectTypes var _ = registerableAdapter{ sdpType: gcpshared.DataplexAspectType, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://dataplex.googleapis.com/v1/projects/%s/locations/%s/aspectTypes/%s", ), SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://dataplex.googleapis.com/v1/projects/%s/locations/%s/aspectTypes", ), SearchDescription: "Search for Dataplex aspect types in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/aspectTypes/[aspect_type_id]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "aspectTypes"}, IAMPermissions: []string{ "dataplex.aspectTypes.get", "dataplex.aspectTypes.list", }, PredefinedRole: "roles/dataplex.catalogViewer", }, linkRules: map[string]*gcpshared.Impact{ // Based on the AspectType structure from the API documentation, // AspectTypes typically define metadata schemas and don't have direct dependencies // on other GCP resources in their core definition. They are schema definitions // rather than runtime resources. // If future updates add resource dependencies, they would be added here. }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dataplex_aspect_type", Description: "id => projects/{{project}}/locations/{{location}}/aspectTypes/{{aspect_type_id}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_dataplex_aspect_type.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dataplex-aspect-type_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "time" "cloud.google.com/go/dataplex/apiv1/dataplexpb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestDataplexAspectType(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() aspectTypeName := "test-aspect-type" // Mock AspectType using proper GCP SDK types aspectType := &dataplexpb.AspectType{ Name: fmt.Sprintf("projects/%s/locations/%s/aspectTypes/%s", projectID, location, aspectTypeName), Uid: "12345678-1234-1234-1234-123456789012", CreateTime: timestamppb.New(mustParseTime("2023-01-15T10:30:00.000Z")), UpdateTime: timestamppb.New(mustParseTime("2023-01-16T14:20:00.000Z")), DisplayName: "Test Aspect Type", Description: "A test aspect type for unit testing", Labels: map[string]string{ "env": "test", "team": "data-platform", }, Etag: "BwWWja0YfJA=", } // Create a second aspect type for list testing aspectTypeName2 := "test-aspect-type-2" aspectType2 := &dataplexpb.AspectType{ Name: fmt.Sprintf("projects/%s/locations/%s/aspectTypes/%s", projectID, location, aspectTypeName2), Uid: "87654321-4321-4321-4321-210987654321", CreateTime: timestamppb.New(mustParseTime("2023-01-17T09:15:00.000Z")), UpdateTime: timestamppb.New(mustParseTime("2023-01-17T16:45:00.000Z")), DisplayName: "Second Test Aspect Type", Description: "A second test aspect type for list testing", Labels: map[string]string{ "env": "prod", "team": "analytics", }, Etag: "CwXXkb1ZgKB=", } // Create the list response using a map structure instead of the protobuf ListAspectTypesResponse // This is necessary because the dynamic adapter expects JSON-serializable structures // Individual items use proper SDK types, but the list wrapper uses a simple map aspectTypesList := map[string]any{ "aspectTypes": []any{aspectType, aspectType2}, } sdpItemType := gcpshared.DataplexAspectType expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/aspectTypes/%s", projectID, location, aspectTypeName): { StatusCode: http.StatusOK, Body: aspectType, }, fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/aspectTypes", projectID, location): { StatusCode: http.StatusOK, Body: aspectTypesList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := fmt.Sprintf("%s|%s", location, aspectTypeName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get Dataplex aspect type: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", getQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Test key attributes (using snake_case as shown in debug output) val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/aspectTypes/%s", projectID, location, aspectTypeName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } val, err = sdpItem.GetAttributes().Get("displayName") if err != nil { t.Fatalf("Failed to get 'displayName' attribute: %v", err) } if val != "Test Aspect Type" { t.Errorf("Expected displayName field to be 'Test Aspect Type', got %s", val) } val, err = sdpItem.GetAttributes().Get("description") if err != nil { t.Fatalf("Failed to get 'description' attribute: %v", err) } if val != "A test aspect type for unit testing" { t.Errorf("Expected description field to be 'A test aspect type for unit testing', got %s", val) } val, err = sdpItem.GetAttributes().Get("uid") if err != nil { t.Fatalf("Failed to get 'uid' attribute: %v", err) } if val != "12345678-1234-1234-1234-123456789012" { t.Errorf("Expected uid field to be '12345678-1234-1234-1234-123456789012', got %s", val) } // Note: createTime and updateTime are struct values (timestamps), not simple strings // Testing their presence rather than exact format _, err = sdpItem.GetAttributes().Get("createTime") if err != nil { t.Fatalf("Failed to get 'createTime' attribute: %v", err) } _, err = sdpItem.GetAttributes().Get("updateTime") if err != nil { t.Fatalf("Failed to get 'updateTime' attribute: %v", err) } val, err = sdpItem.GetAttributes().Get("etag") if err != nil { t.Fatalf("Failed to get 'etag' attribute: %v", err) } if val != "BwWWja0YfJA=" { t.Errorf("Expected etag field to be 'BwWWja0YfJA=', got %s", val) } // Note: Since this adapter doesn't define link rule relationships, // we don't run StaticTests here. The adapter's link rules map is empty, // which is correct as AspectTypes are schema definitions rather than runtime resources. }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(gcpshared.DataplexAspectType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter is not a SearchableAdapter") } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search Dataplex aspect types: %v", err) } // Verify the first item firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } // Verify the second item secondItem := sdpItems[1] if secondItem.GetType() != sdpItemType.String() { t.Errorf("Expected second item type %s, got %s", sdpItemType.String(), secondItem.GetType()) } if secondItem.GetScope() != projectID { t.Errorf("Expected second item scope '%s', got %s", projectID, secondItem.GetScope()) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(gcpshared.DataplexAspectType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter is not a SearchableAdapter") } // Test Terraform format: projects/[project_id]/locations/[location]/aspectTypes/[aspect_type_id] // The adapter should extract the location from this format and search in that location terraformQuery := fmt.Sprintf("projects/%s/locations/%s/aspectTypes/%s", projectID, location, aspectTypeName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search Dataplex aspect types with Terraform format: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 Dataplex aspect types with Terraform format, got %d", len(sdpItems)) } // Verify the first item firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } expectedFirstUniqueAttr := fmt.Sprintf("%s|%s", location, aspectTypeName) if firstItem.UniqueAttributeValue() != expectedFirstUniqueAttr { t.Errorf("Expected first item unique attribute '%s', got %s", expectedFirstUniqueAttr, firstItem.UniqueAttributeValue()) } }) t.Run("Error handling", func(t *testing.T) { // Test 404 error handling errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/aspectTypes/nonexistent", projectID, location): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": map[string]any{"code": 404, "message": "AspectType not found"}}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := fmt.Sprintf("%s|nonexistent", location) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting nonexistent aspect type, got nil") } }) } // Helper function to parse time strings func mustParseTime(timeStr string) time.Time { t, err := time.Parse(time.RFC3339, timeStr) if err != nil { panic(fmt.Sprintf("Failed to parse time %s: %v", timeStr, err)) } return t } ================================================ FILE: sources/gcp/dynamic/adapters/dataplex-data-scan.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Dataplex Data Scan allows you to perform data quality checks, profiling, and discovery within data assets in Dataplex // GCP Ref (GET): https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.dataScans/get // GCP Ref (Schema): https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.dataScans#DataScan // GET https://dataplex.googleapis.com/v1/projects/{project}/locations/{location}/dataScans/{dataScan} // LIST https://dataplex.googleapis.com/v1/projects/{project}/locations/{location}/dataScans var _ = registerableAdapter{ sdpType: gcpshared.DataplexDataScan, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://dataplex.googleapis.com/v1/projects/%s/locations/%s/dataScans/%s", ), SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://dataplex.googleapis.com/v1/projects/%s/locations/%s/dataScans", ), SearchDescription: "Search for Dataplex data scans in a location. Use the location name e.g., 'us-central1' or the format \"projects/[project_id]/locations/[location]/dataScans/[data_scan_id]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "dataScans"}, IAMPermissions: []string{ "dataplex.dataScans.get", "dataplex.dataScans.list", }, PredefinedRole: "roles/dataplex.viewer", // TODO: https://linear.app/overmind/issue/ENG-631 state // https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.dataScans#DataScan }, linkRules: map[string]*gcpshared.Impact{ // Data source references - can scan various data sources "data.entity": { ToSDPItemType: gcpshared.DataplexEntity, Description: "If the Dataplex Entity is deleted: The data scan will fail to access the data source. If the data scan is updated: The dataplex entity remains unaffected.", }, "data.resource": { // Note: data.resource can reference either a Storage Bucket (for DataDiscoveryScan) // or a BigQuery Table (for DataProfileScan, DataQualityScan, or DataDocumentationScan). // The StorageBucket manual linker will detect BigQuery Table URIs and delegate to // the BigQueryTable linker automatically. ToSDPItemType: gcpshared.StorageBucket, Description: "If the data source (Storage Bucket or BigQuery Table) is deleted or inaccessible: The data scan will fail to access the data source. If the data scan is updated: The data source remains unaffected.", }, // Post-scan action BigQuery table exports "dataQualitySpec.postScanActions.bigqueryExport.resultsTable": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery table for exporting data quality scan results is deleted or inaccessible: The post-scan action will fail. If the data scan is updated: The BigQuery table remains unaffected.", }, "dataProfileSpec.postScanActions.bigqueryExport.resultsTable": { ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery table for exporting data profile scan results is deleted or inaccessible: The post-scan action will fail. If the data scan is updated: The BigQuery table remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dataplex_datascan", Description: "id => projects/{{project}}/locations/{{location}}/dataScans/{{data_scan_id}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_dataplex_datascan.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dataplex-data-scan_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/dataplex/apiv1/dataplexpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestDataplexDataScan(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() dataScanName := "test-data-scan" // Create mock protobuf object with storage bucket resource bucketName := "test-bucket" dataScan := &dataplexpb.DataScan{ Name: fmt.Sprintf("projects/%s/locations/%s/dataScans/%s", projectID, location, dataScanName), Data: &dataplexpb.DataSource{ Source: &dataplexpb.DataSource_Resource{ Resource: bucketName, }, }, } // Create second data scan for search testing dataScanName2 := "test-data-scan-2" dataScan2 := &dataplexpb.DataScan{ Name: fmt.Sprintf("projects/%s/locations/%s/dataScans/%s", projectID, location, dataScanName2), Data: &dataplexpb.DataSource{ Source: &dataplexpb.DataSource_Resource{ Resource: "test-bucket", }, }, } // Create list response with multiple items dataScanList := &dataplexpb.ListDataScansResponse{ DataScans: []*dataplexpb.DataScan{dataScan, dataScan2}, } sdpItemType := gcpshared.DataplexDataScan // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/dataScans/%s", projectID, location, dataScanName): { StatusCode: http.StatusOK, Body: dataScan, }, fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/dataScans/%s", projectID, location, dataScanName2): { StatusCode: http.StatusOK, Body: dataScan2, }, fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/dataScans", projectID, location): { StatusCode: http.StatusOK, Body: dataScanList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, dataScanName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Storage bucket link { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: bucketName, ExpectedScope: projectID, }, // Note: data.entity link also exists but DataplexEntity adapter doesn't exist yet } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } terraformQuery := fmt.Sprintf("projects/%s/locations/%s/dataScans/%s", projectID, location, dataScanName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/dataScans/%s", projectID, location, dataScanName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Data scan not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, dataScanName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/dataplex-entry-group.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Dataplex Entry Group adapter for Dataplex entry groups var _ = registerableAdapter{ sdpType: gcpshared.DataplexEntryGroup, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.entryGroups/get // GET https://dataplex.googleapis.com/v1/{name=projects/*/locations/*/entryGroups/*} // IAM permissions: dataplex.entryGroups.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/entryGroups/%s"), // Reference: https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.entryGroups/list // GET https://dataplex.googleapis.com/v1/{parent=projects/*/locations/*}/entryGroups SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/entryGroups"), SearchDescription: "Search for Dataplex entry groups in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/entryGroups/[entry_group_id]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "entryGroups"}, // HEALTH: https://cloud.google.com/dataplex/docs/reference/rest/v1/TransferStatus // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"dataplex.entryGroups.get", "dataplex.entryGroups.list"}, PredefinedRole: "roles/dataplex.catalogViewer", }, linkRules: map[string]*gcpshared.Impact{ // There is no links for this item type. }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dataplex_entry_group#entry_group_id", Description: "id => projects/{{project}}/locations/{{location}}/entryGroups/{{entry_group_id}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_dataplex_entry_group.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dataplex-entry-group_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/dataplex/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestDataplexEntryGroup(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() entryGroupID := "test-entry-group" entryGroup := &dataplex.GoogleCloudDataplexV1EntryGroup{ Name: fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s", projectID, location, entryGroupID), } entryGroupList := &dataplex.GoogleCloudDataplexV1ListEntryGroupsResponse{ EntryGroups: []*dataplex.GoogleCloudDataplexV1EntryGroup{entryGroup}, } sdpItemType := gcpshared.DataplexEntryGroup expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/entryGroups/%s", projectID, location, entryGroupID): { StatusCode: http.StatusOK, Body: entryGroup, }, fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/entryGroups", projectID, location): { StatusCode: http.StatusOK, Body: entryGroupList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, entryGroupID) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get dataplex entry group: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search dataplex entry groups: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 dataplex entry group, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/locations/[location]/entryGroups/[entry_group_id] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s", projectID, location, entryGroupID) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataplex.googleapis.com/v1/projects/%s/locations/%s/entryGroups/%s", projectID, location, entryGroupID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Entry group not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, entryGroupID) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent dataplex entry group, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/dataproc-auto-scaling-policy.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Dataproc AutoscalingPolicy adapter - manages autoscaling behavior for Dataproc clusters // API Get: https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.autoscalingPolicies/get // API List: https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.autoscalingPolicies/list // GET https://dataproc.googleapis.com/v1/projects/{project}/regions/{region}/autoscalingPolicies/{autoscalingPolicyId} // LIST https://dataproc.googleapis.com/v1/projects/{project}/regions/{region}/autoscalingPolicies var _ = registerableAdapter{ sdpType: gcpshared.DataprocAutoscalingPolicy, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.RegionalLevel, GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc( "https://dataproc.googleapis.com/v1/projects/%s/regions/%s/autoscalingPolicies/%s", ), ListEndpointFunc: gcpshared.RegionLevelListFunc( "https://dataproc.googleapis.com/v1/projects/%s/regions/%s/autoscalingPolicies", ), UniqueAttributeKeys: []string{"autoscalingPolicies"}, ListResponseSelector: "policies", IAMPermissions: []string{ "dataproc.autoscalingPolicies.get", "dataproc.autoscalingPolicies.list", }, PredefinedRole: "roles/dataproc.viewer", }, linkRules: map[string]*gcpshared.Impact{ // AutoscalingPolicies don't directly reference other resources, // but they are referenced by Dataproc clusters via config.autoscalingConfig.policyUri // The reverse relationship is handled in the cluster adapter }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dataproc_autoscaling_policy", Description: "Use GET by autoscaling policy name.", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_dataproc_autoscaling_policy.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dataproc-auto-scaling-policy_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/dataproc/v2/apiv1/dataprocpb" "google.golang.org/protobuf/types/known/durationpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestDataprocAutoscalingPolicy(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" linker := gcpshared.NewLinker() policyName := "test-policy" policy := &dataprocpb.AutoscalingPolicy{ Id: policyName, Name: fmt.Sprintf("projects/%s/regions/%s/autoscalingPolicies/%s", projectID, region, policyName), Algorithm: &dataprocpb.AutoscalingPolicy_BasicAlgorithm{ BasicAlgorithm: &dataprocpb.BasicAutoscalingAlgorithm{ Config: &dataprocpb.BasicAutoscalingAlgorithm_YarnConfig{ YarnConfig: &dataprocpb.BasicYarnAutoscalingConfig{ GracefulDecommissionTimeout: durationpb.New(300_000_000_000), // 300s ScaleUpFactor: 0.8, ScaleDownFactor: 0.5, ScaleUpMinWorkerFraction: 0.1, ScaleDownMinWorkerFraction: 0.05, }, }, CooldownPeriod: durationpb.New(120_000_000_000), // 120s }, }, WorkerConfig: &dataprocpb.InstanceGroupAutoscalingPolicyConfig{ MinInstances: 2, MaxInstances: 10, Weight: 2, }, SecondaryWorkerConfig: &dataprocpb.InstanceGroupAutoscalingPolicyConfig{ MinInstances: 0, MaxInstances: 5, Weight: 1, }, Labels: map[string]string{ "environment": "test", "team": "engineering", }, } policyName2 := "test-policy-2" policy2 := &dataprocpb.AutoscalingPolicy{ Id: policyName2, Name: fmt.Sprintf("projects/%s/regions/%s/autoscalingPolicies/%s", projectID, region, policyName2), Algorithm: &dataprocpb.AutoscalingPolicy_BasicAlgorithm{ BasicAlgorithm: &dataprocpb.BasicAutoscalingAlgorithm{ Config: &dataprocpb.BasicAutoscalingAlgorithm_YarnConfig{ YarnConfig: &dataprocpb.BasicYarnAutoscalingConfig{ GracefulDecommissionTimeout: durationpb.New(600_000_000_000), // 600s ScaleUpFactor: 1.0, ScaleDownFactor: 0.3, }, }, CooldownPeriod: durationpb.New(180_000_000_000), // 180s }, }, WorkerConfig: &dataprocpb.InstanceGroupAutoscalingPolicyConfig{ MinInstances: 3, MaxInstances: 15, Weight: 1, }, } policyList := &dataprocpb.ListAutoscalingPoliciesResponse{ Policies: []*dataprocpb.AutoscalingPolicy{policy, policy2}, } sdpItemType := gcpshared.DataprocAutoscalingPolicy expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/autoscalingPolicies/%s", projectID, region, policyName): { StatusCode: http.StatusOK, Body: policy, }, fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/autoscalingPolicies/%s", projectID, region, policyName2): { StatusCode: http.StatusOK, Body: policy2, }, fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/autoscalingPolicies", projectID, region): { StatusCode: http.StatusOK, Body: policyList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), policyName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != policyName { t.Errorf("Expected unique attribute value '%s', got %s", policyName, sdpItem.UniqueAttributeValue()) } // Skip static tests - no link rules for this adapter }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/autoscalingPolicies/%s", projectID, region, policyName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Policy not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), policyName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/dataproc-cluster.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Dataproc Cluster adapter // API Get: https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.clusters/get // API List: https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.clusters/list // GET https://dataproc.googleapis.com/v1/projects/{project}/regions/{region}/clusters/{cluster} // LIST https://dataproc.googleapis.com/v1/projects/{project}/regions/{region}/clusters var _ = registerableAdapter{ sdpType: gcpshared.DataprocCluster, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.RegionalLevel, GetEndpointFunc: gcpshared.RegionalLevelEndpointFunc( "https://dataproc.googleapis.com/v1/projects/%s/regions/%s/clusters/%s", ), ListEndpointFunc: gcpshared.RegionLevelListFunc( "https://dataproc.googleapis.com/v1/projects/%s/regions/%s/clusters", ), UniqueAttributeKeys: []string{"clusters"}, IAMPermissions: []string{ "dataproc.clusters.get", "dataproc.clusters.list", }, PredefinedRole: "roles/dataproc.viewer", NameSelector: "clusterName", // https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.clusters#resource:-cluster // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.clusters#clusterstatus }, linkRules: map[string]*gcpshared.Impact{ "config.gceClusterConfig.networkUri": gcpshared.ComputeNetworkImpactInOnly, "config.gceClusterConfig.subnetworkUri": gcpshared.ComputeSubnetworkImpactInOnly, "config.gceClusterConfig.serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, "config.encryptionConfig.gcePdKmsKeyName": gcpshared.CryptoKeyImpactInOnly, "config.encryptionConfig.kmsKey": gcpshared.CryptoKeyImpactInOnly, "config.masterConfig.imageUri": { ToSDPItemType: gcpshared.ComputeImage, Description: "If the Image is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.masterConfig.managedGroupConfig.instanceGroupManagerUri": { ToSDPItemType: gcpshared.ComputeInstanceGroupManager, Description: "If the Instance Group Manager is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.masterConfig.accelerators.acceleratorTypeUri": { ToSDPItemType: gcpshared.ComputeAcceleratorType, Description: "If the Accelerator Type is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.workerConfig.imageUri": { ToSDPItemType: gcpshared.ComputeImage, Description: "If the Image is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.workerConfig.managedGroupConfig.instanceGroupManagerUri": { ToSDPItemType: gcpshared.ComputeInstanceGroupManager, Description: "If the Instance Group Manager is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.workerConfig.accelerators.acceleratorTypeUri": { ToSDPItemType: gcpshared.ComputeAcceleratorType, Description: "If the Accelerator Type is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.secondaryWorkerConfig.imageUri": { ToSDPItemType: gcpshared.ComputeImage, Description: "If the Image is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.secondaryWorkerConfig.managedGroupConfig.instanceGroupManagerUri": { ToSDPItemType: gcpshared.ComputeInstanceGroupManager, Description: "If the Instance Group Manager is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.secondaryWorkerConfig.accelerators.acceleratorTypeUri": { ToSDPItemType: gcpshared.ComputeAcceleratorType, Description: "If the Accelerator Type is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.autoscalingConfig.policyUri": { ToSDPItemType: gcpshared.DataprocAutoscalingPolicy, Description: "If the Autoscaling Policy is deleted or updated: The cluster may fail to scale. If the cluster is updated: The existing nodes remain unaffected.", }, "config.auxiliaryNodeGroups.nodeGroup.name": { ToSDPItemType: gcpshared.ComputeNodeGroup, Description: "If the Node Group is deleted or updated: The cluster may fail to create new nodes. If the cluster is updated: The existing nodes remain unaffected.", }, "config.tempBucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket is deleted or updated: The cluster may fail to stage data or logs. If the cluster is updated: The bucket remains unaffected.", }, "config.stagingBucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket is deleted or updated: The cluster may fail to stage job dependencies, configuration files, or job driver console output. If the cluster is updated: The bucket remains unaffected.", }, "config.metastoreConfig.dataprocMetastoreService": { ToSDPItemType: gcpshared.DataprocMetastoreService, Description: "If the Dataproc Metastore Service is deleted or updated: The cluster may lose access to centralized metadata or fail to operate correctly. If the cluster is updated: The metastore service remains unaffected.", }, "virtualClusterConfig.kubernetesClusterConfig.gkeClusterConfig.gkeClusterTarget": { ToSDPItemType: gcpshared.ContainerCluster, Description: "If the GKE Cluster is deleted or updated: The Dataproc virtual cluster may become invalid or inaccessible. If the Dataproc cluster is updated: The GKE cluster remains unaffected.", }, "virtualClusterConfig.kubernetesClusterConfig.gkeClusterConfig.nodePoolTarget.nodePool": { ToSDPItemType: gcpshared.ContainerNodePool, Description: "If the GKE Node Pool is deleted or updated: The Dataproc virtual cluster may fail to schedule workloads or lose capacity. If the Dataproc cluster is updated: The node pool remains unaffected.", }, "virtualClusterConfig.stagingBucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket is deleted or updated: The virtual cluster may fail to stage job dependencies, configuration files, or job driver console output. If the cluster is updated: The bucket remains unaffected.", }, "virtualClusterConfig.auxiliaryServicesConfig.sparkHistoryServerConfig.dataprocCluster": { ToSDPItemType: gcpshared.DataprocCluster, Description: "If the Spark History Server Dataproc Cluster is deleted or updated: The cluster may lose access to Spark job history or fail to monitor Spark applications. If the cluster is updated: The Spark History Server cluster remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dataproc_cluster", Description: "Use GET by cluster name.", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_dataproc_cluster.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dataproc-cluster_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/dataproc/v2/apiv1/dataprocpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestDataprocCluster(t *testing.T) { ctx := context.Background() projectID := "test-project" region := "us-central1" zone := region + "-a" linker := gcpshared.NewLinker() clusterName := "test-cluster" cluster := &dataprocpb.Cluster{ ClusterName: clusterName, Config: &dataprocpb.ClusterConfig{ GceClusterConfig: &dataprocpb.GceClusterConfig{ NetworkUri: fmt.Sprintf("projects/%s/global/networks/default", projectID), SubnetworkUri: fmt.Sprintf("projects/%s/regions/%s/subnetworks/default-subnet", projectID, region), ServiceAccount: "test-sa@test-project.iam.gserviceaccount.com", }, EncryptionConfig: &dataprocpb.EncryptionConfig{ GcePdKmsKeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, MasterConfig: &dataprocpb.InstanceGroupConfig{ ImageUri: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/master-dataproc-image", projectID), MachineTypeUri: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/machineTypes/n1-standard-4", projectID, zone), Accelerators: []*dataprocpb.AcceleratorConfig{ { AcceleratorTypeUri: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/acceleratorTypes/nvidia-tesla-t4", projectID, zone), }, }, }, WorkerConfig: &dataprocpb.InstanceGroupConfig{ ImageUri: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/worker-dataproc-image", projectID), MachineTypeUri: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/machineTypes/n1-standard-8", projectID, zone), }, SecondaryWorkerConfig: &dataprocpb.InstanceGroupConfig{ ImageUri: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/secondary-dataproc-image", projectID), MachineTypeUri: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/machineTypes/n1-standard-2", projectID, zone), }, AutoscalingConfig: &dataprocpb.AutoscalingConfig{ PolicyUri: fmt.Sprintf("projects/%s/regions/%s/autoscalingPolicies/test-policy", projectID, region), }, TempBucket: "test-temp-bucket", }, } clusterName2 := "test-cluster-2" cluster2 := &dataprocpb.Cluster{ ClusterName: clusterName2, } clusterList := &dataprocpb.ListClustersResponse{ Clusters: []*dataprocpb.Cluster{cluster, cluster2}, } sdpItemType := gcpshared.DataprocCluster expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/clusters/%s", projectID, region, clusterName): { StatusCode: http.StatusOK, Body: cluster, }, fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/clusters/%s", projectID, region, clusterName2): { StatusCode: http.StatusOK, Body: cluster2, }, fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/clusters", projectID, region): { StatusCode: http.StatusOK, Body: clusterList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), clusterName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != clusterName { t.Errorf("Expected unique attribute value '%s', got %s", clusterName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default-subnet", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // Master config (SEARCH with full ImageUri) { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/master-dataproc-image", projectID), ExpectedScope: projectID, }, // Master accelerator { ExpectedType: gcpshared.ComputeAcceleratorType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "nvidia-tesla-t4", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, // Worker config (SEARCH with full ImageUri) { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/worker-dataproc-image", projectID), ExpectedScope: projectID, }, // Secondary worker config (SEARCH with full ImageUri) { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/secondary-dataproc-image", projectID), ExpectedScope: projectID, }, { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-temp-bucket", ExpectedScope: projectID, }, { ExpectedType: gcpshared.DataprocAutoscalingPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dataproc.googleapis.com/v1/projects/%s/regions/%s/clusters/%s", projectID, region, clusterName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Cluster not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), clusterName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/dns-managed-zone.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // DNS Managed Zone adapter for Cloud DNS managed zones var _ = registerableAdapter{ sdpType: gcpshared.DNSManagedZone, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/dns/docs/reference/rest/v1/managedZones/get // GET https://dns.googleapis.com/dns/v1/projects/{project}/managedZones/{managedZone} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://dns.googleapis.com/dns/v1/projects/%s/managedZones/%s"), // Reference: https://cloud.google.com/dns/docs/reference/rest/v1/managedZones/list // GET https://dns.googleapis.com/dns/v1/projects/{project}/managedZones ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://dns.googleapis.com/dns/v1/projects/%s/managedZones"), UniqueAttributeKeys: []string{"managedZones"}, IAMPermissions: []string{"dns.managedZones.get", "dns.managedZones.list"}, PredefinedRole: "roles/dns.reader", }, linkRules: map[string]*gcpshared.Impact{ "dnsName": { ToSDPItemType: stdlib.NetworkDNS, Description: "Tightly coupled with the DNS Managed Zone.", }, // nameServers is an array of DNS names assigned to the managed zone (output only) "nameServers": { ToSDPItemType: stdlib.NetworkDNS, Description: "Nameservers assigned to the managed zone are tightly coupled with the DNS Managed Zone.", }, "privateVisibilityConfig.networks.networkUrl": gcpshared.ComputeNetworkImpactInOnly, // The resource name of the cluster to bind this ManagedZone to. This should be specified in the format like: projects/*/locations/*/clusters/*. // This is referenced from GKE projects.locations.clusters.get // API: https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters/get "privateVisibilityConfig.gkeClusters.gkeClusterName": { ToSDPItemType: gcpshared.ContainerCluster, Description: "If the GKE Container Cluster is deleted or updated: The DNS Managed Zone may lose visibility for that cluster or fail to resolve names. If the DNS Managed Zone is updated: The cluster remains unaffected.", }, "forwardingConfig.targetNameServers.ipv4Address": gcpshared.IPImpactBothWays, "forwardingConfig.targetNameServers.ipv6Address": gcpshared.IPImpactBothWays, // The presence of this field indicates that DNS Peering is enabled for this zone. The value of this field contains the network to peer with. "peeringConfig.targetNetwork.networkUrl": gcpshared.ComputeNetworkImpactInOnly, // This field links to the associated service directory namespace. // The fully qualified URL of the namespace associated with the zone. // Format must be https://servicedirectory.googleapis.com/v1/projects/{project}/locations/{location}/namespaces/{namespace} "serviceDirectoryConfig.namespace.namespaceUrl": { ToSDPItemType: gcpshared.ServiceDirectoryNamespace, Description: "If the Service Directory Namespace is deleted or updated: The DNS Managed Zone may lose its association or fail to resolve names. If the DNS Managed Zone is updated: The namespace remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dns_managed_zone#name", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_dns_managed_zone.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/dns-managed-zone_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/dns/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestDNSManagedZone(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() zoneName := "test-zone" managedZone := &dns.ManagedZone{ Name: zoneName, DnsName: "example.com.", PrivateVisibilityConfig: &dns.ManagedZonePrivateVisibilityConfig{ Networks: []*dns.ManagedZonePrivateVisibilityConfigNetwork{ { NetworkUrl: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/default", }, }, }, ForwardingConfig: &dns.ManagedZoneForwardingConfig{ TargetNameServers: []*dns.ManagedZoneForwardingConfigNameServerTarget{ { Ipv4Address: "10.0.0.10", }, { Ipv6Address: "2001:db8::1", }, }, }, PeeringConfig: &dns.ManagedZonePeeringConfig{ TargetNetwork: &dns.ManagedZonePeeringConfigTargetNetwork{ NetworkUrl: "https://www.googleapis.com/compute/v1/projects/test-project/global/networks/peering-network", }, }, } zoneList := &dns.ManagedZonesListResponse{ ManagedZones: []*dns.ManagedZone{managedZone}, } sdpItemType := gcpshared.DNSManagedZone expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dns.googleapis.com/dns/v1/projects/%s/managedZones/%s", projectID, zoneName): { StatusCode: http.StatusOK, Body: managedZone, }, fmt.Sprintf("https://dns.googleapis.com/dns/v1/projects/%s/managedZones", projectID): { StatusCode: http.StatusOK, Body: zoneList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, zoneName, true) if err != nil { t.Fatalf("Failed to get DNS managed zone: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // dnsName ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "example.com.", ExpectedScope: "global", }, { // privateVisibilityConfig.networks.networkUrl ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // TODO: Add test for privateVisibilityConfig.gkeClusters.gkeClusterName → ContainerCluster // Link from adapter (ToSDPItemType only) { // forwardingConfig.targetNameServers.ipv4Address ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.10", ExpectedScope: "global", }, { // forwardingConfig.targetNameServers.ipv6Address ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:db8::1", ExpectedScope: "global", }, { // peeringConfig.targetNetwork.networkUrl ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "peering-network", ExpectedScope: projectID, }, // TODO: Add test for serviceDirectoryConfig.namespace.namespaceUrl → ServiceDirectoryNamespace // Requires ServiceDirectoryNamespace adapter to be implemented first } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list DNS managed zones: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 DNS managed zone, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://dns.googleapis.com/dns/v1/projects/%s/managedZones/%s", projectID, zoneName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Managed zone not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, zoneName, true) if err == nil { t.Error("Expected error when getting non-existent DNS managed zone, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/essential-contacts-contact.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Essential Contacts Contact adapter for essential contacts var _ = registerableAdapter{ sdpType: gcpshared.EssentialContactsContact, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OTHER, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/resource-manager/docs/reference/essentialcontacts/rest/v1/projects.contacts/get // GET https://essentialcontacts.googleapis.com/v1/projects/*/contacts/* // IAM permissions: essentialcontacts.contacts.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://essentialcontacts.googleapis.com/v1/projects/%s/contacts/%s"), // Reference: https://cloud.google.com/resource-manager/docs/reference/essentialcontacts/rest/v1/projects.contacts/list // GET https://essentialcontacts.googleapis.com/v1/projects/*/contacts // IAM permissions: essentialcontacts.contacts.list ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://essentialcontacts.googleapis.com/v1/projects/%s/contacts"), // This is a special case where we have to define the SEARCH method for only to support Terraform Mapping. // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, SearchDescription: "Search for contacts by their ID in the form of \"projects/[project_id]/contacts/[contact_id]\".", UniqueAttributeKeys: []string{"contacts"}, // HEALTH: https://cloud.google.com/resource-manager/docs/reference/essentialcontacts/rest/v1/folders.contacts#validationstate // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"essentialcontacts.contacts.get", "essentialcontacts.contacts.list"}, PredefinedRole: "roles/essentialcontacts.viewer", }, linkRules: map[string]*gcpshared.Impact{ // There is no links for this item type. }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/essential_contacts_contact#email", Description: "id => {resourceType}/{resource_id}/contacts/{contact_id}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_essential_contacts_contact.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/essential-contacts-contact_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/essentialcontacts/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestEssentialContactsContact(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() contactID := "test-contact" contact := &essentialcontacts.GoogleCloudEssentialcontactsV1Contact{ Name: fmt.Sprintf("projects/%s/contacts/%s", projectID, contactID), Email: "admin@example.com", } contactList := &essentialcontacts.GoogleCloudEssentialcontactsV1ListContactsResponse{ Contacts: []*essentialcontacts.GoogleCloudEssentialcontactsV1Contact{contact}, } sdpItemType := gcpshared.EssentialContactsContact expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://essentialcontacts.googleapis.com/v1/projects/%s/contacts/%s", projectID, contactID): { StatusCode: http.StatusOK, Body: contact, }, fmt.Sprintf("https://essentialcontacts.googleapis.com/v1/projects/%s/contacts", projectID): { StatusCode: http.StatusOK, Body: contactList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, contactID, true) if err != nil { t.Fatalf("Failed to get contact: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list contacts: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 contact, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/contacts/[contact] terraformQuery := fmt.Sprintf("projects/%s/contacts/%s", projectID, contactID) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://essentialcontacts.googleapis.com/v1/projects/%s/contacts/%s", projectID, contactID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Contact not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, contactID, true) if err == nil { t.Error("Expected error when getting non-existent contact, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/eventarc-trigger.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Eventarc Trigger adapter (IN DEVELOPMENT) // Reference: https://cloud.google.com/eventarc/docs/reference/rest/v1/projects.locations.triggers/get // GET: https://eventarc.googleapis.com/v1/projects/{project}/locations/{location}/triggers/{trigger} // LIST: https://eventarc.googleapis.com/v1/projects/{project}/locations/{location}/triggers var eventarcTriggerAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.EventarcTrigger, meta: gcpshared.AdapterMeta{ InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://eventarc.googleapis.com/v1/projects/%s/locations/%s/triggers/%s", ), // LIST all triggers across all locations using wildcard ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://eventarc.googleapis.com/v1/projects/%s/locations/-/triggers", ), // List requires only the location (region or global) besides project. SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://eventarc.googleapis.com/v1/projects/%s/locations/%s/triggers", ), UniqueAttributeKeys: []string{"locations", "triggers"}, IAMPermissions: []string{ "eventarc.triggers.get", "eventarc.triggers.list", }, PredefinedRole: "roles/eventarc.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Service account used by the trigger to invoke the target service "serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, // Channel associated with the trigger for event delivery "channel": { ToSDPItemType: gcpshared.EventarcChannel, Description: "If the Eventarc Channel is deleted or updated: The trigger may fail to receive events. If the trigger is updated: The channel remains unaffected.", }, // Cloud Run Service destination "destination.cloudRunService.service": { ToSDPItemType: gcpshared.RunService, Description: "If the Cloud Run Service is deleted or updated: The trigger may fail to deliver events to the service. If the trigger is updated: The service remains unaffected.", }, // Cloud Function destination (fully qualified resource name) "destination.cloudFunction": { ToSDPItemType: gcpshared.CloudFunctionsFunction, Description: "If the Cloud Function is deleted or updated: The trigger may fail to deliver events to the function. If the trigger is updated: The function remains unaffected.", }, // GKE Cluster destination "destination.gke.cluster": { ToSDPItemType: gcpshared.ContainerCluster, Description: "If the GKE Cluster is deleted or updated: The trigger may fail to deliver events to services in the cluster. If the trigger is updated: The cluster remains unaffected.", }, // Workflow destination (fully qualified resource name) "destination.workflow": { ToSDPItemType: gcpshared.WorkflowsWorkflow, Description: "If the Workflow is deleted or updated: The trigger may fail to invoke the workflow. If the trigger is updated: The workflow remains unaffected.", }, // HTTP endpoint URI (standard library resource) "destination.httpEndpoint.uri": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the HTTP endpoint is unavailable or misconfigured: The trigger may fail to deliver events. If the trigger is updated: The HTTP endpoint remains unaffected.", }, // Network Attachment for VPC-internal HTTP endpoints "destination.httpEndpoint.networkConfig.networkAttachment": { ToSDPItemType: gcpshared.ComputeNetworkAttachment, Description: "If the Network Attachment is deleted or updated: The trigger may fail to access VPC-internal HTTP endpoints. If the trigger is updated: The network attachment remains unaffected.", }, // Pub/Sub Topic used as transport mechanism "transport.pubsub.topic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or updated: The trigger may fail to transport events. If the trigger is updated: The topic remains unaffected.", }, // Pub/Sub Subscription created and managed by Eventarc (output only) "transport.pubsub.subscription": { ToSDPItemType: gcpshared.PubSubSubscription, Description: "If the Pub/Sub Subscription is deleted or updated: The trigger may fail to receive events from the topic. If the trigger is updated: The subscription may be recreated or updated by Eventarc.", }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/eventarc-trigger_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/eventarc/apiv1/eventarcpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestEventarcTrigger(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() triggerName := "test-trigger" trigger := &eventarcpb.Trigger{ Name: fmt.Sprintf("projects/%s/locations/%s/triggers/%s", projectID, location, triggerName), ServiceAccount: fmt.Sprintf("test-sa@%s.iam.gserviceaccount.com", projectID), } triggerName2 := "test-trigger-2" trigger2 := &eventarcpb.Trigger{ Name: fmt.Sprintf("projects/%s/locations/%s/triggers/%s", projectID, location, triggerName2), } triggerList := &eventarcpb.ListTriggersResponse{ Triggers: []*eventarcpb.Trigger{trigger, trigger2}, } sdpItemType := gcpshared.EventarcTrigger expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://eventarc.googleapis.com/v1/projects/%s/locations/%s/triggers/%s", projectID, location, triggerName): { StatusCode: http.StatusOK, Body: trigger, }, fmt.Sprintf("https://eventarc.googleapis.com/v1/projects/%s/locations/%s/triggers/%s", projectID, location, triggerName2): { StatusCode: http.StatusOK, Body: trigger2, }, fmt.Sprintf("https://eventarc.googleapis.com/v1/projects/%s/locations/%s/triggers", projectID, location): { StatusCode: http.StatusOK, Body: triggerList, }, fmt.Sprintf("https://eventarc.googleapis.com/v1/projects/%s/locations/-/triggers", projectID): { StatusCode: http.StatusOK, Body: triggerList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, triggerName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get Eventarc trigger: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/triggers/%s", projectID, location, triggerName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("test-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search Eventarc triggers: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Eventarc triggers, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } if item.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, item.GetScope()) } } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list Eventarc triggers: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Eventarc triggers, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } if item.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, item.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://eventarc.googleapis.com/v1/projects/%s/locations/%s/triggers/%s", projectID, location, triggerName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Trigger not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, triggerName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent Eventarc trigger, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/file-instance.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Cloud File Instance adapter // Cloud File provides managed NFS file servers for applications that require a filesystem interface and a shared filesystem for data. // // Adapter for GCP Cloud File Instance // API Get: https://cloud.google.com/filestore/docs/reference/rest/v1/projects.locations.instances/get // API List: https://cloud.google.com/filestore/docs/reference/rest/v1/projects.locations.instances/list var _ = registerableAdapter{ sdpType: gcpshared.FileInstance, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, // Project-level adapter (uses locations path parameter) LocationLevel: gcpshared.ProjectLevel, // GET https://file.googleapis.com/v1/projects/{project}/locations/{location}/instances/{instance} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://file.googleapis.com/v1/projects/%s/locations/%s/instances/%s", ), // LIST all instances across all locations using wildcard ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://file.googleapis.com/v1/projects/%s/locations/-/instances", ), // Search (per-location) https://file.googleapis.com/v1/projects/{project}/locations/{location}/instances SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://file.googleapis.com/v1/projects/%s/locations/%s/instances", ), SearchDescription: "Search for Filestore instances in a location. Use the location string or the full resource name supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "instances"}, IAMPermissions: []string{ "file.instances.get", "file.instances.list", }, PredefinedRole: "roles/file.viewer", // TODO: https://linear.app/overmind/issue/ENG-631 => state }, linkRules: map[string]*gcpshared.Impact{ "networks.network": gcpshared.ComputeNetworkImpactInOnly, "networks.ipAddresses": gcpshared.IPImpactBothWays, "kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "fileShares.sourceBackup": { ToSDPItemType: gcpshared.FileBackup, Description: "If the referenced Backup is deleted or updated: Restores or incremental backups may fail. If the File instance is updated: The backup remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/filestore_instance", Description: "id => projects/{{project}}/locations/{{location}}/instances/{{name}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_filestore_instance.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/file-instance_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/filestore/apiv1/filestorepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestFileInstance(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() instanceName := "test-filestore" instance := &filestorepb.Instance{ Name: fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName), Networks: []*filestorepb.NetworkConfig{ { Network: fmt.Sprintf("projects/%s/global/networks/default", projectID), IpAddresses: []string{"10.0.0.100"}, }, }, KmsKeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", FileShares: []*filestorepb.FileShareConfig{ { Source: &filestorepb.FileShareConfig_SourceBackup{ SourceBackup: fmt.Sprintf("projects/%s/locations/%s/backups/test-backup", projectID, location), }, }, }, } instanceName2 := "test-filestore-2" instance2 := &filestorepb.Instance{ Name: fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName2), } instanceList := &filestorepb.ListInstancesResponse{ Instances: []*filestorepb.Instance{instance, instance2}, } sdpItemType := gcpshared.FileInstance // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://file.googleapis.com/v1/projects/%s/locations/%s/instances/%s", projectID, location, instanceName): { StatusCode: http.StatusOK, Body: instance, }, fmt.Sprintf("https://file.googleapis.com/v1/projects/%s/locations/%s/instances/%s", projectID, location, instanceName2): { StatusCode: http.StatusOK, Body: instance2, }, fmt.Sprintf("https://file.googleapis.com/v1/projects/%s/locations/%s/instances", projectID, location): { StatusCode: http.StatusOK, Body: instanceList, }, fmt.Sprintf("https://file.googleapis.com/v1/projects/%s/locations/-/instances", projectID): { StatusCode: http.StatusOK, Body: instanceList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // For multiple query parameters, use the combined query format combinedQuery := shared.CompositeLookupKey(location, instanceName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // IP address link { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.100", ExpectedScope: "global", }, // KMS key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test location-based search sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project_id]/locations/[location]/instances/[instance] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list Filestore instances: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Filestore instances, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } if item.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, item.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://file.googleapis.com/v1/projects/%s/locations/%s/instances/%s", projectID, location, instanceName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Instance not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, instanceName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/iam-role.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // IAM Role adapter for custom IAM roles var _ = registerableAdapter{ sdpType: gcpshared.IAMRole, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/iam/docs/reference/rest/v1/roles/get // https://iam.googleapis.com/v1/projects/{PROJECT_ID}/roles/{CUSTOM_ROLE_ID} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://iam.googleapis.com/v1/projects/%s/roles/%s"), // Reference: https://cloud.google.com/iam/docs/reference/rest/v1/roles/list // https://iam.googleapis.com/v1/projects/{PROJECT_ID}/roles ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://iam.googleapis.com/v1/projects/%s/roles"), UniqueAttributeKeys: []string{"roles"}, IAMPermissions: []string{"iam.roles.get", "iam.roles.list"}, PredefinedRole: "roles/iam.roleViewer", }, linkRules: map[string]*gcpshared.Impact{ // There is no links for this item type. }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/iam-role_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/iam/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestIAMRole(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() roleName := "customRole" role := &iam.Role{ Name: fmt.Sprintf("projects/%s/roles/%s", projectID, roleName), Title: "Custom Role", } roleList := &iam.ListRolesResponse{ Roles: []*iam.Role{role}, } sdpItemType := gcpshared.IAMRole expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/roles/%s", projectID, roleName): { StatusCode: http.StatusOK, Body: role, }, fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/roles", projectID): { StatusCode: http.StatusOK, Body: roleList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, roleName, true) if err != nil { t.Fatalf("Failed to get IAM role: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list IAM roles: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 IAM role, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/roles/%s", projectID, roleName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Role not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, roleName, true) if err == nil { t.Error("Expected error when getting non-existent IAM role, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/logging-bucket.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Logging Bucket adapter for Cloud Logging buckets var _ = registerableAdapter{ sdpType: gcpshared.LoggingBucket, meta: gcpshared.AdapterMeta{ // global is a type of location. // location is generally a region. SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.locations.buckets/get // GET https://logging.googleapis.com/v2/projects/*/locations/*/buckets/* // IAM permissions: logging.buckets.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s"), // LIST all buckets across all locations using wildcard ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://logging.googleapis.com/v2/projects/%s/locations/-/buckets"), // Reference: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.locations.buckets/list // GET https://logging.googleapis.com/v2/projects/*/locations/*/buckets // IAM permissions: logging.buckets.list SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets"), UniqueAttributeKeys: []string{"locations", "buckets"}, // HEALTH: Supports Health status: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LifecycleState // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"logging.buckets.get", "logging.buckets.list"}, PredefinedRole: "roles/logging.viewer", }, linkRules: map[string]*gcpshared.Impact{ "cmekSettings.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "cmekSettings.kmsKeyVersionName": gcpshared.CryptoKeyVersionImpactInOnly, "cmekSettings.serviceAccountId": gcpshared.IAMServiceAccountImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/logging-bucket_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/logging/apiv2/loggingpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestLoggingBucket(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "global" linker := gcpshared.NewLinker() bucketName := "test-bucket" bucket := &loggingpb.LogBucket{ Name: fmt.Sprintf("projects/%s/locations/%s/buckets/%s", projectID, location, bucketName), CmekSettings: &loggingpb.CmekSettings{ KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", KmsKeyVersionName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", ServiceAccountId: "cmek-p123456789@gcp-sa-logging.iam.gserviceaccount.com", }, } bucketList := &loggingpb.ListBucketsResponse{ Buckets: []*loggingpb.LogBucket{bucket}, } sdpItemType := gcpshared.LoggingBucket expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s", projectID, location, bucketName): { StatusCode: http.StatusOK, Body: bucket, }, fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets", projectID, location): { StatusCode: http.StatusOK, Body: bucketList, }, fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/-/buckets", projectID): { StatusCode: http.StatusOK, Body: bucketList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, bucketName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get logging bucket: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // cmekSettings.kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, { // cmekSettings.kmsKeyVersionName ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key", "1"), ExpectedScope: projectID, }, { // cmekSettings.serviceAccountId ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "cmek-p123456789@gcp-sa-logging.iam.gserviceaccount.com", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search logging buckets: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 logging bucket, got %d", len(sdpItems)) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list logging buckets: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 logging bucket, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } if item.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, item.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s", projectID, location, bucketName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Bucket not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, bucketName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent logging bucket, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/logging-link.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Logging Link adapter for Cloud Logging links var _ = registerableAdapter{ sdpType: gcpshared.LoggingLink, meta: gcpshared.AdapterMeta{ // HEALTH: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LifecycleState // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.locations.buckets.links/get // GET https://logging.googleapis.com/v2/projects/*/locations/*/buckets/*/links/* // IAM permissions: logging.links.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithThreeQueries("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s/links/%s"), // Reference: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.locations.buckets.links/list // GET https://logging.googleapis.com/v2/projects/*/locations/*/buckets/*/links // IAM permissions: logging.links.list SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s/links"), UniqueAttributeKeys: []string{"locations", "buckets", "links"}, IAMPermissions: []string{"logging.links.get", "logging.links.list"}, PredefinedRole: "roles/logging.viewer", }, linkRules: map[string]*gcpshared.Impact{ "name": { ToSDPItemType: gcpshared.LoggingBucket, Description: "If the Logging Bucket is deleted or updated: The Logging Link may lose its association or fail to function as expected. If the Logging Link is updated: The bucket remains unaffected.", }, "bigqueryDataset.datasetId": { Description: "They are tightly coupled with the Logging Link.", ToSDPItemType: gcpshared.BigQueryDataset, }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/logging-link_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/logging/apiv2/loggingpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestLoggingLink(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "global" bucketName := "test-bucket" linkName := "test-link" linker := gcpshared.NewLinker() link := &loggingpb.Link{ Name: fmt.Sprintf("projects/%s/locations/%s/buckets/%s/links/%s", projectID, location, bucketName, linkName), BigqueryDataset: &loggingpb.BigQueryDataset{ DatasetId: fmt.Sprintf("bigquery.googleapis.com/projects/%s/datasets/test_dataset", projectID), }, } linkList := &loggingpb.ListLinksResponse{ Links: []*loggingpb.Link{link}, } sdpItemType := gcpshared.LoggingLink expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s/links/%s", projectID, location, bucketName, linkName): { StatusCode: http.StatusOK, Body: link, }, fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s/links", projectID, location, bucketName): { StatusCode: http.StatusOK, Body: linkList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, bucketName, linkName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get logging link: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // name (LoggingBucket) ExpectedType: gcpshared.LoggingBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, bucketName), ExpectedScope: projectID, }, { // bigqueryDataset.datasetId ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test_dataset", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } searchQuery := shared.CompositeLookupKey(location, bucketName) sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search logging links: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 logging link, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/buckets/%s/links/%s", projectID, location, bucketName, linkName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Link not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, bucketName, linkName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent logging link, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/logging-saved-query.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Logging Saved Query adapter for Cloud Logging saved queries var _ = registerableAdapter{ sdpType: gcpshared.LoggingSavedQuery, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.locations.savedQueries/get // GET https://logging.googleapis.com/v2/projects/*/locations/*/savedQueries/* // IAM permissions: logging.savedQueries.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://logging.googleapis.com/v2/projects/%s/locations/%s/savedQueries/%s"), // LIST all saved queries across all locations using wildcard ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://logging.googleapis.com/v2/projects/%s/locations/-/savedQueries"), // Reference: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.locations.savedQueries/list // GET https://logging.googleapis.com/v2/projects/*/locations/*/savedQueries // IAM permissions: logging.savedQueries.list // Saved Query has to be shared with the project (opposite is a private one) to show up here. SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://logging.googleapis.com/v2/projects/%s/locations/%s/savedQueries"), UniqueAttributeKeys: []string{"locations", "savedQueries"}, // Documents lists `get` and `list` as the required iam permissions, but there is no permissions like that. // So, the closest one is chosen. // https://cloud.google.com/iam/docs/roles-permissions/logging IAMPermissions: []string{"logging.queries.getShared", "logging.queries.listShared"}, PredefinedRole: "roles/logging.viewer", }, linkRules: map[string]*gcpshared.Impact{ // There is no links for this item type. }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/logging-saved-query_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/logging/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestLoggingSavedQuery(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "global" linker := gcpshared.NewLinker() queryName := "test-query" savedQuery := &logging.SavedQuery{ Name: fmt.Sprintf("projects/%s/locations/%s/savedQueries/%s", projectID, location, queryName), DisplayName: "Test Query", } queryList := &logging.ListSavedQueriesResponse{ SavedQueries: []*logging.SavedQuery{savedQuery}, } sdpItemType := gcpshared.LoggingSavedQuery expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/savedQueries/%s", projectID, location, queryName): { StatusCode: http.StatusOK, Body: savedQuery, }, fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/savedQueries", projectID, location): { StatusCode: http.StatusOK, Body: queryList, }, fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/-/savedQueries", projectID): { StatusCode: http.StatusOK, Body: queryList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, queryName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get saved query: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search saved queries: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 saved query, got %d", len(sdpItems)) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list saved queries: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 saved query, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } if item.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, item.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://logging.googleapis.com/v2/projects/%s/locations/%s/savedQueries/%s", projectID, location, queryName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Saved query not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, queryName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent saved query, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/models.go ================================================ package adapters import ( gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) type registerableAdapter struct { sdpType shared.ItemType meta gcpshared.AdapterMeta linkRules map[string]*gcpshared.Impact terraformMapping gcpshared.TerraformMapping } func (d registerableAdapter) Register() registerableAdapter { gcpshared.SDPAssetTypeToAdapterMeta[d.sdpType] = d.meta gcpshared.LinkRules[d.sdpType] = d.linkRules gcpshared.SDPAssetTypeToTerraformMappings[d.sdpType] = d.terraformMapping return d } ================================================ FILE: sources/gcp/dynamic/adapters/monitoring-alert-policy.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Monitoring Alert Policy adapter. // GCP API Get Reference: https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.alertPolicies/get // GET https://monitoring.googleapis.com/v3/projects/{project}/alertPolicies/{alert_policy_id} // LIST https://monitoring.googleapis.com/v3/projects/{project}/alertPolicies // NOTE: Search is only implemented to support Terraform mapping where the full name // (projects/{project}/alertPolicies/{policy_id}) may be provided. var _ = registerableAdapter{ sdpType: gcpshared.MonitoringAlertPolicy, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://monitoring.googleapis.com/v3/projects/%s/alertPolicies/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://monitoring.googleapis.com/v3/projects/%s/alertPolicies", ), // Provide a no-op search (same pattern as other adapters) for terraform mapping support. // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, SearchDescription: "Search by full resource name: projects/[project]/alertPolicies/[alert_policy_id] (used for terraform mapping).", UniqueAttributeKeys: []string{"alertPolicies"}, IAMPermissions: []string{ "monitoring.alertPolicies.get", "monitoring.alertPolicies.list", }, PredefinedRole: "roles/monitoring.viewer", }, linkRules: map[string]*gcpshared.Impact{ "notificationChannels": { ToSDPItemType: gcpshared.MonitoringNotificationChannel, Description: "The notification channels that are used to notify when this alert policy is triggered. If notification channels are deleted, the alert policy will not be able to notify when triggered. If the alert policy is deleted, the notification channels will not be affected.", }, "alertStrategy.notificationChannelStrategy.notificationChannelNames": { ToSDPItemType: gcpshared.MonitoringNotificationChannel, Description: "The notification channels specified in the alert strategy for channel-specific renotification behavior. If these notification channels are deleted, the alert policy will not be able to notify when triggered. If the alert policy is deleted, the notification channels will not be affected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/monitoring_alert_policy", Description: "id => projects/{{project}}/alertPolicies/{{alert_policy_id}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_monitoring_alert_policy.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/monitoring-alert-policy_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/monitoring/apiv3/v2/monitoringpb" "google.golang.org/protobuf/types/known/wrapperspb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestMonitoringAlertPolicy(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() policyID := "test-alert-policy-123" // Create mock protobuf object alertPolicy := &monitoringpb.AlertPolicy{ Name: fmt.Sprintf("projects/%s/alertPolicies/%s", projectID, policyID), DisplayName: "Test Alert Policy", Documentation: &monitoringpb.AlertPolicy_Documentation{ Content: "Test alert policy for monitoring", }, NotificationChannels: []string{ fmt.Sprintf("projects/%s/notificationChannels/test-channel-1", projectID), fmt.Sprintf("projects/%s/notificationChannels/test-channel-2", projectID), }, Enabled: wrapperspb.Bool(true), } // Create second alert policy for list testing policyID2 := "test-alert-policy-456" alertPolicy2 := &monitoringpb.AlertPolicy{ Name: fmt.Sprintf("projects/%s/alertPolicies/%s", projectID, policyID2), DisplayName: "Test Alert Policy 2", Documentation: &monitoringpb.AlertPolicy_Documentation{ Content: "Second test alert policy", }, Enabled: wrapperspb.Bool(false), } // Create list response with multiple items alertPolicyList := &monitoringpb.ListAlertPoliciesResponse{ AlertPolicies: []*monitoringpb.AlertPolicy{alertPolicy, alertPolicy2}, } sdpItemType := gcpshared.MonitoringAlertPolicy // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/alertPolicies/%s", projectID, policyID): { StatusCode: http.StatusOK, Body: alertPolicy, }, fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/alertPolicies/%s", projectID, policyID2): { StatusCode: http.StatusOK, Body: alertPolicy2, }, fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/alertPolicies", projectID): { StatusCode: http.StatusOK, Body: alertPolicyList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, policyID, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != policyID { t.Errorf("Expected unique attribute value '%s', got %s", policyID, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/alertPolicies/%s", projectID, policyID) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Notification channel links { ExpectedType: gcpshared.MonitoringNotificationChannel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-channel-1", ExpectedScope: projectID, }, { ExpectedType: gcpshared.MonitoringNotificationChannel.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-channel-2", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/alertPolicies/[alert_policy_id] terraformQuery := fmt.Sprintf("projects/%s/alertPolicies/%s", projectID, policyID) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/alertPolicies/%s", projectID, policyID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Alert policy not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, policyID, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/monitoring-custom-dashboard.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Monitoring Custom Dashboard adapter for Cloud Monitoring dashboards var _ = registerableAdapter{ sdpType: gcpshared.MonitoringCustomDashboard, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/monitoring/api/ref_v3/rest/v1/projects.dashboards/get // GET https://monitoring.googleapis.com/v1/projects/[PROJECT_ID_OR_NUMBER]/dashboards/[DASHBOARD_ID] (for custom dashboards). // IAM Perm: monitoring.dashboards.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://monitoring.googleapis.com/v1/projects/%s/dashboards/%s"), // Reference: https://cloud.google.com/monitoring/api/ref_v3/rest/v1/projects.dashboards/list // GET https://monitoring.googleapis.com/v1/{parent}/dashboards // IAM Perm: monitoring.dashboards.list ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://monitoring.googleapis.com/v1/projects/%s/dashboards"), SearchDescription: "Search for custom dashboards by their ID in the form of \"projects/[project_id]/dashboards/[dashboard_id]\". This is supported for terraform mappings.", // This is a special case where we have to define the SEARCH method for only to support Terraform Mapping. // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, UniqueAttributeKeys: []string{"dashboards"}, IAMPermissions: []string{"monitoring.dashboards.get", "monitoring.dashboards.list"}, PredefinedRole: "roles/monitoring.viewer", }, linkRules: map[string]*gcpshared.Impact{ // There is no links for this item type. }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/monitoring_dashboard", Description: "id => projects/{{project}}/dashboards/{{dashboard_id}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_monitoring_dashboard.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/monitoring-custom-dashboard_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/monitoring/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestMonitoringCustomDashboard(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() dashboardID := "test-dashboard" dashboard := &monitoring.Dashboard{ Name: fmt.Sprintf("projects/%s/dashboards/%s", projectID, dashboardID), DisplayName: "Test Dashboard", } dashboardList := &monitoring.ListDashboardsResponse{ Dashboards: []*monitoring.Dashboard{dashboard}, } sdpItemType := gcpshared.MonitoringCustomDashboard expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://monitoring.googleapis.com/v1/projects/%s/dashboards/%s", projectID, dashboardID): { StatusCode: http.StatusOK, Body: dashboard, }, fmt.Sprintf("https://monitoring.googleapis.com/v1/projects/%s/dashboards", projectID): { StatusCode: http.StatusOK, Body: dashboardList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, dashboardID, true) if err != nil { t.Fatalf("Failed to get dashboard: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list dashboards: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 dashboard, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/dashboards/[dashboard] terraformQuery := fmt.Sprintf("projects/%s/dashboards/%s", projectID, dashboardID) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://monitoring.googleapis.com/v1/projects/%s/dashboards/%s", projectID, dashboardID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Dashboard not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, dashboardID, true) if err == nil { t.Error("Expected error when getting non-existent dashboard, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/monitoring-notification-channel.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Monitoring Notification Channel adapter // GCP Ref (GET): https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.notificationChannels/get // GCP Ref (Schema): https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.notificationChannels#NotificationChannel // GET https://monitoring.googleapis.com/v3/projects/{project}/notificationChannels/{notificationChannel} // LIST https://monitoring.googleapis.com/v3/projects/{project}/notificationChannels var _ = registerableAdapter{ sdpType: gcpshared.MonitoringNotificationChannel, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_OBSERVABILITY, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://monitoring.googleapis.com/v3/projects/%s/notificationChannels/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://monitoring.googleapis.com/v3/projects/%s/notificationChannels", ), // Provide a no-op search (same pattern as other adapters) for terraform mapping support. // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, SearchDescription: "Search by full resource name: projects/[project]/notificationChannels/[notificationChannel] (used for terraform mapping).", UniqueAttributeKeys: []string{"notificationChannels"}, IAMPermissions: []string{ "monitoring.notificationChannels.get", "monitoring.notificationChannels.list", }, PredefinedRole: "roles/monitoring.viewer", }, linkRules: map[string]*gcpshared.Impact{ // For pubsub type notification channels, the topic label contains the Pub/Sub topic resource name // Format: projects/{project}/topics/{topic} "labels.topic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or updated: The Notification Channel may fail to send alerts. If the Notification Channel is updated: The topic remains unaffected.", }, // For webhook_basicauth and webhook_tokenauth type notification channels, the url label contains the HTTP/HTTPS endpoint "labels.url": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the HTTP endpoint is unavailable or updated: The Notification Channel may fail to send alerts. If the Notification Channel is updated: The endpoint remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/monitoring_notification_channel", Description: "id => projects/{{project}}/notificationChannels/{{notificationChannel}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_monitoring_notification_channel.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/monitoring-notification-channel_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/monitoring/apiv3/v2/monitoringpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestMonitoringNotificationChannel(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() channelID := "test-notification-channel" // Create mock protobuf object channel := &monitoringpb.NotificationChannel{ Name: fmt.Sprintf("projects/%s/notificationChannels/%s", projectID, channelID), DisplayName: "Test Notification Channel", Type: "email", Labels: map[string]string{ "email_address": "test@example.com", }, } // Create second channel for list testing channelID2 := "test-notification-channel-2" channel2 := &monitoringpb.NotificationChannel{ Name: fmt.Sprintf("projects/%s/notificationChannels/%s", projectID, channelID2), DisplayName: "Test Notification Channel 2", Type: "slack", } // Create list response with multiple items channelList := &monitoringpb.ListNotificationChannelsResponse{ NotificationChannels: []*monitoringpb.NotificationChannel{channel, channel2}, } sdpItemType := gcpshared.MonitoringNotificationChannel // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/notificationChannels/%s", projectID, channelID): { StatusCode: http.StatusOK, Body: channel, }, fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/notificationChannels/%s", projectID, channelID2): { StatusCode: http.StatusOK, Body: channel2, }, fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/notificationChannels", projectID): { StatusCode: http.StatusOK, Body: channelList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, channelID, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != channelID { t.Errorf("Expected unique attribute value '%s', got %s", channelID, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/notificationChannels/%s", projectID, channelID) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } // Skip static tests - no link rules for this adapter // Static tests fail when linked queries are nil }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://monitoring.googleapis.com/v3/projects/%s/notificationChannels/%s", projectID, channelID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Notification channel not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, channelID, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/orgpolicy-policy.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Org Policy Policy (V2) adapter // API Get: https://cloud.google.com/resource-manager/docs/reference/orgpolicy/rest/v2/projects.policies/get // API List: https://cloud.google.com/resource-manager/docs/reference/orgpolicy/rest/v2/projects.policies/list // GET https://orgpolicy.googleapis.com/v2/projects/{project}/policies/{constraint} // LIST https://orgpolicy.googleapis.com/v2/projects/{project}/policies var orgPolicyPolicyAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.OrgPolicyPolicy, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://orgpolicy.googleapis.com/v2/projects/%s/policies/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://orgpolicy.googleapis.com/v2/projects/%s/policies", ), // Provide a no-op search (same pattern as other adapters) for terraform mapping support. // Returns empty URL to trigger GET with the provided full name. SearchEndpointFunc: func(query string, location gcpshared.LocationInfo) string { return "" }, SearchDescription: "Search with the full policy name: projects/[project]/policies/[constraint] (used for terraform mapping).", UniqueAttributeKeys: []string{"policies"}, IAMPermissions: []string{ "orgpolicy.policy.get", "orgpolicy.policies.list", }, PredefinedRole: "roles/orgpolicy.policyViewer", }, linkRules: map[string]*gcpshared.Impact{ // The name field contains the parent resource identifier (project, folder, or organization) // Format: projects/{project_number}/policies/{constraint} or // folders/{folder_id}/policies/{constraint} or // organizations/{organization_id}/policies/{constraint} // The manual linker (OrgPolicyPolicy in ManualAdapterLinksByAssetType) handles parsing // the prefix to determine the correct parent type and creates the appropriate link. "name": { // Use CloudResourceManagerProject as placeholder - the manual linker will determine // the actual type (project, folder, or organization) based on the name prefix ToSDPItemType: gcpshared.CloudResourceManagerProject, Description: "If the parent resource (project, folder, or organization) is deleted or updated: The Org Policy may become invalid or inaccessible. If the Org Policy is updated: The parent resource remains unaffected.", }, // Note: spec.rules[].condition.expression contains CEL expressions that may reference // Tag Keys and Tag Values via resource.matchTag() or resource.matchTagId(). // However, the framework does not currently parse CEL expressions to extract these // references automatically. This would require additional CEL parsing logic. // spec.rules[].values.allowed_values and spec.rules[].values.denied_values may contain // resource identifiers (e.g., "projects/123", "folders/456") for constraints that // support resource references, but these are constraint-specific and not guaranteed // to be resource references (they could be location strings or other values). }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/org_policy_policy", Description: "Use SEARCH with the full policy name: projects/{project}/policies/{constraint}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_org_policy_policy.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/orgpolicy-policy_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/orgpolicy/apiv2/orgpolicypb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestOrgPolicyPolicy(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() policyName := "gcp.resourceLocations" // Create mock protobuf object policy := &orgpolicypb.Policy{ Name: fmt.Sprintf("projects/%s/policies/%s", projectID, policyName), } // Create second policy for list testing policyName2 := "gcp.requireShieldedVm" policy2 := &orgpolicypb.Policy{ Name: fmt.Sprintf("projects/%s/policies/%s", projectID, policyName2), } // Create list response with multiple items policyList := &orgpolicypb.ListPoliciesResponse{ Policies: []*orgpolicypb.Policy{policy, policy2}, } sdpItemType := gcpshared.OrgPolicyPolicy // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://orgpolicy.googleapis.com/v2/projects/%s/policies/%s", projectID, policyName): { StatusCode: http.StatusOK, Body: policy, }, fmt.Sprintf("https://orgpolicy.googleapis.com/v2/projects/%s/policies/%s", projectID, policyName2): { StatusCode: http.StatusOK, Body: policy2, }, fmt.Sprintf("https://orgpolicy.googleapis.com/v2/projects/%s/policies", projectID): { StatusCode: http.StatusOK, Body: policyList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, policyName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != policyName { t.Errorf("Expected unique attribute value '%s', got %s", policyName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/policies/%s", projectID, policyName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } // Skip static tests - no link rules for this adapter // Static tests fail when linked queries are nil }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/policies/[constraint] terraformQuery := fmt.Sprintf("projects/%s/policies/%s", projectID, policyName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://orgpolicy.googleapis.com/v2/projects/%s/policies/%s", projectID, policyName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Policy not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, policyName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/pubsub-subscription.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Pub/Sub Subscription adapter for Google Cloud Pub/Sub subscriptions var _ = registerableAdapter{ sdpType: gcpshared.PubSubSubscription, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // https://pubsub.googleapis.com/v1/projects/{project}/subscriptions/{subscription} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://pubsub.googleapis.com/v1/projects/%s/subscriptions/%s"), // Reference: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/list?rep_location=global // https://pubsub.googleapis.com/v1/projects/{project}/subscriptions ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://pubsub.googleapis.com/v1/projects/%s/subscriptions"), UniqueAttributeKeys: []string{"subscriptions"}, // HEALTH: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions#state_2 // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"pubsub.subscriptions.get", "pubsub.subscriptions.list"}, PredefinedRole: "roles/pubsub.viewer", }, linkRules: map[string]*gcpshared.Impact{ "topic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or updated: The Subscription may fail to receive messages. If the Subscription is updated: The topic remains unaffected.", }, "deadLetterPolicy.deadLetterTopic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Dead Letter Topic is deleted or updated: The Subscription may fail to deliver failed messages. If the Subscription is updated: The dead letter topic remains unaffected.", }, "pushConfig.pushEndpoint": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the HTTP push endpoint is unavailable or updated: The Subscription may fail to deliver messages via push. If the Subscription is updated: The endpoint remains unaffected.", }, "pushConfig.oidcToken.serviceAccountEmail": gcpshared.IAMServiceAccountImpactInOnly, "bigqueryConfig.table": { // The name of the table to which to write data, of the form {projectId}.{datasetId}.{tableId} // We have a manual adapter for this. ToSDPItemType: gcpshared.BigQueryTable, Description: "If the BigQuery Table is deleted or updated: The Subscription may fail to write data. If the Subscription is updated: The table remains unaffected.", }, "bigqueryConfig.serviceAccountEmail": gcpshared.IAMServiceAccountImpactInOnly, "cloudStorageConfig.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage Bucket is deleted or updated: The Subscription may fail to write data. If the Subscription is updated: The bucket remains unaffected.", }, "cloudStorageConfig.serviceAccountEmail": gcpshared.IAMServiceAccountImpactInOnly, "analyticsHubSubscriptionInfo.subscription": { ToSDPItemType: gcpshared.PubSubSubscription, Description: "If the Pub/Sub Subscription is deleted or updated: The Analytics Hub Subscription may fail to receive messages. If the Analytics Hub Subscription is updated: The Pub/Sub Subscription remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/pubsub_subscription", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_subscription.name", }, // IAM resources for Pub/Sub Subscriptions. These are Terraform-only // constructs (no standalone GCP API resource exists for them). When an // IAM binding/member/policy changes in a Terraform plan, we resolve it // to the parent subscription so that blast radius analysis can show the // downstream impact of the access change. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/pubsub_subscription_iam { // Authoritative for a given role — grants the role to a list of members. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_subscription_iam_binding.subscription", }, { // Non-authoritative — grants a single member a single role. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_subscription_iam_member.subscription", }, { // Authoritative for the entire IAM policy on the subscription. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_subscription_iam_policy.subscription", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/pubsub-subscription_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "strings" "testing" "google.golang.org/api/pubsub/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestPubSubSubscription(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() subscriptionName := "test-subscription" subscription := &pubsub.Subscription{ Name: fmt.Sprintf("projects/%s/subscriptions/%s", projectID, subscriptionName), Topic: fmt.Sprintf("projects/%s/topics/test-topic", projectID), DeadLetterPolicy: &pubsub.DeadLetterPolicy{ DeadLetterTopic: fmt.Sprintf("projects/%s/topics/dead-letter-topic", projectID), MaxDeliveryAttempts: 5, }, PushConfig: &pubsub.PushConfig{ PushEndpoint: "https://example.com/push-endpoint", OidcToken: &pubsub.OidcToken{ ServiceAccountEmail: fmt.Sprintf("push-sa@%s.iam.gserviceaccount.com", projectID), Audience: "https://example.com", }, }, BigqueryConfig: &pubsub.BigQueryConfig{ Table: "test-project.test_dataset.test_table", ServiceAccountEmail: fmt.Sprintf("bq-sa@%s.iam.gserviceaccount.com", projectID), }, CloudStorageConfig: &pubsub.CloudStorageConfig{ Bucket: "test-bucket", ServiceAccountEmail: fmt.Sprintf("storage-sa@%s.iam.gserviceaccount.com", projectID), }, } subscriptionList := &pubsub.ListSubscriptionsResponse{ Subscriptions: []*pubsub.Subscription{subscription}, } sdpItemType := gcpshared.PubSubSubscription expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://pubsub.googleapis.com/v1/projects/%s/subscriptions/%s", projectID, subscriptionName): { StatusCode: http.StatusOK, Body: subscription, }, fmt.Sprintf("https://pubsub.googleapis.com/v1/projects/%s/subscriptions", projectID): { StatusCode: http.StatusOK, Body: subscriptionList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, subscriptionName, true) if err != nil { t.Fatalf("Failed to get subscription: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // topic ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-topic", ExpectedScope: projectID, }, { // deadLetterPolicy.deadLetterTopic ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dead-letter-topic", ExpectedScope: projectID, }, { // pushConfig.pushEndpoint ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://example.com/push-endpoint", ExpectedScope: "global", }, { // pushConfig.oidcToken.serviceAccountEmail ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("push-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, { // bigqueryConfig.table ExpectedType: gcpshared.BigQueryTable.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test_dataset", "test_table"), ExpectedScope: projectID, }, { // bigqueryConfig.serviceAccountEmail ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("bq-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, { // cloudStorageConfig.bucket ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-bucket", ExpectedScope: projectID, }, { // cloudStorageConfig.serviceAccountEmail ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("storage-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list subscriptions: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 subscription, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://pubsub.googleapis.com/v1/projects/%s/subscriptions/%s", projectID, subscriptionName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Subscription not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, subscriptionName, true) if err == nil { t.Error("Expected error when getting non-existent subscription, but got nil") } }) } // TestPubSubSubscriptionIAMTerraformMappings verifies that the IAM Terraform resource // types (iam_binding, iam_member, iam_policy) are registered as terraform mappings on // the PubSub Subscription adapter. This is critical because these Terraform-only // resources don't have their own GCP API — they represent IAM policy changes on the // parent subscription. Without these mappings, IAM changes would show as "Unsupported" // in the change analysis UI instead of being resolved to the parent subscription for // blast radius analysis. // // Background: google_pubsub_subscription_iam_binding is an authoritative Terraform // resource that manages a single role's members on a subscription. When it changes, // we need to resolve it to the affected subscription so customers see the downstream // impact (e.g. services that read from the subscription losing access). func TestPubSubSubscriptionIAMTerraformMappings(t *testing.T) { // Retrieve the terraform mappings registered for PubSubSubscription tfMapping, ok := gcpshared.SDPAssetTypeToTerraformMappings[gcpshared.PubSubSubscription] if !ok { t.Fatal("Expected PubSubSubscription to have terraform mappings registered, but none were found") } // Build a lookup of terraform type -> query field from the registered mappings. // This mirrors the logic in cli/tfutils/plan_mapper.go that splits // TerraformQueryMap on "." to get the terraform type and attribute name. type mappingInfo struct { terraformType string queryField string method sdp.QueryMethod } registeredMappings := make([]mappingInfo, 0, len(tfMapping.Mappings)) for _, m := range tfMapping.Mappings { parts := strings.SplitN(m.GetTerraformQueryMap(), ".", 2) if len(parts) != 2 { t.Errorf("Invalid TerraformQueryMap format: %q (expected 'type.attribute')", m.GetTerraformQueryMap()) continue } registeredMappings = append(registeredMappings, mappingInfo{ terraformType: parts[0], queryField: parts[1], method: m.GetTerraformMethod(), }) } // Define the IAM terraform types we expect to be mapped, along with the // Terraform attribute that identifies the parent subscription. // All three IAM resource types use "subscription" as the attribute that // contains the subscription name. expectedIAMMappings := []struct { terraformType string queryField string method sdp.QueryMethod description string // documents why this mapping exists, for reviewer clarity }{ { terraformType: "google_pubsub_subscription_iam_binding", queryField: "subscription", method: sdp.QueryMethod_GET, description: "Authoritative for a given role — maps to parent subscription for blast radius", }, { terraformType: "google_pubsub_subscription_iam_member", queryField: "subscription", method: sdp.QueryMethod_GET, description: "Non-authoritative single member — maps to parent subscription for blast radius", }, { terraformType: "google_pubsub_subscription_iam_policy", queryField: "subscription", method: sdp.QueryMethod_GET, description: "Authoritative for full IAM policy — maps to parent subscription for blast radius", }, } for _, expected := range expectedIAMMappings { t.Run(expected.terraformType, func(t *testing.T) { found := false for _, registered := range registeredMappings { if registered.terraformType == expected.terraformType { found = true if registered.queryField != expected.queryField { t.Errorf("Terraform type %s: expected query field %q, got %q", expected.terraformType, expected.queryField, registered.queryField) } if registered.method != expected.method { t.Errorf("Terraform type %s: expected method %s, got %s", expected.terraformType, expected.method, registered.method) } break } } if !found { t.Errorf("Terraform type %s is not registered as a mapping on PubSubSubscription. "+ "This means %q changes will show as 'Unsupported' in the change analysis UI. "+ "Purpose: %s", expected.terraformType, expected.terraformType, expected.description) } }) } // Also verify the base subscription mapping still exists (sanity check) t.Run("google_pubsub_subscription", func(t *testing.T) { found := false for _, registered := range registeredMappings { if registered.terraformType == "google_pubsub_subscription" { found = true if registered.queryField != "name" { t.Errorf("Expected query field 'name' for google_pubsub_subscription, got %q", registered.queryField) } break } } if !found { t.Error("Base terraform mapping for google_pubsub_subscription is missing — this would break all subscription change analysis") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/pubsub-topic.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" aws "github.com/overmindtech/cli/sources/aws/shared" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Pub/Sub Topic adapter for Google Cloud Pub/Sub topics var _ = registerableAdapter{ sdpType: gcpshared.PubSubTopic, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // https://pubsub.googleapis.com/v1/projects/{project}/topics/{topic} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://pubsub.googleapis.com/v1/projects/%s/topics/%s"), // https://pubsub.googleapis.com/v1/projects/{project}/topics ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://pubsub.googleapis.com/v1/projects/%s/topics"), UniqueAttributeKeys: []string{"topics"}, // HEALTH: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics#state // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"pubsub.topics.get", "pubsub.topics.list"}, PredefinedRole: "roles/pubsub.viewer", }, linkRules: map[string]*gcpshared.Impact{ "kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // Schema settings for message validation "schemaSettings.schema": { ToSDPItemType: gcpshared.PubSubSchema, Description: "If the Pub/Sub Schema is deleted or updated: The Topic may fail to validate messages. If the Topic is updated: The schema remains unaffected.", }, // Settings for ingestion from a data source into this topic. "ingestionDataSourceSettings.cloudStorage.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage Bucket is deleted or updated: The Pub/Sub Topic may fail to receive data. If the Topic is updated: The bucket remains unaffected.", }, "ingestionDataSourceSettings.awsKinesis.streamArn": { ToSDPItemType: aws.KinesisStream, Description: "The Kinesis stream ARN to ingest data from. If the Kinesis stream is deleted or updated: The Pub/Sub Topic may fail to receive data. If the Topic is updated: The stream remains unaffected.", }, "ingestionDataSourceSettings.awsKinesis.consumerArn": { ToSDPItemType: aws.KinesisStreamConsumer, Description: "The Kinesis consumer ARN used for ingestion in Enhanced Fan-Out mode. The consumer must be already created and ready to be used. If the consumer is deleted or updated: The Pub/Sub Topic may fail to receive data. If the Topic is updated: The consumer remains unaffected.", }, "ingestionDataSourceSettings.awsKinesis.awsRoleArn": { ToSDPItemType: aws.IAMRole, Description: "AWS role to be used for Federated Identity authentication with Kinesis. If the AWS IAM role is deleted or updated: The Pub/Sub Topic may fail to authenticate and receive data. If the Topic is updated: The role remains unaffected.", }, "ingestionDataSourceSettings.awsKinesis.gcpServiceAccount": { ToSDPItemType: gcpshared.IAMServiceAccount, Description: "GCP service account used for federated identity authentication with AWS Kinesis. If the service account is deleted or updated: The Pub/Sub Topic may fail to authenticate and receive data. If the Topic is updated: The service account remains unaffected.", }, "ingestionDataSourceSettings.awsMsk.clusterArn": { ToSDPItemType: aws.MSKCluster, Description: "AWS MSK cluster ARN to ingest data from. If the MSK cluster is deleted or updated: The Pub/Sub Topic may fail to receive data. If the Topic is updated: The cluster remains unaffected.", }, "ingestionDataSourceSettings.awsMsk.awsRoleArn": { ToSDPItemType: aws.IAMRole, Description: "AWS role to be used for Federated Identity authentication with AWS MSK. If the AWS IAM role is deleted or updated: The Pub/Sub Topic may fail to authenticate and receive data. If the Topic is updated: The role remains unaffected.", }, "ingestionDataSourceSettings.awsMsk.gcpServiceAccount": { ToSDPItemType: gcpshared.IAMServiceAccount, Description: "GCP service account used for federated identity authentication with AWS MSK. If the service account is deleted or updated: The Pub/Sub Topic may fail to authenticate and receive data. If the Topic is updated: The service account remains unaffected.", }, "ingestionDataSourceSettings.confluentCloud.bootstrapServer": { ToSDPItemType: stdlib.NetworkDNS, Description: "Confluent Cloud bootstrap server endpoint (hostname:port). The linker automatically detects whether the value is a DNS name or IP address and creates the appropriate link. If the bootstrap server is unreachable: The Pub/Sub Topic may fail to receive data. If the Topic is updated: The bootstrap server remains unaffected.", }, "ingestionDataSourceSettings.confluentCloud.gcpServiceAccount": { ToSDPItemType: gcpshared.IAMServiceAccount, Description: "GCP service account used for federated identity authentication with Confluent Cloud. If the service account is deleted or updated: The Pub/Sub Topic may fail to authenticate and receive data. If the Topic is updated: The service account remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/pubsub_topic", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_topic.name", }, // IAM resources for Pub/Sub Topics. These are Terraform-only constructs // (no standalone GCP API resource exists). When an IAM binding/member/policy // changes, we resolve it to the parent topic for blast radius analysis. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/pubsub_topic_iam { // Authoritative for a given role — grants the role to a list of members. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_topic_iam_binding.topic", }, { // Non-authoritative — grants a single member a single role. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_topic_iam_member.topic", }, { // Authoritative for the entire IAM policy on the topic. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_pubsub_topic_iam_policy.topic", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/pubsub-topic_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/pubsub/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestPubSubTopic(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() topicName := "test-topic" topic := &pubsub.Topic{ Name: fmt.Sprintf("projects/%s/topics/%s", projectID, topicName), KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", IngestionDataSourceSettings: &pubsub.IngestionDataSourceSettings{ CloudStorage: &pubsub.CloudStorage{ Bucket: "ingestion-bucket", }, }, } topicList := &pubsub.ListTopicsResponse{ Topics: []*pubsub.Topic{topic}, } sdpItemType := gcpshared.PubSubTopic expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://pubsub.googleapis.com/v1/projects/%s/topics/%s", projectID, topicName): { StatusCode: http.StatusOK, Body: topic, }, fmt.Sprintf("https://pubsub.googleapis.com/v1/projects/%s/topics", projectID): { StatusCode: http.StatusOK, Body: topicList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, topicName, true) if err != nil { t.Fatalf("Failed to get topic: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // kmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, { // ingestionDataSourceSettings.cloudStorage.bucket ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "ingestion-bucket", ExpectedScope: projectID, }, // TODO: Add tests for AWS Kinesis ingestion settings (streamAr, consumerArn, awsRoleArn) // Requires cross-cloud linking setup } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list topics: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 topic, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://pubsub.googleapis.com/v1/projects/%s/topics/%s", projectID, topicName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Topic not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, topicName, true) if err == nil { t.Error("Expected error when getting non-existent topic, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/redis-instance.go ================================================ package adapters import ( "strings" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // redisInstanceListFilter filters out placeholder entries that GCP returns // for unavailable locations when using wildcard location queries. // Placeholder entries have names ending in "/instances/-" with error status. func redisInstanceListFilter(item *sdp.Item) bool { name, err := item.GetAttributes().Get("name") if err != nil { return true } nameStr, ok := name.(string) if !ok { return true } return !strings.HasSuffix(nameStr, "/instances/-") } // GCP Cloud Memorystore Redis Instance adapter. // Cloud Memorystore for Redis provides a fully managed Redis service that is highly available and scalable. // GCP Ref: // - API Call structure (GET): https://cloud.google.com/memorystore/docs/redis/reference/rest/v1/projects.locations.instances/get // GET https://redis.googleapis.com/v1/projects/{project}/locations/{location}/instances/{instance} // - Type Definition (Instance): https://cloud.google.com/memorystore/docs/redis/reference/rest/v1/projects.locations.instances#Instance // - LIST: https://cloud.google.com/memorystore/docs/redis/reference/rest/v1/projects.locations.instances/list // // Scope: Project-level (uses locations path parameter; unique attributes include location+instance). var _ = registerableAdapter{ sdpType: gcpshared.RedisInstance, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/memorystore/docs/redis/reference/rest/v1/projects.locations.instances/get // GET https://redis.googleapis.com/v1/projects/{project}/locations/{location}/instances/{instance} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://redis.googleapis.com/v1/projects/%s/locations/%s/instances/%s", ), // LIST all instances across all locations using wildcard // Note: wildcard list may include placeholder entries for unavailable locations // (entries with name ending in "/instances/-" and error status) ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://redis.googleapis.com/v1/projects/%s/locations/-/instances", ), // Filter out placeholder entries from LIST results ListFilterFunc: redisInstanceListFilter, // Reference: https://cloud.google.com/memorystore/docs/redis/reference/rest/v1/projects.locations.instances/list // GET https://redis.googleapis.com/v1/projects/{project}/locations/{location}/instances SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://redis.googleapis.com/v1/projects/%s/locations/%s/instances", ), SearchDescription: "Search Redis instances in a location. Use the format \"location\" or \"projects/[project_id]/locations/[location]/instances/[instance_name]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "instances"}, IAMPermissions: []string{ "redis.instances.get", "redis.instances.list", }, PredefinedRole: "roles/redis.viewer", // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/memorystore/docs/redis/reference/rest/v1/projects.locations.instances#Instance.State }, linkRules: map[string]*gcpshared.Impact{ // The name of the VPC network to which the instance is connected. "authorizedNetwork": gcpshared.ComputeNetworkImpactInOnly, // Optional. The KMS key reference that the customer provides when trying to create the instance. "customerManagedKey": gcpshared.CryptoKeyImpactInOnly, // Output only. Hostname or IP address of the exposed Redis endpoint used by clients to connect to the service. "host": gcpshared.IPImpactBothWays, // Output only (standard tier). Endpoint for readonly traffic to the Redis instance. Can be a hostname or IP address. "readEndpoint": gcpshared.IPImpactBothWays, // Output only. List of server CA certificates for the instance. "serverCaCerts.cert": { ToSDPItemType: gcpshared.ComputeSSLCertificate, Description: "If the certificate is deleted or updated: The Redis instance may lose secure connectivity. If the Redis instance is updated: The certificate remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/redis_instance", Description: "id => projects/{project}/locations/{location}/instances/{instance}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_redis_instance.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/redis-instance_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "strings" "testing" "cloud.google.com/go/redis/apiv1/redispb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestRedisInstance(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() instanceName := "test-redis-instance" // Create mock protobuf object instance := &redispb.Instance{ Name: fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName), DisplayName: "Test Redis Instance", LocationId: location, AuthorizedNetwork: fmt.Sprintf("projects/%s/global/networks/default", projectID), CustomerManagedKey: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", Host: "10.0.0.100", ReadEndpoint: "10.0.0.101", ServerCaCerts: []*redispb.TlsCertificate{ { Cert: "-----BEGIN CERTIFICATE-----\nMIIC...test certificate data...\n-----END CERTIFICATE-----", }, }, } // Create second instance for list testing instanceName2 := "test-redis-instance-2" instance2 := &redispb.Instance{ Name: fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName2), DisplayName: "Test Redis Instance 2", LocationId: location, } // Create list response with multiple items instanceList := &redispb.ListInstancesResponse{ Instances: []*redispb.Instance{instance, instance2}, } sdpItemType := gcpshared.RedisInstance // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://redis.googleapis.com/v1/projects/%s/locations/%s/instances/%s", projectID, location, instanceName): { StatusCode: http.StatusOK, Body: instance, }, fmt.Sprintf("https://redis.googleapis.com/v1/projects/%s/locations/%s/instances/%s", projectID, location, instanceName2): { StatusCode: http.StatusOK, Body: instance2, }, fmt.Sprintf("https://redis.googleapis.com/v1/projects/%s/locations/%s/instances", projectID, location): { StatusCode: http.StatusOK, Body: instanceList, }, fmt.Sprintf("https://redis.googleapis.com/v1/projects/%s/locations/-/instances", projectID): { StatusCode: http.StatusOK, Body: instanceList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // For multiple query parameters, use the combined query format combinedQuery := shared.CompositeLookupKey(location, instanceName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Authorized network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // Customer managed key link { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // Host IP address link { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.100", ExpectedScope: "global", }, // Read endpoint IP address link { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.101", ExpectedScope: "global", }, // Server CA certificate link { ExpectedType: gcpshared.ComputeSSLCertificate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "-----BEGIN CERTIFICATE-----\nMIIC...test certificate data...\n-----END CERTIFICATE-----", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test location-based search sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project_id]/locations/[location]/instances/[instance_name] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/instances/%s", projectID, location, instanceName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list Redis instances: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Redis instances, got %d", len(sdpItems)) } if len(sdpItems) >= 1 { item := sdpItems[0] if item.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), item.GetType()) } if item.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, item.GetScope()) } } }) t.Run("List filters out placeholder entries", func(t *testing.T) { placeholder := &redispb.Instance{ Name: fmt.Sprintf("projects/%s/locations/us-west1/instances/-", projectID), LocationId: "us-west1", } instanceListWithPlaceholder := &redispb.ListInstancesResponse{ Instances: []*redispb.Instance{instance, placeholder, instance2}, } responsesWithPlaceholder := map[string]shared.MockResponse{ fmt.Sprintf("https://redis.googleapis.com/v1/projects/%s/locations/-/instances", projectID): { StatusCode: http.StatusOK, Body: instanceListWithPlaceholder, }, } httpCli := shared.NewMockHTTPClientProvider(responsesWithPlaceholder) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list Redis instances: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 Redis instances (placeholder filtered out), got %d", len(sdpItems)) } for _, item := range sdpItems { name, err := item.GetAttributes().Get("name") if err != nil { t.Errorf("Failed to get name attribute: %v", err) continue } nameStr, ok := name.(string) if !ok { t.Errorf("Name is not a string: %T", name) continue } if strings.HasSuffix(nameStr, "/instances/-") { t.Errorf("Placeholder entry was not filtered out: %s", nameStr) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://redis.googleapis.com/v1/projects/%s/locations/%s/instances/%s", projectID, location, instanceName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Redis instance not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, instanceName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/run-revision.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Run Revision adapter for Cloud Run revisions var _ = registerableAdapter{ sdpType: gcpshared.RunRevision, meta: gcpshared.AdapterMeta{ /* A Revision is an immutable snapshot of code and configuration. A Revision references a container image. Revisions are only created by updates to its parent Service. */ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/run/docs/reference/rest/v2/projects.locations.services.revisions/get // GET https://run.googleapis.com/v2/projects/{project}/locations/{location}/services/{service}/revisions/{revision} // IAM Perm: run.revisions.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithThreeQueries("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s/revisions/%s"), // Reference: https://cloud.google.com/run/docs/reference/rest/v2/projects.locations.services.revisions/list // GET https://run.googleapis.com/v2/projects/{project}/locations/{location}/services/{service}/revisions // IAM Perm: run.revisions.list SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s/revisions"), UniqueAttributeKeys: []string{"locations", "services", "revisions"}, IAMPermissions: []string{"run.revisions.get", "run.revisions.list"}, PredefinedRole: "roles/run.viewer", }, linkRules: map[string]*gcpshared.Impact{ "service": { ToSDPItemType: gcpshared.RunService, Description: "If the Run Service is deleted or updated: The Revision may lose its association or fail to run. If the Revision is updated: The service remains unaffected.", }, "vpcAccess.networkInterfaces.network": { ToSDPItemType: gcpshared.ComputeNetwork, Description: "If the Compute Network is deleted or updated: The Revision may lose connectivity or fail to run as expected. If the Revision is updated: The network remains unaffected.", }, "vpcAccess.networkInterfaces.subnetwork": { ToSDPItemType: gcpshared.ComputeSubnetwork, Description: "If the Compute Subnetwork is deleted or updated: The Revision may lose connectivity or fail to run as expected. If the Revision is updated: The subnetwork remains unaffected.", }, "vpcAccess.connector": { ToSDPItemType: gcpshared.VPCAccessConnector, Description: "If the VPC Access Connector is deleted or updated: The Revision may lose connectivity or fail to run as expected. If the Revision is updated: The connector remains unaffected.", }, "serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, "containers.image": { ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, Description: "If the Artifact Registry Docker Image is deleted or updated: The Revision may fail to pull the image. If the Revision is updated: The Docker image remains unaffected.", }, "volumes.cloudSqlInstance.instances": { // Format: {project}:{location}:{instance} // The manual adapter linker handles this format automatically. ToSDPItemType: gcpshared.SQLAdminInstance, Description: "If the Cloud SQL Instance is deleted or updated: The Revision may fail to access the database. If the Revision is updated: The instance remains unaffected.", }, "volumes.gcs.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage Bucket is deleted or updated: The Revision may fail to access the GCS volume. If the Revision is updated: The bucket remains unaffected.", }, "volumes.secret.secret": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager Secret is deleted or updated: The Revision may fail to access sensitive data mounted as a volume. If the Revision is updated: The secret remains unaffected.", }, "volumes.nfs.server": { ToSDPItemType: stdlib.NetworkIP, Description: "If the NFS server (IP address or hostname) becomes unavailable: The Revision may fail to mount the NFS volume. If the Revision is updated: The NFS server remains unaffected. The linker automatically detects whether the value is an IP address or DNS name.", }, "logUri": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the log URI endpoint becomes unavailable: The Revision logs may not be accessible. If the Revision is updated: The log URI endpoint remains unaffected.", }, "encryptionKey": gcpshared.CryptoKeyImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/run-revision_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/run/v2" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestRunRevision(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" serviceName := "test-service" revisionName := "test-revision" linker := gcpshared.NewLinker() revision := &run.GoogleCloudRunV2Revision{ Name: fmt.Sprintf("projects/%s/locations/%s/services/%s/revisions/%s", projectID, location, serviceName, revisionName), ServiceAccount: "run-sa@test-project.iam.gserviceaccount.com", Service: fmt.Sprintf("projects/%s/locations/%s/services/%s", projectID, location, serviceName), } revisionList := &run.GoogleCloudRunV2ListRevisionsResponse{ Revisions: []*run.GoogleCloudRunV2Revision{revision}, } sdpItemType := gcpshared.RunRevision expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s/revisions/%s", projectID, location, serviceName, revisionName): { StatusCode: http.StatusOK, Body: revision, }, fmt.Sprintf("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s/revisions", projectID, location, serviceName): { StatusCode: http.StatusOK, Body: revisionList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, serviceName, revisionName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get revision: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // service ExpectedType: gcpshared.RunService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, serviceName), ExpectedScope: projectID, }, { // serviceAccount ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "run-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } searchQuery := shared.CompositeLookupKey(location, serviceName) sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search revisions: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 revision, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s/revisions/%s", projectID, location, serviceName, revisionName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Revision not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, serviceName, revisionName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent revision, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/run-service.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Cloud Run Service adapter - Manages stateless containerized applications with automatic scaling // Reference: https://cloud.google.com/run/docs/reference/rest/v2/projects.locations.services/get // GET: https://run.googleapis.com/v2/projects/{project}/locations/{location}/services/{service} // LIST: https://run.googleapis.com/v2/projects/{project}/locations/{location}/services var _ = registerableAdapter{ sdpType: gcpshared.RunService, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s", ), // List requires location parameter, so use Search SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://run.googleapis.com/v2/projects/%s/locations/%s/services", ), UniqueAttributeKeys: []string{"locations", "services"}, IAMPermissions: []string{ "run.services.get", "run.services.list", }, PredefinedRole: "roles/run.viewer", // TODO: https://linear.app/overmind/issue/ENG-631 - status field for health monitoring }, linkRules: map[string]*gcpshared.Impact{ "template.serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, "template.vpcAccess.connector": { ToSDPItemType: gcpshared.VPCAccessConnector, Description: "If the VPC Access Connector is deleted or updated: The service may lose connectivity or fail to route traffic correctly. If the service is updated: The connector remains unaffected.", }, "template.vpcAccess.networkInterfaces.network": { ToSDPItemType: gcpshared.ComputeNetwork, Description: "If the Compute Network is deleted or updated: The service may lose connectivity or fail to route traffic correctly. If the service is updated: The network remains unaffected.", }, "template.vpcAccess.networkInterfaces.subnetwork": { ToSDPItemType: gcpshared.ComputeSubnetwork, Description: "If the Compute Subnetwork is deleted or updated: The service may lose connectivity or fail to route traffic correctly. If the service is updated: The subnetwork remains unaffected.", }, "template.containers.image": { ToSDPItemType: gcpshared.ArtifactRegistryDockerImage, Description: "If the Artifact Registry Docker Image is deleted or updated: The service may fail to deploy new revisions. If the service is updated: The Docker image remains unaffected.", }, "template.containers.env.valueSource.secretKeyRef.secret": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the referenced Secret Manager Secret is deleted or updated: the container may fail to start or access sensitive configuration. If the service is updated: the secret remains unaffected.", }, "template.volumes.secret.secret": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager Secret is deleted or updated: The service may fail to access sensitive data. If the service is updated: The secret remains unaffected.", }, "template.volumes.cloudSqlInstance.instances": { ToSDPItemType: gcpshared.SQLAdminInstance, Description: "If the Cloud SQL Instance is deleted or updated: The service may fail to access the database. If the service is updated: The instance remains unaffected.", }, "template.volumes.gcs.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage Bucket is deleted or updated: The service may fail to access stored data. If the service is updated: The bucket remains unaffected.", }, "template.encryptionKey": gcpshared.CryptoKeyImpactInOnly, "latestCreatedRevision": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Service is deleted or updated: Associated revisions may become orphaned or be deleted. If revisions are updated: The service status may reflect the changes.", }, "latestReadyRevision": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Service is deleted or updated: Associated revisions may become orphaned or be deleted. If revisions are updated: The service status may reflect the changes.", }, "traffic.revision": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Service is deleted or updated: Traffic allocation to revisions will be lost. If revisions are updated: The service traffic configuration may need updates.", }, // Forward link from parent to child via SEARCH // Link to all revisions in this service "name": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Service is deleted or updated: All associated Revisions may become invalid or inaccessible. If a Revision is updated: The service remains unaffected.", IsParentToChild: true, }, // Link to Binary Authorization platform policy (when explicitly specified via policy field) // Note: When useDefault is true, the service uses the project's default policy, // but we can't link to it here since there's no explicit policy field value "binaryAuthorization.policy": { ToSDPItemType: gcpshared.BinaryAuthorizationPlatformPolicy, Description: "If the Binary Authorization platform policy is updated: The service may fail to deploy new revisions if images don't meet policy requirements. If the service is updated: The policy remains unaffected.", }, // Link to Cloud Storage bucket used in buildConfig source (if buildConfig is used) "buildConfig.source.storageSource.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage Bucket containing source code is deleted or updated: The service may fail to build new revisions. If the service is updated: The bucket remains unaffected.", }, // Link to HTTP/HTTPS URLs serving traffic for this service "urls": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the HTTP endpoint becomes unavailable: The service cannot serve traffic. If the service is updated: The endpoint URL may change.", }, // Link to main URI serving traffic for this service "uri": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the HTTP endpoint becomes unavailable: The service cannot serve traffic. If the service is updated: The endpoint URI may change.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service", Description: "id => projects/{{project}}/locations/{{location}}/services/{{name}}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_cloud_run_v2_service.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/run-service_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/run/apiv2/runpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestRunService(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" linker := gcpshared.NewLinker() serviceName := "test-service" service := &runpb.Service{ Name: fmt.Sprintf("projects/%s/locations/%s/services/%s", projectID, location, serviceName), Template: &runpb.RevisionTemplate{ ServiceAccount: "test-sa@test-project.iam.gserviceaccount.com", VpcAccess: &runpb.VpcAccess{ Connector: fmt.Sprintf("projects/%s/locations/%s/connectors/test-connector", projectID, location), NetworkInterfaces: []*runpb.VpcAccess_NetworkInterface{ { Network: fmt.Sprintf("projects/%s/global/networks/default", projectID), Subnetwork: fmt.Sprintf("projects/%s/regions/%s/subnetworks/default", projectID, location), }, }, }, Containers: []*runpb.Container{ { Image: fmt.Sprintf("%s-docker.pkg.dev/%s/repo/image:latest", location, projectID), Env: []*runpb.EnvVar{ { Values: &runpb.EnvVar_ValueSource{ ValueSource: &runpb.EnvVarSource{ SecretKeyRef: &runpb.SecretKeySelector{ Secret: fmt.Sprintf("projects/%s/secrets/api-key", projectID), }, }, }, }, }, }, }, Volumes: []*runpb.Volume{ { VolumeType: &runpb.Volume_Secret{ Secret: &runpb.SecretVolumeSource{ Secret: fmt.Sprintf("projects/%s/secrets/db-creds", projectID), }, }, }, { VolumeType: &runpb.Volume_CloudSqlInstance{ CloudSqlInstance: &runpb.CloudSqlInstance{ Instances: []string{fmt.Sprintf("projects/%s/instances/test-db", projectID)}, }, }, }, { VolumeType: &runpb.Volume_Gcs{ Gcs: &runpb.GCSVolumeSource{ Bucket: "test-bucket", }, }, }, }, EncryptionKey: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, LatestReadyRevision: fmt.Sprintf("projects/%s/locations/%s/services/%s/revisions/rev-1", projectID, location, serviceName), LatestCreatedRevision: fmt.Sprintf("projects/%s/locations/%s/services/%s/revisions/rev-2", projectID, location, serviceName), Traffic: []*runpb.TrafficTarget{ { Revision: fmt.Sprintf("projects/%s/locations/%s/services/%s/revisions/rev-3", projectID, location, serviceName), }, }, } serviceName2 := "test-service-2" service2 := &runpb.Service{ Name: fmt.Sprintf("projects/%s/locations/%s/services/%s", projectID, location, serviceName2), } serviceList := &runpb.ListServicesResponse{ Services: []*runpb.Service{service, service2}, } sdpItemType := gcpshared.RunService // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s", projectID, location, serviceName): { StatusCode: http.StatusOK, Body: service, }, fmt.Sprintf("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s", projectID, location, serviceName2): { StatusCode: http.StatusOK, Body: service2, }, fmt.Sprintf("https://run.googleapis.com/v2/projects/%s/locations/%s/services", projectID, location): { StatusCode: http.StatusOK, Body: serviceList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } // For multiple query parameters, use the combined query format combinedQuery := shared.CompositeLookupKey(location, serviceName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/locations/%s/services/%s", projectID, location, serviceName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // template.serviceAccount { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, // template.vpcAccess.networkInterfaces.network { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // template.vpcAccess.networkInterfaces.subnetwork { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.%s", projectID, location), }, // template.containers.env.valueSource.secretKeyRef.secret { ExpectedType: gcpshared.SecretManagerSecret.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "api-key", ExpectedScope: projectID, }, // template.volumes.secret.secret { ExpectedType: gcpshared.SecretManagerSecret.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "db-creds", ExpectedScope: projectID, }, // template.volumes.cloudSqlInstance.instances { ExpectedType: gcpshared.SQLAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-db", ExpectedScope: projectID, }, // template.volumes.gcs.bucket { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-bucket", ExpectedScope: projectID, }, // template.encryptionKey { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // latestReadyRevision { ExpectedType: gcpshared.RunRevision.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, serviceName, "rev-1"), ExpectedScope: projectID, }, // latestCreatedRevision { ExpectedType: gcpshared.RunRevision.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, serviceName, "rev-2"), ExpectedScope: projectID, }, // traffic.revision { ExpectedType: gcpshared.RunRevision.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, serviceName, "rev-3"), ExpectedScope: projectID, }, // name (parent to child search) { ExpectedType: gcpshared.RunRevision.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(location, serviceName), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test location-based search sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project_id]/locations/[location]/services/[service] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/services/%s", projectID, location, serviceName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://run.googleapis.com/v2/projects/%s/locations/%s/services/%s", projectID, location, serviceName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Service not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, serviceName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/run-worker-pool.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Cloud Run Worker Pool: // Reference: https://cloud.google.com/run/docs/reference/rest/v2/projects.locations.workerPools/get // GET: https://run.googleapis.com/v2/projects/{project}/locations/{location}/workerPools/{workerPool} // LIST: https://run.googleapis.com/v2/projects/{project}/locations/{location}/workerPools var _ = registerableAdapter{ sdpType: gcpshared.RunWorkerPool, meta: gcpshared.AdapterMeta{ InDevelopment: true, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://run.googleapis.com/v2/projects/%s/locations/%s/workerPools/%s", ), // The list endpoint requires the location only. SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://run.googleapis.com/v2/projects/%s/locations/%s/workerPools", ), // location|workerPool UniqueAttributeKeys: []string{"locations", "workerPools"}, IAMPermissions: []string{ "run.workerPools.get", "run.workerPools.list", }, PredefinedRole: "roles/run.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Service account used by revisions in the worker pool "template.serviceAccount": gcpshared.IAMServiceAccountImpactInOnly, // Encryption key for image encryption "template.encryptionKey": gcpshared.CryptoKeyImpactInOnly, // VPC Access Connector for network connectivity "template.vpcAccess.connector": { ToSDPItemType: gcpshared.VPCAccessConnector, Description: "If the VPC Access Connector is deleted or updated: The worker pool may lose connectivity or fail to route traffic correctly. If the worker pool is updated: The connector remains unaffected.", }, // VPC Network for direct VPC egress "template.vpcAccess.networkInterfaces.network": { ToSDPItemType: gcpshared.ComputeNetwork, Description: "If the Compute Network is deleted or updated: The worker pool may lose connectivity or fail to route traffic correctly. If the worker pool is updated: The network remains unaffected.", }, // VPC Subnetwork for direct VPC egress "template.vpcAccess.networkInterfaces.subnetwork": { ToSDPItemType: gcpshared.ComputeSubnetwork, Description: "If the Compute Subnetwork is deleted or updated: The worker pool may lose connectivity or fail to route traffic correctly. If the worker pool is updated: The subnetwork remains unaffected.", }, // Service Mesh for advanced networking "template.serviceMesh.mesh": { ToSDPItemType: gcpshared.NetworkServicesMesh, Description: "If the Network Services Mesh is deleted or updated: The worker pool may lose service mesh connectivity or fail to communicate with other mesh services. If the worker pool is updated: The mesh remains unaffected.", }, // Secret Manager secrets mounted as volumes "template.volumes.secret.secret": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager Secret is deleted or updated: The worker pool may fail to access sensitive data mounted as volumes. If the worker pool is updated: The secret remains unaffected.", }, // Cloud SQL instances mounted as volumes "template.volumes.cloudSqlInstance.instances": { ToSDPItemType: gcpshared.SQLAdminInstance, Description: "If the Cloud SQL Instance is deleted or updated: The worker pool may fail to access the database. If the worker pool is updated: The instance remains unaffected.", }, // GCS buckets mounted as volumes "template.volumes.gcs.bucket": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Cloud Storage Bucket is deleted or updated: The worker pool may fail to access stored data. If the worker pool is updated: The bucket remains unaffected.", }, // NFS server (IP address or DNS name) - auto-detected by linker "template.volumes.nfs.server": { ToSDPItemType: stdlib.NetworkIP, Description: "If the NFS server (IP address or hostname) becomes unavailable: The worker pool may fail to mount the NFS volume. If the worker pool is updated: The NFS server remains unaffected. The linker automatically detects whether the value is an IP address or DNS name.", }, // Secret Manager secrets used in environment variables "template.containers.env.valueSource.secretKeyRef.secret": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the referenced Secret Manager Secret is deleted or updated: The container may fail to start or access sensitive configuration. If the worker pool is updated: The secret remains unaffected.", }, // Binary Authorization policy "binaryAuthorization.policy": { ToSDPItemType: gcpshared.BinaryAuthorizationPlatformPolicy, Description: "If the Binary Authorization policy is deleted or updated: The worker pool may fail to deploy new revisions if they don't meet policy requirements. If the worker pool is updated: The policy remains unaffected.", }, // Latest ready revision - child resource "latestReadyRevision": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Worker Pool is deleted or updated: Associated revisions may become orphaned or be deleted. If revisions are updated: The worker pool status may reflect the changes.", }, // Latest created revision - child resource "latestCreatedRevision": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Worker Pool is deleted or updated: Associated revisions may become orphaned or be deleted. If revisions are updated: The worker pool status may reflect the changes.", }, // Instance split revisions - child resources "instanceSplits.revision": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Worker Pool is deleted or updated: Associated revisions may become orphaned or be deleted. If revisions are updated: The worker pool status may reflect the changes.", }, // Forward link from parent to child via SEARCH - discover all revisions in this worker pool "name": { ToSDPItemType: gcpshared.RunRevision, Description: "If the Cloud Run Worker Pool is deleted or updated: All associated Revisions may become invalid or inaccessible. If a Revision is updated: The worker pool remains unaffected.", IsParentToChild: true, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/secret-manager-secret.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Secret Manager Secret adapter. // GCP Refs: // - API (GET): https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets/get // GET https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{secret} // - LIST: https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets/list // GET https://secretmanager.googleapis.com/v1/projects/{project}/secrets // - Type: https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets#Secret // // Scope: Project-level (no locations segment in the resource path). var _ = registerableAdapter{ sdpType: gcpshared.SecretManagerSecret, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://secretmanager.googleapis.com/v1/projects/%s/secrets", ), UniqueAttributeKeys: []string{"secrets"}, IAMPermissions: []string{ "secretmanager.secrets.get", "secretmanager.secrets.list", }, PredefinedRole: "roles/secretmanager.viewer", }, linkRules: map[string]*gcpshared.Impact{ // CMEK used with Automatic replication "replication.automatic.customerManagedEncryption.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // CMEK used with User-managed replication per replica "replication.userManaged.replicas.customerManagedEncryption.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // Pub/Sub topic which Secret Manager will publish to when control plane events occur on this secret. "topics.name": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or its policy changes: Secret event notifications may fail. If the Secret changes: The topic remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/secret_manager_secret", Description: "Use the secret_id to GET the secret within the project.", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_secret_manager_secret.secret_id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/secret-manager-secret_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestSecretManagerSecret(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() secretID := "test-secret" // Create mock protobuf object with automatic replication secret := &secretmanagerpb.Secret{ Name: fmt.Sprintf("projects/%s/secrets/%s", projectID, secretID), Replication: &secretmanagerpb.Replication{ Replication: &secretmanagerpb.Replication_Automatic_{ Automatic: &secretmanagerpb.Replication_Automatic{ CustomerManagedEncryption: &secretmanagerpb.CustomerManagedEncryption{ KmsKeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, }, }, }, Topics: []*secretmanagerpb.Topic{ { Name: fmt.Sprintf("projects/%s/topics/secret-events", projectID), }, }, } // Create second secret with user-managed replication secretID2 := "test-secret-2" secret2 := &secretmanagerpb.Secret{ Name: fmt.Sprintf("projects/%s/secrets/%s", projectID, secretID2), Replication: &secretmanagerpb.Replication{ Replication: &secretmanagerpb.Replication_UserManaged_{ UserManaged: &secretmanagerpb.Replication_UserManaged{ Replicas: []*secretmanagerpb.Replication_UserManaged_Replica{ { Location: "us-central1", CustomerManagedEncryption: &secretmanagerpb.CustomerManagedEncryption{ KmsKeyName: "projects/test-project/locations/us-central1/keyRings/region-ring/cryptoKeys/region-key", }, }, }, }, }, }, } // Create third secret for list testing (minimal) secretID3 := "test-secret-3" secret3 := &secretmanagerpb.Secret{ Name: fmt.Sprintf("projects/%s/secrets/%s", projectID, secretID3), } // Create list response with multiple items secretList := &secretmanagerpb.ListSecretsResponse{ Secrets: []*secretmanagerpb.Secret{secret, secret2, secret3}, } sdpItemType := gcpshared.SecretManagerSecret // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s", projectID, secretID): { StatusCode: http.StatusOK, Body: secret, }, fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s", projectID, secretID2): { StatusCode: http.StatusOK, Body: secret2, }, fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s", projectID, secretID3): { StatusCode: http.StatusOK, Body: secret3, }, fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets", projectID): { StatusCode: http.StatusOK, Body: secretList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, secretID, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != secretID { t.Errorf("Expected unique attribute value '%s', got %s", secretID, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/secrets/%s", projectID, secretID) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // replication.automatic.customerManagedEncryption.kmsKeyName { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // topics.name { ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "secret-events", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get with UserManaged Replication", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, secretID2, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != secretID2 { t.Errorf("Expected unique attribute value '%s', got %s", secretID2, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // replication.userManaged.replicas.customerManagedEncryption.kmsKeyName { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us-central1", "region-ring", "region-key"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 3 { t.Errorf("Expected 3 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s", projectID, secretID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Secret not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, secretID, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/security-center-management-security-center-service.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Security Center Management Security Center Service adapter // Manages Security Center service configurations for organizations and projects. // Reference: https://cloud.google.com/security-command-center/docs/reference/security-center-management/rest/v1/projects.locations.securityCenterServices/get // GET: https://securitycentermanagement.googleapis.com/v1/projects/{project}/locations/{location}/securityCenterServices/{securityCenterService} // LIST: https://securitycentermanagement.googleapis.com/v1/projects/{project}/locations/{location}/securityCenterServices var _ = registerableAdapter{ sdpType: gcpshared.SecurityCenterManagementSecurityCenterService, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries( "https://securitycentermanagement.googleapis.com/v1/projects/%s/locations/%s/securityCenterServices/%s", ), SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://securitycentermanagement.googleapis.com/v1/projects/%s/locations/%s/securityCenterServices", ), SearchDescription: "Search Security Center services in a location. Use the format \"location\".", UniqueAttributeKeys: []string{"locations", "securityCenterServices"}, IAMPermissions: []string{ "securitycentermanagement.securityCenterServices.get", "securitycentermanagement.securityCenterServices.list", }, PredefinedRole: "roles/securitycentermanagement.viewer", // TODO: https://linear.app/overmind/issue/ENG-631 - check if SecurityCenterService has status/state attribute }, linkRules: map[string]*gcpshared.Impact{ // Link to parent resource (project, folder, or organization) from name field // The name field format is: projects/{project}/locations/{location}/securityCenterServices/{service} // or: folders/{folder}/locations/{location}/securityCenterServices/{service} // or: organizations/{organization}/locations/{location}/securityCenterServices/{service} // The manual linker registered for CloudResourceManagerProject will detect the type based on the name prefix // and create the appropriate link to Project, Folder, or Organization "name": { Description: "If the parent Project, Folder, or Organization is deleted or updated: The Security Center Service may become invalid or inaccessible. If the Security Center Service is updated: The parent resource remains unaffected.", ToSDPItemType: gcpshared.CloudResourceManagerProject, // Manual linker handles detection of project/folder/organization from name prefix }, // Note: Custom modules (SecurityHealthAnalyticsCustomModule, EventThreatDetectionCustomModule, etc.) // are not direct children in the API path structure - they are sibling resources under the same // project/location scope. They don't have a direct reference field in SecurityCenterService, // so we don't link to them here. They would be discovered through their own adapters. }, terraformMapping: gcpshared.TerraformMapping{ // No Terraform resource found yet. }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/security-center-management-security-center-service_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/securitycentermanagement/apiv1/securitycentermanagementpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestSecurityCenterManagementSecurityCenterService(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "global" linker := gcpshared.NewLinker() serviceName := "container-threat-detection" service := &securitycentermanagementpb.SecurityCenterService{ Name: fmt.Sprintf("projects/%s/locations/%s/securityCenterServices/%s", projectID, location, serviceName), } serviceName2 := "event-threat-detection" service2 := &securitycentermanagementpb.SecurityCenterService{ Name: fmt.Sprintf("projects/%s/locations/%s/securityCenterServices/%s", projectID, location, serviceName2), } serviceList := &securitycentermanagementpb.ListSecurityCenterServicesResponse{ SecurityCenterServices: []*securitycentermanagementpb.SecurityCenterService{service, service2}, } sdpItemType := gcpshared.SecurityCenterManagementSecurityCenterService expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://securitycentermanagement.googleapis.com/v1/projects/%s/locations/%s/securityCenterServices/%s", projectID, location, serviceName): { StatusCode: http.StatusOK, Body: service, }, fmt.Sprintf("https://securitycentermanagement.googleapis.com/v1/projects/%s/locations/%s/securityCenterServices/%s", projectID, location, serviceName2): { StatusCode: http.StatusOK, Body: service2, }, fmt.Sprintf("https://securitycentermanagement.googleapis.com/v1/projects/%s/locations/%s/securityCenterServices", projectID, location): { StatusCode: http.StatusOK, Body: serviceList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, serviceName) sdpItem, err := adapter.Get(ctx, projectID, combinedQuery, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != combinedQuery { t.Errorf("Expected unique attribute value '%s', got %s", combinedQuery, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Link to parent Project from name field // The name field format is: projects/{project}/locations/{location}/securityCenterServices/{service} // The manual linker will extract the project ID from the name field { ExpectedType: gcpshared.CloudResourceManagerProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: projectID, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, location, true) if err != nil { t.Fatalf("Failed to search resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://securitycentermanagement.googleapis.com/v1/projects/%s/locations/%s/securityCenterServices/%s", projectID, location, serviceName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Service not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } combinedQuery := shared.CompositeLookupKey(location, serviceName) _, err = adapter.Get(ctx, projectID, combinedQuery, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/service-directory-endpoint.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Service Directory Endpoint adapter for Service Directory endpoints var _ = registerableAdapter{ sdpType: gcpshared.ServiceDirectoryEndpoint, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/service-directory/docs/reference/rest/v1/projects.locations.namespaces.services.endpoints/get // GET https://servicedirectory.googleapis.com/v1/projects/*/locations/*/namespaces/*/services/*/endpoints/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithFourQueries("https://servicedirectory.googleapis.com/v1/projects/%s/locations/%s/namespaces/%s/services/%s/endpoints/%s"), // Reference: https://cloud.google.com/service-directory/docs/reference/rest/v1/projects.locations.namespaces.services.endpoints/list // IAM Perm: servicedirectory.endpoints.list // GET https://servicedirectory.googleapis.com/v1/projects/*/locations/*/namespaces/*/services/*/endpoints SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithThreeQueries("https://servicedirectory.googleapis.com/v1/projects/%s/locations/%s/namespaces/%s/services/%s/endpoints"), SearchDescription: "Search for endpoints by \"location|namespace_id|service_id\" or \"projects/[project_id]/locations/[location]/namespaces/[namespace_id]/services/[service_id]/endpoints/[endpoint_id]\" which is supported for terraform mappings.", UniqueAttributeKeys: []string{"locations", "namespaces", "services", "endpoints"}, IAMPermissions: []string{"servicedirectory.endpoints.get", "servicedirectory.endpoints.list"}, PredefinedRole: "roles/servicedirectory.viewer", }, linkRules: map[string]*gcpshared.Impact{ "name": { ToSDPItemType: gcpshared.ServiceDirectoryService, Description: "If the Service Directory Service is deleted or updated: The Endpoint may lose its association or fail to resolve names. If the Endpoint is updated: The service remains unaffected.", }, // An IPv4 or IPv6 address. "address": gcpshared.IPImpactBothWays, // The Google Compute Engine network (VPC) of the endpoint in the format projects//locations/global/networks/*. "network": gcpshared.ComputeNetworkImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_directory_endpoint", Description: "id => projects/*/locations/*/namespaces/*/services/*/endpoints/*", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_service_directory_endpoint.id", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/service-directory-endpoint_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/servicedirectory/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestServiceDirectoryEndpoint(t *testing.T) { ctx := context.Background() projectID := "test-project" location := "us-central1" namespace := "test-namespace" serviceName := "test-service" endpointName := "test-endpoint" linker := gcpshared.NewLinker() endpoint := &servicedirectory.Endpoint{ Name: fmt.Sprintf("projects/%s/locations/%s/namespaces/%s/services/%s/endpoints/%s", projectID, location, namespace, serviceName, endpointName), Address: "192.168.1.1", Network: fmt.Sprintf("projects/%s/locations/global/networks/default", projectID), } endpointList := &servicedirectory.ListEndpointsResponse{ Endpoints: []*servicedirectory.Endpoint{endpoint}, } sdpItemType := gcpshared.ServiceDirectoryEndpoint expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://servicedirectory.googleapis.com/v1/projects/%s/locations/%s/namespaces/%s/services/%s/endpoints/%s", projectID, location, namespace, serviceName, endpointName): { StatusCode: http.StatusOK, Body: endpoint, }, fmt.Sprintf("https://servicedirectory.googleapis.com/v1/projects/%s/locations/%s/namespaces/%s/services/%s/endpoints", projectID, location, namespace, serviceName): { StatusCode: http.StatusOK, Body: endpointList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, namespace, serviceName, endpointName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get endpoint: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // name (ServiceDirectoryService) ExpectedType: gcpshared.ServiceDirectoryService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(location, namespace, serviceName), ExpectedScope: projectID, }, { // address ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { // network ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } searchQuery := shared.CompositeLookupKey(location, namespace, serviceName) sdpItems, err := searchable.Search(ctx, projectID, searchQuery, true) if err != nil { t.Fatalf("Failed to search endpoints: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 endpoint, got %d", len(sdpItems)) } }) t.Run("Search with Terraform format", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Skipf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } // Test Terraform format: projects/[project]/locations/[location]/namespaces/[namespace]/services/[service]/endpoints/[endpoint] terraformQuery := fmt.Sprintf("projects/%s/locations/%s/namespaces/%s/services/%s/endpoints/%s", projectID, location, namespace, serviceName, endpointName) sdpItems, err := searchable.Search(ctx, projectID, terraformQuery, true) if err != nil { t.Fatalf("Failed to search resources with Terraform format: %v", err) } // The search should return only the specific resource matching the Terraform format if len(sdpItems) != 1 { t.Errorf("Expected 1 resource, got %d", len(sdpItems)) return } // Verify the single item returned firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://servicedirectory.googleapis.com/v1/projects/%s/locations/%s/namespaces/%s/services/%s/endpoints/%s", projectID, location, namespace, serviceName, endpointName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Endpoint not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(location, namespace, serviceName, endpointName) _, err = adapter.Get(ctx, projectID, getQuery, true) if err == nil { t.Error("Expected error when getting non-existent endpoint, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/service-directory-service.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Service Directory Service adapter for Service Directory services var _ = registerableAdapter{ sdpType: gcpshared.ServiceDirectoryService, meta: gcpshared.AdapterMeta{ InDevelopment: true, // Reference: https://cloud.google.com/service-directory/docs/reference/rest/v1/projects.locations.namespaces.services/get SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // https://servicedirectory.googleapis.com/v1/projects/*/locations/*/namespaces/*/services/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithThreeQueries("https://servicedirectory.googleapis.com/v1/projects/%s/locations/%s/namespaces/%s/services/%s"), // https://servicedirectory.googleapis.com/v1/projects/*/locations/*/namespaces/*/services // IAM Perm: servicedirectory.services.list SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://servicedirectory.googleapis.com/v1/projects/%s/locations/%s/namespaces/%s/services"), UniqueAttributeKeys: []string{"locations", "namespaces", "services"}, IAMPermissions: []string{"servicedirectory.services.get", "servicedirectory.services.list"}, PredefinedRole: "roles/servicedirectory.viewer", }, linkRules: map[string]*gcpshared.Impact{ // Link from parent Service to child Endpoints via SEARCH // The framework will extract location, namespace, and service from the service name // and create a SEARCH query to find all endpoints under this service "name": { ToSDPItemType: gcpshared.ServiceDirectoryEndpoint, Description: "If the Service Directory Service is deleted or updated: All associated endpoints may become invalid or inaccessible. If an endpoint is updated: The service remains unaffected.", IsParentToChild: true, }, // Link to IP addresses in endpoint addresses (if endpoints are included in the response) // The linker will automatically detect if the value is an IP address or DNS name "endpoints.address": gcpshared.IPImpactBothWays, // Link to VPC networks referenced by endpoints "endpoints.network": gcpshared.ComputeNetworkImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/service-usage-service.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Service Usage Service adapter for enabled GCP services var _ = registerableAdapter{ sdpType: gcpshared.ServiceUsageService, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/service-usage/docs/reference/rest/v1/services/get // GET https://serviceusage.googleapis.com/v1/{name=*/*/services/*} // An example name would be: projects/123/services/service // where 123 is the project number TODO: make sure that this is working with project ID as well // IAM Perm: serviceusage.services.get GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://serviceusage.googleapis.com/v1/projects/%s/services/%s"), // Reference: https://cloud.google.com/service-usage/docs/reference/rest/v1/services/list // GET https://serviceusage.googleapis.com/v1/{parent=*/*}/services /* List all services available to the specified project, and the current state of those services with respect to the project. The list includes all public services, all services for which the calling user has the `servicemanagement.services.bind` permission, and all services that have already been enabled on the project. The list can be filtered to only include services in a specific state, for example to only include services enabled on the project. */ // Let's use the filter to only list enabled services. // IAM Perm: serviceusage.services.list ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://serviceusage.googleapis.com/v1/projects/%s/services?filter=state:ENABLED"), UniqueAttributeKeys: []string{"services"}, // HEALTH: https://cloud.google.com/service-usage/docs/reference/rest/v1/services#state // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"serviceusage.services.get", "serviceusage.services.list"}, PredefinedRole: "roles/serviceusage.serviceUsageViewer", }, linkRules: map[string]*gcpshared.Impact{ "parent": { ToSDPItemType: gcpshared.CloudResourceManagerProject, Description: "If the Project is deleted or updated: The Service Usage Service may become invalid or inaccessible. If the Service Usage Service is updated: The project remains unaffected.", }, "config.name": { ToSDPItemType: stdlib.NetworkDNS, Description: "The DNS address at which this service is available. They are tightly coupled with the Service Usage Service.", }, "config.usage.producerNotificationChannel": { // Google Service Management currently only supports Google Cloud Pub/Sub as a notification channel. // To use Google Cloud Pub/Sub as the channel, this must be the name of a Cloud Pub/Sub topic ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted or updated: The Service Usage Service may fail to send notifications. If the Service Usage Service is updated: The topic remains unaffected.", }, "config.endpoints.name": { ToSDPItemType: stdlib.NetworkDNS, Description: "The canonical DNS name of the endpoint. DNS names and endpoints are tightly coupled - if DNS resolution fails, the endpoint becomes inaccessible.", }, "config.endpoints.target": { // The target field can contain either an IP address or FQDN. // The linker automatically detects which type the value is and creates the appropriate link. ToSDPItemType: stdlib.NetworkIP, Description: "The address of the API frontend (IP address or FQDN). Network connectivity to this address is required for the endpoint to function. The linker automatically detects whether the value is an IP address or DNS name.", }, "config.endpoints.aliases": { // Note: This field is deprecated but may still be present in existing configurations. // The linker will process each alias in the array. ToSDPItemType: stdlib.NetworkDNS, Description: "Additional DNS names/aliases for the endpoint. DNS names and endpoints are tightly coupled - if DNS resolution fails, the endpoint becomes inaccessible.", }, "config.documentation.documentationRootUrl": { ToSDPItemType: stdlib.NetworkHTTP, Description: "The HTTP/HTTPS URL to the root of the service documentation. HTTP connectivity to this URL is required to access the documentation.", }, "config.documentation.serviceRootUrl": { ToSDPItemType: stdlib.NetworkHTTP, Description: "The HTTP/HTTPS service root URL. HTTP connectivity to this URL may be required for service operations.", }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/service-usage-service_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/serviceusage/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestServiceUsageService(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() serviceName := "compute.googleapis.com" service := &serviceusage.GoogleApiServiceusageV1Service{ Name: fmt.Sprintf("projects/%s/services/%s", projectID, serviceName), Config: &serviceusage.GoogleApiServiceusageV1ServiceConfig{ Name: serviceName, }, State: "ENABLED", } serviceList := &serviceusage.ListServicesResponse{ Services: []*serviceusage.GoogleApiServiceusageV1Service{service}, } sdpItemType := gcpshared.ServiceUsageService expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://serviceusage.googleapis.com/v1/projects/%s/services/%s", projectID, serviceName): { StatusCode: http.StatusOK, Body: service, }, fmt.Sprintf("https://serviceusage.googleapis.com/v1/projects/%s/services?filter=state:ENABLED", projectID): { StatusCode: http.StatusOK, Body: serviceList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, serviceName, true) if err != nil { t.Fatalf("Failed to get service: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // config.name ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: serviceName, ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list services: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 service, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://serviceusage.googleapis.com/v1/projects/%s/services/%s", projectID, serviceName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Service not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, serviceName, true) if err == nil { t.Error("Expected error when getting non-existent service, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/spanner-backup.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Spanner Backup adapter for Cloud Spanner backups var _ = registerableAdapter{ sdpType: gcpshared.SpannerBackup, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, InDevelopment: true, LocationLevel: gcpshared.ProjectLevel, // Reference:https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.backups/get?rep_location=global // https://spanner.googleapis.com/v1/projects/*/instances/*/backups/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://spanner.googleapis.com/v1/projects/%s/instances/%s/backups/%s"), // https://spanner.googleapis.com/v1/projects/*/instances/*/backups SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://spanner.googleapis.com/v1/projects/%s/instances/%s/backups"), UniqueAttributeKeys: []string{"instances", "backups"}, IAMPermissions: []string{"spanner.backups.get", "spanner.backups.list"}, }, linkRules: map[string]*gcpshared.Impact{ // This is a backlink to instance. // Framework will extract the instance name and create the linked item query with GET "name": { Description: "If the Spanner Instance is deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The instance remains unaffected.", ToSDPItemType: gcpshared.SpannerInstance, }, // Name of the database from which this backup is created. "database": { Description: "If the Spanner Database is deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The database remains unaffected.", ToSDPItemType: gcpshared.SpannerDatabase, }, // Names of databases restored from this backup. May be across instances. "referencingDatabases": { Description: "If any of the databases restored from this backup are deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The restored databases remain unaffected.", ToSDPItemType: gcpshared.SpannerDatabase, }, // Names of destination backups copying this source backup. "referencingBackups": { Description: "If any of the destination backups copying this source backup are deleted or updated: The source backup may become invalid or inaccessible. If the source backup is updated: The destination backups remain unaffected.", ToSDPItemType: gcpshared.SpannerBackup, }, "encryptionInfo.kmsKeyVersion": gcpshared.CryptoKeyVersionImpactInOnly, // All Cloud KMS key versions used for encrypting the backup. "encryptionInformation.kmsKeyVersion": gcpshared.CryptoKeyVersionImpactInOnly, // URIs of backup schedules associated with this backup (only for scheduled backups). "backupSchedules": { Description: "If any of the backup schedules associated with this backup are deleted or updated: The Backup may stop being created automatically. If the Backup is updated: The backup schedules remain unaffected.", ToSDPItemType: gcpshared.SpannerBackupSchedule, }, // The instance partitions storing the backup (from the state at versionTime). "instancePartitions.instancePartition": { Description: "If any of the instance partitions storing this backup are deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The instance partitions remain unaffected.", ToSDPItemType: gcpshared.SpannerInstancePartition, }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/spanner-database.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Spanner Database adapter for Cloud Spanner databases var _ = registerableAdapter{ sdpType: gcpshared.SpannerDatabase, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases/get?rep_location=global // https://spanner.googleapis.com/v1/projects/*/instances/*/databases/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://spanner.googleapis.com/v1/projects/%s/instances/%s/databases/%s"), // Reference: https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases/list?rep_location=global // https://spanner.googleapis.com/v1/{parent=projects/*/instances/*}/databases SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://spanner.googleapis.com/v1/projects/%s/instances/%s/databases"), UniqueAttributeKeys: []string{"instances", "databases"}, // HEALTH: https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases#state // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items IAMPermissions: []string{"spanner.databases.get", "spanner.databases.list"}, PredefinedRole: "overmind_custom_role", }, linkRules: map[string]*gcpshared.Impact{ // The Cloud KMS key used to encrypt the database. "encryptionConfig.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, "encryptionConfig.kmsKeyNames": gcpshared.CryptoKeyImpactInOnly, "restoreInfo.backupInfo.backup": { Description: "If the Spanner Backup is deleted or updated: The Database may become invalid or inaccessible. If the Database is updated: The backup remains unaffected.", ToSDPItemType: gcpshared.SpannerBackup, }, // Source database from which the backup was taken (if database was restored from backup). "restoreInfo.backupInfo.sourceDatabase": { Description: "If the source Database is deleted or updated: The restored Database may become invalid or lose its restore point reference. If the restored Database is updated: The source database remains unaffected.", ToSDPItemType: gcpshared.SpannerDatabase, }, "encryptionInfo.kmsKeyVersion": gcpshared.CryptoKeyVersionImpactInOnly, // This is a backlink to instance. // Framework will extract the instance name and create the linked item query with GET // NOTE: Child resources (backupSchedules, databaseRoles, operations, sessions) have their own REST API endpoints // but don't appear in the Database response JSON. To link to them, child adapters would need to be created // and the framework would need to support multiple IsParentToChild links from the same field. // Item types have been created for: SpannerBackupSchedule, SpannerDatabaseRole, SpannerDatabaseOperation, SpannerSession "name": { Description: "If the Spanner Instance is deleted or updated: The Database may become invalid or inaccessible. If the Database is updated: The instance remains unaffected.", ToSDPItemType: gcpshared.SpannerInstance, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/spanner_database.html", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_spanner_database.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/spanner-database_test.go ================================================ package adapters import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/spanner/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestSpannerDatabase(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() databaseName := "test-database" instanceName := "test-instance" spannerDatabase := &spanner.Database{ Name: fmt.Sprintf("projects/%s/instances/%s/databases/%s", projectID, instanceName, databaseName), EncryptionConfig: &spanner.EncryptionConfig{ KmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", KmsKeyNames: []string{ "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/array-key-1", }, }, RestoreInfo: &spanner.RestoreInfo{ BackupInfo: &spanner.BackupInfo{ Backup: "projects/test-project/instances/test-instance/backups/my-backup", }, }, EncryptionInfo: []*spanner.EncryptionInfo{ { KmsKeyVersion: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", }, }, } spannerDatabases := &spanner.ListDatabasesResponse{ Databases: []*spanner.Database{spannerDatabase}, } sdpItemType := gcpshared.SpannerDatabase expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://spanner.googleapis.com/v1/projects/%s/instances/%s/databases/%s", projectID, instanceName, databaseName): { StatusCode: http.StatusOK, Body: spannerDatabase, }, fmt.Sprintf("https://spanner.googleapis.com/v1/projects/%s/instances/%s/databases", projectID, instanceName): { StatusCode: http.StatusOK, Body: spannerDatabases, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := shared.CompositeLookupKey(instanceName, databaseName) sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get Spanner database: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", databaseName, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.SpannerBackup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("test-instance", "my-backup"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "array-key-1"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key", "1"), ExpectedScope: projectID, }, { // name field creates a backlink to the Spanner instance ExpectedType: gcpshared.SpannerInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceName, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { // This is a project level adapter, so we pass the project httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement SearchableAdapter", sdpItemType) } sdpItems, err := searchable.Search(ctx, projectID, instanceName, true) if err != nil { t.Fatalf("Failed to list databases images: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 database, got %d", len(sdpItems)) } }) } ================================================ FILE: sources/gcp/dynamic/adapters/spanner-instance-config.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Spanner Instance Config adapter for Cloud Spanner instance configurations var _ = registerableAdapter{ sdpType: gcpshared.SpannerInstanceConfig, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, InDevelopment: true, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instanceConfigs/get?rep_location=global // https://spanner.googleapis.com/v1/projects/*/instanceConfigs/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://spanner.googleapis.com/v1/projects/%s/instanceConfigs/%s"), // https://// https://spanner.googleapis.com/v1/projects/*/instanceConfigs ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://spanner.googleapis.com/v1/projects/%s/instanceConfigs"), UniqueAttributeKeys: []string{"instanceConfigs"}, IAMPermissions: []string{"spanner.instanceConfigs.get", "spanner.instanceConfigs.list"}, PredefinedRole: "roles/spanner.viewer", }, linkRules: map[string]*gcpshared.Impact{}, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/spanner-instance.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) var spannerInstanceAdapter = registerableAdapter{ //nolint:unused sdpType: gcpshared.SpannerInstance, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances/get?rep_location=global // https://spanner.googleapis.com/v1/projects/*/instances/* GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://spanner.googleapis.com/v1/projects/%s/instances/%s"), // Reference: https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances/list?rep_location=global // https://spanner.googleapis.com/v1/projects/*/instances ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://spanner.googleapis.com/v1/projects/%s/instances"), UniqueAttributeKeys: []string{"instances"}, IAMPermissions: []string{"spanner.instances.get", "spanner.instances.list"}, PredefinedRole: "roles/spanner.viewer", // HEALTH: https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances#State // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items }, linkRules: map[string]*gcpshared.Impact{ "config": { ToSDPItemType: gcpshared.SpannerInstanceConfig, Description: "If the Spanner Instance Config is deleted or updated: The Spanner Instance may fail to operate correctly. If the Spanner Instance is updated: The config remains unaffected.", }, // This is a link from parent to child via SEARCH // We need to make sure that the linked item supports `SEARCH` method for the `instance` name. "name": { ToSDPItemType: gcpshared.SpannerDatabase, Description: "If the Spanner Instance is deleted or updated: All associated databases may become invalid or inaccessible. If a database is updated: The instance remains unaffected.", IsParentToChild: true, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/spanner_instance", Mappings: []*sdp.TerraformMapping{ { // TODO: Confirm this is the name that we want to use TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_spanner_instance.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/spanner-instance_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/spanner/admin/instance/apiv1/instancepb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestSpannerInstance(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() instanceName := "test-instance" spannerInstance := &instancepb.Instance{ Name: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName), DisplayName: "Test Spanner Instance", Config: "projects/test-project/instanceConfigs/regional-us-central1", NodeCount: 3, State: instancepb.Instance_READY, Labels: map[string]string{ "env": "test", "team": "devops", }, ProcessingUnits: 1000, } spannerInstances := &instancepb.ListInstancesResponse{ Instances: []*instancepb.Instance{spannerInstance}, } sdpItemType := gcpshared.SpannerInstance expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://spanner.googleapis.com/v1/projects/%s/instances/%s", projectID, instanceName): { StatusCode: http.StatusOK, Body: spannerInstance, }, fmt.Sprintf("https://spanner.googleapis.com/v1/projects/%s/instances", projectID): { StatusCode: http.StatusOK, Body: spannerInstances, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } getQuery := instanceName sdpItem, err := adapter.Get(ctx, projectID, getQuery, true) if err != nil { t.Fatalf("Failed to get Spanner instance: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != getQuery { t.Errorf("Expected unique attribute value '%s', got %s", instanceName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } if val != fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName) { t.Errorf("Expected name field to be 'projects/%s/instances/%s', got %s", projectID, instanceName, val) } val, err = sdpItem.GetAttributes().Get("displayName") if err != nil { t.Fatalf("Failed to get 'displayName' attribute: %v", err) } if val != "Test Spanner Instance" { t.Errorf("Expected displayName field to be 'Test Spanner Instance', got %s", val) } val, err = sdpItem.GetAttributes().Get("config") if err != nil { t.Fatalf("Failed to get 'config' attribute: %v", err) } if val != "projects/test-project/instanceConfigs/regional-us-central1" { t.Errorf("Expected config field to be 'projects/test-project/instanceConfigs/regional-us-central1', got %s", val) } val, err = sdpItem.GetAttributes().Get("nodeCount") if err != nil { t.Fatalf("Failed to get 'nodeCount' attribute: %v", err) } converted, ok := val.(float64) if !ok { t.Fatalf("Expected nodeCount to be a float64, got %T", val) } if converted != 3 { t.Errorf("Expected nodeCount field to be '3', got %s", val) } val, err = sdpItem.GetAttributes().Get("state") if err != nil { t.Fatalf("Failed to get 'state' attribute: %v", err) } stateValue, ok := val.(string) if !ok { t.Fatalf("Expected state to be a string, got %T", val) } if stateValue != "READY" { t.Errorf("Expected state field to be 'READY', got %s", stateValue) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.SpannerInstanceConfig.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "regional-us-central1", ExpectedScope: projectID, }, { ExpectedType: gcpshared.SpannerDatabase.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: instanceName, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(gcpshared.SpannerInstance, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter is not a ListableAdapter") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list Spanner instances: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 Spanner instance, got %d", len(sdpItems)) } }) } ================================================ FILE: sources/gcp/dynamic/adapters/sql-admin-backup-run.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // SQL Admin Backup Run adapter for Cloud SQL backup runs var _ = registerableAdapter{ sdpType: gcpshared.SQLAdminBackupRun, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/backupRuns/get // GET https://sqladmin.googleapis.com/v1/projects/{project}/instances/{instance}/backupRuns/{id} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithTwoQueries("https://sqladmin.googleapis.com/v1/projects/%s/instances/%s/backupRuns/%s"), // LIST all backup runs across all instances using wildcard ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://sqladmin.googleapis.com/v1/projects/%s/instances/-/backupRuns"), // Reference: https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/backupRuns/list // GET https://sqladmin.googleapis.com/v1/projects/{project}/instances/{instance}/backupRuns SearchEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://sqladmin.googleapis.com/v1/projects/%s/instances/%s/backupRuns"), UniqueAttributeKeys: []string{"instances", "backupRuns"}, // HEALTH: https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/backupRuns#sqlbackuprunstatus // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/sql/docs/mysql/iam-permissions#permissions-gcloud IAMPermissions: []string{"cloudsql.backupRuns.get", "cloudsql.backupRuns.list"}, PredefinedRole: "roles/cloudsql.viewer", }, linkRules: map[string]*gcpshared.Impact{ "instance": { ToSDPItemType: gcpshared.SQLAdminInstance, Description: "They are tightly coupled", }, "diskEncryptionConfiguration.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // The Cloud KMS key version used to encrypt the backup. // Format: projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}/cryptoKeyVersions/{version} "diskEncryptionStatus.kmsKeyVersionName": gcpshared.CryptoKeyVersionImpactInOnly, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/sql-admin-backup-run_test.go ================================================ package adapters_test import ( "context" "testing" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestSQLAdminBackupRun(t *testing.T) { _ = context.Background() _ = "test-project" _ = gcpshared.NewLinker() _ = gcpshared.SQLAdminBackupRun // Note: All tests are skipped because the BackupRun API response structure // doesn't include necessary fields for proper item extraction with current adapter implementation t.Run("Get", func(t *testing.T) { // Note: This test is skipped because the BackupRun API response structure // doesn't include necessary fields for proper item extraction t.Skip("BackupRun API response structure is incompatible with current adapter implementation") }) t.Run("Search", func(t *testing.T) { // Note: This test is skipped for the same reason as Get test t.Skip("BackupRun API response structure is incompatible with current adapter implementation") }) t.Run("ErrorHandling", func(t *testing.T) { // Note: This test is skipped for the same reason as Get and Search tests t.Skip("BackupRun API response structure is incompatible with current adapter implementation") }) } ================================================ FILE: sources/gcp/dynamic/adapters/sql-admin-backup.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // SQL Admin Backup adapter for Cloud SQL backups var _ = registerableAdapter{ sdpType: gcpshared.SQLAdminBackup, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/Backups/GetBackup // GET https://sqladmin.googleapis.com/v1/{name=projects/*/backups/*} GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery("https://sqladmin.googleapis.com/v1/projects/%s/backups/%s"), // Reference: https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/Backups/ListBackups // GET https://sqladmin.googleapis.com/v1/{parent=projects/*}/backups ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://sqladmin.googleapis.com/v1/projects/%s/backups"), UniqueAttributeKeys: []string{"backups"}, // HEALTH: https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/Backups#sqlbackupstate // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/sql/docs/mysql/iam-permissions#permissions-gcloud IAMPermissions: []string{"cloudsql.backupRuns.get", "cloudsql.backupRuns.list"}, PredefinedRole: "roles/cloudsql.viewer", }, linkRules: map[string]*gcpshared.Impact{ "instance": { ToSDPItemType: gcpshared.SQLAdminInstance, Description: "If the Cloud SQL Instance is deleted or updated: The Backup may become invalid or inaccessible. If the Backup is updated: The instance cannot recover from the backup.", }, "kmsKey": gcpshared.CryptoKeyImpactInOnly, "kmsKeyVersion": gcpshared.CryptoKeyVersionImpactInOnly, // VPC network used for private IP access (from instance settings snapshot at backup time). "instanceSettings.settings.ipConfiguration.privateNetwork": gcpshared.ComputeNetworkImpactInOnly, // Allowed external IPv4 networks/ranges that can connect to the instance using its public IP (from instance settings snapshot). // Each entry uses CIDR notation (e.g., 203.0.113.0/24, 198.51.100.5/32). "instanceSettings.settings.ipConfiguration.authorizedNetworks.value": gcpshared.IPImpactBothWays, // Named allocated IP range for use (Private IP only, from instance settings snapshot). // This references an Internal Range resource that was used at backup time. "instanceSettings.settings.ipConfiguration.allocatedIpRange": { ToSDPItemType: gcpshared.NetworkConnectivityInternalRange, Description: "If the Reserved Internal Range is deleted or updated: The backup's instance settings snapshot may reference an invalid IP range configuration. If the backup is updated: The internal range remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Description: "There is no terraform resource for this type.", }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/sql-admin-backup_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/sqladmin/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestSQLAdminBackup(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() backupName := "test-backup" backup := &sqladmin.Backup{ Name: backupName, Instance: "test-instance", KmsKey: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", KmsKeyVersion: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", BackupRun: "1234567890", InstanceSettings: &sqladmin.DatabaseInstance{ Settings: &sqladmin.Settings{ IpConfiguration: &sqladmin.IpConfiguration{ PrivateNetwork: "projects/test-project/global/networks/test-network", AuthorizedNetworks: []*sqladmin.AclEntry{ { Value: "203.0.113.0/24", Name: "office-range", }, { Value: "198.51.100.5/32", Name: "admin-ip", }, }, AllocatedIpRange: "projects/test-project/locations/us-central1/internalRanges/test-range", }, }, }, } backupList := &sqladmin.ListBackupsResponse{ Backups: []*sqladmin.Backup{backup}, } sdpItemType := gcpshared.SQLAdminBackup expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://sqladmin.googleapis.com/v1/projects/%s/backups/%s", projectID, backupName): { StatusCode: http.StatusOK, Body: backup, }, fmt.Sprintf("https://sqladmin.googleapis.com/v1/projects/%s/backups", projectID): { StatusCode: http.StatusOK, Body: backupList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, backupName, true) if err != nil { t.Fatalf("Failed to get backup: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // instance ExpectedType: gcpshared.SQLAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: projectID, }, { // kmsKey ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, { // kmsKeyVersion ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key", "1"), ExpectedScope: projectID, }, { // instanceSettings.settings.ipConfiguration.privateNetwork ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: projectID, }, { // instanceSettings.settings.ipConfiguration.authorizedNetworks.value (first entry) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.0/24", ExpectedScope: "global", }, { // instanceSettings.settings.ipConfiguration.authorizedNetworks.value (second entry) ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "198.51.100.5/32", ExpectedScope: "global", }, // Note: allocatedIpRange link is not tested here because the NetworkConnectivityInternalRange adapter doesn't exist yet. // The link rule is defined in the adapter so it will work automatically when the adapter is created. } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list backups: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 backup, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://sqladmin.googleapis.com/v1/projects/%s/backups/%s", projectID, backupName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Backup not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, backupName, true) if err == nil { t.Error("Expected error when getting non-existent backup, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/sql-admin-instance.go ================================================ package adapters import ( "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Cloud SQL Instance adapter // Reference: https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/instances/get // GET: https://sqladmin.googleapis.com/sql/v1/projects/{project}/instances/{instance} // LIST: https://sqladmin.googleapis.com/sql/v1/projects/{project}/instances var _ = registerableAdapter{ sdpType: gcpshared.SQLAdminInstance, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: gcpshared.ProjectLevelEndpointFuncWithSingleQuery( "https://sqladmin.googleapis.com/sql/v1/projects/%s/instances/%s", ), ListEndpointFunc: gcpshared.ProjectLevelListFunc( "https://sqladmin.googleapis.com/sql/v1/projects/%s/instances", ), // Uniqueness within a project is determined by the instance name segment in the path. UniqueAttributeKeys: []string{"instances"}, IAMPermissions: []string{ "cloudsql.instances.get", "cloudsql.instances.list", }, PredefinedRole: "roles/cloudsql.viewer", // TODO: https://linear.app/overmind/issue/ENG-631/investigate-how-we-can-add-health-status-for-supporting-items // https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/instances#SqlInstanceState }, linkRules: map[string]*gcpshared.Impact{ // VPC network used for private service connectivity. "settings.ipConfiguration.privateNetwork": gcpshared.ComputeNetworkImpactInOnly, // CMEK used to encrypt the primary data disk. "diskEncryptionConfiguration.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // CMEK used for automated backups (if configured). "settings.backupConfiguration.kmsKeyName": gcpshared.CryptoKeyImpactInOnly, // Cloud Storage bucket for SQL Server audit logs. "settings.sqlServerAuditConfig.bucket": { Description: "If the Storage Bucket is deleted or updated: The Cloud SQL Instance may fail to write audit logs. If the Cloud SQL Instance is updated: The bucket remains unaffected.", ToSDPItemType: gcpshared.StorageBucket, }, // Name of the primary (master) instance this replica depends on. "masterInstanceName": { Description: "If the master instance is deleted or updated: This replica may lose replication or become stale. If this replica is updated: The master remains unaffected.", ToSDPItemType: gcpshared.SQLAdminInstance, }, // Failover replica for high availability; changes in the failover target can impact this instance's HA posture. "failoverReplica.name": { Description: "If the failover replica is deleted or updated: High availability for this instance may be reduced or fail. If this instance is updated: The failover replica remains unaffected.", ToSDPItemType: gcpshared.SQLAdminInstance, }, // Read replicas sourced from this primary instance. Changes to this instance can impact replicas, but replica changes typically do not impact the primary. "replicaNames": { Description: "If this primary instance is deleted or materially updated: Its replicas may become unavailable or invalid. Changes on replicas generally do not impact the primary.", ToSDPItemType: gcpshared.SQLAdminInstance, }, // Added: All assigned IP addresses (public or private). Treated as tightly coupled network identifiers. "ipAddresses.ipAddress": gcpshared.IPImpactBothWays, "ipv6Address": gcpshared.IPImpactBothWays, // Added: Service account used by the instance for operations. "serviceAccountEmailAddress": gcpshared.IAMServiceAccountImpactInOnly, // Added: DNS name representing the instance endpoint. "dnsName": { Description: "Tightly coupled with the Cloud SQL Instance endpoint.", ToSDPItemType: stdlib.NetworkDNS, }, // Authorized networks (CIDR ranges) allowed to connect to the instance. "settings.ipConfiguration.authorizedNetworks.value": gcpshared.IPImpactBothWays, // Allocated IP range (secondary IP range in VPC) used for private IP allocation. "settings.ipConfiguration.allocatedIpRange": { Description: "If the Subnetwork's secondary IP range is deleted or updated: The Cloud SQL Instance may fail to allocate private IP addresses. If the instance is updated: The subnetwork remains unaffected.", ToSDPItemType: gcpshared.ComputeSubnetwork, }, // CA pool resource name when using customer-managed CAs. // Format: projects/{project}/locations/{region}/caPools/{caPoolId} // TODO: Private CA resource type (PrivateCACAPool) does not exist yet. Uncomment when created. // "settings.ipConfiguration.serverCaPool": { // Description: "If the Private CA Pool is deleted or updated: The Cloud SQL Instance may fail to use customer-managed certificates. If the instance is updated: The CA pool remains unaffected.", // ToSDPItemType: gcpshared.PrivateCACAPool, // }, // Forward link from parent to child via SEARCH // Link to all backup runs for this instance // NOTE: Due to Go map limitations, only one child resource type can be specified per field key. // Additional child resources (databases, users, sslCerts) would also use the "name" field but // cannot be added here until the framework supports multiple child resource types per field. // Child resources that should be linked: // - SQLAdminBackupRun (implemented below) // - SQLAdminDatabase (requires framework support for multiple child types) // - SQLAdminUser (requires framework support for multiple child types) // - SQLAdminSSLCert (requires framework support for multiple child types) "name": { ToSDPItemType: gcpshared.SQLAdminBackupRun, Description: "If the Cloud SQL Instance is deleted or updated: All associated Backup Runs may become invalid or inaccessible. If a Backup Run is updated: The instance remains unaffected.", IsParentToChild: true, }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database_instance", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_sql_database_instance.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/sql-admin-instance_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/sqladmin/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestSQLAdminInstance(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() instanceName := "test-sql-instance" instance := &sqladmin.DatabaseInstance{ Name: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName), Settings: &sqladmin.Settings{ IpConfiguration: &sqladmin.IpConfiguration{ PrivateNetwork: fmt.Sprintf("projects/%s/global/networks/default", projectID), }, SqlServerAuditConfig: &sqladmin.SqlServerAuditConfig{ Bucket: "audit-logs-bucket", }, }, DiskEncryptionConfiguration: &sqladmin.DiskEncryptionConfiguration{ KmsKeyName: "projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, MasterInstanceName: "master-instance", FailoverReplica: &sqladmin.DatabaseInstanceFailoverReplica{ Name: "failover-replica", }, ReplicaNames: []string{"replica-1", "replica-2"}, ServiceAccountEmailAddress: "test-sa@test-project.iam.gserviceaccount.com", DnsName: "test-sql-instance.database.google.com", IpAddresses: []*sqladmin.IpMapping{ { IpAddress: "10.0.0.50", }, }, Ipv6Address: "2001:db8::1", } instanceName2 := "test-sql-instance-2" instance2 := &sqladmin.DatabaseInstance{ Name: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName2), } instanceList := &sqladmin.InstancesListResponse{ Items: []*sqladmin.DatabaseInstance{instance, instance2}, } sdpItemType := gcpshared.SQLAdminInstance // Mock HTTP responses expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://sqladmin.googleapis.com/sql/v1/projects/%s/instances/%s", projectID, instanceName): { StatusCode: http.StatusOK, Body: instance, }, fmt.Sprintf("https://sqladmin.googleapis.com/sql/v1/projects/%s/instances/%s", projectID, instanceName2): { StatusCode: http.StatusOK, Body: instance2, }, fmt.Sprintf("https://sqladmin.googleapis.com/sql/v1/projects/%s/instances", projectID): { StatusCode: http.StatusOK, Body: instanceList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, instanceName, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } // Validate SDP item properties if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != instanceName { t.Errorf("Expected unique attribute value '%s', got %s", instanceName, sdpItem.UniqueAttributeValue()) } if sdpItem.GetScope() != projectID { t.Errorf("Expected scope '%s', got %s", projectID, sdpItem.GetScope()) } // Validate specific attributes val, err := sdpItem.GetAttributes().Get("name") if err != nil { t.Fatalf("Failed to get 'name' attribute: %v", err) } expectedName := fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName) if val != expectedName { t.Errorf("Expected name field to be '%s', got %s", expectedName, val) } // Include static tests - covers ALL link rule links t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // settings.ipConfiguration.privateNetwork { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: projectID, }, // diskEncryptionConfiguration.kmsKeyName { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, // settings.sqlServerAuditConfig.bucket { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "audit-logs-bucket", ExpectedScope: projectID, }, // ipAddresses.ipAddress { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.50", ExpectedScope: "global", }, // ipv6Address { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:db8::1", ExpectedScope: "global", }, // serviceAccountEmailAddress { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, // dnsName { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-sql-instance.database.google.com", ExpectedScope: "global", }, // masterInstanceName { ExpectedType: gcpshared.SQLAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "master-instance", ExpectedScope: projectID, }, // failoverReplica.name { ExpectedType: gcpshared.SQLAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "failover-replica", ExpectedScope: projectID, }, // replicaNames[0] { ExpectedType: gcpshared.SQLAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "replica-1", ExpectedScope: projectID, }, // replicaNames[1] { ExpectedType: gcpshared.SQLAdminInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "replica-2", ExpectedScope: projectID, }, // name (parent to child search) { ExpectedType: gcpshared.SQLAdminBackupRun.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: instanceName, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } // Validate first item if len(sdpItems) > 0 { firstItem := sdpItems[0] if firstItem.GetType() != sdpItemType.String() { t.Errorf("Expected first item type %s, got %s", sdpItemType.String(), firstItem.GetType()) } if firstItem.GetScope() != projectID { t.Errorf("Expected first item scope '%s', got %s", projectID, firstItem.GetScope()) } } }) t.Run("ErrorHandling", func(t *testing.T) { // Test with error responses to simulate API errors errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://sqladmin.googleapis.com/sql/v1/projects/%s/instances/%s", projectID, instanceName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Instance not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, instanceName, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/storage-bucket.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Storage Bucket adapter for Google Cloud Storage buckets var _ = registerableAdapter{ sdpType: gcpshared.StorageBucket, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, LocationLevel: gcpshared.ProjectLevel, // Reference: https://cloud.google.com/storage/docs/json_api/v1/buckets/get // GET https://storage.googleapis.com/storage/v1/b/{bucket} // Note: Storage buckets are globally unique and don't require project ID in the URL GetEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query != "" { return fmt.Sprintf("https://storage.googleapis.com/storage/v1/b/%s", query) } return "" }, // Reference: https://cloud.google.com/storage/docs/json_api/v1/buckets/list // GET https://storage.googleapis.com/storage/v1/b?project={project} ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://storage.googleapis.com/storage/v1/b?project=%s"), UniqueAttributeKeys: []string{"b"}, IAMPermissions: []string{"storage.buckets.get", "storage.buckets.list"}, PredefinedRole: "roles/storage.bucketViewer", }, linkRules: map[string]*gcpshared.Impact{ // A Cloud KMS key that will be used to encrypt objects written to this bucket if no encryption method is specified as part of the object write request. "encryption.defaultKmsKeyName": gcpshared.CryptoKeyImpactInOnly, // Name of the network. // Format: projects/PROJECT_ID/global/networks/NETWORK_NAME "ipFilter.vpcNetworkSources.network": gcpshared.ComputeNetworkImpactInOnly, // The destination bucket where the current bucket's logs should be placed. "logging.logBucket": { ToSDPItemType: gcpshared.LoggingBucket, Description: "If the Logging Bucket is deleted or updated: The Storage Bucket may fail to write logs. If the Storage Bucket is updated: The Logging Bucket remains unaffected.", }, // Parent-to-child: bucket name links to this bucket's IAM policy (SEARCH returns one policy item). "name": { ToSDPItemType: gcpshared.StorageBucketIAMPolicy, Description: "If the Storage Bucket is deleted or updated: Its IAM policy may become invalid. If the IAM policy is updated: The bucket remains unaffected.", IsParentToChild: true, }, // TODO: Add parent-to-child links once the child adapters are implemented: // - StorageBucketAccessControl (requires adapter implementation) // - StorageDefaultObjectAccessControl (requires adapter implementation) // - StorageNotificationConfig (requires adapter implementation) // Note: Only one parent-to-child link per field (map limitation). "name" is used for StorageBucketIAMPolicy. // since the linkItem function iterates into arrays before calling AutoLink, causing // keys like "acl.entity" instead of "acl" which would never match. // The framework only supports one parent-to-child link per field (map limitation). }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_bucket.name", }, // IAM resources for Storage Buckets. These are Terraform-only constructs // (no standalone GCP API resource exists). When an IAM binding/member/policy // changes, we resolve it to the parent bucket for blast radius analysis. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket_iam { // Authoritative for a given role — grants the role to a list of members. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_bucket_iam_binding.bucket", }, { // Non-authoritative — grants a single member a single role. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_bucket_iam_member.bucket", }, { // Authoritative for the entire IAM policy on the bucket. TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_bucket_iam_policy.bucket", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/storage-bucket_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "google.golang.org/api/storage/v1" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestStorageBucket(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() bucketName := "test-bucket" bucket := &storage.Bucket{ Name: bucketName, Encryption: &storage.BucketEncryption{ DefaultKmsKeyName: "projects/test-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key", }, } bucketList := &storage.Buckets{ Items: []*storage.Bucket{bucket}, } sdpItemType := gcpshared.StorageBucket expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://storage.googleapis.com/storage/v1/b/%s", bucketName): { StatusCode: http.StatusOK, Body: bucket, }, fmt.Sprintf("https://storage.googleapis.com/storage/v1/b?project=%s", projectID): { StatusCode: http.StatusOK, Body: bucketList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, bucketName, true) if err != nil { t.Fatalf("Failed to get bucket: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { // encryption.defaultKmsKeyName ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my-keyring", "my-key"), ExpectedScope: projectID, }, { // name -> StorageBucketIAMPolicy (parent-to-child: one policy per bucket, GET by bucket name) ExpectedType: gcpshared.StorageBucketIAMPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: bucketName, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list buckets: %v", err) } if len(sdpItems) != 1 { t.Errorf("Expected 1 bucket, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://storage.googleapis.com/storage/v1/b/%s", bucketName): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Bucket not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, bucketName, true) if err == nil { t.Error("Expected error when getting non-existent bucket, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters/storage-transfer-transfer-job.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Storage Transfer Transfer Job facilitates data transfers between cloud storage systems and on-premises data // GCP Ref (GET): https://cloud.google.com/storage-transfer/docs/reference/rest/v1/transferJobs/get // GCP Ref (Schema): https://cloud.google.com/storage-transfer/docs/reference/rest/v1/transferJobs#TransferJob // GET https://storagetransfer.googleapis.com/v1/transferJobs/{jobName} // LIST https://storagetransfer.googleapis.com/v1/transferJobs var _ = registerableAdapter{ sdpType: gcpshared.StorageTransferTransferJob, meta: gcpshared.AdapterMeta{ SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, LocationLevel: gcpshared.ProjectLevel, GetEndpointFunc: func(query string, location gcpshared.LocationInfo) string { if query != "" { // query is the job name, use location.ProjectID for the project return fmt.Sprintf("https://storagetransfer.googleapis.com/v1/transferJobs/%s?projectId=%s", query, location.ProjectID) } return "" }, ListEndpointFunc: gcpshared.ProjectLevelListFunc("https://storagetransfer.googleapis.com/v1/transferJobs?filter={\"projectId\":\"%s\"}"), UniqueAttributeKeys: []string{"transferJobs"}, IAMPermissions: []string{ "storagetransfer.jobs.get", "storagetransfer.jobs.list", }, PredefinedRole: "roles/storagetransfer.viewer", // TODO: https://linear.app/overmind/issue/ENG-631 status // https://cloud.google.com/storage-transfer/docs/reference/rest/v1/transferJobs#TransferJob.status }, linkRules: map[string]*gcpshared.Impact{ // Transfer spec references to source and destination storage "transferSpec.gcsDataSource.bucketName": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the source GCS bucket is deleted or inaccessible: The transfer job will fail. If the transfer job is updated: The source bucket remains unaffected.", }, "transferSpec.gcsDataSink.bucketName": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the destination GCS bucket is deleted or inaccessible: The transfer job will fail. If the transfer job is updated: The destination bucket remains unaffected.", }, // TODO: Investigate how we can link to AWS and Azure source when the account id (scope) is not available // https://cloud.google.com/storage-transfer/docs/reference/rest/v1/TransferSpec#AwsS3Data // https://cloud.google.com/storage-transfer/docs/reference/rest/v1/TransferSpec#AzureBlobStorageData // AWS S3 data source credentials secret (Secret Manager) "transferSpec.awsS3DataSource.credentialsSecret": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager secret containing AWS credentials is deleted or updated: The transfer job may fail to authenticate with AWS S3. If the transfer job is updated: The secret remains unaffected.", }, // AWS S3 data source CloudFront domain (HTTP endpoint) "transferSpec.awsS3DataSource.cloudfrontDomain": { ToSDPItemType: stdlib.NetworkHTTP, Description: "If the CloudFront domain endpoint is unreachable: The transfer job will fail to access the source data via CloudFront. If the transfer job is updated: The CloudFront endpoint remains unaffected.", }, // Azure Blob Storage data source credentials secret (Secret Manager) "transferSpec.azureBlobStorageDataSource.credentialsSecret": { ToSDPItemType: gcpshared.SecretManagerSecret, Description: "If the Secret Manager secret containing Azure SAS token is deleted or updated: The transfer job may fail to authenticate with Azure Blob Storage. If the transfer job is updated: The secret remains unaffected.", }, // Agent pool for POSIX source "transferSpec.sourceAgentPoolName": { ToSDPItemType: gcpshared.StorageTransferAgentPool, Description: "If the source Agent Pool is deleted or updated: The transfer job may fail to access POSIX source file systems. If the transfer job is updated: The agent pool remains unaffected.", }, // Agent pool for POSIX sink "transferSpec.sinkAgentPoolName": { ToSDPItemType: gcpshared.StorageTransferAgentPool, Description: "If the sink Agent Pool is deleted or updated: The transfer job may fail to write to POSIX sink file systems. If the transfer job is updated: The agent pool remains unaffected.", }, // Transfer manifest location (gs:// URI pointing to manifest file) "transferSpec.transferManifest.location": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the Storage Bucket containing the transfer manifest is deleted or inaccessible: The transfer job may fail to read the manifest file. If the transfer job is updated: The bucket remains unaffected.", }, // HTTP data source URL - link to HTTP endpoint using stdlib "transferSpec.httpDataSource.listUrl": { ToSDPItemType: stdlib.NetworkHTTP, Description: "HTTP data source URL for transfer operations. If the HTTP endpoint is unreachable: The transfer job will fail to access the source data.", }, "transferSpec.gcsIntermediateDataLocation.bucketName": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the intermediate GCS bucket is deleted or inaccessible: The transfer job will fail. If the transfer job is updated: The intermediate bucket remains unaffected.", }, // Replication spec source bucket "replicationSpec.gcsDataSource.bucketName": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the source GCS bucket for replication is deleted or inaccessible: The replication job will fail. If the replication job is updated: The source bucket remains unaffected.", }, // Replication spec destination bucket "replicationSpec.gcsDataSink.bucketName": { ToSDPItemType: gcpshared.StorageBucket, Description: "If the destination GCS bucket for replication is deleted or inaccessible: The replication job will fail. If the replication job is updated: The destination bucket remains unaffected.", }, "serviceAccount": { ToSDPItemType: gcpshared.IAMServiceAccount, Description: "If the Service Account is deleted or permissions are revoked: The transfer job may fail to execute. If the transfer job is updated: The service account remains unaffected.", }, // Notification configuration "notificationConfig.pubsubTopic": { ToSDPItemType: gcpshared.PubSubTopic, Description: "If the Pub/Sub Topic is deleted: Transfer job notifications will fail. If the transfer job is updated: The Pub/Sub topic remains unaffected.", }, // TODO: Investigate whether we can/should support multiple items for a given key. // In this case, the eventStream can be an AWS SQS ARN in the form 'arn:aws:sqs:region:account_id:queue_name' // https://linear.app/overmind/issue/ENG-1348 // Required. Specifies a unique name of the resource such as AWS SQS ARN in the form 'arn:aws:sqs:region:account_id:queue_name', // or Pub/Sub subscription resource name in the form 'projects/{project}/subscriptions/{sub}'. "eventStream.name": { ToSDPItemType: gcpshared.PubSubSubscription, Description: "If the Pub/Sub Subscription for event streaming is deleted: Transfer job events will not be consumed. If the transfer job is updated: The Pub/Sub subscription remains unaffected.", }, // Latest transfer operation (child resource) "latestOperationName": { ToSDPItemType: gcpshared.StorageTransferTransferOperation, Description: "If the Transfer Operation is deleted or updated: The transfer job's latest operation reference may become invalid. If the transfer job is updated: The operation remains unaffected.", }, }, terraformMapping: gcpshared.TerraformMapping{ Reference: "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_transfer_job", Description: "name => transferJobs/{jobName}", Mappings: []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_transfer_job.name", }, }, }, }.Register() ================================================ FILE: sources/gcp/dynamic/adapters/storage-transfer-transfer-job_test.go ================================================ package adapters_test import ( "context" "fmt" "net/http" "testing" "cloud.google.com/go/storagetransfer/apiv1/storagetransferpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestStorageTransferTransferJob(t *testing.T) { ctx := context.Background() projectID := "test-project" linker := gcpshared.NewLinker() jobName := "transferJobs/123456789" jobID := "123456789" // Just the ID for the Get query job := &storagetransferpb.TransferJob{ Name: jobName, ServiceAccount: "test-sa@test-project.iam.gserviceaccount.com", TransferSpec: &storagetransferpb.TransferSpec{ DataSource: &storagetransferpb.TransferSpec_GcsDataSource{ GcsDataSource: &storagetransferpb.GcsData{ BucketName: "source-bucket", }, }, DataSink: &storagetransferpb.TransferSpec_GcsDataSink{ GcsDataSink: &storagetransferpb.GcsData{ BucketName: "dest-bucket", }, }, }, NotificationConfig: &storagetransferpb.NotificationConfig{ PubsubTopic: fmt.Sprintf("projects/%s/topics/transfer-notifications", projectID), }, } // Second job with HTTP data source, intermediate location, and event stream jobName2 := "transferJobs/123456790" jobID2 := "123456790" job2 := &storagetransferpb.TransferJob{ Name: jobName2, ServiceAccount: "test-sa2@test-project.iam.gserviceaccount.com", TransferSpec: &storagetransferpb.TransferSpec{ DataSource: &storagetransferpb.TransferSpec_HttpDataSource{ HttpDataSource: &storagetransferpb.HttpData{ ListUrl: "https://example.com/urllist.tsv", }, }, DataSink: &storagetransferpb.TransferSpec_GcsDataSink{ GcsDataSink: &storagetransferpb.GcsData{ BucketName: "http-dest-bucket", }, }, IntermediateDataLocation: &storagetransferpb.TransferSpec_GcsIntermediateDataLocation{ GcsIntermediateDataLocation: &storagetransferpb.GcsData{ BucketName: "intermediate-bucket", }, }, }, EventStream: &storagetransferpb.EventStream{ Name: fmt.Sprintf("projects/%s/subscriptions/transfer-events", projectID), }, } jobList := &storagetransferpb.ListTransferJobsResponse{ TransferJobs: []*storagetransferpb.TransferJob{job, job2}, } sdpItemType := gcpshared.StorageTransferTransferJob expectedCallAndResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://storagetransfer.googleapis.com/v1/transferJobs/%s?projectId=%s", jobID, projectID): { StatusCode: http.StatusOK, Body: job, }, fmt.Sprintf("https://storagetransfer.googleapis.com/v1/transferJobs/%s?projectId=%s", jobID2, projectID): { StatusCode: http.StatusOK, Body: job2, }, fmt.Sprintf("https://storagetransfer.googleapis.com/v1/transferJobs?filter={\"projectId\":\"%s\"}", projectID): { StatusCode: http.StatusOK, Body: jobList, }, } t.Run("Get", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, jobID, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != jobID { t.Errorf("Expected unique attribute value '%s', got %s", jobID, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // transferSpec.gcsDataSource.bucketName { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-bucket", ExpectedScope: projectID, }, // transferSpec.gcsDataSink.bucketName { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "dest-bucket", ExpectedScope: projectID, }, // serviceAccount { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, // notificationConfig.pubsubTopic { ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "transfer-notifications", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get with HTTP source and intermediate location", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } sdpItem, err := adapter.Get(ctx, projectID, jobID2, true) if err != nil { t.Fatalf("Failed to get resource: %v", err) } if sdpItem.GetType() != sdpItemType.String() { t.Errorf("Expected type %s, got %s", sdpItemType.String(), sdpItem.GetType()) } if sdpItem.UniqueAttributeValue() != jobID2 { t.Errorf("Expected unique attribute value '%s', got %s", jobID2, sdpItem.UniqueAttributeValue()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // transferSpec.httpDataSource.listUrl (HTTP endpoint) { ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://example.com/urllist.tsv", ExpectedScope: "global", }, // transferSpec.gcsDataSink.bucketName { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "http-dest-bucket", ExpectedScope: projectID, }, // transferSpec.gcsIntermediateDataLocation.bucketName { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "intermediate-bucket", ExpectedScope: projectID, }, // serviceAccount { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa2@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, // eventStream.name { ExpectedType: gcpshared.PubSubSubscription.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "transfer-events", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { httpCli := shared.NewMockHTTPClientProvider(expectedCallAndResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Skipf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list resources: %v", err) } if len(sdpItems) != 2 { t.Errorf("Expected 2 resources, got %d", len(sdpItems)) } }) t.Run("ErrorHandling", func(t *testing.T) { errorResponses := map[string]shared.MockResponse{ fmt.Sprintf("https://storagetransfer.googleapis.com/v1/transferJobs/%s?projectId=%s", jobID, projectID): { StatusCode: http.StatusNotFound, Body: map[string]any{"error": "Transfer job not found"}, }, } httpCli := shared.NewMockHTTPClientProvider(errorResponses) adapter, err := dynamic.MakeAdapter(sdpItemType, linker, httpCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } _, err = adapter.Get(ctx, projectID, jobID, true) if err == nil { t.Error("Expected error when getting non-existent resource, but got nil") } }) } ================================================ FILE: sources/gcp/dynamic/adapters.go ================================================ package dynamic import ( "fmt" "net/http" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) type typeOfAdapter string const ( Standard typeOfAdapter = "standard" Listable typeOfAdapter = "listable" Searchable typeOfAdapter = "searchable" SearchableListable typeOfAdapter = "searchableListable" ) // Adapters returns a list of discovery.Adapters for the given locations. // Each adapter type is created once and handles all locations of its scope type. func Adapters(projectLocations, regionLocations, zoneLocations []gcpshared.LocationInfo, linker *gcpshared.Linker, httpCli *http.Client, manualAdapters map[string]bool, cache sdpcache.Cache) ([]discovery.Adapter, error) { var adapters []discovery.Adapter // Group adapters by location level adaptersByLevel := make(map[gcpshared.LocationLevel]map[shared.ItemType]gcpshared.AdapterMeta) for sdpItemType, meta := range gcpshared.SDPAssetTypeToAdapterMeta { if meta.InDevelopment { // Skip adapters that are in development // This is useful for testing new adapters without exposing them to production continue } if _, ok := adaptersByLevel[meta.LocationLevel]; !ok { adaptersByLevel[meta.LocationLevel] = make(map[shared.ItemType]gcpshared.AdapterMeta) } adaptersByLevel[meta.LocationLevel][sdpItemType] = meta } // Create project-level adapters (one per type) if len(projectLocations) > 0 { for sdpItemType := range adaptersByLevel[gcpshared.ProjectLevel] { if _, ok := manualAdapters[sdpItemType.String()]; ok { // Skip, because we have a manual adapter for this item type continue } adapter, err := MakeAdapter(sdpItemType, linker, httpCli, cache, projectLocations) if err != nil { return nil, fmt.Errorf("failed to add adapter for %s: %w", sdpItemType, err) } adapters = append(adapters, adapter) } } // Create regional adapters (one per type, handling all regions) if len(regionLocations) > 0 { for sdpItemType := range adaptersByLevel[gcpshared.RegionalLevel] { if _, ok := manualAdapters[sdpItemType.String()]; ok { // Skip, because we have a manual adapter for this item type continue } adapter, err := MakeAdapter(sdpItemType, linker, httpCli, cache, regionLocations) if err != nil { return nil, fmt.Errorf("failed to add adapter for %s: %w", sdpItemType, err) } adapters = append(adapters, adapter) } } // Create zonal adapters (one per type, handling all zones) if len(zoneLocations) > 0 { for sdpItemType := range adaptersByLevel[gcpshared.ZonalLevel] { if _, ok := manualAdapters[sdpItemType.String()]; ok { // Skip, because we have a manual adapter for this item type continue } adapter, err := MakeAdapter(sdpItemType, linker, httpCli, cache, zoneLocations) if err != nil { return nil, fmt.Errorf("failed to add adapter for %s: %w", sdpItemType, err) } adapters = append(adapters, adapter) } } return adapters, nil } func adapterType(meta gcpshared.AdapterMeta) typeOfAdapter { if meta.ListEndpointFunc != nil && meta.SearchEndpointFunc == nil { return Listable } if meta.SearchEndpointFunc != nil && meta.ListEndpointFunc == nil { return Searchable } if meta.ListEndpointFunc != nil && meta.SearchEndpointFunc != nil { return SearchableListable } return Standard } // MakeAdapter creates a new GCP dynamic adapter based on the provided SDP item type and metadata. // It accepts a slice of LocationInfo representing all locations this adapter should handle. func MakeAdapter(sdpItemType shared.ItemType, linker *gcpshared.Linker, httpCli *http.Client, cache sdpcache.Cache, locations []gcpshared.LocationInfo) (discovery.Adapter, error) { meta, ok := gcpshared.SDPAssetTypeToAdapterMeta[sdpItemType] if !ok { return nil, fmt.Errorf("no adapter metadata found for item type %s", sdpItemType.String()) } // Validate that all locations match the adapter's expected scope type for _, loc := range locations { if loc.LocationLevel() != meta.LocationLevel { return nil, fmt.Errorf("location %s has scope %s, expected %s", loc.ToScope(), loc.LocationLevel(), meta.LocationLevel) } } cfg := &AdapterConfig{ Locations: locations, GetURLFunc: meta.GetEndpointFunc, SDPAssetType: sdpItemType, SDPAdapterCategory: meta.SDPAdapterCategory, TerraformMappings: gcpshared.SDPAssetTypeToTerraformMappings[sdpItemType].Mappings, Linker: linker, HTTPClient: httpCli, UniqueAttributeKeys: meta.UniqueAttributeKeys, IAMPermissions: meta.IAMPermissions, NameSelector: meta.NameSelector, ListResponseSelector: meta.ListResponseSelector, SearchFilterFunc: meta.SearchFilterFunc, ListFilterFunc: meta.ListFilterFunc, } switch adapterType(meta) { case SearchableListable: return NewSearchableListableAdapter(meta.SearchEndpointFunc, meta.ListEndpointFunc, cfg, meta.SearchDescription, cache), nil case Searchable: return NewSearchableAdapter(meta.SearchEndpointFunc, cfg, meta.SearchDescription, cache), nil case Listable: return NewListableAdapter(meta.ListEndpointFunc, cfg, cache), nil case Standard: return NewAdapter(cfg, cache), nil default: return nil, fmt.Errorf("unknown adapter type %s", adapterType(meta)) } } ================================================ FILE: sources/gcp/dynamic/adapters_test.go ================================================ package dynamic import ( "net/http" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func Test_adapterType(t *testing.T) { tests := []struct { name string meta gcpshared.AdapterMeta want typeOfAdapter }{ { name: "Listable only", meta: gcpshared.AdapterMeta{ ListEndpointFunc: func(loc gcpshared.LocationInfo) (string, error) { return "", nil }, }, want: Listable, }, { name: "Searchable only", meta: gcpshared.AdapterMeta{ SearchEndpointFunc: func(query string, loc gcpshared.LocationInfo) string { return "" }, }, want: Searchable, }, { name: "SearchableListable", meta: gcpshared.AdapterMeta{ ListEndpointFunc: func(loc gcpshared.LocationInfo) (string, error) { return "", nil }, SearchEndpointFunc: func(query string, loc gcpshared.LocationInfo) string { return "" }, }, want: SearchableListable, }, { name: "Standard (neither func set)", meta: gcpshared.AdapterMeta{}, want: Standard, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := adapterType(tt.meta); got != tt.want { t.Errorf("adapterType() = %v, want %v", got, tt.want) } }) } } func Test_addAdapter(t *testing.T) { type testCase struct { name string sdpType shared.ItemType locations []gcpshared.LocationInfo listable bool searchable bool searchableListable bool standard bool } projectLocation := []gcpshared.LocationInfo{gcpshared.NewProjectLocation("my-project")} testCases := []testCase{ { name: "Listable adapter", sdpType: gcpshared.ComputeNetwork, locations: projectLocation, listable: true, }, { name: "SearchableListable adapter (firewall with tag search)", sdpType: gcpshared.ComputeFirewall, locations: projectLocation, searchableListable: true, }, { name: "Searchable adapter", sdpType: gcpshared.SQLAdminBackupRun, locations: projectLocation, searchableListable: true, }, { name: "SearchableListable adapter", sdpType: gcpshared.MonitoringCustomDashboard, locations: projectLocation, searchableListable: true, }, { name: "Standard adapter", sdpType: gcpshared.CloudBillingBillingInfo, locations: projectLocation, standard: true, }, } linker := gcpshared.NewLinker() for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { meta := gcpshared.SDPAssetTypeToAdapterMeta[tc.sdpType] adapter, err := MakeAdapter(tc.sdpType, linker, http.DefaultClient, sdpcache.NewNoOpCache(), tc.locations) if err != nil { t.Errorf("MakeAdapter() error = %v", err) } if tc.listable { if meta.ListEndpointFunc == nil { t.Errorf("Expected ListEndpointFunc to be set for listable adapter %s", tc.sdpType) } if meta.SearchEndpointFunc != nil { t.Errorf("Expected SearchEndpointFunc to be nil for listable adapter %s", tc.sdpType) } _, ok := adapter.(discovery.ListableAdapter) if !ok { t.Errorf("Expected adapter to be ListableAdapter, got %T", adapter) } return } if tc.searchable { if meta.SearchEndpointFunc == nil { t.Errorf("Expected SearchEndpointFunc to be set for searchable adapter %s", tc.sdpType) } if meta.ListEndpointFunc != nil { t.Errorf("Expected ListEndpointFunc to be nil for searchable adapter %s", tc.sdpType) } _, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Errorf("Expected adapter to be SearchableAdapter, got %T", adapter) } return } if tc.searchableListable { if meta.ListEndpointFunc == nil { t.Errorf("Expected ListEndpointFunc to be set for searchable listable adapter %s", tc.sdpType) } if meta.SearchEndpointFunc == nil { t.Errorf("Expected SearchEndpointFunc to be set for searchable listable adapter %s", tc.sdpType) } _, ok := adapter.(SearchableListableAdapter) if !ok { t.Errorf("Expected adapter to be SearchableListableAdapter, got %T", adapter) } return } if tc.standard { if meta.ListEndpointFunc != nil { t.Errorf("Expected ListEndpointFunc to be nil for standard adapter %s", tc.sdpType) } if meta.SearchEndpointFunc != nil { t.Errorf("Expected SearchEndpointFunc to be nil for standard adapter %s", tc.sdpType) } return } }) } } func TestAdapters(t *testing.T) { type validator interface { Validate() error } // Let's ensure that we can create adapters without any issues. adapters, err := Adapters( []gcpshared.LocationInfo{gcpshared.NewProjectLocation("my-project")}, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation("my-project", "us-central1")}, []gcpshared.LocationInfo{gcpshared.NewZonalLocation("my-project", "us-central1-a")}, gcpshared.NewLinker(), http.DefaultClient, nil, sdpcache.NewNoOpCache(), ) if err != nil { t.Fatalf("Adapters() error = %v", err) } for _, adapter := range adapters { if adapter == nil { t.Error("Expected non-nil adapter, got nil") continue } meta := adapter.Metadata() if meta == nil { t.Error("Expected non-nil metadata, got nil") continue } validatable, ok := adapter.(validator) if !ok { t.Errorf("Expected adapter to implement Validate(), got %T", adapter) continue } if err := validatable.Validate(); err != nil { t.Errorf("Validate() error for adapter %s: %v", adapter.Name(), err) } if adapter.Metadata().GetTerraformMappings() != nil { for _, tm := range adapter.Metadata().GetTerraformMappings() { if tm.GetTerraformMethod() == sdp.QueryMethod_SEARCH { if _, ok := adapter.(discovery.SearchableAdapter); !ok { t.Errorf("Adapter %s has terraform mapping for SEARCH but does not implement SearchableAdapter", adapter.Name()) } } } } } } ================================================ FILE: sources/gcp/dynamic/ai-tools/README.md ================================================ # Dynamic Adapter AI Tools This directory contains tools for generating prompts and tickets for dynamic adapter development and testing. ## Files - `generate-test-ticket-cmd/` - Go implementation for generating Linear ticket content for dynamic adapter unit tests - `generate-adapter-ticket-cmd/` - Go implementation for generating Linear ticket content for creating new dynamic adapters - `build.sh` - Build script for both tools - `README.md` - This documentation ## Related Files - `../adapters/.cursor/rules/dynamic-adapter-testing.md` - Cursor agent rules for writing adapter tests - `../adapters/.cursor/rules/dynamic-adapter-creation.md` - Cursor agent rules for creating new adapters - `../adapters/` - Directory containing dynamic adapter implementations ## generate-adapter-ticket ### Purpose Generates complete Linear ticket content for creating new dynamic adapters. This tool helps create comprehensive tickets for implementing new GCP resource adapters with proper context and requirements. ### Usage ```bash # Run directly with go run go run generate-adapter-ticket-cmd/main.go -name -api-ref [-type-ref ] [--verbose] # Or build and run ./build.sh ./generate-adapter-ticket -name -api-ref [-type-ref ] [--verbose] # Build for specific platform ./build.sh linux/amd64 ./build.sh darwin/arm64 ``` ### Examples ```bash # Generate ticket for monitoring alert policy adapter go run generate-adapter-ticket-cmd/main.go -name monitoring-alert-policy -api-ref "https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.alertPolicies" # Generate ticket with type reference go run generate-adapter-ticket-cmd/main.go -name compute-instance-template -api-ref "https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates" -type-ref "https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates#InstanceTemplate" # Generate with verbose output go run generate-adapter-ticket-cmd/main.go --verbose -name storage-bucket -api-ref "https://cloud.google.com/storage/docs/json_api/v1/buckets" ``` ### What it does 1. **Create Linear ticket** for new adapter implementation 2. **Generate comprehensive context** with API references 3. **Include implementation checklist** following dynamic adapter patterns 4. **Reference Cursor rules** for consistent implementation 5. **Copy description to clipboard** and optionally print it ### Output The tool generates a Linear URL with pre-filled fields and copies the description to clipboard. The description includes: - Task overview - API references - Files to create - Implementation instructions referencing Cursor rules ## generate-test-ticket ### Purpose Generates complete Linear ticket content for creating unit tests for dynamic adapters. The Go implementation provides better maintainability, type safety, and cross-platform compatibility. ### Usage ```bash # Run directly with go run go run generate-test-ticket-cmd/main.go [--verbose|-v] # Or build and run ./build.sh ./generate-test-ticket [--verbose|-v] # Build for specific platform ./build.sh linux/amd64 ./build.sh darwin/arm64 # Build specific tool only ./build.sh "" generate-test-ticket ./build.sh linux/amd64 generate-test-ticket ``` ### Examples ```bash # Generate ticket for compute global forwarding rule (quiet mode) go run generate-test-ticket-cmd/main.go compute-global-forwarding-rule # Generate ticket with verbose output (shows description) go run generate-test-ticket-cmd/main.go --verbose compute-global-forwarding-rule # Short form of verbose flag go run generate-test-ticket-cmd/main.go -v compute-global-address ``` ### What it does 1. **Extract adapter information** from the adapter file in `../adapters/` 2. **Determine protobuf types** based on adapter name patterns 3. **Extract link rules** configuration from the adapter 4. **Generate a Linear URL** with basic fields pre-filled: - Title: "Write unit test for {adapter-name} dynamic adapter" - Assignee: Cursor Agent - Project: GCP Source Improvements - Cycle: This - Size: Small (2 points) - Status: Todo - Milestone: Quality Improvements 5. **Copy description to clipboard** and optionally print it ### Output The tool generates a Linear URL with basic fields and copies the description to clipboard. In verbose mode (`--verbose` or `-v`), it also prints the complete description for review. ### Requirements - Must be run from the `prompter` directory - Adapter file must exist in `../adapters/` - Adapter file must contain valid SDP item type and link rules configuration - Go 1.19+ required ## Integration with Cursor Agents The generated tickets work seamlessly with: - **Cursor rules** in `../adapters/.cursor/rules/dynamic-adapter-testing.md` - **Existing test patterns** from `../adapters/compute-global-address_test.go` - **Comprehensive testing requirements** for Get, List, and Search functionality ## Workflow ### Creating New Adapters #### Quick Mode (default) 1. **Generate Linear URL** using `generate-adapter-ticket` 2. **Click the URL** to create a new Linear issue with basic fields pre-filled 3. **Paste the description** (already copied to clipboard) into the issue 4. **Save the issue** - it's ready for implementation #### Review Mode (verbose) 1. **Generate Linear URL** using `generate-adapter-ticket --verbose` 2. **Review the description** printed in the output 3. **Click the URL** to create a new Linear issue with basic fields pre-filled 4. **Paste the description** (already copied to clipboard) into the issue 5. **Save the issue** - it's ready for implementation ### Creating Tests for Existing Adapters #### Quick Mode (default) 1. **Generate Linear URL** using `generate-test-ticket` 2. **Click the URL** to create a new Linear issue with basic fields pre-filled 3. **Paste the description** (already copied to clipboard) into the issue 4. **Save the issue** - it's already assigned to Cursor Agent #### Review Mode (verbose) 1. **Generate Linear URL** using `generate-test-ticket --verbose` or `-v` flag 2. **Review the description** printed in the output 3. **Click the URL** to create a new Linear issue with basic fields pre-filled 4. **Paste the description** (already copied to clipboard) into the issue 5. **Save the issue** - it's already assigned to Cursor Agent ### Cursor Agent Execution When a Cursor agent picks up the ticket: 1. It will automatically apply the rules from `../adapters/.cursor/rules/dynamic-adapter-testing.md` 2. Follow the comprehensive testing patterns 3. Create the test file with proper structure 4. Include all required test cases (Get, List, Search if supported) 5. Add proper link rules tests ## Example Ticket Content For `compute-global-forwarding-rule`: **Title**: `Write unit test for compute-global-forwarding-rule dynamic adapter` **Key Details**: - **SDP Item Type**: `gcpshared.ComputeGlobalForwardingRule` - **Protobuf Types**: `computepb.ForwardingRule` and `computepb.ForwardingRuleList` - **API Endpoints**: - GET: `https://compute.googleapis.com/compute/v1/projects/{project}/global/forwardingRules/{forwardingRule}` - LIST: `https://compute.googleapis.com/compute/v1/projects/{project}/global/forwardingRules` - **Link Rules**: network (InOnly), subnetwork (InOnly), IPAddress (BothWays), backendService (BothWays) ## Benefits 1. **Consistency**: All tests follow the same patterns and structure 2. **Completeness**: Comprehensive coverage of Get, List, and Search functionality 3. **Automation**: Cursor agents can automatically generate high-quality tests 4. **Documentation**: Clear requirements and acceptance criteria 5. **Maintainability**: Standardized approach makes tests easier to maintain ## Adding New Adapters ### Complete Workflow for New Adapters #### Step 1: Create Implementation Ticket 1. Run `go run generate-adapter-ticket-cmd/main.go -name my-new-adapter -api-ref "https://api-reference-url"` 2. Click the generated URL to create Linear issue with pre-filled fields 3. Paste the description (copied to clipboard) into the issue 4. Save the issue - it's ready for implementation #### Step 2: Implement the Adapter The Cursor agent (or developer) will: 1. Follow the rules in `../adapters/.cursor/rules/dynamic-adapter-creation.md` 2. Create the adapter file (e.g., `my-new-adapter.go`) 3. Add any necessary SDP item types to `../shared/item-types.go` and `../shared/models.go` #### Step 3: Create Test Ticket 1. Run `go run generate-test-ticket-cmd/main.go my-new-adapter` to generate test ticket content 2. Click the generated URL to create Linear issue with pre-filled fields 3. Paste the description (copied to clipboard) into the issue 4. Save the issue - it's already assigned to Cursor Agent ### Quick Testing for Existing Adapters When you just need tests for an existing adapter: 1. Run `go run generate-test-ticket-cmd/main.go existing-adapter-name` 2. Click the generated URL to create Linear issue with pre-filled fields 3. Paste the description (copied to clipboard) into the issue 4. Save the issue - it's already assigned to Cursor Agent ## Rules Application ### For Adapter Creation The `../adapters/.cursor/rules/dynamic-adapter-creation.md` file ensures that: - Proper adapter structure and patterns are followed - Correct SDP item types and metadata are defined - Appropriate link rules are configured - Terraform mappings are included when applicable - IAM permissions are properly defined ### For Test Creation The `../adapters/.cursor/rules/dynamic-adapter-testing.md` file ensures that: - All tests use the correct package (`adapters_test`) - Proper imports are included - Correct protobuf types are used - Comprehensive test coverage is provided - Static tests with link rules are included - Common mistakes are avoided This ensures consistent, high-quality implementations and unit tests for all dynamic adapters. ## Quick Reference ### Building Tools ```bash # Build both tools for current platform ./build.sh # Build for specific platform ./build.sh linux/amd64 # Build specific tool only ./build.sh "" generate-adapter-ticket ./build.sh "" generate-test-ticket ``` ### Creating New Adapter ```bash # Generate implementation ticket go run generate-adapter-ticket-cmd/main.go -name my-adapter -api-ref "https://api-url" # After implementation, generate test ticket go run generate-test-ticket-cmd/main.go my-adapter ``` ### Testing Existing Adapter ```bash # Generate test ticket go run generate-test-ticket-cmd/main.go existing-adapter-name ``` Both tools support `--verbose` flag to preview the description before creating tickets. ================================================ FILE: sources/gcp/dynamic/ai-tools/build.sh ================================================ #!/bin/bash # Build script for prompter tools # Usage: ./build.sh [platform] [tool] # Examples: # ./build.sh # Build both tools for current platform # ./build.sh linux/amd64 # Build both tools for Linux AMD64 # ./build.sh darwin/arm64 # Build both tools for macOS ARM64 # ./build.sh "" generate-test-ticket # Build only generate-test-ticket for current platform # ./build.sh linux/amd64 generate-adapter-ticket # Build only generate-adapter-ticket for Linux AMD64 set -e # Check if Go is installed if ! command -v go &> /dev/null; then echo "Error: Go is not installed or not in PATH" exit 1 fi PLATFORM="${1:-}" TOOL="${2:-}" # Define available tools TOOLS=("generate-test-ticket" "generate-adapter-ticket") # If specific tool requested, validate it if [ -n "$TOOL" ]; then if [[ ! " ${TOOLS[@]} " =~ " ${TOOL} " ]]; then echo "Error: Unknown tool '$TOOL'. Available tools: ${TOOLS[*]}" exit 1 fi TOOLS=("$TOOL") fi # Build function build_tool() { local tool="$1" local platform="$2" local source_dir="${tool}-cmd" local binary_name="$tool" if [ ! -d "$source_dir" ]; then echo "Error: Source directory '$source_dir' not found" return 1 fi if [ -z "$platform" ]; then echo "Building $binary_name for current platform..." go build -o "$binary_name" "./$source_dir" echo "✅ Built successfully: $binary_name" else echo "Building $binary_name for $platform..." # Split platform into GOOS and GOARCH IFS='/' read -r GOOS GOARCH <<< "$platform" if [ -z "$GOOS" ] || [ -z "$GOARCH" ]; then echo "Error: Invalid platform format. Use: os/arch (e.g., linux/amd64)" return 1 fi OUTPUT_NAME="${binary_name}-${GOOS}-${GOARCH}" if [ "$GOOS" = "windows" ]; then OUTPUT_NAME="${OUTPUT_NAME}.exe" fi GOOS="$GOOS" GOARCH="$GOARCH" go build -o "$OUTPUT_NAME" "./$source_dir" echo "✅ Built successfully: $OUTPUT_NAME" fi } # Build all requested tools for tool in "${TOOLS[@]}"; do build_tool "$tool" "$PLATFORM" done echo "" echo "Built tools:" for tool in "${TOOLS[@]}"; do echo " $tool" done echo "" echo "Usage examples:" echo " ./generate-test-ticket [--verbose|-v] " echo " ./generate-adapter-ticket -name monitoring-alert-policy -api-ref https://..." echo "" echo "For more information, see README.md" ================================================ FILE: sources/gcp/dynamic/ai-tools/generate-adapter-ticket-cmd/main.go ================================================ package main import ( "bufio" "context" "flag" "fmt" "os" "os/exec" "strings" "time" ) // This executable produces an adapter authoring prompt by filling a template with // user-provided parameters. // Usage: // go run ./sources/gcp/dynamic/prompter -name monitoring-alert-policy -api https://... -type https://... // -type is optional. const baseTemplate = `## Task Create a new dynamic adapter for GCP {{NAME}} resource. ## Context - **Adapter File**: ` + "`sources/gcp/dynamic/adapters/{{NAME}}.go`" + ` (to be created) - **API Reference**: {{API_REF}} {{TYPE_LINE}} ## Files to Create - ` + "`sources/gcp/dynamic/adapters/{{NAME}}.go`" + ` - ` + "`sources/gcp/shared/item-types.go`" + ` (if new SDP item type needed) - ` + "`sources/gcp/shared/models.go`" + ` (if new SDP item type needed) ## Instructions Follow the dynamic adapter creation rules in ` + "`.cursor/rules/dynamic-adapter-creation.mdc`" + ` for comprehensive implementation guidance.` func main() { name := flag.String("name", "", "(required) adapter name, e.g. monitoring-alert-policy") api := flag.String("api-ref", "", "(required) GCP reference for API Call structure") typeRef := flag.String("type-ref", "", "(optional) GCP reference for Type Definition") verbose := flag.Bool("verbose", false, "print ticket content instead of copying to clipboard") flag.Parse() missing := []string{} if *name == "" { missing = append(missing, "-name") } if *api == "" { missing = append(missing, "-api-ref") } if len(missing) > 0 { fmt.Fprintf(os.Stderr, "Missing required flags: %s\n", strings.Join(missing, ", ")) flag.Usage() os.Exit(2) } // Generate adapter creation description adapterDescription := baseTemplate adapterDescription = strings.ReplaceAll(adapterDescription, "{{NAME}}", *name) adapterDescription = strings.ReplaceAll(adapterDescription, "{{API_REF}}", *api) if *typeRef != "" { adapterDescription = strings.ReplaceAll(adapterDescription, "{{TYPE_LINE}}", "- **Type Reference**: "+*typeRef+"\n") } else { adapterDescription = strings.ReplaceAll(adapterDescription, "{{TYPE_LINE}}", "") } // Generate test ticket description testDescription, err := generateTestTicketDescription(*name) if err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not generate test ticket description: %v\n", err) testDescription = fmt.Sprintf("## Test Ticket\nWrite unit test for %s dynamic adapter (test ticket generation failed)", *name) } // Combine both descriptions with workflow instructions combinedDescription := fmt.Sprintf(`%s --- %s --- ## Workflow ### Phase 1: Adapter Implementation 1. Create the adapter by following the relevant rule in `+"`.cursor/rules/dynamic-adapter-creation.md`"+` 2. Open PR with adapter implementation ### Phase 2: Unit Tests (After Reviewer Tag) 1. Wait for reviewer to add the `+"`adapter-is-approved`"+` tag to the PR 2. Once tagged, add unit tests to the same PR following `+"`.cursor/rules/dynamic-adapter-testing.md`"+` 3. Update the existing PR with test implementation`, adapterDescription, testDescription) // Generate Linear URL url := generateLinearURL(*name) fmt.Printf("Generated Linear issue URL:\n%s\n\n", url) if err := copyToClipboard(combinedDescription); err != nil { fmt.Println("💡 Tip: Copy the description below to paste into the Linear issue") } else { fmt.Println("✅ Combined description copied to clipboard!") } fmt.Printf("\nClick the URL above to create a new Linear issue with:\n") fmt.Printf("- Title: Create %s dynamic adapter\n", *name) fmt.Printf("- Assignee: cursor\n") fmt.Printf("- Project: GCP Source Improvements\n") fmt.Printf("- Cycle: This\n") fmt.Printf("- Size: Small (2 points)\n") fmt.Printf("- Status: Todo\n") fmt.Printf("- Milestone: Quality Improvements\n\n") if *verbose { fmt.Println("Combined description is already copied to clipboard - paste it into the issue:") fmt.Println("==========================================") fmt.Println(combinedDescription) fmt.Println("==========================================") } else { fmt.Println("Combined description is already copied to clipboard - paste it into the issue.") } } func generateTestTicketDescription(adapterName string) (string, error) { // Minimal test ticket description - let Cursor rule handle the details return fmt.Sprintf(`## Test Ticket Write unit tests for the `+"`%s`"+` dynamic adapter. ## Files to Create - `+"`sources/gcp/dynamic/adapters/%s_test.go`"+` ## Instructions Follow the dynamic adapter testing rules in `+"`.cursor/rules/dynamic-adapter-testing.md`"+` for comprehensive test implementation.`, adapterName, adapterName), nil } func generateLinearURL(adapterName string) string { title := fmt.Sprintf("Create %s dynamic adapter", adapterName) titleEncoded := strings.ReplaceAll(title, " ", "+") return fmt.Sprintf("https://linear.new?title=%s&assignee=cursor&project=GCP+Source+Improvements&cycle=This&estimate=2&status=Todo&projectMilestone=Quantity+Improvements", titleEncoded) } func copyToClipboard(text string) error { // Define allowed clipboard commands for security allowedCommands := map[string][]string{ "pbcopy": {}, "xclip": {"-selection", "clipboard"}, "wl-copy": {}, } // Try different clipboard commands based on OS commandOrder := []string{"pbcopy", "xclip", "wl-copy"} for _, cmdName := range commandOrder { args := allowedCommands[cmdName] // Check if command is available with timeout ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if exec.CommandContext(ctx, cmdName).Run() != nil { cancel() continue // Command not available } cancel() ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) process := exec.CommandContext(ctx, cmdName, args...) stdin, err := process.StdinPipe() if err != nil { cancel() continue } if err := process.Start(); err != nil { cancel() continue } writer := bufio.NewWriter(stdin) if _, err := writer.WriteString(text); err != nil { stdin.Close() cancel() continue } writer.Flush() stdin.Close() if err := process.Wait(); err != nil { cancel() continue } cancel() return nil // Success } return fmt.Errorf("no clipboard command available") } ================================================ FILE: sources/gcp/dynamic/ai-tools/generate-test-ticket-cmd/main.go ================================================ package main import ( "bufio" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) type Config struct { Verbose bool AdapterName string } type AdapterInfo struct { Name string } func main() { config := parseArgs() adapterInfo, err := extractAdapterInfo(config.AdapterName) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } description := generateDescription(adapterInfo) url := generateLinearURL(adapterInfo.Name) fmt.Printf("Generated Linear issue URL:\n%s\n\n", url) if err := copyToClipboard(description); err != nil { fmt.Println("💡 Tip: Copy the description below to paste into the Linear issue") } else { fmt.Println("✅ Description copied to clipboard!") } fmt.Printf("\nClick the URL above to create a new Linear issue with:\n") fmt.Printf("- Title: Write unit test for %s dynamic adapter\n", adapterInfo.Name) fmt.Printf("- Assignee: cursor\n") fmt.Printf("- Project: GCP Source Improvements\n") fmt.Printf("- Cycle: This\n") fmt.Printf("- Size: Small (2 points)\n") fmt.Printf("- Status: Todo\n") fmt.Printf("- Milestone: Quality Improvements\n\n") if config.Verbose { fmt.Println("Description is already copied to clipboard - paste it into the issue:") fmt.Println("==========================================") fmt.Println(description) fmt.Println("==========================================") } else { fmt.Println("Description is already copied to clipboard - paste it into the issue.") } } func parseArgs() Config { config := Config{} if len(os.Args) < 2 { printUsage() os.Exit(1) } args := os.Args[1:] for _, arg := range args { switch arg { case "--verbose", "-v": config.Verbose = true default: if config.AdapterName == "" { config.AdapterName = arg } else { fmt.Fprintf(os.Stderr, "Error: Multiple adapter names provided\n") os.Exit(1) } } } if config.AdapterName == "" { printUsage() os.Exit(1) } return config } func printUsage() { fmt.Printf("Usage: %s [--verbose|-v] \n", os.Args[0]) fmt.Printf("Example: %s compute-global-forwarding-rule\n", os.Args[0]) fmt.Printf("Example: %s --verbose compute-global-forwarding-rule\n", os.Args[0]) } func extractAdapterInfo(adapterName string) (*AdapterInfo, error) { adapterFile := adapterName + ".go" adapterPath := filepath.Join("..", "adapters", adapterFile) // Check if adapter file exists if _, err := os.Stat(adapterPath); os.IsNotExist(err) { return nil, fmt.Errorf("adapter file '%s' not found", adapterPath) } // For simplified version, we just need the adapter name info := &AdapterInfo{Name: adapterName} return info, nil } func generateDescription(info *AdapterInfo) string { return fmt.Sprintf(`## Task Write unit tests for the `+"`%s`"+` dynamic adapter. ## Context - **Adapter File**: `+"`sources/gcp/dynamic/adapters/%s.go`"+` - **Test File**: `+"`sources/gcp/dynamic/adapters/%s_test.go`"+` (to be created) ## Files to Create - `+"`sources/gcp/dynamic/adapters/%s_test.go`"+` ## Instructions Follow the dynamic adapter testing rules in `+"`.cursor/rules/dynamic-adapter-testing.mdc`"+` for comprehensive test implementation.`, info.Name, info.Name, info.Name, info.Name) } func generateLinearURL(adapterName string) string { title := fmt.Sprintf("Write unit test for %s dynamic adapter", adapterName) titleEncoded := strings.ReplaceAll(title, " ", "+") return fmt.Sprintf("https://linear.new?title=%s&assignee=cursor&project=GCP+Source+Improvements&cycle=This&estimate=2&status=Todo&projectMilestone=Quality+Improvements", titleEncoded) } func copyToClipboard(text string) error { // Define allowed clipboard commands for security allowedCommands := map[string][]string{ "pbcopy": {}, "xclip": {"-selection", "clipboard"}, "wl-copy": {}, } // Try different clipboard commands based on OS commandOrder := []string{"pbcopy", "xclip", "wl-copy"} for _, cmdName := range commandOrder { args := allowedCommands[cmdName] // Check if command is available with timeout ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if exec.CommandContext(ctx, cmdName).Run() != nil { cancel() continue // Command not available } cancel() ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) process := exec.CommandContext(ctx, cmdName, args...) stdin, err := process.StdinPipe() if err != nil { cancel() continue } if err := process.Start(); err != nil { cancel() continue } writer := bufio.NewWriter(stdin) if _, err := writer.WriteString(text); err != nil { stdin.Close() cancel() continue } writer.Flush() stdin.Close() if err := process.Wait(); err != nil { cancel() continue } cancel() return nil // Success } return fmt.Errorf("no clipboard command available") } ================================================ FILE: sources/gcp/dynamic/errors.go ================================================ package dynamic import ( "fmt" ) type PermissionError struct { URL string } func (e *PermissionError) Error() string { return fmt.Sprintf("permission denied: %s", e.URL) } ================================================ FILE: sources/gcp/dynamic/shared.go ================================================ package dynamic import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "slices" "strings" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/pool" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ( getDescription = func(sdpAssetType shared.ItemType, uniqueAttributeKeys []string) string { selector := "\"name\"" if len(uniqueAttributeKeys) > 1 { // i.e.: "datasets|tables" for bigquery tables selector = "\"" + strings.Join(uniqueAttributeKeys, shared.QuerySeparator) + "\"" } return fmt.Sprintf("Get a %s by its %s", sdpAssetType, selector) } listDescription = func(sdpAssetType shared.ItemType) string { return fmt.Sprintf("List all %s", sdpAssetType) } searchDescription = func(sdpAssetType shared.ItemType, uniqueAttributeKeys []string, customSearchMethodDesc string) string { if customSearchMethodDesc != "" { return customSearchMethodDesc } if len(uniqueAttributeKeys) < 2 { panic("searchDescription requires at least two unique attribute keys: " + sdpAssetType.String()) } // For service directory endpoint adapter, the uniqueAttributeKeys is: []string{"locations", "namespaces", "services", "endpoints"} // We want to create a selector like: // locations|namespaces|services // We remove the last key, because it defines the actual item selector selector := "\"" + strings.Join(uniqueAttributeKeys[:len(uniqueAttributeKeys)-1], shared.QuerySeparator) + "\"" return fmt.Sprintf("Search for %s by its %s", sdpAssetType, selector) } ) // enrichNOTFOUNDQueryError sets Scope, SourceName, ItemType, ResponderName on a NOTFOUND QueryError when they are empty, // so cached/returned errors have consistent metadata for debugging and cache inspection. func enrichNOTFOUNDQueryError(err error, scope, sourceName, itemType string) { var qe *sdp.QueryError if err == nil || !errors.As(err, &qe) || qe.GetErrorType() != sdp.QueryError_NOTFOUND { return } if qe.GetScope() != "" { return } qe.Scope = scope qe.SourceName = sourceName qe.ItemType = itemType qe.ResponderName = sourceName } func linkItem(ctx context.Context, projectID string, sdpItem *sdp.Item, sdpAssetType shared.ItemType, linker *gcpshared.Linker, resp any, keys []string) { if value, ok := resp.(string); ok { linker.AutoLink(ctx, projectID, sdpItem, sdpAssetType, value, keys) return } if listAny, ok := resp.([]any); ok { for _, v := range listAny { linkItem(ctx, projectID, sdpItem, sdpAssetType, linker, v, keys) } return } if mapAny, ok := resp.(map[string]any); ok { for k, item := range mapAny { linkItem(ctx, projectID, sdpItem, sdpAssetType, linker, item, append(keys, k)) } return } } func externalToSDP( ctx context.Context, location gcpshared.LocationInfo, uniqueAttrKeys []string, resp map[string]any, sdpAssetType shared.ItemType, linker *gcpshared.Linker, nameSelector string, ) (*sdp.Item, error) { attributes, err := shared.ToAttributesWithExclude(resp, "labels") if err != nil { return nil, err } labels := make(map[string]string) if lls, ok := resp["labels"]; ok { if labelsAny, ok := lls.(map[string]any); ok { for lk, lv := range labelsAny { // Convert the label value to string labels[lk] = fmt.Sprintf("%v", lv) } } } sdpItem := &sdp.Item{ Type: sdpAssetType.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: location.ToScope(), Tags: labels, } nameSel := nameSelector if nameSel == "" { nameSel = "name" } if name, ok := resp[nameSel].(string); ok { attrValues := gcpshared.ExtractPathParams(name, uniqueAttrKeys...) uniqueAttrValue := strings.Join(attrValues, shared.QuerySeparator) err = sdpItem.GetAttributes().Set("uniqueAttr", uniqueAttrValue) if err != nil { return nil, err } } else { return nil, fmt.Errorf("unable to determine the name") } for k, v := range resp { keys := []string{k} linkItem(ctx, location.ProjectID, sdpItem, sdpAssetType, linker, v, keys) } return sdpItem, nil } func externalCallSingle(ctx context.Context, httpCli *http.Client, url string) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := httpCli.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, readErr := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusNotFound { // Return NOTFOUND regardless of body read so callers can cache via IsNotFound(err) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("resource not found: %s", url), } } if readErr == nil { if resp.StatusCode == http.StatusForbidden { return nil, &PermissionError{URL: url} } return nil, fmt.Errorf( "failed to make a GET call: %s, HTTP Status: %s, HTTP Body: %s", url, resp.Status, string(body), ) } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.http.url": url, "ovm.source.http.response-status": resp.Status, }).Warnf("failed to read the response body: %v", readErr) return nil, fmt.Errorf("failed to make call: %s", resp.Status) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var result map[string]any if err = json.Unmarshal(data, &result); err != nil { return nil, err } return result, nil } // externalCallMulti makes a paginated HTTP GET request to the specified URL and sends the results to the provided output channel. func externalCallMulti(ctx context.Context, itemsSelector string, httpCli *http.Client, urlForList string, out chan<- map[string]any) error { if out == nil { return fmt.Errorf("no output channel provided") } currentURL := urlForList for { req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil) if err != nil { return err } resp, err := httpCli.Do(req) if err != nil { return err } if resp.StatusCode != http.StatusOK { body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode == http.StatusNotFound { // Return QueryError NOTFOUND so callers (streamSDPItems, aggregateSDPItems) can cache via IsNotFound(err) return &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("resource not found: %s", currentURL), } } if readErr == nil { return fmt.Errorf( "failed to make the GET call. HTTP Status: %s, HTTP Body: %s", resp.Status, string(body), ) } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.http.url-for-list": currentURL, "ovm.source.http.response-status": resp.Status, }).Warnf("failed to read the response body: %v", readErr) return fmt.Errorf("failed to make the GET call. HTTP Status: %s", resp.Status) } data, err := io.ReadAll(resp.Body) if err != nil { return err } var result map[string]any if err = json.Unmarshal(data, &result); err != nil { return err } // Extract items from the current page itemsAny, ok := result[itemsSelector] if !ok { itemsSelector = "items" // Fallback to a generic "items" key itemsAny, ok = result[itemsSelector] if !ok { log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.http.url-for-list": currentURL, "ovm.source.http.items-selector": itemsSelector, "ovm.source.http.result": result, }).Debug("not found any items in the result") break } } items, ok := itemsAny.([]any) if !ok { log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.http.url-for-list": currentURL, "ovm.source.http.items-selector": itemsSelector, }).Warnf("failed to cast resp as a list of %s: within %v", itemsSelector, result) break } // Add items from this page to our collection for _, item := range items { if itemMap, ok := item.(map[string]any); ok { // If out channel is provided, send the item to it select { case out <- itemMap: case <-ctx.Done(): log.WithContext(ctx).Warn("context cancelled while sending items") return ctx.Err() } } } // Check if there's a next page nextPageToken, ok := result["nextPageToken"].(string) if !ok || nextPageToken == "" { break // No more pages to process } // Properly construct the next page URL with the pageToken parsedURL, err := url.Parse(urlForList) if err != nil { return fmt.Errorf("failed to parse URL %s: %w", urlForList, err) } // Get existing query parameters or create new ones query := parsedURL.Query() query.Set("pageToken", nextPageToken) parsedURL.RawQuery = query.Encode() // Use the properly constructed URL for the next request currentURL = parsedURL.String() } return nil } func potentialLinksFromLinkRules(itemType shared.ItemType, linkRules map[shared.ItemType]map[string]*gcpshared.Impact) []string { potentialLinksMap := make(map[string]bool) for key, impact := range linkRules[itemType] { potentialLinksMap[impact.ToSDPItemType.String()] = true // Special case: stdlib.NetworkIP and stdlib.NetworkDNS are interchangeable // because the linker automatically detects whether a value is an IP address or DNS name // If you specify either one, both are included in potential links if impact.ToSDPItemType.String() == "ip" || impact.ToSDPItemType.String() == "dns" { potentialLinksMap["ip"] = true potentialLinksMap["dns"] = true } // Network tag keys produce additional links via AutoLink that aren't // captured by ToSDPItemType alone. if gcpshared.IsNetworkTagKey(key) { switch itemType { case gcpshared.ComputeFirewall, gcpshared.ComputeRoute: potentialLinksMap[gcpshared.ComputeInstance.String()] = true case gcpshared.ComputeInstance, gcpshared.ComputeInstanceTemplate: potentialLinksMap[gcpshared.ComputeFirewall.String()] = true potentialLinksMap[gcpshared.ComputeRoute.String()] = true } } } potentialLinks := make([]string, 0, len(potentialLinksMap)) for it := range potentialLinksMap { potentialLinks = append(potentialLinks, it) } // Sort to ensure deterministic ordering slices.Sort(potentialLinks) return potentialLinks } // aggregateSDPItems retrieves items from an external API and converts them to SDP items. func aggregateSDPItems(ctx context.Context, a Adapter, url string, location gcpshared.LocationInfo) ([]*sdp.Item, error) { var items []*sdp.Item itemsSelector := a.uniqueAttributeKeys[len(a.uniqueAttributeKeys)-1] // Use the last key as the item selector if a.listResponseSelector != "" { itemsSelector = a.listResponseSelector } out := make(chan map[string]any) p := pool.New().WithErrors().WithContext(ctx) p.Go(func(ctx context.Context) error { defer close(out) err := externalCallMulti(ctx, itemsSelector, a.httpCli, url, out) if err != nil { return fmt.Errorf("failed to retrieve items for %s: %w", url, err) } return nil }, ) hadExtractError := false var lastExtractErr error for resp := range out { item, err := externalToSDP(ctx, location, a.uniqueAttributeKeys, resp, a.sdpAssetType, a.linker, a.nameSelector) if err != nil { log.WithError(err).Warn("failed to extract item from response") hadExtractError = true lastExtractErr = err continue } items = append(items, item) } err := p.Wait() if err != nil { // If we have items but the pool failed with NOTFOUND (e.g. 404 on a later pagination page), // return the items we collected so the caller does not cache NOTFOUND for a non-empty result. if sources.IsNotFound(err) && len(items) > 0 { return items, nil } return nil, err } // If all items failed extraction, return error so caller does not cache NOTFOUND (matches streamSDPItems) if len(items) == 0 && hadExtractError && lastExtractErr != nil { return nil, lastExtractErr } return items, nil } // streamSDPItems retrieves items from an external API and streams them as SDP items. func streamSDPItems(ctx context.Context, a Adapter, url string, location gcpshared.LocationInfo, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { itemsSelector := a.uniqueAttributeKeys[len(a.uniqueAttributeKeys)-1] // Use the last key as the item selector if a.listResponseSelector != "" { itemsSelector = a.listResponseSelector } out := make(chan map[string]any) p := pool.New().WithErrors().WithContext(ctx) p.Go(func(ctx context.Context) error { defer close(out) err := externalCallMulti(ctx, itemsSelector, a.httpCli, url, out) if err != nil { return fmt.Errorf("failed to retrieve items for %s: %w", url, err) } return nil }) itemsSent := 0 hadExtractError := false for resp := range out { item, err := externalToSDP(ctx, location, a.uniqueAttributeKeys, resp, a.sdpAssetType, a.linker, a.nameSelector) if err != nil { log.WithError(err).Warn("failed to extract item from response") hadExtractError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) itemsSent++ stream.SendItem(item) } err := p.Wait() if err != nil { // Only cache NOTFOUND when no items were sent. For NOTFOUND, don't send error on stream // so behaviour matches cached path (0 items, no error). When items were already sent, // also don't send NOTFOUND (consistent with aggregateSDPItems returning items, nil). if sources.IsNotFound(err) && itemsSent == 0 { cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, cacheKey) } if !sources.IsNotFound(err) { stream.SendError(err) } } else if itemsSent == 0 && !hadExtractError { // Cache not-found when no items were sent AND no extraction errors occurred // If we had extraction errors, items may exist but couldn't be processed notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found in scope %s", a.sdpAssetType.String(), location.ToScope()), Scope: location.ToScope(), SourceName: a.Name(), ItemType: a.sdpAssetType.String(), ResponderName: a.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } // Note: No items found is valid. The caller's defer done() will release pending work. } func terraformMappingViaSearch(ctx context.Context, a Adapter, query string, location gcpshared.LocationInfo, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) ([]*sdp.Item, error) { // query is in the format of: // projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} // // Extract the relevant parts from the query // We need to extract the path parameters based on the number of unique attribute keys // From projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} // we get: ["account", "key"] // if the unique attribute keys are ["serviceAccounts", "keys"] queryParts := gcpshared.ExtractPathParamsWithCount(query, len(a.uniqueAttributeKeys)) if len(queryParts) != len(a.uniqueAttributeKeys) { err := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to handle terraform mapping from query %s for %s", query, a.sdpAssetType, ), } cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, cacheKey) return nil, err } // Reconstruct the query from the parts with default separator // For example, if the unique attribute keys are ["serviceAccounts", "keys"] // and the query parts are ["account", "key"], we get "account|key" query = strings.Join(queryParts, shared.QuerySeparator) // We use the GET endpoint for this query. Because the terraform mappings are for single items, getURL := a.getURLFunc(query, location) if getURL == "" { err := &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to construct the URL for the query \"%s\". SEARCH method description: %s", query, a.Metadata().GetSupportedQueryMethods().GetSearchDescription(), ), } cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, cacheKey) return nil, err } resp, err := externalCallSingle(ctx, a.httpCli, getURL) if err != nil { enrichNOTFOUNDQueryError(err, location.ToScope(), a.Name(), a.Type()) if sources.IsNotFound(err) { cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, cacheKey) // Return empty result, nil error so behaviour matches cached NOTFOUND (caller converts to [], nil) return []*sdp.Item{}, nil } return nil, err } item, err := externalToSDP(ctx, location, a.uniqueAttributeKeys, resp, a.sdpAssetType, a.linker, a.nameSelector) if err != nil { wrappedErr := fmt.Errorf("failed to convert response to SDP: %w", err) return nil, wrappedErr } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) return []*sdp.Item{item}, nil } ================================================ FILE: sources/gcp/dynamic/shared_test.go ================================================ package dynamic import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "reflect" "testing" "google.golang.org/protobuf/types/known/structpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func Test_externalToSDP(t *testing.T) { type args struct { location gcpshared.LocationInfo uniqueAttrKeys []string resp map[string]any sdpAssetType shared.ItemType nameSelector string } testLocation := gcpshared.NewProjectLocation("test-project") tests := []struct { name string args args want *sdp.Item wantErr bool }{ { name: "ReturnsSDPItemWithCorrectAttributes", args: args{ location: testLocation, uniqueAttrKeys: []string{"projects", "locations", "instances"}, resp: map[string]any{ "name": "projects/test-project/locations/us-central1/instances/instance-1", "labels": map[string]any{"env": "prod"}, "foo": "bar", }, sdpAssetType: gcpshared.ComputeInstance, }, want: &sdp.Item{ Type: gcpshared.ComputeInstance.String(), UniqueAttribute: "uniqueAttr", Scope: testLocation.ToScope(), Tags: map[string]string{"env": "prod"}, Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue("projects/test-project/locations/us-central1/instances/instance-1"), "foo": structpb.NewStringValue("bar"), "uniqueAttr": structpb.NewStringValue("test-project|us-central1|instance-1"), }, }, }, }, wantErr: false, }, { name: "ReturnsSDPItemWithCorrectAttributesWhenNameDoesNotHaveUniqueAttrKeys", args: args{ location: testLocation, uniqueAttrKeys: []string{"projects", "locations", "instances"}, resp: map[string]any{ // There is name, but it does not include uniqueAttrKeys, expected to use the name as is. "name": "instance-1", "labels": map[string]any{"env": "prod"}, "foo": "bar", }, sdpAssetType: gcpshared.ComputeInstance, }, want: &sdp.Item{ Type: gcpshared.ComputeInstance.String(), UniqueAttribute: "uniqueAttr", Scope: testLocation.ToScope(), Tags: map[string]string{"env": "prod"}, Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue("instance-1"), "foo": structpb.NewStringValue("bar"), "uniqueAttr": structpb.NewStringValue("instance-1"), }, }, }, }, wantErr: false, }, { name: "ReturnsErrorWhenNameMissing", args: args{ location: testLocation, uniqueAttrKeys: []string{"projects", "locations", "instances"}, resp: map[string]any{ "labels": map[string]any{"env": "prod"}, "foo": "bar", }, sdpAssetType: gcpshared.ComputeInstance, }, want: nil, wantErr: true, }, { name: "UseCustomNameSelectorWhenProvided", args: args{ location: testLocation, uniqueAttrKeys: []string{"projects", "locations", "instances"}, resp: map[string]any{ "instanceName": "instance-1", "labels": map[string]any{"env": "prod"}, "foo": "bar", }, sdpAssetType: gcpshared.ComputeInstance, nameSelector: "instanceName", // This instructs to look for instanceName instead of name }, want: &sdp.Item{ Type: gcpshared.ComputeInstance.String(), UniqueAttribute: "uniqueAttr", Scope: testLocation.ToScope(), Tags: map[string]string{"env": "prod"}, Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "instanceName": structpb.NewStringValue("instance-1"), "foo": structpb.NewStringValue("bar"), "uniqueAttr": structpb.NewStringValue("instance-1"), }, }, }, }, wantErr: false, }, { name: "ReturnsSDPItemWithEmptyLabels", args: args{ location: testLocation, uniqueAttrKeys: []string{"projects", "locations", "instances"}, resp: map[string]any{ "name": "projects/test-project/locations/us-central1/instances/instance-2", "foo": "baz", }, sdpAssetType: gcpshared.ComputeInstance, }, want: &sdp.Item{ Type: gcpshared.ComputeInstance.String(), UniqueAttribute: "uniqueAttr", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": structpb.NewStringValue("projects/test-project/locations/us-central1/instances/instance-2"), "foo": structpb.NewStringValue("baz"), "uniqueAttr": structpb.NewStringValue("test-project|us-central1|instance-2"), }, }, }, Scope: testLocation.ToScope(), Tags: map[string]string{}, }, wantErr: false, }, } linker := gcpshared.NewLinker() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := externalToSDP(context.Background(), tt.args.location, tt.args.uniqueAttrKeys, tt.args.resp, tt.args.sdpAssetType, linker, tt.args.nameSelector) if (err != nil) != tt.wantErr { t.Errorf("externalToSDP() error = %v, wantErr %v", err, tt.wantErr) return } // got.Attributes = createAttr(t, tt.args.resp) if !reflect.DeepEqual(got, tt.want) { t.Errorf("externalToSDP() got = %v, want %v", got, tt.want) } }) } } func Test_getDescription_ReturnsSelectorWithNameWhenNoUniqueAttrKeys(t *testing.T) { got := getDescription(gcpshared.ComputeInstance, []string{}) want := fmt.Sprintf("Get a %s by its \"name\"", gcpshared.ComputeInstance) if got != want { t.Errorf("getDescription() got = %v, want %v", got, want) } } func Test_getDescription_ReturnsSelectorWithUniqueAttrKeys(t *testing.T) { got := getDescription(gcpshared.BigQueryTable, []string{"datasets", "tables"}) want := fmt.Sprintf("Get a %s by its \"datasets|tables\"", gcpshared.BigQueryTable) if got != want { t.Errorf("getDescription() got = %v, want %v", got, want) } } func Test_getDescription_ReturnsSelectorWithSingleUniqueAttrKey(t *testing.T) { got := getDescription(gcpshared.StorageBucket, []string{"buckets"}) want := fmt.Sprintf("Get a %s by its \"name\"", gcpshared.StorageBucket) if got != want { t.Errorf("getDescription() got = %v, want %v", got, want) } } func Test_listDescription_ReturnsCorrectDescription(t *testing.T) { got := listDescription(gcpshared.ComputeInstance) want := "List all gcp-compute-instance" if got != want { t.Errorf("listDescription() got = %v, want %v", got, want) } } func Test_listDescription_HandlesEmptyScope(t *testing.T) { got := listDescription(gcpshared.BigQueryTable) want := "List all gcp-big-query-table" if got != want { t.Errorf("listDescription() got = %v, want %v", got, want) } } func Test_searchDescription_ReturnsSelectorWithMultipleKeys(t *testing.T) { got := searchDescription(gcpshared.ServiceDirectoryEndpoint, []string{"locations", "namespaces", "services", "endpoints"}, "") want := "Search for gcp-service-directory-endpoint by its \"locations|namespaces|services\"" if got != want { t.Errorf("searchDescription() got = %v, want %v", got, want) } } func Test_searchDescription_ReturnsSelectorWithTwoKeys(t *testing.T) { got := searchDescription(gcpshared.BigQueryTable, []string{"datasets", "tables"}, "") want := "Search for gcp-big-query-table by its \"datasets\"" if got != want { t.Errorf("searchDescription() got = %v, want %v", got, want) } } func Test_searchDescription_PanicsWithOneKey(t *testing.T) { defer func() { if r := recover(); r == nil { t.Errorf("searchDescription() did not panic with one unique attribute key; expected panic") } }() _ = searchDescription(gcpshared.StorageBucket, []string{"buckets"}, "") } func Test_searchDescription_WithCustomSearchDescription(t *testing.T) { customDesc := "Custom search description for gcp-service-directory-endpoint" got := searchDescription(gcpshared.ServiceDirectoryEndpoint, []string{"locations", "namespaces", "services", "endpoints"}, customDesc) if got != customDesc { t.Errorf("searchDescription() got = %v, want %v", got, customDesc) } } // TestStreamSDPItemsZeroItemsCachesNotFound verifies that when the API returns zero items, // streamSDPItems caches NOTFOUND so a subsequent Lookup returns the cached error. func TestStreamSDPItemsZeroItemsCachesNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"instances": []any{}}) })) defer server.Close() ctx := context.Background() cache := sdpcache.NewMemoryCache() location := gcpshared.NewProjectLocation("test-project") scope := location.ToScope() listMethod := sdp.QueryMethod_LIST a := Adapter{ httpCli: server.Client(), uniqueAttributeKeys: []string{"instances"}, sdpAssetType: gcpshared.ComputeInstance, linker: &gcpshared.Linker{}, nameSelector: "name", listResponseSelector: "", } stream := discovery.NewRecordingQueryResultStream() ck := sdpcache.CacheKeyFromParts(a.Name(), listMethod, scope, a.Type(), "") streamSDPItems(ctx, a, server.URL, location, stream, cache, ck) cacheHit, _, _, qErr, done := cache.Lookup(ctx, a.Name(), listMethod, scope, a.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit after streamSDPItems with zero items") } if qErr == nil { t.Fatal("expected cached NOTFOUND error, got nil") } if qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected NOTFOUND, got %v", qErr.GetErrorType()) } } // ListCachesNotFoundWithMemoryCache verifies that when List returns 0 items, NOTFOUND is cached // and a second List returns 0 items from cache without calling the API again. func TestListCachesNotFoundWithMemoryCache(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"instances": []any{}}) })) defer server.Close() ctx := context.Background() cache := sdpcache.NewMemoryCache() location := gcpshared.NewProjectLocation("test-project") scope := location.ToScope() listEndpointFunc := func(loc gcpshared.LocationInfo) (string, error) { return server.URL, nil } config := &AdapterConfig{ Locations: []gcpshared.LocationInfo{location}, HTTPClient: server.Client(), GetURLFunc: func(string, gcpshared.LocationInfo) string { return "" }, SDPAssetType: gcpshared.ComputeInstance, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, Linker: &gcpshared.Linker{}, UniqueAttributeKeys: []string{"instances"}, NameSelector: "name", ListResponseSelector: "", } adapter := NewListableAdapter(listEndpointFunc, config, cache) discAdapter := adapter.(discovery.Adapter) // Prove cache is empty before the first query cacheHit, _, _, _, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if cacheHit { t.Fatal("cache should be empty before first List") } items, err := adapter.List(ctx, scope, false) if err != nil { t.Fatalf("first List: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first List: expected 0 items, got %d", len(items)) } // the not found error should be cached cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List, got %v", qErr) } items, err = adapter.List(ctx, scope, false) if err != nil { t.Fatalf("second List: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second List: expected 0 items, got %d", len(items)) } } // SearchCachesNotFoundWithMemoryCache verifies that when Search returns 0 items, NOTFOUND is cached // and a second Search returns 0 items from cache without calling the API again. func TestSearchCachesNotFoundWithMemoryCache(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"instances": []any{}}) })) defer server.Close() ctx := context.Background() cache := sdpcache.NewMemoryCache() location := gcpshared.NewProjectLocation("test-project") scope := location.ToScope() query := "some-instance" searchEndpointFunc := func(q string, loc gcpshared.LocationInfo) string { return server.URL } config := &AdapterConfig{ Locations: []gcpshared.LocationInfo{location}, HTTPClient: server.Client(), GetURLFunc: func(string, gcpshared.LocationInfo) string { return "" }, SDPAssetType: gcpshared.ComputeInstance, SDPAdapterCategory: sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, Linker: &gcpshared.Linker{}, UniqueAttributeKeys: []string{"instances"}, NameSelector: "name", ListResponseSelector: "", } adapter := NewSearchableAdapter(searchEndpointFunc, config, "search by instances", cache) discAdapter := adapter.(discovery.Adapter) // Prove cache is empty before the first query cacheHit, _, _, _, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if cacheHit { t.Fatal("cache should be empty before first Search") } items, err := adapter.Search(ctx, scope, query, false) if err != nil { t.Fatalf("first Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } // the not found error should be cached cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", qErr) } items, err = adapter.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } } // TestStreamSDPItemsExtractionErrorDoesNotCacheNotFound verifies that when the API returns // items but extraction fails (e.g. missing required "name"), streamSDPItems does NOT cache NOTFOUND. func TestStreamSDPItemsExtractionErrorDoesNotCacheNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Item without "name" causes externalToSDP to return error (ReturnsErrorWhenNameMissing). _ = json.NewEncoder(w).Encode(map[string]any{ "instances": []any{ map[string]any{"foo": "bar"}, }, }) })) defer server.Close() ctx := context.Background() cache := sdpcache.NewMemoryCache() location := gcpshared.NewProjectLocation("test-project") scope := location.ToScope() listMethod := sdp.QueryMethod_LIST a := Adapter{ httpCli: server.Client(), uniqueAttributeKeys: []string{"instances"}, sdpAssetType: gcpshared.ComputeInstance, linker: &gcpshared.Linker{}, nameSelector: "name", listResponseSelector: "", } stream := discovery.NewRecordingQueryResultStream() ck := sdpcache.CacheKeyFromParts(a.Name(), listMethod, scope, a.Type(), "") streamSDPItems(ctx, a, server.URL, location, stream, cache, ck) cacheHit, _, _, qErr, done := cache.Lookup(ctx, a.Name(), listMethod, scope, a.Type(), "", false) done() if cacheHit && qErr != nil && qErr.GetErrorType() == sdp.QueryError_NOTFOUND { t.Error("extraction errors must not result in NOTFOUND being cached") } } ================================================ FILE: sources/gcp/dynamic/testing.go ================================================ package dynamic // Multiply creates a slice of pointers to copies of the provided value. // It takes a value of type T and a count, returning a slice with that many // pointers to copies of the original value. // Example: multiply(dockerImage, 100) returns a slice with 100 elements, // each being a pointer to a copy of dockerImage. func Multiply[T any](value T, count int) []T { if count <= 0 { return []T{} } result := make([]T, count) for i := range result { result[i] = value } return result } ================================================ FILE: sources/gcp/integration-tests/README.md ================================================ # Running Integration Tests for GCP Integration tests are defined in an individual file for each resource. Test names follow the pattern `TestIntegration`, where `` is the API name and `` is the resource name. For example, `TestComputeInstanceIntegration` tests the Compute API's Instance resource. ## Setup your local environment for testing 1. Log in with your Google account here, `https://console.cloud.google.com/` 2. Use your brex credit card information to create a project and a billing account to use for integration tests. 3. You can see the other overmind projects, it will be under projects -> all. 4. Login to gcloud `gcloud auth login` on the terminal. 5. Enable the tested APIs: ```bash gcloud services enable \ compute.googleapis.com \ bigquery.googleapis.com \ spanner.googleapis.com \ cloudresourcemanager.googleapis.com \ iam.googleapis.com \ iamcredentials.googleapis.com \ cloudkms.googleapis.com \ cloudasset.googleapis.com \ --project=integration-tests-484908 ``` > **Note:** `integration-tests-484908` is the project id of the shared project used for integration tests. 6. To run the **integration tests in debug mode** you need to set the following environment variables. `~/.config/Cursor/User/settings.json` ```json { "window.commandCenter": true, "workbench.activityBar.orientation": "vertical", "go.testEnvVars": { "RUN_GCP_INTEGRATION_TESTS": "true", "GCP_PROJECT_ID": "integration-tests-484908", } } ``` > **Note:** `"integration-tests-484908"` is a shared project that is used for integration tests. Communicate on discord when you're using it, to avoid conflicts. Or you can run them in the CLI by using: ```bash export RUN_GCP_INTEGRATION_TESTS=true # For GCP export GCP_PROJECT_ID="integration-tests-484908" # use your own project id here export GCP_ZONE="us-central1-c" # not all tests need a zone export GCP_REGION="us-central1" # not all tests need a region ``` 7. Integration tests are using Google Cloud Client Libraries and Google API Client Libraries to interact with GCP resources. These libraries require setting up the Application Default Credentials (ADC) to authenticate with GCP. See the [official documentation](https://cloud.google.com/docs/authentication/set-up-adc-local-dev-environment) for how to set up the ADC for your local development environment. Login `gcloud auth application-default login` 8. **optional** You may need to set the quota project `gcloud auth application-default set-quota-project integration-tests-484908`. 9. You can now run integration tests. Each test has `Setup`, `Run`, and `Teardown` methods. - `Setup` is used to create any resources needed for the test. - `Run` is where the actual test logic is implemented. - `Teardown` is used to clean up any resources created during the test. The `Setup` and `Teardown` methods are idempotent, meaning they can be run multiple times without causing issues. This allows for flexibility in running tests in different orders or multiple times. We can easily run all `Setup` tests to create resources, then run all `Run` tests to execute the actual tests, and finally run all `Teardown` tests to clean up resources. From the `sources/gcp` directory: For building up the infra for the Compute API resources. ```bash go test ./integration-tests -run "TestCompute.*/Setup" -count 1 ``` For running the actual tests for the Compute API resources. ```bash go test ./integration-tests -run "TestCompute.*/Run" -count 1 ``` For tearing down the infra for the Compute API resources. ```bash go test ./integration-tests -run "TestCompute.*/Teardown" -count 1 ``` > **Note:** `-count 1` is used to ensure that the tests are run and no cached results are used. > **Note:** that the TestServiceAccountImpersonationIntegration tests do not have separate Setup, Run, and Teardown methods, as it requires state to be shared between the tests. ================================================ FILE: sources/gcp/integration-tests/big-query-model_test.go ================================================ package integrationtests import ( "context" "os" "strings" "testing" "cloud.google.com/go/bigquery" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestBigQueryModel(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable is not set, skipping BigQuery model tests") } t.Parallel() dataSet := "test_dataset" model := "test_model" routine := "test_routine" table := "test_table" ctx := context.Background() ctrl := gomock.NewController(t) client, err := bigquery.NewClient(ctx, projectID) if err != nil { t.Fatalf("Failed to create BigQuery client: %v", err) } defer client.Close() defer ctrl.Finish() t.Run("Setup", func(t *testing.T) { datasetItem := client.Dataset(dataSet) err := datasetItem.Create(ctx, &bigquery.DatasetMetadata{ Name: dataSet, Description: "Test dataset for model integration tests", }) if err != nil && !strings.Contains(err.Error(), "Already Exists") { t.Fatalf("Failed to create dataset %s: %v", dataSet, err) } t.Logf("Dataset %s created successfully", dataSet) query := "CREATE OR REPLACE MODEL `" + projectID + "." + dataSet + "." + model + "` OPTIONS " + `(model_type='LOGISTIC_REG', labels=['animal_label'] ) AS SELECT 1 AS feature_dummy, -- A dummy feature for 'cats' 'cats' AS animal_label -- The primary label we want to output UNION ALL SELECT 2 AS feature_dummy, -- A different dummy feature for the second label 'dogs' AS animal_label; -- A second, dummy label to satisfy the classification requirement` op, err := client.Query(query).Run(ctx) if err != nil { t.Fatalf("Failed to create model: %v", err) } if _, err := op.Wait(ctx); err != nil { t.Fatalf("Failed to wait for model creation: %v", err) } modelItem := client.Dataset(dataSet).Model(model) modelMetadata, err := modelItem.Update(ctx, bigquery.ModelMetadataToUpdate{ Name: model, Description: "Test model description", }, "") if err != nil { t.Fatalf("Failed to create model: %v", err) } t.Logf("Model created: %s", modelMetadata.Name) routineQuery := "CREATE OR REPLACE FUNCTION `" + projectID + "." + dataSet + "." + routine + "`(input INT64)\n" + "RETURNS INT64\n" + "AS (\n" + " input + 1\n" + ");" routineOp, err := client.Query(routineQuery).Run(ctx) if err != nil { t.Fatalf("Failed to create routine: %v", err) } if _, err := routineOp.Wait(ctx); err != nil { t.Fatalf("Failed to wait for routine creation: %v", err) } routineItem := client.Dataset(dataSet).Routine(routine) if _, err := routineItem.Metadata(ctx); err != nil { t.Fatalf("Failed to retrieve routine metadata: %v", err) } t.Logf("Routine created: %s", routine) tableItem := client.Dataset(dataSet).Table(table) err = tableItem.Create(ctx, &bigquery.TableMetadata{ Name: table, Description: "Test table for integration tests", Schema: bigquery.Schema{ {Name: "id", Type: bigquery.IntegerFieldType, Required: true}, {Name: "name", Type: bigquery.StringFieldType}, }, }) if err != nil && !strings.Contains(err.Error(), "Already Exists") { t.Fatalf("Failed to create table %s: %v", table, err) } if _, err := tableItem.Metadata(ctx); err != nil { t.Fatalf("Failed to retrieve table metadata: %v", err) } t.Logf("Table created: %s", table) }) t.Run("Get", func(t *testing.T) { bigqueryClient := gcpshared.NewBigQueryModelClient(client) adapter := manual.NewBigQueryModel(bigqueryClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) sdpItem, err := adapter.Get(ctx, adapter.Scopes()[0], dataSet, model) if err != nil { t.Fatalf("Failed to get item: %v", err) } if sdpItem == nil { t.Fatal("Expected an item, got nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, attrErr := sdpItem.GetAttributes().Get(uniqueAttrKey) if attrErr != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != model { t.Fatalf("Expected unique attribute value to be %s, got %s", model, uniqueAttrValue) } searchable, ok := adapter.(sources.SearchableWrapper) if !ok { t.Fatalf("Expected adapter to support search") } sdpItems, err := searchable.Search(ctx, adapter.Scopes()[0], dataSet) if err != nil { t.Fatalf("Failed to search items: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one model in dataset, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == model { found = true break } } if !found { t.Fatalf("Expected to find model %s in the list of dataset models", model) } }) t.Run("GetRoutine", func(t *testing.T) { routineClient := gcpshared.NewBigQueryRoutineClient(client) adapter := manual.NewBigQueryRoutine(routineClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) sdpItem, err := adapter.Get(ctx, adapter.Scopes()[0], dataSet, routine) if err != nil { t.Fatalf("Failed to get routine: %v", err) } if sdpItem == nil { t.Fatal("Expected a routine item, got nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, attrErr := sdpItem.GetAttributes().Get(uniqueAttrKey) if attrErr != nil { t.Fatalf("Failed to get routine unique attribute: %v", attrErr) } expectedUniqueAttrValue := shared.CompositeLookupKey(dataSet, routine) if uniqueAttrValue != expectedUniqueAttrValue { t.Fatalf("Expected routine unique attribute value to be %s, got %v", expectedUniqueAttrValue, uniqueAttrValue) } searchable, ok := adapter.(sources.SearchableWrapper) if !ok { t.Fatalf("Expected adapter to support search") } sdpItems, err := searchable.Search(ctx, adapter.Scopes()[0], dataSet) if err != nil { t.Fatalf("Failed to search routines: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one routine in dataset, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUniqueAttrValue { found = true break } } if !found { t.Fatalf("Expected to find routine %s in the list of dataset routines", routine) } }) t.Run("GetDataset", func(t *testing.T) { datasetClient := gcpshared.NewBigQueryDatasetClient(client) adapter := manual.NewBigQueryDataset(datasetClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) sdpItem, err := adapter.Get(ctx, adapter.Scopes()[0], dataSet) if err != nil { t.Fatalf("Failed to get dataset: %v", err) } if sdpItem == nil { t.Fatal("Expected a dataset item, got nil") } expectedScope := projectID modelLinkFound := false routineLinkFound := false tableLinkFound := false for _, linkedItem := range sdpItem.GetLinkedItemQueries() { query := linkedItem.GetQuery() if query == nil { continue } switch query.GetType() { case gcpshared.BigQueryModel.String(): if query.GetMethod() != sdp.QueryMethod_SEARCH { t.Fatalf("Expected model link method to be %s, got %s", sdp.QueryMethod_SEARCH, query.GetMethod()) } if query.GetQuery() != dataSet { t.Fatalf("Expected model link query to be %s, got %s", dataSet, query.GetQuery()) } if query.GetScope() != expectedScope { t.Fatalf("Expected model link scope to be %s, got %s", expectedScope, query.GetScope()) } modelLinkFound = true case gcpshared.BigQueryRoutine.String(): if query.GetMethod() != sdp.QueryMethod_SEARCH { t.Fatalf("Expected routine link method to be %s, got %s", sdp.QueryMethod_SEARCH, query.GetMethod()) } if query.GetQuery() != dataSet { t.Fatalf("Expected routine link query to be %s, got %s", dataSet, query.GetQuery()) } if query.GetScope() != expectedScope { t.Fatalf("Expected routine link scope to be %s, got %s", expectedScope, query.GetScope()) } routineLinkFound = true case gcpshared.BigQueryTable.String(): if query.GetMethod() != sdp.QueryMethod_SEARCH { t.Fatalf("Expected table link method to be %s, got %s", sdp.QueryMethod_SEARCH, query.GetMethod()) } if query.GetQuery() != dataSet { t.Fatalf("Expected table link query to be %s, got %s", dataSet, query.GetQuery()) } if query.GetScope() != expectedScope { t.Fatalf("Expected table link scope to be %s, got %s", expectedScope, query.GetScope()) } tableLinkFound = true } } if !modelLinkFound { t.Fatalf("Expected dataset %s to include a link to its models", dataSet) } if !routineLinkFound { t.Fatalf("Expected dataset %s to include a link to its routines", dataSet) } if !tableLinkFound { t.Fatalf("Expected dataset %s to include a link to its tables", dataSet) } }) t.Run("GetTable", func(t *testing.T) { tableClient := gcpshared.NewBigQueryTableClient(client) adapter := manual.NewBigQueryTable(tableClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) sdpItem, err := adapter.Get(ctx, adapter.Scopes()[0], dataSet, table) if err != nil { t.Fatalf("Failed to get table: %v", err) } if sdpItem == nil { t.Fatal("Expected a table item, got nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, attrErr := sdpItem.GetAttributes().Get(uniqueAttrKey) if attrErr != nil { t.Fatalf("Failed to get table unique attribute: %v", attrErr) } expectedUniqueAttrValue := shared.CompositeLookupKey(dataSet, table) if uniqueAttrValue != expectedUniqueAttrValue { t.Fatalf("Expected table unique attribute value to be %s, got %v", expectedUniqueAttrValue, uniqueAttrValue) } searchable, ok := adapter.(sources.SearchableWrapper) if !ok { t.Fatalf("Expected adapter to support search") } sdpItems, err := searchable.Search(ctx, adapter.Scopes()[0], dataSet) if err != nil { t.Fatalf("Failed to search tables: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one table in dataset, got %d", len(sdpItems)) } found := false for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == expectedUniqueAttrValue { found = true break } } if !found { t.Fatalf("Expected to find table %s in the list of dataset tables", table) } }) t.Run("Teardown", func(t *testing.T) { // Cleanup resources if needed err := client.Dataset(dataSet).DeleteWithContents(ctx) if err != nil { t.Fatalf("Failed to delete dataset %s: %v", dataSet, err) } else { t.Logf("Dataset %s deleted successfully", dataSet) } }) } ================================================ FILE: sources/gcp/integration-tests/compute-address_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeAddressIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() region := os.Getenv("GCP_REGION") if region == "" { t.Skip("GCP_REGION environment variable not set") } addressName := "overmind-test-address" ctx := context.Background() client, err := compute.NewAddressesRESTClient(ctx) if err != nil { t.Fatalf("NewAddressesRESTClient: %v", err) } defer client.Close() t.Run("Setup", func(t *testing.T) { err := createComputeAddress(ctx, client, projectID, region, addressName) if err != nil { t.Fatalf("Failed to create compute address: %v", err) } }) t.Run("Run", func(t *testing.T) { log.Printf("Running integration test for Compute Address in project %s, region %s", projectID, region) addressWrapper := manual.NewComputeAddress(shared.NewComputeAddressClient(client), []shared.LocationInfo{shared.NewRegionalLocation(projectID, region)}) scope := addressWrapper.Scopes()[0] addressAdapter := sources.WrapperToAdapter(addressWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := addressAdapter.Get(ctx, scope, addressName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != addressName { t.Fatalf("Expected unique attribute value to be %s, got %s", addressName, uniqueAttrValue) } // Check if adapter supports listing listable, ok := addressAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute addresses: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute addresses, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == addressName { found = true break } } if !found { t.Fatalf("Expected to find address %s in the list of compute addresses", addressName) } }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeAddress(ctx, client, region, projectID, addressName) if err != nil { t.Fatal(err) } }) } // createComputeAddress creates a GCP Compute Engine address with the given parameters. func createComputeAddress(ctx context.Context, client *compute.AddressesClient, projectID, region, addressName string) error { // Define the address configuration address := &computepb.Address{ Name: new(addressName), Labels: map[string]string{ "test": "integration", }, NetworkTier: new("PREMIUM"), Region: new(region), } // Create the address req := &computepb.InsertAddressRequest{ Project: projectID, Region: region, AddressResource: address, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } // Wait for the operation to complete if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for address creation operation: %w", err) } log.Printf("Address %s created successfully in project %s, region %s", addressName, projectID, region) return nil } // Delete a compute address template. func deleteComputeAddress(ctx context.Context, client *compute.AddressesClient, region, projectID, addressName string) error { req := &computepb.DeleteAddressRequest{ Project: projectID, Region: region, Address: addressName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for address deletion operation: %w", err) } log.Printf("Compute address %s deleted successfully", addressName) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-autoscaler_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeAutoscalerIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } // Can replace with an environment-specific ID later. suffix := "default" // 3 resources to create: // Autoscaler -> Instance Group Manager -> Instance Template instanceTemplateName := "overmind-integration-test-instance-template-" + suffix instanceGroupManagerName := "overmind-integration-test-igm-" + suffix autoscalerName := "overmind-integration-test-autoscaler-" + suffix ctx := context.Background() // Create a new Compute Engine client client, err := compute.NewAutoscalersRESTClient(ctx) if err != nil { t.Fatalf("NewAutoscalersRESTClient: %v", err) } defer client.Close() itClient, err := compute.NewInstanceTemplatesRESTClient(ctx) if err != nil { t.Fatalf("NewInstanceTemplatesRESTClient: %v", err) } defer itClient.Close() igmClient, err := compute.NewInstanceGroupManagersRESTClient(ctx) if err != nil { t.Fatalf("NewInstanceGroupManagersRESTClient: %v", err) } defer igmClient.Close() t.Run("Setup", func(t *testing.T) { err := createComputeInstanceTemplate(ctx, itClient, projectID, instanceTemplateName) if err != nil { t.Fatalf("Failed to create compute instance template: %v", err) } err = createInstanceGroupManager(ctx, igmClient, projectID, zone, instanceGroupManagerName, instanceTemplateName) if err != nil { t.Fatalf("Failed to create instance group manager: %v", err) } fullIgmName := "projects/" + projectID + "/zones/" + zone + "/instanceGroupManagers/" + instanceGroupManagerName err = createComputeAutoscaler(ctx, client, fullIgmName, projectID, zone, autoscalerName) if err != nil { t.Fatalf("Failed to create compute autoscaler: %v", err) } }) t.Run("Run", func(t *testing.T) { log.Printf("Running integration test for Compute Autoscaler in project %s, zone %s", projectID, zone) autoscalerWrapper := manual.NewComputeAutoscaler(gcpshared.NewComputeAutoscalerClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := autoscalerWrapper.Scopes()[0] autoscalerAdapter := sources.WrapperToAdapter(autoscalerWrapper, sdpcache.NewNoOpCache()) // [SPEC] GET against a valid resource name will return an SDP item wrapping the // available resource. sdpItem, err := autoscalerAdapter.Get(ctx, scope, autoscalerName, true) if err != nil { t.Fatalf("autoscalerAdapter.Get returned unexpected error: %v", err) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } // [SPEC] The attributes contained in the SDP item directly match the attributes // from the GCP API. uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != autoscalerName { t.Fatalf("Expected unique attribute value to be %s, got %s", autoscalerName, uniqueAttrValue) } // [SPEC] The only linked item query is one Instance Group Manager. { if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Fatalf("Expected 1 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } linkedItem := sdpItem.GetLinkedItemQueries()[0] if linkedItem.GetQuery().GetType() != gcpshared.ComputeInstanceGroupManager.String() { t.Fatalf("Expected linked item type to be %s, got: %s", gcpshared.ComputeInstanceGroupManager, linkedItem.GetQuery().GetType()) } if linkedItem.GetQuery().GetQuery() != instanceGroupManagerName { t.Fatalf("Expected linked item query to be %s, got: %s", instanceGroupManagerName, linkedItem.GetQuery().GetQuery()) } expectedScope := gcpshared.ZonalScope(projectID, zone) if linkedItem.GetQuery().GetScope() != expectedScope { t.Fatalf("Expected linked item scope to be %s, got: %s", expectedScope, linkedItem.GetQuery().GetScope()) } } // [SPEC] The LIST operation for autoscalers will list all autoscalers in a given // scope. // Check if adapter supports listing listable, ok := autoscalerAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute autoscalers: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute autoscaler, got %d", len(sdpItems)) } // The LIST operation result should include our autoscaler. found := false for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == autoscalerName { found = true break } } if !found { t.Fatalf("Expected to find autoscaler %s in list, but it was not found", autoscalerName) } }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeAutoscaler(ctx, client, projectID, zone, autoscalerName) if err != nil { t.Errorf("Warning: failed to delete compute autoscaler: %v", err) } err = deleteInstanceGroupManager(ctx, igmClient, projectID, zone, instanceGroupManagerName) if err != nil { t.Errorf("Warning: failed to delete instance group manager: %v", err) } err = deleteComputeInstanceTemplate(ctx, itClient, projectID, instanceTemplateName) if err != nil { t.Errorf("Warning: failed to delete compute instance template: %v", err) } }) } // Create a compute instance template in GCP to test against. Uses a common Debian image // and basic network configuration. func createComputeInstanceTemplate(ctx context.Context, client *compute.InstanceTemplatesClient, projectID, name string) error { // Create a new instance template instanceTemplate := &computepb.InstanceTemplate{ Name: new(name), Properties: &computepb.InstanceProperties{ Disks: []*computepb.AttachedDisk{ { AutoDelete: new(true), Boot: new(true), DeviceName: new(name), InitializeParams: &computepb.AttachedDiskInitializeParams{ DiskSizeGb: new(int64(10)), DiskType: new("pd-balanced"), SourceImage: new("projects/debian-cloud/global/images/debian-12-bookworm-v20250415"), }, Mode: new("READ_WRITE"), Type: new("PERSISTENT"), // Labels? Tags? }, }, NetworkInterfaces: []*computepb.NetworkInterface{ { AccessConfigs: []*computepb.AccessConfig{ { Kind: new("compute#accessConfig"), Name: new("External NAT"), NetworkTier: new("PREMIUM"), Type: new("ONE_TO_ONE_NAT"), }, }, Network: new("projects/" + projectID + "/global/networks/default"), StackType: new("IPV4_ONLY"), }, }, MachineType: new("e2-micro"), Tags: &computepb.Tags{ Items: []string{"overmind-test"}, }, }, } // Create the instance template req := &computepb.InsertInstanceTemplateRequest{ Project: projectID, InstanceTemplateResource: instanceTemplate, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } // Wait for the operation to complete if err := op.Wait(ctx); err != nil { return fmt.Errorf("Failed to wait for instance template operation: %w", err) } log.Printf("Instance template %s created successfully in project %s", name, projectID) return nil } // Delete a compute instance template. func deleteComputeInstanceTemplate(ctx context.Context, client *compute.InstanceTemplatesClient, projectID, name string) error { req := &computepb.DeleteInstanceTemplateRequest{ Project: projectID, InstanceTemplate: name, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance template deletion operation: %w", err) } log.Printf("Instance template %s deleted successfully in project %s", name, projectID) return nil } // Create a compute autoscaler in GCP targeting the given instance group manager. func createComputeAutoscaler(ctx context.Context, client *compute.AutoscalersClient, targetedInstanceGroupManager, projectID, zone, name string) error { // Create a new autoscaler autoscaler := &computepb.Autoscaler{ Name: new(name), Target: &targetedInstanceGroupManager, AutoscalingPolicy: &computepb.AutoscalingPolicy{ MinNumReplicas: new(int32(0)), MaxNumReplicas: new(int32(1)), CpuUtilization: &computepb.AutoscalingPolicyCpuUtilization{ UtilizationTarget: new(float64(0.6)), }, }, } req := &computepb.InsertAutoscalerRequest{ Project: projectID, Zone: zone, AutoscalerResource: autoscaler, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for autoscaler creation operation: %w", err) } log.Printf("Autoscaler %s created successfully in project %s, zone %s", name, projectID, zone) return nil } // Delete a compute autoscaler in GCP. func deleteComputeAutoscaler(ctx context.Context, client *compute.AutoscalersClient, projectID, zone, name string) error { req := &computepb.DeleteAutoscalerRequest{ Project: projectID, Zone: zone, Autoscaler: name, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for autoscaler deletion operation: %w", err) } log.Printf("Autoscaler %s deleted successfully in project %s, zone %s", name, projectID, zone) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-disk_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeDiskIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } diskName := "integration-test-disk" ctx := context.Background() // Create a new Compute Disks client diskClient, err := compute.NewDisksRESTClient(ctx) if err != nil { t.Fatalf("NewDisksRESTClient: %v", err) } defer diskClient.Close() t.Run("Setup", func(t *testing.T) { err := createDisk(ctx, diskClient, projectID, zone, diskName) if err != nil { t.Fatalf("Failed to create disk: %v", err) } }) t.Run("ListDisks", func(t *testing.T) { log.Printf("Listing disks in project %s, zone %s", projectID, zone) disksWrapper := manual.NewComputeDisk(gcpshared.NewComputeDiskClient(diskClient), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := disksWrapper.Scopes()[0] disksAdapter := sources.WrapperToAdapter(disksWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := disksAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute disks: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute disk, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == diskName { found = true break } } if !found { t.Fatalf("Expected to find disk %s in the list of compute disks", diskName) } log.Printf("Found %d disks in project %s, zone %s", len(sdpItems), projectID, zone) }) t.Run("GetDisk", func(t *testing.T) { log.Printf("Retrieving disk %s in project %s, zone %s", diskName, projectID, zone) disksWrapper := manual.NewComputeDisk(gcpshared.NewComputeDiskClient(diskClient), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := disksWrapper.Scopes()[0] disksAdapter := sources.WrapperToAdapter(disksWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := disksAdapter.Get(ctx, scope, diskName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != diskName { t.Fatalf("Expected unique attribute value to be %s, got %s", diskName, uniqueAttrValue) } log.Printf("Successfully retrieved disk %s in project %s, zone %s", diskName, projectID, zone) }) t.Run("Teardown", func(t *testing.T) { err := deleteDisk(ctx, diskClient, projectID, zone, diskName) if err != nil { t.Fatalf("Failed to delete disk: %v", err) } }) } func createDisk(ctx context.Context, client *compute.DisksClient, projectID, zone, diskName string) error { disk := &computepb.Disk{ Name: new(diskName), SizeGb: new(int64(10)), Type: new(fmt.Sprintf( "projects/%s/zones/%s/diskTypes/pd-standard", projectID, zone, )), Labels: map[string]string{ "test": "integration", }, } req := &computepb.InsertDiskRequest{ Project: projectID, Zone: zone, DiskResource: disk, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("Failed to wait for disk creation operation: %w", err) } log.Printf("Disk %s created successfully in project %s, zone %s", diskName, projectID, zone) return nil } func deleteDisk(ctx context.Context, client *compute.DisksClient, projectID, zone, diskName string) error { req := &computepb.DeleteDiskRequest{ Project: projectID, Zone: zone, Disk: diskName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for disk deletion operation: %w", err) } log.Printf("Disk %s deleted successfully in project %s, zone %s", diskName, projectID, zone) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-forwarding-rule_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeForwardingRuleIntegration(t *testing.T) { // TODO: Implement the dependencies for Compute Forwarding Rule // This test currently asserts that the GCP SDK client satisfies the adapter interface t.Skipf("Skipping integration test for Compute Forwarding Rule until we implement the dependencies: BackendService, or Load Balancer and Target HTTP Proxy") projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() region := os.Getenv("GCP_REGION") if region == "" { t.Skip("GCP_REGION environment variable not set") } ruleName := "integration-test-forwarding-rule" ctx := context.Background() // Create a new Compute Forwarding Rule client client, err := compute.NewForwardingRulesRESTClient(ctx) if err != nil { t.Fatalf("NewForwardingRulesRESTClient: %v", err) } defer client.Close() t.Run("Setup", func(t *testing.T) { err := createComputeForwardingRule(ctx, client, projectID, region, ruleName) if err != nil { t.Fatalf("Failed to create forwarding rule: %v", err) } }) t.Run("Run", func(t *testing.T) { log.Printf("Running integration test for Compute Forwarding Rule in project %s, region %s", projectID, region) ruleWrapper := manual.NewComputeForwardingRule(gcpshared.NewComputeForwardingRuleClient(client), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) scope := ruleWrapper.Scopes()[0] ruleAdapter := sources.WrapperToAdapter(ruleWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := ruleAdapter.Get(ctx, scope, ruleName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != ruleName { t.Fatalf("Expected unique attribute value to be %s, got %s", ruleName, uniqueAttrValue) } // Check if adapter supports listing listable, ok := ruleAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list forwarding rules: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one forwarding rule, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == ruleName { found = true break } } if !found { t.Fatalf("Expected to find forwarding rule %s in the list", ruleName) } }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeForwardingRule(ctx, client, projectID, region, ruleName) if err != nil { t.Fatalf("Failed to delete forwarding rule: %v", err) } }) } // createComputeForwardingRule creates a GCP Compute Forwarding Rule with the given parameters. func createComputeForwardingRule(ctx context.Context, client *compute.ForwardingRulesClient, projectID, region, ruleName string) error { req := &computepb.InsertForwardingRuleRequest{ Project: projectID, Region: region, ForwardingRuleResource: &computepb.ForwardingRule{ Name: new(ruleName), // IP address for which this forwarding rule accepts traffic. // When a client sends traffic to this IP address, the forwarding rule directs the traffic to the referenced target or backendService. // While creating a forwarding rule, specifying an IPAddress is required under the following circumstances: // - When the target is set to targetGrpcProxy and validateForProxyless is set to true, the IPAddress should be set to 0.0.0.0. // - When the target is a Private Service Connect Google APIs bundle, you must specify an IPAddress. // Otherwise, you can optionally specify an IP address that references an existing static (reserved) IP address resource. // When omitted, Google Cloud assigns an ephemeral IP address. // Use one of the following formats to specify an IP address while creating a forwarding rule: // * IP address number, as in `100.1.2.3` // * IPv6 address range, as in `2600:1234::/96` // * Full resource URL, as in https://www.googleapis.com/compute/v1/projects/ project_id/regions/region/addresses/address-name // * Partial URL or by name, as in: // - projects/project_id/regions/region/addresses/address-name // - regions/region/addresses/address-name // - global/addresses/address-name // - address-name // The forwarding rule's target or backendService, and in most cases, also the loadBalancingScheme, // determine the type of IP address that you can use. // For detailed information, see [IP address specifications](https://cloud.google.com/load-balancing/docs/forwarding-rule-concepts#ip_address_specifications). // When reading an IPAddress, the API always returns the IP address number. IPAddress: new("192.168.1.1"), IPProtocol: new("TCP"), PortRange: new("80-80"), // The URL of the target resource to receive the matched traffic. // For regional forwarding rules, this target must be in the same region as the forwarding rule. // For global forwarding rules, this target must be a global load balancing resource. // The forwarded traffic must be of a type appropriate to the target object. //- For load balancers, see the "Target" column in [Port specifications](https://cloud.google.com/load-balancing/docs/forwarding-rule-concepts#ip_address_specifications). //- For Private Service Connect forwarding rules that forward traffic to Google APIs, provide the name of a supported Google API bundle: //- vpc-sc - APIs that support VPC Service Controls. //- all-apis - All supported Google APIs. //- For Private Service Connect forwarding rules that forward traffic to managed services, the target must be a service attachment. //The target is not mutable once set as a service attachment. Target: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/targetPools/test-target-pool"), }, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return err } log.Printf("Forwarding rule %s created successfully in project %s, region %s", ruleName, projectID, region) return nil } func deleteComputeForwardingRule(ctx context.Context, client *compute.ForwardingRulesClient, projectID, region, ruleName string) error { req := &computepb.DeleteForwardingRuleRequest{ Project: projectID, Region: region, ForwardingRule: ruleName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for forwarding rule deletion operation: %w", err) } log.Printf("Forwarding rule %s deleted successfully", ruleName) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-healthcheck_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeHealthCheckIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() healthCheckName := "integration-test-healthcheck" ctx := context.Background() // Create both global and regional Compute HealthCheck clients to avoid nil pointer issues globalClient, err := compute.NewHealthChecksRESTClient(ctx) if err != nil { t.Fatalf("NewHealthChecksRESTClient: %v", err) } defer globalClient.Close() regionalClient, err := compute.NewRegionHealthChecksRESTClient(ctx) if err != nil { t.Fatalf("NewRegionHealthChecksRESTClient: %v", err) } defer regionalClient.Close() t.Run("Setup", func(t *testing.T) { err := createComputeHealthCheck(ctx, globalClient, projectID, healthCheckName) if err != nil { t.Fatalf("Failed to create compute health check: %v", err) } }) t.Run("Run", func(t *testing.T) { log.Printf("Running integration test for Compute HealthCheck in project %s", projectID) healthCheckWrapper := manual.NewComputeHealthCheck( gcpshared.NewComputeHealthCheckClient(globalClient), gcpshared.NewComputeRegionHealthCheckClient(regionalClient), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil, // No regional locations for this test, but regional client is available if needed ) scope := healthCheckWrapper.Scopes()[0] healthCheckAdapter := sources.WrapperToAdapter(healthCheckWrapper, sdpcache.NewNoOpCache()) // [SPEC] GET against a valid resource name will return an SDP item wrapping the // available resource. sdpItem, err := healthCheckAdapter.Get(ctx, scope, healthCheckName, true) if err != nil { t.Fatalf("healthCheckAdapter.Get returned unexpected error: %v", err) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } // [SPEC] The attributes contained in the SDP item directly match the attributes // from the GCP API. uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != healthCheckName { t.Fatalf("Expected unique attribute value to be %s, got %s", healthCheckName, uniqueAttrValue) } // [SPEC] HealthChecks have no linked items. if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Fatalf("Expected 0 linked item queries, got: %d", len(sdpItem.GetLinkedItemQueries())) } // [SPEC] The LIST operation for health checks will list all health checks in a given // scope. // Check if adapter supports listing listable, ok := healthCheckAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute health checks: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute health check, got %d", len(sdpItems)) } // The LIST operation result should include our health check. found := false for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == healthCheckName { found = true break } } if !found { t.Fatalf("Expected to find health check %s in list, but it was not found", healthCheckName) } }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeHealthCheck(ctx, globalClient, projectID, healthCheckName) if err != nil { t.Errorf("Warning: failed to delete compute health check: %v", err) } }) } // createComputeHealthCheck creates a GCP Compute HealthCheck with the given parameters. func createComputeHealthCheck(ctx context.Context, client *compute.HealthChecksClient, projectID, healthCheckName string) error { healthCheck := &computepb.HealthCheck{ Name: new(healthCheckName), CheckIntervalSec: new(int32(5)), TimeoutSec: new(int32(5)), Type: new("TCP"), TcpHealthCheck: &computepb.TCPHealthCheck{ Port: new(int32(80)), }, } req := &computepb.InsertHealthCheckRequest{ Project: projectID, HealthCheckResource: healthCheck, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for health check creation operation: %w", err) } log.Printf("Health check %s created successfully in project %s", healthCheckName, projectID) return nil } // deleteComputeHealthCheck deletes a GCP Compute HealthCheck. func deleteComputeHealthCheck(ctx context.Context, client *compute.HealthChecksClient, projectID, healthCheckName string) error { req := &computepb.DeleteHealthCheckRequest{ Project: projectID, HealthCheck: healthCheckName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for health check deletion operation: %w", err) } log.Printf("Health check %s deleted successfully in project %s", healthCheckName, projectID) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-image_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeImageIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() imageName := "integration-test-image" diskName := "integration-test-disk" ctx := context.Background() // Create a new Compute Images client client, err := compute.NewImagesRESTClient(ctx) if err != nil { t.Fatalf("NewImagesRESTClient: %v", err) } defer client.Close() diskClient, err := compute.NewDisksRESTClient(ctx) if err != nil { t.Fatalf("NewDisksRESTClient: %v", err) } defer diskClient.Close() t.Run("Setup", func(t *testing.T) { err = createDisk(ctx, diskClient, projectID, zone, diskName) if err != nil { t.Fatalf("Failed to create source disk: %v", err) } err := createComputeImage(ctx, client, projectID, zone, imageName, diskName) if err != nil { t.Fatalf("Failed to create compute image: %v", err) } }) t.Run("ListImages", func(t *testing.T) { log.Printf("Listing images in project %s", projectID) imagesWrapper := manual.NewComputeImage(gcpshared.NewComputeImagesClient(client), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) scope := imagesWrapper.Scopes()[0] imagesAdapter := sources.WrapperToAdapter(imagesWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := imagesAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute images: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute image, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == imageName { found = true break } } if !found { t.Fatalf("Expected to find images %s in the list of compute images", imageName) } log.Printf("Found %d images in project %s", len(sdpItems), projectID) }) t.Run("GetImage", func(t *testing.T) { log.Printf("Retrieving image %s in project %s", imageName, projectID) imagesWrapper := manual.NewComputeImage(gcpshared.NewComputeImagesClient(client), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) scope := imagesWrapper.Scopes()[0] imagesAdapter := sources.WrapperToAdapter(imagesWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := imagesAdapter.Get(ctx, scope, imageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != imageName { t.Fatalf("Expected unique attribute value to be %s, got %s", imageName, uniqueAttrValue) } log.Printf("Successfully retrieved image %s in project %s", imageName, projectID) }) t.Run("Teardown", func(t *testing.T) { err := deleteImage(ctx, client, projectID, zone, imageName) if err != nil { t.Fatalf("Failed to delete compute image: %v", err) } }) } // createComputeImage creates a GCP Compute Image with the given parameters. func createComputeImage(ctx context.Context, client *compute.ImagesClient, projectID, zone, imageName, diskName string) error { image := &computepb.Image{ Name: new(imageName), SourceDisk: new(fmt.Sprintf( "projects/%s/zones/%s/disks/%s", projectID, zone, diskName, )), Labels: map[string]string{ "test": "integration", }, } // Create the image req := &computepb.InsertImageRequest{ Project: projectID, ImageResource: image, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for image creation operation: %w", err) } log.Printf("Image %s created successfully in project %s", imageName, projectID) return nil } func deleteImage(ctx context.Context, client *compute.ImagesClient, projectID, zone, imageName string) error { req := &computepb.DeleteImageRequest{ Project: projectID, Image: imageName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for image deletion operation: %w", err) } log.Printf("Compute image %s deleted successfully in project %s", imageName, projectID) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-instance-group-manager_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeInstanceGroupManagerIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() instanceGroupManagerName := "overmind-test-instance-group-manager" templateName := "overmind-integration-test-template" ctx := context.Background() instanceGroupManagerClient, err := compute.NewInstanceGroupManagersRESTClient(ctx) if err != nil { t.Fatalf("NewRegionInstanceGroupManagersRESTClient: %v", err) } defer instanceGroupManagerClient.Close() instanceTemplatesClient, err := compute.NewInstanceTemplatesRESTClient(ctx) if err != nil { t.Fatalf("NewInstanceTemplatesRESTClient: %v", err) } defer instanceTemplatesClient.Close() // Setup: create instance template and instance group manager t.Run("Setup", func(t *testing.T) { err := createInstanceTemplate(ctx, instanceTemplatesClient, projectID, templateName) if err != nil { t.Fatalf("Failed to create instance template: %v", err) } err = createInstanceGroupManager(ctx, instanceGroupManagerClient, projectID, zone, instanceGroupManagerName, templateName) if err != nil { t.Fatalf("Failed to create instance group manager: %v", err) } }) t.Run("Run", func(t *testing.T) { log.Printf("Running integration test for Compute Instance Group Manager in project %s, zone %s", projectID, zone) instanceGroupManagerWrapper := manual.NewComputeInstanceGroupManager(gcpshared.NewComputeInstanceGroupManagerClient(instanceGroupManagerClient), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := instanceGroupManagerWrapper.Scopes()[0] instanceGroupManagerAdapter := sources.WrapperToAdapter(instanceGroupManagerWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := instanceGroupManagerAdapter.Get(ctx, scope, instanceGroupManagerName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != instanceGroupManagerName { t.Fatalf("Expected unique attribute value to be %s, got %s", instanceGroupManagerName, uniqueAttrValue) } // [SPEC] The only two linked item queries being created at the moment are one Instance Template and Instance Group { if len(sdpItem.GetLinkedItemQueries()) != 3 { t.Logf("Linked item queries: %v", sdpItem.GetLinkedItemQueries()) t.Fatalf("Expected 3 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } // [SPEC] Ensure Instance Template is present linkedItem := sdpItem.GetLinkedItemQueries()[0] if linkedItem.GetQuery().GetType() != gcpshared.ComputeInstanceTemplate.String() { t.Fatalf("Expected linked item type to be %s, got: %s", gcpshared.ComputeInstanceTemplate, linkedItem.GetQuery().GetType()) } if linkedItem.GetQuery().GetQuery() != templateName { t.Fatalf("Expected linked item query to be %s, got: %s", instanceGroupManagerName, linkedItem.GetQuery().GetQuery()) } if linkedItem.GetQuery().GetScope() != projectID { t.Fatalf("Expected linked item scope to be %s, got: %s", projectID, linkedItem.GetQuery().GetScope()) } } // Check if adapter supports listing listable, ok := instanceGroupManagerAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list instance group managers: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one instance group manager, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == instanceGroupManagerName { found = true break } } if !found { t.Fatalf("Expected to find instance group manager %s in the list", instanceGroupManagerName) } }) t.Run("Teardown", func(t *testing.T) { err := deleteInstanceGroupManager(ctx, instanceGroupManagerClient, projectID, zone, instanceGroupManagerName) if err != nil { t.Fatalf("Failed to delete instance group manager: %v", err) } err = deleteInstanceTemplate(ctx, instanceTemplatesClient, projectID, templateName) if err != nil { t.Fatalf("Failed to delete instance template: %v", err) } }) } // createInstanceTemplate creates a GCP Compute Engine instance template. func createInstanceTemplate(ctx context.Context, client *compute.InstanceTemplatesClient, projectID, templateName string) error { template := &computepb.InstanceTemplate{ Name: new(templateName), Properties: &computepb.InstanceProperties{ MachineType: new("e2-micro"), Disks: []*computepb.AttachedDisk{ { Boot: new(true), AutoDelete: new(true), Type: new("PERSISTENT"), InitializeParams: &computepb.AttachedDiskInitializeParams{ SourceImage: new("projects/debian-cloud/global/images/family/debian-11"), }, }, }, NetworkInterfaces: []*computepb.NetworkInterface{ { Network: new("global/networks/default"), }, }, }, } req := &computepb.InsertInstanceTemplateRequest{ Project: projectID, InstanceTemplateResource: template, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance template creation: %w", err) } log.Printf("Instance template %s created successfully in project %s", templateName, projectID) return nil } // deleteInstanceTemplate deletes a GCP Compute Engine instance template. func deleteInstanceTemplate(ctx context.Context, client *compute.InstanceTemplatesClient, projectID, templateName string) error { req := &computepb.DeleteInstanceTemplateRequest{ Project: projectID, InstanceTemplate: templateName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance template deletion: %w", err) } log.Printf("Instance template %s deleted successfully", templateName) return nil } // createInstanceGroupManager creates a GCP Compute Engine instance group manager. func createInstanceGroupManager(ctx context.Context, client *compute.InstanceGroupManagersClient, projectID, zone, instanceGroupManagerName, templateName string) error { instanceGroupManager := &computepb.InstanceGroupManager{ Name: new(instanceGroupManagerName), InstanceTemplate: new(fmt.Sprintf("projects/%s/global/instanceTemplates/%s", projectID, templateName)), TargetSize: new(int32(1)), } req := &computepb.InsertInstanceGroupManagerRequest{ Project: projectID, Zone: zone, InstanceGroupManagerResource: instanceGroupManager, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance group manager creation: %w", err) } log.Printf("Instance group manager %s created successfully in", instanceGroupManagerName) return nil } // deleteInstanceGroupManager deletes a GCP Compute Engine instance group manager. func deleteInstanceGroupManager(ctx context.Context, client *compute.InstanceGroupManagersClient, projectID, zone, instanceGroupManagerName string) error { req := &computepb.DeleteInstanceGroupManagerRequest{ Project: projectID, Zone: zone, InstanceGroupManager: instanceGroupManagerName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance group manager deletion: %w", err) } log.Printf("Instance group manager %s deleted successfully", instanceGroupManagerName) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-instance-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeInstanceGroupIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() instanceGroupName := "integration-test-instance-group" ctx := context.Background() // Create a new Compute InstanceGroups client client, err := compute.NewInstanceGroupsRESTClient(ctx) if err != nil { t.Fatalf("NewInstanceGroupsRESTClient: %v", err) } defer client.Close() t.Run("Setup", func(t *testing.T) { err := createInstanceGroup(ctx, client, projectID, zone, instanceGroupName) if err != nil { t.Fatalf("Failed to create instance group: %v", err) } }) t.Run("ListInstanceGroups", func(t *testing.T) { log.Printf("Listing instance groups in project %s, zone %s", projectID, zone) instanceGroupWrapper := manual.NewComputeInstanceGroup(gcpshared.NewComputeInstanceGroupsClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := instanceGroupWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(instanceGroupWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list instance groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one instance group, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() v, err := item.GetAttributes().Get(uniqueAttrKey) if err == nil && v == instanceGroupName { found = true break } } if !found { t.Fatalf("Expected to find instance group %s in the list of instance groups", instanceGroupName) } log.Printf("Found %d instance groups in project %s, zone %s", len(sdpItems), projectID, zone) }) t.Run("GetInstanceGroup", func(t *testing.T) { log.Printf("Retrieving instance group %s in project %s, zone %s", instanceGroupName, projectID, zone) instanceGroupWrapper := manual.NewComputeInstanceGroup(gcpshared.NewComputeInstanceGroupsClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := instanceGroupWrapper.Scopes()[0] adapter := sources.WrapperToAdapter(instanceGroupWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, scope, instanceGroupName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != instanceGroupName { t.Fatalf("Expected unique attribute value to be %s, got %s", instanceGroupName, uniqueAttrValue) } log.Printf("Successfully retrieved instance group %s in project %s, zone %s", instanceGroupName, projectID, zone) }) t.Run("Teardown", func(t *testing.T) { err := deleteInstanceGroup(ctx, client, projectID, zone, instanceGroupName) if err != nil { t.Fatalf("Failed to delete instance group: %v", err) } }) } func createInstanceGroup(ctx context.Context, client *compute.InstanceGroupsClient, projectID, zone, instanceGroupName string) error { instanceGroup := &computepb.InstanceGroup{ Name: new(instanceGroupName), } req := &computepb.InsertInstanceGroupRequest{ Project: projectID, Zone: zone, InstanceGroupResource: instanceGroup, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance group creation operation: %w", err) } log.Printf("Instance group %s created successfully in project %s, zone %s", instanceGroupName, projectID, zone) return nil } func deleteInstanceGroup(ctx context.Context, client *compute.InstanceGroupsClient, projectID, zone, instanceGroupName string) error { req := &computepb.DeleteInstanceGroupRequest{ Project: projectID, Zone: zone, InstanceGroup: instanceGroupName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance group deletion operation: %w", err) } log.Printf("Instance group %s deleted successfully in project %s, zone %s", instanceGroupName, projectID, zone) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-instance_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeInstanceIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() instanceName := "integration-test-instance" ctx := context.Background() // Create a new Compute Instance client client, err := compute.NewInstancesRESTClient(ctx) if err != nil { t.Fatalf("NewInstancesRESTClient: %v", err) } defer client.Close() t.Run("Setup", func(t *testing.T) { err := createComputeInstance(ctx, client, projectID, zone, instanceName, "", "", "") if err != nil { t.Fatalf("Failed to create compute instance: %v", err) } }) t.Run("Run", func(t *testing.T) { log.Printf("Running integration test for Compute Instance in project %s, zone %s", projectID, zone) instanceWrapper := manual.NewComputeInstance(gcpshared.NewComputeInstanceClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := instanceWrapper.Scopes()[0] instanceAdapter := sources.WrapperToAdapter(instanceWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := instanceAdapter.Get(ctx, scope, instanceName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != instanceName { t.Fatalf("Expected unique attribute value to be %s, got %s", instanceName, uniqueAttrValue) } // Check if adapter supports listing listable, ok := instanceAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute instances: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute instance, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == instanceName { found = true break } } if !found { t.Fatalf("Expected to find instance %s in the list of compute instances", instanceName) } }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeInstance(ctx, client, projectID, zone, instanceName) if err != nil { t.Fatalf("Failed to delete compute instance: %v", err) } }) } // createComputeInstance creates a GCP Compute Instance with the given parameters. // If network or subnetwork is an empty string, it defaults to the project's default network configuration. func createComputeInstance(ctx context.Context, client *compute.InstancesClient, projectID, zone, instanceName, network, subnetwork, region string) error { // Construct the network interface networkInterface := &computepb.NetworkInterface{ StackType: new("IPV4_ONLY"), } if network != "" { networkInterface.Network = new(fmt.Sprintf("projects/%s/global/networks/%s", projectID, network)) } if subnetwork != "" { networkInterface.Subnetwork = new(fmt.Sprintf("projects/%s/regions/%s/subnetworks/%s", projectID, region, subnetwork)) } // Define the instance configuration instance := &computepb.Instance{ Name: new(instanceName), MachineType: new(fmt.Sprintf("zones/%s/machineTypes/e2-micro", zone)), Disks: []*computepb.AttachedDisk{ { Boot: new(true), AutoDelete: new(true), InitializeParams: &computepb.AttachedDiskInitializeParams{ SourceImage: new("projects/debian-cloud/global/images/debian-12-bookworm-v20250415"), DiskSizeGb: new(int64(10)), }, }, }, NetworkInterfaces: []*computepb.NetworkInterface{networkInterface}, Labels: map[string]string{ "test": "integration", }, } // Create the instance req := &computepb.InsertInstanceRequest{ Project: projectID, Zone: zone, InstanceResource: instance, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } // Wait for the operation to complete if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance creation operation: %w", err) } log.Printf("Instance %s created successfully in project %s, zone %s", instanceName, projectID, zone) return nil } func deleteComputeInstance(ctx context.Context, client *compute.InstancesClient, projectID, zone, instanceName string) error { req := &computepb.DeleteInstanceRequest{ Project: projectID, Zone: zone, Instance: instanceName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instance deletion operation: %w", err) } log.Printf("Compute instance %s deleted successfully", instanceName) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-instant-snapshot_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeInstantSnapshotIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() snapshotName := "integration-test-instant-snapshot" diskName := "integration-test-disk-for-snapshot" diskFullName := fmt.Sprintf( "projects/%s/zones/%s/disks/%s", projectID, zone, diskName, ) ctx := context.Background() // Create a new Compute InstantSnapshots client client, err := compute.NewInstantSnapshotsRESTClient(ctx) if err != nil { t.Fatalf("NewInstantSnapshotsRESTClient: %v", err) } defer client.Close() diskClient, err := compute.NewDisksRESTClient(ctx) if err != nil { t.Fatalf("NewDisksRESTClient: %v", err) } defer diskClient.Close() t.Run("Setup", func(t *testing.T) { err = createDisk(ctx, diskClient, projectID, zone, diskName) if err != nil { t.Fatalf("Failed to create source disk: %v", err) } err := createInstantSnapshot(ctx, client, projectID, zone, snapshotName, diskFullName) if err != nil { t.Fatalf("Failed to create instant snapshot: %v", err) } }) t.Run("ListInstantSnapshots", func(t *testing.T) { log.Printf("Listing instant snapshots in project %s, zone %s", projectID, zone) snapshotsWrapper := manual.NewComputeInstantSnapshot(gcpshared.NewComputeInstantSnapshotsClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := snapshotsWrapper.Scopes()[0] snapshotsAdapter := sources.WrapperToAdapter(snapshotsWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := snapshotsAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list instant snapshots: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one instant snapshot, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == snapshotName { found = true break } } if !found { t.Fatalf("Expected to find snapshot %s in the list of instant snapshots", snapshotName) } log.Printf("Found %d instant snapshots in project %s, zone %s", len(sdpItems), projectID, zone) }) t.Run("GetInstantSnapshot", func(t *testing.T) { log.Printf("Retrieving instant snapshot %s in project %s, zone %s", snapshotName, projectID, zone) snapshotsWrapper := manual.NewComputeInstantSnapshot(gcpshared.NewComputeInstantSnapshotsClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := snapshotsWrapper.Scopes()[0] snapshotsAdapter := sources.WrapperToAdapter(snapshotsWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := snapshotsAdapter.Get(ctx, scope, snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != snapshotName { t.Fatalf("Expected unique attribute value to be %s, got %s", snapshotName, uniqueAttrValue) } // [SPEC] The only two linked item queries being created at the moment are one Instance Template and Instance Group { if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Fatalf("Expected 1 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } // [SPEC] Ensure Source Disk is linked linkedItem := sdpItem.GetLinkedItemQueries()[0] if linkedItem.GetQuery().GetType() != gcpshared.ComputeDisk.String() { t.Fatalf("Expected linked item type to be %s, got: %s", gcpshared.ComputeDisk, linkedItem.GetQuery().GetType()) } if linkedItem.GetQuery().GetQuery() != diskName { t.Fatalf("Expected linked item query to be %s, got: %s", diskName, linkedItem.GetQuery().GetQuery()) } if linkedItem.GetQuery().GetScope() != gcpshared.ZonalScope(projectID, zone) { t.Fatalf("Expected linked item scope to be %s, got: %s", gcpshared.ZonalScope(projectID, zone), linkedItem.GetQuery().GetScope()) } } log.Printf("Successfully retrieved instant snapshot %s in project %s, zone %s", snapshotName, projectID, zone) }) t.Run("Teardown", func(t *testing.T) { err := deleteInstantSnapshot(ctx, client, projectID, zone, snapshotName) if err != nil { t.Fatalf("Failed to delete instant snapshot: %v", err) } }) } // createInstantSnapshot creates a GCP Compute Instant Snapshot with the given parameters. func createInstantSnapshot(ctx context.Context, client *compute.InstantSnapshotsClient, projectID, zone, snapshotName, diskName string) error { snapshot := &computepb.InstantSnapshot{ Name: new(snapshotName), SourceDisk: new(diskName), Labels: map[string]string{ "test": "integration", }, } // Create the instant snapshot req := &computepb.InsertInstantSnapshotRequest{ Project: projectID, Zone: zone, InstantSnapshotResource: snapshot, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instant snapshot creation operation: %w", err) } log.Printf("Instant snapshot %s created successfully in project %s, zone %s", snapshotName, projectID, zone) return nil } func deleteInstantSnapshot(ctx context.Context, client *compute.InstantSnapshotsClient, projectID, zone, snapshotName string) error { req := &computepb.DeleteInstantSnapshotRequest{ Project: projectID, Zone: zone, InstantSnapshot: snapshotName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for instant snapshot deletion operation: %w", err) } log.Printf("Instant snapshot %s deleted successfully", snapshotName) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-machine-image_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeMachineImageIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } region := os.Getenv("GCP_REGION") if region == "" { t.Skip("GCP_REGION environment variable not set") } t.Parallel() machineImageName := "integration-test-machine-image" sourceInstanceName := "integration-test-instance" ctx := context.Background() // Create a new Compute Machine Images client client, err := compute.NewMachineImagesRESTClient(ctx) if err != nil { t.Fatalf("NewMachineImagesRESTClient: %v", err) } defer client.Close() instanceClient, err := compute.NewInstancesRESTClient(ctx) if err != nil { t.Fatalf("NewInstancesRESTClient: %v", err) } defer instanceClient.Close() t.Run("Setup", func(t *testing.T) { err = createComputeInstance(ctx, instanceClient, projectID, zone, sourceInstanceName, "default", "default", region) if err != nil { t.Fatalf("Failed to create source instance: %v", err) } err := createComputeMachineImage(t, ctx, client, projectID, zone, machineImageName, sourceInstanceName) if err != nil { t.Fatalf("Failed to create compute machine image: %v", err) } }) t.Run("ListMachineImages", func(t *testing.T) { log.Printf("Listing machine images in project %s", projectID) machineImagesWrapper := manual.NewComputeMachineImage(gcpshared.NewComputeMachineImageClient(client), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) scope := machineImagesWrapper.Scopes()[0] machineImagesAdapter := sources.WrapperToAdapter(machineImagesWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := machineImagesAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute machine images: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute machine image, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == machineImageName { found = true break } } if !found { t.Fatalf("Expected to find machine image %s in the list of compute machine images", machineImageName) } log.Printf("Found %d machine images in project %s", len(sdpItems), projectID) }) t.Run("GetMachineImage", func(t *testing.T) { log.Printf("Retrieving machine image %s in project %s", machineImageName, projectID) machineImagesWrapper := manual.NewComputeMachineImage(gcpshared.NewComputeMachineImageClient(client), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) scope := machineImagesWrapper.Scopes()[0] machineImagesAdapter := sources.WrapperToAdapter(machineImagesWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := machineImagesAdapter.Get(ctx, scope, machineImageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != machineImageName { t.Fatalf("Expected unique attribute value to be %s, got %s", machineImageName, uniqueAttrValue) } log.Printf("Successfully retrieved machine image %s in project %s", machineImageName, projectID) }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeMachineImage(ctx, client, projectID, machineImageName) if err != nil { t.Fatalf("Failed to delete compute machine image: %v", err) } err = deleteComputeInstance(ctx, instanceClient, projectID, zone, sourceInstanceName) if err != nil { t.Fatalf("Failed to delete source instance: %v", err) } }) } // createComputeMachineImage creates a GCP Compute Machine Image with the given parameters. func createComputeMachineImage(t *testing.T, ctx context.Context, client *compute.MachineImagesClient, projectID, zone, machineImageName, sourceInstanceName string) error { machineImage := &computepb.MachineImage{ Name: new(machineImageName), SourceInstance: new(fmt.Sprintf( "projects/%s/zones/%s/instances/%s", projectID, zone, sourceInstanceName, )), Labels: map[string]string{ "test": "integration", }, } req := &computepb.InsertMachineImageRequest{ Project: projectID, MachineImageResource: machineImage, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for machine image creation operation: %w", err) } log.Printf("Machine image %s created successfully in project %s", machineImageName, projectID) return nil } func deleteComputeMachineImage(ctx context.Context, client *compute.MachineImagesClient, projectID, machineImageName string) error { req := &computepb.DeleteMachineImageRequest{ Project: projectID, MachineImage: machineImageName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for machine image deletion operation: %w", err) } log.Printf("Compute machine image %s deleted successfully", machineImageName) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-network_test.go ================================================ package integrationtests import ( "os" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeNetworkIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() ctx := t.Context() networkName := "default" // Use an existing network for testing t.Run("Setup", func(t *testing.T) { t.Logf("We will use the default network '%s' in project '%s' for testing", networkName, projectID) }) t.Run("Run", func(t *testing.T) { t.Logf("Running test for Compute Network: %s", networkName) sdpItemType := gcpshared.ComputeNetwork gcpHTTPCliWithOtel, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("Failed to create GCP HTTP client: %v", err) } adapter, err := dynamic.MakeAdapter(sdpItemType, gcpshared.NewLinker(), gcpHTTPCliWithOtel, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list networks: %v", err) } for _, sdp := range sdpItems { uniqueAttrVal, err := sdp.GetAttributes().Get(sdp.GetUniqueAttribute()) if err != nil { t.Errorf("Failed to get unique attribute for %s: %v", sdp.GetUniqueAttribute(), err) } uniqueAttrValue, ok := uniqueAttrVal.(string) if !ok { t.Errorf("Unique attribute value for %s is not a string: %v", sdp.GetUniqueAttribute(), uniqueAttrVal) continue } sdpItem, qErr := adapter.Get(ctx, projectID, uniqueAttrValue, true) if qErr != nil { t.Errorf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Errorf("Expected sdpItem to be non-nil for network %s", sdp.GetUniqueAttribute()) } if err := sdpItem.Validate(); err != nil { t.Errorf("SDP item validation failed for %s: %v", sdp.GetUniqueAttribute(), err) } } }) t.Run("Teardown", func(t *testing.T) { t.Logf("Skipping teardown for Compute Network test as we are using the default network '%s'", networkName) }) } ================================================ FILE: sources/gcp/integration-tests/compute-node-group_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "strings" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // The scope of this integration test should cover nodegroups, nodes, and node templates. func TestComputeNodeGroupIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() region := zone[:strings.LastIndex(zone, "-")] // Can replace with an environment-specific ID later. suffix := "default" // Nodegroup -> Node Template nodeTemplateName := "overmind-integration-test-node-template-" + suffix nodeGroupName := "overmind-integration-test-node-group-" + suffix fullNodeTemplateName := "projects/" + projectID + "/regions/" + region + "/nodeTemplates/" + nodeTemplateName ctx := context.Background() // Create a new Compute Engine client client, err := compute.NewNodeGroupsRESTClient(ctx) if err != nil { t.Fatalf("NewNodeGroupsRESTClient: %v", err) } defer client.Close() ntClient, err := compute.NewNodeTemplatesRESTClient(ctx) if err != nil { t.Fatalf("NewNodeTemplatesRESTClient: %v", err) } defer ntClient.Close() t.Run("Setup", func(t *testing.T) { err := createComputeNodeTemplate(ctx, ntClient, projectID, region, nodeTemplateName) if err != nil { t.Fatalf("Failed to create compute node template: %v", err) } err = createComputeNodeGroup(ctx, client, fullNodeTemplateName, projectID, zone, nodeGroupName) if err != nil { t.Fatalf("Failed to create compute node group: %v", err) } }) t.Run("Test for Node Group", func(t *testing.T) { log.Printf("Running integration test for Compute Node Group in project %s, zone %s", projectID, zone) nodeGroupWrapper := manual.NewComputeNodeGroup(gcpshared.NewComputeNodeGroupClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := nodeGroupWrapper.Scopes()[0] nodeGroupAdapter := sources.WrapperToAdapter(nodeGroupWrapper, sdpcache.NewNoOpCache()) // [SPEC] GET against a valid resource name will return an SDP item wrapping the // available resource. sdpItem, err := nodeGroupAdapter.Get(ctx, scope, nodeGroupName, true) if err != nil { t.Fatalf("nodeGroupAdapter.Get returned unexpected error: %v", err) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } // [SPEC] The attributes contained in the SDP item directly match the attributes // from the GCP API. uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != nodeGroupName { t.Fatalf("Expected unique attribute value to be %s, got %s", nodeGroupName, uniqueAttrValue) } // [SPEC] The only linked item query is one Node Template. { if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Fatalf("Expected 1 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } linkedItem := sdpItem.GetLinkedItemQueries()[0] if linkedItem.GetQuery().GetType() != gcpshared.ComputeNodeTemplate.String() { t.Fatalf("Expected linked item type to be %s, got: %s", gcpshared.ComputeNodeTemplate.String(), linkedItem.GetQuery().GetType()) } if linkedItem.GetQuery().GetQuery() != nodeTemplateName { t.Fatalf("Expected linked item query to be %s, got: %s", nodeTemplateName, linkedItem.GetQuery().GetQuery()) } expectedScope := gcpshared.RegionalScope(projectID, region) if linkedItem.GetQuery().GetScope() != expectedScope { t.Fatalf("Expected linked item scope to be %s, got: %s", expectedScope, linkedItem.GetQuery().GetScope()) } } // [SPEC] The LIST operation for node groups will list all node groups in a given // scope. // Check if adapter supports listing listable, ok := nodeGroupAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute node groups: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute node group, got %d", len(sdpItems)) } // The LIST operation result should include our node group. found := false for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == nodeGroupName { found = true break } } if !found { t.Fatalf("Expected to find node group %s in list, but it was not found", nodeGroupName) } }) t.Run("Test for Node Template", func(t *testing.T) { log.Printf("Running integration test for Compute Node Template in project %s, zone %s", projectID, zone) nodeTemplateWrapper := manual.NewComputeNodeTemplate(gcpshared.NewComputeNodeTemplateClient(ntClient), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) scope := nodeTemplateWrapper.Scopes()[0] nodeTemplateAdapter := sources.WrapperToAdapter(nodeTemplateWrapper, sdpcache.NewNoOpCache()) // [SPEC] GET against a valid resource name will return an SDP item wrapping the // available resource. sdpItem, err := nodeTemplateAdapter.Get(ctx, scope, nodeTemplateName, true) if err != nil { t.Fatalf("nodeTemplateAdapter.Get returned unexpected error: %v", err) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } // [SPEC] The attributes contained in the SDP item directly match the attributes // from the GCP API. uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != nodeTemplateName { t.Fatalf("Expected unique attribute value to be %s, got %s", nodeTemplateName, uniqueAttrValue) } // [SPEC] Node templates one backlink defined, linking to node groups. { if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Fatalf("Expected 1 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } // [SPEC] The expected query must match the full URL, including the Google API // hostname. queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNodeGroup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: nodeTemplateName, ExpectedScope: "*", }, } shared.RunStaticTests(t, nodeTemplateAdapter, sdpItem, queryTests) linkedItem := sdpItem.GetLinkedItemQueries()[0] if linkedItem.GetQuery().GetType() != gcpshared.ComputeNodeGroup.String() { t.Fatalf("Expected linked item type to be %s, got: %s", gcpshared.ComputeNodeGroup.String(), linkedItem.GetQuery().GetType()) } if linkedItem.GetQuery().GetQuery() != nodeTemplateName { t.Fatalf("Expected linked item query to be %s, got: %s", nodeTemplateName, linkedItem.GetQuery().GetQuery()) } expectedScope := "*" if linkedItem.GetQuery().GetScope() != expectedScope { t.Fatalf("Expected linked item scope to be %s, got: %s", expectedScope, linkedItem.GetQuery().GetScope()) } } // [SPEC] The LIST operation for node templates will list all node groups in a given // scope. // Check if adapter supports listing listable, ok := nodeTemplateAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute node templates: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute node template, got %d", len(sdpItems)) } // The LIST operation result should include our node group. found := false for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == nodeTemplateName { found = true break } } if !found { t.Fatalf("Expected to find node group %s in list, but it was not found", nodeTemplateName) } }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeNodeGroup(ctx, client, projectID, zone, nodeGroupName) if err != nil { t.Errorf("Warning: failed to delete compute node group: %v", err) } err = deleteComputeNodeTemplate(ctx, ntClient, projectID, region, nodeTemplateName) if err != nil { t.Errorf("Warning: failed to delete node template: %v", err) } }) } // Create a compute node template in GCP to test against. func createComputeNodeTemplate(ctx context.Context, client *compute.NodeTemplatesClient, projectID, region, name string) error { // Create a new node template nodeTemplate := &computepb.NodeTemplate{ Name: new(name), NodeType: new("c2-node-60-240"), } // Create the node template req := &computepb.InsertNodeTemplateRequest{ Project: projectID, NodeTemplateResource: nodeTemplate, Region: region, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } // Wait for the operation to complete if err := op.Wait(ctx); err != nil { return fmt.Errorf("Failed to wait for node template operation: %w", err) } log.Printf("Node template %s created successfully in project %s", name, projectID) return nil } // Delete a compute node template. func deleteComputeNodeTemplate(ctx context.Context, client *compute.NodeTemplatesClient, projectID, region, name string) error { req := &computepb.DeleteNodeTemplateRequest{ Project: projectID, Region: region, NodeTemplate: name, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for node template deletion operation: %w", err) } log.Printf("Node template %s deleted successfully in project %s", name, projectID) return nil } // Create a compute node group in GCP using the given node template. func createComputeNodeGroup(ctx context.Context, client *compute.NodeGroupsClient, nodeTemplate, projectID, zone, name string) error { // Create a new node group nodeGroup := &computepb.NodeGroup{ Name: new(name), NodeTemplate: new(nodeTemplate), AutoscalingPolicy: &computepb.NodeGroupAutoscalingPolicy{ Mode: new(computepb.NodeGroupAutoscalingPolicy_OFF.String()), MinNodes: new(int32(0)), MaxNodes: new(int32(1)), }, } req := &computepb.InsertNodeGroupRequest{ Project: projectID, Zone: zone, NodeGroupResource: nodeGroup, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for node group creation operation: %w", err) } log.Printf("Node group %s created successfully in project %s, zone %s", name, projectID, zone) return nil } // Delete a compute node group in GCP. func deleteComputeNodeGroup(ctx context.Context, client *compute.NodeGroupsClient, projectID, zone, name string) error { req := &computepb.DeleteNodeGroupRequest{ Project: projectID, Zone: zone, NodeGroup: name, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for node group deletion operation: %w", err) } log.Printf("Node group %s deleted successfully in project %s, zone %s", name, projectID, zone) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-reservation_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeReservationIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() reservationName := "integration-test-reservation" machineType := "e2-medium" // Use a common machine type for testing ctx := context.Background() // Create a new Compute Reservations client client, err := compute.NewReservationsRESTClient(ctx) if err != nil { t.Fatalf("NewReservationsRESTClient: %v", err) } defer client.Close() t.Run("Setup", func(t *testing.T) { err := createComputeReservation(ctx, client, projectID, zone, reservationName, machineType) if err != nil { t.Fatalf("Failed to create compute reservation: %v", err) } }) t.Run("ListReservations", func(t *testing.T) { log.Printf("Listing reservations in project %s, zone %s", projectID, zone) reservationsWrapper := manual.NewComputeReservation(gcpshared.NewComputeReservationClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := reservationsWrapper.Scopes()[0] reservationsAdapter := sources.WrapperToAdapter(reservationsWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := reservationsAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute reservations: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute reservation, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == reservationName { found = true break } } if !found { t.Fatalf("Expected to find reservation %s in the list of compute reservations", reservationName) } log.Printf("Found %d reservations in project %s, zone %s", len(sdpItems), projectID, zone) }) t.Run("GetReservation", func(t *testing.T) { log.Printf("Retrieving reservation %s in project %s, zone %s", reservationName, projectID, zone) reservationsWrapper := manual.NewComputeReservation(gcpshared.NewComputeReservationClient(client), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) scope := reservationsWrapper.Scopes()[0] reservationsAdapter := sources.WrapperToAdapter(reservationsWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := reservationsAdapter.Get(ctx, scope, reservationName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != reservationName { t.Fatalf("Expected unique attribute value to be %s, got %s", reservationName, uniqueAttrValue) } log.Printf("Successfully retrieved reservation %s in project %s, zone %s", reservationName, projectID, zone) }) t.Run("Teardown", func(t *testing.T) { err := deleteReservation(ctx, client, projectID, zone, reservationName) if err != nil { t.Fatalf("Failed to delete compute reservation: %v", err) } }) } // createComputeReservation creates a GCP Compute Reservation with the given parameters. func createComputeReservation(ctx context.Context, client *compute.ReservationsClient, projectID, zone, reservationName, machineType string) error { reservation := &computepb.Reservation{ Name: new(reservationName), SpecificReservation: &computepb.AllocationSpecificSKUReservation{ InstanceProperties: &computepb.AllocationSpecificSKUAllocationReservedInstanceProperties{ MachineType: new(machineType), }, Count: new(int64(1)), }, } req := &computepb.InsertReservationRequest{ Project: projectID, Zone: zone, ReservationResource: reservation, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for reservation creation operation: %w", err) } log.Printf("Reservation %s created successfully in project %s, zone %s", reservationName, projectID, zone) return nil } func deleteReservation(ctx context.Context, client *compute.ReservationsClient, projectID, zone, reservationName string) error { req := &computepb.DeleteReservationRequest{ Project: projectID, Zone: zone, Reservation: reservationName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for reservation deletion operation: %w", err) } log.Printf("Compute reservation %s deleted successfully in project %s, zone %s", reservationName, projectID, zone) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-snapshot_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeSnapshotIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() snapshotName := "integration-test-snapshot" diskName := "integration-test-disk-for-snapshot" ctx := context.Background() // Create a new Compute Snapshots client client, err := compute.NewSnapshotsRESTClient(ctx) if err != nil { t.Fatalf("NewSnapshotsRESTClient: %v", err) } defer client.Close() diskClient, err := compute.NewDisksRESTClient(ctx) if err != nil { t.Fatalf("NewDisksRESTClient: %v", err) } defer diskClient.Close() t.Run("Setup", func(t *testing.T) { err = createDisk(ctx, diskClient, projectID, zone, diskName) if err != nil { t.Fatalf("Failed to create source disk: %v", err) } err := createComputeSnapshot(ctx, client, projectID, zone, snapshotName, diskName) if err != nil { t.Fatalf("Failed to create compute snapshot: %v", err) } }) t.Run("ListSnapshots", func(t *testing.T) { log.Printf("Listing snapshots in project %s", projectID) snapshotsWrapper := manual.NewComputeSnapshot(gcpshared.NewComputeSnapshotsClient(client), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) scope := snapshotsWrapper.Scopes()[0] snapshotsAdapter := sources.WrapperToAdapter(snapshotsWrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := snapshotsAdapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list compute snapshots: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute snapshot, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { uniqueAttrKey := item.GetUniqueAttribute() if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == snapshotName { found = true break } } if !found { t.Fatalf("Expected to find snapshot %s in the list of compute snapshots", snapshotName) } log.Printf("Found %d snapshots in project %s", len(sdpItems), projectID) }) t.Run("GetSnapshot", func(t *testing.T) { log.Printf("Retrieving snapshot %s in project %s", snapshotName, projectID) snapshotsWrapper := manual.NewComputeSnapshot(gcpshared.NewComputeSnapshotsClient(client), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) scope := snapshotsWrapper.Scopes()[0] snapshotsAdapter := sources.WrapperToAdapter(snapshotsWrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := snapshotsAdapter.Get(ctx, scope, snapshotName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected sdpItem to be non-nil") } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != snapshotName { t.Fatalf("Expected unique attribute value to be %s, got %s", snapshotName, uniqueAttrValue) } log.Printf("Successfully retrieved snapshot %s in project %s", snapshotName, projectID) }) t.Run("Teardown", func(t *testing.T) { err := deleteComputeSnapshot(ctx, client, projectID, snapshotName) if err != nil { t.Fatalf("Failed to delete compute snapshot: %v", err) } }) } // createComputeSnapshot creates a GCP Compute Snapshot with the given parameters. func createComputeSnapshot(ctx context.Context, client *compute.SnapshotsClient, projectID, zone, snapshotName, diskName string) error { snapshot := &computepb.Snapshot{ Name: new(snapshotName), SourceDisk: new(fmt.Sprintf( "projects/%s/zones/%s/disks/%s", projectID, zone, diskName, )), Labels: map[string]string{ "test": "integration", }, StorageLocations: []string{"us-central1"}, } // Create the snapshot req := &computepb.InsertSnapshotRequest{ Project: projectID, SnapshotResource: snapshot, } op, err := client.Insert(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for snapshot creation operation: %w", err) } log.Printf("Snapshot %s created successfully in project %s", snapshotName, projectID) return nil } func deleteComputeSnapshot(ctx context.Context, client *compute.SnapshotsClient, projectID, snapshotName string) error { req := &computepb.DeleteSnapshotRequest{ Project: projectID, Snapshot: snapshotName, } op, err := client.Delete(ctx, req) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } if err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for snapshot deletion operation: %w", err) } log.Printf("Compute snapshot %s deleted successfully", snapshotName) return nil } ================================================ FILE: sources/gcp/integration-tests/compute-subnetwork_test.go ================================================ package integrationtests import ( "fmt" "os" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeSubnetworkIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } region := os.Getenv("GCP_REGION") if region == "" { region = "us-central1" // Default region if not specified t.Logf("GCP_REGION environment variable not set, using default: %s", region) } t.Parallel() ctx := t.Context() // We'll use the default subnetwork for testing subnetworkName := "default" // Default subnetworks are created for default networks t.Run("Setup", func(t *testing.T) { t.Logf("We will use the default subnetwork '%s' in region '%s' of project '%s' for testing", subnetworkName, region, projectID) }) t.Run("Run", func(t *testing.T) { t.Logf("Running test for Compute Subnetwork: %s", subnetworkName) sdpItemType := gcpshared.ComputeSubnetwork gcpHTTPCliWithOtel, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("Failed to create GCP HTTP client: %v", err) } // For subnetworks, we need to include the region as an initialization parameter adapter, err := dynamic.MakeAdapter(sdpItemType, gcpshared.NewLinker(), gcpHTTPCliWithOtel, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } scope := fmt.Sprintf("%s.%s", projectID, region) sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list subnetworks in region %s: %v", region, err) } if len(sdpItems) == 0 { t.Logf("No subnetworks found in project %s and region %s", projectID, region) return } for _, sdp := range sdpItems { uniqueAttrVal, err := sdp.GetAttributes().Get(sdp.GetUniqueAttribute()) if err != nil { t.Errorf("Failed to get unique attribute for %s: %v", sdp.GetUniqueAttribute(), err) continue } uniqueAttrValue, ok := uniqueAttrVal.(string) if !ok { t.Errorf("Unique attribute value for %s is not a string: %v", sdp.GetUniqueAttribute(), uniqueAttrVal) continue } sdpItem, qErr := adapter.Get(ctx, scope, uniqueAttrValue, true) if qErr != nil { t.Errorf("Expected no error, got: %v", qErr) continue } if sdpItem == nil { t.Errorf("Expected sdpItem to be non-nil for subnetwork %s", uniqueAttrValue) continue } if err := sdpItem.Validate(); err != nil { t.Errorf("SDP item validation failed for %s: %v", uniqueAttrValue, err) } } }) t.Run("Teardown", func(t *testing.T) { t.Logf("Skipping teardown for Compute Subnetwork test as we are using the default subnetwork '%s'", subnetworkName) }) } ================================================ FILE: sources/gcp/integration-tests/computer-instance-template_test.go ================================================ package integrationtests import ( "os" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestComputeInstanceTemplateIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() ctx := t.Context() t.Run("Setup", func(t *testing.T) { t.Logf("We will test existing instance templates in project '%s'", projectID) }) t.Run("Run", func(t *testing.T) { t.Logf("Running test for Compute Instance Templates") sdpItemType := gcpshared.ComputeInstanceTemplate gcpHTTPCliWithOtel, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("Failed to create GCP HTTP client: %v", err) } // Instance templates are global resources, no region needed adapter, err := dynamic.MakeAdapter(sdpItemType, gcpshared.NewLinker(), gcpHTTPCliWithOtel, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to create adapter for %s: %v", sdpItemType, err) } listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter for %s does not implement ListableAdapter", sdpItemType) } // For global resources, scope is just the project ID scope := projectID sdpItems, err := listable.List(ctx, scope, true) if err != nil { t.Fatalf("Failed to list instance templates: %v", err) } if len(sdpItems) == 0 { t.Logf("No instance templates found in project %s", projectID) return } for _, sdp := range sdpItems { uniqueAttrVal, err := sdp.GetAttributes().Get(sdp.GetUniqueAttribute()) if err != nil { t.Errorf("Failed to get unique attribute for %s: %v", sdp.GetUniqueAttribute(), err) continue } uniqueAttrValue, ok := uniqueAttrVal.(string) if !ok { t.Errorf("Unique attribute value for %s is not a string: %v", sdp.GetUniqueAttribute(), uniqueAttrVal) continue } sdpItem, qErr := adapter.Get(ctx, scope, uniqueAttrValue, true) if qErr != nil { t.Errorf("Expected no error, got: %v", qErr) continue } if sdpItem == nil { t.Errorf("Expected sdpItem to be non-nil for instance template %s", uniqueAttrValue) continue } if err := sdpItem.Validate(); err != nil { t.Errorf("SDP item validation failed for %s: %v", uniqueAttrValue, err) } } }) t.Run("Teardown", func(t *testing.T) { t.Logf("No teardown needed for Compute Instance Template test as we only performed read operations") }) } ================================================ FILE: sources/gcp/integration-tests/kms_vs_asset_inventory_test.go ================================================ package integrationtests // GCP Cloud KMS Limitations // // This test compares the Cloud KMS direct API with the Cloud Asset Inventory API. // Understanding the following GCP limitations is essential for working with KMS resources: // // 1. CryptoKey Deletion: // - CryptoKeys CANNOT be immediately deleted from GCP // - Must destroy all CryptoKeyVersions first (schedules for deletion after 24h by default) // - Even after version destruction, the CryptoKey resource remains (in DESTROYED state) // - The key name cannot be reused after destruction // - See: https://cloud.google.com/kms/docs/destroy-restore // // 2. KeyRing Deletion: // - KeyRings CANNOT be deleted at all in GCP // - Once created, they persist forever in the project // - This is by design for audit/compliance purposes // - See: https://cloud.google.com/kms/docs/resource-hierarchy // // 3. Resource Naming: // - KeyRing and CryptoKey names must be unique within their parent // - Names cannot be reused even after destruction // - This test uses a shared KeyRing to avoid proliferation // // 4. Asset Inventory Indexing: // - Cloud Asset Inventory indexes resources asynchronously // - New resources may take 1-5 minutes to appear in queries // - The test includes retry logic to handle this delay // // API Rate Limits (for reference): // // Cloud KMS API: // - Read requests: 300 queries per minute (QPM) // - Enforced per-second (QPS), not per-minute // - Exceeding limit returns RESOURCE_EXHAUSTED error // - See: https://cloud.google.com/kms/quotas // // Cloud Asset Inventory API: // - ListAssets: 100 QPM per project, 800 QPM per organization // - SearchAllResources: 400 QPM per project // - See: https://cloud.google.com/asset-inventory/docs/quota import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "testing" "time" kms "cloud.google.com/go/kms/apiv1" "cloud.google.com/go/kms/apiv1/kmspb" log "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) const ( // Shared KeyRing name - reused across test runs since KeyRings cannot be deleted testKeyRingName = "integration-test-keyring" // Location for KMS resources testKMSLocation = "global" // CryptoKey name prefix - timestamp will be appended for uniqueness testCryptoKeyPrefix = "api-comparison-test-key" ) // TestKMSvsAssetInventoryComparison compares the Cloud KMS direct API with the // Cloud Asset Inventory API for retrieving CryptoKey information. // // This test demonstrates the differences in: // - Calling conventions (URL structure, query parameters) // - Response structure (direct resource vs wrapped asset) // - Available metadata (ancestors, update times, etc.) // - Rate limits and quotas func TestKMSvsAssetInventoryComparison(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } ctx := context.Background() // Create KMS client for resource management kmsClient, err := kms.NewKeyManagementClient(ctx) if err != nil { t.Fatalf("Failed to create KMS client: %v", err) } defer kmsClient.Close() // Create HTTP client for direct API calls httpClient, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("Failed to create HTTP client: %v", err) } // Generate unique CryptoKey name for this test run cryptoKeyName := fmt.Sprintf("%s-%d", testCryptoKeyPrefix, time.Now().Unix()) // Full resource names keyRingParent := fmt.Sprintf("projects/%s/locations/%s", projectID, testKMSLocation) keyRingFullName := fmt.Sprintf("%s/keyRings/%s", keyRingParent, testKeyRingName) cryptoKeyFullName := fmt.Sprintf("%s/cryptoKeys/%s", keyRingFullName, cryptoKeyName) t.Run("Setup", func(t *testing.T) { // Create KeyRing (idempotent - will succeed if already exists) err := createKeyRing(ctx, kmsClient, keyRingParent, testKeyRingName) if err != nil { t.Fatalf("Failed to create KeyRing: %v", err) } log.Printf("KeyRing ready: %s", keyRingFullName) // Create CryptoKey for this test err = createCryptoKey(ctx, kmsClient, keyRingFullName, cryptoKeyName) if err != nil { t.Fatalf("Failed to create CryptoKey: %v", err) } log.Printf("CryptoKey created: %s", cryptoKeyFullName) }) t.Run("CompareAPIs", func(t *testing.T) { t.Log("=== GCP API Comparison: Cloud KMS vs Cloud Asset Inventory ===") t.Log("") // --- Cloud KMS Direct API --- t.Log("--- Cloud KMS Direct API ---") kmsURL := fmt.Sprintf("https://cloudkms.googleapis.com/v1/%s", cryptoKeyFullName) t.Logf("URL: %s", kmsURL) t.Logf("Method: GET") t.Logf("Required Permission: cloudkms.cryptoKeys.get") t.Logf("Rate Limit: 300 QPM (enforced per-second)") t.Log("") kmsStart := time.Now() kmsResponse, err := callKMSDirectAPI(ctx, httpClient, cryptoKeyFullName) kmsLatency := time.Since(kmsStart) if err != nil { t.Fatalf("Failed to call KMS API: %v", err) } t.Logf("Latency: %v", kmsLatency) t.Log("") // Pretty print KMS response kmsJSON, _ := json.MarshalIndent(kmsResponse, "", " ") t.Logf("Response Structure (Cloud KMS):\n%s", string(kmsJSON)) t.Log("") // --- Cloud Asset Inventory API --- t.Log("--- Cloud Asset Inventory API ---") assetURL := fmt.Sprintf( "https://cloudasset.googleapis.com/v1/projects/%s/assets?assetTypes=cloudkms.googleapis.com/CryptoKey&contentType=RESOURCE", projectID, ) t.Logf("URL: %s", assetURL) t.Logf("Method: GET") t.Logf("Required Permission: cloudasset.assets.listResource") t.Logf("Rate Limit: 100 QPM per project (ListAssets)") t.Log("") // Asset Inventory may have indexing delay - retry with backoff var assetResponse map[string]any var assetLatency time.Duration var foundAsset bool t.Log("Note: Cloud Asset Inventory indexes resources asynchronously.") t.Log("Retrying with backoff if the newly created key is not yet indexed...") t.Log("") for attempt := 1; attempt <= 10; attempt++ { assetStart := time.Now() assetResponse, err = callAssetInventoryAPI(ctx, httpClient, projectID, cryptoKeyFullName) assetLatency = time.Since(assetStart) if err != nil { t.Logf("Attempt %d: Error calling Asset Inventory API: %v", attempt, err) } else if assetResponse != nil { foundAsset = true t.Logf("Attempt %d: Found asset after %v", attempt, assetLatency) break } else { t.Logf("Attempt %d: Asset not yet indexed, waiting...", attempt) } // Exponential backoff: 5s, 10s, 20s, 40s... up to 60s waitTime := min(time.Duration(5*(1<<(attempt-1)))*time.Second, 60*time.Second) time.Sleep(waitTime) } if !foundAsset { t.Log("WARNING: Asset not found in Cloud Asset Inventory after retries.") t.Log("This may indicate the indexing delay exceeds our retry window.") t.Log("The test will continue with partial comparison.") } else { // Pretty print Asset Inventory response assetJSON, _ := json.MarshalIndent(assetResponse, "", " ") t.Logf("Response Structure (Cloud Asset Inventory):\n%s", string(assetJSON)) } t.Log("") // --- Comparison Summary --- t.Log("=== Comparison Summary ===") t.Log("") t.Log("| Aspect | Cloud KMS API | Cloud Asset Inventory API |") t.Log("|-------------------------|----------------------------|---------------------------------|") t.Log("| Endpoint | cloudkms.googleapis.com | cloudasset.googleapis.com |") t.Log("| Response Type | Direct resource | Wrapped in Asset object |") t.Log("| Resource Data Location | Root of response | resource.data field |") t.Log("| Rate Limit | 300 QPM | 100 QPM (ListAssets) |") t.Log("| Ancestry Info | Not included | Included (ancestors field) |") t.Log("| IAM Policy | Separate API call | Optional (contentType param) |") t.Log("| Update Timestamp | createTime only | updateTime + createTime |") t.Logf("| Observed Latency | %v | %v |", kmsLatency.Round(time.Millisecond), assetLatency.Round(time.Millisecond)) t.Log("") t.Log("Key Differences:") t.Log("1. Cloud KMS returns the CryptoKey resource directly") t.Log("2. Cloud Asset Inventory wraps the resource with metadata (ancestors, assetType, updateTime)") t.Log("3. Asset Inventory can batch multiple asset types in a single request") t.Log("4. Asset Inventory provides resource hierarchy information (ancestors)") t.Log("5. Cloud KMS API has higher rate limits for targeted resource access") t.Log("6. Asset Inventory has indexing delay (resources not immediately available)") }) t.Run("Teardown", func(t *testing.T) { // Note: We cannot delete CryptoKeys or KeyRings in GCP. // The best we can do is destroy the CryptoKeyVersion to make the key unusable. // // From GCP documentation: // "You cannot delete a CryptoKey or KeyRing resource. These resources are retained // indefinitely for audit and compliance purposes." // // To minimize resource accumulation, we: // 1. Destroy the primary CryptoKeyVersion (schedules it for deletion after 24h) // 2. Leave the CryptoKey in DESTROYED state // 3. Reuse the same KeyRing for all test runs err := destroyCryptoKeyVersion(ctx, kmsClient, cryptoKeyFullName) if err != nil { // Log but don't fail - the key will remain but be unusable log.Printf("Warning: Failed to destroy CryptoKeyVersion: %v", err) log.Printf("The CryptoKey %s will remain active but can be manually destroyed later", cryptoKeyFullName) } else { log.Printf("CryptoKeyVersion scheduled for destruction: %s", cryptoKeyFullName) log.Printf("Note: The CryptoKey resource itself cannot be deleted (GCP limitation)") } }) } // createKeyRing creates a KeyRing if it doesn't already exist. // KeyRings cannot be deleted, so this is idempotent. func createKeyRing(ctx context.Context, client *kms.KeyManagementClient, parent, keyRingID string) error { req := &kmspb.CreateKeyRingRequest{ Parent: parent, KeyRingId: keyRingID, KeyRing: &kmspb.KeyRing{}, } _, err := client.CreateKeyRing(ctx, req) if err != nil { // Check for gRPC AlreadyExists error - KeyRing already exists is fine if st, ok := status.FromError(err); ok && st.Code() == codes.AlreadyExists { log.Printf("KeyRing already exists (expected): %s/%s", parent, keyRingID) return nil } return fmt.Errorf("failed to create KeyRing: %w", err) } return nil } // createCryptoKey creates a new CryptoKey for encryption/decryption. func createCryptoKey(ctx context.Context, client *kms.KeyManagementClient, keyRingName, cryptoKeyID string) error { req := &kmspb.CreateCryptoKeyRequest{ Parent: keyRingName, CryptoKeyId: cryptoKeyID, CryptoKey: &kmspb.CryptoKey{ Purpose: kmspb.CryptoKey_ENCRYPT_DECRYPT, Labels: map[string]string{ "test": "integration", "purpose": "api-comparison", }, }, } _, err := client.CreateCryptoKey(ctx, req) if err != nil { return fmt.Errorf("failed to create CryptoKey: %w", err) } return nil } // destroyCryptoKeyVersion destroys the primary version of a CryptoKey. // This is the closest we can get to "deleting" a key in GCP. // The version is scheduled for destruction after 24 hours by default. func destroyCryptoKeyVersion(ctx context.Context, client *kms.KeyManagementClient, cryptoKeyName string) error { // First, get the CryptoKey to find its primary version getReq := &kmspb.GetCryptoKeyRequest{ Name: cryptoKeyName, } cryptoKey, err := client.GetCryptoKey(ctx, getReq) if err != nil { return fmt.Errorf("failed to get CryptoKey: %w", err) } if cryptoKey.GetPrimary() == nil { log.Printf("CryptoKey has no primary version (may already be destroyed)") return nil } // Destroy the primary version destroyReq := &kmspb.DestroyCryptoKeyVersionRequest{ Name: cryptoKey.GetPrimary().GetName(), } _, err = client.DestroyCryptoKeyVersion(ctx, destroyReq) if err != nil { return fmt.Errorf("failed to destroy CryptoKeyVersion: %w", err) } return nil } // callKMSDirectAPI calls the Cloud KMS REST API directly to get a CryptoKey. func callKMSDirectAPI(ctx context.Context, httpClient *http.Client, cryptoKeyName string) (map[string]any, error) { apiURL := fmt.Sprintf("https://cloudkms.googleapis.com/v1/%s", cryptoKeyName) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("KMS API returned status %d: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var result map[string]any if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } return result, nil } // callAssetInventoryAPI calls the Cloud Asset Inventory API to find a specific CryptoKey. // Returns the asset if found, nil if not found (may indicate indexing delay). func callAssetInventoryAPI(ctx context.Context, httpClient *http.Client, projectID, cryptoKeyName string) (map[string]any, error) { // Build the Asset Inventory ListAssets URL baseURL := fmt.Sprintf("https://cloudasset.googleapis.com/v1/projects/%s/assets", projectID) params := url.Values{} params.Set("assetTypes", "cloudkms.googleapis.com/CryptoKey") params.Set("contentType", "RESOURCE") apiURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Cloud Asset Inventory API requires a quota project header when using user credentials // This tells GCP which project to bill for the API usage req.Header.Set("X-Goog-User-Project", projectID) resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("Asset Inventory API returned status %d: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var result map[string]any if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } // Find the specific CryptoKey in the assets list assets, ok := result["assets"].([]any) if !ok || len(assets) == 0 { return nil, nil // No assets found - may indicate indexing delay } // The Asset Inventory uses full resource names with // prefix // e.g., //cloudkms.googleapis.com/projects/PROJECT/locations/global/keyRings/RING/cryptoKeys/KEY expectedAssetName := fmt.Sprintf("//cloudkms.googleapis.com/%s", cryptoKeyName) for _, asset := range assets { assetMap, ok := asset.(map[string]any) if !ok { continue } name, ok := assetMap["name"].(string) if !ok { continue } if strings.HasSuffix(name, cryptoKeyName) || name == expectedAssetName { return assetMap, nil } } return nil, nil // Specific key not found in results } ================================================ FILE: sources/gcp/integration-tests/main_test.go ================================================ package integrationtests import ( "fmt" "os" "strconv" "testing" _ "github.com/overmindtech/cli/sources/gcp/dynamic/adapters" // force import of adapters to register them ) func TestMain(m *testing.M) { if shouldRunIntegrationTests() { fmt.Println("Running integration tests") os.Exit(m.Run()) } else { fmt.Println("Skipping integration tests, set RUN_GCP_INTEGRATION_TESTS=true to run them") os.Exit(0) } } func shouldRunIntegrationTests() bool { run, found := os.LookupEnv("RUN_GCP_INTEGRATION_TESTS") if !found { return false } shouldRun, err := strconv.ParseBool(run) if err != nil { return false } return shouldRun } ================================================ FILE: sources/gcp/integration-tests/network-tags_test.go ================================================ // Run commands (assumes RUN_GCP_INTEGRATION_TESTS, GCP_PROJECT_ID, GCP_ZONE are exported): // // All: go test ./sources/gcp/integration-tests/ -run "TestNetworkTagRelationships" -count 1 -v // Setup: go test ./sources/gcp/integration-tests/ -run "TestNetworkTagRelationships/Setup" -count 1 -v // Run: go test ./sources/gcp/integration-tests/ -run "TestNetworkTagRelationships/(Instance|Firewall|Route)" -count 1 -v // Teardown: go test ./sources/gcp/integration-tests/ -run "TestNetworkTagRelationships/Teardown" -count 1 -v // // Verify created resources with gcloud: // // gcloud compute instances describe integration-test-nettag-instance --zone=$GCP_ZONE --project=$GCP_PROJECT_ID --format="value(tags.items)" // gcloud compute firewall-rules describe integration-test-nettag-fw --project=$GCP_PROJECT_ID --format="value(targetTags)" // gcloud compute routes describe integration-test-nettag-route --project=$GCP_PROJECT_ID --format="value(tags)" // package integrationtests import ( "context" "errors" "fmt" "net/http" "os" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/dynamic" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) const ( networkTagTestInstance = "integration-test-nettag-instance" networkTagTestFirewall = "integration-test-nettag-fw" networkTagTestRoute = "integration-test-nettag-route" networkTagTestInstanceTemplate = "integration-test-nettag-template" networkTag = "nettag-test" ) func TestNetworkTagRelationships(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } zone := os.Getenv("GCP_ZONE") if zone == "" { t.Skip("GCP_ZONE environment variable not set") } t.Parallel() ctx := context.Background() instanceClient, err := compute.NewInstancesRESTClient(ctx) if err != nil { t.Fatalf("NewInstancesRESTClient: %v", err) } defer instanceClient.Close() firewallClient, err := compute.NewFirewallsRESTClient(ctx) if err != nil { t.Fatalf("NewFirewallsRESTClient: %v", err) } defer firewallClient.Close() routeClient, err := compute.NewRoutesRESTClient(ctx) if err != nil { t.Fatalf("NewRoutesRESTClient: %v", err) } defer routeClient.Close() instanceTemplateClient, err := compute.NewInstanceTemplatesRESTClient(ctx) if err != nil { t.Fatalf("NewInstanceTemplatesRESTClient: %v", err) } defer instanceTemplateClient.Close() // --- Setup --- t.Run("Setup", func(t *testing.T) { if err := createInstanceWithTags(ctx, instanceClient, projectID, zone); err != nil { t.Fatalf("Failed to create tagged instance: %v", err) } if err := createFirewallWithTags(ctx, firewallClient, projectID); err != nil { t.Fatalf("Failed to create tagged firewall: %v", err) } if err := createRouteWithTags(ctx, routeClient, projectID); err != nil { t.Fatalf("Failed to create tagged route: %v", err) } if err := createInstanceTemplateWithTags(ctx, instanceTemplateClient, projectID); err != nil { t.Fatalf("Failed to create tagged instance template: %v", err) } }) // --- Run --- t.Run("InstanceEmitsSearchLinksToFirewallAndRoute", func(t *testing.T) { wrapper := manual.NewComputeInstance( gcpshared.NewComputeInstanceClient(instanceClient), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}, ) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], networkTagTestInstance, true) if qErr != nil { t.Fatalf("Get instance: %v", qErr) } assertHasLinkedItemQuery(t, sdpItem, gcpshared.ComputeFirewall.String(), sdp.QueryMethod_SEARCH, networkTag, projectID) assertHasLinkedItemQuery(t, sdpItem, gcpshared.ComputeRoute.String(), sdp.QueryMethod_SEARCH, networkTag, projectID) }) t.Run("FirewallSearchByTagReturnsFirewall", func(t *testing.T) { gcpHTTPCli, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("GCPHTTPClientWithOtel: %v", err) } adapter, err := dynamic.MakeAdapter(gcpshared.ComputeFirewall, gcpshared.NewLinker(), gcpHTTPCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("MakeAdapter: %v", err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Firewall adapter does not implement SearchableAdapter") } items, qErr := searchable.Search(ctx, projectID, networkTag, true) if qErr != nil { t.Fatalf("Search: %v", qErr) } found := false for _, item := range items { if v, err := item.GetAttributes().Get("name"); err == nil && v == networkTagTestFirewall { found = true break } } if !found { t.Errorf("Expected to find firewall %s in search results for tag %q, got %d items", networkTagTestFirewall, networkTag, len(items)) } }) t.Run("RouteSearchByTagReturnsRoute", func(t *testing.T) { gcpHTTPCli, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("GCPHTTPClientWithOtel: %v", err) } adapter, err := dynamic.MakeAdapter(gcpshared.ComputeRoute, gcpshared.NewLinker(), gcpHTTPCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("MakeAdapter: %v", err) } searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Route adapter does not implement SearchableAdapter") } items, qErr := searchable.Search(ctx, projectID, networkTag, true) if qErr != nil { t.Fatalf("Search: %v", qErr) } found := false for _, item := range items { if v, err := item.GetAttributes().Get("name"); err == nil && v == networkTagTestRoute { found = true break } } if !found { t.Errorf("Expected to find route %s in search results for tag %q, got %d items", networkTagTestRoute, networkTag, len(items)) } }) t.Run("InstanceSearchByTagReturnsInstance", func(t *testing.T) { wrapper := manual.NewComputeInstance( gcpshared.NewComputeInstanceClient(instanceClient), []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}, ) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Instance adapter does not implement SearchableAdapter") } scopeWithZone := fmt.Sprintf("%s.%s", projectID, zone) items, qErr := searchable.Search(ctx, scopeWithZone, networkTag, true) if qErr != nil { t.Fatalf("Search: %v", qErr) } found := false for _, item := range items { if v, err := item.GetAttributes().Get("name"); err == nil && v == networkTagTestInstance { found = true break } } if !found { t.Errorf("Expected to find instance %s in search results for tag %q, got %d items", networkTagTestInstance, networkTag, len(items)) } }) t.Run("FirewallEmitsSearchLinksToInstance", func(t *testing.T) { gcpHTTPCli, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("GCPHTTPClientWithOtel: %v", err) } adapter, err := dynamic.MakeAdapter(gcpshared.ComputeFirewall, gcpshared.NewLinker(), gcpHTTPCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("MakeAdapter: %v", err) } sdpItem, qErr := adapter.Get(ctx, projectID, networkTagTestFirewall, true) if qErr != nil { t.Fatalf("Get firewall: %v", qErr) } assertHasLinkedItemQuery(t, sdpItem, gcpshared.ComputeInstance.String(), sdp.QueryMethod_SEARCH, networkTag, projectID) }) t.Run("RouteEmitsSearchLinksToInstance", func(t *testing.T) { gcpHTTPCli, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("GCPHTTPClientWithOtel: %v", err) } adapter, err := dynamic.MakeAdapter(gcpshared.ComputeRoute, gcpshared.NewLinker(), gcpHTTPCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("MakeAdapter: %v", err) } sdpItem, qErr := adapter.Get(ctx, projectID, networkTagTestRoute, true) if qErr != nil { t.Fatalf("Get route: %v", qErr) } assertHasLinkedItemQuery(t, sdpItem, gcpshared.ComputeInstance.String(), sdp.QueryMethod_SEARCH, networkTag, projectID) }) t.Run("InstanceTemplateEmitsSearchLinksToFirewallAndRoute", func(t *testing.T) { gcpHTTPCli, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("GCPHTTPClientWithOtel: %v", err) } adapter, err := dynamic.MakeAdapter(gcpshared.ComputeInstanceTemplate, gcpshared.NewLinker(), gcpHTTPCli, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("MakeAdapter: %v", err) } sdpItem, qErr := adapter.Get(ctx, projectID, networkTagTestInstanceTemplate, true) if qErr != nil { t.Fatalf("Get instance template: %v", qErr) } assertHasLinkedItemQuery(t, sdpItem, gcpshared.ComputeFirewall.String(), sdp.QueryMethod_SEARCH, networkTag, projectID) assertHasLinkedItemQuery(t, sdpItem, gcpshared.ComputeRoute.String(), sdp.QueryMethod_SEARCH, networkTag, projectID) }) // --- Teardown --- t.Run("Teardown", func(t *testing.T) { if err := deleteComputeInstance(ctx, instanceClient, projectID, zone, networkTagTestInstance); err != nil { t.Errorf("Failed to delete instance: %v", err) } if err := deleteFirewall(ctx, firewallClient, projectID, networkTagTestFirewall); err != nil { t.Errorf("Failed to delete firewall: %v", err) } if err := deleteRoute(ctx, routeClient, projectID, networkTagTestRoute); err != nil { t.Errorf("Failed to delete route: %v", err) } if err := deleteInstanceTemplate(ctx, instanceTemplateClient, projectID, networkTagTestInstanceTemplate); err != nil { t.Errorf("Failed to delete instance template: %v", err) } }) } func assertHasLinkedItemQuery(t *testing.T, item *sdp.Item, expectedType string, expectedMethod sdp.QueryMethod, expectedQuery, expectedScope string) { t.Helper() for _, liq := range item.GetLinkedItemQueries() { q := liq.GetQuery() if q.GetType() == expectedType && q.GetMethod() == expectedMethod && q.GetQuery() == expectedQuery && q.GetScope() == expectedScope { return } } t.Errorf("Missing LinkedItemQuery{type=%s, method=%s, query=%s, scope=%s} on item %s", expectedType, expectedMethod, expectedQuery, expectedScope, item.UniqueAttributeValue()) } // --- Resource creation/deletion helpers --- func createInstanceWithTags(ctx context.Context, client *compute.InstancesClient, projectID, zone string) error { instance := &computepb.Instance{ Name: new(networkTagTestInstance), MachineType: new(fmt.Sprintf("zones/%s/machineTypes/e2-micro", zone)), Tags: &computepb.Tags{ Items: []string{networkTag}, }, Disks: []*computepb.AttachedDisk{ { Boot: new(true), AutoDelete: new(true), InitializeParams: &computepb.AttachedDiskInitializeParams{ SourceImage: new("projects/debian-cloud/global/images/debian-12-bookworm-v20250415"), DiskSizeGb: new(int64(10)), }, }, }, NetworkInterfaces: []*computepb.NetworkInterface{ {StackType: new("IPV4_ONLY")}, }, } op, err := client.Insert(ctx, &computepb.InsertInstanceRequest{ Project: projectID, Zone: zone, InstanceResource: instance, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Instance %s already exists, skipping", networkTagTestInstance) return nil } return fmt.Errorf("insert instance: %w", err) } return op.Wait(ctx) } func createFirewallWithTags(ctx context.Context, client *compute.FirewallsClient, projectID string) error { fw := &computepb.Firewall{ Name: new(networkTagTestFirewall), Network: new(fmt.Sprintf("projects/%s/global/networks/default", projectID)), TargetTags: []string{networkTag}, Allowed: []*computepb.Allowed{ { IPProtocol: new("tcp"), Ports: []string{"8080"}, }, }, SourceRanges: []string{"0.0.0.0/0"}, } op, err := client.Insert(ctx, &computepb.InsertFirewallRequest{ Project: projectID, FirewallResource: fw, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Firewall %s already exists, skipping", networkTagTestFirewall) return nil } return fmt.Errorf("insert firewall: %w", err) } return op.Wait(ctx) } func createRouteWithTags(ctx context.Context, client *compute.RoutesClient, projectID string) error { route := &computepb.Route{ Name: new(networkTagTestRoute), Network: new(fmt.Sprintf("projects/%s/global/networks/default", projectID)), DestRange: new("10.99.0.0/24"), NextHopGateway: new(fmt.Sprintf("projects/%s/global/gateways/default-internet-gateway", projectID)), Tags: []string{networkTag}, Priority: new(uint32(900)), } op, err := client.Insert(ctx, &computepb.InsertRouteRequest{ Project: projectID, RouteResource: route, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Route %s already exists, skipping", networkTagTestRoute) return nil } return fmt.Errorf("insert route: %w", err) } return op.Wait(ctx) } func deleteFirewall(ctx context.Context, client *compute.FirewallsClient, projectID, name string) error { op, err := client.Delete(ctx, &computepb.DeleteFirewallRequest{ Project: projectID, Firewall: name, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { return nil } return fmt.Errorf("delete firewall: %w", err) } return op.Wait(ctx) } func deleteRoute(ctx context.Context, client *compute.RoutesClient, projectID, name string) error { op, err := client.Delete(ctx, &computepb.DeleteRouteRequest{ Project: projectID, Route: name, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusNotFound { return nil } return fmt.Errorf("delete route: %w", err) } return op.Wait(ctx) } func createInstanceTemplateWithTags(ctx context.Context, client *compute.InstanceTemplatesClient, projectID string) error { template := &computepb.InstanceTemplate{ Name: new(networkTagTestInstanceTemplate), Properties: &computepb.InstanceProperties{ MachineType: new("e2-micro"), Tags: &computepb.Tags{ Items: []string{networkTag}, }, Disks: []*computepb.AttachedDisk{ { Boot: new(true), AutoDelete: new(true), InitializeParams: &computepb.AttachedDiskInitializeParams{ SourceImage: new("projects/debian-cloud/global/images/debian-12-bookworm-v20250415"), DiskSizeGb: new(int64(10)), }, }, }, NetworkInterfaces: []*computepb.NetworkInterface{ { Network: new("global/networks/default"), StackType: new("IPV4_ONLY"), }, }, }, } op, err := client.Insert(ctx, &computepb.InsertInstanceTemplateRequest{ Project: projectID, InstanceTemplateResource: template, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.HTTPCode() == http.StatusConflict { log.Printf("Instance template %s already exists, skipping", networkTagTestInstanceTemplate) return nil } return fmt.Errorf("insert instance template: %w", err) } return op.Wait(ctx) } ================================================ FILE: sources/gcp/integration-tests/service-account-impersonation_test.go ================================================ package integrationtests import ( "context" "encoding/base64" "errors" "fmt" "net/http" "os" "slices" "strings" "testing" "time" authcredentials "cloud.google.com/go/auth/credentials" "cloud.google.com/go/auth/oauth2adapt" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" credentials "cloud.google.com/go/iam/credentials/apiv1" credentialspb "cloud.google.com/go/iam/credentials/apiv1/credentialspb" "github.com/google/uuid" "github.com/googleapis/gax-go/v2/apierror" "golang.org/x/oauth2" cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" "google.golang.org/api/googleapi" "google.golang.org/api/iam/v1" "google.golang.org/api/option" ) // Test state structure to hold service account information type testState struct { projectID string ourServiceAccountID string ourServiceAccountEmail string ourServiceAccountKey []byte ourServiceAccountKeyID string customerServiceAccountID string customerServiceAccountEmail string customerServiceAccountKey []byte customerServiceAccountKeyID string } func TestServiceAccountImpersonationIntegration(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() // Initialize Cloud Resource Manager service crmService, err := cloudresourcemanager.NewService(t.Context()) if err != nil { t.Errorf("Failed to create Cloud Resource Manager service: %v", err) return } state := &testState{ projectID: projectID, } // Initialize IAM service using Application Default Credentials iamService, err := iam.NewService(t.Context()) if err != nil { t.Errorf("Failed to create IAM service: %v", err) return } // Create UUIDs for service account names ourSAUUID := uuid.New().String() customerSAUUID := uuid.New().String() // Generate service account IDs (max 30 chars, must be alphanumeric and lowercase) // Remove hyphens and take first part of UUID state.ourServiceAccountID = fmt.Sprintf("ovm-test-our-sa-%s", strings.ReplaceAll(ourSAUUID[:8], "-", "")) state.customerServiceAccountID = fmt.Sprintf("ovm-test-cust-%s", strings.ReplaceAll(customerSAUUID[:8], "-", "")) // since this test needs to keep state between tests, we wrap it in a Run function t.Run("Run", func(t *testing.T) { if !setupTest(t, t.Context(), iamService, crmService, state) { return } t.Cleanup(func() { teardownTest(t, t.Context(), iamService, crmService, state) }) t.Run("Test1_OurServiceAccountDirectAuth", func(t *testing.T) { testOurServiceAccountDirectAuth(t, t.Context(), state) }) t.Run("Test2_CustomerServiceAccountDirectAuth", func(t *testing.T) { testCustomerServiceAccountDirectAuth(t, t.Context(), state) }) t.Run("Test3_Impersonation", func(t *testing.T) { testImpersonation(t, t.Context(), state) }) }) } func setupTest(t *testing.T, ctx context.Context, iamService *iam.Service, crmService *cloudresourcemanager.Service, state *testState) bool { // Create "Our Service Account" t.Logf("Creating 'Our Service Account': %s", state.ourServiceAccountID) ourSA, err := createServiceAccount(ctx, iamService, state.projectID, state.ourServiceAccountID, "Our Service Account for impersonation test") if err != nil { t.Errorf("Failed to create 'Our Service Account': %v", err) return false } state.ourServiceAccountEmail = ourSA.Email t.Logf("Created 'Our Service Account': %s", state.ourServiceAccountEmail) // Create "Customer Service Account" t.Logf("Creating 'Customer Service Account': %s", state.customerServiceAccountID) customerSA, err := createServiceAccount(ctx, iamService, state.projectID, state.customerServiceAccountID, "Customer Service Account for impersonation test") if err != nil { t.Errorf("Failed to create 'Customer Service Account': %v", err) return false } state.customerServiceAccountEmail = customerSA.Email t.Logf("Created 'Customer Service Account': %s", state.customerServiceAccountEmail) // Verify service accounts are created t.Log("Verifying service accounts are created...") maxAttempts := 30 for attempt := 1; attempt <= maxAttempts; attempt++ { ourSAVerified, err := verifyServiceAccountExists(ctx, iamService, state.projectID, state.ourServiceAccountEmail) if err != nil { t.Logf("Attempt %d/%d: Error verifying 'Our Service Account': %v", attempt, maxAttempts, err) } customerSAVerified, err := verifyServiceAccountExists(ctx, iamService, state.projectID, state.customerServiceAccountEmail) if err != nil { t.Logf("Attempt %d/%d: Error verifying 'Customer Service Account': %v", attempt, maxAttempts, err) } if ourSAVerified && customerSAVerified { t.Logf("✓ Service accounts verified after %d attempt(s)", attempt) break } else { t.Logf("Attempt %d/%d: Service accounts not yet available, waiting...", attempt, maxAttempts) } if attempt < maxAttempts { time.Sleep(1 * time.Second) } else { t.Errorf("Service account verification failed after %d attempts. The service accounts may not have been created correctly.", maxAttempts) return false } } // Grant "Our Service Account" permission to impersonate "Customer Service Account" t.Logf("Granting impersonation permission to 'Our Service Account'") err = grantServiceAccountTokenCreator(ctx, iamService, state.projectID, state.customerServiceAccountEmail, state.ourServiceAccountEmail) if err != nil { t.Errorf("Failed to grant serviceAccountTokenCreator role: %v", err) return false } // Verify IAM policy binding is effective t.Log("Verifying IAM policy binding for serviceAccountTokenCreator role...") maxAttempts = 30 for attempt := 1; attempt <= maxAttempts; attempt++ { verified, err := verifyServiceAccountTokenCreatorBinding(ctx, iamService, state.projectID, state.customerServiceAccountEmail, state.ourServiceAccountEmail) if err != nil { t.Logf("Attempt %d/%d: Error verifying IAM policy: %v", attempt, maxAttempts, err) } else if verified { t.Logf("✓ IAM policy binding verified after %d attempt(s)", attempt) break } else { t.Logf("Attempt %d/%d: IAM policy binding not yet effective, waiting...", attempt, maxAttempts) } if attempt < maxAttempts { time.Sleep(1 * time.Second) } else { t.Errorf("IAM policy binding verification failed after %d attempts. The role may not have been granted correctly.", maxAttempts) return false } } // Grant "Customer Service Account" permission to list Compute Engine instances t.Logf("Granting roles/compute.viewer to 'Customer Service Account' at project level") err = grantProjectIAMRole(ctx, crmService, state.projectID, state.customerServiceAccountEmail, "roles/compute.viewer") if err != nil { t.Errorf("Failed to grant roles/compute.viewer role: %v", err) return false } // Create service account keys for authentication t.Log("Creating service account keys...") // Create key for "Our Service Account" ourKey, err := createServiceAccountKey(ctx, iamService, state.projectID, state.ourServiceAccountEmail) if err != nil { t.Errorf("Failed to create key for 'Our Service Account': %v", err) return false } state.ourServiceAccountKey = []byte(ourKey.PrivateKeyData) state.ourServiceAccountKeyID = extractKeyID(ourKey.Name) t.Logf("Created key for 'Our Service Account': %s", state.ourServiceAccountKeyID) // Create key for "Customer Service Account" customerKey, err := createServiceAccountKey(ctx, iamService, state.projectID, state.customerServiceAccountEmail) if err != nil { t.Errorf("Failed to create key for 'Customer Service Account': %v", err) return false } state.customerServiceAccountKey = []byte(customerKey.PrivateKeyData) state.customerServiceAccountKeyID = extractKeyID(customerKey.Name) t.Logf("Created key for 'Customer Service Account': %s", state.customerServiceAccountKeyID) // Verify permission is actually effective by attempting GenerateAccessToken // This is different from just checking the IAM policy exists - it verifies enforcement t.Log("Verifying permission is actually effective by attempting GenerateAccessToken...") keyData, err := base64.StdEncoding.DecodeString(string(state.ourServiceAccountKey)) if err != nil { t.Errorf("Failed to decode service account key for verification: %v", err) return false } maxAttempts = 60 // Allow more time for enforcement for attempt := 1; attempt <= maxAttempts; attempt++ { // Create credentials from "Our Service Account" key testCreds, err := authcredentials.NewCredentialsFromJSON(authcredentials.ServiceAccount, keyData, &authcredentials.DetectOptions{Scopes: []string{iam.CloudPlatformScope}}) if err != nil { t.Errorf("Failed to create credentials for verification: %v", err) return false } testTokenSource := oauth2adapt.TokenSourceFromTokenProvider(testCreds) // Create IAM Credentials client testClient, err := credentials.NewIamCredentialsClient(ctx, option.WithTokenSource(testTokenSource)) if err != nil { t.Errorf("Failed to create IAM Credentials client for verification: %v", err) return false } // Attempt to generate a token to verify the permission is actually effective testReq := &credentialspb.GenerateAccessTokenRequest{ Name: fmt.Sprintf("projects/-/serviceAccounts/%s", state.customerServiceAccountEmail), Scope: []string{"https://www.googleapis.com/auth/cloud-platform"}, } _, err = testClient.GenerateAccessToken(ctx, testReq) testClient.Close() if err == nil { t.Logf("✓ Permission is actually effective after %d attempt(s)", attempt) break } if attempt < maxAttempts { t.Logf("Attempt %d/%d: Permission not yet effective, error: %v, waiting...", attempt, maxAttempts, err) time.Sleep(2 * time.Second) } else { t.Errorf("Permission verification failed after %d attempts. The permission may not be enforced yet. Last error: %v", maxAttempts, err) return false } } return true } func testOurServiceAccountDirectAuth(t *testing.T, ctx context.Context, state *testState) { t.Log("Test 1: Authenticating as 'Our Service Account' directly") // Decode the service account key keyData, err := base64.StdEncoding.DecodeString(string(state.ourServiceAccountKey)) if err != nil { t.Errorf("Failed to decode service account key: %v", err) return } // Create credentials from the key creds, err := authcredentials.NewCredentialsFromJSON(authcredentials.ServiceAccount, keyData, &authcredentials.DetectOptions{Scopes: compute.DefaultAuthScopes()}) if err != nil { t.Logf("Key data: %s", string(keyData)) t.Errorf("Failed to create credentials from key: %v", err) return } tokenSource := oauth2adapt.TokenSourceFromTokenProvider(creds) // Create Compute Engine client using these credentials client, err := compute.NewInstancesRESTClient(ctx, option.WithTokenSource(tokenSource)) if err != nil { t.Errorf("Failed to create Compute client: %v", err) return } defer client.Close() // Attempt to list instances - this should fail with permission denied zone := os.Getenv("GCP_ZONE") if zone == "" { zone = "us-central1-a" // Default zone } req := &computepb.ListInstancesRequest{ Project: state.projectID, Zone: zone, } it := client.List(ctx, req) _, err = it.Next() // We expect a permission error if err == nil { t.Error("Expected permission denied error, but listing succeeded") return } // Check if it's a permission error var apiErr *apierror.APIError if errors.As(err, &apiErr) { if apiErr.HTTPCode() == http.StatusForbidden || apiErr.GRPCStatus().Code().String() == "PermissionDenied" { t.Logf("✓ Correctly received permission denied error: %v", err) return } t.Errorf("Expected permission denied error, got: %v", err) return } // Also check for googleapi.Error var gErr *googleapi.Error if errors.As(err, &gErr) { if gErr.Code == http.StatusForbidden { t.Logf("✓ Correctly received permission denied error: %v", err) return } t.Errorf("Expected permission denied error, got: %v", err) return } t.Errorf("Expected permission denied error, got unexpected error: %v", err) } func testCustomerServiceAccountDirectAuth(t *testing.T, ctx context.Context, state *testState) { t.Log("Test 2: Authenticating as 'Customer Service Account' directly") // Decode the service account key keyData, err := base64.StdEncoding.DecodeString(string(state.customerServiceAccountKey)) if err != nil { t.Errorf("Failed to decode service account key: %v", err) return } // Create credentials from the key creds, err := authcredentials.NewCredentialsFromJSON(authcredentials.ServiceAccount, keyData, &authcredentials.DetectOptions{Scopes: compute.DefaultAuthScopes()}) if err != nil { t.Errorf("Failed to create credentials from key: %v", err) return } tokenSource := oauth2adapt.TokenSourceFromTokenProvider(creds) // Create Compute Engine client using these credentials client, err := compute.NewInstancesRESTClient(ctx, option.WithTokenSource(tokenSource)) if err != nil { t.Errorf("Failed to create Compute client: %v", err) return } defer client.Close() // Attempt to list instances - this should succeed zone := os.Getenv("GCP_ZONE") if zone == "" { zone = "us-central1-a" // Default zone } req := &computepb.ListInstancesRequest{ Project: state.projectID, Zone: zone, } it := client.List(ctx, req) _, err = it.Next() if err != nil { t.Errorf("Expected to successfully list instances, but got error: %v", err) return } t.Log("✓ Successfully listed instances as 'Customer Service Account'") } func testImpersonation(t *testing.T, ctx context.Context, state *testState) { t.Log("Test 3: Authenticating as 'Our Service Account' and impersonating 'Customer Service Account'") // Decode the "Our Service Account" key keyData, err := base64.StdEncoding.DecodeString(string(state.ourServiceAccountKey)) if err != nil { t.Errorf("Failed to decode service account key: %v", err) return } // Create credentials from "Our Service Account" key creds, err := authcredentials.NewCredentialsFromJSON(authcredentials.ServiceAccount, keyData, &authcredentials.DetectOptions{Scopes: []string{iam.CloudPlatformScope}}) if err != nil { t.Errorf("Failed to create credentials from key: %v", err) return } tokenSource := oauth2adapt.TokenSourceFromTokenProvider(creds) // Create IAM Credentials client using "Our Service Account" credentials iamCredsClient, err := credentials.NewIamCredentialsClient(ctx, option.WithTokenSource(tokenSource)) if err != nil { t.Errorf("Failed to create IAM Credentials client: %v", err) return } defer iamCredsClient.Close() // Generate access token for "Customer Service Account" for impersonating it generateTokenReq := &credentialspb.GenerateAccessTokenRequest{ Name: fmt.Sprintf("projects/-/serviceAccounts/%s", state.customerServiceAccountEmail), Scope: compute.DefaultAuthScopes(), } tokenResp, err := iamCredsClient.GenerateAccessToken(ctx, generateTokenReq) if err != nil { t.Errorf("Failed to generate access token for impersonated service account: %v", err) return } // Create Compute Engine client using the impersonated token impersonatedTS := oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: tokenResp.GetAccessToken(), }) client, err := compute.NewInstancesRESTClient(ctx, option.WithTokenSource(impersonatedTS)) if err != nil { t.Errorf("Failed to create Compute client: %v", err) return } defer client.Close() // Attempt to list instances - this should succeed zone := os.Getenv("GCP_ZONE") if zone == "" { zone = "us-central1-a" // Default zone } req := &computepb.ListInstancesRequest{ Project: state.projectID, Zone: zone, } it := client.List(ctx, req) _, err = it.Next() if err != nil { t.Errorf("Expected to successfully list instances via impersonation, but got error: %v", err) return } t.Log("✓ Successfully listed instances via impersonation") } func teardownTest(t *testing.T, ctx context.Context, iamService *iam.Service, crmService *cloudresourcemanager.Service, state *testState) { // Delete service account keys first (required before deleting service accounts) if state.ourServiceAccountKeyID != "" { t.Logf("Deleting key for 'Our Service Account': %s", state.ourServiceAccountKeyID) keyResource := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", state.projectID, state.ourServiceAccountEmail, state.ourServiceAccountKeyID) _, err := iamService.Projects.ServiceAccounts.Keys.Delete(keyResource).Do() if err != nil { var gErr *googleapi.Error if errors.As(err, &gErr) && gErr.Code == http.StatusNotFound { t.Log("Key already deleted or not found") } else { t.Logf("Failed to delete key (non-fatal): %v", err) } } } if state.customerServiceAccountKeyID != "" { t.Logf("Deleting key for 'Customer Service Account': %s", state.customerServiceAccountKeyID) keyResource := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", state.projectID, state.customerServiceAccountEmail, state.customerServiceAccountKeyID) _, err := iamService.Projects.ServiceAccounts.Keys.Delete(keyResource).Do() if err != nil { var gErr *googleapi.Error if errors.As(err, &gErr) && gErr.Code == http.StatusNotFound { t.Log("Key already deleted or not found") } else { t.Logf("Failed to delete key (non-fatal): %v", err) } } } // Delete service accounts if state.customerServiceAccountEmail != "" { t.Logf("Deleting 'Customer Service Account': %s", state.customerServiceAccountEmail) saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", state.projectID, state.customerServiceAccountEmail) _, err := iamService.Projects.ServiceAccounts.Delete(saResource).Do() if err != nil { var gErr *googleapi.Error if errors.As(err, &gErr) && (gErr.Code == http.StatusNotFound || gErr.Code == http.StatusForbidden) { t.Log("Service account already deleted or not found") } else { t.Logf("Failed to delete service account (non-fatal): %v", err) } } } if state.ourServiceAccountEmail != "" { t.Logf("Deleting 'Our Service Account': %s", state.ourServiceAccountEmail) saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", state.projectID, state.ourServiceAccountEmail) _, err := iamService.Projects.ServiceAccounts.Delete(saResource).Do() if err != nil { var gErr *googleapi.Error if errors.As(err, &gErr) && (gErr.Code == http.StatusNotFound || gErr.Code == http.StatusForbidden) { t.Log("Service account already deleted or not found") } else { t.Logf("Failed to delete service account (non-fatal): %v", err) } } } } // Helper functions func createServiceAccount(ctx context.Context, iamService *iam.Service, projectID, accountID, displayName string) (*iam.ServiceAccount, error) { projectResource := fmt.Sprintf("projects/%s", projectID) req := &iam.CreateServiceAccountRequest{ AccountId: accountID, ServiceAccount: &iam.ServiceAccount{ DisplayName: displayName, Description: fmt.Sprintf("Test service account created for integration testing: %s", accountID), }, } sa, err := iamService.Projects.ServiceAccounts.Create(projectResource, req).Do() if err != nil { var gErr *googleapi.Error if errors.As(err, &gErr) && gErr.Code == http.StatusConflict { // Service account already exists, try to get it saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", accountID, projectID) saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail) return iamService.Projects.ServiceAccounts.Get(saResource).Do() } return nil, fmt.Errorf("failed to create service account: %w", err) } return sa, nil } func grantServiceAccountTokenCreator(ctx context.Context, iamService *iam.Service, projectID, targetSAEmail, impersonatorSAEmail string) error { saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, targetSAEmail) // Get current IAM policy policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).Do() if err != nil { return fmt.Errorf("failed to get IAM policy: %w", err) } if policy == nil { policy = &iam.Policy{} } if policy.Bindings == nil { policy.Bindings = make([]*iam.Binding, 0) } // Find or create the serviceAccountTokenCreator binding role := "roles/iam.serviceAccountTokenCreator" member := fmt.Sprintf("serviceAccount:%s", impersonatorSAEmail) roleFound := false for i, binding := range policy.Bindings { if binding.Role == role { // Check if member already exists memberFound := slices.Contains(binding.Members, member) if !memberFound { policy.Bindings[i].Members = append(policy.Bindings[i].Members, member) } roleFound = true break } } if !roleFound { policy.Bindings = append(policy.Bindings, &iam.Binding{ Role: role, Members: []string{member}, }) } // Set the updated policy _, err = iamService.Projects.ServiceAccounts.SetIamPolicy(saResource, &iam.SetIamPolicyRequest{ Policy: policy, }).Do() return err } // verifyServiceAccountExists verifies that a service account exists. // Returns (true, nil) if the service account exists, (false, nil) if not found, or (false, error) on error. func verifyServiceAccountExists(ctx context.Context, iamService *iam.Service, projectID, saEmail string) (bool, error) { saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail) _, err := iamService.Projects.ServiceAccounts.Get(saResource).Do() if err != nil { var gErr *googleapi.Error if errors.As(err, &gErr) && gErr.Code == http.StatusNotFound { return false, nil } return false, fmt.Errorf("failed to get service account: %w", err) } return true, nil } // verifyServiceAccountTokenCreatorBinding verifies that the impersonator service account // has the serviceAccountTokenCreator role on the target service account. // Returns (true, nil) if verified, (false, nil) if not yet effective, or (false, error) on error. func verifyServiceAccountTokenCreatorBinding(ctx context.Context, iamService *iam.Service, projectID, targetSAEmail, impersonatorSAEmail string) (bool, error) { saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, targetSAEmail) // Get current IAM policy policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).Do() if err != nil { return false, fmt.Errorf("failed to get IAM policy: %w", err) } if policy == nil || policy.Bindings == nil { return false, nil } role := "roles/iam.serviceAccountTokenCreator" member := fmt.Sprintf("serviceAccount:%s", impersonatorSAEmail) // Check if the binding exists for _, binding := range policy.Bindings { if binding.Role == role { // Check if the impersonator service account is in the members list if slices.Contains(binding.Members, member) { return true, nil } } } return false, nil } func createServiceAccountKey(ctx context.Context, iamService *iam.Service, projectID, saEmail string) (*iam.ServiceAccountKey, error) { saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail) req := &iam.CreateServiceAccountKeyRequest{} key, err := iamService.Projects.ServiceAccounts.Keys.Create(saResource, req).Do() if err != nil { return nil, fmt.Errorf("failed to create service account key: %w", err) } return key, nil } func extractKeyID(keyName string) string { // Key name format: projects/{project}/serviceAccounts/{email}/keys/{keyId} parts := strings.Split(keyName, "/") if len(parts) > 0 { return parts[len(parts)-1] } return "" } func grantProjectIAMRole(ctx context.Context, crmService *cloudresourcemanager.Service, projectID, saEmail, role string) error { member := fmt.Sprintf("serviceAccount:%s", saEmail) // Get current IAM policy policy, err := crmService.Projects.GetIamPolicy(projectID, &cloudresourcemanager.GetIamPolicyRequest{}).Do() if err != nil { return fmt.Errorf("failed to get IAM policy: %w", err) } if policy == nil { policy = &cloudresourcemanager.Policy{} } if policy.Bindings == nil { policy.Bindings = make([]*cloudresourcemanager.Binding, 0) } // Find or create the binding for the role roleFound := false for i, binding := range policy.Bindings { if binding.Role == role { // Check if member already exists memberFound := slices.Contains(binding.Members, member) if !memberFound { policy.Bindings[i].Members = append(policy.Bindings[i].Members, member) } roleFound = true break } } if !roleFound { policy.Bindings = append(policy.Bindings, &cloudresourcemanager.Binding{ Role: role, Members: []string{member}, }) } // Set the updated policy _, err = crmService.Projects.SetIamPolicy(projectID, &cloudresourcemanager.SetIamPolicyRequest{ Policy: policy, }).Do() return err } ================================================ FILE: sources/gcp/integration-tests/spanner-database_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "log" "os" "testing" database "cloud.google.com/go/spanner/admin/database/apiv1" "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" instance "cloud.google.com/go/spanner/admin/instance/apiv1" "github.com/googleapis/gax-go/v2/apierror" "google.golang.org/grpc/codes" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestSpannerDatabase(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() instanceName := "integration-test-instance" databaseName := "integration-test-database" ctx := t.Context() // Create a new Admin Database instanceClient instanceClient, err := instance.NewInstanceAdminClient(ctx) if err != nil { t.Fatalf("Failed to create Spanner client: %v", err) } defer instanceClient.Close() databaseClient, err := database.NewDatabaseAdminClient(ctx) if err != nil { t.Fatalf("Failed to create Spanner database client: %v", err) } defer databaseClient.Close() t.Run("Setup", func(t *testing.T) { err := setupSpannerInstance(ctx, instanceClient, projectID, instanceName) if err != nil { t.Fatalf("Failed to setup Spanner Instance: %v", err) } err = setupSpannerDatabase(ctx, databaseClient, projectID, instanceName, databaseName) if err != nil { t.Fatalf("Failed to setup Spanner Database: %v", err) } }) t.Run("Run", func(t *testing.T) { linker := gcpshared.NewLinker() gcpHTTPCliWithOtel, err := gcpshared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("Failed to create gcp http client with otel") } adapter, err := dynamic.MakeAdapter(gcpshared.SpannerDatabase, linker, gcpHTTPCliWithOtel, sdpcache.NewNoOpCache(), []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to make adapter for spanner database") } query := shared.CompositeLookupKey(instanceName, databaseName) sdpItem, err := adapter.Get(ctx, projectID, query, true) if err != nil { t.Fatalf("Failed to get item: %v", err) } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != query { t.Fatalf("Expected unique attribute value to be %s, got %s", query, uniqueAttrValue) } sdpItems, err := adapter.(dynamic.SearchableAdapter).Search(ctx, projectID, instanceName, true) if err != nil { t.Fatalf("Failed to use spanner database adapter to search: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one database, got %d", len(sdpItems)) } }) t.Run("Teardown", func(t *testing.T) { err := deleteSpannerDatabase(ctx, databaseClient, projectID, instanceName, databaseName) if err != nil { t.Fatalf("Failed to teardown Spanner Database: %v", err) } err = deleteSpannerInstance(ctx, instanceClient, projectID, instanceName) if err != nil { t.Fatalf("Failed to teardown Spanner Instance: %v", err) } }) } func setupSpannerDatabase(ctx context.Context, client *database.DatabaseAdminClient, projectID, instanceName, databaseName string) error { // Create the database op, err := client.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{ Parent: "projects/" + projectID + "/instances/" + instanceName, CreateStatement: "CREATE DATABASE `" + databaseName + "`", }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.GRPCStatus().Proto().GetCode() == int32(codes.AlreadyExists) { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } // Wait for the operation to complete if _, err := op.Wait(ctx); err != nil { return err } return nil } func deleteSpannerDatabase(ctx context.Context, client *database.DatabaseAdminClient, projectID, instanceName, databaseName string) error { // Delete the database err := client.DropDatabase(ctx, &databasepb.DropDatabaseRequest{ Database: "projects/" + projectID + "/instances/" + instanceName + "/databases/" + databaseName, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.GRPCStatus().Proto().GetCode() == int32(codes.NotFound) { log.Printf("Failed to find resource to delete: %v", err) return nil } return fmt.Errorf("failed to delete resource: %w", err) } log.Printf("Spanner database %s deleted successfully", databaseName) return nil } ================================================ FILE: sources/gcp/integration-tests/spanner-instance_test.go ================================================ package integrationtests import ( "context" "errors" "fmt" "os" "testing" instance "cloud.google.com/go/spanner/admin/instance/apiv1" "cloud.google.com/go/spanner/admin/instance/apiv1/instancepb" "github.com/googleapis/gax-go/v2/apierror" log "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/gcp/dynamic" "github.com/overmindtech/cli/sources/gcp/shared" ) func TestSpannerInstance(t *testing.T) { projectID := os.Getenv("GCP_PROJECT_ID") if projectID == "" { t.Skip("GCP_PROJECT_ID environment variable not set") } t.Parallel() instanceName := "integration-test-instance" ctx := t.Context() // Create a new Admin Database client client, err := instance.NewInstanceAdminClient(ctx) if err != nil { t.Fatalf("Failed to create Spanner client: %v", err) } defer client.Close() t.Run("Setup", func(t *testing.T) { ctx := t.Context() err := setupSpannerInstance(ctx, client, projectID, instanceName) if err != nil { t.Fatalf("Failed to setup Spanner Instance: %v", err) } }) t.Run("Run", func(t *testing.T) { ctx := t.Context() linker := shared.NewLinker() gcpHTTPCliWithOtel, err := shared.GCPHTTPClientWithOtel(ctx, "") if err != nil { t.Fatalf("Failed to create gcp http client with otel") } adapter, err := dynamic.MakeAdapter(shared.SpannerInstance, linker, gcpHTTPCliWithOtel, sdpcache.NewNoOpCache(), []shared.LocationInfo{shared.NewProjectLocation(projectID)}) if err != nil { t.Fatalf("Failed to make adapter for spanner instance: %v", err) } sdpItem, err := adapter.Get(ctx, projectID, instanceName, true) if err != nil { t.Fatalf("Failed to get item: %v", err) } uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != instanceName { t.Fatalf("Expected unique attribute value to be %s, got %s", instanceName, uniqueAttrValue) } // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, projectID, true) if err != nil { t.Fatalf("Failed to list compute instances: %v", err) } if len(sdpItems) < 1 { t.Fatalf("Expected at least one compute instance, got %d", len(sdpItems)) } var found bool for _, item := range sdpItems { if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == instanceName { found = true break } } if !found { t.Fatalf("Expected to find instance %s in the list of compute instances", instanceName) } }) t.Run("Teardown", func(t *testing.T) { ctx := t.Context() err := deleteSpannerInstance(ctx, client, projectID, instanceName) if err != nil { t.Fatalf("Failed to delete Spanner Instance: %v", err) } }) } func deleteSpannerInstance(ctx context.Context, client *instance.InstanceAdminClient, projectID, instanceName string) error { return client.DeleteInstance(ctx, &instancepb.DeleteInstanceRequest{ Name: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName), }) } func setupSpannerInstance(ctx context.Context, client *instance.InstanceAdminClient, projectID, instanceName string) error { // Implement the setup logic for Spanner Instance setup here op, err := client.CreateInstance(ctx, &instancepb.CreateInstanceRequest{ Parent: "projects/" + projectID, InstanceId: instanceName, Instance: &instancepb.Instance{ Name: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceName), Config: fmt.Sprintf("projects/%s/instanceConfigs/eur3", projectID), DisplayName: instanceName, NodeCount: 1, }, }) if err != nil { var apiErr *apierror.APIError if errors.As(err, &apiErr) && apiErr.GRPCStatus().Proto().GetCode() == int32(codes.AlreadyExists) { log.Printf("Resource already exists in project, skipping creation: %v", err) return nil } return fmt.Errorf("failed to create resource: %w", err) } if _, err := op.Wait(ctx); err != nil { return fmt.Errorf("failed to wait for image creation operation: %w", err) } log.Printf("Spanner instance %s created successfully in project %s", instanceName, projectID) return nil } ================================================ FILE: sources/gcp/main.go ================================================ package main import ( _ "go.uber.org/automaxprocs" "github.com/overmindtech/cli/sources/gcp/cmd" ) func main() { cmd.Execute() } ================================================ FILE: sources/gcp/manual/README.md ================================================ # GCP Manual Adapters This directory contains manually implemented GCP adapters that cannot be generated using the dynamic adapter framework due to their complex API response patterns or resource relationships. ## When to Use Manual Adapters **Prefer Dynamic Adapters**: Always use the [dynamic adapter framework](../../dynamic/adapters/README.md) when possible. Dynamic adapters are automatically generated from GCP API specifications and are easier to maintain. **Create Manual Adapters Only When**: 1. **Non-standard API Response Format**: The GCP API response doesn't follow the general pattern where resource names or attributes reference different types of resources that require manual handling for linked item queries. 2. **Complex Resource Relationships**: The adapter needs to manually parse and link to multiple different resource types based on the API response content. ## Examples of Manual Adapter Use Cases ### Non-standard API Response Format **BigQuery Dataset** (`big-query-dataset.go`): - Uses dot notation for resource references (`projectID:datasetID`) - Requires manual parsing of the `FullID` field to extract dataset ID - Complex access control parsing with multiple entity types **BigQuery Table** (`big-query-table.go`): - Uses dot notation for composite keys (`projectID:datasetID.tableID`) - Requires manual parsing and splitting of the `FullID` field - Multiple connection ID formats need manual parsing (`projectId.locationId;connectionId` vs `projects/projectId/locations/locationId/connections/connectionId`) ### Attributes Referencing Different Resource Types **Logging Sink** (`logging-sink.go`): - The `destination` field can reference multiple different resource types: - Storage buckets: `storage.googleapis.com/[BUCKET]` - BigQuery datasets: `bigquery.googleapis.com/projects/[PROJECT]/datasets/[DATASET]` - Pub/Sub topics: `pubsub.googleapis.com/projects/[PROJECT]/topics/[TOPIC]` - Logging buckets: `logging.googleapis.com/projects/[PROJECT]/locations/[LOCATION]/buckets/[BUCKET]` - Requires manual parsing and conditional linking based on the destination format ## Implementation Guidelines ### For Detailed Implementation Rules Refer to the [cursor rules](.cursor/rules/gcp-manual-adapter-creation.mdc) for comprehensive implementation patterns, examples, and best practices. ### Key Implementation Requirements 1. **Follow Naming Conventions**: - File names: `{api}-{resource}.go` (e.g., `compute-subnetwork.go`, `bigquery-table.go`, `logging-sink.go`) - Struct names: `{resourceName}Wrapper` (e.g., `computeSubnetworkWrapper`, `bigQueryTableWrapper`) - Constructor: `New{ResourceName}` (e.g., `NewComputeSubnetwork`, `NewBigQueryTable`) 2. **Implement Required Methods**: - `IAMPermissions()` - List specific GCP API permissions - `PredefinedRole()` - Most restrictive GCP predefined role - `PotentialLinks()` - All possible linked resource types - `TerraformMappings()` - Terraform registry mappings - `GetLookups()` / `SearchLookups()` - Query parameter definitions 3. **Handle Complex Resource Linking**: - Parse non-standard API response formats - Extract resource identifiers from various formats - Create appropriate linked item queries 4. **Include Comprehensive Tests**: - Unit tests for all methods - Static tests for linked item queries - Mock-based testing with gomock - Interface compliance tests ## Code Review Checklist When reviewing PRs for manual adapters, ensure: ### ✅ Fundamentals Coverage - [ ] Unit tests cover all adapter methods (Get, List, Search if applicable) - [ ] Static tests validate linked item queries using `shared.RunStaticTests` - [ ] Mock expectations are properly set up with gomock - [ ] Interface compliance is tested (ListableWrapper, SearchableWrapper, etc.) ### ✅ Terraform Integration - [ ] Terraform mappings reference official Terraform registry URLs - [ ] Terraform method (GET vs SEARCH) matches adapter capabilities - [ ] Terraform query map uses correct resource attribute names ### ✅ Naming and Structure - [ ] File name follows `{api}-{resource}.go` convention (e.g., `compute-subnetwork.go`) - [ ] Struct and function names follow Go conventions - [ ] Package imports are properly organized ### ✅ Linked Item Queries - [ ] Example values in tests match actual GCP resource formats - [ ] Scopes for linked item queries are correct (verify with linked resource documentation) - [ ] Linked item queries are appropriately defined - [ ] All possible resource references are handled (no missing cases) ### ✅ Documentation and References - [ ] GCP API documentation URLs are included in comments - [ ] Resource linking explanations are documented - [ ] Complex parsing logic is well-commented - [ ] Official GCP reference links are provided for linked resources ### ✅ Error Handling - [ ] Proper error wrapping with `gcpshared.QueryError` - [ ] Input validation for parsed values - [ ] Graceful handling of malformed API responses ## Testing Examples ### Static Tests for Linked Item Queries ```go t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-dataset", ExpectedScope: "test-project-id", }, // ... more test cases } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) ``` ### Mock Setup for Complex APIs ```go mockClient := mocks.NewMockBigQueryTableClient(ctrl) mockClient.EXPECT().Get(ctx, projectID, datasetID, tableID).Return( createTableMetadata(projectID, datasetID, tableID, connectionID), nil) ``` ## Common Patterns ### Parsing Composite IDs ```go // BigQuery format: projectID:datasetID.tableID parts := strings.Split(strings.TrimPrefix(metadata.FullID, b.ProjectID()+":"), ".") if len(parts) != 2 { return nil, gcpshared.QueryError(fmt.Errorf("invalid table full ID: %s", metadata.FullID), scope, b.Type()) } ``` ### Conditional Resource Linking ```go if sink.GetDestination() != "" { switch { case strings.HasPrefix(sink.GetDestination(), "storage.googleapis.com"): // Handle storage bucket linking case strings.HasPrefix(sink.GetDestination(), "bigquery.googleapis.com"): // Handle BigQuery dataset linking // ... more cases } } ``` ### Path Parameter Extraction ```go values := gcpshared.ExtractPathParams(keyName, "locations", "keyRings", "cryptoKeys") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { // Use extracted values for linking } ``` ## Getting Help - **Implementation Details**: See [cursor rules](.cursor/rules/gcp-manual-adapter-creation.mdc) - **Dynamic Adapters**: See [dynamic adapter README](../../dynamic/adapters/README.md) - **General Source Adapters**: See [sources README](../../README.md) - **GCP API Documentation**: Always reference official GCP documentation for API specifics ## Related Files - **Cursor Rules**: `.cursor/rules/gcp-manual-adapter-creation.mdc` - Comprehensive implementation guide - **Shared Utilities**: `../../shared/` - Common utilities and patterns - **GCP Shared**: `../shared/` - GCP-specific utilities and base structs - **Test Utilities**: `../../shared/testing.go` - Testing helpers and patterns ================================================ FILE: sources/gcp/manual/adapters.go ================================================ package manual import ( "context" "fmt" "cloud.google.com/go/bigquery" certificatemanager "cloud.google.com/go/certificatemanager/apiv1" compute "cloud.google.com/go/compute/apiv1" iamAdmin "cloud.google.com/go/iam/admin/apiv1" logging "cloud.google.com/go/logging/apiv2" "cloud.google.com/go/storage" "golang.org/x/oauth2" "google.golang.org/api/option" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/shared" ) // Adapters returns a slice of discovery.Adapter instances for GCP Source. // It initializes GCP clients if initGCPClients is true, and creates adapters for the specified locations. // Otherwise, it uses nil clients, which is useful for enumerating adapters for documentation purposes. func Adapters(ctx context.Context, projectLocations, regionLocations, zoneLocations []shared.LocationInfo, tokenSource *oauth2.TokenSource, initGCPClients bool, cache sdpcache.Cache) ([]discovery.Adapter, error) { var err error var ( instanceCli *compute.InstancesClient addressCli *compute.AddressesClient autoscalerCli *compute.AutoscalersClient computeImagesCli *compute.ImagesClient computeForwardingCli *compute.ForwardingRulesClient computeHealthCheckCli *compute.HealthChecksClient computeReservationCli *compute.ReservationsClient computeSecurityPolicyCli *compute.SecurityPoliciesClient computeSnapshotCli *compute.SnapshotsClient computeInstantSnapshotCli *compute.InstantSnapshotsClient computeMachineImageCli *compute.MachineImagesClient backendServiceCli *compute.BackendServicesClient instanceGroupCli *compute.InstanceGroupsClient instanceGroupManagerCli *compute.InstanceGroupManagersClient regionInstanceGroupManagerCli *compute.RegionInstanceGroupManagersClient diskCli *compute.DisksClient iamServiceAccountKeyCli *iamAdmin.IamClient iamServiceAccountCli *iamAdmin.IamClient certificateManagerCli *certificatemanager.Client kmsLoader *shared.CloudKMSAssetLoader bigQueryDatasetCli *bigquery.Client loggingConfigCli *logging.ConfigClient nodeGroupCli *compute.NodeGroupsClient nodeTemplateCli *compute.NodeTemplatesClient regionBackendServiceCli *compute.RegionBackendServicesClient regionHealthCheckCli *compute.RegionHealthChecksClient storageCli *storage.Client ) if initGCPClients { opts := []option.ClientOption{} if tokenSource != nil { opts = append(opts, option.WithTokenSource(*tokenSource)) } instanceCli, err = compute.NewInstancesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute instances client: %w", err) } addressCli, err = compute.NewAddressesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute addresses client: %w", err) } autoscalerCli, err = compute.NewAutoscalersRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute autoscalers client: %w", err) } computeImagesCli, err = compute.NewImagesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute images client: %w", err) } computeForwardingCli, err = compute.NewForwardingRulesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute forwarding rules client: %w", err) } computeHealthCheckCli, err = compute.NewHealthChecksRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute health checks client: %w", err) } computeReservationCli, err = compute.NewReservationsRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute reservations client: %w", err) } computeSecurityPolicyCli, err = compute.NewSecurityPoliciesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute security policies client: %w", err) } computeSnapshotCli, err = compute.NewSnapshotsRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute snapshots client: %w", err) } computeInstantSnapshotCli, err = compute.NewInstantSnapshotsRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute instant snapshots client: %w", err) } computeMachineImageCli, err = compute.NewMachineImagesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute machine images client: %w", err) } backendServiceCli, err = compute.NewBackendServicesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute backend services client: %w", err) } instanceGroupCli, err = compute.NewInstanceGroupsRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute instance groups client: %w", err) } instanceGroupManagerCli, err = compute.NewInstanceGroupManagersRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute instance group managers client: %w", err) } regionInstanceGroupManagerCli, err = compute.NewRegionInstanceGroupManagersRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute region instance group managers client: %w", err) } diskCli, err = compute.NewDisksRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute disks client: %w", err) } // IAM iamServiceAccountKeyCli, err = iamAdmin.NewIamClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create IAM service account key client: %w", err) } iamServiceAccountCli, err = iamAdmin.NewIamClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create IAM service account client: %w", err) } // Certificate Manager certificateManagerCli, err = certificatemanager.NewClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create certificate manager client: %w", err) } // Extract project ID from projectLocations for BigQuery client initialization. // // IMPORTANT: The project ID passed to bigquery.NewClient() is used for: // 1. Billing - all BigQuery operations are billed to this project // 2. Client initialization - required parameter, cannot be omitted // // This does NOT restrict which projects we can query. All actual API operations // in our codebase explicitly specify the target project using: // - DatasetInProject(projectID, datasetID) for Get operations // - dsIterator.ProjectID = projectID for List operations // // Therefore, using the first project ID here allows the adapter to query // resources across ALL configured projects. The only consideration is billing: // if projects have separate billing accounts, operations will be billed to // the first project. If all projects share billing, this doesn't matter. // // We use the first project ID rather than bigquery.DetectProjectID because: // - Auto-detection fails in containerized/Kubernetes environments // - We have explicit project IDs available in projectLocations // - Explicit configuration is more reliable than environment detection var bigQueryProjectID string for _, loc := range projectLocations { if loc.ProjectID != "" { bigQueryProjectID = loc.ProjectID break } } if bigQueryProjectID == "" { return nil, fmt.Errorf("at least one project location with a valid project ID is required to create BigQuery client") } bigQueryDatasetCli, err = bigquery.NewClient(ctx, bigQueryProjectID, opts...) if err != nil { return nil, fmt.Errorf("failed to create bigquery client: %w", err) } // Create KMS asset loader (uses Cloud Asset API for bulk loading) httpClient, err := shared.GCPHTTPClientWithOtel(ctx, "") if err != nil { return nil, fmt.Errorf("failed to create HTTP client for KMS loader: %w", err) } kmsLoader = shared.NewCloudKMSAssetLoader(httpClient, bigQueryProjectID, cache, "gcp-source", projectLocations) loggingConfigCli, err = logging.NewConfigClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create logging config client: %w", err) } nodeGroupCli, err = compute.NewNodeGroupsRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute node groups client: %w", err) } nodeTemplateCli, err = compute.NewNodeTemplatesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute node templates client: %w", err) } regionBackendServiceCli, err = compute.NewRegionBackendServicesRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute region backend services client: %w", err) } regionHealthCheckCli, err = compute.NewRegionHealthChecksRESTClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create compute region health checks client: %w", err) } storageCli, err = storage.NewClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create storage client: %w", err) } } var adapters []discovery.Adapter // Multi-scope regional adapters (one adapter per type handling all regions) if len(regionLocations) > 0 { adapters = append(adapters, sources.WrapperToAdapter(NewComputeAddress(shared.NewComputeAddressClient(addressCli), regionLocations), cache), sources.WrapperToAdapter(NewComputeForwardingRule(shared.NewComputeForwardingRuleClient(computeForwardingCli), regionLocations), cache), sources.WrapperToAdapter(NewComputeNodeTemplate(shared.NewComputeNodeTemplateClient(nodeTemplateCli), regionLocations), cache), sources.WrapperToAdapter(NewComputeRegionInstanceGroupManager(shared.NewRegionInstanceGroupManagerClient(regionInstanceGroupManagerCli), regionLocations), cache), ) } // Multi-scope zonal adapters (one adapter per type handling all zones) if len(zoneLocations) > 0 { adapters = append(adapters, sources.WrapperToAdapter(NewComputeInstance(shared.NewComputeInstanceClient(instanceCli), zoneLocations), cache), sources.WrapperToAdapter(NewComputeAutoscaler(shared.NewComputeAutoscalerClient(autoscalerCli), zoneLocations), cache), sources.WrapperToAdapter(NewComputeInstanceGroup(shared.NewComputeInstanceGroupsClient(instanceGroupCli), zoneLocations), cache), sources.WrapperToAdapter(NewComputeInstanceGroupManager(shared.NewComputeInstanceGroupManagerClient(instanceGroupManagerCli), zoneLocations), cache), sources.WrapperToAdapter(NewComputeReservation(shared.NewComputeReservationClient(computeReservationCli), zoneLocations), cache), sources.WrapperToAdapter(NewComputeInstantSnapshot(shared.NewComputeInstantSnapshotsClient(computeInstantSnapshotCli), zoneLocations), cache), sources.WrapperToAdapter(NewComputeDisk(shared.NewComputeDiskClient(diskCli), zoneLocations), cache), sources.WrapperToAdapter(NewComputeNodeGroup(shared.NewComputeNodeGroupClient(nodeGroupCli), zoneLocations), cache), ) } // Dual-scope adapters (handle both global and regional) if len(projectLocations) > 0 || len(regionLocations) > 0 { adapters = append(adapters, sources.WrapperToAdapter( NewComputeBackendService( shared.NewComputeBackendServiceClient(backendServiceCli), shared.NewComputeRegionBackendServiceClient(regionBackendServiceCli), projectLocations, regionLocations, ), cache, ), sources.WrapperToAdapter( NewComputeHealthCheck( shared.NewComputeHealthCheckClient(computeHealthCheckCli), shared.NewComputeRegionHealthCheckClient(regionHealthCheckCli), projectLocations, regionLocations, ), cache, ), ) } // global - project level - adapters if len(projectLocations) > 0 { adapters = append(adapters, sources.WrapperToAdapter(NewComputeImage(shared.NewComputeImagesClient(computeImagesCli), projectLocations), cache), sources.WrapperToAdapter(NewComputeSecurityPolicy(shared.NewComputeSecurityPolicyClient(computeSecurityPolicyCli), projectLocations), cache), sources.WrapperToAdapter(NewComputeMachineImage(shared.NewComputeMachineImageClient(computeMachineImageCli), projectLocations), cache), sources.WrapperToAdapter(NewComputeSnapshot(shared.NewComputeSnapshotsClient(computeSnapshotCli), projectLocations), cache), sources.WrapperToAdapter(NewIAMServiceAccountKey(shared.NewIAMServiceAccountKeyClient(iamServiceAccountKeyCli), projectLocations), cache), sources.WrapperToAdapter(NewIAMServiceAccount(shared.NewIAMServiceAccountClient(iamServiceAccountCli), projectLocations), cache), sources.WrapperToAdapter(NewCertificateManagerCertificate(shared.NewCertificateManagerCertificateClient(certificateManagerCli), projectLocations), cache), sources.WrapperToAdapter(NewCloudKMSKeyRing(kmsLoader, projectLocations), cache), sources.WrapperToAdapter(NewCloudKMSCryptoKey(kmsLoader, projectLocations), cache), sources.WrapperToAdapter(NewCloudKMSCryptoKeyVersion(kmsLoader, projectLocations), cache), sources.WrapperToAdapter(NewBigQueryDataset(shared.NewBigQueryDatasetClient(bigQueryDatasetCli), projectLocations), cache), sources.WrapperToAdapter(NewBigQueryTable(shared.NewBigQueryTableClient(bigQueryDatasetCli), projectLocations), cache), sources.WrapperToAdapter(NewLoggingSink(shared.NewLoggingConfigClient(loggingConfigCli), projectLocations), cache), sources.WrapperToAdapter(NewBigQueryRoutine(shared.NewBigQueryRoutineClient(bigQueryDatasetCli), projectLocations), cache), sources.WrapperToAdapter(NewStorageBucketIAMPolicy(shared.NewStorageBucketIAMPolicyGetter(storageCli), projectLocations), cache), ) } return adapters, nil } ================================================ FILE: sources/gcp/manual/big-query-dataset.go ================================================ package manual import ( "context" "fmt" "strings" "cloud.google.com/go/bigquery" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var BigQueryDatasetLookupByID = shared.NewItemTypeLookup("id", gcpshared.BigQueryDataset) type BigQueryDatasetWrapper struct { client gcpshared.BigQueryDatasetClient *gcpshared.ProjectBase } // NewBigQueryDataset creates a new bigQueryDatasetWrapper instance. func NewBigQueryDataset(client gcpshared.BigQueryDatasetClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &BigQueryDatasetWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, gcpshared.BigQueryDataset, ), } } func (b BigQueryDatasetWrapper) IAMPermissions() []string { return []string{ "bigquery.datasets.get", } } func (b BigQueryDatasetWrapper) PredefinedRole() string { return "roles/bigquery.metadataViewer" } func (b BigQueryDatasetWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.IAMServiceAccount, gcpshared.CloudKMSCryptoKey, gcpshared.BigQueryDataset, gcpshared.BigQueryConnection, gcpshared.BigQueryModel, gcpshared.BigQueryRoutine, gcpshared.BigQueryTable, ) } func (b BigQueryDatasetWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigquery_dataset.dataset_id", }, // IAM resources for BigQuery Datasets. These are Terraform-only constructs // (no standalone GCP API resource exists). When an IAM binding/member/policy // changes, we resolve it to the parent dataset for blast radius analysis. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_dataset_iam { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigquery_dataset_iam_binding.dataset_id", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigquery_dataset_iam_member.dataset_id", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_bigquery_dataset_iam_policy.dataset_id", }, } } func (b BigQueryDatasetWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BigQueryDatasetLookupByID, } } func (b BigQueryDatasetWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := b.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } metadata, getErr := b.client.Get(ctx, location.ProjectID, queryParts[0]) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, b.Type()) } return b.gcpBigQueryDatasetToItem(metadata, location) } func (b BigQueryDatasetWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { location, err := b.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } items, listErr := b.client.List(ctx, location.ProjectID, func(ctx context.Context, md *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError) { return b.gcpBigQueryDatasetToItem(md, location) }) if listErr != nil { return nil, gcpshared.QueryError(listErr, scope, b.Type()) } return items, nil } func (b BigQueryDatasetWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { location, err := b.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } b.client.ListStream(ctx, location.ProjectID, stream, func(ctx context.Context, md *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError) { item, qerr := b.gcpBigQueryDatasetToItem(md, location) if qerr == nil && item != nil { cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) } return item, qerr }) } func (b BigQueryDatasetWrapper) gcpBigQueryDatasetToItem(metadata *bigquery.DatasetMetadata, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(metadata, "labels") if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), b.Type()) } // The full dataset ID in the form projectID:datasetID. parts := strings.Split(metadata.FullID, ":") if len(parts) != 2 { return nil, gcpshared.QueryError(fmt.Errorf("invalid dataset full ID: %s", metadata.FullID), location.ToScope(), b.Type()) } err = attributes.Set("id", parts[1]) if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), b.Type()) } sdpItem := &sdp.Item{ Type: gcpshared.BigQueryDataset.String(), UniqueAttribute: "id", Attributes: attributes, Scope: location.ToScope(), Tags: metadata.Labels, } // Link to contained models. sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryModel.String(), Method: sdp.QueryMethod_SEARCH, Query: parts[1], Scope: location.ToScope(), }, }) // Link to contained tables. sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryTable.String(), Method: sdp.QueryMethod_SEARCH, Query: parts[1], Scope: location.ToScope(), }, }) // Link to contained routines. sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryRoutine.String(), Method: sdp.QueryMethod_SEARCH, Query: parts[1], Scope: location.ToScope(), }, }) for _, access := range metadata.Access { if access.EntityType == bigquery.GroupEmailEntity || access.EntityType == bigquery.UserEmailEntity || access.EntityType == bigquery.IAMMemberEntity { if access.Entity != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccount.String(), Method: sdp.QueryMethod_GET, Query: access.Entity, Scope: location.ToScope(), }, }) } } if access.Dataset != nil && access.Dataset.Dataset != nil { // Link to the dataset that this access applies to sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Query: access.Dataset.Dataset.DatasetID, Scope: location.ToScope(), }, }) } } if metadata.DefaultEncryptionConfig != nil { // Link to the KMS key used for default encryption values := gcpshared.ExtractPathParams(metadata.DefaultEncryptionConfig.KMSKeyName, "locations", "keyRings", "cryptoKeys") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values...), Scope: location.ProjectID, }, }) } } if metadata.ExternalDatasetReference != nil && metadata.ExternalDatasetReference.Connection != "" { // Link to the external dataset reference // Format: projects/{projectId}/locations/{locationId}/connections/{connectionId} values := gcpshared.ExtractPathParams(metadata.ExternalDatasetReference.Connection, "locations", "connections") if len(values) == 2 && values[0] != "" && values[1] != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryConnection.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values...), Scope: location.ToScope(), }, }) } } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/big-query-dataset_test.go ================================================ package manual_test import ( "context" "testing" "cloud.google.com/go/bigquery" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestBigQueryDataset(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryDatasetClient(ctrl) projectID := "test-project" datasetID := "test_dataset" t.Run("Get", func(t *testing.T) { wrapper := manual.NewBigQueryDataset(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, projectID, datasetID).Return(createDataset(projectID, datasetID), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], datasetID, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-user@example.com", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: datasetID, ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryModel.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: datasetID, ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryRoutine.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: datasetID, ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryTable.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: datasetID, ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryConnection.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-connection"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewBigQueryDataset(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().List(ctx, projectID, gomock.Any()).Return([]*sdp.Item{ {}, {}, }, nil) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 2 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } _, ok = adapter.(discovery.SearchableAdapter) if ok { t.Fatalf("Expected adapter to not support Search operation, but it does") } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryDatasetClient(ctrl) projectID := "cache-test-project" scope := projectID mockClient.EXPECT().List(ctx, projectID, gomock.Any()).Return([]*sdp.Item{}, nil).Times(1) wrapper := manual.NewBigQueryDataset(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) items, err := listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first List: expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List, got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second List: expected 0 items, got %d", len(items)) } }) } // createDataset creates a BigQuery Dataset for testing. func createDataset(projectID, datasetID string) *bigquery.DatasetMetadata { return &bigquery.DatasetMetadata{ Name: datasetID, FullID: projectID + ":" + datasetID, Location: "EU", Description: "Test dataset for unit tests", Labels: map[string]string{ "env": "test", }, Access: []*bigquery.AccessEntry{ { Role: bigquery.ReaderRole, EntityType: bigquery.UserEmailEntity, Entity: "test-user@example.com", Dataset: &bigquery.DatasetAccessEntry{ Dataset: &bigquery.Dataset{ ProjectID: projectID, DatasetID: datasetID, }, }, }, }, DefaultEncryptionConfig: &bigquery.EncryptionConfig{ KMSKeyName: "projects/" + projectID + "/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, ExternalDatasetReference: &bigquery.ExternalDatasetReference{ // projects/{projectId}/locations/{locationId}/connections/{connectionId} Connection: "projects/" + projectID + "/locations/global/connections/test-connection", }, } } ================================================ FILE: sources/gcp/manual/big-query-model.go ================================================ package manual import ( "context" "cloud.google.com/go/bigquery" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var BigQueryModelLookupById = shared.NewItemTypeLookup("id", gcpshared.BigQueryModel) // BigQueryModelWrapper is a wrapper for the BigQueryModelClient that implements the sources.SearchableWrapper interface type BigQueryModelWrapper struct { client gcpshared.BigQueryModelClient *gcpshared.ProjectBase } // NewBigQueryModel creates a new BigQueryModelWrapper instance func NewBigQueryModel(client gcpshared.BigQueryModelClient, locations []gcpshared.LocationInfo) sources.SearchStreamableWrapper { return &BigQueryModelWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, gcpshared.BigQueryModel, ), } } func (m BigQueryModelWrapper) IAMPermissions() []string { return []string{ "bigquery.models.getMetadata", "bigquery.models.list", } } func (m BigQueryModelWrapper) PredefinedRole() string { // https://cloud.google.com/iam/docs/roles-permissions/bigquery#bigquery.metadataViewer return "roles/bigquery.metadataViewer" } func (m BigQueryModelWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BigQueryDatasetLookupByID, BigQueryModelLookupById, } } func (m BigQueryModelWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := m.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } metadata, err := m.client.Get(ctx, location.ProjectID, queryParts[0], queryParts[1]) if err != nil { return nil, gcpshared.QueryError(err, scope, m.Type()) } return m.GCPBigQueryMetadataToItem(ctx, location, queryParts[0], metadata) } func (m BigQueryModelWrapper) GCPBigQueryMetadataToItem(ctx context.Context, location gcpshared.LocationInfo, dataSetId string, metadata *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(metadata, "labels") if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), m.Type()) } sdpItem := &sdp.Item{ Type: gcpshared.BigQueryModel.String(), UniqueAttribute: "Name", Attributes: attributes, Scope: location.ToScope(), Tags: metadata.Labels, } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Scope: location.ProjectID, Query: dataSetId, }, // Model is in a dataset, if dataset is deleted, model is deleted. // If the model is deleted, the dataset is not deleted. }) if metadata.EncryptionConfig != nil && metadata.EncryptionConfig.KMSKeyName != "" { values := gcpshared.ExtractPathParams(metadata.EncryptionConfig.KMSKeyName, "locations", "keyRings", "cryptoKeys") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Scope: location.ProjectID, Query: shared.CompositeLookupKey(values...), }, }) } } for _, row := range metadata.RawTrainingRuns() { if row.DataSplitResult != nil { // Link to evaluation table (already existed) if row.DataSplitResult.EvaluationTable != nil && row.DataSplitResult.EvaluationTable.TableId != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryTable.String(), Method: sdp.QueryMethod_GET, Scope: location.ProjectID, Query: shared.CompositeLookupKey(dataSetId, row.DataSplitResult.EvaluationTable.TableId), }, // If the evaluation table is deleted or updated: The model's evaluation results may become invalid or inaccessible. If the model is updated: The table remains unaffected. }) } // Link to training table if row.DataSplitResult.TrainingTable != nil && row.DataSplitResult.TrainingTable.TableId != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryTable.String(), Method: sdp.QueryMethod_GET, Scope: location.ProjectID, Query: shared.CompositeLookupKey(dataSetId, row.DataSplitResult.TrainingTable.TableId), }, // If the training table is deleted or updated: The model's training data may become invalid or inaccessible. If the model is updated: The table remains unaffected. }) } // Link to test table if row.DataSplitResult.TestTable != nil && row.DataSplitResult.TestTable.TableId != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryTable.String(), Method: sdp.QueryMethod_GET, Scope: location.ProjectID, Query: shared.CompositeLookupKey(dataSetId, row.DataSplitResult.TestTable.TableId), }, // If the test table is deleted or updated: The model's test results may become invalid or inaccessible. If the model is updated: The table remains unaffected. }) } } } // TODO: Link to BigQuery Connection and Vertex AI Endpoint for remote models // RemoteModelInfo (containing connection and endpoint fields) is not directly accessible // in the Go SDK's ModelMetadata struct. To implement these links, we would need to: // 1. Use the REST API directly to fetch model metadata, or // 2. Wait for the Go SDK to expose RemoteModelInfo fields, or // 3. Access the raw JSON response if available // Connection format: projects/{projectId}/locations/{locationId}/connections/{connectionId} // Endpoint format: https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/{endpoint_id} return sdpItem, nil } func (m BigQueryModelWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.CloudKMSCryptoKey, gcpshared.BigQueryDataset, gcpshared.BigQueryTable, gcpshared.BigQueryConnection, gcpshared.AIPlatformEndpoint, ) } func (m BigQueryModelWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { BigQueryModelLookupById, }, } } func (m BigQueryModelWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { location, err := m.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } items, listErr := m.client.List(ctx, location.ProjectID, queryParts[0], func(datasetID string, md *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError) { return m.GCPBigQueryMetadataToItem(ctx, location, datasetID, md) }) return items, listErr } func (m BigQueryModelWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { location, err := m.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } m.client.ListStream(ctx, location.ProjectID, queryParts[0], stream, func(datasetID string, md *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError) { item, qerr := m.GCPBigQueryMetadataToItem(ctx, location, datasetID, md) if qerr == nil && item != nil { cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) } return item, qerr }) } ================================================ FILE: sources/gcp/manual/big-query-model_test.go ================================================ package manual_test import ( "context" "testing" bigquery "cloud.google.com/go/bigquery" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestBigQueryModel(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryModelClient(ctrl) projectID := "test-project" datasetID := "test_dataset" modelName := "test_model" t.Run("Get", func(t *testing.T) { wrapper := manual.NewBigQueryModel(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, projectID, datasetID, modelName).Return(createDatasetModel(projectID, modelName), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) query := shared.CompositeLookupKey(datasetID, modelName) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatal("Expected an item, got nil") } // Cannot test for linked table as you cannot set the model metadata training runs. t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "test-ring", "test-key"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: datasetID, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { wrapper := manual.NewBigQueryModel(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().List(ctx, projectID, datasetID, gomock.Any()).Return([]*sdp.Item{ {}, }, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, qErr := searchable.Search(ctx, wrapper.Scopes()[0], datasetID, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 items, got: %d", len(sdpItems)) } _, ok = adapter.(discovery.ListStreamableAdapter) if ok { t.Fatalf("Adapter should not support ListStream operation") } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryModelClient(ctrl) projectID := "cache-test-project" scope := projectID datasetID := "empty_dataset" query := datasetID mockClient.EXPECT().List(ctx, projectID, datasetID, gomock.Any()).Return([]*sdp.Item{}, nil).Times(1) wrapper := manual.NewBigQueryModel(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) items, qErr := searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("first Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, cachedErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if cachedErr == nil || cachedErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", cachedErr) } items, qErr = searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("second Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("List_Unsupported", func(t *testing.T) { wrapper := manual.NewBigQueryModel(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list - it should not _, ok := adapter.(discovery.ListableAdapter) if ok { t.Fatalf("Expected adapter to not support List operation, but it does") } }) } func createDatasetModel(projectID, modelName string) *bigquery.ModelMetadata { model := &bigquery.ModelMetadata{ Name: modelName, Type: "LINEAR_REGRESSION", Labels: map[string]string{ "env": "test", }, Location: "US", ETag: "etag123", Description: "Test model description", EncryptionConfig: &bigquery.EncryptionConfig{ KMSKeyName: "projects/" + projectID + "/locations/global/keyRings/test-ring/cryptoKeys/test-key", }, } return model } ================================================ FILE: sources/gcp/manual/big-query-routine.go ================================================ package manual import ( "context" "strings" "cloud.google.com/go/bigquery" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var BigQueryRoutineLookupByID = shared.NewItemTypeLookup("id", gcpshared.BigQueryRoutine) type BigQueryRoutineWrapper struct { client gcpshared.BigQueryRoutineClient *gcpshared.ProjectBase } func NewBigQueryRoutine(client gcpshared.BigQueryRoutineClient, locations []gcpshared.LocationInfo) sources.SearchStreamableWrapper { return &BigQueryRoutineWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, gcpshared.BigQueryRoutine), } } func (b BigQueryRoutineWrapper) IAMPermissions() []string { return []string{ "bigquery.routines.get", "bigquery.routines.list", } } func (b BigQueryRoutineWrapper) PredefinedRole() string { return "roles/bigquery.metadataViewer" } // PotentialLinks returns the potential links for the BigQuery routine wrapper func (b BigQueryRoutineWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.BigQueryDataset, gcpshared.StorageBucket, gcpshared.BigQueryConnection, stdlib.NetworkHTTP, ) } // TerraformMappings returns the Terraform mappings for the BigQuery routine wrapper func (b BigQueryRoutineWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_routine // ID format: projects/{{project}}/datasets/{{dataset_id}}/routines/{{routine_id}} // The framework automatically intercepts queries starting with "projects/" and converts // them to GET operations by extracting the last N path parameters (based on GetLookups count). TerraformQueryMap: "google_bigquery_routine.id", }, } } // GetLookups returns the lookups for the BigQuery routine func (b BigQueryRoutineWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BigQueryDatasetLookupByID, BigQueryRoutineLookupByID, } } func (b BigQueryRoutineWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { BigQueryRoutineLookupByID, }, } } // Get retrieves a BigQuery routine by its ID func (b BigQueryRoutineWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := b.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } // 0: dataset ID // 1: routine ID metadata, getErr := b.client.Get(ctx, location.ProjectID, queryParts[0], queryParts[1]) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, b.Type()) } return b.gcpBigQueryRoutineToItem(metadata, queryParts[0], queryParts[1], location) } func (b BigQueryRoutineWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { location, err := b.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } toItem := func(metadata *bigquery.RoutineMetadata, datasetID, routineID string) (*sdp.Item, *sdp.QueryError) { return b.gcpBigQueryRoutineToItem(metadata, datasetID, routineID, location) } items, listErr := b.client.List(ctx, location.ProjectID, queryParts[0], toItem) if listErr != nil { return nil, gcpshared.QueryError(listErr, scope, b.Type()) } return items, nil } func (b BigQueryRoutineWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { location, err := b.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } toItem := func(metadata *bigquery.RoutineMetadata, datasetID, routineID string) (*sdp.Item, *sdp.QueryError) { item, qerr := b.gcpBigQueryRoutineToItem(metadata, datasetID, routineID, location) if qerr == nil && item != nil { cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) } return item, qerr } items, listErr := b.client.List(ctx, location.ProjectID, queryParts[0], toItem) if listErr != nil { stream.SendError(gcpshared.QueryError(listErr, scope, b.Type())) return } for _, item := range items { stream.SendItem(item) } } func (b BigQueryRoutineWrapper) gcpBigQueryRoutineToItem(metadata *bigquery.RoutineMetadata, datasetID, routineID string, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(metadata, "") if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), b.Type()) } err = attributes.Set("id", shared.CompositeLookupKey(datasetID, routineID)) if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), b.Type()) } sdpItem := &sdp.Item{ Type: gcpshared.BigQueryRoutine.String(), UniqueAttribute: "id", Attributes: attributes, Scope: location.ToScope(), Tags: make(map[string]string), } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Query: datasetID, Scope: location.ProjectID, }, }) // Link to imported libraries (GCS buckets) for JavaScript routines // Format: gs://bucket-name/path/to/file.js if len(metadata.ImportedLibraries) > 0 { if linkFunc, ok := gcpshared.ManualAdapterLinksByAssetType[gcpshared.StorageBucket]; ok { for _, libraryURI := range metadata.ImportedLibraries { if libraryURI != "" { linkedQuery := linkFunc(location.ProjectID, location.ToScope(), libraryURI) if linkedQuery != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, linkedQuery) } } } } } // Link to BigQuery Connection used for remote function authentication // Format: projects/{projectId}/locations/{locationId}/connections/{connectionId} // or: {projectId}.{locationId};{connectionId} if metadata.RemoteFunctionOptions != nil && metadata.RemoteFunctionOptions.Connection != "" { var projectID, location, connectionID string values := gcpshared.ExtractPathParams(metadata.RemoteFunctionOptions.Connection, "projects", "locations", "connections") if len(values) == 3 { projectID = values[0] location = values[1] connectionID = values[2] } else { // Try short format: {projectId}.{locationId};{connectionId} resParts := strings.Split(metadata.RemoteFunctionOptions.Connection, ".") if len(resParts) == 2 { projectID = resParts[0] colParts := strings.Split(resParts[1], ";") if len(colParts) == 2 { location = colParts[0] connectionID = colParts[1] } } } if projectID != "" && location != "" && connectionID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryConnection.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(location, connectionID), Scope: projectID, }, }) } } // Link to HTTP endpoint for remote function calls // Format: https://example.com/run or http://example.com/run if metadata.RemoteFunctionOptions != nil && metadata.RemoteFunctionOptions.Endpoint != "" { endpoint := strings.TrimSpace(metadata.RemoteFunctionOptions.Endpoint) if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkHTTP.String(), Method: sdp.QueryMethod_SEARCH, Query: endpoint, Scope: "global", }, }) } } // NOTE: SparkOptions and ExternalRuntimeOptions are not currently available in the Go SDK's RoutineMetadata struct, // even though they exist in the REST API. If the Go SDK is updated to include these fields in the future, // we should add links for: // - sparkOptions.connection (BigQuery Connection) // - sparkOptions.mainFileUri, pyFileUris, jarUris, fileUris, archiveUris (GCS buckets) // - externalRuntimeOptions.runtimeConnection (BigQuery Connection) // NOTE: optional feature for the future - parse routine_definition to identify referenced tables/views/connections and add links. Out-of-scope for initial version. return sdpItem, nil } ================================================ FILE: sources/gcp/manual/big-query-routine_test.go ================================================ package manual_test import ( "context" "testing" "time" "cloud.google.com/go/bigquery" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" "github.com/stretchr/testify/assert" ) func TestBigQueryRoutine(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryRoutineClient(ctrl) projectID := "test-project" datasetID := "test_dataset" routineID := "test_routine" t.Run("Get", func(t *testing.T) { wrapper := manual.NewBigQueryRoutine(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, projectID, datasetID, routineID).Return(createRoutineMetadata("test routine"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(datasetID, routineID), true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != gcpshared.BigQueryRoutine.String() { t.Fatalf("Expected type %s, got: %s", gcpshared.BigQueryRoutine.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: datasetID, ExpectedScope: projectID, }, // Imported library GCS bucket link { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "bucket", ExpectedScope: projectID, }, // Remote function connection link { ExpectedType: gcpshared.BigQueryConnection.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us|example-conn", ExpectedScope: "example", }, // Remote function HTTP endpoint link { ExpectedType: stdlib.NetworkHTTP.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://example.com/run", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get error", func(t *testing.T) { wrapper := manual.NewBigQueryRoutine(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, projectID, datasetID, routineID).Return(nil, assert.AnError) _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(datasetID, routineID), true) if qErr == nil { t.Fatalf("Expected error, got nil") } }) t.Run("Search", func(t *testing.T) { wrapper := manual.NewBigQueryRoutine(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Mock the List function to call the converter with each routine mockClient.EXPECT().List( gomock.Any(), projectID, datasetID, gomock.Any(), ).DoAndReturn(func(ctx context.Context, projectID string, datasetID string, converter func(routine *bigquery.RoutineMetadata, datasetID, routineID string) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { items := make([]*sdp.Item, 0, 2) routine1 := createRoutineMetadata("test routine 1") item1, qErr := converter(routine1, datasetID, "routine1") if qErr != nil { return nil, qErr } items = append(items, item1) routine2 := createRoutineMetadata("test routine 2") item2, qErr := converter(routine2, datasetID, "routine2") if qErr != nil { return nil, qErr } items = append(items, item2) return items, nil }) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], datasetID, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 2 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("Search error", func(t *testing.T) { wrapper := manual.NewBigQueryRoutine(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Mock the List function to call the converter with each routine mockClient.EXPECT().List( gomock.Any(), projectID, datasetID, gomock.Any(), ).Return(nil, &sdp.QueryError{ErrorType: sdp.QueryError_OTHER, ErrorString: "test error"}) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } _, err := searchable.Search(ctx, wrapper.Scopes()[0], datasetID, true) if err == nil { t.Fatalf("Expected error, got nil") } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryRoutineClient(ctrl) projectID := "cache-test-project" scope := projectID datasetID := "empty_dataset" query := datasetID mockClient.EXPECT().List(gomock.Any(), projectID, datasetID, gomock.Any()).Return([]*sdp.Item{}, nil).Times(1) wrapper := manual.NewBigQueryRoutine(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) items, err := searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("first Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", qErr) } items, err = searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("Search with terraform format", func(t *testing.T) { wrapper := manual.NewBigQueryRoutine(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Use terraform-style path format terraformStyleQuery := "projects/test-project/datasets/test_dataset/routines/test_routine" // Mock Get (called internally when terraform format is detected) mockClient.EXPECT().Get(ctx, projectID, datasetID, routineID).Return(createRoutineMetadata("terraform format test"), nil) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], terraformStyleQuery, true) if qErr != nil { t.Fatalf("Expected no error with terraform format, got: %v", qErr) } if len(items) != 1 { t.Fatalf("Expected 1 item, got: %d", len(items)) } if items[0].GetType() != gcpshared.BigQueryRoutine.String() { t.Fatalf("Expected type %s, got: %s", gcpshared.BigQueryRoutine.String(), items[0].GetType()) } }) t.Run("Search with legacy pipe format", func(t *testing.T) { wrapper := manual.NewBigQueryRoutine(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Use legacy dataset ID format legacyQuery := datasetID // Mock the List function mockClient.EXPECT().List( gomock.Any(), projectID, datasetID, gomock.Any(), ).DoAndReturn(func(ctx context.Context, projectID string, datasetID string, converter func(routine *bigquery.RoutineMetadata, datasetID, routineID string) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { items := make([]*sdp.Item, 0, 1) routine := createRoutineMetadata("legacy format test") item, qErr := converter(routine, datasetID, routineID) if qErr != nil { return nil, qErr } items = append(items, item) return items, nil }) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], legacyQuery, true) if qErr != nil { t.Fatalf("Expected no error with legacy format, got: %v", qErr) } if len(items) != 1 { t.Fatalf("Expected 1 item, got: %d", len(items)) } }) } func createRoutineMetadata(description string) *bigquery.RoutineMetadata { return &bigquery.RoutineMetadata{ Type: bigquery.ScalarFunctionRoutine, CreationTime: time.Unix(1710000000, 0), LastModifiedTime: time.Unix(1710003600, 0), Language: "SQL", Description: description, Body: "BEGIN SELECT 1; END;", Arguments: []*bigquery.RoutineArgument{ { Name: "input_num", Kind: "FIXED_TYPE", Mode: "IN", DataType: &bigquery.StandardSQLDataType{ TypeKind: "INT64", }, }, }, ReturnType: &bigquery.StandardSQLDataType{ TypeKind: "INT64", }, DataGovernanceType: string(bigquery.Deterministic), ImportedLibraries: []string{"gs://bucket/lib.js"}, RemoteFunctionOptions: &bigquery.RemoteFunctionOptions{ Connection: "projects/example/locations/us/connections/example-conn", Endpoint: "https://example.com/run", }, } } ================================================ FILE: sources/gcp/manual/big-query-table.go ================================================ package manual import ( "context" "fmt" "strings" "cloud.google.com/go/bigquery" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var BigQueryTableLookupByID = shared.NewItemTypeLookup("id", gcpshared.BigQueryTable) type BigQueryTableWrapper struct { client gcpshared.BigQueryTableClient *gcpshared.ProjectBase } // NewBigQueryTable creates a new bigQueryTable instance func NewBigQueryTable(client gcpshared.BigQueryTableClient, locations []gcpshared.LocationInfo) sources.SearchStreamableWrapper { return &BigQueryTableWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_DATABASE, gcpshared.BigQueryTable, ), } } func (b BigQueryTableWrapper) IAMPermissions() []string { return []string{ "bigquery.tables.get", "bigquery.tables.list", } } func (b BigQueryTableWrapper) PredefinedRole() string { return "roles/bigquery.metadataViewer" } // PotentialLinks returns the potential links for the BigQuery table wrapper func (b BigQueryTableWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.CloudKMSCryptoKey, gcpshared.BigQueryDataset, gcpshared.BigQueryConnection, gcpshared.StorageBucket, gcpshared.BigQueryTable, ) } // TerraformMappings returns the Terraform mappings for the BigQuery table wrapper func (b BigQueryTableWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_table // ID format: projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // The framework automatically intercepts queries starting with "projects/" and converts // them to GET operations by extracting the last N path parameters (based on GetLookups count). TerraformQueryMap: "google_bigquery_table.id", }, // IAM resources for BigQuery Tables. These are Terraform-only constructs // (no standalone GCP API resource exists). We use the dataset_id attribute // because table_id is a bare name that the SEARCH handler would misinterpret // as a dataset ID. Using dataset_id lists all tables in the affected dataset, // providing dataset-level blast radius for table IAM changes. // // Reference: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_table_iam { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigquery_table_iam_binding.dataset_id", }, { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigquery_table_iam_member.dataset_id", }, { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_bigquery_table_iam_policy.dataset_id", }, } } // GetLookups returns the lookups for the BigQuery dataset func (b BigQueryTableWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ BigQueryDatasetLookupByID, BigQueryTableLookupByID, } } func (b BigQueryTableWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { BigQueryDatasetLookupByID, }, } } // Get retrieves a BigQuery dataset by its ID func (b BigQueryTableWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := b.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } // O: dataset ID // 1: table ID metadata, err := b.client.Get(ctx, location.ProjectID, queryParts[0], queryParts[1]) if err != nil { return nil, gcpshared.QueryError(err, scope, b.Type()) } return b.GCPBigQueryTableToItem(location, metadata) } func (b BigQueryTableWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { location, err := b.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } // queryParts[0]: Dataset ID items, listErr := b.client.List(ctx, location.ProjectID, queryParts[0], func(md *bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError) { return b.GCPBigQueryTableToItem(location, md) }) return items, listErr } func (b BigQueryTableWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { location, err := b.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } // queryParts[0]: Dataset ID b.client.ListStream(ctx, location.ProjectID, queryParts[0], stream, func(md *bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError) { item, qerr := b.GCPBigQueryTableToItem(location, md) if qerr == nil && item != nil { cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) } return item, qerr }) } func (b BigQueryTableWrapper) GCPBigQueryTableToItem(location gcpshared.LocationInfo, metadata *bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(metadata, "labels") if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), b.Type()) } // The full dataset ID in the form projectID:datasetID.tableID parts := strings.Split(strings.TrimPrefix(metadata.FullID, location.ProjectID+":"), ".") if len(parts) != 2 { return nil, gcpshared.QueryError(fmt.Errorf("invalid table full ID: %s", metadata.FullID), location.ToScope(), b.Type()) } // O: dataset ID // 1: table ID err = attributes.Set("id", strings.Join(parts, shared.QuerySeparator)) if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), b.Type()) } sdpItem := &sdp.Item{ Type: gcpshared.BigQueryTable.String(), UniqueAttribute: "id", Attributes: attributes, Scope: location.ToScope(), Tags: metadata.Labels, } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Query: parts[0], // dataset ID Scope: location.ProjectID, }, }) if metadata.EncryptionConfig != nil && metadata.EncryptionConfig.KMSKeyName != "" { // The KMS key used to encrypt the table. // The KMS key name can have the form // projects/{projectId}/locations/{locationId}/keyRings/{keyRingId}/cryptoKeys/{cryptoKeyId} values := gcpshared.ExtractPathParams(metadata.EncryptionConfig.KMSKeyName, "locations", "keyRings", "cryptoKeys") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values...), Scope: location.ProjectID, }, }) } } if metadata.ExternalDataConfig != nil { if metadata.ExternalDataConfig.ConnectionID != "" { // The connection specifying the credentials to be used to read external storage, such as Azure Blob, Cloud Storage, or S3. // The connectionId can have the form // {projectId}.{locationId};{connectionId} or // projects/{projectId}/locations/{locationId}/connections/{connectionId} var projectID, connectionLocation, connectionID string values := gcpshared.ExtractPathParams(metadata.ExternalDataConfig.ConnectionID, "projects", "locations", "connections") if len(values) == 3 { projectID = values[0] connectionLocation = values[1] connectionID = values[2] } else { // {projectId}.{locationId};{connectionId} resParts := strings.Split(metadata.ExternalDataConfig.ConnectionID, ".") if len(resParts) == 2 { projectID = resParts[0] // {locationId};{connectionId} colParts := strings.Split(resParts[1], ";") if len(colParts) == 2 { connectionLocation = colParts[0] connectionID = colParts[1] } } } if projectID != "" && connectionLocation != "" && connectionID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryConnection.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(connectionLocation, connectionID), Scope: projectID, }, }) } } // Link to Storage Buckets referenced in source URIs (gs:// URIs). // Format: gs://bucket-name/path/to/file or gs://bucket-name/path/* (wildcard allowed after bucket name). // GET https://storage.googleapis.com/storage/v1/b/{bucket} // https://cloud.google.com/storage/docs/json_api/v1/buckets/get if len(metadata.ExternalDataConfig.SourceURIs) > 0 { // Use a map to deduplicate bucket names bucketMap := make(map[string]bool) for _, sourceURI := range metadata.ExternalDataConfig.SourceURIs { if sourceURI != "" { // Use the StorageBucket linker to extract bucket name from various URI formats if linkFunc, ok := gcpshared.ManualAdapterLinksByAssetType[gcpshared.StorageBucket]; ok { // The linker handles gs:// URIs and extracts bucket names linkedQuery := linkFunc(location.ProjectID, location.ToScope(), sourceURI) if linkedQuery != nil { // Create a unique key from query and scope to deduplicate bucketKey := fmt.Sprintf("%s|%s", linkedQuery.GetQuery().GetQuery(), linkedQuery.GetQuery().GetScope()) if !bucketMap[bucketKey] { bucketMap[bucketKey] = true sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, linkedQuery) } } } } } } } // Link to base table if this is a snapshot. // The base table from which this snapshot was created. // GET https://bigquery.googleapis.com/bigquery/v2/projects/{projectId}/datasets/{datasetId}/tables/{tableId} // https://cloud.google.com/bigquery/docs/reference/rest/v2/tables/get if metadata.SnapshotDefinition != nil && metadata.SnapshotDefinition.BaseTableReference != nil { baseTableRef := metadata.SnapshotDefinition.BaseTableReference if baseTableRef.ProjectID != "" && baseTableRef.DatasetID != "" && baseTableRef.TableID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryTable.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(baseTableRef.DatasetID, baseTableRef.TableID), Scope: baseTableRef.ProjectID, }, }) } } // Link to base table if this is a clone. // The base table from which this clone was created. // GET https://bigquery.googleapis.com/bigquery/v2/projects/{projectId}/datasets/{datasetId}/tables/{tableId} // https://cloud.google.com/bigquery/docs/reference/rest/v2/tables/get if metadata.CloneDefinition != nil && metadata.CloneDefinition.BaseTableReference != nil { baseTableRef := metadata.CloneDefinition.BaseTableReference if baseTableRef.ProjectID != "" && baseTableRef.DatasetID != "" && baseTableRef.TableID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryTable.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(baseTableRef.DatasetID, baseTableRef.TableID), Scope: baseTableRef.ProjectID, }, }) } } // Note: Replicas field is not available in the Go client library's TableMetadata struct, // even though it exists in the REST API. If needed in the future, we would need to access // the raw REST API response or wait for the Go client library to expose this field. return sdpItem, nil } ================================================ FILE: sources/gcp/manual/big-query-table_test.go ================================================ package manual_test import ( "context" "fmt" "testing" "cloud.google.com/go/bigquery" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestBigQueryTable(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryTableClient(ctrl) projectID := "test-project" datasetID := "test_dataset" tableID := "test_table" t.Run("Get", func(t *testing.T) { wrapper := manual.NewBigQueryTable(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, projectID, datasetID, tableID).Return(createTableMetadata(projectID, datasetID, tableID, projectID+".us;test-connection"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(datasetID, tableID), true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != gcpshared.BigQueryTable.String() { t.Fatalf("Expected type %s, got: %s", gcpshared.BigQueryTable.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: datasetID, ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us", "test-ring", "test-key"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryConnection.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us", "test-connection"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get with alternative connection id", func(t *testing.T) { wrapper := manual.NewBigQueryTable(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, projectID, datasetID, tableID).Return(createTableMetadata(projectID, datasetID, tableID, fmt.Sprintf("projects/%s/locations/us/connections/test-connection", projectID)), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(datasetID, tableID), true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != gcpshared.BigQueryTable.String() { t.Fatalf("Expected type %s, got: %s", gcpshared.BigQueryTable.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: datasetID, ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us", "test-ring", "test-key"), ExpectedScope: projectID, }, { ExpectedType: gcpshared.BigQueryConnection.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("us", "test-connection"), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { wrapper := manual.NewBigQueryTable(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Mock the List function to call the converter with each table mockClient.EXPECT().List( gomock.Any(), projectID, datasetID, gomock.Any(), ).DoAndReturn(func(ctx context.Context, projectID, datasetID string, converter func(*bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { items := make([]*sdp.Item, 0, 2) table1 := createTableMetadata(projectID, datasetID, "table1", projectID+".us;test-connection") item1, qErr := converter(table1) if qErr != nil { return nil, qErr } items = append(items, item1) table2 := createTableMetadata(projectID, datasetID, "table2", projectID+".us;test-connection") item2, qErr := converter(table2) if qErr != nil { return nil, qErr } items = append(items, item2) return items, nil }) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], datasetID, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 2 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("SearchWithTerraformMapping", func(t *testing.T) { wrapper := manual.NewBigQueryTable(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Mock the List function to call the converter with each table mockClient.EXPECT().Get(ctx, projectID, datasetID, tableID). Return(createTableMetadata( projectID, datasetID, tableID, fmt.Sprintf("projects/%s/locations/us/connections/test-connection", projectID), ), nil) terraformMapping := fmt.Sprintf("projects/%s/datasets/%s/tables/%s", projectID, datasetID, tableID) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], terraformMapping, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 1 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } if err := sdpItems[0].Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockBigQueryTableClient(ctrl) projectID := "cache-test-project" scope := projectID datasetID := "empty_dataset" query := datasetID mockClient.EXPECT().List(gomock.Any(), projectID, datasetID, gomock.Any()).Return([]*sdp.Item{}, nil).Times(1) wrapper := manual.NewBigQueryTable(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) items, err := searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("first Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", qErr) } items, err = searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("List_Unsupported", func(t *testing.T) { wrapper := manual.NewBigQueryTable(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list - it should not _, ok := adapter.(discovery.ListableAdapter) if ok { t.Fatalf("Expected adapter to not support List operation, but it does") } // Check if adapter supports ListStream - it should not _, ok = adapter.(discovery.ListStreamableAdapter) if ok { t.Fatalf("Adapter should not support ListStream operation") } }) } // createTableMetadata creates a BigQuery TableMetadata for testing. func createTableMetadata(projectID, datasetID, tableID, connectionID string) *bigquery.TableMetadata { return &bigquery.TableMetadata{ Name: tableID, FullID: projectID + ":" + datasetID + "." + tableID, Type: "TABLE", Location: "US", Labels: map[string]string{"env": "test"}, EncryptionConfig: &bigquery.EncryptionConfig{ KMSKeyName: "projects/" + projectID + "/locations/us/keyRings/test-ring/cryptoKeys/test-key", }, ExternalDataConfig: &bigquery.ExternalDataConfig{ ConnectionID: connectionID, }, } } ================================================ FILE: sources/gcp/manual/certificate-manager-certificate.go ================================================ package manual import ( "context" "errors" certificatemanagerpb "cloud.google.com/go/certificatemanager/apiv1/certificatemanagerpb" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ( CertificateManagerCertificateLookupByLocation = shared.NewItemTypeLookup("location", gcpshared.CertificateManagerCertificate) CertificateManagerCertificateLookupByName = shared.NewItemTypeLookup("name", gcpshared.CertificateManagerCertificate) ) type certificateManagerCertificateWrapper struct { client gcpshared.CertificateManagerCertificateClient *gcpshared.ProjectBase } // NewCertificateManagerCertificate creates a new certificateManagerCertificateWrapper. func NewCertificateManagerCertificate(client gcpshared.CertificateManagerCertificateClient, locations []gcpshared.LocationInfo) sources.SearchableWrapper { return &certificateManagerCertificateWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.CertificateManagerCertificate, ), } } func (c certificateManagerCertificateWrapper) IAMPermissions() []string { return []string{ "certificatemanager.certs.get", "certificatemanager.certs.list", } } func (c certificateManagerCertificateWrapper) PredefinedRole() string { return "roles/certificatemanager.viewer" } func (c certificateManagerCertificateWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.CertificateManagerDnsAuthorization, gcpshared.CertificateManagerCertificateIssuanceConfig, stdlib.NetworkDNS, ) } func (c certificateManagerCertificateWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/certificate_manager_certificate // ID format: projects/{{project}}/locations/{{location}}/certificates/{{name}} // The framework automatically intercepts queries starting with "projects/" and converts // them to GET operations by extracting the last N path parameters (based on GetLookups count). TerraformQueryMap: "google_certificate_manager_certificate.id", }, } } func (c certificateManagerCertificateWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ CertificateManagerCertificateLookupByLocation, CertificateManagerCertificateLookupByName, } } // Get retrieves a Certificate Manager Certificate by its unique attribute (location|certificateName). func (c certificateManagerCertificateWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } if len(queryParts) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Get requires exactly 2 query parts: location and certificate name", } } locationName := queryParts[0] certificateName := queryParts[1] // Construct the full resource name // Format: projects/{project}/locations/{location}/certificates/{certificate} name := "projects/" + location.ProjectID + "/locations/" + locationName + "/certificates/" + certificateName req := &certificatemanagerpb.GetCertificateRequest{ Name: name, } certificate, getErr := c.client.GetCertificate(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } item, sdpErr := c.gcpCertificateToSDPItem(certificate, location) if sdpErr != nil { return nil, sdpErr } return item, nil } func (c certificateManagerCertificateWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { CertificateManagerCertificateLookupByLocation, }, } } // Search searches Certificate Manager Certificates by location. func (c certificateManagerCertificateWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.SearchStream(ctx, stream, cache, cacheKey, scope, queryParts...) }) } // SearchStream streams certificates matching the search criteria (location). func (c certificateManagerCertificateWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } if len(queryParts) != 1 { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "Search requires 1 query part: location", }) return } locationName := queryParts[0] // Construct the parent path // Format: projects/{project}/locations/{location} parent := "projects/" + location.ProjectID + "/locations/" + locationName req := &certificatemanagerpb.ListCertificatesRequest{ Parent: parent, } results := c.client.ListCertificates(ctx, req) for { cert, iterErr := results.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpCertificateToSDPItem(cert, location) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } func (c certificateManagerCertificateWrapper) gcpCertificateToSDPItem(certificate *certificatemanagerpb.Certificate, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(certificate, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } // Extract location and certificate name from the resource name // Format: projects/{project}/locations/{location}/certificates/{certificate} values := gcpshared.ExtractPathParams(certificate.GetName(), "locations", "certificates") if len(values) != 2 { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "invalid certificate name format: " + certificate.GetName(), } } locationName := values[0] certificateName := values[1] // Set composite unique attribute err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(locationName, certificateName)) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.CertificateManagerCertificate.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: location.ToScope(), Tags: certificate.GetLabels(), } // Link to DNS names from sanDnsNames (covers both managed and self-managed certificates) for _, dnsName := range certificate.GetSanDnsnames() { if dnsName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: dnsName, Scope: "global", }, }) } } // Link to DNS Authorizations used for managed certificate domain validation if managed := certificate.GetManaged(); managed != nil { // Link to DNS names from managed.domains for _, domain := range managed.GetDomains() { if domain != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: domain, Scope: "global", }, }) } } for _, dnsAuthURI := range managed.GetDnsAuthorizations() { // Extract location and dnsAuthorization name from URI // Format: projects/{project}/locations/{location}/dnsAuthorizations/{dnsAuthorization} values := gcpshared.ExtractPathParams(dnsAuthURI, "locations", "dnsAuthorizations") if len(values) == 2 && values[0] != "" && values[1] != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CertificateManagerDnsAuthorization.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values[0], values[1]), Scope: location.ProjectID, }, }) } } // Link to Certificate Issuance Config for private PKI certificates if issuanceConfigURI := managed.GetIssuanceConfig(); issuanceConfigURI != "" { // Extract location and issuanceConfig name from URI // Format: projects/{project}/locations/{location}/certificateIssuanceConfigs/{certificateIssuanceConfig} values := gcpshared.ExtractPathParams(issuanceConfigURI, "locations", "certificateIssuanceConfigs") if len(values) == 2 && values[0] != "" && values[1] != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CertificateManagerCertificateIssuanceConfig.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values[0], values[1]), Scope: location.ProjectID, }, }) } } } // Note: The Certificate resource's UsedBy field (which lists resources using this certificate) // is not available in the Go SDK protobuf. The reverse links from CertificateMap, // CertificateMapEntry, and TargetHttpsProxy to Certificate will be established // when those adapters are created. return sdpItem, nil } ================================================ FILE: sources/gcp/manual/certificate-manager-certificate_test.go ================================================ package manual_test import ( "context" "testing" certificatemanagerpb "cloud.google.com/go/certificatemanager/apiv1/certificatemanagerpb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "google.golang.org/protobuf/types/known/timestamppb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func createCertificate(projectID, location, name string) *certificatemanagerpb.Certificate { return &certificatemanagerpb.Certificate{ Name: "projects/" + projectID + "/locations/" + location + "/certificates/" + name, Description: "Test certificate", CreateTime: timestamppb.Now(), UpdateTime: timestamppb.Now(), Labels: map[string]string{ "env": "test", }, Scope: certificatemanagerpb.Certificate_DEFAULT, } } func TestCertificateManagerCertificate(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockCertificateManagerCertificateClient(ctrl) projectID := "test-project-id" location := "us-central1" certificateName := "test-certificate" t.Run("Get", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().GetCertificate(ctx, gomock.Any()).Return(createCertificate(projectID, location, certificateName), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], location+shared.QuerySeparator+certificateName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatal("Expected item, got nil") } if err := sdpItem.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } // Verify the item type if sdpItem.GetType() != gcpshared.CertificateManagerCertificate.String() { t.Errorf("Expected type %s, got: %s", gcpshared.CertificateManagerCertificate.String(), sdpItem.GetType()) } // Verify the unique attribute if sdpItem.GetUniqueAttribute() != "uniqueAttr" { t.Errorf("Expected unique attribute 'uniqueAttr', got: %s", sdpItem.GetUniqueAttribute()) } // Verify the scope expectedScope := projectID if sdpItem.GetScope() != expectedScope { t.Errorf("Expected scope %s, got: %s", expectedScope, sdpItem.GetScope()) } }) t.Run("Search", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockCertificateIterator(ctrl) mockIterator.EXPECT().Next().Return(createCertificate(projectID, location, "cert1"), nil) mockIterator.EXPECT().Next().Return(createCertificate(projectID, location, "cert2"), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().ListCertificates(ctx, gomock.Any()).Return(mockIterator) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], location, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 2 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockCertificateManagerCertificateClient(ctrl) projectID := "cache-test-project" scope := projectID locationName := "us-central1" query := locationName mockIter := mocks.NewMockCertificateIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().ListCertificates(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) items, err := searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("first Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", qErr) } items, err = searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("GetLookups", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) lookups := wrapper.GetLookups() if len(lookups) != 2 { t.Errorf("Expected 2 lookups, got: %d", len(lookups)) } // Verify the lookup types expectedTypes := []string{"location", "name"} for i, lookup := range lookups { if lookup.By != expectedTypes[i] { t.Errorf("Expected lookup by %s, got: %s", expectedTypes[i], lookup.By) } } }) t.Run("SearchLookups", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) searchLookups := wrapper.SearchLookups() if len(searchLookups) != 1 { t.Errorf("Expected 1 search lookup, got: %d", len(searchLookups)) } // Verify the search lookup has only location if len(searchLookups[0]) != 1 { t.Errorf("Expected 1 lookup in search lookup, got: %d", len(searchLookups[0])) } }) t.Run("TerraformMappings", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mappings := wrapper.TerraformMappings() if len(mappings) != 1 { t.Errorf("Expected 1 terraform mapping, got: %d", len(mappings)) } mapping := mappings[0] if mapping.GetTerraformMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Expected SEARCH method, got: %v", mapping.GetTerraformMethod()) } expectedQueryMap := "google_certificate_manager_certificate.id" if mapping.GetTerraformQueryMap() != expectedQueryMap { t.Errorf("Expected query map %s, got: %s", expectedQueryMap, mapping.GetTerraformQueryMap()) } }) t.Run("IAMPermissions", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) permissions := wrapper.IAMPermissions() expectedPermissions := []string{ "certificatemanager.certs.get", "certificatemanager.certs.list", } if len(permissions) != len(expectedPermissions) { t.Errorf("Expected %d permissions, got: %d", len(expectedPermissions), len(permissions)) } for i, perm := range permissions { if perm != expectedPermissions[i] { t.Errorf("Expected permission %s, got: %s", expectedPermissions[i], perm) } } }) t.Run("PredefinedRole", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) // PredefinedRole is available on the wrapper, not the adapter role := wrapper.(interface{ PredefinedRole() string }).PredefinedRole() expectedRole := "roles/certificatemanager.viewer" if role != expectedRole { t.Errorf("Expected role %s, got: %s", expectedRole, role) } }) t.Run("PotentialLinks", func(t *testing.T) { wrapper := manual.NewCertificateManagerCertificate(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) links := wrapper.PotentialLinks() expectedLinks := map[shared.ItemType]bool{ gcpshared.CertificateManagerDnsAuthorization: true, gcpshared.CertificateManagerCertificateIssuanceConfig: true, stdlib.NetworkDNS: true, } if len(links) != len(expectedLinks) { t.Errorf("Expected %d potential links, got: %d", len(expectedLinks), len(links)) } for expectedLink := range expectedLinks { if !links[expectedLink] { t.Errorf("Expected link to %s", expectedLink) } } }) } ================================================ FILE: sources/gcp/manual/cloud-kms-crypto-key-version.go ================================================ package manual import ( "context" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var CloudKMSCryptoKeyVersionLookupByVersion = shared.NewItemTypeLookup("version", gcpshared.CloudKMSCryptoKeyVersion) // cloudKMSCryptoKeyVersionWrapper wraps the KMS CryptoKeyVersion operations using CloudKMSAssetLoader. type cloudKMSCryptoKeyVersionWrapper struct { loader *gcpshared.CloudKMSAssetLoader *gcpshared.ProjectBase } // NewCloudKMSCryptoKeyVersion creates a new cloudKMSCryptoKeyVersionWrapper. func NewCloudKMSCryptoKeyVersion(loader *gcpshared.CloudKMSAssetLoader, locations []gcpshared.LocationInfo) sources.SearchStreamableWrapper { return &cloudKMSCryptoKeyVersionWrapper{ loader: loader, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.CloudKMSCryptoKeyVersion, ), } } func (c cloudKMSCryptoKeyVersionWrapper) IAMPermissions() []string { return []string{ "cloudasset.assets.listResource", } } func (c cloudKMSCryptoKeyVersionWrapper) PredefinedRole() string { return "roles/cloudasset.viewer" } // PotentialLinks returns the potential links for the CryptoKeyVersion wrapper. func (c cloudKMSCryptoKeyVersionWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.CloudKMSCryptoKey, gcpshared.CloudKMSImportJob, gcpshared.CloudKMSEKMConnection, ) } // TerraformMappings returns the Terraform mappings for the CryptoKeyVersion wrapper. func (c cloudKMSCryptoKeyVersionWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/kms_crypto_key_version // ID format: projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}/cryptoKeyVersions/{version} // The framework automatically intercepts queries starting with "projects/" and converts // them to GET operations by extracting the last N path parameters (based on GetLookups count). TerraformQueryMap: "google_kms_crypto_key_version.id", }, } } // GetLookups returns the lookups for the CryptoKeyVersion wrapper. func (c cloudKMSCryptoKeyVersionWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ CloudKMSCryptoKeyRingLookupByLocation, CloudKMSCryptoKeyRingLookupByName, CloudKMSCryptoKeyLookupByName, CloudKMSCryptoKeyVersionLookupByVersion, } } // Get retrieves a KMS CryptoKeyVersion by its unique attribute (location|keyRing|cryptoKey|version). // Data is loaded via Cloud Asset API and cached in sdpcache. func (c cloudKMSCryptoKeyVersionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { _, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } uniqueAttr := shared.CompositeLookupKey(queryParts...) return c.loader.GetItem(ctx, scope, c.Type(), uniqueAttr) } // SearchLookups returns the lookups for the CryptoKeyVersion wrapper. func (c cloudKMSCryptoKeyVersionWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { CloudKMSCryptoKeyRingLookupByLocation, CloudKMSCryptoKeyRingLookupByName, CloudKMSCryptoKeyLookupByName, }, } } // Search searches KMS CryptoKeyVersions by cryptoKey (location|keyRing|cryptoKey). // Data is loaded via Cloud Asset API and cached in sdpcache. func (c cloudKMSCryptoKeyVersionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.SearchStream(ctx, stream, cache, cacheKey, scope, queryParts...) }) } // SearchStream streams CryptoKeyVersions matching the search criteria (location|keyRing|cryptoKey). func (c cloudKMSCryptoKeyVersionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, _ sdpcache.Cache, _ sdpcache.CacheKey, scope string, queryParts ...string) { _, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } // CryptoKeyVersion search is by location|keyRing|cryptoKey searchQuery := shared.CompositeLookupKey(queryParts[0], queryParts[1], queryParts[2]) c.loader.SearchItems(ctx, stream, scope, c.Type(), searchQuery) } ================================================ FILE: sources/gcp/manual/cloud-kms-crypto-key-version_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudKMSCryptoKeyVersion(t *testing.T) { ctx := context.Background() projectID := "test-project-id" t.Run("Get_CacheHit", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with a CryptoKeyVersion item attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", "uniqueAttr": "us|test-keyring|test-key|1", "state": "ENABLED", }) _ = attrs.Set("uniqueAttr", "us|test-keyring|test-key|1") item := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, Health: sdp.Health_HEALTH_OK.Enum(), } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), "us|test-keyring|test-key|1") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "us|test-keyring|test-key|1", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected item, got nil") } uniqueAttr, err := sdpItem.GetAttributes().Get("uniqueAttr") if err != nil { t.Fatalf("Failed to get uniqueAttr: %v", err) } if uniqueAttr != "us|test-keyring|test-key|1" { t.Fatalf("Expected uniqueAttr 'us|test-keyring|test-key|1', got: %v", uniqueAttr) } // Verify health if sdpItem.GetHealth() != sdp.Health_HEALTH_OK { t.Fatalf("Expected health HEALTH_OK, got: %v", sdpItem.GetHealth()) } }) t.Run("Get_CacheMiss_NotFound", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with a NOTFOUND error to simulate item not existing notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "No resources found in Cloud Asset API", } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), "us|test-keyring|test-key|99") cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) // Get a non-existent item - should return NOTFOUND from cache _, err := adapter.Get(ctx, wrapper.Scopes()[0], "us|test-keyring|test-key|99", false) if err == nil { t.Fatalf("Expected NOTFOUND error, got nil") } var qErr *sdp.QueryError if !errors.As(err, &qErr) { t.Fatalf("Expected QueryError, got: %T - %v", err, err) } if qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("Expected NOTFOUND error type, got: %v", qErr.GetErrorType()) } }) t.Run("Search_CacheHit", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with CryptoKeyVersion items under SEARCH cache key (by cryptoKey) attrs1, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", "uniqueAttr": "us|test-keyring|test-key|1", }) _ = attrs1.Set("uniqueAttr", "us|test-keyring|test-key|1") attrs2, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/2", "uniqueAttr": "us|test-keyring|test-key|2", }) _ = attrs2.Set("uniqueAttr", "us|test-keyring|test-key|2") item1 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs1, Scope: projectID, Health: sdp.Health_HEALTH_OK.Enum(), } item2 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs2, Scope: projectID, Health: sdp.Health_HEALTH_WARNING.Enum(), } // Search by location|keyRing|cryptoKey searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), "us|test-keyring|test-key") cache.StoreItem(ctx, item1, shared.DefaultCacheDuration, searchCacheKey) cache.StoreItem(ctx, item2, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "us|test-keyring|test-key", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("Search_CacheHit_Empty", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Store NOTFOUND error in cache to simulate empty result notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "No resources found in Cloud Asset API", } searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), "us|test-keyring|empty-key") cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "us|test-keyring|empty-key", false) if qErr != nil { t.Fatalf("Expected no error (empty search is valid), got: %v", qErr) } // Empty result is valid for SEARCH - should return empty slice, not error if len(items) != 0 { t.Fatalf("Expected 0 items (empty result), got: %d", len(items)) } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no crypto key versions found for search query", } query := "us|test-keyring|empty-key" searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), query) cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) scope := wrapper.Scopes()[0] items, qErr := searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("first Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, cachedErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if cachedErr == nil || cachedErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", cachedErr) } items, qErr = searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("second Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("List_Unsupported", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) // Check if adapter supports list - it should not _, ok := adapter.(discovery.ListableAdapter) if ok { t.Fatalf("Expected adapter to not support List operation, but it does") } }) t.Run("Search_TerraformFormat", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with CryptoKeyVersion items under SEARCH cache key (by cryptoKey) attrs1, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1", "uniqueAttr": "us-central1|my-keyring|my-key|1", }) _ = attrs1.Set("uniqueAttr", "us-central1|my-keyring|my-key|1") attrs2, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/2", "uniqueAttr": "us-central1|my-keyring|my-key|2", }) _ = attrs2.Set("uniqueAttr", "us-central1|my-keyring|my-key|2") item1 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs1, Scope: projectID, Health: sdp.Health_HEALTH_OK.Enum(), } item2 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs2, Scope: projectID, Health: sdp.Health_HEALTH_OK.Enum(), } // Search by location|keyRing|cryptoKey (what the terraform format will be converted to) searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), "us-central1|my-keyring|my-key") cache.StoreItem(ctx, item1, shared.DefaultCacheDuration, searchCacheKey) cache.StoreItem(ctx, item2, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } // Use terraform-style path format terraformStyleQuery := "projects/test-project-id/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1" items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], terraformStyleQuery, false) if qErr != nil { t.Fatalf("Expected no error with terraform format, got: %v", qErr) } // Verify we got at least one item back if len(items) == 0 { t.Fatalf("Expected at least 1 item with terraform format, got: %d", len(items)) } // Verify the items have the expected unique attributes foundVersion1 := false for _, item := range items { uniqueAttr, err := item.GetAttributes().Get("uniqueAttr") if err == nil && (uniqueAttr == "us-central1|my-keyring|my-key|1" || uniqueAttr == "us-central1|my-keyring|my-key|2") { if uniqueAttr == "us-central1|my-keyring|my-key|1" { foundVersion1 = true } } } if !foundVersion1 { t.Fatalf("Expected to find version 1 in results") } }) t.Run("Search_LegacyPipeFormat", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with CryptoKeyVersion items attrs1, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/europe-west1/keyRings/prod-keyring/cryptoKeys/prod-key/cryptoKeyVersions/1", "uniqueAttr": "europe-west1|prod-keyring|prod-key|1", }) _ = attrs1.Set("uniqueAttr", "europe-west1|prod-keyring|prod-key|1") item1 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs1, Scope: projectID, Health: sdp.Health_HEALTH_OK.Enum(), } // Search by location|keyRing|cryptoKey (legacy format) searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), "europe-west1|prod-keyring|prod-key") cache.StoreItem(ctx, item1, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } // Use legacy pipe-separated format with multiple query parts items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "europe-west1|prod-keyring|prod-key", false) if qErr != nil { t.Fatalf("Expected no error with legacy format, got: %v", qErr) } if len(items) != 1 { t.Fatalf("Expected 1 item with legacy format, got: %d", len(items)) } }) t.Run("StaticTests", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with a CryptoKeyVersion item with linked queries attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/1", "uniqueAttr": "us|test-keyring|test-key|1", }) _ = attrs.Set("uniqueAttr", "us|test-keyring|test-key|1") item := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, Health: sdp.Health_HEALTH_OK.Enum(), LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Query: "us|test-keyring|test-key", Scope: projectID, }, }, }, } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSCryptoKeyVersion.String(), "us|test-keyring|test-key|1") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKeyVersion(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "us|test-keyring|test-key|1", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us|test-keyring|test-key", ExpectedScope: "test-project-id", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) } ================================================ FILE: sources/gcp/manual/cloud-kms-crypto-key.go ================================================ package manual import ( "context" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var CloudKMSCryptoKeyLookupByName = shared.NewItemTypeLookup("name", gcpshared.CloudKMSCryptoKey) // cloudKMSCryptoKeyWrapper wraps the KMS CryptoKey operations using CloudKMSAssetLoader. type cloudKMSCryptoKeyWrapper struct { loader *gcpshared.CloudKMSAssetLoader *gcpshared.ProjectBase } // NewCloudKMSCryptoKey creates a new cloudKMSCryptoKeyWrapper. func NewCloudKMSCryptoKey(loader *gcpshared.CloudKMSAssetLoader, locations []gcpshared.LocationInfo) sources.SearchStreamableWrapper { return &cloudKMSCryptoKeyWrapper{ loader: loader, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.CloudKMSCryptoKey, ), } } func (c cloudKMSCryptoKeyWrapper) IAMPermissions() []string { return []string{ "cloudasset.assets.listResource", } } func (c cloudKMSCryptoKeyWrapper) PredefinedRole() string { return "roles/cloudasset.viewer" } // PotentialLinks returns the potential links for the CryptoKey wrapper. func (c cloudKMSCryptoKeyWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.CloudKMSCryptoKeyVersion, gcpshared.CloudKMSImportJob, gcpshared.CloudKMSEKMConnection, gcpshared.IAMPolicy, gcpshared.CloudKMSKeyRing, ) } // TerraformMappings returns the Terraform mappings for the CryptoKey wrapper. func (c cloudKMSCryptoKeyWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/kms_crypto_key // ID format: projects/{{project}}/locations/{{location}}/keyRings/{{keyRing}}/cryptoKeys/{{name}} // The framework automatically intercepts queries starting with "projects/" and converts // them to GET operations by extracting the last N path parameters (based on GetLookups count). TerraformQueryMap: "google_kms_crypto_key.id", }, } } // GetLookups returns the lookups for the CryptoKey wrapper. func (c cloudKMSCryptoKeyWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ CloudKMSCryptoKeyRingLookupByLocation, CloudKMSCryptoKeyRingLookupByName, CloudKMSCryptoKeyLookupByName, } } // Get retrieves a KMS CryptoKey by its unique attribute (location|keyRing|cryptoKeyName). // Data is loaded via Cloud Asset API and cached in sdpcache. func (c cloudKMSCryptoKeyWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { _, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } uniqueAttr := shared.CompositeLookupKey(queryParts...) return c.loader.GetItem(ctx, scope, c.Type(), uniqueAttr) } // SearchLookups returns the lookups for the CryptoKey wrapper. func (c cloudKMSCryptoKeyWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { CloudKMSCryptoKeyRingLookupByLocation, CloudKMSCryptoKeyRingLookupByName, }, } } // Search searches KMS CryptoKeys by keyRing (location|keyRing). // Data is loaded via Cloud Asset API and cached in sdpcache. func (c cloudKMSCryptoKeyWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.SearchStream(ctx, stream, cache, cacheKey, scope, queryParts...) }) } // SearchStream streams CryptoKeys matching the search criteria (location|keyRing). func (c cloudKMSCryptoKeyWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, _ sdpcache.Cache, _ sdpcache.CacheKey, scope string, queryParts ...string) { _, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } // CryptoKey search is by location|keyRing searchQuery := shared.CompositeLookupKey(queryParts[0], queryParts[1]) c.loader.SearchItems(ctx, stream, scope, c.Type(), searchQuery) } ================================================ FILE: sources/gcp/manual/cloud-kms-crypto-key_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudKMSCryptoKey(t *testing.T) { ctx := context.Background() projectID := "test-project-id" t.Run("Get_CacheHit", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with a CryptoKey item attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key", "uniqueAttr": "global|test-keyring|test-key", }) _ = attrs.Set("uniqueAttr", "global|test-keyring|test-key") item := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, Tags: map[string]string{"env": "test"}, } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSCryptoKey.String(), "global|test-keyring|test-key") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "global|test-keyring|test-key", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected item, got nil") } uniqueAttr, err := sdpItem.GetAttributes().Get("uniqueAttr") if err != nil { t.Fatalf("Failed to get uniqueAttr: %v", err) } if uniqueAttr != "global|test-keyring|test-key" { t.Fatalf("Expected uniqueAttr 'global|test-keyring|test-key', got: %v", uniqueAttr) } // Verify tags if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()) } }) t.Run("Get_CacheMiss_NotFound", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with a NOTFOUND error to simulate item not existing notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "No resources found in Cloud Asset API", } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSCryptoKey.String(), "global|test-keyring|nonexistent") cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) // Get a non-existent item - should return NOTFOUND from cache _, err := adapter.Get(ctx, wrapper.Scopes()[0], "global|test-keyring|nonexistent", false) if err == nil { t.Fatalf("Expected NOTFOUND error, got nil") } var qErr *sdp.QueryError if !errors.As(err, &qErr) { t.Fatalf("Expected QueryError, got: %T - %v", err, err) } if qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("Expected NOTFOUND error type, got: %v", qErr.GetErrorType()) } }) t.Run("Search_CacheHit", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with CryptoKey items under SEARCH cache key (by keyRing) attrs1, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key-1", "uniqueAttr": "global|test-keyring|test-key-1", }) _ = attrs1.Set("uniqueAttr", "global|test-keyring|test-key-1") attrs2, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key-2", "uniqueAttr": "global|test-keyring|test-key-2", }) _ = attrs2.Set("uniqueAttr", "global|test-keyring|test-key-2") item1 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs1, Scope: projectID, } item2 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs2, Scope: projectID, } // Search by location|keyRing searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKey.String(), "global|test-keyring") cache.StoreItem(ctx, item1, shared.DefaultCacheDuration, searchCacheKey) cache.StoreItem(ctx, item2, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "global|test-keyring", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("Search_CacheHit_Empty", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Store NOTFOUND error in cache to simulate empty result notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "No resources found in Cloud Asset API", } searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKey.String(), "global|empty-keyring") cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "global|empty-keyring", false) if qErr != nil { t.Fatalf("Expected no error (empty search is valid), got: %v", qErr) } // Empty result is valid for SEARCH - should return empty slice, not error if len(items) != 0 { t.Fatalf("Expected 0 items (empty result), got: %d", len(items)) } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no crypto keys found for search query", } query := "global|empty-keyring" searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKey.String(), query) cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) scope := wrapper.Scopes()[0] items, qErr := searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("first Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, cachedErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if cachedErr == nil || cachedErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", cachedErr) } items, qErr = searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("second Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("Search_TerraformFormat", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with a specific CryptoKey item // Note: Terraform queries with full path are converted to GET operations by the adapter framework attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key-1", "uniqueAttr": "us-central1|my-keyring|my-key-1", }) _ = attrs.Set("uniqueAttr", "us-central1|my-keyring|my-key-1") item := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, } // Store with GET cache key (terraform queries are converted to GET operations) getCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSCryptoKey.String(), "us-central1|my-keyring|my-key-1") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, getCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } // Search using terraform-style path format // The adapter framework detects this and converts it to a GET operation terraformID := "projects/test-project-id/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key-1" items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], terraformID, false) if qErr != nil { t.Fatalf("Expected no error with terraform format, got: %v", qErr) } // Terraform queries with full path return 1 specific item (converted to GET) if len(items) != 1 { t.Fatalf("Expected 1 item with terraform format (converted to GET), got: %d", len(items)) } // Verify the returned item has the correct unique attribute uniqueAttr, err := items[0].GetAttributes().Get("uniqueAttr") if err != nil { t.Fatalf("Failed to get uniqueAttr: %v", err) } if uniqueAttr != "us-central1|my-keyring|my-key-1" { t.Fatalf("Expected uniqueAttr 'us-central1|my-keyring|my-key-1', got: %v", uniqueAttr) } }) t.Run("Search_LegacyFormat", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with CryptoKey items attrs1, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key-1", "uniqueAttr": "us-central1|my-keyring|my-key-1", }) _ = attrs1.Set("uniqueAttr", "us-central1|my-keyring|my-key-1") attrs2, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key-2", "uniqueAttr": "us-central1|my-keyring|my-key-2", }) _ = attrs2.Set("uniqueAttr", "us-central1|my-keyring|my-key-2") item1 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs1, Scope: projectID, } item2 := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs2, Scope: projectID, } // Store with location|keyRing search key searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSCryptoKey.String(), "us-central1|my-keyring") cache.StoreItem(ctx, item1, shared.DefaultCacheDuration, searchCacheKey) cache.StoreItem(ctx, item2, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } // Search using legacy pipe format legacyQuery := "us-central1|my-keyring" items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], legacyQuery, false) if qErr != nil { t.Fatalf("Expected no error with legacy format, got: %v", qErr) } if len(items) != 2 { t.Fatalf("Expected 2 items with legacy format, got: %d", len(items)) } // Verify both expected items are present (order is not guaranteed) found := make(map[string]bool) for _, item := range items { ua, err := item.GetAttributes().Get("uniqueAttr") if err != nil { t.Fatalf("Failed to get uniqueAttr: %v", err) } found[ua.(string)] = true } for _, expected := range []string{"us-central1|my-keyring|my-key-1", "us-central1|my-keyring|my-key-2"} { if !found[expected] { t.Fatalf("Expected item with uniqueAttr %q not found in results", expected) } } }) t.Run("List_Unsupported", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) // Check if adapter supports list - it should not _, ok := adapter.(discovery.ListableAdapter) if ok { t.Fatalf("Expected adapter to not support List operation, but it does") } }) t.Run("StaticTests", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with a CryptoKey item with linked queries attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key", "uniqueAttr": "global|test-keyring|test-key", }) _ = attrs.Set("uniqueAttr", "global|test-keyring|test-key") item := &sdp.Item{ Type: gcpshared.CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: gcpshared.IAMPolicy.String(), Method: sdp.QueryMethod_GET, Query: "global|test-keyring|test-key", Scope: projectID, }, }, { Query: &sdp.Query{ Type: gcpshared.CloudKMSKeyRing.String(), Method: sdp.QueryMethod_GET, Query: "global|test-keyring", Scope: projectID, }, }, { Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_SEARCH, Query: "global|test-keyring|test-key", Scope: projectID, }, }, }, } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSCryptoKey.String(), "global|test-keyring|test-key") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSCryptoKey(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "global|test-keyring|test-key", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.IAMPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.CloudKMSKeyRing.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "global|test-keyring|test-key", ExpectedScope: "test-project-id", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) } ================================================ FILE: sources/gcp/manual/cloud-kms-key-ring.go ================================================ package manual import ( "context" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ( CloudKMSCryptoKeyRingLookupByName = shared.NewItemTypeLookup("name", gcpshared.CloudKMSKeyRing) CloudKMSCryptoKeyRingLookupByLocation = shared.NewItemTypeLookup("location", gcpshared.CloudKMSKeyRing) ) // cloudKMSKeyRingWrapper wraps the KMS KeyRing operations using CloudKMSAssetLoader. type cloudKMSKeyRingWrapper struct { loader *gcpshared.CloudKMSAssetLoader *gcpshared.ProjectBase } // NewCloudKMSKeyRing creates a new cloudKMSKeyRingWrapper. func NewCloudKMSKeyRing(loader *gcpshared.CloudKMSAssetLoader, locations []gcpshared.LocationInfo) sources.SearchableListableWrapper { return &cloudKMSKeyRingWrapper{ loader: loader, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.CloudKMSKeyRing, ), } } func (c cloudKMSKeyRingWrapper) IAMPermissions() []string { return []string{ "cloudasset.assets.listResource", } } func (c cloudKMSKeyRingWrapper) PredefinedRole() string { return "roles/cloudasset.viewer" } // PotentialLinks returns the potential links for the kms key ring func (c cloudKMSKeyRingWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.IAMPolicy, gcpshared.CloudKMSCryptoKey, ) } // TerraformMappings returns the Terraform mappings for the KeyRing wrapper. func (c cloudKMSKeyRingWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/kms_key_ring // ID format: projects/{{project}}/locations/{{location}}/keyRings/{{name}} // The framework automatically intercepts queries starting with "projects/" and converts // them to GET operations by extracting the last N path parameters (based on GetLookups count). TerraformQueryMap: "google_kms_key_ring.id", }, } } // GetLookups returns the lookups for the KeyRing wrapper. func (c cloudKMSKeyRingWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ CloudKMSCryptoKeyRingLookupByLocation, CloudKMSCryptoKeyRingLookupByName, } } // Get retrieves a KMS KeyRing by its unique attribute (location|keyRingName). // Data is loaded via Cloud Asset API and cached in sdpcache. func (c cloudKMSKeyRingWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { _, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } uniqueAttr := shared.CompositeLookupKey(queryParts...) return c.loader.GetItem(ctx, scope, c.Type(), uniqueAttr) } // SearchLookups returns the lookups for the KeyRing wrapper. func (c cloudKMSKeyRingWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { CloudKMSCryptoKeyRingLookupByLocation, }, } } // Search searches KMS KeyRings by location. // Data is loaded via Cloud Asset API and cached in sdpcache. func (c cloudKMSKeyRingWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.SearchStream(ctx, stream, cache, cacheKey, scope, queryParts...) }) } // SearchStream streams KeyRings matching the search criteria (location). func (c cloudKMSKeyRingWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, _ sdpcache.Cache, _ sdpcache.CacheKey, scope string, queryParts ...string) { _, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } // KeyRing search is by location only location := queryParts[0] c.loader.SearchItems(ctx, stream, scope, c.Type(), location) } // List lists all KMS KeyRings in the project. // Data is loaded via Cloud Asset API and cached in sdpcache. func (c cloudKMSKeyRingWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } // ListStream streams all KeyRings in the project. func (c cloudKMSKeyRingWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, _ sdpcache.Cache, _ sdpcache.CacheKey, scope string) { _, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } c.loader.ListItems(ctx, stream, scope, c.Type()) } ================================================ FILE: sources/gcp/manual/cloud-kms-key-ring_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestCloudKMSKeyRing(t *testing.T) { ctx := context.Background() projectID := "test-project-id" t.Run("Get_CacheHit", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with a KeyRing item (simulating what the loader would do) attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring", "uniqueAttr": "us|test-keyring", }) _ = attrs.Set("uniqueAttr", "us|test-keyring") item := &sdp.Item{ Type: gcpshared.CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSKeyRing.String(), "us|test-keyring") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) // Create loader that won't need to make API calls since cache is populated loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "us|test-keyring", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem == nil { t.Fatalf("Expected item, got nil") } uniqueAttr, err := sdpItem.GetAttributes().Get("uniqueAttr") if err != nil { t.Fatalf("Failed to get uniqueAttr: %v", err) } if uniqueAttr != "us|test-keyring" { t.Fatalf("Expected uniqueAttr 'us|test-keyring', got: %v", uniqueAttr) } }) t.Run("Get_CacheMiss_NotFound", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with a NOTFOUND error to simulate item not existing notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "No resources found in Cloud Asset API", } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSKeyRing.String(), "us|nonexistent") cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) // Get a non-existent item - should return NOTFOUND from cache _, err := adapter.Get(ctx, wrapper.Scopes()[0], "us|nonexistent", false) if err == nil { t.Fatalf("Expected NOTFOUND error, got nil") } var qErr *sdp.QueryError if !errors.As(err, &qErr) { t.Fatalf("Expected QueryError, got: %T - %v", err, err) } if qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("Expected NOTFOUND error type, got: %v", qErr.GetErrorType()) } }) t.Run("List_CacheHit", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with KeyRing items under LIST cache key attrs1, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring-1", "uniqueAttr": "us|test-keyring-1", }) _ = attrs1.Set("uniqueAttr", "us|test-keyring-1") attrs2, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring-2", "uniqueAttr": "us|test-keyring-2", }) _ = attrs2.Set("uniqueAttr", "us|test-keyring-2") item1 := &sdp.Item{ Type: gcpshared.CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs1, Scope: projectID, } item2 := &sdp.Item{ Type: gcpshared.CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs2, Scope: projectID, } listCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_LIST, projectID, gcpshared.CloudKMSKeyRing.String(), "") cache.StoreItem(ctx, item1, shared.DefaultCacheDuration, listCacheKey) cache.StoreItem(ctx, item2, shared.DefaultCacheDuration, listCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, qErr := listable.List(ctx, wrapper.Scopes()[0], false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("List_CacheHit_Empty", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Store NOTFOUND error in cache to simulate empty result notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "No resources found in Cloud Asset API", } listCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_LIST, projectID, gcpshared.CloudKMSKeyRing.String(), "") cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, listCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } items, qErr := listable.List(ctx, wrapper.Scopes()[0], false) if qErr != nil { t.Fatalf("Expected no error (empty list is valid), got: %v", qErr) } // Empty result is valid for LIST - should return empty slice, not error if len(items) != 0 { t.Fatalf("Expected 0 items (empty result), got: %d", len(items)) } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no key rings found for list", } listCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_LIST, projectID, gcpshared.CloudKMSKeyRing.String(), "") cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, listCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) scope := wrapper.Scopes()[0] items, qErr := listable.List(ctx, scope, false) if qErr != nil { t.Fatalf("first List: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("first List: expected 0 items, got %d", len(items)) } cacheHit, _, _, cachedErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List after first call") } if cachedErr == nil || cachedErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List, got %v", cachedErr) } items, qErr = listable.List(ctx, scope, false) if qErr != nil { t.Fatalf("second List: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("second List: expected 0 items, got %d", len(items)) } }) t.Run("Search_CacheHit_ByLocation", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with KeyRing items under SEARCH cache key (by location) attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring", "uniqueAttr": "us|test-keyring", }) _ = attrs.Set("uniqueAttr", "us|test-keyring") item := &sdp.Item{ Type: gcpshared.CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, } searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSKeyRing.String(), "us") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], "us", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 1 { t.Fatalf("Expected 1 item, got: %d", len(items)) } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no key rings found for search query", } query := "us-central1" searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSKeyRing.String(), query) cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) scope := wrapper.Scopes()[0] items, qErr := searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("first Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, cachedErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if cachedErr == nil || cachedErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", cachedErr) } items, qErr = searchable.Search(ctx, scope, query, false) if qErr != nil { t.Fatalf("second Search: unexpected error: %v", qErr) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("Search_TerraformFormat", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with KeyRing item attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us-central1/keyRings/my-keyring", "uniqueAttr": "us-central1|my-keyring", }) _ = attrs.Set("uniqueAttr", "us-central1|my-keyring") item := &sdp.Item{ Type: gcpshared.CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, } // Store with location-based search key (terraform format is converted to location) searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSKeyRing.String(), "us-central1") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } // Search using terraform-style path format // The SearchStream will extract the location and search by that terraformID := "projects/test-project-id/locations/us-central1/keyRings/my-keyring" items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], terraformID, false) if qErr != nil { t.Fatalf("Expected no error with terraform format, got: %v", qErr) } if len(items) != 1 { t.Fatalf("Expected 1 item with terraform format, got: %d", len(items)) } // Verify the returned item has the correct unique attribute uniqueAttr, err := items[0].GetAttributes().Get("uniqueAttr") if err != nil { t.Fatalf("Failed to get uniqueAttr: %v", err) } if uniqueAttr != "us-central1|my-keyring" { t.Fatalf("Expected uniqueAttr 'us-central1|my-keyring', got: %v", uniqueAttr) } }) t.Run("Search_LegacyLocationFormat", func(t *testing.T) { cache := sdpcache.NewCache(ctx) defer cache.Clear() // Pre-populate cache with KeyRing item attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us-central1/keyRings/my-keyring", "uniqueAttr": "us-central1|my-keyring", }) _ = attrs.Set("uniqueAttr", "us-central1|my-keyring") item := &sdp.Item{ Type: gcpshared.CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, } // Store with location-based search key searchCacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_SEARCH, projectID, gcpshared.CloudKMSKeyRing.String(), "us-central1") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, searchCacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } // Search using legacy location format legacyQuery := "us-central1" items, qErr := searchable.Search(ctx, wrapper.Scopes()[0], legacyQuery, false) if qErr != nil { t.Fatalf("Expected no error with legacy format, got: %v", qErr) } if len(items) != 1 { t.Fatalf("Expected 1 item with legacy format, got: %d", len(items)) } // Verify the returned item has the correct unique attribute uniqueAttr, err := items[0].GetAttributes().Get("uniqueAttr") if err != nil { t.Fatalf("Failed to get uniqueAttr: %v", err) } if uniqueAttr != "us-central1|my-keyring" { t.Fatalf("Expected uniqueAttr 'us-central1|my-keyring', got: %v", uniqueAttr) } }) t.Run("StaticTests", func(t *testing.T) { cache := sdpcache.NewMemoryCache() defer cache.Clear() // Pre-populate cache with a KeyRing item attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "projects/test-project-id/locations/us/keyRings/test-keyring", "uniqueAttr": "us|test-keyring", }) _ = attrs.Set("uniqueAttr", "us|test-keyring") item := &sdp.Item{ Type: gcpshared.CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: projectID, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: gcpshared.IAMPolicy.String(), Method: sdp.QueryMethod_GET, Query: "us|test-keyring", Scope: projectID, }, }, { Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_SEARCH, Query: "us|test-keyring", Scope: projectID, }, }, }, } cacheKey := sdpcache.CacheKeyFromParts("gcp-source", sdp.QueryMethod_GET, projectID, gcpshared.CloudKMSKeyRing.String(), "us|test-keyring") cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) loader := gcpshared.NewCloudKMSAssetLoader(nil, projectID, cache, "gcp-source", []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) wrapper := manual.NewCloudKMSKeyRing(loader, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, cache) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "us|test-keyring", false) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.IAMPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us|test-keyring", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "us|test-keyring", ExpectedScope: "test-project-id", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) } ================================================ FILE: sources/gcp/manual/compute-address.go ================================================ package manual import ( "context" "errors" "fmt" "strings" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeAddressLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeAddress) type computeAddressWrapper struct { client gcpshared.ComputeAddressClient *gcpshared.RegionBase } // NewComputeAddress creates a new computeAddressWrapper. func NewComputeAddress(client gcpshared.ComputeAddressClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeAddressWrapper{ client: client, RegionBase: gcpshared.NewRegionBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, gcpshared.ComputeAddress, ), } } func (c computeAddressWrapper) IAMPermissions() []string { return []string{ "compute.addresses.get", "compute.addresses.list", } } func (c computeAddressWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeAddressWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( stdlib.NetworkIP, gcpshared.ComputeAddress, gcpshared.ComputeSubnetwork, gcpshared.ComputeNetwork, gcpshared.ComputeForwardingRule, gcpshared.ComputeGlobalForwardingRule, gcpshared.ComputeInstance, gcpshared.ComputeTargetVpnGateway, gcpshared.ComputeRouter, gcpshared.ComputePublicDelegatedPrefix, ) } func (c computeAddressWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_address.name", }, } } func (c computeAddressWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeAddressLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute addresses since they use aggregatedList func (c computeAddressWrapper) SupportsWildcardScope() bool { return true } func (c computeAddressWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetAddressRequest{ Project: location.ProjectID, Region: location.Region, Address: queryParts[0], } address, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeAddressToSDPItem(ctx, address, location) } func (c computeAddressWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeAddressWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-region List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListAddressesRequest{ Project: location.ProjectID, Region: location.Region, }) var itemsSent int var hadError bool for { address, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeAddressToSDPItem(ctx, address, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute addresses found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all addresses across all regions func (c computeAddressWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListAddressesRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "regions/us-central1") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process addresses in this scope if pair.Value != nil && pair.Value.GetAddresses() != nil { for _, address := range pair.Value.GetAddresses() { item, sdpErr := c.gcpComputeAddressToSDPItem(ctx, address, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute addresses found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeAddressWrapper) gcpComputeAddressToSDPItem(ctx context.Context, address *computepb.Address, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(address, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeAddress.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: address.GetLabels(), } if network := address.GetNetwork(); network != "" { if strings.Contains(network, "/") { networkName := gcpshared.LastPathComponent(network) scope, err := gcpshared.ExtractScopeFromURI(ctx, network) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetwork.String(), Method: sdp.QueryMethod_GET, Query: networkName, Scope: scope, }, }) } } } if subnetwork := address.GetSubnetwork(); subnetwork != "" { if strings.Contains(subnetwork, "/") { subnetworkName := gcpshared.LastPathComponent(subnetwork) scope, err := gcpshared.ExtractScopeFromURI(ctx, subnetwork) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSubnetwork.String(), Method: sdp.QueryMethod_GET, Query: subnetworkName, Scope: scope, }, }) } } } if ip := address.GetAddress(); ip != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: ip, Scope: "global", }, }) } // Link to resources using this address for _, userURI := range address.GetUsers() { if userURI != "" { linkedQuery := gcpshared.AddressUsersLinker( ctx, location.ProjectID, userURI, ) if linkedQuery != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, linkedQuery) } } } // Link to Public Delegated Prefix if ipCollection := address.GetIpCollection(); ipCollection != "" { if strings.Contains(ipCollection, "/") { region := gcpshared.ExtractPathParam("regions", ipCollection) prefixName := gcpshared.LastPathComponent(ipCollection) if region != "" && prefixName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputePublicDelegatedPrefix.String(), Method: sdp.QueryMethod_GET, Query: prefixName, Scope: fmt.Sprintf("%s.%s", location.ProjectID, region), }, }) } } } switch address.GetStatus() { case computepb.Address_RESERVING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.Address_UNDEFINED_STATUS.String(): sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case computepb.Address_RESERVED.String(), computepb.Address_IN_USE.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-address_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeAddress(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeAddressClient(ctrl) projectID := "test-project-id" region := "us-central1" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeAddress(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeAddress("test-address"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-address", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeAddress(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeAddressIterator(ctrl) // Add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeAddress("test-address-1"), nil) mockComputeIterator.EXPECT().Next().Return(createComputeAddress("test-address-2"), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeAddress(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeAddressIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeAddress("test-address-1"), nil) mockComputeIterator.EXPECT().Next().Return(createComputeAddress("test-address-2"), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeAddressClient(ctrl) projectID := "cache-test-project" region := "us-central1" scope := projectID + "." + region mockAggIter := mocks.NewMockAddressesScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.AddressesScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeAddressIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeAddress(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) t.Run("GetWithUsers", func(t *testing.T) { wrapper := manual.NewComputeAddress(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) // Test with various user resource types users := []string{ // Regional forwarding rule fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/forwardingRules/test-forwarding-rule", projectID, region), // Global forwarding rule fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/forwardingRules/test-global-forwarding-rule", projectID), // VM Instance fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/us-central1-a/instances/test-instance", projectID), // Target VPN Gateway fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/targetVpnGateways/test-vpn-gateway", projectID, region), // Router fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/routers/test-router", projectID, region), } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeAddressWithUsers("test-address-with-users", users), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-address-with-users", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, // Subnetwork link { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // IP address link { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, // Regional forwarding rule link (from users) { ExpectedType: gcpshared.ComputeForwardingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-forwarding-rule", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // Global forwarding rule link (from users) { ExpectedType: gcpshared.ComputeGlobalForwardingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-global-forwarding-rule", ExpectedScope: projectID, }, // Instance link (from users) { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.us-central1-a", projectID), }, // Target VPN Gateway link (from users) { ExpectedType: gcpshared.ComputeTargetVpnGateway.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-vpn-gateway", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // Router link (from users) { ExpectedType: gcpshared.ComputeRouter.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-router", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithIPCollection", func(t *testing.T) { wrapper := manual.NewComputeAddress(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) ipCollection := fmt.Sprintf("projects/%s/regions/%s/publicDelegatedPrefixes/test-prefix", projectID, region) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeAddressWithIPCollection("test-address-with-ip-collection", ipCollection), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-address-with-ip-collection", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, // Subnetwork link { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, // IP address link { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, // Public Delegated Prefix link (from ipCollection) { ExpectedType: gcpshared.ComputePublicDelegatedPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-prefix", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) } func createComputeAddress(addressName string) *computepb.Address { return &computepb.Address{ Name: new(addressName), Labels: map[string]string{"env": "test"}, Network: new("https://www.googleapis.com/compute/v1/projects/test-project-id/global/networks/network"), Subnetwork: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/subnetworks/default"), Address: new("192.168.1.3"), } } func createComputeAddressWithUsers(addressName string, users []string) *computepb.Address { addr := createComputeAddress(addressName) addr.Users = users return addr } func createComputeAddressWithIPCollection(addressName string, ipCollection string) *computepb.Address { addr := createComputeAddress(addressName) addr.IpCollection = new(ipCollection) return addr } ================================================ FILE: sources/gcp/manual/compute-autoscaler.go ================================================ package manual import ( "context" "errors" "strings" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeAutoscalerLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeAutoscaler) type computeAutoscalerWrapper struct { client gcpshared.ComputeAutoscalerClient *gcpshared.ZoneBase } // NewComputeAutoscaler creates a new computeAutoscalerWrapper instance. func NewComputeAutoscaler(client gcpshared.ComputeAutoscalerClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeAutoscalerWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, gcpshared.ComputeAutoscaler, ), } } func (c computeAutoscalerWrapper) IAMPermissions() []string { return []string{ "compute.autoscalers.get", "compute.autoscalers.list", } } func (c computeAutoscalerWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeAutoscalerWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeInstanceGroupManager, ) } func (c computeAutoscalerWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_address#argument-reference TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_autoscaler.name", }, } } func (c computeAutoscalerWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeAutoscalerLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute autoscalers since they use aggregatedList func (c computeAutoscalerWrapper) SupportsWildcardScope() bool { return true } // Get retrieves an autoscaler by its name for a specific scope. func (c computeAutoscalerWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetAutoscalerRequest{ Project: location.ProjectID, Zone: location.Zone, Autoscaler: queryParts[0], } autoscaler, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeAutoscalerToSDPItem(ctx, autoscaler, location) } // List lists autoscalers for a specific scope. func (c computeAutoscalerWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } // ListStream lists autoscalers for a specific scope and sends them to a stream. func (c computeAutoscalerWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } results := c.client.List(ctx, &computepb.ListAutoscalersRequest{ Project: location.ProjectID, Zone: location.Zone, }) var itemsSent int var hadError bool for { autoscaler, iterErr := results.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeAutoscalerToSDPItem(ctx, autoscaler, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute autoscalers found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all autoscalers across all zones func (c computeAutoscalerWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListAutoscalersRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process autoscalers in this scope if pair.Value != nil && pair.Value.GetAutoscalers() != nil { for _, autoscaler := range pair.Value.GetAutoscalers() { item, sdpErr := c.gcpComputeAutoscalerToSDPItem(ctx, autoscaler, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute autoscalers found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeAutoscalerWrapper) gcpComputeAutoscalerToSDPItem(ctx context.Context, autoscaler *computepb.Autoscaler, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(autoscaler) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeAutoscaler.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), // Autoscalers don't have labels. } instanceGroupManagerName := autoscaler.GetTarget() if instanceGroupManagerName != "" { igmNameParts := strings.Split(instanceGroupManagerName, "/") igmName := igmNameParts[len(igmNameParts)-1] scope, err := gcpshared.ExtractScopeFromURI(ctx, instanceGroupManagerName) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstanceGroupManager.String(), Method: sdp.QueryMethod_GET, Query: igmName, Scope: scope, }, }) } } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-autoscaler_test.go ================================================ package manual_test import ( "context" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeAutoscalerWrapper(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeAutoscalerClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" t.Run("Get", func(t *testing.T) { // Attach mock client to our wrapper. wrapper := manual.NewComputeAutoscaler(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createAutoscalerApiFixture("test-autoscaler"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-autoscaler", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // [SPEC] The default scope is a combined zone and project id. if sdpItem.GetScope() != "test-project-id.us-central1-a" { t.Fatalf("Expected scope to be 'test-project-id.us-central1-a', got: %s", sdpItem.GetScope()) } // [SPEC] Autoscalers have one link: the targeted Instance Group Manager. if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Fatalf("Expected 1 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } t.Run("Attributes", func(t *testing.T) { // Check for a few attributes from the fixture to make sure they were copied properly. // These will not really fail ever unless the underlying shared sources change; so it's more of a sanity check. attributes := sdpItem.GetAttributes() name, err := attributes.Get("name") if err != nil { t.Fatalf("Error getting name attribute: %v", err) } if name.(string) != "test-autoscaler" { t.Fatalf("Expected name to be 'test-autoscaler', got: %s", name) } // Nested attributes. minReplicas, err := attributes.Get("autoscaling_policy.min_num_replicas") if err != nil { t.Fatalf("Error getting MinNumReplicas attribute: %v", err) } if minReplicas.(float64) != 1 { t.Fatalf("Expected minNumReplicas to be 1, got: %d", minReplicas) } }) t.Run("StaticTests", func(t *testing.T) { // [SPEC] An autoscaler is linked to a instance group manager. The query will // match the name of the IGM resource, and the scope is the same zone. queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeInstanceGroupManager.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance-group", ExpectedScope: "test-project-id.us-central1-a", // [SPEC] Autoscalers are tightly coupled with the instance group manager // (albeit less strength on the IN direction). }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeAutoscaler(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeAutoscalerIter := mocks.NewMockComputeAutoscalerIterator(ctrl) // Mock out items listed from the API. mockComputeAutoscalerIter.EXPECT().Next().Return(createAutoscalerApiFixture("test-autoscaler-1"), nil) mockComputeAutoscalerIter.EXPECT().Next().Return(createAutoscalerApiFixture("test-autoscaler-2"), nil) mockComputeAutoscalerIter.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeAutoscalerIter) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeAutoscaler(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeAutoscalerIter := mocks.NewMockComputeAutoscalerIterator(ctrl) mockComputeAutoscalerIter.EXPECT().Next().Return(createAutoscalerApiFixture("test-autoscaler-1"), nil) mockComputeAutoscalerIter.EXPECT().Next().Return(createAutoscalerApiFixture("test-autoscaler-2"), nil) mockComputeAutoscalerIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeAutoscalerIter) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item); wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeAutoscalerClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone mockAggIter := mocks.NewMockAutoscalersScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.AutoscalersScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeAutoscalerIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeAutoscaler(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } // Create an autoscaler fixture (as returned from GCP API). func createAutoscalerApiFixture(autoscalerName string) *computepb.Autoscaler { return &computepb.Autoscaler{ Name: new(autoscalerName), Target: new("https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a/instanceGroupManagers/test-instance-group"), AutoscalingPolicy: &computepb.AutoscalingPolicy{ MinNumReplicas: new(int32(1)), MaxNumReplicas: new(int32(5)), CpuUtilization: &computepb.AutoscalingPolicyCpuUtilization{ UtilizationTarget: new(float64(0.6)), }, }, Zone: new("us-central1-a"), } } ================================================ FILE: sources/gcp/manual/compute-backend-service.go ================================================ package manual import ( "context" "errors" "fmt" "slices" "strings" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeBackendServiceLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeBackendService) type computeBackendServiceWrapper struct { globalClient gcpshared.ComputeBackendServiceClient regionalClient gcpshared.ComputeRegionBackendServiceClient projectLocations []gcpshared.LocationInfo // For global backend services regionLocations []gcpshared.LocationInfo // For regional backend services *shared.Base } // NewComputeBackendService creates a new computeBackendServiceWrapper instance that handles both global and regional backend services. func NewComputeBackendService(globalClient gcpshared.ComputeBackendServiceClient, regionalClient gcpshared.ComputeRegionBackendServiceClient, projectLocations []gcpshared.LocationInfo, regionLocations []gcpshared.LocationInfo) sources.ListStreamableWrapper { // Combine all locations for scope generation allLocations := make([]gcpshared.LocationInfo, 0, len(projectLocations)+len(regionLocations)) allLocations = append(allLocations, projectLocations...) allLocations = append(allLocations, regionLocations...) scopes := make([]string, 0, len(allLocations)) for _, location := range allLocations { scopes = append(scopes, location.ToScope()) } return &computeBackendServiceWrapper{ globalClient: globalClient, regionalClient: regionalClient, projectLocations: projectLocations, regionLocations: regionLocations, Base: shared.NewBase(sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeBackendService, scopes), } } // validateAndParseScope parses the scope and validates it against configured locations. // Returns the LocationInfo if valid, or a QueryError if the scope is invalid or not configured. func (c computeBackendServiceWrapper) validateAndParseScope(scope string) (gcpshared.LocationInfo, *sdp.QueryError) { location, err := gcpshared.LocationFromScope(scope) if err != nil { return gcpshared.LocationInfo{}, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } // Check if the location is in the adapter's configured locations allLocations := append([]gcpshared.LocationInfo{}, c.projectLocations...) allLocations = append(allLocations, c.regionLocations...) if slices.ContainsFunc(allLocations, location.Equals) { return location, nil } return gcpshared.LocationInfo{}, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("scope %s not found in adapter's configured locations", scope), } } func (c computeBackendServiceWrapper) IAMPermissions() []string { return []string{ "compute.backendServices.get", "compute.backendServices.list", "compute.regionBackendServices.get", "compute.regionBackendServices.list", } } func (c computeBackendServiceWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (computeBackendServiceWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeNetwork, gcpshared.ComputeSecurityPolicy, gcpshared.NetworkSecurityClientTlsPolicy, gcpshared.NetworkServicesServiceLbPolicy, gcpshared.NetworkServicesServiceBinding, gcpshared.ComputeInstanceGroup, gcpshared.ComputeNetworkEndpointGroup, gcpshared.ComputeHealthCheck, gcpshared.ComputeInstance, gcpshared.ComputeRegion, ) } func (c computeBackendServiceWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_backend_service.name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_region_backend_service.name", }, } } func (c computeBackendServiceWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeBackendServiceLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for backend services since they use aggregatedList func (c computeBackendServiceWrapper) SupportsWildcardScope() bool { return true } func (c computeBackendServiceWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { // Parse and validate the scope location, err := c.validateAndParseScope(scope) if err != nil { return nil, err } // Route to the appropriate API based on whether the scope includes a region if location.Regional() { // Regional backend service req := &computepb.GetRegionBackendServiceRequest{ Project: location.ProjectID, Region: location.Region, BackendService: queryParts[0], } service, getErr := c.regionalClient.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return gcpComputeBackendServiceToSDPItem(ctx, location.ProjectID, location.ToScope(), service, gcpshared.ComputeBackendService) } // Global backend service req := &computepb.GetBackendServiceRequest{ Project: location.ProjectID, BackendService: queryParts[0], } service, getErr := c.globalClient.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return gcpComputeBackendServiceToSDPItem(ctx, location.ProjectID, location.ToScope(), service, gcpshared.ComputeBackendService) } func (c computeBackendServiceWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeBackendServiceWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Parse and validate the scope location, err := c.validateAndParseScope(scope) if err != nil { stream.SendError(err) return } // Route to the appropriate API based on whether the scope includes a region var itemsSent int var hadError bool if location.Regional() { // Regional backend services it := c.regionalClient.List(ctx, &computepb.ListRegionBackendServicesRequest{ Project: location.ProjectID, Region: location.Region, }) for { backendService, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := gcpComputeBackendServiceToSDPItem(ctx, location.ProjectID, location.ToScope(), backendService, gcpshared.ComputeBackendService) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } } else { // Global backend services it := c.globalClient.List(ctx, &computepb.ListBackendServicesRequest{ Project: location.ProjectID, }) for { backendService, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := gcpComputeBackendServiceToSDPItem(ctx, location.ProjectID, location.ToScope(), backendService, gcpshared.ComputeBackendService) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute backend services found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all backend services across all regions (global and regional) func (c computeBackendServiceWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.projectLocations, c.regionLocations) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.globalClient.AggregatedList(ctx, &computepb.AggregatedListBackendServicesRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "global" or "regions/us-central1") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.projectLocations, c.regionLocations) { continue } // Process backend services in this scope if pair.Value != nil && pair.Value.GetBackendServices() != nil { for _, backendService := range pair.Value.GetBackendServices() { item, sdpErr := gcpComputeBackendServiceToSDPItem(ctx, scopeLocation.ProjectID, scopeLocation.ToScope(), backendService, gcpshared.ComputeBackendService) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute backend services found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func gcpComputeBackendServiceToSDPItem(ctx context.Context, projectID string, scope string, bs *computepb.BackendService, itemType shared.ItemType) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(bs) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: itemType.String(), UniqueAttribute: "name", Attributes: attributes, Scope: scope, } // The URL of the network to which this backend service belongs. // This field must be set for Internal Passthrough Network Load Balancers when the haPolicy is enabled, // and for External Passthrough Network Load Balancers when the haPolicy fastIpMove is enabled. // This field can only be specified when the load balancing scheme is set to INTERNAL. if network := bs.GetNetwork(); network != "" { if strings.Contains(network, "/") { networkName := gcpshared.LastPathComponent(network) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetwork.String(), Method: sdp.QueryMethod_GET, Query: networkName, // This is a global resource Scope: projectID, }, }) } } // TODO: We need keyring as well for linking keys. // So, at this point, without a proper integration tests, we don't have enough confidence to link this. // Names of the keys for signing request URLs. // signedURLKeyNames := bs.GetCdnPolicy().GetSignedUrlKeyNames() // The resource URL for the security policy associated with this backend service. // GET https://compute.googleapis.com/compute/v1/projects/{project}/global/securityPolicies/{securityPolicy} // https://cloud.google.com/compute/docs/reference/rest/v1/securityPolicies/get if securityPolicy := bs.GetSecurityPolicy(); securityPolicy != "" { if strings.Contains(securityPolicy, "/") { securityPolicyName := gcpshared.LastPathComponent(securityPolicy) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSecurityPolicy.String(), Method: sdp.QueryMethod_GET, Query: securityPolicyName, Scope: projectID, }, }) } } // The resource URL for the edge security policy associated with this backend service. if edgeSecurityPolicy := bs.GetEdgeSecurityPolicy(); edgeSecurityPolicy != "" { if strings.Contains(edgeSecurityPolicy, "/") { edgeSecurityPolicyName := gcpshared.LastPathComponent(edgeSecurityPolicy) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSecurityPolicy.String(), Method: sdp.QueryMethod_GET, Query: edgeSecurityPolicyName, Scope: projectID, }, }) } } // Optional. A URL referring to a networksecurity.ClientTlsPolicy resource that describes how clients should authenticate with this service's backends. // clientTlsPolicy only applies to a global BackendService with the loadBalancingScheme set to INTERNAL_SELF_MANAGED. // If left blank, communications are not encrypted. if bs.GetSecuritySettings() != nil { if clientTlsPolicy := bs.GetSecuritySettings().GetClientTlsPolicy(); clientTlsPolicy != "" { // The URL should look like this: // GET https://networksecurity.googleapis.com/v1/{name=projects/*/locations/*/clientTlsPolicies/*} // See: https://cloud.google.com/service-mesh/docs/reference/network-security/rest/v1/projects.locations.clientTlsPolicies/get // This will be a global resource but it will require a location dynamically. // So, we need to extract the location and the policy name from the URL. if strings.Contains(clientTlsPolicy, "/") { params := gcpshared.ExtractPathParams(clientTlsPolicy, "locations", "clientTlsPolicies") if len(params) == 2 && params[0] != "" && params[1] != "" { location := params[0] policyName := params[1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ // The resource name will be: "gcp-network-security-client-tls-policy" Type: gcpshared.NetworkSecurityClientTlsPolicy.String(), Method: sdp.QueryMethod_GET, // This is a global resource but it will require a location dynamically. Query: shared.CompositeLookupKey(location, policyName), Scope: projectID, }, }) } } } } // Health checks are used by the backend service to probe the health of its backends. // At most one health check can be specified per backend service. // For regional backend services, these are typically regional health checks. // GET https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/healthChecks/{healthCheck} // or GET https://compute.googleapis.com/compute/v1/projects/{project}/global/healthChecks/{healthCheck} // https://cloud.google.com/compute/docs/reference/rest/v1/regionHealthChecks/get // https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/get if healthChecks := bs.GetHealthChecks(); len(healthChecks) > 0 { // At most one health check is allowed, but we iterate in case multiple are present for _, healthCheckURL := range healthChecks { if healthCheckURL != "" && strings.Contains(healthCheckURL, "/") { // Extract scope from the health check URL (could be global or regional) healthCheckScope, err := gcpshared.ExtractScopeFromURI(ctx, healthCheckURL) if err != nil { // If scope extraction fails, skip this health check continue } // Extract health check name from URL healthCheckName := gcpshared.LastPathComponent(healthCheckURL) if healthCheckName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: healthCheckName, Scope: healthCheckScope, }, }) } } } } for _, backend := range bs.GetBackends() { if backend.GetGroup() != "" { // The group field is a URL to a Compute Instance Group or Network Endpoint Group. // We can link it to the Compute Instance Group or Network Endpoint Group. if strings.Contains(backend.GetGroup(), "/nodeGroups/") { // https://cloud.google.com/compute/docs/reference/rest/v1/nodeGroups/get#http-request params := gcpshared.ExtractPathParams(backend.GetGroup(), "zones", "nodeGroups") if len(params) == 2 && params[0] != "" && params[1] != "" { zone := params[0] groupName := params[1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstanceGroup.String(), Method: sdp.QueryMethod_GET, Query: groupName, Scope: zone, }, }) } } if strings.Contains(backend.GetGroup(), "/networkEndpointGroups/") { // Network Endpoint Groups can be zonal, regional, or global // https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/get // https://cloud.google.com/compute/docs/reference/rest/v1/regionNetworkEndpointGroups/get // https://cloud.google.com/compute/docs/reference/rest/v1/globalNetworkEndpointGroups/get // Extract scope from the NEG URL negScope, err := gcpshared.ExtractScopeFromURI(ctx, backend.GetGroup()) if err != nil { // Fallback to zonal extraction for backward compatibility params := gcpshared.ExtractPathParams(backend.GetGroup(), "zones", "networkEndpointGroups") if len(params) == 2 && params[0] != "" && params[1] != "" { zone := params[0] negName := params[1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetworkEndpointGroup.String(), Method: sdp.QueryMethod_GET, Query: negName, Scope: zone, }, }) } } else { // Use scope extraction for zonal, regional, or global NEGs negName := gcpshared.LastPathComponent(backend.GetGroup()) if negName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetworkEndpointGroup.String(), Method: sdp.QueryMethod_GET, Query: negName, Scope: negScope, }, }) } } } // Also check for instanceGroups (unmanaged instance groups) if strings.Contains(backend.GetGroup(), "/instanceGroups/") { // https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroups/get params := gcpshared.ExtractPathParams(backend.GetGroup(), "zones", "instanceGroups") if len(params) == 2 && params[0] != "" && params[1] != "" { zone := params[0] groupName := params[1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstanceGroup.String(), Method: sdp.QueryMethod_GET, Query: groupName, Scope: zone, }, }) } } } } // URL to networkservices.ServiceLbPolicy resource. Can only be set if load balancing scheme is EXTERNAL, EXTERNAL_MANAGED, INTERNAL_MANAGED or INTERNAL_SELF_MANAGED and the scope is global. // GET https://networkservices.googleapis.com/v1/{name=projects/*/locations/*/serviceLbPolicies/*} // https://cloud.google.com/service-mesh/docs/reference/network-services/rest/v1/projects.locations.serviceLbPolicies/get if serviceLbPolicy := bs.GetServiceLbPolicy(); serviceLbPolicy != "" { if strings.Contains(serviceLbPolicy, "/") { params := gcpshared.ExtractPathParams(serviceLbPolicy, "locations", "serviceLbPolicies") if len(params) == 2 && params[0] != "" && params[1] != "" { location := params[0] policyName := params[1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.NetworkServicesServiceLbPolicy.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(location, policyName), Scope: projectID, }, }) } } } // URLs of networkservices.ServiceBinding resources. Can only be set if load balancing scheme is INTERNAL_SELF_MANAGED. If set, lists of backends and health checks must be both empty. // GET https://networkservices.googleapis.com/v1alpha1/{name=projects/*/locations/*/serviceBindings/*} // https://cloud.google.com/service-mesh/docs/reference/network-services/rest/v1alpha1/projects.locations.serviceBindings/get if serviceBindings := bs.GetServiceBindings(); serviceBindings != nil { for _, serviceBinding := range serviceBindings { if strings.Contains(serviceBinding, "/") { params := gcpshared.ExtractPathParams(serviceBinding, "locations", "serviceBindings") if len(params) == 2 && params[0] != "" && params[1] != "" { location := params[0] bindingName := params[1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.NetworkServicesServiceBinding.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(location, bindingName), Scope: projectID, }, }) } } } } // HA Policy (High Availability Policy) for External Passthrough and Internal Passthrough Network Load Balancers. // Used for self-managed high availability with zonal NEG backends. // GET https://cloud.google.com/compute/docs/reference/rest/v1/backendServices#BackendService if haPolicy := bs.GetHaPolicy(); haPolicy != nil { if leader := haPolicy.GetLeader(); leader != nil { // Link to the Network Endpoint Group containing the leader endpoint // haPolicy.leader.backendGroup is a fully-qualified URL of the zonal NEG containing the leader endpoint. // GET https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/networkEndpointGroups/{networkEndpointGroup} // https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/get if backendGroup := leader.GetBackendGroup(); backendGroup != "" { negScope, err := gcpshared.ExtractScopeFromURI(ctx, backendGroup) if err == nil { negName := gcpshared.LastPathComponent(backendGroup) if negName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetworkEndpointGroup.String(), Method: sdp.QueryMethod_GET, Query: negName, Scope: negScope, }, }) } } } // Link to the Compute Instance designated as leader // haPolicy.leader.networkEndpoint.instance is the name of the VM instance in the NEG to be leader. // GET https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/instances/{instance} // https://cloud.google.com/compute/docs/reference/rest/v1/instances/get if networkEndpoint := leader.GetNetworkEndpoint(); networkEndpoint != nil { if instanceName := networkEndpoint.GetInstance(); instanceName != "" { // The instance name alone is not enough - we need to extract the zone from the backendGroup // Since the leader must be in the same NEG as specified in backendGroup, we can extract zone from there if backendGroup := leader.GetBackendGroup(); backendGroup != "" { // Extract zone from backendGroup URL zone := gcpshared.ExtractPathParam("zones", backendGroup) if zone != "" { instanceScope := gcpshared.ZonalScope(projectID, zone) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstance.String(), Method: sdp.QueryMethod_GET, Query: instanceName, Scope: instanceScope, }, }) } } } } } } // The URL of the region where the regional backend service resides. // This field is output-only and is not applicable to global backend services. // GET https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region} // https://cloud.google.com/compute/docs/reference/rest/v1/regions/get if region := bs.GetRegion(); region != "" { if strings.Contains(region, "/") { regionNameParts := strings.Split(region, "/") regionName := regionNameParts[len(regionNameParts)-1] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRegion.String(), Method: sdp.QueryMethod_GET, Query: regionName, // Regions are project-scoped resources Scope: projectID, }, }) } } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-backend-service_test.go ================================================ package manual_test import ( "context" "fmt" "strings" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeBackendService(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockGlobalClient := mocks.NewMockComputeBackendServiceClient(ctrl) mockRegionalClient := mocks.NewMockComputeRegionBackendServiceClient(ctrl) projectID := "test-project" t.Run("Get-Scope-Validation-Global", func(t *testing.T) { // Adapter configured for project-level only (global resources) wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Attempt to query a regional scope that wasn't configured unauthorizedScope := fmt.Sprintf("%s.us-central1", projectID) _, qErr := wrapper.Get(ctx, unauthorizedScope, "test-backend-service") // Should fail with NOSCOPE error since us-central1 wasn't configured if qErr == nil { t.Fatal("Expected error when querying unconfigured regional scope, got nil") } if qErr.GetErrorType() != sdp.QueryError_NOSCOPE { t.Errorf("Expected NOSCOPE error, got: %v (error: %s)", qErr.GetErrorType(), qErr.GetErrorString()) } }) t.Run("Get-Scope-Validation-Regional", func(t *testing.T) { // Adapter configured for us-west1 only wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, nil, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, "us-west1")}) // Attempt to query us-central1 which wasn't configured unauthorizedScope := fmt.Sprintf("%s.us-central1", projectID) _, qErr := wrapper.Get(ctx, unauthorizedScope, "test-backend-service") // Should fail with NOSCOPE error since us-central1 wasn't configured if qErr == nil { t.Fatal("Expected error when querying unconfigured regional scope, got nil") } if qErr.GetErrorType() != sdp.QueryError_NOSCOPE { t.Errorf("Expected NOSCOPE error, got: %v (error: %s)", qErr.GetErrorType(), qErr.GetErrorString()) } }) t.Run("ListStream-Scope-Validation-Global", func(t *testing.T) { // Adapter configured for project-level only (global resources) wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Attempt to list from a regional scope that wasn't configured unauthorizedScope := fmt.Sprintf("%s.us-central1", projectID) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) cache := sdpcache.NewNoOpCache() wrapper.ListStream(ctx, stream, cache, sdpcache.CacheKey{}, unauthorizedScope) // Should fail with NOSCOPE error since us-central1 wasn't configured if len(errs) == 0 { t.Fatal("Expected error when listing from unconfigured regional scope, got none") } // The error should contain scope-related error message if len(errs) > 0 { // The first error should be a QueryError about scope expectedError := "scope" if err := errs[0]; err == nil || err.Error() == "" { t.Errorf("Expected error containing '%s', got nil or empty error", expectedError) } else if err := errs[0]; !strings.Contains(err.Error(), expectedError) { t.Errorf("Expected error containing '%s', got: %v", expectedError, err) } } }) t.Run("Get-Global", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeBackendService("test-backend-service"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-backend-service", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: "test-project", }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy", ExpectedScope: "test-project", }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-edge-security-policy", ExpectedScope: "test-project", }, { ExpectedType: gcpshared.NetworkSecurityClientTlsPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-client-tls-policy", ExpectedScope: "test-project", }, { ExpectedType: gcpshared.NetworkServicesServiceLbPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-lb-policy", ExpectedScope: "test-project", }, { ExpectedType: gcpshared.NetworkServicesServiceBinding.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-binding", ExpectedScope: "test-project", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List-Global", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockBackendServiceIterator := mocks.NewMockComputeBackendServiceIterator(ctrl) mockBackendServiceIterator.EXPECT().Next().Return(createComputeBackendService("test-backend-service"), nil) mockBackendServiceIterator.EXPECT().Next().Return(createComputeBackendService("test-backend-service"), nil) mockBackendServiceIterator.EXPECT().Next().Return(nil, iterator.Done) mockGlobalClient.EXPECT().List(ctx, gomock.Any()).Return(mockBackendServiceIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream-Global", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockBackendServiceIterator := mocks.NewMockComputeBackendServiceIterator(ctrl) // add mock implementation here mockBackendServiceIterator.EXPECT().Next().Return(createComputeBackendService("test-backend-service-1"), nil) mockBackendServiceIterator.EXPECT().Next().Return(createComputeBackendService("test-backend-service-2"), nil) mockBackendServiceIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockGlobalClient.EXPECT().List(ctx, gomock.Any()).Return(mockBackendServiceIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockGlobalClient := mocks.NewMockComputeBackendServiceClient(ctrl) mockRegionalClient := mocks.NewMockComputeRegionBackendServiceClient(ctrl) projectID := "cache-test-project" mockAggIter := mocks.NewMockBackendServicesScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.BackendServicesScopedListPair{}, iterator.Done) mockGlobalClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } }) t.Run("GetWithHealthCheck", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Test with global health check healthCheckURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/healthChecks/test-health-check", projectID) backendService := createComputeBackendService("test-backend-service") backendService.HealthChecks = []string{healthCheckURL} mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(backendService, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-backend-service", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-edge-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkSecurityClientTlsPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-client-tls-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceLbPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-lb-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceBinding.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-binding", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeHealthCheck.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-health-check", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithRegionalHealthCheck", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Test with regional health check region := "us-central1" healthCheckURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/healthChecks/test-regional-health-check", projectID, region) backendService := createComputeBackendService("test-backend-service") backendService.HealthChecks = []string{healthCheckURL} mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(backendService, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-backend-service", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-edge-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkSecurityClientTlsPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-client-tls-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceLbPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-lb-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceBinding.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-binding", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeHealthCheck.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-regional-health-check", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithInstanceGroup", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Test with unmanaged instance group zone := "us-central1-a" instanceGroupURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/instanceGroups/test-instance-group", projectID, zone) backendService := createComputeBackendService("test-backend-service") backendService.Backends = []*computepb.Backend{ { Group: new(instanceGroupURL), }, } mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(backendService, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-backend-service", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-edge-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkSecurityClientTlsPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-client-tls-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceLbPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-lb-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceBinding.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-binding", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeInstanceGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance-group", ExpectedScope: zone, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithHAPolicy", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Test with HA Policy zone := "us-central1-a" backendGroupURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups/test-neg", projectID, zone) instanceName := "test-leader-instance" backendService := createComputeBackendService("test-backend-service") backendService.HaPolicy = &computepb.BackendServiceHAPolicy{ Leader: &computepb.BackendServiceHAPolicyLeader{ BackendGroup: new(backendGroupURL), NetworkEndpoint: &computepb.BackendServiceHAPolicyLeaderNetworkEndpoint{ Instance: new(instanceName), }, }, } mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(backendService, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-backend-service", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-edge-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkSecurityClientTlsPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-client-tls-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceLbPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-lb-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceBinding.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-binding", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeNetworkEndpointGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-neg", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceName, ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithRegion", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Test with region field (output-only, typically for regional backend services) region := "us-central1" regionURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s", projectID, region) backendService := createComputeBackendService("test-backend-service") backendService.Region = new(regionURL) mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(backendService, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-backend-service", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSecurityPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-edge-security-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkSecurityClientTlsPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-client-tls-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceLbPolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-lb-policy", ExpectedScope: projectID, }, { ExpectedType: gcpshared.NetworkServicesServiceBinding.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-location|test-service-binding", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeRegion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: region, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) // Regional backend service tests region := "us-central1" t.Run("Get-Regional", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, nil, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockRegionalClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeBackendService("test-regional-backend-service"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), "test-regional-backend-service", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify the item has the correct type (should be ComputeBackendService, not ComputeRegionBackendService) if sdpItem.GetType() != gcpshared.ComputeBackendService.String() { t.Fatalf("Expected type to be '%s', got: %s", gcpshared.ComputeBackendService.String(), sdpItem.GetType()) } // Verify the scope is regional if sdpItem.GetScope() != fmt.Sprintf("%s.%s", projectID, region) { t.Fatalf("Expected scope to be '%s.%s', got: %s", projectID, region, sdpItem.GetScope()) } }) t.Run("List-Regional", func(t *testing.T) { wrapper := manual.NewComputeBackendService(mockGlobalClient, mockRegionalClient, nil, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockBackendServiceIterator := mocks.NewMockComputeRegionBackendServiceIterator(ctrl) mockBackendServiceIterator.EXPECT().Next().Return(createComputeBackendService("test-regional-backend-service-1"), nil) mockBackendServiceIterator.EXPECT().Next().Return(createComputeBackendService("test-regional-backend-service-2"), nil) mockBackendServiceIterator.EXPECT().Next().Return(nil, iterator.Done) mockRegionalClient.EXPECT().List(ctx, gomock.Any()).Return(mockBackendServiceIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { // Verify each item has the correct type if item.GetType() != gcpshared.ComputeBackendService.String() { t.Fatalf("Expected type to be '%s', got: %s", gcpshared.ComputeBackendService.String(), item.GetType()) } // Verify each item has the correct regional scope if item.GetScope() != fmt.Sprintf("%s.%s", projectID, region) { t.Fatalf("Expected scope to be '%s.%s', got: %s", projectID, region, item.GetScope()) } if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) } func createComputeBackendService(name string) *computepb.BackendService { return &computepb.BackendService{ Name: new(name), Network: new("global/networks/network"), SecurityPolicy: new("https://compute.googleapis.com/compute/v1/projects/test-project/global/securityPolicies/test-security-policy"), EdgeSecurityPolicy: new("https://compute.googleapis.com/compute/v1/projects/test-project/global/securityPolicies/test-edge-security-policy"), SecuritySettings: &computepb.SecuritySettings{ ClientTlsPolicy: new("https://networksecurity.googleapis.com/v1/projects/test-project/locations/test-location/clientTlsPolicies/test-client-tls-policy"), }, ServiceLbPolicy: new(" https://networkservices.googleapis.com/v1alpha1/name=projects/test-project/locations/test-location/serviceLbPolicies/test-service-lb-policy"), ServiceBindings: []string{ "https://networkservices.googleapis.com/v1alpha1/projects/test-project/locations/test-location/serviceBindings/test-service-binding", }, } } ================================================ FILE: sources/gcp/manual/compute-disk.go ================================================ package manual import ( "context" "errors" "strings" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeDiskLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeDisk) type computeDiskWrapper struct { client gcpshared.ComputeDiskClient *gcpshared.ZoneBase } // NewComputeDisk creates a new computeDiskWrapper. func NewComputeDisk(client gcpshared.ComputeDiskClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeDiskWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, gcpshared.ComputeDisk, ), } } func (c computeDiskWrapper) IAMPermissions() []string { return []string{ "compute.disks.get", "compute.disks.list", } } func (c computeDiskWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeDiskWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeResourcePolicy, gcpshared.ComputeDisk, gcpshared.ComputeImage, gcpshared.ComputeSnapshot, gcpshared.ComputeInstantSnapshot, gcpshared.ComputeDiskType, gcpshared.ComputeInstance, gcpshared.CloudKMSCryptoKeyVersion, gcpshared.StorageBucket, gcpshared.ComputeStoragePool, ) } func (c computeDiskWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_disk.name", }, } } func (c computeDiskWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeDiskLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute disks since they use aggregatedList func (c computeDiskWrapper) SupportsWildcardScope() bool { return true } func (c computeDiskWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetDiskRequest{ Project: location.ProjectID, Zone: location.Zone, Disk: queryParts[0], } disk, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeDiskToSDPItem(ctx, disk, location) } func (c computeDiskWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeDiskWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListDisksRequest{ Project: location.ProjectID, Zone: location.Zone, }) var itemsSent int var hadError bool for { disk, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeDiskToSDPItem(ctx, disk, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute disks found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all disks across all zones func (c computeDiskWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListDisksRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process disks in this scope if pair.Value != nil && pair.Value.GetDisks() != nil { for _, disk := range pair.Value.GetDisks() { item, sdpErr := c.gcpComputeDiskToSDPItem(ctx, disk, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute disks found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeDiskWrapper) gcpComputeDiskToSDPItem(ctx context.Context, disk *computepb.Disk, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(disk, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeDisk.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: disk.GetLabels(), } // The resource URL for the disk type associated with this disk. // GET https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/diskTypes/{diskType} // https://cloud.google.com/compute/docs/reference/rest/v1/diskTypes/get if diskType := disk.GetType(); diskType != "" { if strings.Contains(diskType, "/") { diskTypeName := gcpshared.LastPathComponent(diskType) if diskTypeName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, diskType) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDiskType.String(), Method: sdp.QueryMethod_GET, Query: diskTypeName, Scope: scope, }, }) } } } } // The resource URL for the image used to create this disk. // GET https://compute.googleapis.com/compute/v1/projects/{project}/global/images/{image} // https://cloud.google.com/compute/docs/reference/rest/v1/images/get if sourceImage := disk.GetSourceImage(); sourceImage != "" { if strings.Contains(sourceImage, "/") { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceImage) if err == nil { // Use SEARCH for all image references - it handles both family and specific image formats sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeImage.String(), Method: sdp.QueryMethod_SEARCH, Query: sourceImage, // Pass full URI so Search can detect format Scope: scope, }, }) } } } // The resource URL for the snapshot used to create this disk. // GET https://compute.googleapis.com/compute/v1/projects/{project}/global/snapshots/{snapshot} // https://cloud.google.com/compute/docs/reference/rest/v1/snapshots/get if sourceSnapshot := disk.GetSourceSnapshot(); sourceSnapshot != "" { if strings.Contains(sourceSnapshot, "/") { snapshotName := gcpshared.LastPathComponent(sourceSnapshot) if snapshotName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceSnapshot) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: scope, }, }) } } } } // The resource URL for the instant snapshot used to create this disk. // GET https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/instantSnapshots/{instantSnapshot} // https://cloud.google.com/compute/docs/reference/rest/v1/instantSnapshots/get if sourceInstantSnapshot := disk.GetSourceInstantSnapshot(); sourceInstantSnapshot != "" { if strings.Contains(sourceInstantSnapshot, "/") { instantSnapshotName := gcpshared.LastPathComponent(sourceInstantSnapshot) if instantSnapshotName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceInstantSnapshot) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstantSnapshot.String(), Method: sdp.QueryMethod_GET, Query: instantSnapshotName, Scope: scope, }, }) } } } } // The resource URL for the source disk used to create this disk. // GET https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/disks/{disk} // https://cloud.google.com/compute/docs/reference/rest/v1/disks/get if sourceDisk := disk.GetSourceDisk(); sourceDisk != "" { if strings.Contains(sourceDisk, "/") { sourceDiskName := gcpshared.LastPathComponent(sourceDisk) if sourceDiskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceDisk) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: sourceDiskName, Scope: scope, }, }) } } } } // The resource URLs for the resource policies associated with this disk. // GET https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/resourcePolicies/{resourcePolicy} // https://cloud.google.com/compute/docs/reference/rest/v1/resourcePolicies/get for _, rp := range disk.GetResourcePolicies() { if strings.Contains(rp, "/") { rpName := gcpshared.LastPathComponent(rp) if rpName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, rp) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: rpName, Scope: scope, }, }) } } } } // The resource URLs for the users (instances) using this disk. // GET https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/instances/{instance} // https://cloud.google.com/compute/docs/reference/rest/v1/instances/get for _, instance := range disk.GetUsers() { if strings.Contains(instance, "/") { instanceName := gcpshared.LastPathComponent(instance) if instanceName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, instance) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstance.String(), Method: sdp.QueryMethod_GET, Query: instanceName, Scope: scope, }, }) } } } } // The Encryption keys associated with this disk; appears in the following format: // "diskEncryptionKey.kmsKeyName": "projects/kms_project_id/locations/region/keyRings/key_region/cryptoKeys/key/cryptoKeysVersions/version // GET https://cloudkms.googleapis.com/v1/{name=projects/*/locations/*/keyRings/*/cryptoKeys/*/cryptoKeyVersions/*} // https://cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys.cryptoKeyVersions // DiskEncryptionKey.kmsKeyName -> CloudKMSCryptoKeyVersion if diskEncryptionKey := disk.GetDiskEncryptionKey(); diskEncryptionKey != nil { if keyName := diskEncryptionKey.GetKmsKeyName(); keyName != "" { loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, // Deleting a key might break the disk’s ability to function and have its data read // Deleting a disk in GCP does not affect its associated encryption key }) } } } // The customer-supplied encryption key of the source image; appears in the following format: // "sourceImageEncryptionKey.kmsKeyName": ""projects/ kms_project_id/locations/ region/keyRings/ key_region/cryptoKeys/key /cryptoKeyVersions/1" // GET https://cloudkms.googleapis.com/v1/{name=projects/*/locations/*/keyRings/*/cryptoKeys/*/cryptoKeyVersions/*} // https://cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys.cryptoKeyVersions // SourceImageEncryptionKey.kmsKeyName -> CloudKMSCryptoKeyVersion if sourceImageEncryptionKey := disk.GetSourceImageEncryptionKey(); sourceImageEncryptionKey != nil { if keyName := sourceImageEncryptionKey.GetKmsKeyName(); keyName != "" { loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, // Deleting a key might break the disk’s ability to function and have its data read // Deleting a disk in GCP does not affect its source image's encryption key }) } } } // The customer-supplied encryption key of the source snapshot; appears in the following format: // "sourceImageEncryptionKey.kmsKeyName": "projects/ kms_project_id/locations/ region/keyRings/ key_region/cryptoKeys/key /cryptoKeyVersions/1" // GET https://cloudkms.googleapis.com/v1/{name=projects/*/locations/*/keyRings/*/cryptoKeys/*/cryptoKeyVersions/*} // https://cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys.cryptoKeyVersions // SourceSnapshotEncryptionKey.kmsKeyName -> CloudKMSCryptoKeyVersion if sourceSnapshotEncryptionKey := disk.GetSourceSnapshotEncryptionKey(); sourceSnapshotEncryptionKey != nil { if keyName := sourceSnapshotEncryptionKey.GetKmsKeyName(); keyName != "" { loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, // Deleting a key might break the disk’s ability to function and have its data read // Deleting a disk in GCP does not affect its source image's encryption key }) } } } // The URL of the DiskConsistencyGroupPolicy for a secondary disk that was created using a consistency group; this is a type of Resource Policy. // GET https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/resourcePolicies/{resourcePolicy} // https://cloud.google.com/compute/docs/reference/rest/v1/resourcePolicies if sourceConsistencyGroupPolicy := disk.GetSourceConsistencyGroupPolicy(); sourceConsistencyGroupPolicy != "" { if strings.Contains(sourceConsistencyGroupPolicy, "/") { rpName := gcpshared.LastPathComponent(sourceConsistencyGroupPolicy) if rpName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceConsistencyGroupPolicy) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: rpName, Scope: scope, }, }) } } } } // The Cloud Storage URI for a disk image (tarball .tar.gz or .vmdk) used to create this disk. // Format: gs://bucket-name/path/to/object or https://storage.googleapis.com/bucket-name/path/to/object // GET https://storage.googleapis.com/storage/v1/b/{bucket} // https://cloud.google.com/storage/docs/json_api/v1/buckets/get // Note: Storage Bucket adapter only supports GET method (not SEARCH), so we extract the bucket name // and use GET. We reuse the existing StorageBucket manual adapter linker to avoid duplicating // GCS URI parsing logic, which handles various formats: // - //storage.googleapis.com/projects/PROJECT_ID/buckets/BUCKET_ID // - https://storage.googleapis.com/projects/PROJECT_ID/buckets/BUCKET_ID // - gs://bucket-name // - gs://bucket-name/path/to/file // - bucket-name (without gs:// prefix) if sourceStorageObject := disk.GetSourceStorageObject(); sourceStorageObject != "" { if linkFunc, ok := gcpshared.ManualAdapterLinksByAssetType[gcpshared.StorageBucket]; ok { linkedQuery := linkFunc(location.ProjectID, location.ToScope(), sourceStorageObject) if linkedQuery != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, linkedQuery) } } } // The storage pool to create new disk in. URL or partial resource path accepted. // GET https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/storagePools/{storagePool} // https://cloud.google.com/compute/docs/reference/rest/v1/storagePools/get if storagePool := disk.GetStoragePool(); storagePool != "" { if strings.Contains(storagePool, "/") { storagePoolName := gcpshared.LastPathComponent(storagePool) if storagePoolName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, storagePool) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeStoragePool.String(), Method: sdp.QueryMethod_GET, Query: storagePoolName, Scope: scope, }, // If the Storage Pool is deleted or updated: The disk may fail to operate correctly or become invalid. If the disk is updated: The Storage Pool remains unaffected. }) } } } } // Link async primary disk if asyncPrimaryDisk := disk.GetAsyncPrimaryDisk(); asyncPrimaryDisk != nil { if primaryDisk := asyncPrimaryDisk.GetDisk(); primaryDisk != "" { if strings.Contains(primaryDisk, "/") { primaryDiskName := gcpshared.LastPathComponent(primaryDisk) if primaryDiskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, primaryDisk) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: primaryDiskName, Scope: scope, }, }) } } } } if consistencyGroupPolicy := asyncPrimaryDisk.GetConsistencyGroupPolicy(); consistencyGroupPolicy != "" { if strings.Contains(consistencyGroupPolicy, "/") { policyName := gcpshared.LastPathComponent(consistencyGroupPolicy) if policyName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, consistencyGroupPolicy) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: policyName, Scope: scope, }, }) } } } } } // Link async secondary disks for _, asyncSecondaryDisk := range disk.GetAsyncSecondaryDisks() { if asyncReplicationDisk := asyncSecondaryDisk.GetAsyncReplicationDisk(); asyncReplicationDisk != nil { if secondaryDisk := asyncReplicationDisk.GetDisk(); secondaryDisk != "" { if strings.Contains(secondaryDisk, "/") { secondaryDiskName := gcpshared.LastPathComponent(secondaryDisk) if secondaryDiskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, secondaryDisk) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: secondaryDiskName, Scope: scope, }, }) } } } } if consistencyGroupPolicy := asyncReplicationDisk.GetConsistencyGroupPolicy(); consistencyGroupPolicy != "" { if strings.Contains(consistencyGroupPolicy, "/") { policyName := gcpshared.LastPathComponent(consistencyGroupPolicy) if policyName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, consistencyGroupPolicy) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: policyName, Scope: scope, }, }) } } } } } } // Set health status switch disk.GetStatus() { case computepb.Disk_UNDEFINED_STATUS.String(): sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case computepb.Disk_CREATING.String(), computepb.Disk_RESTORING.String(), computepb.Disk_DELETING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.Disk_FAILED.String(), computepb.Disk_UNAVAILABLE.String(): sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() case computepb.Disk_READY.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-disk_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeDisk(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeDiskClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeDisk("test-disk", computepb.Disk_READY), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-disk", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } expectedTag := "test" actualTag := sdpItem.GetTags()["env"] if actualTag != expectedTag { t.Fatalf("Expected tag 'env=%s', got: %v", expectedTag, actualTag) } t.Run("StaticTests", func(t *testing.T) { type staticTestCase struct { name string sourceType string sourceValue string expectedLinked shared.QueryTests } cases := []staticTestCase{ { name: "SourceImage", sourceType: "image", sourceValue: "projects/test-project-id/global/images/test-image", expectedLinked: shared.QueryTests{ { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "projects/test-project-id/global/images/test-image", ExpectedScope: "test-project-id", }, }, }, { name: "SourceSnapshot", sourceType: "snapshot", sourceValue: "projects/test-project-id/global/snapshots/test-snapshot", expectedLinked: shared.QueryTests{ { ExpectedType: gcpshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-snapshot", ExpectedScope: "test-project-id", }, }, }, { name: "SourceInstantSnapshot", sourceType: "instantSnapshot", sourceValue: "projects/test-project-id/zones/us-central1-a/instantSnapshots/test-instant-snapshot", expectedLinked: shared.QueryTests{ { ExpectedType: gcpshared.ComputeInstantSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instant-snapshot", ExpectedScope: "test-project-id.us-central1-a", }, }, }, { name: "SourceDisk", sourceType: "disk", sourceValue: "projects/test-project-id/zones/us-central1-a/disks/source-disk", expectedLinked: shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "source-disk", ExpectedScope: "test-project-id.us-central1-a", }, }, }, } // These are always present resourcePolicyTest := shared.QueryTest{ ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", } userTest := shared.QueryTest{ ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: "test-project-id.us-central1-a", } diskTypeTest := shared.QueryTest{ ExpectedType: gcpshared.ComputeDiskType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pd-standard", ExpectedScope: "test-project-id.us-central1-a", } diskEncryptionKeyTest := shared.QueryTest{ ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-disk", ExpectedScope: "test-project-id", } sourceImageEncryptionKeyTest := shared.QueryTest{ ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-image", ExpectedScope: "test-project-id", } sourceSnapshotEncryptionKeyTest := shared.QueryTest{ ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-snapshot", ExpectedScope: "test-project-id", } sourceConsistencyGroupPolicy := shared.QueryTest{ ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-consistency-group-policy", ExpectedScope: "test-project-id.us-central1", } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { disk := createComputeDiskWithSource("test-disk", computepb.Disk_READY, tc.sourceType, tc.sourceValue) wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Mock the Get call to return our disk mockClient.EXPECT().Get(ctx, gomock.Any()).Return(disk, nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-disk", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Compose expected queries for this source type expectedQueries := append(tc.expectedLinked, resourcePolicyTest, userTest, diskTypeTest, diskEncryptionKeyTest, sourceImageEncryptionKeyTest, sourceSnapshotEncryptionKeyTest, sourceConsistencyGroupPolicy) shared.RunStaticTests(t, adapter, sdpItem, expectedQueries) }) } }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.Disk_Status expected sdp.Health } testCases := []testCase{ { name: "Ready", input: computepb.Disk_READY, expected: sdp.Health_HEALTH_OK, }, { name: "Creating", input: computepb.Disk_CREATING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Restoring", input: computepb.Disk_RESTORING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Deleting", input: computepb.Disk_DELETING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Failed", input: computepb.Disk_FAILED, expected: sdp.Health_HEALTH_ERROR, }, { name: "Unavailable", input: computepb.Disk_UNAVAILABLE, expected: sdp.Health_HEALTH_ERROR, }, { name: "Unknown", input: computepb.Disk_UNDEFINED_STATUS, expected: sdp.Health_HEALTH_UNKNOWN, }, } mockClient = mocks.NewMockComputeDiskClient(ctrl) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeDisk("test-disk", tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-disk", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s (input: %s)", tc.expected, sdpItem.GetHealth(), tc.input) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeDiskIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeDisk("test-disk-1", computepb.Disk_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeDisk("test-disk-2", computepb.Disk_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 2 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeDiskIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeDisk("test-disk-1", computepb.Disk_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeDisk("test-disk-2", computepb.Disk_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item); wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeDiskClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone mockAggIter := mocks.NewMockDisksScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.DisksScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeDiskIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) t.Run("GetWithSourceStorageObject", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) // Test with gs:// URI format sourceStorageObject := "gs://test-bucket/path/to/image.tar.gz" disk := createComputeDisk("test-disk", computepb.Disk_READY) disk.SourceStorageObject = new(sourceStorageObject) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(disk, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-disk", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.ComputeDiskType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pd-standard", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-disk", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-image", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-snapshot", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-consistency-group-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "projects/test-project-id/global/images/test-image", ExpectedScope: projectID, }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-bucket", ExpectedScope: projectID, }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithStoragePool", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) storagePoolURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/storagePools/test-storage-pool", projectID, zone) disk := createComputeDisk("test-disk", computepb.Disk_READY) disk.StoragePool = new(storagePoolURL) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(disk, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-disk", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present (same as above) baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.ComputeDiskType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pd-standard", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-disk", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-image", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-snapshot", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-consistency-group-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "projects/test-project-id/global/images/test-image", ExpectedScope: projectID, }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeStoragePool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-storage-pool", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithAsyncPrimaryDisk", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) primaryDiskURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/disks/primary-disk", projectID, zone) consistencyGroupPolicyURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/us-central1/resourcePolicies/test-consistency-policy", projectID) disk := createComputeDisk("test-disk", computepb.Disk_READY) disk.AsyncPrimaryDisk = &computepb.DiskAsyncReplication{ Disk: new(primaryDiskURL), ConsistencyGroupPolicy: new(consistencyGroupPolicyURL), } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(disk, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-disk", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.ComputeDiskType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pd-standard", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-disk", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-image", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-snapshot", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-consistency-group-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "projects/test-project-id/global/images/test-image", ExpectedScope: projectID, }, } // Add the new queries we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "primary-disk", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, shared.QueryTest{ ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-consistency-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, ) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithAsyncSecondaryDisks", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) secondaryDisk1URL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/disks/secondary-disk-1", projectID, zone) secondaryDisk2URL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones/%s/disks/secondary-disk-2", projectID, zone) consistencyGroupPolicyURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/us-central1/resourcePolicies/test-consistency-policy", projectID) disk := createComputeDisk("test-disk", computepb.Disk_READY) disk.AsyncSecondaryDisks = map[string]*computepb.DiskAsyncReplicationList{ "secondary-disk-1": { AsyncReplicationDisk: &computepb.DiskAsyncReplication{ Disk: new(secondaryDisk1URL), ConsistencyGroupPolicy: new(consistencyGroupPolicyURL), }, }, "secondary-disk-2": { AsyncReplicationDisk: &computepb.DiskAsyncReplication{ Disk: new(secondaryDisk2URL), }, }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(disk, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-disk", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.ComputeDiskType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "pd-standard", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-disk", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-image", ExpectedScope: projectID, }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-snapshot", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-consistency-group-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "projects/test-project-id/global/images/test-image", ExpectedScope: projectID, }, } // Add the new queries we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "secondary-disk-1", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, shared.QueryTest{ ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-consistency-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, shared.QueryTest{ ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "secondary-disk-2", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, ) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("SupportsWildcardScope", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Check if adapter implements WildcardScopeAdapter if wildcardAdapter, ok := adapter.(discovery.WildcardScopeAdapter); ok { if !wildcardAdapter.SupportsWildcardScope() { t.Fatal("Expected SupportsWildcardScope to return true") } } else { t.Fatal("Expected adapter to implement WildcardScopeAdapter interface") } }) t.Run("List with wildcard scope", func(t *testing.T) { zone1 := "us-central1-a" zone2 := "us-central1-b" wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{ gcpshared.NewZonalLocation(projectID, zone1), gcpshared.NewZonalLocation(projectID, zone2), }) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Create mock aggregated list iterator mockAggregatedIterator := mocks.NewMockDisksScopedListPairIterator(ctrl) // Mock response for zone1 mockAggregatedIterator.EXPECT().Next().Return(compute.DisksScopedListPair{ Key: "zones/us-central1-a", Value: &computepb.DisksScopedList{ Disks: []*computepb.Disk{ createComputeDisk("disk-1-zone-a", computepb.Disk_READY), }, }, }, nil) // Mock response for zone2 mockAggregatedIterator.EXPECT().Next().Return(compute.DisksScopedListPair{ Key: "zones/us-central1-b", Value: &computepb.DisksScopedList{ Disks: []*computepb.Disk{ createComputeDisk("disk-1-zone-b", computepb.Disk_READY), }, }, }, nil) // Mock response for a zone not in our config (should be filtered) mockAggregatedIterator.EXPECT().Next().Return(compute.DisksScopedListPair{ Key: "zones/us-west1-a", Value: &computepb.DisksScopedList{ Disks: []*computepb.Disk{ createComputeDisk("disk-west", computepb.Disk_READY), }, }, }, nil) // End of iteration mockAggregatedIterator.EXPECT().Next().Return(compute.DisksScopedListPair{}, iterator.Done) // Mock the AggregatedList method mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).DoAndReturn( func(ctx context.Context, req *computepb.AggregatedListDisksRequest, opts ...any) gcpshared.DisksScopedListPairIterator { // Verify request parameters if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if !req.GetReturnPartialSuccess() { t.Error("Expected ReturnPartialSuccess to be true") } return mockAggregatedIterator }, ) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } // Call List with wildcard scope sdpItems, err := listable.List(ctx, "*", true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should return only items from configured zones (zone-a and zone-b, not west1-a) if len(sdpItems) != 2 { t.Fatalf("Expected 2 items (filtered), got: %d", len(sdpItems)) } // Verify items have correct scopes scopesSeen := make(map[string]bool) for _, item := range sdpItems { scopesSeen[item.GetScope()] = true } expectedScopes := []string{ fmt.Sprintf("%s.%s", projectID, zone1), fmt.Sprintf("%s.%s", projectID, zone2), } for _, expectedScope := range expectedScopes { if !scopesSeen[expectedScope] { t.Errorf("Expected to see scope %s in results", expectedScope) } } }) t.Run("List with specific scope still works", func(t *testing.T) { wrapper := manual.NewComputeDisk(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeDiskIterator(ctrl) // Mock normal per-zone List behavior mockComputeIterator.EXPECT().Next().Return(createComputeDisk("test-disk", computepb.Disk_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } // Call List with specific scope (not wildcard) sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } }) } func createComputeDisk(diskName string, status computepb.Disk_Status) *computepb.Disk { return createComputeDiskWithSource(diskName, status, "image", "projects/test-project-id/global/images/test-image") } // createComputeDiskWithSource creates a Disk with only the specified source field set. // sourceType can be "image", "snapshot", "instantSnapshot", or "disk". // sourceValue is the value to set for the source field. func createComputeDiskWithSource(diskName string, status computepb.Disk_Status, sourceType, sourceValue string) *computepb.Disk { disk := &computepb.Disk{ Name: new(diskName), Labels: map[string]string{"env": "test"}, Type: new("projects/test-project-id/zones/us-central1-a/diskTypes/pd-standard"), Status: new(status.String()), ResourcePolicies: []string{"projects/test-project-id/regions/us-central1/resourcePolicies/test-policy"}, Users: []string{"projects/test-project-id/zones/us-central1-a/instances/test-instance"}, DiskEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-disk"), RawKey: new("test-key"), }, SourceImageEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-image"), RawKey: new("test-key"), }, SourceSnapshotEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-snapshot"), RawKey: new("test-key"), }, SourceConsistencyGroupPolicy: new("projects/test-project-id/regions/us-central1/resourcePolicies/test-consistency-group-policy"), } switch sourceType { case "image": disk.SourceImage = new(sourceValue) case "snapshot": disk.SourceSnapshot = new(sourceValue) case "instantSnapshot": disk.SourceInstantSnapshot = new(sourceValue) case "disk": disk.SourceDisk = new(sourceValue) default: // Default to image if unknown type disk.SourceImage = new("projects/test-project-id/global/images/test-image") } return disk } ================================================ FILE: sources/gcp/manual/compute-forwarding-rule.go ================================================ package manual import ( "context" "errors" "strings" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeForwardingRuleLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeForwardingRule) type computeForwardingRuleWrapper struct { client gcpshared.ComputeForwardingRuleClient *gcpshared.RegionBase } // NewComputeForwardingRule creates a new computeForwardingRuleWrapper. func NewComputeForwardingRule(client gcpshared.ComputeForwardingRuleClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeForwardingRuleWrapper{ client: client, RegionBase: gcpshared.NewRegionBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, gcpshared.ComputeForwardingRule, ), } } func (c computeForwardingRuleWrapper) IAMPermissions() []string { return []string{ "compute.forwardingRules.get", "compute.forwardingRules.list", } } func (c computeForwardingRuleWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeForwardingRuleWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( stdlib.NetworkIP, gcpshared.ComputeSubnetwork, gcpshared.ComputeNetwork, gcpshared.ComputeBackendService, gcpshared.ComputeTargetHttpProxy, gcpshared.ComputeTargetHttpsProxy, gcpshared.ComputeTargetTcpProxy, gcpshared.ComputeTargetSslProxy, gcpshared.ComputeTargetPool, gcpshared.ComputeTargetVpnGateway, gcpshared.ComputeTargetInstance, gcpshared.ComputeServiceAttachment, gcpshared.ComputeForwardingRule, gcpshared.ComputePublicDelegatedPrefix, gcpshared.ServiceDirectoryNamespace, gcpshared.ServiceDirectoryService, ) } func (c computeForwardingRuleWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_forwarding_rule.name", }, } } func (c computeForwardingRuleWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeForwardingRuleLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute forwarding rules since they use aggregatedList func (c computeForwardingRuleWrapper) SupportsWildcardScope() bool { return true } func (c computeForwardingRuleWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetForwardingRuleRequest{ Project: location.ProjectID, Region: location.Region, ForwardingRule: queryParts[0], } rule, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeForwardingRuleToSDPItem(ctx, rule, location) } func (c computeForwardingRuleWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeForwardingRuleWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-region List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListForwardingRulesRequest{ Project: location.ProjectID, Region: location.Region, }) var itemsSent int var hadError bool for { rule, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeForwardingRuleToSDPItem(ctx, rule, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute forwarding rules found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all forwarding rules across all regions func (c computeForwardingRuleWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListForwardingRulesRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "regions/us-central1") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process forwarding rules in this scope if pair.Value != nil && pair.Value.GetForwardingRules() != nil { for _, rule := range pair.Value.GetForwardingRules() { item, sdpErr := c.gcpComputeForwardingRuleToSDPItem(ctx, rule, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute forwarding rules found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeForwardingRuleWrapper) gcpComputeForwardingRuleToSDPItem(ctx context.Context, rule *computepb.ForwardingRule, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(rule, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeForwardingRule.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: rule.GetLabels(), } if rule.GetIPAddress() != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: rule.GetIPAddress(), Scope: "global", }, }) } if rule.GetBackendService() != "" { if strings.Contains(rule.GetBackendService(), "/") { backendServiceName := gcpshared.LastPathComponent(rule.GetBackendService()) scope, err := gcpshared.ExtractScopeFromURI(ctx, rule.GetBackendService()) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeBackendService.String(), Method: sdp.QueryMethod_GET, Query: backendServiceName, Scope: scope, }, }) } } } if rule.GetPscConnectionStatus() != "" { switch rule.GetPscConnectionStatus() { case computepb.ForwardingRule_UNDEFINED_PSC_CONNECTION_STATUS.String(), computepb.ForwardingRule_STATUS_UNSPECIFIED.String(): sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case computepb.ForwardingRule_ACCEPTED.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case computepb.ForwardingRule_PENDING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.ForwardingRule_REJECTED.String(), computepb.ForwardingRule_CLOSED.String(): sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() case computepb.ForwardingRule_NEEDS_ATTENTION.String(): sdpItem.Health = sdp.Health_HEALTH_WARNING.Enum() } } if rule.GetNetwork() != "" { if strings.Contains(rule.GetNetwork(), "/") { networkName := gcpshared.LastPathComponent(rule.GetNetwork()) scope, err := gcpshared.ExtractScopeFromURI(ctx, rule.GetNetwork()) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetwork.String(), Method: sdp.QueryMethod_GET, Query: networkName, Scope: scope, }, }) } } } if subnetwork := rule.GetSubnetwork(); subnetwork != "" { if strings.Contains(subnetwork, "/") { subnetworkName := gcpshared.LastPathComponent(subnetwork) scope, err := gcpshared.ExtractScopeFromURI(ctx, subnetwork) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSubnetwork.String(), Method: sdp.QueryMethod_GET, Query: subnetworkName, Scope: scope, }, }) } } } // Link to target resource (polymorphic) if target := rule.GetTarget(); target != "" { linkedQuery := gcpshared.ForwardingRuleTargetLinker(location.ProjectID, location.ToScope(), target) if linkedQuery != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, linkedQuery) } } // Link to base forwarding rule if baseForwardingRule := rule.GetBaseForwardingRule(); baseForwardingRule != "" { if strings.Contains(baseForwardingRule, "/") { forwardingRuleName := gcpshared.LastPathComponent(baseForwardingRule) scope, err := gcpshared.ExtractScopeFromURI(ctx, baseForwardingRule) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeForwardingRule.String(), Method: sdp.QueryMethod_GET, Query: forwardingRuleName, Scope: scope, }, }) } } } // Link to Public Delegated Prefix if ipCollection := rule.GetIpCollection(); ipCollection != "" { if strings.Contains(ipCollection, "/") { prefixName := gcpshared.LastPathComponent(ipCollection) scope, err := gcpshared.ExtractScopeFromURI(ctx, ipCollection) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputePublicDelegatedPrefix.String(), Method: sdp.QueryMethod_GET, Query: prefixName, Scope: scope, }, }) } } } // Link to Service Directory for _, reg := range rule.GetServiceDirectoryRegistrations() { if namespace := reg.GetNamespace(); namespace != "" { loc := gcpshared.ExtractPathParam("locations", namespace) namespaceName := gcpshared.ExtractPathParam("namespaces", namespace) if loc != "" && namespaceName != "" { query := shared.CompositeLookupKey(loc, namespaceName) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ServiceDirectoryNamespace.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: location.ProjectID, }, }) } } if service := reg.GetService(); service != "" { namespace := reg.GetNamespace() if namespace != "" && service != "" { loc := gcpshared.ExtractPathParam("locations", namespace) namespaceName := gcpshared.ExtractPathParam("namespaces", namespace) if loc != "" && namespaceName != "" { query := shared.CompositeLookupKey(loc, namespaceName, service) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ServiceDirectoryService.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: location.ProjectID, }, }) } } } } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-forwarding-rule_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeForwardingRule(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeForwardingRuleClient(ctrl) projectID := "test-project-id" region := "us-central1" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createForwardingRule("test-rule", projectID, region, "192.168.1.1"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-rule", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnetwork", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "backend-service", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockForwardingRuleIterator(ctrl) mockIterator.EXPECT().Next().Return(createForwardingRule("test-rule-1", projectID, region, "192.168.1.1"), nil) mockIterator.EXPECT().Next().Return(createForwardingRule("test-rule-2", projectID, region, "192.168.1.2"), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockForwardingRuleIterator(ctrl) mockIterator.EXPECT().Next().Return(createForwardingRule("test-rule-1", projectID, region, "192.168.1.1"), nil) mockIterator.EXPECT().Next().Return(createForwardingRule("test-rule-2", projectID, region, "192.168.1.2"), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeForwardingRuleClient(ctrl) projectID := "cache-test-project" region := "us-central1" scope := projectID + "." + region mockAggIter := mocks.NewMockForwardingRulesScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.ForwardingRulesScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockForwardingRuleIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) t.Run("GetWithTarget", func(t *testing.T) { wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) // Test with TargetHttpProxy targetURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/global/targetHttpProxies/test-target-proxy", projectID) forwardingRule := createForwardingRule("test-rule", projectID, region, "192.168.1.1") forwardingRule.Target = new(targetURL) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(forwardingRule, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-rule", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnetwork", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "backend-service", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeTargetHttpProxy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-target-proxy", ExpectedScope: projectID, }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithBaseForwardingRule", func(t *testing.T) { wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) baseForwardingRuleURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/forwardingRules/base-forwarding-rule", projectID, region) forwardingRule := createForwardingRule("test-rule", projectID, region, "192.168.1.1") forwardingRule.BaseForwardingRule = new(baseForwardingRuleURL) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(forwardingRule, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-rule", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnetwork", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "backend-service", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeForwardingRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "base-forwarding-rule", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithIPCollection", func(t *testing.T) { wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) ipCollectionURL := fmt.Sprintf("projects/%s/regions/%s/publicDelegatedPrefixes/test-prefix", projectID, region) forwardingRule := createForwardingRule("test-rule", projectID, region, "192.168.1.1") forwardingRule.IpCollection = new(ipCollectionURL) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(forwardingRule, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-rule", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnetwork", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "backend-service", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputePublicDelegatedPrefix.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-prefix", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithServiceDirectoryRegistrations", func(t *testing.T) { wrapper := manual.NewComputeForwardingRule(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) namespaceURL := fmt.Sprintf("projects/%s/locations/us-central1/namespaces/test-namespace", projectID) serviceName := "test-service" forwardingRule := createForwardingRule("test-rule", projectID, region, "192.168.1.1") forwardingRule.ServiceDirectoryRegistrations = []*computepb.ForwardingRuleServiceDirectoryRegistration{ { Namespace: new(namespaceURL), Service: new(serviceName), }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(forwardingRule, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-rule", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.1", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnetwork", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeBackendService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "backend-service", ExpectedScope: fmt.Sprintf("%s.%s", projectID, region), }, } // Add the new queries we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ServiceDirectoryNamespace.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1|test-namespace", ExpectedScope: projectID, }, shared.QueryTest{ ExpectedType: gcpshared.ServiceDirectoryService.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1|test-namespace|test-service", ExpectedScope: projectID, }, ) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) } func createForwardingRule(name, projectID, region, ipAddress string) *computepb.ForwardingRule { return &computepb.ForwardingRule{ Name: new(name), IPAddress: new(ipAddress), Labels: map[string]string{"env": "test"}, Network: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/networks/test-network", projectID)), Subnetwork: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/regions/%s/subnetworks/test-subnetwork", projectID, region)), BackendService: new(fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/regions/%s/backendServices/backend-service", projectID, region)), } } ================================================ FILE: sources/gcp/manual/compute-healthcheck.go ================================================ package manual import ( "context" "errors" "fmt" "net" "slices" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeHealthCheckLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeHealthCheck) type computeHealthCheckWrapper struct { globalClient gcpshared.ComputeHealthCheckClient regionalClient gcpshared.ComputeRegionHealthCheckClient projectLocations []gcpshared.LocationInfo // For global health checks regionLocations []gcpshared.LocationInfo // For regional health checks *shared.Base } // NewComputeHealthCheck creates a new computeHealthCheckWrapper instance that handles both global and regional health checks. func NewComputeHealthCheck(globalClient gcpshared.ComputeHealthCheckClient, regionalClient gcpshared.ComputeRegionHealthCheckClient, projectLocations []gcpshared.LocationInfo, regionLocations []gcpshared.LocationInfo) sources.ListStreamableWrapper { // Combine all locations for scope generation allLocations := make([]gcpshared.LocationInfo, 0, len(projectLocations)+len(regionLocations)) allLocations = append(allLocations, projectLocations...) allLocations = append(allLocations, regionLocations...) scopes := make([]string, 0, len(allLocations)) for _, location := range allLocations { scopes = append(scopes, location.ToScope()) } return &computeHealthCheckWrapper{ globalClient: globalClient, regionalClient: regionalClient, projectLocations: projectLocations, regionLocations: regionLocations, Base: shared.NewBase(sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, gcpshared.ComputeHealthCheck, scopes), } } // validateAndParseScope parses the scope and validates it against configured locations. // Returns the LocationInfo if valid, or a QueryError if the scope is invalid or not configured. func (c computeHealthCheckWrapper) validateAndParseScope(scope string) (gcpshared.LocationInfo, *sdp.QueryError) { location, err := gcpshared.LocationFromScope(scope) if err != nil { return gcpshared.LocationInfo{}, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } // Check if the location is in the adapter's configured locations allLocations := append([]gcpshared.LocationInfo{}, c.projectLocations...) allLocations = append(allLocations, c.regionLocations...) if slices.ContainsFunc(allLocations, location.Equals) { return location, nil } return gcpshared.LocationInfo{}, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("scope %s not found in adapter's configured locations", scope), } } func (c computeHealthCheckWrapper) IAMPermissions() []string { return []string{ "compute.healthChecks.get", "compute.healthChecks.list", "compute.regionHealthChecks.get", "compute.regionHealthChecks.list", } } func (c computeHealthCheckWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeHealthCheckWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( stdlib.NetworkIP, stdlib.NetworkDNS, gcpshared.ComputeRegion, ) } func (c computeHealthCheckWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_health_check.name", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_region_health_check.name", }, } } func (c computeHealthCheckWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeHealthCheckLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for health checks since they use aggregatedList func (c computeHealthCheckWrapper) SupportsWildcardScope() bool { return true } func (c computeHealthCheckWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { // Parse and validate the scope location, err := c.validateAndParseScope(scope) if err != nil { return nil, err } // Route to the appropriate API based on whether the scope includes a region if location.Regional() { // Regional health check req := &computepb.GetRegionHealthCheckRequest{ Project: location.ProjectID, Region: location.Region, HealthCheck: queryParts[0], } healthCheck, getErr := c.regionalClient.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return GcpComputeHealthCheckToSDPItem(healthCheck, location, gcpshared.ComputeHealthCheck) } // Global health check req := &computepb.GetHealthCheckRequest{ Project: location.ProjectID, HealthCheck: queryParts[0], } healthCheck, getErr := c.globalClient.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return GcpComputeHealthCheckToSDPItem(healthCheck, location, gcpshared.ComputeHealthCheck) } func (c computeHealthCheckWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeHealthCheckWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Parse and validate the scope location, err := c.validateAndParseScope(scope) if err != nil { stream.SendError(err) return } // Route to the appropriate API based on whether the scope includes a region var itemsSent int var hadError bool if location.Regional() { // Regional health checks it := c.regionalClient.List(ctx, &computepb.ListRegionHealthChecksRequest{ Project: location.ProjectID, Region: location.Region, }) for { healthCheck, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := GcpComputeHealthCheckToSDPItem(healthCheck, location, gcpshared.ComputeHealthCheck) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } } else { // Global health checks it := c.globalClient.List(ctx, &computepb.ListHealthChecksRequest{ Project: location.ProjectID, }) for { healthCheck, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := GcpComputeHealthCheckToSDPItem(healthCheck, location, gcpshared.ComputeHealthCheck) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute health checks found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all health checks across all regions (global and regional) func (c computeHealthCheckWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.projectLocations, c.regionLocations) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.globalClient.AggregatedList(ctx, &computepb.AggregatedListHealthChecksRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "global" or "regions/us-central1") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.projectLocations, c.regionLocations) { continue } // Process health checks in this scope if pair.Value != nil && pair.Value.GetHealthChecks() != nil { for _, healthCheck := range pair.Value.GetHealthChecks() { item, sdpErr := GcpComputeHealthCheckToSDPItem(healthCheck, scopeLocation, gcpshared.ComputeHealthCheck) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute health checks found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // GcpComputeHealthCheckToSDPItem converts a GCP health check to an SDP item. // This function is shared by both global and regional health check adapters. func GcpComputeHealthCheckToSDPItem(healthCheck *computepb.HealthCheck, location gcpshared.LocationInfo, itemType shared.ItemType) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(healthCheck) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: itemType.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), } // Link to host field from HTTP health checks if httpHealthCheck := healthCheck.GetHttpHealthCheck(); httpHealthCheck != nil { if host := httpHealthCheck.GetHost(); host != "" { linkHostToNetworkResource(sdpItem, host) } } // Link to host field from HTTPS health checks if httpsHealthCheck := healthCheck.GetHttpsHealthCheck(); httpsHealthCheck != nil { if host := httpsHealthCheck.GetHost(); host != "" { linkHostToNetworkResource(sdpItem, host) } } // Link to host field from HTTP/2 health checks if http2HealthCheck := healthCheck.GetHttp2HealthCheck(); http2HealthCheck != nil { if host := http2HealthCheck.GetHost(); host != "" { linkHostToNetworkResource(sdpItem, host) } } // Link to source regions for _, regionName := range healthCheck.GetSourceRegions() { if regionName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRegion.String(), Method: sdp.QueryMethod_GET, Query: regionName, Scope: location.ProjectID, }, }) } } // Link to region field if region := healthCheck.GetRegion(); region != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRegion.String(), Method: sdp.QueryMethod_GET, Query: region, Scope: location.ProjectID, }, }) } return sdpItem, nil } func linkHostToNetworkResource(sdpItem *sdp.Item, host string) { if host == "" { return } if net.ParseIP(host) != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: host, Scope: "global", }, }) } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: host, Scope: "global", }, }) } } ================================================ FILE: sources/gcp/manual/compute-healthcheck_test.go ================================================ package manual_test import ( "context" "fmt" "strings" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeHealthCheck(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockGlobalClient := mocks.NewMockComputeHealthCheckClient(ctrl) mockRegionalClient := mocks.NewMockComputeRegionHealthCheckClient(ctrl) projectID := "test-project-id" t.Run("Get-Scope-Validation-Global", func(t *testing.T) { // Adapter configured for project-level only (global resources) wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Attempt to query a regional scope that wasn't configured unauthorizedScope := fmt.Sprintf("%s.us-central1", projectID) _, qErr := wrapper.Get(ctx, unauthorizedScope, "test-healthcheck") // Should fail with NOSCOPE error since us-central1 wasn't configured if qErr == nil { t.Fatal("Expected error when querying unconfigured regional scope, got nil") } if qErr.GetErrorType() != sdp.QueryError_NOSCOPE { t.Errorf("Expected NOSCOPE error, got: %v (error: %s)", qErr.GetErrorType(), qErr.GetErrorString()) } }) t.Run("Get-Scope-Validation-Regional", func(t *testing.T) { // Adapter configured for us-west1 only wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, nil, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, "us-west1")}) // Attempt to query us-central1 which wasn't configured unauthorizedScope := fmt.Sprintf("%s.us-central1", projectID) _, qErr := wrapper.Get(ctx, unauthorizedScope, "test-healthcheck") // Should fail with NOSCOPE error since us-central1 wasn't configured if qErr == nil { t.Fatal("Expected error when querying unconfigured regional scope, got nil") } if qErr.GetErrorType() != sdp.QueryError_NOSCOPE { t.Errorf("Expected NOSCOPE error, got: %v (error: %s)", qErr.GetErrorType(), qErr.GetErrorString()) } }) t.Run("ListStream-Scope-Validation-Global", func(t *testing.T) { // Adapter configured for project-level only (global resources) wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) // Attempt to list from a regional scope that wasn't configured unauthorizedScope := fmt.Sprintf("%s.us-central1", projectID) var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) cache := sdpcache.NewNoOpCache() wrapper.ListStream(ctx, stream, cache, sdpcache.CacheKey{}, unauthorizedScope) // Should fail with NOSCOPE error since us-central1 wasn't configured if len(errs) == 0 { t.Fatal("Expected error when listing from unconfigured regional scope, got none") } // The error should contain scope-related error message if len(errs) > 0 { // The first error should be a QueryError about scope expectedError := "scope" if err := errs[0]; err == nil || err.Error() == "" { t.Errorf("Expected error containing '%s', got nil or empty error", expectedError) } else if err := errs[0]; !strings.Contains(err.Error(), expectedError) { t.Errorf("Expected error containing '%s', got: %v", expectedError, err) } } }) t.Run("Get-Global", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(createHealthCheck("test-healthcheck"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-healthcheck", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // [SPEC] The default scope is the project ID. if sdpItem.GetScope() != "test-project-id" { t.Fatalf("Expected scope to be 'test-project-id', got: %s", sdpItem.GetScope()) } // [SPEC] TCP HealthChecks have no linked items (no host field). if len(sdpItem.GetLinkedItemQueries()) != 0 { t.Fatalf("Expected 0 linked item queries for TCP health check, got: %d", len(sdpItem.GetLinkedItemQueries())) } }) t.Run("GetWithHTTPHealthCheck", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) httpHealthCheck := createHTTPHealthCheck("test-http-healthcheck", "example.com") mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(httpHealthCheck, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-http-healthcheck", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // DNS name link from HTTP health check host field { ExpectedType: stdlib.NetworkDNS.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "example.com", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithHTTPSHealthCheckWithIP", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) httpsHealthCheck := createHTTPSHealthCheck("test-https-healthcheck", "192.168.1.100") mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(httpsHealthCheck, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-https-healthcheck", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // IP address link from HTTPS health check host field { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.100", ExpectedScope: "global", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithSourceRegions", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) healthCheckWithRegions := createHealthCheckWithSourceRegions("test-healthcheck-regions", []string{"us-central1", "us-east1", "europe-west1"}) mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(healthCheckWithRegions, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-healthcheck-regions", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Region links from sourceRegions array { ExpectedType: gcpshared.ComputeRegion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeRegion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-east1", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeRegion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "europe-west1", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithRegion", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) regionalHealthCheck := createRegionalHealthCheck("test-regional-healthcheck", "us-central1") mockGlobalClient.EXPECT().Get(ctx, gomock.Any()).Return(regionalHealthCheck, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-regional-healthcheck", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Region link from region field (output only, for regional health checks) { ExpectedType: gcpshared.ComputeRegion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeHealthCheckIter := mocks.NewMockComputeHealthCheckIterator(ctrl) // Mock out items listed from the API. mockComputeHealthCheckIter.EXPECT().Next().Return(createHealthCheck("test-healthcheck-1"), nil) mockComputeHealthCheckIter.EXPECT().Next().Return(createHealthCheck("test-healthcheck-2"), nil) mockComputeHealthCheckIter.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockGlobalClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeHealthCheckIter) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeHealthCheckIter := mocks.NewMockComputeHealthCheckIterator(ctrl) mockComputeHealthCheckIter.EXPECT().Next().Return(createHealthCheck("test-healthcheck-1"), nil) mockComputeHealthCheckIter.EXPECT().Next().Return(createHealthCheck("test-healthcheck-2"), nil) mockComputeHealthCheckIter.EXPECT().Next().Return(nil, iterator.Done) mockGlobalClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeHealthCheckIter) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item); wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockGlobalClient := mocks.NewMockComputeHealthCheckClient(ctrl) mockRegionalClient := mocks.NewMockComputeRegionHealthCheckClient(ctrl) projectID := "cache-test-project" mockAggIter := mocks.NewMockHealthChecksScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.HealthChecksScopedListPair{}, iterator.Done) mockGlobalClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) // Project-only: List("*") uses AggregatedList; we only test the "*" path here. wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}, nil) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } }) // Regional health check tests region := "us-central1" t.Run("Get-Regional", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, nil, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockRegionalClient.EXPECT().Get(ctx, gomock.Any()).Return(createHealthCheck("test-regional-healthcheck"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, fmt.Sprintf("%s.%s", projectID, region), "test-regional-healthcheck", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify the item has the correct type (should be ComputeHealthCheck, not ComputeRegionHealthCheck) if sdpItem.GetType() != gcpshared.ComputeHealthCheck.String() { t.Fatalf("Expected type to be '%s', got: %s", gcpshared.ComputeHealthCheck.String(), sdpItem.GetType()) } // Verify the scope is regional expectedScope := fmt.Sprintf("%s.%s", projectID, region) if sdpItem.GetScope() != expectedScope { t.Fatalf("Expected scope to be '%s', got: %s", expectedScope, sdpItem.GetScope()) } }) t.Run("List-Regional", func(t *testing.T) { wrapper := manual.NewComputeHealthCheck(mockGlobalClient, mockRegionalClient, nil, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockHealthCheckIterator := mocks.NewMockComputeRegionHealthCheckIterator(ctrl) mockHealthCheckIterator.EXPECT().Next().Return(createHealthCheck("test-regional-healthcheck-1"), nil) mockHealthCheckIterator.EXPECT().Next().Return(createHealthCheck("test-regional-healthcheck-2"), nil) mockHealthCheckIterator.EXPECT().Next().Return(nil, iterator.Done) mockRegionalClient.EXPECT().List(ctx, gomock.Any()).Return(mockHealthCheckIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, fmt.Sprintf("%s.%s", projectID, region), true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { // Verify each item has the correct type if item.GetType() != gcpshared.ComputeHealthCheck.String() { t.Fatalf("Expected type to be '%s', got: %s", gcpshared.ComputeHealthCheck.String(), item.GetType()) } // Verify each item has the correct regional scope expectedScope := fmt.Sprintf("%s.%s", projectID, region) if item.GetScope() != expectedScope { t.Fatalf("Expected scope to be '%s', got: %s", expectedScope, item.GetScope()) } if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) } func createHealthCheck(healthCheckName string) *computepb.HealthCheck { return &computepb.HealthCheck{ Name: new(healthCheckName), CheckIntervalSec: new(int32(5)), TimeoutSec: new(int32(5)), Type: new("TCP"), TcpHealthCheck: &computepb.TCPHealthCheck{ Port: new(int32(80)), }, } } func createHTTPHealthCheck(healthCheckName, host string) *computepb.HealthCheck { return &computepb.HealthCheck{ Name: new(healthCheckName), CheckIntervalSec: new(int32(5)), TimeoutSec: new(int32(5)), Type: new("HTTP"), HttpHealthCheck: &computepb.HTTPHealthCheck{ Port: new(int32(80)), Host: new(host), RequestPath: new("/"), }, } } func createHTTPSHealthCheck(healthCheckName, host string) *computepb.HealthCheck { return &computepb.HealthCheck{ Name: new(healthCheckName), CheckIntervalSec: new(int32(5)), TimeoutSec: new(int32(5)), Type: new("HTTPS"), HttpsHealthCheck: &computepb.HTTPSHealthCheck{ Port: new(int32(443)), Host: new(host), RequestPath: new("/"), }, } } func createHealthCheckWithSourceRegions(healthCheckName string, regions []string) *computepb.HealthCheck { return &computepb.HealthCheck{ Name: new(healthCheckName), CheckIntervalSec: new(int32(30)), TimeoutSec: new(int32(5)), Type: new("TCP"), TcpHealthCheck: &computepb.TCPHealthCheck{ Port: new(int32(80)), }, SourceRegions: regions, } } func createRegionalHealthCheck(healthCheckName, region string) *computepb.HealthCheck { return &computepb.HealthCheck{ Name: new(healthCheckName), CheckIntervalSec: new(int32(5)), TimeoutSec: new(int32(5)), Type: new("TCP"), TcpHealthCheck: &computepb.TCPHealthCheck{ Port: new(int32(80)), }, Region: new(region), } } ================================================ FILE: sources/gcp/manual/compute-image.go ================================================ package manual import ( "context" "errors" "strings" "cloud.google.com/go/compute/apiv1/computepb" "google.golang.org/api/iterator" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ( ComputeImageLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeImage) ComputeImageLookupByFamily = shared.NewItemTypeLookup("family", gcpshared.ComputeImage) ) type computeImageWrapper struct { client gcpshared.ComputeImagesClient *gcpshared.ProjectBase } // NewComputeImage creates a new computeImageWrapper instance. func NewComputeImage(client gcpshared.ComputeImagesClient, locations []gcpshared.LocationInfo) sources.SearchableListableWrapper { return &computeImageWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeImage, ), } } func (c computeImageWrapper) IAMPermissions() []string { return []string{ "compute.images.get", "compute.images.list", } } func (c computeImageWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeImageWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_image.name", }, } } func (c computeImageWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeImageLookupByName, } } func (c computeImageWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeImageLookupByFamily, }, } } func (c computeImageWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeDisk, gcpshared.ComputeSnapshot, gcpshared.ComputeImage, gcpshared.ComputeLicense, gcpshared.StorageBucket, gcpshared.CloudKMSCryptoKey, gcpshared.CloudKMSCryptoKeyVersion, gcpshared.IAMServiceAccount, ) } func (c computeImageWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetImageRequest{ Project: location.ProjectID, Image: queryParts[0], } image, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeImageToSDPItem(ctx, image, location) } func (c computeImageWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } query := queryParts[0] var image *computepb.Image var getErr error // Check if query is a full URI format if strings.Contains(query, "/images/") { // Extract project ID from URI if present imageProjectID := gcpshared.ExtractPathParam("projects", query) if imageProjectID == "" { imageProjectID = location.ProjectID } // Check if it's a family reference if strings.Contains(query, "/images/family/") { // Extract family name familyName := gcpshared.LastPathComponent(query) req := &computepb.GetFromFamilyImageRequest{ Project: imageProjectID, Family: familyName, } image, getErr = c.client.GetFromFamily(ctx, req) } else { // Regular image reference - extract image name imageName := gcpshared.LastPathComponent(query) req := &computepb.GetImageRequest{ Project: imageProjectID, Image: imageName, } image, getErr = c.client.Get(ctx, req) } } else { // Query is just a name - try Get first, then fallback to GetFromFamily req := &computepb.GetImageRequest{ Project: location.ProjectID, Image: query, } image, getErr = c.client.Get(ctx, req) // If Get fails with not found, try GetFromFamily (treating the name as a family) if getErr != nil { // Check if it's a "not found" error if s, ok := status.FromError(getErr); ok && s.Code() == codes.NotFound { familyReq := &computepb.GetFromFamilyImageRequest{ Project: location.ProjectID, Family: query, } image, getErr = c.client.GetFromFamily(ctx, familyReq) } } } if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } item, sdpErr := c.gcpComputeImageToSDPItem(ctx, image, location) if sdpErr != nil { return nil, sdpErr } return []*sdp.Item{item}, nil } func (c computeImageWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeImageWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListImagesRequest{ Project: location.ProjectID, }) var itemsSent int var hadError bool for { image, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeImageToSDPItem(ctx, image, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute images found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeImageWrapper) gcpComputeImageToSDPItem(ctx context.Context, image *computepb.Image, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(image, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeImage.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: image.GetLabels(), } switch image.GetStatus() { case computepb.Image_UNDEFINED_STATUS.String(): sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case computepb.Image_FAILED.String(): sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() case computepb.Image_PENDING.String(), computepb.Image_DELETING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.Image_READY.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() } // Link to source disk if sourceDisk := image.GetSourceDisk(); sourceDisk != "" { diskName := gcpshared.LastPathComponent(sourceDisk) if diskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceDisk) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: scope, }, }) } } } // Link to source snapshot if sourceSnapshot := image.GetSourceSnapshot(); sourceSnapshot != "" { snapshotName := gcpshared.LastPathComponent(sourceSnapshot) if snapshotName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: location.ProjectID, }, }) } } // Link to source image if sourceImage := image.GetSourceImage(); sourceImage != "" { projectID := gcpshared.ExtractPathParam("projects", sourceImage) scope := location.ProjectID if projectID != "" { scope = projectID } // Use SEARCH for all image references - it handles both family and specific image formats sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeImage.String(), Method: sdp.QueryMethod_SEARCH, Query: sourceImage, // Pass full URI so Search can detect format Scope: scope, }, }) } // Link to licenses for _, license := range image.GetLicenses() { licenseName := gcpshared.LastPathComponent(license) if licenseName != "" { projectID := gcpshared.ExtractPathParam("projects", license) scope := location.ProjectID if projectID != "" { scope = projectID } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeLicense.String(), Method: sdp.QueryMethod_GET, Query: licenseName, Scope: scope, }, }) } } // Link to raw disk storage bucket if rawDisk := image.GetRawDisk(); rawDisk != nil { if rawDiskSource := rawDisk.GetSource(); rawDiskSource != "" { if linkFunc, ok := gcpshared.ManualAdapterLinksByAssetType[gcpshared.StorageBucket]; ok { linkedQuery := linkFunc(location.ProjectID, location.ToScope(), rawDiskSource) if linkedQuery != nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, linkedQuery) } } } } // Link to image encryption key if imageEncryptionKey := image.GetImageEncryptionKey(); imageEncryptionKey != nil { c.addKMSKeyLinks(sdpItem, imageEncryptionKey.GetKmsKeyName(), imageEncryptionKey.GetKmsKeyServiceAccount(), location) } // Link to source image encryption key if sourceImageEncryptionKey := image.GetSourceImageEncryptionKey(); sourceImageEncryptionKey != nil { c.addKMSKeyLinks(sdpItem, sourceImageEncryptionKey.GetKmsKeyName(), sourceImageEncryptionKey.GetKmsKeyServiceAccount(), location) } // Link to source snapshot encryption key if sourceSnapshotEncryptionKey := image.GetSourceSnapshotEncryptionKey(); sourceSnapshotEncryptionKey != nil { c.addKMSKeyLinks(sdpItem, sourceSnapshotEncryptionKey.GetKmsKeyName(), sourceSnapshotEncryptionKey.GetKmsKeyServiceAccount(), location) } // Link to replacement image if deprecated := image.GetDeprecated(); deprecated != nil { if replacement := deprecated.GetReplacement(); replacement != "" { projectID := gcpshared.ExtractPathParam("projects", replacement) scope := location.ProjectID if projectID != "" { scope = projectID } // Use SEARCH for all image references - it handles both family and specific image formats sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeImage.String(), Method: sdp.QueryMethod_SEARCH, Query: replacement, // Pass full URI so Search can detect format Scope: scope, }, }) } } return sdpItem, nil } func (c computeImageWrapper) addKMSKeyLinks(sdpItem *sdp.Item, keyName, kmsKeyServiceAccount string, location gcpshared.LocationInfo) { if keyName != "" { loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, }) } else if loc != "" && keyRing != "" && cryptoKey != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey), Scope: location.ProjectID, }, }) } } if kmsKeyServiceAccount != "" { serviceAccountEmail := kmsKeyServiceAccount if strings.Contains(kmsKeyServiceAccount, "/serviceAccounts/") { serviceAccountEmail = gcpshared.LastPathComponent(kmsKeyServiceAccount) } if serviceAccountEmail != "" { projectID := location.ProjectID if strings.Contains(kmsKeyServiceAccount, "/projects/") { extractedProjectID := gcpshared.ExtractPathParam("projects", kmsKeyServiceAccount) if extractedProjectID != "" { projectID = extractedProjectID } } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccount.String(), Method: sdp.QueryMethod_GET, Query: serviceAccountEmail, Scope: projectID, }, }) } } } ================================================ FILE: sources/gcp/manual/compute-image_test.go ================================================ package manual_test import ( "context" "errors" "fmt" "sync" "testing" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeImage(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeImagesClient(ctrl) projectID := "test-project-id" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeImageWithLinks(projectID, "test-image", computepb.Image_READY), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-image", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // sourceDisk link { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-source-disk", ExpectedScope: fmt.Sprintf("%s.us-central1-a", projectID), }, // sourceSnapshot link { ExpectedType: gcpshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-source-snapshot", ExpectedScope: projectID, }, // sourceImage link (SEARCH handles full URI; createComputeImageWithLinks uses https URL) { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/test-source-image", projectID), ExpectedScope: projectID, }, // licenses link (first license) { ExpectedType: gcpshared.ComputeLicense.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-license-1", ExpectedScope: projectID, }, // licenses link (second license) { ExpectedType: gcpshared.ComputeLicense.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-license-2", ExpectedScope: projectID, }, // rawDisk.source (GCS bucket) link { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("%s-raw-disk-bucket", projectID), ExpectedScope: projectID, }, // imageEncryptionKey.kmsKeyName (CryptoKeyVersion) link { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-image-key|test-version-image", ExpectedScope: projectID, }, // imageEncryptionKey.kmsKeyServiceAccount link { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("test-image-kms-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, // sourceImageEncryptionKey.kmsKeyName (CryptoKeyVersion) link { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-source-image-key|test-version-source-image", ExpectedScope: projectID, }, // sourceImageEncryptionKey.kmsKeyServiceAccount link { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("test-source-image-kms-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, // sourceSnapshotEncryptionKey.kmsKeyName (CryptoKeyVersion) link { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-source-snapshot-key|test-version-source-snapshot", ExpectedScope: projectID, }, // sourceSnapshotEncryptionKey.kmsKeyServiceAccount link { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: fmt.Sprintf("test-source-snapshot-kms-sa@%s.iam.gserviceaccount.com", projectID), ExpectedScope: projectID, }, // deprecated.replacement link (SEARCH handles full URI) { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/test-replacement-image", projectID), ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.Image_Status expected sdp.Health } testCases := []testCase{ { name: "Undefined", input: computepb.Image_UNDEFINED_STATUS, expected: sdp.Health_HEALTH_UNKNOWN, }, { name: "Deleting", input: computepb.Image_DELETING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Failed", input: computepb.Image_FAILED, expected: sdp.Health_HEALTH_ERROR, }, { name: "Pending", input: computepb.Image_PENDING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Healthy", input: computepb.Image_READY, expected: sdp.Health_HEALTH_OK, }, } mockClient = mocks.NewMockComputeImagesClient(ctrl) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeImage("test-instance", tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeImageIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeImage("test-image-1", computepb.Image_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeImage("test-image-2", computepb.Image_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeImageIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeImage("test-image-1", computepb.Image_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeImage("test-image-2", computepb.Image_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeImagesClient(ctrl) projectID := "cache-test-project" scope := projectID mockIter := mocks.NewMockComputeImageIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) items, err := listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) // SearchCachesNotFoundWithMemoryCache verifies that when Search returns no items // (NotFound from both Get and GetFromFamily), NOTFOUND is cached. Second Search // hits cache and returns 0 items without calling the backend again. t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeImagesClient(ctrl) projectID := "cache-test-project" scope := projectID query := "nonexistent-image" mockClient.EXPECT().Get(ctx, gomock.Any()).Return(nil, status.Error(codes.NotFound, "image not found")).Times(1) mockClient.EXPECT().GetFromFamily(ctx, gomock.Any()).Return(nil, status.Error(codes.NotFound, "family not found")).Times(1) wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) // First Search: cache miss → Get then GetFromFamily return NotFound → transformer stores NOTFOUND. _, err := searchable.Search(ctx, scope, query, false) if err == nil { t.Fatal("first Search: expected error (NOTFOUND), got nil") } var qe *sdp.QueryError if errors.As(err, &qe) && qe.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("first Search: expected NOTFOUND, got %v", err) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", qErr) } // Second Search: cache hit → transformer returns empty result, no backend calls. items, err := searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("Search", func(t *testing.T) { t.Run("SearchByFamilyName", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) familyName := "test-image-family" expectedImageName := "test-image-family-20240101" // When searching by name (not URI), Search tries Get first, then falls back to GetFromFamily mockClient.EXPECT().Get(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.GetImageRequest, opts ...any) (*computepb.Image, error) { if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if req.GetImage() != familyName { t.Errorf("Expected image %s, got %s", familyName, req.GetImage()) } return nil, status.Error(codes.NotFound, "image not found") }) mockClient.EXPECT().GetFromFamily(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.GetFromFamilyImageRequest, opts ...any) (*computepb.Image, error) { if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if req.GetFamily() != familyName { t.Errorf("Expected family %s, got %s", familyName, req.GetFamily()) } return createComputeImageWithLinks(projectID, expectedImageName, computepb.Image_READY), nil }) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Verify adapter implements SearchableWrapper searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter should implement SearchableAdapter interface") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], familyName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } sdpItem := sdpItems[0] if sdpItem.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", sdpItem.Validate()) } // Verify the returned image has the correct name (from GetFromFamily) uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != expectedImageName { t.Fatalf("Expected image name %s, got %s", expectedImageName, uniqueAttrValue) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } }) t.Run("SearchByFamilyURI", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) // Pass the full URI - Search should detect it's a family reference familyURI := "projects/" + projectID + "/global/images/family/test-image-family" expectedImageName := "test-image-family-20240101" mockClient.EXPECT().GetFromFamily(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.GetFromFamilyImageRequest, opts ...any) (*computepb.Image, error) { if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if req.GetFamily() != "test-image-family" { t.Errorf("Expected family 'test-image-family', got %s", req.GetFamily()) } return createComputeImageWithLinks(projectID, expectedImageName, computepb.Image_READY), nil }) // Call Search directly on the wrapper to bypass adapter's projects/ routing logic sdpItems, err := wrapper.Search(ctx, wrapper.Scopes()[0], familyURI) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } sdpItem := sdpItems[0] uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, attrErr := sdpItem.GetAttributes().Get(uniqueAttrKey) if attrErr != nil { t.Fatalf("Failed to get unique attribute: %v", attrErr) } if uniqueAttrValue != expectedImageName { t.Fatalf("Expected image name %s, got %s", expectedImageName, uniqueAttrValue) } }) t.Run("SearchByImageURI", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) imageURI := "projects/" + projectID + "/global/images/test-image-exact" expectedImageName := "test-image-exact" mockClient.EXPECT().Get(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.GetImageRequest, opts ...any) (*computepb.Image, error) { if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if req.GetImage() != expectedImageName { t.Errorf("Expected image %s, got %s", expectedImageName, req.GetImage()) } return createComputeImage(expectedImageName, computepb.Image_READY), nil }) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], imageURI, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } sdpItem := sdpItems[0] uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != expectedImageName { t.Fatalf("Expected image name %s, got %s", expectedImageName, uniqueAttrValue) } }) t.Run("SearchByImageNameWithFallback", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeImagesClient(ctrl) wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) imageName := "test-image-name" expectedImageName := "test-image-name" // First Get call fails with NotFound mockClient.EXPECT().Get(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.GetImageRequest, opts ...any) (*computepb.Image, error) { if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if req.GetImage() != imageName { t.Errorf("Expected image %s, got %s", imageName, req.GetImage()) } return nil, status.Error(codes.NotFound, "image not found") }) // Then GetFromFamily succeeds (treating name as family) mockClient.EXPECT().GetFromFamily(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.GetFromFamilyImageRequest, opts ...any) (*computepb.Image, error) { if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if req.GetFamily() != imageName { t.Errorf("Expected family %s, got %s", imageName, req.GetFamily()) } return createComputeImage(expectedImageName, computepb.Image_READY), nil }) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable := adapter.(discovery.SearchableAdapter) sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], imageName, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } sdpItem := sdpItems[0] uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != expectedImageName { t.Fatalf("Expected image name %s, got %s", expectedImageName, uniqueAttrValue) } }) }) t.Run("GetStillWorksWithExactName", func(t *testing.T) { wrapper := manual.NewComputeImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) exactImageName := "test-image-exact" mockClient.EXPECT().Get(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.GetImageRequest, opts ...any) (*computepb.Image, error) { if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if req.GetImage() != exactImageName { t.Errorf("Expected image %s, got %s", exactImageName, req.GetImage()) } return createComputeImage(exactImageName, computepb.Image_READY), nil }) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], exactImageName, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify Get still works with exact image names uniqueAttrKey := sdpItem.GetUniqueAttribute() uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey) if err != nil { t.Fatalf("Failed to get unique attribute: %v", err) } if uniqueAttrValue != exactImageName { t.Fatalf("Expected image name %s, got %s", exactImageName, uniqueAttrValue) } }) } func createComputeImage(imageName string, status computepb.Image_Status) *computepb.Image { return &computepb.Image{ Name: new(imageName), Labels: map[string]string{"env": "test"}, Status: new(status.String()), } } func createComputeImageWithLinks(projectID, imageName string, status computepb.Image_Status) *computepb.Image { zone := "us-central1-a" sourceDiskURL := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/test-source-disk", projectID, zone) sourceSnapshotURL := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/snapshots/test-source-snapshot", projectID) sourceImageURL := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/test-source-image", projectID) replacementImageURL := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/test-replacement-image", projectID) return &computepb.Image{ Name: new(imageName), Labels: map[string]string{"env": "test"}, Status: new(status.String()), SourceDisk: &sourceDiskURL, SourceSnapshot: &sourceSnapshotURL, SourceImage: &sourceImageURL, Licenses: []string{ fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/licenses/test-license-1", projectID), fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/licenses/test-license-2", projectID), }, RawDisk: &computepb.RawDisk{ Source: new(fmt.Sprintf("gs://%s-raw-disk-bucket/raw-disk.tar.gz", projectID)), }, ImageEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new(fmt.Sprintf("projects/%s/locations/global/keyRings/test-keyring/cryptoKeys/test-image-key/cryptoKeyVersions/test-version-image", projectID)), KmsKeyServiceAccount: new(fmt.Sprintf("projects/%s/serviceAccounts/test-image-kms-sa@%s.iam.gserviceaccount.com", projectID, projectID)), }, SourceImageEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new(fmt.Sprintf("projects/%s/locations/global/keyRings/test-keyring/cryptoKeys/test-source-image-key/cryptoKeyVersions/test-version-source-image", projectID)), KmsKeyServiceAccount: new(fmt.Sprintf("projects/%s/serviceAccounts/test-source-image-kms-sa@%s.iam.gserviceaccount.com", projectID, projectID)), }, SourceSnapshotEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new(fmt.Sprintf("projects/%s/locations/global/keyRings/test-keyring/cryptoKeys/test-source-snapshot-key/cryptoKeyVersions/test-version-source-snapshot", projectID)), KmsKeyServiceAccount: new(fmt.Sprintf("projects/%s/serviceAccounts/test-source-snapshot-kms-sa@%s.iam.gserviceaccount.com", projectID, projectID)), }, Deprecated: &computepb.DeprecationStatus{ Replacement: &replacementImageURL, }, } } ================================================ FILE: sources/gcp/manual/compute-instance-group-manager-shared.go ================================================ package manual import ( "context" "strings" "cloud.google.com/go/compute/apiv1/computepb" "github.com/overmindtech/cli/go/sdp-go" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // InstanceGroupManagerToSDPItem converts a GCP InstanceGroupManager to an SDP Item. // This function is shared between zonal and regional instance group manager adapters. // The itemType parameter determines which Overmind type the SDP item will have. func InstanceGroupManagerToSDPItem(ctx context.Context, instanceGroupManager *computepb.InstanceGroupManager, location gcpshared.LocationInfo, itemType shared.ItemType) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(instanceGroupManager, "") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: itemType.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), } // Deleting the Instance Group Manager: // If the IGM is deleted, the associated instances are also deleted, but the instance template remains unaffected. // The instance template can still be used by other IGMs or for creating standalone instances. // Deleting an instance template also doesn't not delete the IGM. // Link instance template if instanceTemplate := instanceGroupManager.GetInstanceTemplate(); instanceTemplate != "" { instanceTemplateName := gcpshared.LastPathComponent(instanceTemplate) scope, err := gcpshared.ExtractScopeFromURI(ctx, instanceTemplate) if err == nil && instanceTemplateName != "" { templateType := gcpshared.ComputeInstanceTemplate if strings.Contains(instanceTemplate, "/regions/") { templateType = gcpshared.ComputeRegionInstanceTemplate } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: templateType.String(), Method: sdp.QueryMethod_GET, Query: instanceTemplateName, Scope: scope, }, }) } } // Link instance group if group := instanceGroupManager.GetInstanceGroup(); group != "" { instanceGroupName := gcpshared.LastPathComponent(group) scope, err := gcpshared.ExtractScopeFromURI(ctx, group) if err == nil && instanceGroupName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstanceGroup.String(), Method: sdp.QueryMethod_GET, Query: instanceGroupName, Scope: scope, }, }) } } // Link zone (for zonal instance group managers) if zone := instanceGroupManager.GetZone(); zone != "" { zoneName := gcpshared.LastPathComponent(zone) if zoneName != "" && location.ProjectID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeZone.String(), Method: sdp.QueryMethod_GET, Query: zoneName, Scope: location.ProjectID, }, }) } } // Link region (for regional instance group managers) if region := instanceGroupManager.GetRegion(); region != "" { regionName := gcpshared.LastPathComponent(region) if regionName != "" && location.ProjectID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRegion.String(), Method: sdp.QueryMethod_GET, Query: regionName, Scope: location.ProjectID, }, }) } } // Link zones from distribution policy (for regional MIGs with explicit zone distribution) if distributionPolicy := instanceGroupManager.GetDistributionPolicy(); distributionPolicy != nil { for _, zoneConfig := range distributionPolicy.GetZones() { if zoneURL := zoneConfig.GetZone(); zoneURL != "" { zoneName := gcpshared.LastPathComponent(zoneURL) if zoneName != "" && location.ProjectID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeZone.String(), Method: sdp.QueryMethod_GET, Query: zoneName, Scope: location.ProjectID, }, }) } } } } // Link target pools for _, targetPool := range instanceGroupManager.GetTargetPools() { targetPoolName := gcpshared.LastPathComponent(targetPool) scope, err := gcpshared.ExtractScopeFromURI(ctx, targetPool) if err == nil && targetPoolName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeTargetPool.String(), Method: sdp.QueryMethod_GET, Query: targetPoolName, Scope: scope, }, }) } } // Link resource policies from ResourcePolicies.WorkloadPolicy if resourcePolicies := instanceGroupManager.GetResourcePolicies(); resourcePolicies != nil { if workloadPolicy := resourcePolicies.GetWorkloadPolicy(); workloadPolicy != "" { resourcePolicyName := gcpshared.LastPathComponent(workloadPolicy) scope, err := gcpshared.ExtractScopeFromURI(ctx, workloadPolicy) if err == nil && resourcePolicyName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: resourcePolicyName, Scope: scope, }, }) } } } // Link to instance templates in versions array (used for canary/rolling deployments) // If versions are defined, they override the top-level instanceTemplate // Each version can have its own template, so we need to link all of them for _, version := range instanceGroupManager.GetVersions() { if versionTemplate := version.GetInstanceTemplate(); versionTemplate != "" { versionTemplateName := gcpshared.LastPathComponent(versionTemplate) scope, err := gcpshared.ExtractScopeFromURI(ctx, versionTemplate) if err == nil && versionTemplateName != "" { templateType := gcpshared.ComputeInstanceTemplate if strings.Contains(versionTemplate, "/regions/") { templateType = gcpshared.ComputeRegionInstanceTemplate } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: templateType.String(), Method: sdp.QueryMethod_GET, Query: versionTemplateName, Scope: scope, }, }) } } } // Link to health checks used in auto-healing policies // Auto-healing policies use health checks to determine if instances are healthy // If the health check is deleted or updated, auto-healing may fail for _, autoHealingPolicy := range instanceGroupManager.GetAutoHealingPolicies() { if healthCheckURL := autoHealingPolicy.GetHealthCheck(); healthCheckURL != "" { healthCheckName := gcpshared.LastPathComponent(healthCheckURL) scope, err := gcpshared.ExtractScopeFromURI(ctx, healthCheckURL) if err == nil && healthCheckName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: healthCheckName, Scope: scope, }, }) } } } // Autoscalers set the Instance Group Manager target size // InstanceGroupManagers orphans the autoscaler when deleted if status := instanceGroupManager.GetStatus(); status != nil { if autoscalerURL := status.GetAutoscaler(); autoscalerURL != "" { autoscalerName := gcpshared.LastPathComponent(autoscalerURL) scope, err := gcpshared.ExtractScopeFromURI(ctx, autoscalerURL) if err == nil && autoscalerName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeAutoscaler.String(), Method: sdp.QueryMethod_GET, Query: autoscalerName, Scope: scope, }, }) } } } switch { case instanceGroupManager.GetStatus() != nil && instanceGroupManager.GetStatus().GetIsStable(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-instance-group-manager.go ================================================ package manual import ( "context" "errors" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeInstanceGroupManagerLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeInstanceGroupManager) type computeInstanceGroupManagerWrapper struct { client gcpshared.ComputeInstanceGroupManagerClient *gcpshared.ZoneBase } // NewComputeInstanceGroupManager creates a new computeInstanceGroupManagerWrapper. func NewComputeInstanceGroupManager(client gcpshared.ComputeInstanceGroupManagerClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeInstanceGroupManagerWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeInstanceGroupManager, ), } } func (c computeInstanceGroupManagerWrapper) IAMPermissions() []string { return []string{ "compute.instanceGroupManagers.get", "compute.instanceGroupManagers.list", } } func (c computeInstanceGroupManagerWrapper) PredefinedRole() string { return "roles/compute.viewer" } // PotentialLinks returns the potential links for the compute instance group manager wrapper func (c computeInstanceGroupManagerWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeInstanceTemplate, gcpshared.ComputeRegionInstanceTemplate, gcpshared.ComputeInstanceGroup, gcpshared.ComputeTargetPool, gcpshared.ComputeResourcePolicy, gcpshared.ComputeAutoscaler, gcpshared.ComputeHealthCheck, gcpshared.ComputeZone, gcpshared.ComputeRegion, ) } // TerraformMappings returns the Terraform mappings for the compute instance group manager wrapper func (c computeInstanceGroupManagerWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_instance_group_manager#argument-reference TerraformQueryMap: "google_compute_instance_group_manager.name", }, } } // GetLookups returns the lookups for the compute instance group manager wrapper func (c computeInstanceGroupManagerWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeInstanceGroupManagerLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute instance group managers since they use aggregatedList func (c computeInstanceGroupManagerWrapper) SupportsWildcardScope() bool { return true } // Get retrieves a compute instance group manager by its name func (c computeInstanceGroupManagerWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetInstanceGroupManagerRequest{ Project: location.ProjectID, Zone: location.Zone, InstanceGroupManager: queryParts[0], } igm, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpInstanceGroupManagerToSDPItem(ctx, igm, location) } func (c computeInstanceGroupManagerWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeInstanceGroupManagerWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListInstanceGroupManagersRequest{ Project: location.ProjectID, Zone: location.Zone, }) var itemsSent int var hadError bool for { igm, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpInstanceGroupManagerToSDPItem(ctx, igm, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instance group managers found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all instance group managers across all zones func (c computeInstanceGroupManagerWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListInstanceGroupManagersRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process instance group managers in this scope if pair.Value != nil && pair.Value.GetInstanceGroupManagers() != nil { for _, igm := range pair.Value.GetInstanceGroupManagers() { item, sdpErr := c.gcpInstanceGroupManagerToSDPItem(ctx, igm, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instance group managers found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeInstanceGroupManagerWrapper) gcpInstanceGroupManagerToSDPItem(ctx context.Context, instanceGroupManager *computepb.InstanceGroupManager, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { return InstanceGroupManagerToSDPItem(ctx, instanceGroupManager, location, gcpshared.ComputeInstanceGroupManager) } ================================================ FILE: sources/gcp/manual/compute-instance-group-manager_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeInstanceGroupManager(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstanceGroupManagerClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" region := "us-central1" instanceTemplateName := "https://www.googleapis.com/compute/v1/projects/test-project-id/global/instanceTemplates/unit-test-template" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createInstanceGroupManager("test-instance-group-manager", true, instanceTemplateName), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != gcpshared.ComputeInstanceGroupManager.String() { t.Fatalf("Expected type %s, got: %s", gcpshared.ComputeInstanceGroupManager.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { t.Run("GlobalInstanceTemplate", func(t *testing.T) { igm := createInstanceGroupManager("test-instance-group-manager", true, instanceTemplateName) wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(igm, nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "unit-test-template", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeInstanceGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-group", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: gcpshared.ComputeZone.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1-a", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeTargetPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pool", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("RegionalInstanceTemplate", func(t *testing.T) { regionalInstanceTemplateName := "https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/instanceTemplates/unit-test-template" igm := createInstanceGroupManager("test-instance-group-manager", true, regionalInstanceTemplateName) wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(igm, nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeRegionInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "unit-test-template", ExpectedScope: gcpshared.RegionalScope(projectID, region), }, { ExpectedType: gcpshared.ComputeInstanceGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-group", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: gcpshared.ComputeZone.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1-a", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeTargetPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pool", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("VersionsWithInstanceTemplates", func(t *testing.T) { // Create IGM with versions array containing multiple templates igm := &computepb.InstanceGroupManager{ Name: new("test-instance-group-manager"), Status: &computepb.InstanceGroupManagerStatus{ IsStable: new(true), }, Versions: []*computepb.InstanceGroupManagerVersion{ { Name: new("canary"), InstanceTemplate: new("https://www.googleapis.com/compute/v1/projects/test-project-id/global/instanceTemplates/canary-template"), }, { Name: new("stable"), InstanceTemplate: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/instanceTemplates/stable-template"), }, }, InstanceGroup: new("projects/test-project-id/zones/us-central1-a/instanceGroups/test-group"), TargetPools: []string{ "https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/targetPools/test-pool", }, ResourcePolicies: &computepb.InstanceGroupManagerResourcePolicies{ WorkloadPolicy: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/resourcePolicies/test-policy"), }, } wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(igm, nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ // Canary version template (global) { ExpectedType: gcpshared.ComputeInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "canary-template", ExpectedScope: projectID, }, // Stable version template (regional) { ExpectedType: gcpshared.ComputeRegionInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "stable-template", ExpectedScope: gcpshared.RegionalScope(projectID, region), }, { ExpectedType: gcpshared.ComputeInstanceGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-group", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: gcpshared.ComputeTargetPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pool", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("AutoHealingPoliciesWithHealthCheck", func(t *testing.T) { // Create IGM with auto-healing policy containing health check igm := &computepb.InstanceGroupManager{ Name: new("test-instance-group-manager"), Status: &computepb.InstanceGroupManagerStatus{ IsStable: new(true), }, Zone: new("https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a"), InstanceTemplate: new(instanceTemplateName), InstanceGroup: new("projects/test-project-id/zones/us-central1-a/instanceGroups/test-group"), AutoHealingPolicies: []*computepb.InstanceGroupManagerAutoHealingPolicy{ { HealthCheck: new("https://www.googleapis.com/compute/v1/projects/test-project-id/global/healthChecks/test-health-check"), InitialDelaySec: new(int32(300)), }, }, TargetPools: []string{ "https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/targetPools/test-pool", }, ResourcePolicies: &computepb.InstanceGroupManagerResourcePolicies{ WorkloadPolicy: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/resourcePolicies/test-policy"), }, } wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(igm, nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "unit-test-template", ExpectedScope: projectID, }, // Health check from auto-healing policy { ExpectedType: gcpshared.ComputeHealthCheck.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-health-check", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeInstanceGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-group", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: gcpshared.ComputeZone.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1-a", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeTargetPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pool", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string isStable bool expected sdp.Health } testCases := []testCase{ { name: "Healthy", isStable: true, expected: sdp.Health_HEALTH_OK, }, { name: "Unhealthy", isStable: false, expected: sdp.Health_HEALTH_UNKNOWN, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createInstanceGroupManager("test-instance-group-manager", tc.isStable, instanceTemplateName), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockComputeInstanceGroupManagerIterator(ctrl) mockIterator.EXPECT().Next().Return(createInstanceGroupManager("instance-group-manager-1", true, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(createInstanceGroupManager("instance-group-manager-2", false, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for i, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } expectedName := "instance-group-manager-" + fmt.Sprintf("%d", i+1) if item.UniqueAttributeValue() != expectedName { t.Fatalf("Expected name %s, got: %s", expectedName, item.UniqueAttributeValue()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockComputeInstanceGroupManagerIterator(ctrl) mockIterator.EXPECT().Next().Return(createInstanceGroupManager("instance-group-manager-1", true, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(createInstanceGroupManager("instance-group-manager-2", false, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item); wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstanceGroupManagerClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone mockAggIter := mocks.NewMockInstanceGroupManagersScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.InstanceGroupManagersScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeInstanceGroupManagerIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createInstanceGroupManager(name string, isStable bool, instanceTemplate string) *computepb.InstanceGroupManager { return &computepb.InstanceGroupManager{ Name: new(name), Status: &computepb.InstanceGroupManagerStatus{ IsStable: new(isStable), }, Zone: new("https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a"), InstanceTemplate: new(instanceTemplate), InstanceGroup: new("projects/test-project-id/zones/us-central1-a/instanceGroups/test-group"), TargetPools: []string{ "https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/targetPools/test-pool", }, ResourcePolicies: &computepb.InstanceGroupManagerResourcePolicies{ WorkloadPolicy: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/resourcePolicies/test-policy"), }, } } ================================================ FILE: sources/gcp/manual/compute-instance-group.go ================================================ package manual import ( "context" "errors" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeInstanceGroupLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeInstanceGroup) type computeInstanceGroupWrapper struct { client gcpshared.ComputeInstanceGroupsClient *gcpshared.ZoneBase } // NewComputeInstanceGroup creates a new computeInstanceGroupWrapper instance. func NewComputeInstanceGroup(client gcpshared.ComputeInstanceGroupsClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeInstanceGroupWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeInstanceGroup, ), } } func (c computeInstanceGroupWrapper) IAMPermissions() []string { return []string{ "compute.instanceGroups.get", "compute.instanceGroups.list", } } func (c computeInstanceGroupWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeInstanceGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeSubnetwork, gcpshared.ComputeNetwork, gcpshared.ComputeZone, gcpshared.ComputeRegion, ) } func (c computeInstanceGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_instance_group.name", }, } } func (c computeInstanceGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeInstanceGroupLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute instance groups since they use aggregatedList func (c computeInstanceGroupWrapper) SupportsWildcardScope() bool { return true } func (c computeInstanceGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetInstanceGroupRequest{ Project: location.ProjectID, Zone: location.Zone, InstanceGroup: queryParts[0], } instanceGroup, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeInstanceGroupToSDPItem(instanceGroup, location) } func (c computeInstanceGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeInstanceGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListInstanceGroupsRequest{ Project: location.ProjectID, Zone: location.Zone, }) var itemsSent int var hadError bool for { instanceGroup, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeInstanceGroupToSDPItem(instanceGroup, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instance groups found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all instance groups across all zones func (c computeInstanceGroupWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListInstanceGroupsRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process instance groups in this scope if pair.Value != nil && pair.Value.GetInstanceGroups() != nil { for _, instanceGroup := range pair.Value.GetInstanceGroups() { item, sdpErr := c.gcpComputeInstanceGroupToSDPItem(instanceGroup, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instance groups found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeInstanceGroupWrapper) gcpComputeInstanceGroupToSDPItem(instanceGroup *computepb.InstanceGroup, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(instanceGroup, "") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } item := &sdp.Item{ Type: gcpshared.ComputeInstanceGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), } if network := instanceGroup.GetNetwork(); network != "" { networkName := gcpshared.LastPathComponent(network) if networkName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetwork.String(), Method: sdp.QueryMethod_GET, Query: networkName, Scope: location.ProjectID, }, }) } } if subnetwork := instanceGroup.GetSubnetwork(); subnetwork != "" { subnetworkName := gcpshared.LastPathComponent(subnetwork) if subnetworkName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSubnetwork.String(), Method: sdp.QueryMethod_GET, Query: subnetworkName, Scope: location.ProjectID, }, }) } } if zone := instanceGroup.GetZone(); zone != "" { zoneName := gcpshared.LastPathComponent(zone) if zoneName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeZone.String(), Method: sdp.QueryMethod_GET, Query: zoneName, Scope: location.ProjectID, }, }) } } if region := instanceGroup.GetRegion(); region != "" { regionName := gcpshared.LastPathComponent(region) if regionName != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRegion.String(), Method: sdp.QueryMethod_GET, Query: regionName, Scope: location.ProjectID, }, }) } } return item, nil } ================================================ FILE: sources/gcp/manual/compute-instance-group_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeInstanceGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstanceGroupsClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeInstanceGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeInstanceGroup("test-ig", "test-network", "test-subnetwork", projectID, zone), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-ig", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } nameAttrValue, err := sdpItem.GetAttributes().Get("name") if err != nil || nameAttrValue != "test-ig" { t.Fatalf("Expected name 'test-ig', got: %s. Error: %v", nameAttrValue, err) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnetwork", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeZone.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: zone, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeInstanceGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockComputeInstanceGroupIterator(ctrl) mockIterator.EXPECT().Next().Return(createComputeInstanceGroup("test-ig-1", "net-1", "subnet-1", projectID, zone), nil) mockIterator.EXPECT().Next().Return(createComputeInstanceGroup("test-ig-2", "net-2", "subnet-2", projectID, zone), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeInstanceGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockComputeInstanceGroupIterator(ctrl) mockIterator.EXPECT().Next().Return(createComputeInstanceGroup("test-ig-1", "net-1", "subnet-1", projectID, zone), nil) mockIterator.EXPECT().Next().Return(createComputeInstanceGroup("test-ig-2", "net-2", "subnet-2", projectID, zone), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item); wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstanceGroupsClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone mockAggIter := mocks.NewMockInstanceGroupsScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.InstanceGroupsScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeInstanceGroupIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeInstanceGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createComputeInstanceGroup(name, network, subnetwork, projectID, zone string) *computepb.InstanceGroup { return &computepb.InstanceGroup{ Name: new(name), Network: new(fmt.Sprintf("projects/%s/global/networks/%s", projectID, network)), Subnetwork: new(fmt.Sprintf("projects/%s/regions/us-central1/subnetworks/%s", projectID, subnetwork)), Zone: new(fmt.Sprintf("projects/%s/zones/%s", projectID, zone)), } } ================================================ FILE: sources/gcp/manual/compute-instance.go ================================================ package manual import ( "context" "errors" "strings" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeInstanceLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeInstance) var ComputeInstanceLookupByNetworkTag = shared.NewItemTypeLookup("networkTag", gcpshared.ComputeInstance) type computeInstanceWrapper struct { client gcpshared.ComputeInstanceClient *gcpshared.ZoneBase } // NewComputeInstance creates a new computeInstanceWrapper instance. func NewComputeInstance(client gcpshared.ComputeInstanceClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeInstanceWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeInstance, ), } } func (c computeInstanceWrapper) IAMPermissions() []string { return []string{ "compute.instances.get", "compute.instances.list", } } func (c computeInstanceWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeInstanceWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( stdlib.NetworkIP, stdlib.NetworkDNS, gcpshared.ComputeDisk, gcpshared.ComputeSubnetwork, gcpshared.ComputeNetwork, gcpshared.ComputeResourcePolicy, gcpshared.IAMServiceAccount, gcpshared.ComputeImage, gcpshared.ComputeSnapshot, gcpshared.CloudKMSCryptoKey, gcpshared.CloudKMSCryptoKeyVersion, gcpshared.ComputeZone, gcpshared.ComputeInstanceTemplate, gcpshared.ComputeRegionInstanceTemplate, gcpshared.ComputeInstanceGroupManager, gcpshared.ComputeFirewall, gcpshared.ComputeRoute, ) } func (c computeInstanceWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_instance#argument-reference TerraformQueryMap: "google_compute_instance.name", }, } } func (c computeInstanceWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeInstanceLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute instances since they use aggregatedList func (c computeInstanceWrapper) SupportsWildcardScope() bool { return true } func (c computeInstanceWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {ComputeInstanceLookupByNetworkTag}, } } // Search finds compute instances by network tag. The engine routes // project-scoped SEARCH queries to zonal scopes via substring matching, so // scope is a zonal scope like "project.zone". We list all instances via // AggregatedList and filter to the matching zone + tag. func (c computeInstanceWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { tag := queryParts[0] allItems, qErr := c.List(ctx, "*") if qErr != nil { return nil, qErr } var matched []*sdp.Item for _, item := range allItems { if item.GetScope() != scope { continue } tagsVal, err := item.GetAttributes().Get("tags") if err != nil { continue } tagsMap, ok := tagsVal.(map[string]any) if !ok { continue } itemsVal, ok := tagsMap["items"] if !ok { continue } itemsList, ok := itemsVal.([]any) if !ok { continue } for _, t := range itemsList { if s, ok := t.(string); ok && s == tag { matched = append(matched, item) break } } } return matched, nil } func (c computeInstanceWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetInstanceRequest{ Project: location.ProjectID, Zone: location.Zone, Instance: queryParts[0], } instance, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeInstanceToSDPItem(ctx, instance, location) } func (c computeInstanceWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeInstanceWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListInstancesRequest{ Project: location.ProjectID, Zone: location.Zone, }) itemsSent := 0 var hadError bool for { instance, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeInstanceToSDPItem(ctx, instance, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instances found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all instances across all zones func (c computeInstanceWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) var itemsSent atomic.Int32 var hadError atomic.Bool // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListInstancesRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process instances in this scope if pair.Value != nil && pair.Value.GetInstances() != nil { for _, instance := range pair.Value.GetInstances() { item, sdpErr := c.gcpComputeInstanceToSDPItem(ctx, instance, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instances found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeInstanceWrapper) gcpComputeInstanceToSDPItem(ctx context.Context, instance *computepb.Instance, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(instance, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeInstance.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: instance.GetLabels(), } for _, disk := range instance.GetDisks() { if disk.GetSource() != "" { if strings.Contains(disk.GetSource(), "/") { diskNameParts := strings.Split(disk.GetSource(), "/") diskName := diskNameParts[len(diskNameParts)-1] scope, err := gcpshared.ExtractScopeFromURI(ctx, disk.GetSource()) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: scope, }, }) } } } // Link to source image if disk is being initialized from an image if initializeParams := disk.GetInitializeParams(); initializeParams != nil { if sourceImage := initializeParams.GetSourceImage(); sourceImage != "" { imageName := gcpshared.LastPathComponent(sourceImage) if imageName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceImage) if err == nil { // Use SEARCH for all image references - it handles both family and specific image formats sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeImage.String(), Method: sdp.QueryMethod_SEARCH, Query: sourceImage, // Pass full URI so Search can detect format Scope: scope, }, }) } } } // Link to source snapshot if disk is being initialized from a snapshot if sourceSnapshot := initializeParams.GetSourceSnapshot(); sourceSnapshot != "" { snapshotName := gcpshared.LastPathComponent(sourceSnapshot) if snapshotName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceSnapshot) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: scope, }, }) } } } // Link to KMS key used to decrypt source image if sourceImageEncryptionKey := initializeParams.GetSourceImageEncryptionKey(); sourceImageEncryptionKey != nil { if keyName := sourceImageEncryptionKey.GetKmsKeyName(); keyName != "" { loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, }) } } } // Link to KMS key used to decrypt source snapshot if sourceSnapshotEncryptionKey := initializeParams.GetSourceSnapshotEncryptionKey(); sourceSnapshotEncryptionKey != nil { if keyName := sourceSnapshotEncryptionKey.GetKmsKeyName(); keyName != "" { loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, }) } } } } // Link to KMS key used for disk encryption if diskEncryptionKey := disk.GetDiskEncryptionKey(); diskEncryptionKey != nil { if keyName := diskEncryptionKey.GetKmsKeyName(); keyName != "" { loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" { if cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, }) } else { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey), Scope: location.ProjectID, }, }) } } } } } if instance.GetNetworkInterfaces() != nil { for _, networkInterface := range instance.GetNetworkInterfaces() { if networkInterface.GetNetworkIP() != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: networkInterface.GetNetworkIP(), Scope: "global", }, }) } if networkInterface.GetIpv6Address() != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: networkInterface.GetIpv6Address(), Scope: "global", }, }) } // Link to external IPv4 address from access configs for _, accessConfig := range networkInterface.GetAccessConfigs() { if natIP := accessConfig.GetNatIP(); natIP != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: natIP, Scope: "global", }, }) } if externalIPv6 := accessConfig.GetExternalIpv6(); externalIPv6 != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: externalIPv6, Scope: "global", }, }) } } // Link to external IPv6 address from ipv6AccessConfigs for _, ipv6AccessConfig := range networkInterface.GetIpv6AccessConfigs() { if externalIPv6 := ipv6AccessConfig.GetExternalIpv6(); externalIPv6 != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: externalIPv6, Scope: "global", }, }) } } if subnetwork := networkInterface.GetSubnetwork(); subnetwork != "" { if strings.Contains(subnetwork, "/") { subnetworkName := gcpshared.LastPathComponent(subnetwork) region := gcpshared.ExtractPathParam("regions", subnetwork) if region != "" && subnetworkName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSubnetwork.String(), Method: sdp.QueryMethod_GET, Query: subnetworkName, Scope: gcpshared.RegionalScope(location.ProjectID, region), }, }) } } } if network := networkInterface.GetNetwork(); network != "" { if strings.Contains(network, "/") { networkName := gcpshared.LastPathComponent(network) if networkName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetwork.String(), Method: sdp.QueryMethod_GET, Query: networkName, Scope: location.ProjectID, }, }) } } } } } // Link to resource policies for _, rp := range instance.GetResourcePolicies() { if strings.Contains(rp, "/") { parts := gcpshared.ExtractPathParams(rp, "regions", "resourcePolicies") if len(parts) == 2 && parts[0] != "" && parts[1] != "" { resourcePolicyName := parts[1] region := parts[0] sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: resourcePolicyName, Scope: gcpshared.RegionalScope(location.ProjectID, region), }, }) } } } // Link to service account for _, sa := range instance.GetServiceAccounts() { if email := sa.GetEmail(); email != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccount.String(), Method: sdp.QueryMethod_GET, Query: email, Scope: location.ProjectID, }, }) } } // Link to zone if zoneURL := instance.GetZone(); zoneURL != "" { zoneName := gcpshared.LastPathComponent(zoneURL) if zoneName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeZone.String(), Method: sdp.QueryMethod_GET, Query: zoneName, Scope: location.ProjectID, }, }) } } // Link to instance template and instance group manager from metadata if metadata := instance.GetMetadata(); metadata != nil { for _, item := range metadata.GetItems() { key := item.GetKey() value := item.GetValue() switch key { case "instance-template": // Link to instance template (global or regional) if value != "" { templateName := gcpshared.LastPathComponent(value) scope, err := gcpshared.ExtractScopeFromURI(ctx, value) if err == nil && templateName != "" { templateType := gcpshared.ComputeInstanceTemplate if strings.Contains(value, "/regions/") { templateType = gcpshared.ComputeRegionInstanceTemplate } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: templateType.String(), Method: sdp.QueryMethod_GET, Query: templateName, Scope: scope, }, }) } } case "created-by": // Link to instance group manager (zonal or regional) if value != "" { igmName := gcpshared.LastPathComponent(value) scope, err := gcpshared.ExtractScopeFromURI(ctx, value) if err == nil && igmName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstanceGroupManager.String(), Method: sdp.QueryMethod_GET, Query: igmName, Scope: scope, }, }) } } } } } // Link to firewalls and routes by network tag. // Tag-based SEARCH lists all firewalls/routes in scope then filters; // may be slow in very large projects. if tags := instance.GetTags(); tags != nil { for _, tag := range tags.GetItems() { tag = strings.TrimSpace(tag) if tag == "" { continue } sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeFirewall.String(), Method: sdp.QueryMethod_SEARCH, Query: tag, Scope: location.ProjectID, }, }, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRoute.String(), Method: sdp.QueryMethod_SEARCH, Query: tag, Scope: location.ProjectID, }, }, ) } } // Set health based on status switch instance.GetStatus() { case computepb.Instance_RUNNING.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case computepb.Instance_STOPPING.String(), computepb.Instance_SUSPENDING.String(), computepb.Instance_PROVISIONING.String(), computepb.Instance_STAGING.String(), computepb.Instance_REPAIRING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.Instance_TERMINATED.String(), computepb.Instance_STOPPED.String(), computepb.Instance_SUSPENDED.String(): // No health set for stopped/terminated instances } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-instance_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeInstance(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstanceClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeInstance("test-instance", computepb.Instance_RUNNING), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: "test-project-id", }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.Instance_Status expected sdp.Health } testCases := []testCase{ { name: "Healthy", input: computepb.Instance_RUNNING, expected: sdp.Health_HEALTH_OK, }, { name: "Terminated", input: computepb.Instance_TERMINATED, expected: sdp.Health_HEALTH_UNKNOWN, }, { name: "Stopped", input: computepb.Instance_STOPPED, expected: sdp.Health_HEALTH_UNKNOWN, }, { name: "Suspended", input: computepb.Instance_SUSPENDED, expected: sdp.Health_HEALTH_UNKNOWN, }, { name: "Provisioning", input: computepb.Instance_PROVISIONING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Repairing", input: computepb.Instance_REPAIRING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Staging", input: computepb.Instance_STAGING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Stopping", input: computepb.Instance_STOPPING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Suspending", input: computepb.Instance_SUSPENDING, expected: sdp.Health_HEALTH_PENDING, }, } mockClient = mocks.NewMockComputeInstanceClient(ctrl) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeInstance("test-instance", tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeInstanceIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeInstance("test-instance-1", computepb.Instance_RUNNING), nil) mockComputeIterator.EXPECT().Next().Return(createComputeInstance("test-instance-2", computepb.Instance_RUNNING), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeInstanceIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeInstance("test-instance-1", computepb.Instance_RUNNING), nil) mockComputeIterator.EXPECT().Next().Return(createComputeInstance("test-instance-2", computepb.Instance_RUNNING), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter should support Search operation (for network tag search)") } }) // ListCachesNotFoundWithMemoryCache verifies that when List returns 0 items, // NOTFOUND is cached (for both "*" and a specific scope). We verify caching // by: (1) calling cache.Lookup and asserting cache hit with NOTFOUND error, // (2) repeating the List call and asserting the GCP client is not called again (gomock Times(1)). t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstanceClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone // Empty aggregated iterator: one Next() then Done (for List("*")). mockAggIter := mocks.NewMockInstancesScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.InstancesScopedListPair{}, iterator.Done) // Empty per-zone iterator: one Next() then Done (for List(scope)). mockListIter := mocks.NewMockComputeInstanceIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" (wildcard): two List calls --- // First List("*"): cache miss → listAggregatedStream → AggregatedList (0 items) → NOTFOUND cached. items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } // Verify NOTFOUND is in the cache for "*". cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*) after first call") } if qErr == nil { t.Fatal("expected cached NOTFOUND error for List(*), got nil") } if qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected cached error type NOTFOUND for List(*), got %v", qErr.GetErrorType()) } // Second List("*"): must hit cache (no second AggregatedList call). items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope: two List calls --- // First List(scope): cache miss → per-zone List → List (0 items) → NOTFOUND cached. items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } // Verify NOTFOUND is in the cache for scope. cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope) after first call") } if qErr == nil { t.Fatal("expected cached NOTFOUND error for List(scope), got nil") } if qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected cached error type NOTFOUND for List(scope), got %v", qErr.GetErrorType()) } // Second List(scope): must hit cache (no second List call). items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } // We know it was cached: (1) cache.Lookup returned cacheHit true with NOTFOUND, and // (2) ctrl.Finish() verifies AggregatedList and List were each called exactly once. }) t.Run("GetWithInitializeParams", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) // Test with sourceImage and sourceSnapshot in initializeParams sourceImageURL := fmt.Sprintf("projects/%s/global/images/test-image", projectID) sourceSnapshotURL := fmt.Sprintf("projects/%s/global/snapshots/test-snapshot", projectID) sourceImageKeyName := fmt.Sprintf("projects/%s/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-image", projectID) sourceSnapshotKeyName := fmt.Sprintf("projects/%s/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-snapshot", projectID) instance := createComputeInstance("test-instance", computepb.Instance_RUNNING) instance.Disks = []*computepb.AttachedDisk{ { DeviceName: new("test-disk"), Source: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/test-instance", projectID, zone)), InitializeParams: &computepb.AttachedDiskInitializeParams{ SourceImage: new(sourceImageURL), SourceSnapshot: new(sourceSnapshotURL), SourceImageEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new(sourceImageKeyName), }, SourceSnapshotEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new(sourceSnapshotKeyName), }, }, }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(instance, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, } // Add the new queries we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: fmt.Sprintf("projects/%s/global/images/test-image", projectID), ExpectedScope: projectID, }, shared.QueryTest{ ExpectedType: gcpshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-snapshot", ExpectedScope: projectID, }, shared.QueryTest{ ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-image", ExpectedScope: projectID, }, shared.QueryTest{ ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-snapshot", ExpectedScope: projectID, }, ) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithDiskEncryptionKey", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) // Test with diskEncryptionKey (with version) diskKeyName := fmt.Sprintf("projects/%s/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-disk", projectID) instance := createComputeInstance("test-instance", computepb.Instance_RUNNING) instance.Disks = []*computepb.AttachedDisk{ { DeviceName: new("test-disk"), Source: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/test-instance", projectID, zone)), DiskEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new(diskKeyName), }, }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(instance, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-disk", ExpectedScope: projectID, }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithDiskEncryptionKeyWithoutVersion", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) // Test with diskEncryptionKey (without version - should link to CryptoKey) diskKeyName := fmt.Sprintf("projects/%s/locations/global/keyRings/test-keyring/cryptoKeys/test-key", projectID) instance := createComputeInstance("test-instance", computepb.Instance_RUNNING) instance.Disks = []*computepb.AttachedDisk{ { DeviceName: new("test-disk"), Source: new(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/test-instance", projectID, zone)), DiskEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new(diskKeyName), }, }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(instance, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.CloudKMSCryptoKey.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key", ExpectedScope: projectID, }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithServiceAccount", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) // Test with service account email serviceAccountEmail := "test-service-account@test-project-id.iam.gserviceaccount.com" instance := createComputeInstance("test-instance", computepb.Instance_RUNNING) instance.ServiceAccounts = []*computepb.ServiceAccount{ { Email: new(serviceAccountEmail), }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(instance, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, } // Add the new query we're testing queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: serviceAccountEmail, ExpectedScope: projectID, }) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithMetadata", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) // Test with instance-template and created-by metadata instanceTemplateName := "my-template" instanceTemplateURI := fmt.Sprintf("projects/%s/global/instanceTemplates/%s", projectID, instanceTemplateName) igmName := "my-mig" igmURI := fmt.Sprintf("projects/%s/regions/us-central1/instanceGroupManagers/%s", projectID, igmName) instance := createComputeInstance("test-instance", computepb.Instance_RUNNING) instance.Metadata = &computepb.Metadata{ Items: []*computepb.Items{ { Key: new("instance-template"), Value: new(instanceTemplateURI), }, { Key: new("created-by"), Value: new(igmURI), }, }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(instance, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, } // Add the metadata-based links queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceTemplateName, ExpectedScope: projectID, }, shared.QueryTest{ ExpectedType: gcpshared.ComputeInstanceGroupManager.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: igmName, ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, ) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithRegionalInstanceTemplate", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) // Test with regional instance template instanceTemplateName := "my-regional-template" instanceTemplateURI := fmt.Sprintf("projects/%s/regions/us-central1/instanceTemplates/%s", projectID, instanceTemplateName) instance := createComputeInstance("test-instance", computepb.Instance_RUNNING) instance.Metadata = &computepb.Metadata{ Items: []*computepb.Items{ { Key: new("instance-template"), Value: new(instanceTemplateURI), }, }, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(instance, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { // Base queries that are always present baseQueries := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: fmt.Sprintf("%s.%s", projectID, zone), }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "192.168.1.3", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "default", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "network", ExpectedScope: projectID, }, { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ExpectedScope: "global", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, } // Add the metadata-based link for regional instance template queryTests := append(baseQueries, shared.QueryTest{ ExpectedType: gcpshared.ComputeRegionInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: instanceTemplateName, ExpectedScope: fmt.Sprintf("%s.us-central1", projectID), }, ) shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("GetWithNetworkTags", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) instance := createComputeInstance("test-instance", computepb.Instance_RUNNING) instance.Tags = &computepb.Tags{ Items: []string{"web-server", "http-server"}, } mockClient.EXPECT().Get(ctx, gomock.Any()).Return(instance, nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-instance", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // Verify SEARCH links to ComputeFirewall and ComputeRoute for each tag tagLinkTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeFirewall.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "web-server", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeRoute.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "web-server", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeFirewall.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "http-server", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeRoute.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "http-server", ExpectedScope: projectID, }, } for _, qt := range tagLinkTests { found := false for _, liq := range sdpItem.GetLinkedItemQueries() { q := liq.GetQuery() if q.GetType() == qt.ExpectedType && q.GetMethod() == qt.ExpectedMethod && q.GetQuery() == qt.ExpectedQuery && q.GetScope() == qt.ExpectedScope { found = true break } } if !found { t.Errorf("Missing LinkedItemQuery{type=%s, method=%s, query=%s, scope=%s}", qt.ExpectedType, qt.ExpectedMethod, qt.ExpectedQuery, qt.ExpectedScope) } } }) t.Run("SupportsWildcardScope", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Check if adapter implements WildcardScopeAdapter if wildcardAdapter, ok := adapter.(discovery.WildcardScopeAdapter); ok { if !wildcardAdapter.SupportsWildcardScope() { t.Fatal("Expected SupportsWildcardScope to return true") } } else { t.Fatal("Expected adapter to implement WildcardScopeAdapter interface") } }) t.Run("List with wildcard scope", func(t *testing.T) { zone1 := "us-central1-a" zone2 := "us-central1-b" wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{ gcpshared.NewZonalLocation(projectID, zone1), gcpshared.NewZonalLocation(projectID, zone2), }) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Create mock aggregated list iterator mockAggregatedIterator := mocks.NewMockInstancesScopedListPairIterator(ctrl) // Mock response for zone1 mockAggregatedIterator.EXPECT().Next().Return(compute.InstancesScopedListPair{ Key: "zones/us-central1-a", Value: &computepb.InstancesScopedList{ Instances: []*computepb.Instance{ createComputeInstance("instance-1-zone-a", computepb.Instance_RUNNING), }, }, }, nil) // Mock response for zone2 mockAggregatedIterator.EXPECT().Next().Return(compute.InstancesScopedListPair{ Key: "zones/us-central1-b", Value: &computepb.InstancesScopedList{ Instances: []*computepb.Instance{ createComputeInstance("instance-1-zone-b", computepb.Instance_RUNNING), }, }, }, nil) // Mock response for a zone not in our config (should be filtered) mockAggregatedIterator.EXPECT().Next().Return(compute.InstancesScopedListPair{ Key: "zones/us-west1-a", Value: &computepb.InstancesScopedList{ Instances: []*computepb.Instance{ createComputeInstance("instance-west", computepb.Instance_RUNNING), }, }, }, nil) // End of iteration mockAggregatedIterator.EXPECT().Next().Return(compute.InstancesScopedListPair{}, iterator.Done) // Mock the AggregatedList method mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).DoAndReturn( func(ctx context.Context, req *computepb.AggregatedListInstancesRequest, opts ...any) gcpshared.InstancesScopedListPairIterator { // Verify request parameters if req.GetProject() != projectID { t.Errorf("Expected project %s, got %s", projectID, req.GetProject()) } if !req.GetReturnPartialSuccess() { t.Error("Expected ReturnPartialSuccess to be true") } return mockAggregatedIterator }, ) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } // Call List with wildcard scope sdpItems, err := listable.List(ctx, "*", true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // Should return only items from configured zones (zone-a and zone-b, not west1-a) if len(sdpItems) != 2 { t.Fatalf("Expected 2 items (filtered), got: %d", len(sdpItems)) } // Verify items have correct scopes scopesSeen := make(map[string]bool) for _, item := range sdpItems { scopesSeen[item.GetScope()] = true } expectedScopes := []string{ fmt.Sprintf("%s.%s", projectID, zone1), fmt.Sprintf("%s.%s", projectID, zone2), } for _, expectedScope := range expectedScopes { if !scopesSeen[expectedScope] { t.Errorf("Expected to see scope %s in results", expectedScope) } } }) t.Run("List with specific scope still works", func(t *testing.T) { wrapper := manual.NewComputeInstance(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeInstanceIterator(ctrl) // Mock normal per-zone List behavior mockComputeIterator.EXPECT().Next().Return(createComputeInstance("test-instance", computepb.Instance_RUNNING), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } // Call List with specific scope (not wildcard) sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 1 { t.Fatalf("Expected 1 item, got: %d", len(sdpItems)) } }) } func createComputeInstance(instanceName string, status computepb.Instance_Status) *computepb.Instance { return &computepb.Instance{ Name: new(instanceName), Labels: map[string]string{"env": "test"}, Disks: []*computepb.AttachedDisk{ { DeviceName: new("test-disk"), Source: new("https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a/disks/test-instance"), }, }, NetworkInterfaces: []*computepb.NetworkInterface{ { NetworkIP: new("192.168.1.3"), Subnetwork: new("projects/test-project-id/regions/us-central1/subnetworks/default"), Network: new("https://www.googleapis.com/compute/v1/projects/test-project-id/global/networks/network"), Ipv6Address: new("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), }, }, Status: new(status.String()), ResourcePolicies: []string{ "projects/test-project-id/regions/us-central1/resourcePolicies/test-policy", }, } } ================================================ FILE: sources/gcp/manual/compute-instant-snapshot.go ================================================ package manual import ( "context" "errors" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeInstantSnapshotLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeInstantSnapshot) type computeInstantSnapshotWrapper struct { client gcpshared.ComputeInstantSnapshotsClient *gcpshared.ZoneBase } // NewComputeInstantSnapshot creates a new computeInstantSnapshotWrapper instance. func NewComputeInstantSnapshot(client gcpshared.ComputeInstantSnapshotsClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeInstantSnapshotWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, gcpshared.ComputeInstantSnapshot, ), } } func (c computeInstantSnapshotWrapper) IAMPermissions() []string { return []string{ "compute.instantSnapshots.get", "compute.instantSnapshots.list", } } func (c computeInstantSnapshotWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeInstantSnapshotWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeDisk, ) } func (c computeInstantSnapshotWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_instant_snapshot.name", }, } } func (c computeInstantSnapshotWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeInstantSnapshotLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute instant snapshots since they use aggregatedList func (c computeInstantSnapshotWrapper) SupportsWildcardScope() bool { return true } func (c computeInstantSnapshotWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetInstantSnapshotRequest{ Project: location.ProjectID, Zone: location.Zone, InstantSnapshot: queryParts[0], } instantSnapshot, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeInstantSnapshotToSDPItem(ctx, instantSnapshot, location) } func (c computeInstantSnapshotWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeInstantSnapshotWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListInstantSnapshotsRequest{ Project: location.ProjectID, Zone: location.Zone, }) var itemsSent int var hadError bool for { instantSnapshot, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeInstantSnapshotToSDPItem(ctx, instantSnapshot, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instant snapshots found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all instant snapshots across all zones func (c computeInstantSnapshotWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListInstantSnapshotsRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process instant snapshots in this scope if pair.Value != nil && pair.Value.GetInstantSnapshots() != nil { for _, instantSnapshot := range pair.Value.GetInstantSnapshots() { item, sdpErr := c.gcpComputeInstantSnapshotToSDPItem(ctx, instantSnapshot, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute instant snapshots found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeInstantSnapshotWrapper) gcpComputeInstantSnapshotToSDPItem(ctx context.Context, instantSnapshot *computepb.InstantSnapshot, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(instantSnapshot, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeInstantSnapshot.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: instantSnapshot.GetLabels(), } // Link source disk if disk := instantSnapshot.GetSourceDisk(); disk != "" { diskName := gcpshared.LastPathComponent(disk) if diskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, disk) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: scope, }, }) } } } switch instantSnapshot.GetStatus() { case computepb.InstantSnapshot_UNDEFINED_STATUS.String(): sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case computepb.InstantSnapshot_CREATING.String(), computepb.InstantSnapshot_DELETING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.InstantSnapshot_FAILED.String(), computepb.InstantSnapshot_UNAVAILABLE.String(): sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() case computepb.InstantSnapshot_READY.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-instant-snapshot_test.go ================================================ package manual_test import ( "context" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeInstantSnapshot(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstantSnapshotsClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeInstantSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeInstantSnapshot("test-snapshot", zone, computepb.InstantSnapshot_READY), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-snapshot", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } // [SPEC] The default scope for disk is a combined zone and project id. if sdpItem.GetScope() != "test-project-id.us-central1-a" { t.Fatalf("Expected scope to be 'test-project-id.us-central1-a', got: %s", sdpItem.GetScope()) } // [SPEC] Instant snapshots have one link: the source Disk. if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Fatalf("Expected 1 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } // [SPEC] Ensure Source Disk is linked linkedItem := sdpItem.GetLinkedItemQueries()[0] diskName := "test-disk" if linkedItem.GetQuery().GetType() != gcpshared.ComputeDisk.String() { t.Fatalf("Expected linked item type to be %s, got: %s", gcpshared.ComputeDisk, linkedItem.GetQuery().GetType()) } if linkedItem.GetQuery().GetQuery() != diskName { t.Fatalf("Expected linked item query to be %s, got: %s", diskName, linkedItem.GetQuery().GetQuery()) } if linkedItem.GetQuery().GetScope() != gcpshared.ZonalScope(projectID, zone) { t.Fatalf("Expected linked item scope to be %s, got: %s", gcpshared.ZonalScope(projectID, zone), linkedItem.GetQuery().GetScope()) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk", ExpectedScope: "test-project-id.us-central1-a", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.InstantSnapshot_Status expected sdp.Health } testCases := []testCase{ { name: "Undefined", input: computepb.InstantSnapshot_UNDEFINED_STATUS, expected: sdp.Health_HEALTH_UNKNOWN, }, { name: "Creating", input: computepb.InstantSnapshot_CREATING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Deleting", input: computepb.InstantSnapshot_DELETING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Failed", input: computepb.InstantSnapshot_FAILED, expected: sdp.Health_HEALTH_ERROR, }, { name: "Ready", input: computepb.InstantSnapshot_READY, expected: sdp.Health_HEALTH_OK, }, { name: "Unavailable", input: computepb.InstantSnapshot_UNAVAILABLE, expected: sdp.Health_HEALTH_ERROR, }, } mockClient = mocks.NewMockComputeInstantSnapshotsClient(ctrl) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeInstantSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeInstantSnapshot("test-snapshot", zone, tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-snapshot", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeInstantSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeInstantSnapshotIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeInstantSnapshot("test-snapshot-1", zone, computepb.InstantSnapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeInstantSnapshot("test-snapshot-2", zone, computepb.InstantSnapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeInstantSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeInstantSnapshotIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeInstantSnapshot("test-snapshot-1", zone, computepb.InstantSnapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeInstantSnapshot("test-snapshot-2", zone, computepb.InstantSnapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeInstantSnapshotsClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone mockAggIter := mocks.NewMockInstantSnapshotsScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.InstantSnapshotsScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeInstantSnapshotIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeInstantSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createComputeInstantSnapshot(snapshotName, zone string, status computepb.InstantSnapshot_Status) *computepb.InstantSnapshot { return &computepb.InstantSnapshot{ Name: new(snapshotName), Labels: map[string]string{"env": "test"}, Status: new(status.String()), Zone: new(zone), SourceDisk: new( "projects/test-project-id/zones/" + zone + "/disks/test-disk", ), Architecture: new(computepb.InstantSnapshot_X86_64.String()), } } ================================================ FILE: sources/gcp/manual/compute-machine-image.go ================================================ package manual import ( "context" "errors" "strings" "cloud.google.com/go/compute/apiv1/computepb" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) var ComputeMachineImageLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeMachineImage) type computeMachineImageWrapper struct { client gcpshared.ComputeMachineImageClient *gcpshared.ProjectBase } // NewComputeMachineImage creates a new computeMachineImageWrapper instance. func NewComputeMachineImage(client gcpshared.ComputeMachineImageClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeMachineImageWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeMachineImage, ), } } func (c computeMachineImageWrapper) IAMPermissions() []string { return []string{ "compute.machineImages.get", "compute.machineImages.list", } } func (c computeMachineImageWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeMachineImageWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeNetwork, gcpshared.ComputeSubnetwork, gcpshared.ComputeNetworkAttachment, gcpshared.ComputeDisk, gcpshared.ComputeImage, gcpshared.ComputeSnapshot, gcpshared.CloudKMSCryptoKeyVersion, gcpshared.ComputeInstance, gcpshared.IAMServiceAccount, gcpshared.ComputeAcceleratorType, stdlib.NetworkIP, ) } func (c computeMachineImageWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_machine_image.name", }, } } func (c computeMachineImageWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeMachineImageLookupByName, } } func (c computeMachineImageWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetMachineImageRequest{ Project: location.ProjectID, MachineImage: queryParts[0], } machineImage, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeMachineImageToSDPItem(ctx, machineImage, location) } func (c computeMachineImageWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeMachineImageWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListMachineImagesRequest{ Project: location.ProjectID, }) var itemsSent int var hadError bool for { machineImage, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeMachineImageToSDPItem(ctx, machineImage, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute machine images found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeMachineImageWrapper) gcpComputeMachineImageToSDPItem(ctx context.Context, machineImage *computepb.MachineImage, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(machineImage, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeMachineImage.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: machineImage.GetLabels(), } if instanceProperties := machineImage.GetInstanceProperties(); instanceProperties != nil { for _, networkInterface := range instanceProperties.GetNetworkInterfaces() { if network := networkInterface.GetNetwork(); network != "" { networkName := gcpshared.LastPathComponent(network) if networkName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, network) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetwork.String(), Method: sdp.QueryMethod_GET, Query: networkName, Scope: scope, }, }) } } } if subnet := networkInterface.GetSubnetwork(); subnet != "" { subnetworkName := gcpshared.LastPathComponent(subnet) if subnetworkName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, subnet) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSubnetwork.String(), Method: sdp.QueryMethod_GET, Query: subnetworkName, Scope: scope, }, }) } } } if networkAttachment := networkInterface.GetNetworkAttachment(); networkAttachment != "" { networkAttachmentName := gcpshared.LastPathComponent(networkAttachment) if networkAttachmentName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, networkAttachment) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNetworkAttachment.String(), Method: sdp.QueryMethod_GET, Query: networkAttachmentName, Scope: scope, }, }) } } } if networkIP := networkInterface.GetNetworkIP(); networkIP != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: networkIP, Scope: "global", }, }) } if ipv6Address := networkInterface.GetIpv6Address(); ipv6Address != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: ipv6Address, Scope: "global", }, }) } for _, accessConfig := range networkInterface.GetAccessConfigs() { if natIP := accessConfig.GetNatIP(); natIP != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: natIP, Scope: "global", }, }) } } for _, ipv6AccessConfig := range networkInterface.GetIpv6AccessConfigs() { if externalIpv6 := ipv6AccessConfig.GetExternalIpv6(); externalIpv6 != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkIP.String(), Method: sdp.QueryMethod_GET, Query: externalIpv6, Scope: "global", }, }) } } } for _, disk := range instanceProperties.GetDisks() { if diskSource := disk.GetSource(); diskSource != "" { if strings.Contains(diskSource, "/") { diskName := gcpshared.LastPathComponent(diskSource) if diskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, diskSource) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: scope, }, }) if sourceDiskEncryptionKey := disk.GetDiskEncryptionKey(); sourceDiskEncryptionKey != nil { c.addKMSKeyLink(sdpItem, sourceDiskEncryptionKey.GetKmsKeyName(), location) } } } } } if initializeParams := disk.GetInitializeParams(); initializeParams != nil { if sourceImage := initializeParams.GetSourceImage(); sourceImage != "" { imageName := gcpshared.LastPathComponent(sourceImage) if imageName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceImage) if err == nil { // Use SEARCH for all image references - it handles both family and specific image formats sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeImage.String(), Method: sdp.QueryMethod_SEARCH, Query: sourceImage, // Pass full URI so Search can detect format Scope: scope, }, }) } } } if sourceSnapshot := initializeParams.GetSourceSnapshot(); sourceSnapshot != "" { snapshotName := gcpshared.LastPathComponent(sourceSnapshot) if snapshotName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceSnapshot) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeSnapshot.String(), Method: sdp.QueryMethod_GET, Query: snapshotName, Scope: scope, }, }) } } } if sourceImageEncryptionKey := initializeParams.GetSourceImageEncryptionKey(); sourceImageEncryptionKey != nil { c.addKMSKeyLink(sdpItem, sourceImageEncryptionKey.GetKmsKeyName(), location) } if sourceSnapshotEncryptionKey := initializeParams.GetSourceSnapshotEncryptionKey(); sourceSnapshotEncryptionKey != nil { c.addKMSKeyLink(sdpItem, sourceSnapshotEncryptionKey.GetKmsKeyName(), location) } } } for _, serviceAccount := range instanceProperties.GetServiceAccounts() { if email := serviceAccount.GetEmail(); email != "" { saEmail := email if strings.Contains(email, "/") { saEmail = gcpshared.LastPathComponent(email) } if saEmail != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccount.String(), Method: sdp.QueryMethod_GET, Query: saEmail, Scope: location.ProjectID, }, }) } } } for _, accelerator := range instanceProperties.GetGuestAccelerators() { if acceleratorType := accelerator.GetAcceleratorType(); acceleratorType != "" { acceleratorTypeName := gcpshared.LastPathComponent(acceleratorType) if acceleratorTypeName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, acceleratorType) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeAcceleratorType.String(), Method: sdp.QueryMethod_GET, Query: acceleratorTypeName, Scope: scope, }, }) } } } } } if machineImageEncryptionKey := machineImage.GetMachineImageEncryptionKey(); machineImageEncryptionKey != nil { c.addKMSKeyLink(sdpItem, machineImageEncryptionKey.GetKmsKeyName(), location) } if sourceInstance := machineImage.GetSourceInstance(); sourceInstance != "" { sourceInstanceName := gcpshared.LastPathComponent(sourceInstance) if sourceInstanceName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceInstance) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstance.String(), Method: sdp.QueryMethod_GET, Query: sourceInstanceName, Scope: scope, }, }) } } } for _, savedDisk := range machineImage.GetSavedDisks() { if sourceDisk := savedDisk.GetSourceDisk(); sourceDisk != "" { diskName := gcpshared.LastPathComponent(sourceDisk) if diskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceDisk) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: scope, }, }) } } } } switch machineImage.GetStatus() { case computepb.MachineImage_READY.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case computepb.MachineImage_CREATING.String(), computepb.MachineImage_DELETING.String(), computepb.MachineImage_UPLOADING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.MachineImage_INVALID.String(): sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() } return sdpItem, nil } func (c computeMachineImageWrapper) addKMSKeyLink(sdpItem *sdp.Item, keyName string, location gcpshared.LocationInfo) { if keyName == "" { return } loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, }) } } ================================================ FILE: sources/gcp/manual/compute-machine-image_test.go ================================================ package manual_test import ( "context" "sync" "testing" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestComputeMachineImage(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeMachineImageClient(ctrl) projectID := "test-project-id" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeMachineImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeMachineImage("test-machine-image", computepb.MachineImage_READY), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-machine-image", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Network link { ExpectedType: gcpshared.ComputeNetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network", ExpectedScope: "test-project-id", }, // Subnetwork link { ExpectedType: gcpshared.ComputeSubnetwork.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-subnetwork", ExpectedScope: "test-project-id.us-central1", }, // Network Attachment link { ExpectedType: gcpshared.ComputeNetworkAttachment.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-network-attachment", ExpectedScope: "test-project-id.us-central1", }, // IPv4 internal IP address { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "10.0.0.1", ExpectedScope: "global", }, // IPv6 internal address { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:db8::1", ExpectedScope: "global", }, // External IPv4 address (NAT IP) { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "203.0.113.1", ExpectedScope: "global", }, // External IPv6 address { ExpectedType: stdlib.NetworkIP.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "2001:db8::2", ExpectedScope: "global", }, // Disk source link { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk", ExpectedScope: "test-project-id.us-central1-a", }, // Disk encryption key { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-disk", ExpectedScope: "test-project-id", }, // Source image link (SEARCH handles full URI) { ExpectedType: gcpshared.ComputeImage.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "https://www.googleapis.com/compute/v1/projects/test-project-id/global/images/test-source-image", ExpectedScope: "test-project-id", }, // Source snapshot link { ExpectedType: gcpshared.ComputeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-source-snapshot", ExpectedScope: "test-project-id", }, // Source image encryption key { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-image", ExpectedScope: "test-project-id", }, // Source snapshot encryption key { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-snapshot", ExpectedScope: "test-project-id", }, // Service account link { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-sa@test-project-id.iam.gserviceaccount.com", ExpectedScope: "test-project-id", }, // Accelerator type link { ExpectedType: gcpshared.ComputeAcceleratorType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "nvidia-tesla-k80", ExpectedScope: "test-project-id.us-central1-a", }, // Machine image encryption key { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-machine-encryption-key", ExpectedScope: "test-project-id", }, // Source instance link { ExpectedType: gcpshared.ComputeInstance.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instance", ExpectedScope: "test-project-id.us-central1-a", }, // Saved disk link (from savedDisks) { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-saved-disk", ExpectedScope: "test-project-id.us-central1-a", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.MachineImage_Status expected sdp.Health } testCases := []testCase{ { name: "Ready", input: computepb.MachineImage_READY, expected: sdp.Health_HEALTH_OK, }, { name: "Creating", input: computepb.MachineImage_CREATING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Deleting", input: computepb.MachineImage_DELETING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Uploading", input: computepb.MachineImage_UPLOADING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Invalid", input: computepb.MachineImage_INVALID, expected: sdp.Health_HEALTH_ERROR, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeMachineImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeMachineImage("test-machine-image", tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-machine-image", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeMachineImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeMachineImageIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeMachineImage("test-machine-image-1", computepb.MachineImage_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeMachineImage("test-machine-image-2", computepb.MachineImage_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeMachineImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeMachineImageIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeMachineImage("test-machine-image-1", computepb.MachineImage_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeMachineImage("test-machine-image-2", computepb.MachineImage_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeMachineImageClient(ctrl) projectID := "cache-test-project" scope := projectID mockIter := mocks.NewMockComputeMachineImageIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewComputeMachineImage(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) items, err := listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createComputeMachineImage(imageName string, status computepb.MachineImage_Status) *computepb.MachineImage { return &computepb.MachineImage{ Name: new(imageName), Labels: map[string]string{"env": "test"}, Status: new(status.String()), InstanceProperties: &computepb.InstanceProperties{ NetworkInterfaces: []*computepb.NetworkInterface{ { Network: new("https://www.googleapis.com/compute/v1/projects/test-project-id/global/networks/test-network"), Subnetwork: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/subnetworks/test-subnetwork"), NetworkAttachment: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/networkAttachments/test-network-attachment"), NetworkIP: new("10.0.0.1"), Ipv6Address: new("2001:db8::1"), AccessConfigs: []*computepb.AccessConfig{ { NatIP: new("203.0.113.1"), }, }, Ipv6AccessConfigs: []*computepb.AccessConfig{ { ExternalIpv6: new("2001:db8::2"), }, }, }, }, Disks: []*computepb.AttachedDisk{ { Source: new("https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a/disks/test-disk"), DiskEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-disk"), }, InitializeParams: &computepb.AttachedDiskInitializeParams{ SourceImage: new("https://www.googleapis.com/compute/v1/projects/test-project-id/global/images/test-source-image"), SourceSnapshot: new("https://www.googleapis.com/compute/v1/projects/test-project-id/global/snapshots/test-source-snapshot"), SourceImageEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-image"), }, SourceSnapshotEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-snapshot"), }, }, }, }, ServiceAccounts: []*computepb.ServiceAccount{ { Email: new("test-sa@test-project-id.iam.gserviceaccount.com"), }, }, GuestAccelerators: []*computepb.AcceleratorConfig{ { AcceleratorType: new("https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a/acceleratorTypes/nvidia-tesla-k80"), AcceleratorCount: new(int32(1)), }, }, }, MachineImageEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-machine-encryption-key"), }, SourceInstance: new("projects/test-project-id/zones/us-central1-a/instances/test-instance"), SavedDisks: []*computepb.SavedDisk{ { SourceDisk: new("https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a/disks/test-saved-disk"), }, }, } } ================================================ FILE: sources/gcp/manual/compute-node-group.go ================================================ package manual import ( "context" "errors" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ( ComputeNodeGroupLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeNodeGroup) ComputeNodeGroupLookupByNodeTemplateName = shared.NewItemTypeLookup("nodeTemplateName", gcpshared.ComputeNodeGroup) ) type computeNodeGroupWrapper struct { client gcpshared.ComputeNodeGroupClient *gcpshared.ZoneBase } // NewComputeNodeGroup creates a new computeNodeGroupWrapper instance. func NewComputeNodeGroup(client gcpshared.ComputeNodeGroupClient, locations []gcpshared.LocationInfo) sources.SearchableListableWrapper { return &computeNodeGroupWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeNodeGroup, ), } } func (c computeNodeGroupWrapper) IAMPermissions() []string { return []string{ "compute.nodeGroups.get", "compute.nodeGroups.list", } } func (c computeNodeGroupWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeNodeGroupWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeNodeTemplate, ) } func (c computeNodeGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_node_group.name", }, { TerraformMethod: sdp.QueryMethod_SEARCH, TerraformQueryMap: "google_compute_node_template.name", }, } } func (c computeNodeGroupWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeNodeGroupLookupByName, } } func (c computeNodeGroupWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { ComputeNodeGroupLookupByNodeTemplateName, }, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute node groups since they use aggregatedList func (c computeNodeGroupWrapper) SupportsWildcardScope() bool { return true } func (c computeNodeGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetNodeGroupRequest{ Project: location.ProjectID, Zone: location.Zone, NodeGroup: queryParts[0], } nodeGroup, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeNodeGroupToSDPItem(ctx, nodeGroup, location) } func (c computeNodeGroupWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeNodeGroupWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListNodeGroupsRequest{ Project: location.ProjectID, Zone: location.Zone, }) var itemsSent int var hadError bool for { nodeGroup, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeNodeGroupToSDPItem(ctx, nodeGroup, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute node groups found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all node groups across all zones func (c computeNodeGroupWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListNodeGroupsRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process node groups in this scope if pair.Value != nil && pair.Value.GetNodeGroups() != nil { for _, nodeGroup := range pair.Value.GetNodeGroups() { item, sdpErr := c.gcpComputeNodeGroupToSDPItem(ctx, nodeGroup, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute node groups found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeNodeGroupWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.SearchStream(ctx, stream, cache, cacheKey, scope, queryParts...) }) } func (c computeNodeGroupWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } nodeTemplate := queryParts[0] req := &computepb.ListNodeGroupsRequest{ Project: location.ProjectID, Zone: location.Zone, Filter: new("nodeTemplate = " + nodeTemplate), } it := c.client.List(ctx, req) for { nodeGroup, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeNodeGroupToSDPItem(ctx, nodeGroup, location) if sdpErr != nil { stream.SendError(sdpErr) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) } } func (c computeNodeGroupWrapper) gcpComputeNodeGroupToSDPItem(ctx context.Context, nodegroup *computepb.NodeGroup, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(nodegroup) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeNodeGroup.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), // No labels for node groups. } templateUrl := nodegroup.GetNodeTemplate() if templateUrl != "" { name := gcpshared.LastPathComponent(templateUrl) if name != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, templateUrl) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNodeTemplate.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: scope, }, }) } } } switch nodegroup.GetStatus() { case computepb.NodeGroup_READY.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() case computepb.NodeGroup_INVALID.String(): sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() case computepb.NodeGroup_CREATING.String(), computepb.NodeGroup_DELETING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-node-group_test.go ================================================ package manual_test import ( "context" "strings" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeNodeGroup(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeNodeGroupClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" testTemplateUrl := "https://www.googleapis.com/compute/v1/projects/test-project/regions/northamerica-northeast1/nodeTemplates/node-template-1" testTemplateUrl2 := "https://www.googleapis.com/compute/v1/projects/test-project/regions/northamerica-northeast1/nodeTemplates/node-template-2" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeNodeGroup("test-node-group", testTemplateUrl, computepb.NodeGroup_READY), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-node-group", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNodeTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "node-template-1", ExpectedScope: "test-project.northamerica-northeast1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.NodeGroup_Status expected sdp.Health } testCases := []testCase{ { name: "Ready status", input: computepb.NodeGroup_READY, expected: sdp.Health_HEALTH_OK, }, { name: "Invalid status", input: computepb.NodeGroup_INVALID, expected: sdp.Health_HEALTH_ERROR, }, { name: "Creating status", input: computepb.NodeGroup_CREATING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Deleting status", input: computepb.NodeGroup_DELETING, expected: sdp.Health_HEALTH_PENDING, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeNodeGroup("test-ng", "test-temp", tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-node-group", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Errorf("Expected health: %v, got: %v", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeNodeGroupIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeNodeGroup("test-node-group-1", testTemplateUrl, computepb.NodeGroup_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeNodeGroup("test-node-group-2", testTemplateUrl2, computepb.NodeGroup_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } query := item.GetLinkedItemQueries()[0].GetQuery().GetQuery() if !strings.Contains(query, "node-template") { t.Fatalf("Expected node-template in query, got: %s", query) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeNodeGroupIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeNodeGroup("test-node-group-1", testTemplateUrl, computepb.NodeGroup_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeNodeGroup("test-node-group-2", testTemplateUrl2, computepb.NodeGroup_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) var items []*sdp.Item var errs []error mockItemHandler := func(item *sdp.Item) { items = append(items, item); wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeNodeGroupClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone mockAggIter := mocks.NewMockNodeGroupsScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.NodeGroupsScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeNodeGroupIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) t.Run("Search", func(t *testing.T) { wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) filterBy := testTemplateUrl // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.ListNodeGroupsRequest, opts ...any) *mocks.MockComputeNodeGroupIterator { fullList := []*computepb.NodeGroup{ createComputeNodeGroup("test-node-group-1", testTemplateUrl, computepb.NodeGroup_READY), createComputeNodeGroup("test-node-group-2", testTemplateUrl2, computepb.NodeGroup_READY), createComputeNodeGroup("test-node-group-3", testTemplateUrl, computepb.NodeGroup_READY), createComputeNodeGroup("test-node-group-4", testTemplateUrl, computepb.NodeGroup_READY), } expectedFilter := "nodeTemplate = " + filterBy if req.GetFilter() != expectedFilter { t.Fatalf("Expected filter to be %s, got: %s", expectedFilter, req.GetFilter()) } mockComputeIterator := mocks.NewMockComputeNodeGroupIterator(ctrl) for _, nodeGroup := range fullList { if nodeGroup.GetNodeTemplate() == filterBy { mockComputeIterator.EXPECT().Next().Return(nodeGroup, nil) } } mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) return mockComputeIterator }) // [SPEC] Search filters by the node template URL. It will list and filter out // any node groups that are not using the given URL. // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], testTemplateUrl, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } // 1 of 4 are filtered out. if len(sdpItems) != 3 { t.Fatalf("Expected 3 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } attributes := item.GetAttributes() nodeTemplate, err := attributes.Get("node_template") if err != nil { t.Fatalf("Failed to get node_template attribute: %v", err) } if nodeTemplate != testTemplateUrl { t.Fatalf("Expected node_template to be %s, got: %s", testTemplateUrl, nodeTemplate) } } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeNodeGroupClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone query := "https://www.googleapis.com/compute/v1/projects/cache-test-project/zones/us-central1-a/nodeTemplates/nonexistent-template" mockIter := mocks.NewMockComputeNodeGroupIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) items, err := searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("first Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", qErr) } items, err = searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("SearchStream", func(t *testing.T) { wrapper := manual.NewComputeNodeGroup(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) filterBy := testTemplateUrl mockClient.EXPECT().List(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, req *computepb.ListNodeGroupsRequest, opts ...any) *mocks.MockComputeNodeGroupIterator { fullList := []*computepb.NodeGroup{ createComputeNodeGroup("test-node-group-1", testTemplateUrl, computepb.NodeGroup_READY), createComputeNodeGroup("test-node-group-2", testTemplateUrl2, computepb.NodeGroup_READY), createComputeNodeGroup("test-node-group-3", testTemplateUrl, computepb.NodeGroup_READY), createComputeNodeGroup("test-node-group-4", testTemplateUrl, computepb.NodeGroup_READY), } expectedFilter := "nodeTemplate = " + filterBy if req.GetFilter() != expectedFilter { t.Fatalf("Expected filter to be %s, got: %s", expectedFilter, req.GetFilter()) } mockComputeIterator := mocks.NewMockComputeNodeGroupIterator(ctrl) for _, nodeGroup := range fullList { if nodeGroup.GetNodeTemplate() == filterBy { mockComputeIterator.EXPECT().Next().Return(nodeGroup, nil) } } mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) return mockComputeIterator }) var items []*sdp.Item var errs []error wg := &sync.WaitGroup{} wg.Add(3) // 3 items expected mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports search streaming searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], testTemplateUrl, true, stream) wg.Wait() if len(errs) > 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 3 { t.Fatalf("Expected 3 items, got: %d", len(items)) } for _, item := range items { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } attributes := item.GetAttributes() nodeTemplate, err := attributes.Get("node_template") if err != nil { t.Fatalf("Failed to get node_template attribute: %v", err) } if nodeTemplate != testTemplateUrl { t.Fatalf("Expected node_template to be %s, got: %s", testTemplateUrl, nodeTemplate) } } }) } func createComputeNodeGroup(name, templateUrl string, status computepb.NodeGroup_Status) *computepb.NodeGroup { return &computepb.NodeGroup{ Name: new(name), NodeTemplate: new(templateUrl), Status: new(status.String()), } } ================================================ FILE: sources/gcp/manual/compute-node-template.go ================================================ package manual import ( "context" "errors" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeNodeTemplateLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeNodeTemplate) type computeNodeTemplateWrapper struct { client gcpshared.ComputeNodeTemplateClient *gcpshared.RegionBase } // NewComputeNodeTemplate creates a new computeNodeTemplateWrapper instance. func NewComputeNodeTemplate(client gcpshared.ComputeNodeTemplateClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeNodeTemplateWrapper{ client: client, RegionBase: gcpshared.NewRegionBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, gcpshared.ComputeNodeTemplate, ), } } func (c computeNodeTemplateWrapper) IAMPermissions() []string { return []string{ "compute.nodeTemplates.get", "compute.nodeTemplates.list", } } func (c computeNodeTemplateWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeNodeTemplateWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeNodeGroup, ) } func (c computeNodeTemplateWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_node_template.name", }, } } func (c computeNodeTemplateWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeNodeTemplateLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute node templates since they use aggregatedList func (c computeNodeTemplateWrapper) SupportsWildcardScope() bool { return true } func (c computeNodeTemplateWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetNodeTemplateRequest{ Project: location.ProjectID, Region: location.Region, NodeTemplate: queryParts[0], } nodeTemplate, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeNodeTemplateToSDPItem(nodeTemplate, location) } func (c computeNodeTemplateWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeNodeTemplateWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-region List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListNodeTemplatesRequest{ Project: location.ProjectID, Region: location.Region, }) var itemsSent int var hadError bool for { nodeTemplate, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeNodeTemplateToSDPItem(nodeTemplate, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute node templates found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all node templates across all regions func (c computeNodeTemplateWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListNodeTemplatesRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "regions/us-central1") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process node templates in this scope if pair.Value != nil && pair.Value.GetNodeTemplates() != nil { for _, nodeTemplate := range pair.Value.GetNodeTemplates() { item, sdpErr := c.gcpComputeNodeTemplateToSDPItem(nodeTemplate, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute node templates found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeNodeTemplateWrapper) gcpComputeNodeTemplateToSDPItem(nodeTemplate *computepb.NodeTemplate, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(nodeTemplate) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeNodeTemplate.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), } // Backlink to any node group using this template. sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeNodeGroup.String(), Method: sdp.QueryMethod_SEARCH, Query: nodeTemplate.GetName(), Scope: "*", }, }) return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-node-template_test.go ================================================ package manual_test import ( "context" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeNodeTemplate(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeNodeTemplateClient(ctrl) projectID := "test-project-id" region := "us-central1" t.Run("Get", func(t *testing.T) { // Attach mock client to our wrapper. wrapper := manual.NewComputeNodeTemplate(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createNodeTemplateApiFixture("test-node-template"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-node-template", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } // [SPEC] The default scope is a combined region and project id. if sdpItem.GetScope() != "test-project-id.us-central1" { t.Fatalf("Expected scope to be 'test-project-id.us-central1', got: %s", sdpItem.GetScope()) } // [SPEC] Node templates are linked to one or more node groups. // TODO - this is not currently implemented in the adapter. if len(sdpItem.GetLinkedItemQueries()) != 1 { t.Fatalf("Expected 1 linked item query, got: %d", len(sdpItem.GetLinkedItemQueries())) } t.Run("Attributes", func(t *testing.T) { // Check for a few attributes from the fixture to make sure they were copied properly. // These will not really fail ever unless the underlying shared sources change; so it's more of a sanity check. attributes := sdpItem.GetAttributes() name, err := attributes.Get("name") if err != nil { t.Fatalf("Error getting name attribute: %v", err) } if name.(string) != "test-node-template" { t.Fatalf("Expected name to be 'test-node-template', got: %s", name) } // [SPEC] Nested attributes are visible under attribute_parent.attribute_child serverBindingType, err := attributes.Get("server_binding.type") if err != nil { t.Fatalf("Error getting serverBindingType attribute: %v", err) } if serverBindingType.(string) != "RESTART_NODE_ON_ANY_SERVER" { t.Fatalf("Expected serverBindingType to be RESTART_NODE_ON_ANY_SERVER, got: %v", serverBindingType) } }) t.Run("StaticTests", func(t *testing.T) { // [SPEC] A node template is linked to one or more node groups. // The query will be a SEARCH query against the node template URL. // The query uses all scopes as the scope of the node group is not the same as the template. queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeNodeGroup.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-node-template", ExpectedScope: "*", // [SPEC] The node groups does not affect the node template. }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeNodeTemplate(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeNodeTemplateIter := mocks.NewMockComputeNodeTemplateIterator(ctrl) // Mock out items listed from the API. mockComputeNodeTemplateIter.EXPECT().Next().Return(createNodeTemplateApiFixture("test-node-template-1"), nil) mockComputeNodeTemplateIter.EXPECT().Next().Return(createNodeTemplateApiFixture("test-node-template-2"), nil) mockComputeNodeTemplateIter.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeNodeTemplateIter) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeNodeTemplate(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeNodeTemplateIter := mocks.NewMockComputeNodeTemplateIterator(ctrl) // add mock implementation here mockComputeNodeTemplateIter.EXPECT().Next().Return(createNodeTemplateApiFixture("test-node-template-1"), nil) mockComputeNodeTemplateIter.EXPECT().Next().Return(createNodeTemplateApiFixture("test-node-template-2"), nil) mockComputeNodeTemplateIter.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeNodeTemplateIter) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeNodeTemplateClient(ctrl) projectID := "cache-test-project" region := "us-central1" scope := projectID + "." + region mockAggIter := mocks.NewMockNodeTemplatesScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.NodeTemplatesScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeNodeTemplateIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeNodeTemplate(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } // Create an node template fixture (as returned from GCP API). func createNodeTemplateApiFixture(nodeTemplateName string) *computepb.NodeTemplate { return &computepb.NodeTemplate{ Name: new(nodeTemplateName), NodeType: new("c2-node-60-240"), ServerBinding: &computepb.ServerBinding{ Type: new("RESTART_NODE_ON_ANY_SERVER"), }, SelfLink: new("test-self-link"), Region: new("us-central1"), } } ================================================ FILE: sources/gcp/manual/compute-region-instance-group-manager.go ================================================ package manual import ( "context" "errors" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeRegionInstanceGroupManagerLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeRegionInstanceGroupManager) type computeRegionInstanceGroupManagerWrapper struct { client gcpshared.RegionInstanceGroupManagerClient *gcpshared.RegionBase } // NewComputeRegionInstanceGroupManager creates a new computeRegionInstanceGroupManagerWrapper. func NewComputeRegionInstanceGroupManager(client gcpshared.RegionInstanceGroupManagerClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeRegionInstanceGroupManagerWrapper{ client: client, RegionBase: gcpshared.NewRegionBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeRegionInstanceGroupManager, ), } } func (c computeRegionInstanceGroupManagerWrapper) IAMPermissions() []string { return []string{ "compute.regionInstanceGroupManagers.get", "compute.regionInstanceGroupManagers.list", } } func (c computeRegionInstanceGroupManagerWrapper) PredefinedRole() string { return "roles/compute.viewer" } // PotentialLinks returns the potential links for the regional compute instance group manager wrapper func (c computeRegionInstanceGroupManagerWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeInstanceTemplate, gcpshared.ComputeRegionInstanceTemplate, gcpshared.ComputeInstanceGroup, gcpshared.ComputeTargetPool, gcpshared.ComputeResourcePolicy, gcpshared.ComputeAutoscaler, gcpshared.ComputeHealthCheck, gcpshared.ComputeZone, gcpshared.ComputeRegion, ) } // TerraformMappings returns the Terraform mappings for the regional compute instance group manager wrapper func (c computeRegionInstanceGroupManagerWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_region_instance_group_manager#argument-reference TerraformQueryMap: "google_compute_region_instance_group_manager.name", }, } } // GetLookups returns the lookups for the regional compute instance group manager wrapper func (c computeRegionInstanceGroupManagerWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeRegionInstanceGroupManagerLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Returns true for regional compute instance group managers since they can list across all regions func (c computeRegionInstanceGroupManagerWrapper) SupportsWildcardScope() bool { return true } // Get retrieves a regional compute instance group manager by its name func (c computeRegionInstanceGroupManagerWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetRegionInstanceGroupManagerRequest{ Project: location.ProjectID, Region: location.Region, InstanceGroupManager: queryParts[0], } igm, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpRegionInstanceGroupManagerToSDPItem(ctx, igm, location) } func (c computeRegionInstanceGroupManagerWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeRegionInstanceGroupManagerWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope by listing across all configured regions if scope == "*" { c.listAllRegionsStream(ctx, stream, cache, cacheKey) return } // Handle specific regional scope with per-region List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListRegionInstanceGroupManagersRequest{ Project: location.ProjectID, Region: location.Region, }) var itemsSent int var hadError bool for { igm, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpRegionInstanceGroupManagerToSDPItem(ctx, igm, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute region instance group managers found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeRegionInstanceGroupManagerWrapper) listAllRegionsStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Use a pool to list across all regions in parallel p := pool.New().WithContext(ctx).WithMaxGoroutines(10) var itemsSent atomic.Int32 var hadError atomic.Bool for _, location := range c.Locations() { p.Go(func(ctx context.Context) error { it := c.client.List(ctx, &computepb.ListRegionInstanceGroupManagersRequest{ Project: location.ProjectID, Region: location.Region, }) for { igm, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, location.ToScope(), c.Type())) hadError.Store(true) return iterErr } item, sdpErr := c.gcpRegionInstanceGroupManagerToSDPItem(ctx, igm, location) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute region instance group managers found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeRegionInstanceGroupManagerWrapper) gcpRegionInstanceGroupManagerToSDPItem(ctx context.Context, instanceGroupManager *computepb.InstanceGroupManager, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { return InstanceGroupManagerToSDPItem(ctx, instanceGroupManager, location, gcpshared.ComputeRegionInstanceGroupManager) } ================================================ FILE: sources/gcp/manual/compute-region-instance-group-manager_test.go ================================================ package manual_test import ( "context" "fmt" "testing" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeRegionInstanceGroupManager(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockRegionInstanceGroupManagerClient(ctrl) projectID := "test-project-id" region := "us-central1" instanceTemplateName := "https://www.googleapis.com/compute/v1/projects/test-project-id/global/instanceTemplates/unit-test-template" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeRegionInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createRegionInstanceGroupManager("test-region-instance-group-manager", true, instanceTemplateName), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-region-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetType() != gcpshared.ComputeRegionInstanceGroupManager.String() { t.Fatalf("Expected type %s, got: %s", gcpshared.ComputeRegionInstanceGroupManager.String(), sdpItem.GetType()) } t.Run("StaticTests", func(t *testing.T) { t.Run("GlobalInstanceTemplate", func(t *testing.T) { igm := createRegionInstanceGroupManager("test-region-instance-group-manager", true, instanceTemplateName) wrapper := manual.NewComputeRegionInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(igm, nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-region-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "unit-test-template", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeInstanceGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-group", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeRegion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeTargetPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pool", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeAutoscaler.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-autoscaler", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) t.Run("RegionalInstanceTemplate", func(t *testing.T) { regionalInstanceTemplateName := "https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/instanceTemplates/regional-template" igm := createRegionInstanceGroupManager("test-region-instance-group-manager", true, regionalInstanceTemplateName) wrapper := manual.NewComputeRegionInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(igm, nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-region-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeRegionInstanceTemplate.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "regional-template", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeInstanceGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-group", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeRegion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "us-central1", ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeTargetPool.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-pool", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeAutoscaler.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-autoscaler", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) }) t.Run("HealthCheck", func(t *testing.T) { healthTests := []struct { name string isStable bool expectedHealth sdp.Health }{ { name: "Stable", isStable: true, expectedHealth: sdp.Health_HEALTH_OK, }, { name: "Unstable", isStable: false, expectedHealth: sdp.Health_HEALTH_UNKNOWN, }, } for _, tc := range healthTests { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeRegionInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createRegionInstanceGroupManager("test-region-instance-group-manager", tc.isStable, instanceTemplateName), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-region-instance-group-manager", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expectedHealth { t.Fatalf("Expected health %v, got: %v", tc.expectedHealth, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeRegionInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockIterator := mocks.NewMockRegionInstanceGroupManagerIterator(ctrl) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) mockIterator.EXPECT().Next().Return(createRegionInstanceGroupManager("region-instance-group-manager-1", true, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(createRegionInstanceGroupManager("region-instance-group-manager-2", false, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) items, qErr := wrapper.(sources.ListableWrapper).List(ctx, wrapper.Scopes()[0]) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } for i, item := range items { expectedName := "region-instance-group-manager-" + fmt.Sprintf("%d", i+1) if item.GetAttributes().GetAttrStruct().GetFields()["name"].GetStringValue() != expectedName { t.Fatalf("Expected name %s, got: %s", expectedName, item.GetAttributes().GetAttrStruct().GetFields()["name"].GetStringValue()) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeRegionInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) mockIterator := mocks.NewMockRegionInstanceGroupManagerIterator(ctrl) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) mockIterator.EXPECT().Next().Return(createRegionInstanceGroupManager("region-instance-group-manager-1", true, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(createRegionInstanceGroupManager("region-instance-group-manager-2", false, instanceTemplateName), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) stream := discovery.NewRecordingQueryResultStream() noOpCache := sdpcache.NewNoOpCache() emptyCacheKey := sdpcache.CacheKey{} wrapper.ListStream(ctx, stream, noOpCache, emptyCacheKey, wrapper.Scopes()[0]) items := stream.GetItems() if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockRegionInstanceGroupManagerClient(ctrl) projectID := "cache-test-project" region := "us-central1" scope := projectID + "." + region // "*" path calls List once per region; specific scope calls List once. With 1 region: 2 List calls total. mockIter1 := mocks.NewMockRegionInstanceGroupManagerIterator(ctrl) mockIter1.EXPECT().Next().Return(nil, iterator.Done) mockIter2 := mocks.NewMockRegionInstanceGroupManagerIterator(ctrl) mockIter2.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockIter1).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockIter2).Times(1) wrapper := manual.NewComputeRegionInstanceGroupManager(mockClient, []gcpshared.LocationInfo{gcpshared.NewRegionalLocation(projectID, region)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createRegionInstanceGroupManager(name string, isStable bool, instanceTemplate string) *computepb.InstanceGroupManager { return &computepb.InstanceGroupManager{ Name: new(name), Status: &computepb.InstanceGroupManagerStatus{ IsStable: new(isStable), Autoscaler: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/autoscalers/test-autoscaler"), }, Region: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1"), InstanceTemplate: new(instanceTemplate), InstanceGroup: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/instanceGroups/test-group"), TargetPools: []string{"https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/targetPools/test-pool"}, ResourcePolicies: &computepb.InstanceGroupManagerResourcePolicies{ WorkloadPolicy: new("https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/resourcePolicies/test-policy"), }, } } ================================================ FILE: sources/gcp/manual/compute-reservation.go ================================================ package manual import ( "context" "errors" "sync/atomic" "cloud.google.com/go/compute/apiv1/computepb" "github.com/sourcegraph/conc/pool" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeReservationLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeReservation) type computeReservationWrapper struct { client gcpshared.ComputeReservationClient *gcpshared.ZoneBase } // NewComputeReservation creates a new computeReservationWrapper. func NewComputeReservation(client gcpshared.ComputeReservationClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeReservationWrapper{ client: client, ZoneBase: gcpshared.NewZoneBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION, gcpshared.ComputeReservation, ), } } func (c computeReservationWrapper) IAMPermissions() []string { return []string{ "compute.reservations.get", "compute.reservations.list", } } func (c computeReservationWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeReservationWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeRegionCommitment, gcpshared.ComputeAcceleratorType, gcpshared.ComputeResourcePolicy, ) } func (c computeReservationWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_reservation.name", }, } } func (c computeReservationWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeReservationLookupByName, } } // SupportsWildcardScope implements the WildcardScopeAdapter interface // Always returns true for compute reservations since they use aggregatedList func (c computeReservationWrapper) SupportsWildcardScope() bool { return true } func (c computeReservationWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetReservationRequest{ Project: location.ProjectID, Zone: location.Zone, Reservation: queryParts[0], } reservation, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeReservationToSDPItem(ctx, reservation, location) } func (c computeReservationWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeReservationWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { // Handle wildcard scope with AggregatedList if scope == "*" { c.listAggregatedStream(ctx, stream, cache, cacheKey) return } // Handle specific scope with per-zone List location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListReservationsRequest{ Project: location.ProjectID, Zone: location.Zone, }) var itemsSent int var hadError bool for { reservation, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeReservationToSDPItem(ctx, reservation, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute reservations found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } // listAggregatedStream uses AggregatedList to stream all reservations across all zones func (c computeReservationWrapper) listAggregatedStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { // Get all unique project IDs projectIDs := gcpshared.GetProjectIDsFromLocations(c.Locations()) // Use a pool with 10x concurrency to parallelize AggregatedList calls p := pool.New().WithMaxGoroutines(10).WithContext(ctx) var itemsSent atomic.Int32 var hadError atomic.Bool for _, projectID := range projectIDs { p.Go(func(ctx context.Context) error { it := c.client.AggregatedList(ctx, &computepb.AggregatedListReservationsRequest{ Project: projectID, ReturnPartialSuccess: new(true), // Handle partial failures gracefully }) for { pair, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, projectID, c.Type())) hadError.Store(true) return iterErr } // Parse scope from pair.Key (e.g., "zones/us-central1-a") scopeLocation, err := gcpshared.ParseAggregatedListScope(projectID, pair.Key) if err != nil { continue // Skip unparseable scopes } // Only process if this scope is in our adapter's configured locations if !gcpshared.HasLocationInSlices(scopeLocation, c.Locations()) { continue } // Process reservations in this scope if pair.Value != nil && pair.Value.GetReservations() != nil { for _, reservation := range pair.Value.GetReservations() { item, sdpErr := c.gcpComputeReservationToSDPItem(ctx, reservation, scopeLocation) if sdpErr != nil { stream.SendError(sdpErr) hadError.Store(true) continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent.Add(1) } } } return nil }) } // Wait for all goroutines to complete _ = p.Wait() if itemsSent.Load() == 0 && !hadError.Load() { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute reservations found in scope *", Scope: "*", SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeReservationWrapper) gcpComputeReservationToSDPItem(ctx context.Context, reservation *computepb.Reservation, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(reservation) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeReservation.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), } // Link commitment if commitmentURL := reservation.GetCommitment(); commitmentURL != "" { commitmentName := gcpshared.LastPathComponent(commitmentURL) if commitmentName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, commitmentURL) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRegionCommitment.String(), Method: sdp.QueryMethod_GET, Query: commitmentName, Scope: scope, }, }) } } } // Link accelerator types if reservation.GetSpecificReservation() != nil && reservation.GetSpecificReservation().GetInstanceProperties() != nil { for _, accelerator := range reservation.GetSpecificReservation().GetInstanceProperties().GetGuestAccelerators() { if accelerator != nil && accelerator.GetAcceleratorType() != "" { acceleratorType := accelerator.GetAcceleratorType() acceleratorName := gcpshared.LastPathComponent(acceleratorType) if acceleratorName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, acceleratorType) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeAcceleratorType.String(), Method: sdp.QueryMethod_GET, Query: acceleratorName, Scope: scope, }, }) } } } } } // Link resource policies for _, policyURL := range reservation.GetResourcePolicies() { if policyURL != "" { policyName := gcpshared.LastPathComponent(policyURL) if policyName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, policyURL) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: policyName, Scope: scope, }, }) } } } } switch reservation.GetStatus() { case computepb.Reservation_CREATING.String(), computepb.Reservation_DELETING.String(), computepb.Reservation_UPDATING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.Reservation_READY.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-reservation_test.go ================================================ package manual_test import ( "context" "sync" "testing" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeReservation(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeReservationClient(ctrl) projectID := "test-project-id" zone := "us-central1-a" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeReservation(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeReservation("test-reservation", computepb.Reservation_READY), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-reservation", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeRegionCommitment.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-commitment", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.ComputeAcceleratorType.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "nvidia-tesla-k80", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-policy", ExpectedScope: "test-project-id.us-central1", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.Reservation_Status expected sdp.Health } testCases := []testCase{ { name: "Ready", input: computepb.Reservation_READY, expected: sdp.Health_HEALTH_OK, }, { name: "Creating", input: computepb.Reservation_CREATING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Deleting", input: computepb.Reservation_DELETING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Updating", input: computepb.Reservation_UPDATING, expected: sdp.Health_HEALTH_PENDING, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeReservation(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeReservation("test-reservation", tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-reservation", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeReservation(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeReservationIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeReservation("test-reservation-1", computepb.Reservation_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeReservation("test-reservation-2", computepb.Reservation_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeReservation(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeReservationIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeReservation("test-reservation-1", computepb.Reservation_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeReservation("test-reservation-2", computepb.Reservation_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeReservationClient(ctrl) projectID := "cache-test-project" zone := "us-central1-a" scope := projectID + "." + zone mockAggIter := mocks.NewMockReservationsScopedListPairIterator(ctrl) mockAggIter.EXPECT().Next().Return(compute.ReservationsScopedListPair{}, iterator.Done) mockListIter := mocks.NewMockComputeReservationIterator(ctrl) mockListIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().AggregatedList(gomock.Any(), gomock.Any()).Return(mockAggIter).Times(1) mockClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(mockListIter).Times(1) wrapper := manual.NewComputeReservation(mockClient, []gcpshared.LocationInfo{gcpshared.NewZonalLocation(projectID, zone)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) // --- Scope "*" --- items, err := listable.List(ctx, "*", false) if err != nil { t.Fatalf("first List(*): %v", err) } if len(items) != 0 { t.Errorf("first List(*): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, "*", discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(*)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(*), got %v", qErr) } items, err = listable.List(ctx, "*", false) if err != nil { t.Fatalf("second List(*): %v", err) } if len(items) != 0 { t.Errorf("second List(*): expected 0 items, got %d", len(items)) } // --- Specific scope --- items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done = cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createComputeReservation(reservationName string, status computepb.Reservation_Status) *computepb.Reservation { return &computepb.Reservation{ Name: new(reservationName), Commitment: new( "https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/commitments/test-commitment", ), SpecificReservation: &computepb.AllocationSpecificSKUReservation{ InstanceProperties: &computepb.AllocationSpecificSKUAllocationReservedInstanceProperties{ MachineType: new( "https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a/machineTypes/n1-standard-1", ), GuestAccelerators: []*computepb.AcceleratorConfig{ { AcceleratorType: new( "https://www.googleapis.com/compute/v1/projects/test-project-id/zones/us-central1-a/acceleratorTypes/nvidia-tesla-k80", ), }, }, }, }, ResourcePolicies: map[string]string{ "policy1": "https://www.googleapis.com/compute/v1/projects/test-project-id/regions/us-central1/resourcePolicies/test-policy", }, Status: new(status.String()), } } ================================================ FILE: sources/gcp/manual/compute-security-policy.go ================================================ package manual import ( "context" "errors" "strconv" "cloud.google.com/go/compute/apiv1/computepb" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeSecurityPolicyLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeSecurityPolicy) type computeSecurityPolicyWrapper struct { client gcpshared.ComputeSecurityPolicyClient *gcpshared.ProjectBase } // NewComputeSecurityPolicy creates a new computeSecurityPolicyWrapper instance. func NewComputeSecurityPolicy(client gcpshared.ComputeSecurityPolicyClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeSecurityPolicyWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.ComputeSecurityPolicy, ), } } func (c computeSecurityPolicyWrapper) IAMPermissions() []string { return []string{ "compute.securityPolicies.get", "compute.securityPolicies.list", } } func (c computeSecurityPolicyWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeSecurityPolicyWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeRule, ) } func (c computeSecurityPolicyWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_security_policy.name", }, } } func (c computeSecurityPolicyWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeSecurityPolicyLookupByName, } } func (c computeSecurityPolicyWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetSecurityPolicyRequest{ Project: location.ProjectID, SecurityPolicy: queryParts[0], } policy, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeSecurityPolicyToSDPItem(policy, location) } func (c computeSecurityPolicyWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeSecurityPolicyWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListSecurityPoliciesRequest{ Project: location.ProjectID, }) var itemsSent int var hadError bool for { securityPolicy, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeSecurityPolicyToSDPItem(securityPolicy, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute security policies found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeSecurityPolicyWrapper) gcpComputeSecurityPolicyToSDPItem(securityPolicy *computepb.SecurityPolicy, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(securityPolicy, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeSecurityPolicy.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: securityPolicy.GetLabels(), } // Link to associated rules for _, rule := range securityPolicy.GetRules() { policyName := securityPolicy.GetName() rulePriority := strconv.Itoa(int(rule.GetPriority())) sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeRule.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(policyName, rulePriority), Scope: location.ProjectID, }, }) } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/compute-security-policy_test.go ================================================ package manual_test import ( "context" "sync" "testing" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeSecurityPolicy(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeSecurityPolicyClient(ctrl) projectID := "test-project-id" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeSecurityPolicy(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeSecurityPolicy("test-security-policy"), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-security-policy", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeRule.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-security-policy|1000", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeSecurityPolicy(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeSecurityPolicyIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeSecurityPolicy("test-security-policy-1"), nil) mockComputeIterator.EXPECT().Next().Return(createComputeSecurityPolicy("test-security-policy-2"), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeSecurityPolicy(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeSecurityPolicyIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeSecurityPolicy("test-security-policy-1"), nil) mockComputeIterator.EXPECT().Next().Return(createComputeSecurityPolicy("test-security-policy-2"), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeSecurityPolicyClient(ctrl) projectID := "cache-test-project" scope := projectID mockIter := mocks.NewMockComputeSecurityPolicyIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewComputeSecurityPolicy(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) items, err := listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createComputeSecurityPolicy(policyName string) *computepb.SecurityPolicy { return &computepb.SecurityPolicy{ Name: new(policyName), Labels: map[string]string{"env": "test"}, Rules: []*computepb.SecurityPolicyRule{ { Priority: new(int32(1000)), }, }, Region: new("us-central1"), } } ================================================ FILE: sources/gcp/manual/compute-snapshot.go ================================================ package manual import ( "context" "errors" "cloud.google.com/go/compute/apiv1/computepb" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var ComputeSnapshotLookupByName = shared.NewItemTypeLookup("name", gcpshared.ComputeSnapshot) type computeSnapshotWrapper struct { client gcpshared.ComputeSnapshotsClient *gcpshared.ProjectBase } // NewComputeSnapshot creates a new computeSnapshotWrapper instance. func NewComputeSnapshot(client gcpshared.ComputeSnapshotsClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &computeSnapshotWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, gcpshared.ComputeSnapshot, ), } } func (c computeSnapshotWrapper) IAMPermissions() []string { return []string{ "compute.snapshots.get", "compute.snapshots.list", } } func (c computeSnapshotWrapper) PredefinedRole() string { return "roles/compute.viewer" } func (c computeSnapshotWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.ComputeInstantSnapshot, gcpshared.ComputeLicense, gcpshared.ComputeDisk, gcpshared.CloudKMSCryptoKeyVersion, gcpshared.ComputeResourcePolicy, ) } func (c computeSnapshotWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_compute_snapshot.name", }, } } func (c computeSnapshotWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ ComputeSnapshotLookupByName, } } func (c computeSnapshotWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } req := &computepb.GetSnapshotRequest{ Project: location.ProjectID, Snapshot: queryParts[0], } snapshot, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpComputeSnapshotToSDPItem(ctx, snapshot, location) } func (c computeSnapshotWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c computeSnapshotWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := c.client.List(ctx, &computepb.ListSnapshotsRequest{ Project: location.ProjectID, }) var itemsSent int var hadError bool for { snapshot, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpComputeSnapshotToSDPItem(ctx, snapshot, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no compute snapshots found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c computeSnapshotWrapper) gcpComputeSnapshotToSDPItem(ctx context.Context, snapshot *computepb.Snapshot, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(snapshot, "labels") if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.ComputeSnapshot.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), Tags: snapshot.GetLabels(), } // Link to licenses for _, license := range snapshot.GetLicenses() { licenseName := gcpshared.LastPathComponent(license) if licenseName != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeLicense.String(), Method: sdp.QueryMethod_GET, Query: licenseName, Scope: location.ProjectID, }, }) } } // Link to source instant snapshot if sourceInstantSnapshot := snapshot.GetSourceInstantSnapshot(); sourceInstantSnapshot != "" { instantSnapshotName := gcpshared.LastPathComponent(sourceInstantSnapshot) if instantSnapshotName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceInstantSnapshot) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeInstantSnapshot.String(), Method: sdp.QueryMethod_GET, Query: instantSnapshotName, Scope: scope, }, }) } if sourceInstantSnapshotEncryptionKey := snapshot.GetSourceInstantSnapshotEncryptionKey(); sourceInstantSnapshotEncryptionKey != nil { c.addKMSKeyLink(sdpItem, sourceInstantSnapshotEncryptionKey.GetKmsKeyName(), location) } } } // Link to source disk if disk := snapshot.GetSourceDisk(); disk != "" { diskName := gcpshared.LastPathComponent(disk) if diskName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, disk) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: diskName, Scope: scope, }, }) } } if sourceDiskEncryptionKey := snapshot.GetSourceDiskEncryptionKey(); sourceDiskEncryptionKey != nil { c.addKMSKeyLink(sdpItem, sourceDiskEncryptionKey.GetKmsKeyName(), location) } } // Link to snapshot schedule policy if sourceSnapshotSchedulePolicy := snapshot.GetSourceSnapshotSchedulePolicy(); sourceSnapshotSchedulePolicy != "" { snapshotSchedulePolicyName := gcpshared.LastPathComponent(sourceSnapshotSchedulePolicy) if snapshotSchedulePolicyName != "" { scope, err := gcpshared.ExtractScopeFromURI(ctx, sourceSnapshotSchedulePolicy) if err == nil { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeResourcePolicy.String(), Method: sdp.QueryMethod_GET, Query: snapshotSchedulePolicyName, Scope: scope, }, }) } } } // Link to snapshot encryption key if snapshotEncryptionKey := snapshot.GetSnapshotEncryptionKey(); snapshotEncryptionKey != nil { c.addKMSKeyLink(sdpItem, snapshotEncryptionKey.GetKmsKeyName(), location) } switch snapshot.GetStatus() { case computepb.Snapshot_UNDEFINED_STATUS.String(): sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() case computepb.Snapshot_CREATING.String(), computepb.Snapshot_DELETING.String(), computepb.Snapshot_UPLOADING.String(): sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() case computepb.Snapshot_FAILED.String(): sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() case computepb.Snapshot_READY.String(): sdpItem.Health = sdp.Health_HEALTH_OK.Enum() default: sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() } return sdpItem, nil } func (c computeSnapshotWrapper) addKMSKeyLink(sdpItem *sdp.Item, keyName string, location gcpshared.LocationInfo) { if keyName == "" { return } loc := gcpshared.ExtractPathParam("locations", keyName) keyRing := gcpshared.ExtractPathParam("keyRings", keyName) cryptoKey := gcpshared.ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := gcpshared.ExtractPathParam("cryptoKeyVersions", keyName) if loc != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(loc, keyRing, cryptoKey, cryptoKeyVersion), Scope: location.ProjectID, }, }) } } ================================================ FILE: sources/gcp/manual/compute-snapshot_test.go ================================================ package manual_test import ( "context" "sync" "testing" "cloud.google.com/go/compute/apiv1/computepb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestComputeSnapshot(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeSnapshotsClient(ctrl) projectID := "test-project-id" t.Run("Get", func(t *testing.T) { wrapper := manual.NewComputeSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeSnapshot("test-snapshot", computepb.Snapshot_READY), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-snapshot", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.ComputeLicense.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-license", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.ComputeInstantSnapshot.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-instant-snapshot", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-snapshot", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.ComputeDisk.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-disk", ExpectedScope: "test-project-id.us-central1-a", }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-source-disk", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.ComputeResourcePolicy.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-source-snapshot-schedule-policy", ExpectedScope: "test-project-id.us-central1", }, { ExpectedType: gcpshared.CloudKMSCryptoKeyVersion.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "global|test-keyring|test-key|test-version-snapshot", ExpectedScope: "test-project-id", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("HealthCheck", func(t *testing.T) { type testCase struct { name string input computepb.Snapshot_Status expected sdp.Health } testCases := []testCase{ { name: "Undefined", input: computepb.Snapshot_UNDEFINED_STATUS, expected: sdp.Health_HEALTH_UNKNOWN, }, { name: "Creating", input: computepb.Snapshot_CREATING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Deleting", input: computepb.Snapshot_DELETING, expected: sdp.Health_HEALTH_PENDING, }, { name: "Failed", input: computepb.Snapshot_FAILED, expected: sdp.Health_HEALTH_ERROR, }, { name: "Ready", input: computepb.Snapshot_READY, expected: sdp.Health_HEALTH_OK, }, { name: "Uploading", input: computepb.Snapshot_UPLOADING, expected: sdp.Health_HEALTH_PENDING, }, } mockClient = mocks.NewMockComputeSnapshotsClient(ctrl) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewComputeSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createComputeSnapshot("test-snapshot", tc.input), nil) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "test-snapshot", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } if sdpItem.GetHealth() != tc.expected { t.Fatalf("Expected health %s, got: %s", tc.expected, sdpItem.GetHealth()) } }) } }) t.Run("List", func(t *testing.T) { wrapper := manual.NewComputeSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeSnapshotIterator(ctrl) mockComputeIterator.EXPECT().Next().Return(createComputeSnapshot("test-snapshot-1", computepb.Snapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeSnapshot("test-snapshot-2", computepb.Snapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } if item.GetTags()["env"] != "test" { t.Fatalf("Expected tag 'env=test', got: %s", item.GetTags()["env"]) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewComputeSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockComputeIterator := mocks.NewMockComputeSnapshotIterator(ctrl) // add mock implementation here mockComputeIterator.EXPECT().Next().Return(createComputeSnapshot("test-snapshot-1", computepb.Snapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(createComputeSnapshot("test-snapshot-2", computepb.Snapshot_READY), nil) mockComputeIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockComputeIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockComputeSnapshotsClient(ctrl) projectID := "cache-test-project" scope := projectID mockIter := mocks.NewMockComputeSnapshotIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewComputeSnapshot(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) items, err := listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createComputeSnapshot(snapshotName string, status computepb.Snapshot_Status) *computepb.Snapshot { return &computepb.Snapshot{ Name: new(snapshotName), Labels: map[string]string{"env": "test"}, Status: new(status.String()), SourceInstantSnapshot: new("projects/test-project-id/zones/us-central1-a/instantSnapshots/test-instant-snapshot"), StorageLocations: []string{"us-central1"}, Licenses: []string{"projects/test-project-id/global/licenses/test-license"}, SourceDiskEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-disk"), }, SourceDisk: new("projects/test-project-id/zones/us-central1-a/disks/test-disk"), SourceInstantSnapshotEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-source-snapshot"), RawKey: new("test-key"), }, SourceSnapshotSchedulePolicy: new("projects/test-project-id/regions/us-central1/resourcePolicies/test-source-snapshot-schedule-policy"), SnapshotEncryptionKey: &computepb.CustomerEncryptionKey{ KmsKeyName: new("projects/test-project-id/locations/global/keyRings/test-keyring/cryptoKeys/test-key/cryptoKeyVersions/test-version-snapshot"), }, } } ================================================ FILE: sources/gcp/manual/iam-service-account-key.go ================================================ package manual import ( "context" "fmt" "cloud.google.com/go/iam/admin/apiv1/adminpb" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var IAMServiceAccountKeyLookupByName = shared.NewItemTypeLookup("name", gcpshared.IAMServiceAccountKey) type iamServiceAccountKeyWrapper struct { client gcpshared.IAMServiceAccountKeyClient *gcpshared.ProjectBase } // NewIAMServiceAccountKey creates a new IAM Service Account Key adapter func NewIAMServiceAccountKey(client gcpshared.IAMServiceAccountKeyClient, locations []gcpshared.LocationInfo) sources.SearchStreamableWrapper { return &iamServiceAccountKeyWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.IAMServiceAccountKey, ), } } func (c iamServiceAccountKeyWrapper) IAMPermissions() []string { return []string{ "iam.serviceAccountKeys.get", "iam.serviceAccountKeys.list", } } func (c iamServiceAccountKeyWrapper) PredefinedRole() string { return "roles/iam.serviceAccountViewer" } // PotentialLinks returns the potential links for the iam service account wrapper func (c iamServiceAccountKeyWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.IAMServiceAccount, ) } // TerraformMappings returns the Terraform mappings for the IAM Service Account Key wrapper func (c iamServiceAccountKeyWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_SEARCH, // https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_key // ID format: projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} // The framework automatically intercepts queries starting with "projects/" and converts // them to GET operations by extracting the last N path parameters (based on GetLookups count). TerraformQueryMap: "google_service_account_key.id", }, } } // GetLookups returns the lookups for the IAM Service Account Key wrapper func (c iamServiceAccountKeyWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ IAMServiceAccountLookupByEmailOrUniqueID, IAMServiceAccountKeyLookupByName, } } // Get retrieves a Service Account Key by its name and related serviceAccount // See: https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys/get // Format: GET https://iam.googleapis.com/v1/{name=projects/*/serviceAccounts/*/keys/*} func (c iamServiceAccountKeyWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } serviceAccountIdentifier := queryParts[0] keyName := queryParts[1] req := &adminpb.GetServiceAccountKeyRequest{ Name: "projects/" + location.ProjectID + "/serviceAccounts/" + serviceAccountIdentifier + "/keys/" + keyName, } key, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } return c.gcpIAMServiceAccountKeyToSDPItem(key, location) } // SearchLookups defines how the source can be searched for specific items. func (c iamServiceAccountKeyWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ { IAMServiceAccountLookupByEmailOrUniqueID, }, } } // Search retrieves Service Account Keys by name (or other supported fields in the future) func (c iamServiceAccountKeyWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.SearchStream(ctx, stream, cache, cacheKey, scope, queryParts...) }) } // SearchStream streams the search results for Service Account Keys. func (c iamServiceAccountKeyWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } serviceAccountIdentifier := queryParts[0] it, searchErr := c.client.Search(ctx, &adminpb.ListServiceAccountKeysRequest{ Name: "projects/" + location.ProjectID + "/serviceAccounts/" + serviceAccountIdentifier, }) if searchErr != nil { stream.SendError(gcpshared.QueryError(searchErr, scope, c.Type())) return } for _, key := range it.GetKeys() { item, sdpErr := c.gcpIAMServiceAccountKeyToSDPItem(key, location) if sdpErr != nil { stream.SendError(sdpErr) continue } if item != nil { cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) } stream.SendItem(item) } } // gcpIAMServiceAccountKeyToSDPItem converts a ServiceAccountKey to an sdp.Item func (c iamServiceAccountKeyWrapper) gcpIAMServiceAccountKeyToSDPItem(key *adminpb.ServiceAccountKey, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(key) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } // The unique attribute must be the same as the query parameter for the Get method. // Which is in the format: serviceAccountName|keyName // We will extract the path parameters from the ServiceAccountKey name to create a unique lookup key. // // `projects/{PROJECT_ID}/serviceAccounts/{ACCOUNT}/keys/{key}`. keyVals := gcpshared.ExtractPathParams(key.GetName(), "serviceAccounts", "keys") serviceAccountName := keyVals[0] keyName := keyVals[1] if serviceAccountName == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "service account name not found in key name", } } err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(serviceAccountName, keyName)) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to set unique attribute: %v", err), } } sdpItem := &sdp.Item{ Type: gcpshared.IAMServiceAccountKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: location.ToScope(), } // The URL for the ServiceAccount related to this ServiceAccountKey // GET https://iam.googleapis.com/v1/{name=projects/*/serviceAccounts/*} // https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccount.String(), Method: sdp.QueryMethod_GET, Query: serviceAccountName, Scope: location.ProjectID, }, }) return sdpItem, nil } ================================================ FILE: sources/gcp/manual/iam-service-account-key_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" "cloud.google.com/go/iam/admin/apiv1/adminpb" "go.uber.org/mock/gomock" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestIAMServiceAccountKey(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockIAMServiceAccountKeyClient(ctrl) projectID := "test-project-id" testServiceAccount := "test-sa@test-project-id.iam.gserviceaccount.com" testKeyName := "1234567890abcdef" testKeyFullName := "projects/test-project-id/serviceAccounts/test-sa@test-project-id.iam.gserviceaccount.com/keys/1234567890abcdef" t.Run("Get", func(t *testing.T) { wrapper := manual.NewIAMServiceAccountKey(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createServiceAccountKey(testKeyFullName), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], shared.CompositeLookupKey(testServiceAccount, testKeyName), true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: testServiceAccount, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Search", func(t *testing.T) { wrapper := manual.NewIAMServiceAccountKey(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Search(ctx, gomock.Any()).Return(&adminpb.ListServiceAccountKeysResponse{ Keys: []*adminpb.ServiceAccountKey{ createServiceAccountKey(testKeyFullName), }, }, nil) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], testServiceAccount, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 1 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } } }) t.Run("SearchCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockIAMServiceAccountKeyClient(ctrl) projectID := "cache-test-project" scope := projectID query := "nonexistent-sa@cache-test-project.iam.gserviceaccount.com" mockClient.EXPECT().Search(ctx, gomock.Any()).Return(&adminpb.ListServiceAccountKeysResponse{Keys: nil}, nil).Times(1) wrapper := manual.NewIAMServiceAccountKey(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) searchable := adapter.(discovery.SearchableAdapter) items, err := searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("first Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_SEARCH, scope, discAdapter.Type(), query, false) done() if !cacheHit { t.Fatal("expected cache hit for Search after first call") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for Search, got %v", qErr) } items, err = searchable.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error: %v", err) } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } }) t.Run("SearchWithTerraformQueryMap", func(t *testing.T) { wrapper := manual.NewIAMServiceAccountKey(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createServiceAccountKey(testKeyFullName), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} terraformResourceID := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", projectID, testServiceAccount, testKeyName) // Check if adapter supports searching searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Fatalf("Adapter does not support Search operation") } sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], terraformResourceID, true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 1 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } if err := sdpItems[0].Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } }) t.Run("SearchStream", func(t *testing.T) { wrapper := manual.NewIAMServiceAccountKey(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockClient.EXPECT().Search(ctx, gomock.Any()).Return(&adminpb.ListServiceAccountKeysResponse{ Keys: []*adminpb.ServiceAccountKey{ createServiceAccountKey(testKeyFullName), }, }, nil) var items []*sdp.Item var errs []error wg := &sync.WaitGroup{} wg.Add(1) mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() } mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports search streaming searchStreamable, ok := adapter.(discovery.SearchStreamableAdapter) if !ok { t.Fatalf("Adapter does not support SearchStream operation") } searchStreamable.SearchStream(ctx, wrapper.Scopes()[0], testServiceAccount, true, stream) wg.Wait() if len(errs) > 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 1 { t.Fatalf("Expected 1 item, got: %d", len(items)) } for _, item := range items { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } } _, ok = adapter.(discovery.ListStreamableAdapter) if ok { t.Fatalf("Adapter should not support ListStream operation") } }) t.Run("List_Unsupported", func(t *testing.T) { wrapper := manual.NewIAMServiceAccountKey(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Check if adapter supports list - it should not _, ok := adapter.(discovery.ListableAdapter) if ok { t.Fatalf("Expected adapter to not support List operation, but it does") } }) } // createServiceAccountKey creates a ServiceAccountKey with the specified name. func createServiceAccountKey(name string) *adminpb.ServiceAccountKey { return &adminpb.ServiceAccountKey{ Name: name, } } ================================================ FILE: sources/gcp/manual/iam-service-account.go ================================================ package manual import ( "context" "errors" "strings" "cloud.google.com/go/iam/admin/apiv1/adminpb" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var IAMServiceAccountLookupByEmailOrUniqueID = shared.NewItemTypeLookup("email or unique_id", gcpshared.IAMServiceAccount) type iamServiceAccountWrapper struct { client gcpshared.IAMServiceAccountClient *gcpshared.ProjectBase } // NewIAMServiceAccount creates a new iamServiceAccountWrapper. func NewIAMServiceAccount(client gcpshared.IAMServiceAccountClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &iamServiceAccountWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.IAMServiceAccount, ), } } func (c iamServiceAccountWrapper) IAMPermissions() []string { return []string{ "iam.serviceAccounts.get", "iam.serviceAccounts.list", } } func (c iamServiceAccountWrapper) PredefinedRole() string { return "roles/iam.serviceAccountViewer" } func (c iamServiceAccountWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.CloudResourceManagerProject, gcpshared.IAMServiceAccountKey, ) } func (c iamServiceAccountWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_service_account.email", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_service_account.unique_id", }, } } func (c iamServiceAccountWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ IAMServiceAccountLookupByEmailOrUniqueID, } } func (c iamServiceAccountWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := c.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } resourceIdentifier := queryParts[0] name := "projects/" + location.ProjectID + "/serviceAccounts/" + resourceIdentifier req := &adminpb.GetServiceAccountRequest{ Name: name, } serviceAccount, getErr := c.client.Get(ctx, req) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, c.Type()) } item, sdpErr := c.gcpIAMServiceAccountToSDPItem(serviceAccount, location) if sdpErr != nil { return nil, sdpErr } if strings.Contains(resourceIdentifier, "@") { item.UniqueAttribute = "email" } return item, nil } func (c iamServiceAccountWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { c.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (c iamServiceAccountWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { location, err := c.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } req := &adminpb.ListServiceAccountsRequest{ Name: "projects/" + location.ProjectID, } results := c.client.List(ctx, req) var itemsSent int var hadError bool for { sa, iterErr := results.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, c.Type())) return } item, sdpErr := c.gcpIAMServiceAccountToSDPItem(sa, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no IAM service accounts found in scope " + scope, Scope: scope, SourceName: c.Name(), ItemType: c.Type(), ResponderName: c.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (c iamServiceAccountWrapper) gcpIAMServiceAccountToSDPItem(serviceAccount *adminpb.ServiceAccount, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(serviceAccount) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } sdpItem := &sdp.Item{ Type: gcpshared.IAMServiceAccount.String(), UniqueAttribute: "unique_id", Attributes: attributes, Scope: location.ToScope(), } if projectID := serviceAccount.GetProjectId(); projectID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.CloudResourceManagerProject.String(), Method: sdp.QueryMethod_GET, Query: projectID, Scope: location.ProjectID, }, }) } if serviceAccountName := serviceAccount.GetName(); serviceAccountName != "" { if strings.Contains(serviceAccountName, "/") { serviceAccountID := gcpshared.ExtractPathParam("serviceAccounts", serviceAccountName) if serviceAccountID != "" { sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccountKey.String(), Method: sdp.QueryMethod_SEARCH, Query: serviceAccountID, Scope: location.ProjectID, }, }) } } } return sdpItem, nil } ================================================ FILE: sources/gcp/manual/iam-service-account_test.go ================================================ package manual_test import ( "context" "sync" "testing" "cloud.google.com/go/iam/admin/apiv1/adminpb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestIAMServiceAccount(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockIAMServiceAccountClient(ctrl) projectID := "test-project-id" testUniqueID := "1234567890" testEmail := "test-sa@test-project-id.iam.gserviceaccount.com" testDisplayName := "Test Service Account" t.Run("Get by unique_id", func(t *testing.T) { wrapper := manual.NewIAMServiceAccount(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createServiceAccount(testUniqueID, testEmail, testDisplayName, projectID, false), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], testUniqueID, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.CloudResourceManagerProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-project-id", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.IAMServiceAccountKey.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-service-account-id", ExpectedScope: "test-project-id", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("Get by email", func(t *testing.T) { wrapper := manual.NewIAMServiceAccount(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().Get(ctx, gomock.Any()).Return(createServiceAccount(testUniqueID, testEmail, testDisplayName, projectID, false), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], testEmail, true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.CloudResourceManagerProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "test-project-id", ExpectedScope: "test-project-id", }, { ExpectedType: gcpshared.IAMServiceAccountKey.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: "test-service-account-id", ExpectedScope: "test-project-id", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewIAMServiceAccount(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockIAMServiceAccountIterator(ctrl) mockIterator.EXPECT().Next().Return(createServiceAccount("111", "sa1@test-project-id.iam.gserviceaccount.com", "SA 1", projectID, false), nil) mockIterator.EXPECT().Next().Return(createServiceAccount("222", "sa2@test-project-id.iam.gserviceaccount.com", "SA 2", projectID, true), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } expectedCount := 2 actualCount := len(sdpItems) if actualCount != expectedCount { t.Fatalf("Expected %d items, got: %d", expectedCount, actualCount) } for _, item := range sdpItems { if err := item.Validate(); err != nil { t.Fatalf("Expected no validation error, got: %v", err) } } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewIAMServiceAccount(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockIterator := mocks.NewMockIAMServiceAccountIterator(ctrl) // add mock implementation here mockIterator.EXPECT().Next().Return(createServiceAccount("111", "sa1@test-project-id.iam.gserviceaccount.com", "SA 1", projectID, false), nil) mockIterator.EXPECT().Next().Return(createServiceAccount("222", "sa2@test-project-id.iam.gserviceaccount.com", "SA 2", projectID, true), nil) mockIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the List method mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockIAMServiceAccountClient(ctrl) projectID := "cache-test-project" scope := projectID mockIter := mocks.NewMockIAMServiceAccountIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().List(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewIAMServiceAccount(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) items, err := listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } // createServiceAccount creates a ServiceAccount with the specified fields. func createServiceAccount(uniqueID, email, displayName, projectID string, disabled bool) *adminpb.ServiceAccount { return &adminpb.ServiceAccount{ UniqueId: uniqueID, Email: email, DisplayName: displayName, Disabled: disabled, ProjectId: projectID, Name: "projects/test-project-id/serviceAccounts/test-service-account-id", } } ================================================ FILE: sources/gcp/manual/logging-sink.go ================================================ package manual import ( "context" "errors" "fmt" "strings" "cloud.google.com/go/logging/apiv2/loggingpb" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) var LoggingSinkLookupByName = shared.NewItemTypeLookup("name", gcpshared.LoggingSink) // NewLoggingSink creates a new logging sink instance. func NewLoggingSink(client gcpshared.LoggingConfigClient, locations []gcpshared.LocationInfo) sources.ListStreamableWrapper { return &loggingSinkWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_CONFIGURATION, gcpshared.LoggingSink, ), } } // IAMPermissions returns the required IAM permissions for the logging sink wrapper func (l loggingSinkWrapper) IAMPermissions() []string { return []string{ "logging.sinks.get", "logging.sinks.list", } } func (l loggingSinkWrapper) PredefinedRole() string { return "roles/logging.viewer" } type loggingSinkWrapper struct { client gcpshared.LoggingConfigClient *gcpshared.ProjectBase } // assert interface var _ sources.ListStreamableWrapper = (*loggingSinkWrapper)(nil) func (l loggingSinkWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ LoggingSinkLookupByName, } } func (l loggingSinkWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.StorageBucket, gcpshared.BigQueryDataset, gcpshared.PubSubTopic, gcpshared.LoggingBucket, gcpshared.IAMServiceAccount, ) } func (l loggingSinkWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := l.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } sink, getErr := l.client.GetSink(ctx, &loggingpb.GetSinkRequest{ SinkName: fmt.Sprintf("projects/%s/sinks/%s", location.ProjectID, queryParts[0]), }) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, l.Type()) } return l.gcpLoggingSinkToItem(sink, location) } func (l loggingSinkWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { return gcpshared.CollectFromStream(ctx, func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey) { l.ListStream(ctx, stream, cache, cacheKey, scope) }) } func (l loggingSinkWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { location, err := l.LocationFromScope(scope) if err != nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), }) return } it := l.client.ListSinks(ctx, &loggingpb.ListSinksRequest{ Parent: fmt.Sprintf("projects/%s", location.ProjectID), }) var itemsSent int var hadError bool for { sink, iterErr := it.Next() if errors.Is(iterErr, iterator.Done) { break } if iterErr != nil { stream.SendError(gcpshared.QueryError(iterErr, scope, l.Type())) return } item, sdpErr := l.gcpLoggingSinkToItem(sink, location) if sdpErr != nil { stream.SendError(sdpErr) hadError = true continue } cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) stream.SendItem(item) itemsSent++ } if itemsSent == 0 && !hadError { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no logging sinks found in scope " + scope, Scope: scope, SourceName: l.Name(), ItemType: l.Type(), ResponderName: l.Name(), } cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, cacheKey) } } func (l loggingSinkWrapper) gcpLoggingSinkToItem(sink *loggingpb.LogSink, location gcpshared.LocationInfo) (*sdp.Item, *sdp.QueryError) { attributes, err := shared.ToAttributesWithExclude(sink) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), } } item := &sdp.Item{ Type: gcpshared.LoggingSink.String(), UniqueAttribute: "name", Attributes: attributes, Scope: location.ToScope(), } if sink.GetDestination() != "" { switch { case strings.HasPrefix(sink.GetDestination(), "storage.googleapis.com"): // "storage.googleapis.com/[GCS_BUCKET]" parts := strings.Split(sink.GetDestination(), "/") if len(parts) == 2 { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.StorageBucket.String(), Method: sdp.QueryMethod_GET, Query: parts[1], // Bucket name Scope: location.ProjectID, }, }) } case strings.HasPrefix(sink.GetDestination(), "bigquery.googleapis.com"): // "bigquery.googleapis.com/projects/[PROJECT_ID]/datasets/[DATASET]" values := gcpshared.ExtractPathParams(sink.GetDestination(), "projects", "datasets") if len(values) == 2 && values[0] != "" && values[1] != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Query: values[1], // Dataset ID Scope: values[0], // Project ID }, }) } case strings.HasPrefix(sink.GetDestination(), "pubsub.googleapis.com"): // "pubsub.googleapis.com/projects/[PROJECT_ID]/topics/[TOPIC_ID]" values := gcpshared.ExtractPathParams(sink.GetDestination(), "projects", "topics") if len(values) == 2 && values[0] != "" && values[1] != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.PubSubTopic.String(), Method: sdp.QueryMethod_GET, Query: values[1], // Topic ID Scope: values[0], // Project ID }, }) } case strings.HasPrefix(sink.GetDestination(), "logging.googleapis.com"): // "logging.googleapis.com/projects/[PROJECT_ID]/locations/[LOCATION_ID]/buckets/[BUCKET_ID]" values := gcpshared.ExtractPathParams(sink.GetDestination(), "projects", "locations", "buckets") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.LoggingBucket.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values[1], values[2]), // location|bucket_ID Scope: values[0], // Project ID }, }) } } } // Link to IAM Service Account from writerIdentity // The writerIdentity field contains the IAM identity (service account email or group) under which // Cloud Logging writes the exported log entries. We only link if it's a service account email. // Format: service-account@project-id.iam.gserviceaccount.com if writerIdentity := sink.GetWriterIdentity(); writerIdentity != "" { if strings.Contains(writerIdentity, ".iam.gserviceaccount.com") { // Extract project ID from service account email // Format: {account-id}@{project-id}.iam.gserviceaccount.com parts := strings.Split(writerIdentity, "@") if len(parts) == 2 { domain := parts[1] // Remove .iam.gserviceaccount.com to get project ID projectID := strings.TrimSuffix(domain, ".iam.gserviceaccount.com") if projectID != "" { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccount.String(), Method: sdp.QueryMethod_GET, Query: writerIdentity, // Service account email Scope: projectID, // Project ID extracted from email }, }) } } } } return item, nil } ================================================ FILE: sources/gcp/manual/logging-sink_test.go ================================================ package manual_test import ( "context" "fmt" "sync" "testing" "cloud.google.com/go/logging/apiv2/loggingpb" "go.uber.org/mock/gomock" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/gcp/shared/mocks" "github.com/overmindtech/cli/sources/shared" ) func TestNewLoggingSink(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockLoggingConfigClient(ctrl) projectID := "my-project-id" t.Run("Get", func(t *testing.T) { type testCase struct { name string destination string expectedQueryTest shared.QueryTest } testCases := []testCase{ { name: "Cloud Storage Bucket", destination: "storage.googleapis.com/my_bucket", expectedQueryTest: shared.QueryTest{ ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my_bucket", ExpectedScope: projectID, }, }, { name: "BigQuery Dataset", destination: "bigquery.googleapis.com/projects/my-project-id/datasets/my_dataset", expectedQueryTest: shared.QueryTest{ ExpectedType: gcpshared.BigQueryDataset.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my_dataset", ExpectedScope: projectID, }, }, { name: "Pub/Sub Topic", destination: "pubsub.googleapis.com/projects/my-project-id/topics/my_topic", expectedQueryTest: shared.QueryTest{ ExpectedType: gcpshared.PubSubTopic.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my_topic", ExpectedScope: projectID, }, }, { name: "Logging Bucket", destination: "logging.googleapis.com/projects/my-project-id/locations/global/buckets/my_bucket", expectedQueryTest: shared.QueryTest{ ExpectedType: gcpshared.LoggingBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey("global", "my_bucket"), ExpectedScope: projectID, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { wrapper := manual.NewLoggingSink(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockClient.EXPECT().GetSink(ctx, gomock.Any()).Return(createLoggingSink("my-sink", tc.destination, ""), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "my-sink", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } uniqAttr := sdpItem.GetUniqueAttribute() uniqAttrVal, err := sdpItem.GetAttributes().Get(uniqAttr) if err != nil { t.Fatalf("Expected to find unique attribute %s, got error: %v", uniqAttr, err) } if uniqAttrVal.(string) != "my-sink" { t.Errorf("Expected unique attribute value to be 'my-sink', got: %s", uniqAttrVal) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{tc.expectedQueryTest} shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) } // Test writerIdentity link to IAM Service Account t.Run("WriterIdentity Service Account", func(t *testing.T) { wrapper := manual.NewLoggingSink(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) writerIdentity := fmt.Sprintf("logging-sink-writer@%s.iam.gserviceaccount.com", projectID) mockClient.EXPECT().GetSink(ctx, gomock.Any()).Return(createLoggingSink("my-sink", "storage.googleapis.com/my_bucket", writerIdentity), nil) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], "my-sink", true) if qErr != nil { t.Fatalf("Expected no error, got: %v", qErr) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ // Storage bucket link { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "my_bucket", ExpectedScope: projectID, }, // IAM Service Account link from writerIdentity { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: writerIdentity, ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) }) }) t.Run("List", func(t *testing.T) { wrapper := manual.NewLoggingSink(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) mockLoggingSinkIterator := mocks.NewMockLoggingSinkIterator(ctrl) mockLoggingSinkIterator.EXPECT().Next().Return(createLoggingSink("sink1", "storage.googleapis.com/my_bucket", ""), nil) mockLoggingSinkIterator.EXPECT().Next().Return(createLoggingSink("sink2", "bigquery.googleapis.com/projects/my-project-id/datasets/my_dataset", ""), nil) mockLoggingSinkIterator.EXPECT().Next().Return(nil, iterator.Done) // End of iteration mockClient.EXPECT().ListSinks(ctx, gomock.Any()).Return(mockLoggingSinkIterator) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Check if adapter supports listing listable, ok := adapter.(discovery.ListableAdapter) if !ok { t.Fatalf("Adapter does not support List operation") } sdpItems, err := listable.List(ctx, wrapper.Scopes()[0], true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(sdpItems) != 2 { t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) } for _, item := range sdpItems { if item.Validate() != nil { t.Fatalf("Expected no validation error, got: %v", item.Validate()) } } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListStream", func(t *testing.T) { wrapper := manual.NewLoggingSink(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) mockLoggingSinkIterator := mocks.NewMockLoggingSinkIterator(ctrl) // add mock implementation here mockLoggingSinkIterator.EXPECT().Next().Return(createLoggingSink("sink1", "storage.googleapis.com/my_bucket", ""), nil) mockLoggingSinkIterator.EXPECT().Next().Return(createLoggingSink("sink2", "bigquery.googleapis.com/projects/my-project-id/datasets/my_dataset", ""), nil) mockLoggingSinkIterator.EXPECT().Next().Return(nil, iterator.Done) // Mock the ListSinks method mockClient.EXPECT().ListSinks(ctx, gomock.Any()).Return(mockLoggingSinkIterator) wg := &sync.WaitGroup{} wg.Add(2) // we added two items var items []*sdp.Item mockItemHandler := func(item *sdp.Item) { items = append(items, item) wg.Done() // signal that we processed an item } var errs []error mockErrorHandler := func(err error) { errs = append(errs, err) } stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) // Check if adapter supports list streaming listStreamable, ok := adapter.(discovery.ListStreamableAdapter) if !ok { t.Fatalf("Adapter does not support ListStream operation") } listStreamable.ListStream(ctx, wrapper.Scopes()[0], true, stream) wg.Wait() if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } if len(items) != 2 { t.Fatalf("Expected 2 items, got: %d", len(items)) } _, ok = adapter.(discovery.SearchStreamableAdapter) if ok { t.Fatalf("Adapter should not support SearchStream operation") } }) t.Run("ListCachesNotFoundWithMemoryCache", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mocks.NewMockLoggingConfigClient(ctrl) projectID := "cache-test-project" scope := projectID mockIter := mocks.NewMockLoggingSinkIterator(ctrl) mockIter.EXPECT().Next().Return(nil, iterator.Done) mockClient.EXPECT().ListSinks(ctx, gomock.Any()).Return(mockIter).Times(1) wrapper := manual.NewLoggingSink(mockClient, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) cache := sdpcache.NewMemoryCache() adapter := sources.WrapperToAdapter(wrapper, cache) discAdapter := adapter.(discovery.Adapter) listable := adapter.(discovery.ListableAdapter) items, err := listable.List(ctx, scope, false) if err != nil { t.Fatalf("first List(scope): %v", err) } if len(items) != 0 { t.Errorf("first List(scope): expected 0 items, got %d", len(items)) } cacheHit, _, _, qErr, done := cache.Lookup(ctx, discAdapter.Name(), sdp.QueryMethod_LIST, scope, discAdapter.Type(), "", false) done() if !cacheHit { t.Fatal("expected cache hit for List(scope)") } if qErr == nil || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Fatalf("expected cached NOTFOUND for List(scope), got %v", qErr) } items, err = listable.List(ctx, scope, false) if err != nil { t.Fatalf("second List(scope): %v", err) } if len(items) != 0 { t.Errorf("second List(scope): expected 0 items, got %d", len(items)) } }) } func createLoggingSink(name, destination, writerIdentity string) *loggingpb.LogSink { sink := &loggingpb.LogSink{ Name: name, Destination: destination, Filter: "severity>=ERROR", } if writerIdentity != "" { sink.WriterIdentity = writerIdentity } return sink } ================================================ FILE: sources/gcp/manual/storage-bucket-iam-policy.go ================================================ package manual import ( "context" "strings" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // Storage Bucket IAM Policy adapter: one item per bucket representing the bucket's full IAM policy. // Uses the Storage Bucket getIamPolicy V3 API. All Terraform bucket IAM resources (binding, member, policy) map to this item. // See: https://cloud.google.com/storage/docs/json_api/v1/buckets/getIamPolicy var ( StorageBucketIAMPolicyLookupByBucket = shared.NewItemTypeLookup("bucket", gcpshared.StorageBucketIAMPolicy) ) type storageBucketIAMPolicyWrapper struct { client gcpshared.StorageBucketIAMPolicyGetter *gcpshared.ProjectBase } // NewStorageBucketIAMPolicy creates a SearchableWrapper for Storage Bucket IAM policy (one item per bucket). func NewStorageBucketIAMPolicy(client gcpshared.StorageBucketIAMPolicyGetter, locations []gcpshared.LocationInfo) sources.SearchableWrapper { return &storageBucketIAMPolicyWrapper{ client: client, ProjectBase: gcpshared.NewProjectBase( locations, sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, gcpshared.StorageBucketIAMPolicy, ), } } func (w *storageBucketIAMPolicyWrapper) IAMPermissions() []string { return []string{"storage.buckets.getIamPolicy"} } func (w *storageBucketIAMPolicyWrapper) PredefinedRole() string { return "overmind_custom_role" } func (w *storageBucketIAMPolicyWrapper) PotentialLinks() map[shared.ItemType]bool { return shared.NewItemTypesSet( gcpshared.StorageBucket, gcpshared.IAMServiceAccount, gcpshared.IAMRole, gcpshared.ComputeProject, stdlib.NetworkDNS, ) } func (w *storageBucketIAMPolicyWrapper) TerraformMappings() []*sdp.TerraformMapping { return []*sdp.TerraformMapping{ { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_bucket_iam_binding.bucket", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_bucket_iam_member.bucket", }, { TerraformMethod: sdp.QueryMethod_GET, TerraformQueryMap: "google_storage_bucket_iam_policy.bucket", }, } } func (w *storageBucketIAMPolicyWrapper) GetLookups() sources.ItemTypeLookups { return sources.ItemTypeLookups{ StorageBucketIAMPolicyLookupByBucket, } } func (w *storageBucketIAMPolicyWrapper) SearchLookups() []sources.ItemTypeLookups { return []sources.ItemTypeLookups{ {StorageBucketIAMPolicyLookupByBucket}, } } func (w *storageBucketIAMPolicyWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { location, err := w.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } if len(queryParts) < 1 || queryParts[0] == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "GET requires bucket name", } } bucketName := queryParts[0] bindings, getErr := w.client.GetBucketIAMPolicy(ctx, bucketName) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, w.Type()) } return w.policyToItem(location, bucketName, bindings) } func (w *storageBucketIAMPolicyWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { location, err := w.LocationFromScope(scope) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: err.Error(), } } if len(queryParts) < 1 || queryParts[0] == "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "SEARCH requires bucket name", } } bucketName := queryParts[0] bindings, getErr := w.client.GetBucketIAMPolicy(ctx, bucketName) if getErr != nil { return nil, gcpshared.QueryError(getErr, scope, w.Type()) } item, qErr := w.policyToItem(location, bucketName, bindings) if qErr != nil { return nil, qErr } return []*sdp.Item{item}, nil } // policyBinding is the serialized shape of one binding in the policy item attributes. type policyBinding struct { Role string `json:"role"` Members []string `json:"members"` ConditionExpression string `json:"conditionExpression,omitempty"` ConditionTitle string `json:"conditionTitle,omitempty"` ConditionDescription string `json:"conditionDescription,omitempty"` } // policyToItem builds one SDP item for the bucket's IAM policy and adds linked item queries from all bindings. func (w *storageBucketIAMPolicyWrapper) policyToItem(location gcpshared.LocationInfo, bucketName string, bindings []gcpshared.BucketIAMBinding) (*sdp.Item, *sdp.QueryError) { policyBindings := make([]policyBinding, 0, len(bindings)) for _, b := range bindings { policyBindings = append(policyBindings, policyBinding{ Role: b.Role, Members: b.Members, ConditionExpression: b.ConditionExpression, ConditionTitle: b.ConditionTitle, ConditionDescription: b.ConditionDescription, }) } type policyAttrs struct { Bucket string `json:"bucket"` Bindings []policyBinding `json:"bindings"` } attrs, err := shared.ToAttributesWithExclude(policyAttrs{Bucket: bucketName, Bindings: policyBindings}) if err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), w.Type()) } if err = attrs.Set("uniqueAttr", bucketName); err != nil { return nil, gcpshared.QueryError(err, location.ToScope(), w.Type()) } item := &sdp.Item{ Type: gcpshared.StorageBucketIAMPolicy.String(), UniqueAttribute: "uniqueAttr", Attributes: attrs, Scope: location.ToScope(), } // Link to StorageBucket (In: true, Out: true) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.StorageBucket.String(), Method: sdp.QueryMethod_GET, Query: bucketName, Scope: location.ProjectID, }, }) // Collect unique linked SAs, projects, domains, and custom IAM roles across all bindings. linkedSAs := make(map[string]string) // email -> projectID linkedProjects := make(map[string]struct{}) linkedDomains := make(map[string]struct{}) linkedRoles := make(map[string]map[string]struct{}) // projectID -> set of roleIDs for _, b := range bindings { // Custom roles are in the form projects/{project}/roles/{roleId}; predefined roles are roles/... if projectID, roleID := extractCustomRoleProjectAndID(b.Role); projectID != "" && roleID != "" { if linkedRoles[projectID] == nil { linkedRoles[projectID] = make(map[string]struct{}) } linkedRoles[projectID][roleID] = struct{}{} } for _, member := range b.Members { saEmail := extractServiceAccountEmailFromMember(member) if saEmail != "" { projectID := extractProjectFromServiceAccountEmail(saEmail) if projectID != "" && !isGoogleManagedServiceAccountDomain(projectID) { linkedSAs[saEmail] = projectID } } projectID := extractProjectIDFromProjectPrincipalMember(member) if projectID != "" { linkedProjects[projectID] = struct{}{} } domainName := extractDomainFromDomainMember(member) if domainName != "" { linkedDomains[domainName] = struct{}{} } } } for saEmail, projectID := range linkedSAs { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMServiceAccount.String(), Method: sdp.QueryMethod_GET, Query: saEmail, Scope: projectID, }, }) } for projectID, roleIDs := range linkedRoles { for roleID := range roleIDs { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.IAMRole.String(), Method: sdp.QueryMethod_GET, Query: roleID, Scope: projectID, }, }) } } for projectID := range linkedProjects { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: gcpshared.ComputeProject.String(), Method: sdp.QueryMethod_GET, Query: projectID, Scope: projectID, }, }) } for domainName := range linkedDomains { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: stdlib.NetworkDNS.String(), Method: sdp.QueryMethod_SEARCH, Query: domainName, Scope: "global", }, }) } return item, nil } // extractCustomRoleProjectAndID parses a custom IAM role reference "projects/{project}/roles/{roleId}" // and returns (projectID, roleID). For predefined roles (e.g. "roles/storage.objectViewer") returns ("", ""). func extractCustomRoleProjectAndID(role string) (projectID, roleID string) { const prefix = "projects/" const suffix = "/roles/" if !strings.HasPrefix(role, prefix) || !strings.Contains(role, suffix) { return "", "" } rest := strings.TrimPrefix(role, prefix) before, after, ok := strings.Cut(rest, suffix) if !ok { return "", "" } projectID = before roleID = after if projectID == "" || roleID == "" { return "", "" } return projectID, roleID } // extractDomainFromDomainMember returns the domain for "domain:example.com" or // "deleted:domain:example.com", or "" otherwise. The value is a DNS name. // For deleted members, any "?uid=..." suffix is stripped so the result is a valid DNS link. func extractDomainFromDomainMember(member string) string { var domain string if after, ok := strings.CutPrefix(member, "deleted:domain:"); ok { domain = after } else if after, ok := strings.CutPrefix(member, "domain:"); ok { domain = after } else { return "" } // Deleted domain members can include "?uid=123456789"; strip so link uses the actual domain. if idx := strings.Index(domain, "?"); idx != -1 { domain = domain[:idx] } return domain } // extractProjectIDFromProjectPrincipalMember returns the project ID for project principal members // (projectOwner:projectId, projectEditor:projectId, projectViewer:projectId), or "" otherwise. func extractProjectIDFromProjectPrincipalMember(member string) string { for _, prefix := range []string{"projectOwner:", "projectEditor:", "projectViewer:"} { if after, ok := strings.CutPrefix(member, prefix); ok { return after } } return "" } // extractServiceAccountEmailFromMember returns the email for "serviceAccount:email" or "deleted:serviceAccount:email", or "" if not a service account member. // For deleted members, any "?uid=..." suffix is stripped so the result is a valid IAMServiceAccount lookup query (email only). func extractServiceAccountEmailFromMember(member string) string { var email string if after, ok := strings.CutPrefix(member, "deleted:serviceAccount:"); ok { email = after } else if after, ok := strings.CutPrefix(member, "serviceAccount:"); ok { email = after } else { return "" } // Deleted SAs can include "?uid=123456789"; strip query part so link uses the actual SA email. if idx := strings.Index(email, "?"); idx != -1 { email = email[:idx] } return email } // extractProjectFromServiceAccountEmail extracts project ID from "name@project.iam.gserviceaccount.com". // Only project-scoped SAs use that domain; developer.gserviceaccount.com and appspot.gserviceaccount.com // use a shared domain where the first label is not a project ID, so we return "" to avoid invalid links. // For Google-managed SAs (e.g. name@gcp-sa-logging.iam.gserviceaccount.com) use isGoogleManagedServiceAccountDomain to skip. func extractProjectFromServiceAccountEmail(email string) string { _, after, ok := strings.Cut(email, "@") if !ok { return "" } domain := after // Only use first label as project when domain is project.iam.gserviceaccount.com. // developer.gserviceaccount.com and appspot.gserviceaccount.com must not be treated as project IDs. if !strings.HasSuffix(domain, ".iam.gserviceaccount.com") { return "" } before, _, ok := strings.Cut(domain, ".") if !ok { return "" } return before } // isGoogleManagedServiceAccountDomain reports whether the domain's first label is a known // Google-managed pattern (not a customer project ID). Such SAs cannot be resolved to a // project-scoped IAMServiceAccount item with a valid Scope. func isGoogleManagedServiceAccountDomain(firstLabel string) bool { // gcp-sa-* (e.g. gcp-sa-logging, gcp-sa-datalabeling) if strings.HasPrefix(firstLabel, "gcp-sa-") { return true } // cloudservices.gserviceaccount.com, gs-project-accounts, system.gserviceaccount.com switch firstLabel { case "cloudservices", "gs-project-accounts", "system": return true } return false } ================================================ FILE: sources/gcp/manual/storage-bucket-iam-policy_test.go ================================================ package manual_test import ( "context" "errors" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources" "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // fakeBucketIAMPolicyGetter returns a fixed list of bindings for testing. type fakeBucketIAMPolicyGetter struct { bindings []gcpshared.BucketIAMBinding returnErr error bucketSeen string } func (f *fakeBucketIAMPolicyGetter) GetBucketIAMPolicy(ctx context.Context, bucketName string) ([]gcpshared.BucketIAMBinding, error) { f.bucketSeen = bucketName if f.returnErr != nil { return nil, f.returnErr } return f.bindings, nil } // policyWithBindings builds []BucketIAMBinding from role -> members (no condition). // For conditional bindings, construct []BucketIAMBinding directly. func policyWithBindings(bindings map[string][]string) []gcpshared.BucketIAMBinding { out := make([]gcpshared.BucketIAMBinding, 0, len(bindings)) for role, members := range bindings { out = append(out, gcpshared.BucketIAMBinding{Role: role, Members: members, ConditionExpression: ""}) } return out } func TestStorageBucketIAMPolicy_Get(t *testing.T) { ctx := context.Background() projectID := "test-project" bucketName := "my-bucket" role := "roles/storage.objectViewer" saMember := "serviceAccount:siem-sa@test-project.iam.gserviceaccount.com" bindings := policyWithBindings(map[string][]string{ role: {saMember, "user:alice@example.com"}, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) scope := projectID sdpItem, qErr := adapter.Get(ctx, scope, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } if sdpItem.GetType() != gcpshared.StorageBucketIAMPolicy.String() { t.Errorf("type: got %s, want %s", sdpItem.GetType(), gcpshared.StorageBucketIAMPolicy.String()) } if getter.bucketSeen != bucketName { t.Errorf("bucket seen: got %s, want %s", getter.bucketSeen, bucketName) } // Policy item has bucket and bindings attributes if ua, _ := sdpItem.GetAttributes().Get("uniqueAttr"); ua != bucketName { t.Errorf("uniqueAttr: got %v, want %s", ua, bucketName) } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: bucketName, ExpectedScope: projectID, }, { ExpectedType: gcpshared.IAMServiceAccount.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "siem-sa@test-project.iam.gserviceaccount.com", ExpectedScope: projectID, }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) } func TestStorageBucketIAMPolicy_Get_ProjectPrincipalMembers_Linked(t *testing.T) { ctx := context.Background() projectID := "bucket-project" bucketName := "my-bucket" role := "roles/storage.objectViewer" bindings := policyWithBindings(map[string][]string{ role: { "projectOwner:other-project", "projectEditor:another-project", "projectViewer:bucket-project", }, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } t.Run("StaticTests", func(t *testing.T) { queryTests := shared.QueryTests{ { ExpectedType: gcpshared.StorageBucket.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: bucketName, ExpectedScope: projectID, }, { ExpectedType: gcpshared.ComputeProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "other-project", ExpectedScope: "other-project", }, { ExpectedType: gcpshared.ComputeProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "another-project", ExpectedScope: "another-project", }, { ExpectedType: gcpshared.ComputeProject.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: "bucket-project", ExpectedScope: "bucket-project", }, } shared.RunStaticTests(t, adapter, sdpItem, queryTests) }) } func TestStorageBucketIAMPolicy_Get_ProjectPrincipalMembers_Deduplicated(t *testing.T) { ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" bindings := policyWithBindings(map[string][]string{ "roles/storage.admin": { "projectOwner:shared-project", "projectEditor:shared-project", }, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } var projectLinks int for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == gcpshared.ComputeProject.String() { projectLinks++ if q.GetQuery().GetQuery() != "shared-project" || q.GetQuery().GetScope() != "shared-project" { t.Errorf("ComputeProject link: got query=%q scope=%q, want shared-project", q.GetQuery().GetQuery(), q.GetQuery().GetScope()) } } } if projectLinks != 1 { t.Errorf("expected 1 ComputeProject link (deduplicated), got %d", projectLinks) } } func TestStorageBucketIAMPolicy_Get_DeletedServiceAccount_IsLinked(t *testing.T) { ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" bindings := policyWithBindings(map[string][]string{ "roles/storage.objectViewer": { "deleted:serviceAccount:old-sa@my-project.iam.gserviceaccount.com?uid=123456789", }, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } var iamLinks int for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == gcpshared.IAMServiceAccount.String() { iamLinks++ if q.GetQuery().GetScope() != "my-project" { t.Errorf("IAM link scope: got %q, want my-project", q.GetQuery().GetScope()) } } } if iamLinks != 1 { t.Errorf("expected 1 IAMServiceAccount link for deleted:serviceAccount: member, got %d", iamLinks) } } func TestStorageBucketIAMPolicy_Get_DomainMembers_EmitDNSLinks(t *testing.T) { ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" bindings := policyWithBindings(map[string][]string{ "roles/storage.objectViewer": { "domain:example.com", "domain:acme.co.uk", "domain:example.com", }, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } var dnsLinks int dnsQueries := make(map[string]struct{}) for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == "dns" { dnsLinks++ dnsQueries[q.GetQuery().GetQuery()] = struct{}{} if q.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH || q.GetQuery().GetScope() != "global" { t.Errorf("dns link: method=%v scope=%q (want SEARCH, global)", q.GetQuery().GetMethod(), q.GetQuery().GetScope()) } } } if dnsLinks != 2 { t.Errorf("expected 2 dns links (example.com, acme.co.uk; example.com deduped), got %d", dnsLinks) } if _, ok := dnsQueries["example.com"]; !ok { t.Error("missing dns link for example.com") } if _, ok := dnsQueries["acme.co.uk"]; !ok { t.Error("missing dns link for acme.co.uk") } } func TestStorageBucketIAMPolicy_Get_DeletedDomainMember_StripsUIDSuffix(t *testing.T) { // deleted:domain:example.com?uid=123456789 should produce a DNS link with query "example.com", not "example.com?uid=123456789". ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" bindings := policyWithBindings(map[string][]string{ "roles/storage.objectViewer": { "deleted:domain:example.com?uid=123456789", }, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } var dnsLinks int for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == "dns" { dnsLinks++ query := q.GetQuery().GetQuery() if query != "example.com" { t.Errorf("dns link query: got %q, want example.com (?uid= suffix must be stripped)", query) } } } if dnsLinks != 1 { t.Errorf("expected 1 dns link, got %d", dnsLinks) } } func TestStorageBucketIAMPolicy_Get_CustomRole_EmitsIAMRoleLink(t *testing.T) { // Bindings that reference custom IAM roles (projects/{project}/roles/{roleId}) should emit LinkedItemQuery to IAMRole. ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" bindings := []gcpshared.BucketIAMBinding{ { Role: "projects/custom-project/roles/myCustomRole", Members: []string{"user:admin@example.com"}, ConditionExpression: "", ConditionTitle: "", ConditionDescription: "", }, { Role: "roles/storage.objectViewer", Members: []string{"user:viewer@example.com"}, ConditionExpression: "", ConditionTitle: "", ConditionDescription: "", }, } getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } var iamRoleLinks int for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == gcpshared.IAMRole.String() { iamRoleLinks++ if q.GetQuery().GetScope() != "custom-project" || q.GetQuery().GetQuery() != "myCustomRole" { t.Errorf("IAMRole link: got scope=%q query=%q, want scope=custom-project query=myCustomRole", q.GetQuery().GetScope(), q.GetQuery().GetQuery()) } } } if iamRoleLinks != 1 { t.Errorf("expected 1 IAMRole link for custom role, got %d", iamRoleLinks) } } func TestStorageBucketIAMPolicy_Get_GoogleManagedSA_SkipsLink(t *testing.T) { ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" bindings := policyWithBindings(map[string][]string{ "roles/storage.objectViewer": { "serviceAccount:my-sa@my-project.iam.gserviceaccount.com", "serviceAccount:123456@gcp-sa-logging.iam.gserviceaccount.com", }, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } var iamLinks int for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == gcpshared.IAMServiceAccount.String() { iamLinks++ if q.GetQuery().GetScope() != "my-project" || q.GetQuery().GetQuery() != "my-sa@my-project.iam.gserviceaccount.com" { t.Errorf("IAM link: scope=%q query=%q (expected customer SA only)", q.GetQuery().GetScope(), q.GetQuery().GetQuery()) } } } if iamLinks != 1 { t.Errorf("expected 1 IAMServiceAccount link (customer SA), got %d (Google-managed SA should be skipped)", iamLinks) } } func TestStorageBucketIAMPolicy_Get_DeveloperAndAppspotSA_SkipLink(t *testing.T) { ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" bindings := policyWithBindings(map[string][]string{ "roles/storage.objectViewer": { "serviceAccount:123456@developer.gserviceaccount.com", "serviceAccount:my-app@appspot.gserviceaccount.com", }, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed: %v", qErr) return } var iamLinks int for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == gcpshared.IAMServiceAccount.String() { iamLinks++ scope := q.GetQuery().GetScope() if scope == "developer" || scope == "appspot" { t.Errorf("must not create IAM link with scope %q (not a project ID)", scope) } } } if iamLinks != 0 { t.Errorf("expected 0 IAMServiceAccount links for developer/appspot SAs, got %d", iamLinks) } } func TestStorageBucketIAMPolicy_Get_ClientError(t *testing.T) { ctx := context.Background() projectID := "test-project" getter := &fakeBucketIAMPolicyGetter{returnErr: errors.New("api error"), bindings: nil} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) _, qErr := adapter.Get(ctx, projectID, "my-bucket", true) if qErr == nil { t.Error("expected error when getter returns error") return } } func TestStorageBucketIAMPolicy_Search(t *testing.T) { ctx := context.Background() projectID := "test-project" bucketName := "my-bucket" bindings := policyWithBindings(map[string][]string{ "roles/storage.objectViewer": {"serviceAccount:sa1@test-project.iam.gserviceaccount.com"}, "roles/storage.admin": {"user:admin@example.com"}, }) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) searchable, ok := adapter.(discovery.SearchableAdapter) if !ok { t.Error("adapter does not implement SearchableAdapter") return } items, qErr := searchable.Search(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Search failed: %v", qErr) return } if len(items) != 1 { t.Errorf("Search: got %d items, want 1 (one policy per bucket)", len(items)) } if getter.bucketSeen != bucketName { t.Errorf("bucket seen: got %s, want %s", getter.bucketSeen, bucketName) } if len(items) > 0 { if err := items[0].Validate(); err != nil { t.Errorf("item validation: %v", err) } if items[0].GetType() != gcpshared.StorageBucketIAMPolicy.String() { t.Errorf("Search item type: got %s, want %s", items[0].GetType(), gcpshared.StorageBucketIAMPolicy.String()) } } } func TestStorageBucketIAMPolicy_TerraformMapping(t *testing.T) { bindings := policyWithBindings(map[string][]string{"roles/storage.objectViewer": {"user:u@example.com"}}) getter := &fakeBucketIAMPolicyGetter{bindings: bindings} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation("p")}) mappings := wrapper.TerraformMappings() wantMaps := map[string]bool{ "google_storage_bucket_iam_binding.bucket": false, "google_storage_bucket_iam_member.bucket": false, "google_storage_bucket_iam_policy.bucket": false, } if len(mappings) != 3 { t.Errorf("TerraformMappings: got %d entries, want 3", len(mappings)) return } for _, m := range mappings { if m.GetTerraformMethod() != sdp.QueryMethod_GET { t.Errorf("TerraformMethod: got %v, want GET", m.GetTerraformMethod()) } qm := m.GetTerraformQueryMap() if _, ok := wantMaps[qm]; !ok { t.Errorf("TerraformQueryMap: unexpected %q", qm) } wantMaps[qm] = true } for qm, seen := range wantMaps { if !seen { t.Errorf("TerraformQueryMap: missing %q", qm) } } } func TestStorageBucketIAMPolicy_Get_InsufficientQueryParts(t *testing.T) { ctx := context.Background() getter := &fakeBucketIAMPolicyGetter{bindings: nil} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation("p")}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) // Get with empty query should fail (no bucket name) _, qErr := adapter.Get(ctx, "p", "", true) if qErr == nil { t.Error("expected error when query is empty (no bucket name)") return } } func TestStorageBucketIAMPolicy_Get_EmptyPolicy_ReturnsItem(t *testing.T) { // Bucket with no bindings still returns a valid policy item (empty bindings array). ctx := context.Background() projectID := "my-project" bucketName := "my-bucket" getter := &fakeBucketIAMPolicyGetter{bindings: []gcpshared.BucketIAMBinding{}} wrapper := manual.NewStorageBucketIAMPolicy(getter, []gcpshared.LocationInfo{gcpshared.NewProjectLocation(projectID)}) adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) sdpItem, qErr := adapter.Get(ctx, projectID, bucketName, true) if qErr != nil { t.Errorf("Get failed for empty policy: %v", qErr) return } if sdpItem.GetType() != gcpshared.StorageBucketIAMPolicy.String() { t.Errorf("type: got %s, want %s", sdpItem.GetType(), gcpshared.StorageBucketIAMPolicy.String()) } // Should still link to the bucket var bucketLinks int for _, q := range sdpItem.GetLinkedItemQueries() { if q.GetQuery().GetType() == gcpshared.StorageBucket.String() { bucketLinks++ } } if bucketLinks != 1 { t.Errorf("expected 1 StorageBucket link, got %d", bucketLinks) } } ================================================ FILE: sources/gcp/proc/proc.go ================================================ package proc import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" resourcemanager "cloud.google.com/go/resourcemanager/apiv3" resourcemanagerpb "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/iter" "github.com/spf13/viper" "golang.org/x/oauth2" "google.golang.org/api/impersonate" "google.golang.org/api/iterator" "google.golang.org/api/option" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/go/tracing" "github.com/overmindtech/cli/sources/gcp/dynamic" _ "github.com/overmindtech/cli/sources/gcp/dynamic/adapters" // Import all adapters to register them "github.com/overmindtech/cli/sources/gcp/manual" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) // Metadata contains the metadata for the GCP source var Metadata = sdp.AdapterMetadataList{} // GCPConfig holds configuration for GCP source type GCPConfig struct { Parent string // Optional: Can be organization, folder, or project. If empty, will discover all accessible projects ProjectID string // Deprecated: Use Parent instead. Optional: If empty, will discover all accessible projects Regions []string Zones []string ImpersonationServiceAccountEmail string // leave empty for direct access using Application Default Credentials } // ProjectPermissionCheckResult contains detailed results from checking project permissions type ProjectPermissionCheckResult struct { SuccessCount int FailureCount int ProjectErrors map[string]error } // FormatError generates a detailed error message from the permission check results func (r *ProjectPermissionCheckResult) FormatError() error { if r.FailureCount == 0 { return nil } totalProjects := r.SuccessCount + r.FailureCount failurePercentage := (float64(r.FailureCount) / float64(totalProjects)) * 100 // Build error message var errMsg strings.Builder fmt.Fprintf(&errMsg, "%d out of %d projects (%.1f%%) failed permission checks\n\n", r.FailureCount, totalProjects, failurePercentage) // List failed projects with their errors errMsg.WriteString("Failed projects:\n") for projectID, err := range r.ProjectErrors { fmt.Fprintf(&errMsg, " - %s: %v\n", projectID, err) } return errors.New(errMsg.String()) } // ParentType represents the type of GCP parent resource type ParentType int const ( ParentTypeUnknown ParentType = iota ParentTypeOrganization ParentTypeFolder ParentTypeProject ) // projectCheckResult holds the result of checking a single project's permissions type projectCheckResult struct { ProjectID string Error error } // ProjectHealthChecker manages permission checks for GCP projects with caching support type ProjectHealthChecker struct { projectIDs []string adapter discovery.Adapter cacheDuration time.Duration cachedResult *ProjectPermissionCheckResult cacheTime time.Time mu sync.RWMutex } // NewProjectHealthChecker creates a new ProjectHealthChecker with the given configuration func NewProjectHealthChecker( projectIDs []string, adapter discovery.Adapter, cacheDuration time.Duration, ) *ProjectHealthChecker { return &ProjectHealthChecker{ projectIDs: projectIDs, adapter: adapter, cacheDuration: cacheDuration, } } // Check runs the permission check, using cached results if available and valid func (c *ProjectHealthChecker) Check(ctx context.Context) (*ProjectPermissionCheckResult, error) { // Fast path: check cache with read lock c.mu.RLock() if c.cachedResult != nil && time.Since(c.cacheTime) < c.cacheDuration { result := c.cachedResult c.mu.RUnlock() return result, result.FormatError() } c.mu.RUnlock() // Slow path: need to run check, acquire write lock c.mu.Lock() // Double-check in case another goroutine just populated the cache if c.cachedResult != nil && time.Since(c.cacheTime) < c.cacheDuration { result := c.cachedResult c.mu.Unlock() return result, result.FormatError() } // Run the actual check while holding the lock result, err := c.runCheck(ctx) c.cachedResult = result c.cacheTime = time.Now() c.mu.Unlock() return result, err } // runCheck performs the actual permission check without caching func (c *ProjectHealthChecker) runCheck(ctx context.Context) (*ProjectPermissionCheckResult, error) { // Map over project IDs and check permissions in parallel mapper := iter.Mapper[string, projectCheckResult]{ MaxGoroutines: 20, } checkResults, _ := mapper.MapErr(c.projectIDs, func(projectID *string) (projectCheckResult, error) { // Get the project from the cloud resource manager // Giving this permission is mandatory for the GCP source health check prj, err := c.adapter.Get(ctx, *projectID, *projectID, false) if err != nil { // Check if this is a permission error and provide a simplified message var permissionError *dynamic.PermissionError if errors.As(err, &permissionError) { err = fmt.Errorf("insufficient permissions to access GCP project '%s'. "+ "Please ensure the service account has the 'resourcemanager.projects.get' permission via the 'roles/browser' predefined GCP role", *projectID) } else { err = fmt.Errorf("error accessing project %s: %w", *projectID, err) } return projectCheckResult{ ProjectID: *projectID, Error: err, }, nil } if prj == nil { return projectCheckResult{ ProjectID: *projectID, Error: fmt.Errorf("project %s not found in cloud resource manager", *projectID), }, nil } prjID, err := prj.GetAttributes().Get("projectId") if err != nil { return projectCheckResult{ ProjectID: *projectID, Error: fmt.Errorf("error getting project ID from project %s: %w", *projectID, err), }, nil } prjIDStr, ok := prjID.(string) if !ok { return projectCheckResult{ ProjectID: *projectID, Error: fmt.Errorf("project ID is not a string for project %s: %v", *projectID, prjID), }, nil } if prjIDStr != *projectID { return projectCheckResult{ ProjectID: *projectID, Error: fmt.Errorf("project ID mismatch for project %s: expected %s, got %s", *projectID, *projectID, prjIDStr), }, nil } // Success return projectCheckResult{ ProjectID: *projectID, Error: nil, }, nil }) // Aggregate results into final structure result := &ProjectPermissionCheckResult{ ProjectErrors: make(map[string]error), } for _, check := range checkResults { if check.Error != nil { result.FailureCount++ result.ProjectErrors[check.ProjectID] = check.Error } else { result.SuccessCount++ } } // Generate formatted error if there were failures if result.FailureCount > 0 { return result, result.FormatError() } return result, nil } // detectParentType determines the type of parent resource based on its format func detectParentType(parent string) (ParentType, error) { if parent == "" { return ParentTypeUnknown, fmt.Errorf("parent is empty") } // Check for organization format if len(parent) >= len("organizations/") && parent[:len("organizations/")] == "organizations/" { return ParentTypeOrganization, nil } // Check for folder format if len(parent) >= len("folders/") && parent[:len("folders/")] == "folders/" { return ParentTypeFolder, nil } // Check for explicit project format if len(parent) >= len("projects/") && parent[:len("projects/")] == "projects/" { return ParentTypeProject, nil } // If none of the above, assume it's a project ID // GCP project IDs must: // - Start with a lowercase letter // - Contain only lowercase letters, digits, and hyphens // - Be between 6 and 30 characters // This is a simplified check - we'll let the API validate the actual format if len(parent) >= 6 && len(parent) <= 30 { return ParentTypeProject, nil } return ParentTypeUnknown, fmt.Errorf("unable to determine parent type from: %s. Expected formats: 'organizations/{org_id}', 'folders/{folder_id}', or project ID", parent) } // normalizeParent converts a parent string to its canonical format // For projects, it converts "projects/{project_id}" to just the project ID // For organizations and folders, it ensures the format is correct func normalizeParent(parent string, parentType ParentType) (string, error) { switch parentType { case ParentTypeOrganization: // Organizations should be in format "organizations/{org_id}" // Validate that there's an ID after the prefix prefix := "organizations/" if !strings.HasPrefix(parent, prefix) || len(parent) <= len(prefix) { return "", fmt.Errorf("invalid organization format: %s. Expected 'organizations/{org_id}'", parent) } return parent, nil case ParentTypeFolder: // Folders should be in format "folders/{folder_id}" // Validate that there's an ID after the prefix prefix := "folders/" if !strings.HasPrefix(parent, prefix) || len(parent) <= len(prefix) { return "", fmt.Errorf("invalid folder format: %s. Expected 'folders/{folder_id}'", parent) } return parent, nil case ParentTypeProject: // Extract project ID from "projects/{project_id}" format if present var projectID string if strings.HasPrefix(parent, "projects/") { projectID = parent[len("projects/"):] } else { projectID = parent } // Validate that the project ID is not empty if projectID == "" { return "", fmt.Errorf("invalid project format: %s. Expected 'projects/{project_id}' or a valid project ID", parent) } return projectID, nil case ParentTypeUnknown: return "", fmt.Errorf("unknown parent type") default: return "", fmt.Errorf("unknown parent type") } } func init() { // Register the GCP source metadata for documentation purposes ctx := context.Background() // Placeholder locations for metadata registration projectLocations := []gcpshared.LocationInfo{gcpshared.NewProjectLocation("project")} regionLocations := []gcpshared.LocationInfo{gcpshared.NewRegionalLocation("project", "region")} zoneLocations := []gcpshared.LocationInfo{gcpshared.NewZonalLocation("project", "zone")} discoveryAdapters, err := adapters( ctx, projectLocations, regionLocations, zoneLocations, "", nil, false, sdpcache.NewNoOpCache(), // no-op cache for metadata registration ) if err != nil { // docs generation should fail if there are errors creating adapters panic(fmt.Errorf("error creating adapters: %w", err)) } for _, adapter := range discoveryAdapters { Metadata.Register(adapter.Metadata()) } log.Debug("Registered GCP source metadata", " with ", len(Metadata.AllAdapterMetadata()), " adapters") } // InitializeAdapters adds GCP adapters to an existing engine. This is a single-attempt // function; retry logic is handled by the caller via Engine.InitialiseAdapters. // // cfg must not be nil — call ConfigFromViper() first for config validation. func InitializeAdapters(ctx context.Context, engine *discovery.Engine, cfg *GCPConfig) error { // ReadinessCheck verifies adapters are healthy by using a CloudResourceManagerProject adapter // Timeout is handled by SendHeartbeat, HTTP handlers rely on request context engine.SetReadinessCheck(func(ctx context.Context) error { // Find a CloudResourceManagerProject adapter to verify adapter health adapters := engine.AdaptersByType(gcpshared.CloudResourceManagerProject.String()) if len(adapters) == 0 { return fmt.Errorf("readiness check failed: no %s adapters available", gcpshared.CloudResourceManagerProject.String()) } // Use first adapter and try to get from first scope adapter := adapters[0] scopes := adapter.Scopes() if len(scopes) == 0 { return fmt.Errorf("readiness check failed: no scopes available for %s adapter", gcpshared.CloudResourceManagerProject.String()) } // Use the first scope's project ID to verify adapter health scope := scopes[0] _, err := adapter.Get(ctx, scope, scope, true) if err != nil { return fmt.Errorf("readiness check (getting project) failed: %w", err) } return nil }) // Create a shared cache for all adapters in this source sharedCache := sdpcache.NewCache(ctx) // Determine which projects to use based on the parent configuration var projectIDs []string if cfg.Parent == "" { // No parent specified - discover all accessible projects log.WithFields(log.Fields{ "ovm.source.type": "gcp", }).Info("No parent specified, discovering all accessible projects") discoveredProjects, err := discoverProjects(ctx, cfg.ImpersonationServiceAccountEmail) if err != nil { return fmt.Errorf("error discovering projects: %w", err) } projectIDs = discoveredProjects } else { // Parent is specified - determine its type and discover accordingly parentType, err := detectParentType(cfg.Parent) if err != nil { return fmt.Errorf("error detecting parent type: %w", err) } normalizedParent, err := normalizeParent(cfg.Parent, parentType) if err != nil { return fmt.Errorf("error normalizing parent: %w", err) } switch parentType { case ParentTypeProject: // Single project - no discovery needed log.WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.parent": cfg.Parent, "ovm.source.project_id": normalizedParent, }).Info("Using specified project") projectIDs = []string{normalizedParent} case ParentTypeOrganization, ParentTypeFolder: // Organization or folder - discover all projects within it log.WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.parent": cfg.Parent, "parent_type": parentType, }).Info("Discovering projects under parent") discoveredProjects, err := discoverProjectsUnderSpecificParent(ctx, cfg.Parent, cfg.ImpersonationServiceAccountEmail) if err != nil { return fmt.Errorf("error discovering projects under parent %s: %w", cfg.Parent, err) } if len(discoveredProjects) == 0 { return fmt.Errorf("no accessible projects found under parent %s. Please ensure the service account has the 'resourcemanager.projects.list' permission via the 'roles/browser' predefined GCP role", cfg.Parent) } projectIDs = discoveredProjects case ParentTypeUnknown: return fmt.Errorf("unknown parent type for parent: %s", cfg.Parent) default: return fmt.Errorf("unknown parent type for parent: %s", cfg.Parent) } } logFields := log.Fields{ "ovm.source.type": "gcp", "ovm.source.project_count": len(projectIDs), "ovm.source.regions": cfg.Regions, "ovm.source.zones": cfg.Zones, "ovm.source.impersonation-service-account-email": cfg.ImpersonationServiceAccountEmail, } if cfg.Parent == "" { logFields["ovm.source.parent"] = "" } else { logFields["ovm.source.parent"] = cfg.Parent } if cfg.ProjectID != "" { logFields["ovm.source.project_id"] = cfg.ProjectID } log.WithFields(logFields).Info("Got config") // If still no regions/zones this is no valid config. if len(cfg.Regions) == 0 && len(cfg.Zones) == 0 { return fmt.Errorf("GCP source must specify at least one region or zone") } linker := gcpshared.NewLinker() // Build LocationInfo slices for all projects, regions, and zones projectLocations := make([]gcpshared.LocationInfo, 0, len(projectIDs)) for _, projectID := range projectIDs { projectLocations = append(projectLocations, gcpshared.NewProjectLocation(projectID)) } regionLocations := make([]gcpshared.LocationInfo, 0, len(projectIDs)*len(cfg.Regions)) for _, projectID := range projectIDs { for _, region := range cfg.Regions { regionLocations = append(regionLocations, gcpshared.NewRegionalLocation(projectID, region)) } } zoneLocations := make([]gcpshared.LocationInfo, 0, len(projectIDs)*len(cfg.Zones)) for _, projectID := range projectIDs { for _, zone := range cfg.Zones { zoneLocations = append(zoneLocations, gcpshared.NewZonalLocation(projectID, zone)) } } // Create adapters once for all projects using pre-built LocationInfo log.WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.project_count": len(projectIDs), }).Debug("Creating multi-project adapters") allAdapters, err := adapters( ctx, projectLocations, regionLocations, zoneLocations, cfg.ImpersonationServiceAccountEmail, linker, true, sharedCache, ) if err != nil { return fmt.Errorf("error creating discovery adapters: %w", err) } // Find the single multi-project CloudResourceManagerProject adapter var cloudResourceManagerProjectAdapter discovery.Adapter for _, adapter := range allAdapters { if adapter.Type() == gcpshared.CloudResourceManagerProject.String() { cloudResourceManagerProjectAdapter = adapter break } } if cloudResourceManagerProjectAdapter == nil { return fmt.Errorf("cloud resource manager project adapter not found") } // Create health checker with single multi-project adapter and 5 minute cache duration healthChecker := NewProjectHealthChecker( projectIDs, cloudResourceManagerProjectAdapter, 5*time.Minute, ) // Run initial permission check before starting the source to fail fast if // we don't have the required permissions. This validates that we can access // the Cloud Resource Manager API for all configured projects. checkCtx, checkSpan := tracing.Tracer().Start(ctx, "InitializeAdapters.HealthCheck") result, err := healthChecker.Check(checkCtx) checkSpan.End() if err != nil { log.WithContext(ctx).WithError(err).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.success_count": result.SuccessCount, "ovm.source.failure_count": result.FailureCount, "ovm.source.project_count": len(projectIDs), }).Error("Permission check failed for some projects") } else { log.WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.success_count": result.SuccessCount, "ovm.source.project_count": len(projectIDs), }).Info("All projects passed permission checks") } // Add the adapters to the engine err = engine.AddAdapters(allAdapters...) if err != nil { return fmt.Errorf("error adding adapters to engine: %w", err) } log.Debug("Sources initialized") return nil } // ConfigFromViper reads and validates the GCP configuration from viper flags. // This performs local validation only (no API calls) and should be called // before InitializeAdapters to catch permanent config errors early. func ConfigFromViper() (*GCPConfig, error) { parent := viper.GetString("gcp-parent") projectID := viper.GetString("gcp-project-id") // Handle backwards compatibility // If both are specified, parent takes precedence (with a warning) // If only project-id is specified, convert it to parent format for internal use if parent != "" && projectID != "" { log.WithFields(log.Fields{ "ovm.source.type": "gcp", }).Warn("Both --gcp-parent and --gcp-project-id are specified. Using --gcp-parent. Note: --gcp-project-id is deprecated, please use --gcp-parent instead.") } else if projectID != "" { log.WithFields(log.Fields{ "ovm.source.type": "gcp", }).Warn("Using deprecated --gcp-project-id flag. Please use --gcp-parent instead for future compatibility.") // Convert project ID to parent format for internal consistency parent = projectID } l := &GCPConfig{ Parent: parent, ProjectID: projectID, // Keep for backwards compatibility in logging/debugging ImpersonationServiceAccountEmail: viper.GetString("gcp-impersonation-service-account-email"), } // TODO: In the future, we will try to get the zones via Search API // https://github.com/overmindtech/workspace/issues/1340 zones := viper.GetStringSlice("gcp-zones") regions := viper.GetStringSlice("gcp-regions") if len(zones) == 0 && len(regions) == 0 { return nil, fmt.Errorf("need at least one gcp-zones or gcp-regions value") } uniqueRegions := make(map[string]bool) for _, region := range regions { uniqueRegions[region] = true } for _, zone := range zones { if zone == "" { return nil, fmt.Errorf("zone name is empty") } l.Zones = append(l.Zones, zone) region := gcpshared.ZoneToRegion(zone) if region == "" { return nil, fmt.Errorf("zone %s is not valid", zone) } uniqueRegions[region] = true } for region := range uniqueRegions { l.Regions = append(l.Regions, region) } return l, nil } // discoverProjects uses the Cloud Resource Manager API to discover all projects accessible to the service account // Requires the resourcemanager.projects.list permission (included in roles/browser) // It recursively traverses the organization/folder hierarchy since the API only returns direct children func discoverProjects(ctx context.Context, impersonationServiceAccountEmail string) ([]string, error) { // Create client options var clientOpts []option.ClientOption if impersonationServiceAccountEmail != "" { // Use impersonation credentials ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ TargetPrincipal: impersonationServiceAccountEmail, Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, }) if err != nil { return nil, fmt.Errorf("failed to create impersonated token source: %w", err) } clientOpts = append(clientOpts, option.WithTokenSource(ts)) } // Create clients for organizations, folders, and projects orgsClient, err := resourcemanager.NewOrganizationsClient(ctx, clientOpts...) if err != nil { return nil, fmt.Errorf("failed to create organizations client: %w", err) } defer orgsClient.Close() foldersClient, err := resourcemanager.NewFoldersClient(ctx, clientOpts...) if err != nil { return nil, fmt.Errorf("failed to create folders client: %w", err) } defer foldersClient.Close() projectsClient, err := resourcemanager.NewProjectsClient(ctx, clientOpts...) if err != nil { return nil, fmt.Errorf("failed to create projects client: %w", err) } defer projectsClient.Close() // Use a map to track discovered projects and avoid duplicates projectSet := make(map[string]bool) // Search for organizations (no parent needed) var organizationParents []string orgIt := orgsClient.SearchOrganizations(ctx, &resourcemanagerpb.SearchOrganizationsRequest{}) for { org, err := orgIt.Next() if errors.Is(err, iterator.Done) { break } if err != nil { // Not all accounts have organizations (e.g., personal accounts), so this is not fatal log.WithError(err).Debug("Error searching organizations, continuing without org-based discovery") break } organizationParents = append(organizationParents, org.GetName()) log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "organization": org.GetName(), }).Debug("Discovered organization") } // Recursively discover projects under each organization for _, orgParent := range organizationParents { if err := discoverProjectsUnderParent(ctx, orgParent, projectsClient, foldersClient, projectSet); err != nil { log.WithContext(ctx).WithError(err).WithField("parent", orgParent).Debug("Error discovering projects under organization, continuing") } } // Convert map to slice var projects []string for projectID := range projectSet { projects = append(projects, projectID) } if len(projects) == 0 { if len(organizationParents) == 0 { return nil, fmt.Errorf("no accessible projects found. If you're using a personal account without an organization, please specify --gcp-project-id explicitly") } return nil, fmt.Errorf("no accessible projects found. Please ensure the service account has the 'resourcemanager.projects.list' permission via the 'roles/browser' predefined GCP role") } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.project_count": len(projects), }).Info("Successfully discovered projects") return projects, nil } // discoverProjectsUnderParent recursively discovers all projects under a given parent (organization or folder) // It lists direct child projects and folders, then recursively processes each folder func discoverProjectsUnderParent( ctx context.Context, parent string, projectsClient *resourcemanager.ProjectsClient, foldersClient *resourcemanager.FoldersClient, projectSet map[string]bool, ) error { // List direct projects under this parent projectIt := projectsClient.ListProjects(ctx, &resourcemanagerpb.ListProjectsRequest{ Parent: parent, }) for { project, err := projectIt.Next() if errors.Is(err, iterator.Done) { break } if err != nil { // Log but continue - permission errors on individual parents shouldn't stop discovery log.WithContext(ctx).WithError(err).WithField("parent", parent).Debug("Error listing projects under parent, continuing") break } // Only include active projects if project.GetState() == resourcemanagerpb.Project_ACTIVE && project.GetProjectId() != "" { projectID := project.GetProjectId() if !projectSet[projectID] { projectSet[projectID] = true log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.project_id": projectID, "ovm.source.display_name": project.GetDisplayName(), "parent": parent, }).Debug("Discovered project") } } } // List direct folders under this parent folderIt := foldersClient.ListFolders(ctx, &resourcemanagerpb.ListFoldersRequest{ Parent: parent, }) for { folder, err := folderIt.Next() if errors.Is(err, iterator.Done) { break } if err != nil { // Log but continue - permission errors on individual folders shouldn't stop discovery log.WithContext(ctx).WithError(err).WithField("parent", parent).Debug("Error listing folders under parent, continuing") break } folderName := folder.GetName() log.WithFields(log.Fields{ "ovm.source.type": "gcp", "folder": folderName, "parent": parent, }).Debug("Discovered folder") // Recursively discover projects under this folder if err := discoverProjectsUnderParent(ctx, folderName, projectsClient, foldersClient, projectSet); err != nil { log.WithContext(ctx).WithError(err).WithField("parent", folderName).Debug("Error discovering projects under folder, continuing") } } return nil } // discoverProjectsUnderSpecificParent discovers all projects under a specific parent (organization or folder) // This is similar to discoverProjects but starts from a specific parent instead of searching for all organizations func discoverProjectsUnderSpecificParent(ctx context.Context, parent string, impersonationServiceAccountEmail string) ([]string, error) { // Create client options var clientOpts []option.ClientOption if impersonationServiceAccountEmail != "" { // Use impersonation credentials ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ TargetPrincipal: impersonationServiceAccountEmail, Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, }) if err != nil { return nil, fmt.Errorf("failed to create impersonated token source: %w", err) } clientOpts = append(clientOpts, option.WithTokenSource(ts)) } // Create clients for folders and projects foldersClient, err := resourcemanager.NewFoldersClient(ctx, clientOpts...) if err != nil { return nil, fmt.Errorf("failed to create folders client: %w", err) } defer foldersClient.Close() projectsClient, err := resourcemanager.NewProjectsClient(ctx, clientOpts...) if err != nil { return nil, fmt.Errorf("failed to create projects client: %w", err) } defer projectsClient.Close() // Use a map to track discovered projects and avoid duplicates projectSet := make(map[string]bool) // Recursively discover projects under the specified parent if err := discoverProjectsUnderParent(ctx, parent, projectsClient, foldersClient, projectSet); err != nil { return nil, fmt.Errorf("error discovering projects under parent %s: %w", parent, err) } // Convert map to slice var projects []string for projectID := range projectSet { projects = append(projects, projectID) } // Return the list even if empty - let the caller handle the empty case // with a more informative error message if len(projects) > 0 { log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": "gcp", "ovm.source.parent": parent, "ovm.source.project_count": len(projects), }).Info("Successfully discovered projects under parent") } return projects, nil } // adapters returns a list of discovery adapters for GCP. It includes both // manual adapters and dynamic adapters. func adapters( ctx context.Context, projectLocations []gcpshared.LocationInfo, regionLocations []gcpshared.LocationInfo, zoneLocations []gcpshared.LocationInfo, impersonationServiceAccountEmail string, linker *gcpshared.Linker, initGCPClients bool, cache sdpcache.Cache, ) ([]discovery.Adapter, error) { adapters := make([]discovery.Adapter, 0) var tokenSource *oauth2.TokenSource if impersonationServiceAccountEmail != "" { // Base credentials sourced from ADC ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ TargetPrincipal: impersonationServiceAccountEmail, // Broad access to all GCP resources // It is restricted by the IAM permissions of the service account Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, }) if err != nil { return nil, fmt.Errorf("failed to create token source: %w", err) } tokenSource = &ts } // Add manual adapters manualAdapters, err := manual.Adapters( ctx, projectLocations, regionLocations, zoneLocations, tokenSource, initGCPClients, cache, ) if err != nil { return nil, err } initiatedManualAdapters := make(map[string]bool) for _, adapter := range manualAdapters { initiatedManualAdapters[adapter.Type()] = true } adapters = append(adapters, manualAdapters...) httpClient := http.DefaultClient if initGCPClients { var errCli error httpClient, errCli = gcpshared.GCPHTTPClientWithOtel(ctx, impersonationServiceAccountEmail) if errCli != nil { return nil, fmt.Errorf("error creating GCP HTTP client: %w", errCli) } } // Add dynamic adapters dynamicAdapters, err := dynamic.Adapters( projectLocations, regionLocations, zoneLocations, linker, httpClient, initiatedManualAdapters, cache, ) if err != nil { return nil, err } adapters = append(adapters, dynamicAdapters...) return adapters, nil } ================================================ FILE: sources/gcp/proc/proc_test.go ================================================ package proc import ( "context" "fmt" "slices" "sort" "strings" "sync" "sync/atomic" "testing" "time" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" _ "github.com/overmindtech/cli/sources/gcp/dynamic" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "google.golang.org/protobuf/types/known/structpb" ) func Test_adapters(t *testing.T) { ctx := context.Background() projectLocations := []gcpshared.LocationInfo{gcpshared.NewProjectLocation("project")} regionLocations := []gcpshared.LocationInfo{gcpshared.NewRegionalLocation("project", "region")} zoneLocations := []gcpshared.LocationInfo{gcpshared.NewZonalLocation("project", "zone")} discoveryAdapters, err := adapters( ctx, projectLocations, regionLocations, zoneLocations, "", gcpshared.NewLinker(), false, sdpcache.NewNoOpCache(), ) if err != nil { t.Fatalf("error creating adapters: %v", err) } numberOfAdapters := len(discoveryAdapters) if numberOfAdapters == 0 { t.Fatal("Expected at least one adapter, got none") } if len(Metadata.AllAdapterMetadata()) != numberOfAdapters { t.Fatalf("Expected %d adapters in metadata, got %d", numberOfAdapters, len(Metadata.AllAdapterMetadata())) } // Check if the Spanner adapter is present // Because it is created externally and it needs to be registered during the initialization of the source // we need to ensure that it is included in the discoveryAdapters list. spannerAdapterFound := false for _, adapter := range discoveryAdapters { if adapter.Type() == gcpshared.SpannerDatabase.String() { spannerAdapterFound = true break } } if !spannerAdapterFound { t.Fatal("Expected to find Spanner adapter in the list of adapters") } aiPlatformCustomJobFound := false for _, adapter := range discoveryAdapters { if adapter.Type() == gcpshared.AIPlatformCustomJob.String() { aiPlatformCustomJobFound = true break } } if !aiPlatformCustomJobFound { t.Fatal("Expected to find AIPlatform Custom Job adapter in the list of adapters") } t.Logf("GCP Adapters found: %v", len(discoveryAdapters)) } func Test_ensureMandatoryFieldsInDynamicAdapters(t *testing.T) { predefinedRoles := make(map[string]bool, len(gcpshared.SDPAssetTypeToAdapterMeta)) for sdpItemType, meta := range gcpshared.SDPAssetTypeToAdapterMeta { t.Run(sdpItemType.String(), func(t *testing.T) { if meta.InDevelopment == true { t.Skipf("InDevelopment is true for %s", sdpItemType.String()) } if meta.GetEndpointFunc == nil { t.Errorf("GetEndpointFunc is nil for %s", sdpItemType) } if meta.LocationLevel == "" { t.Errorf("LocationLevel is empty for %s", sdpItemType) } if len(meta.UniqueAttributeKeys) == 0 { t.Errorf("UniqueAttributeKeys is empty for %s", sdpItemType) } if len(meta.IAMPermissions) == 0 { t.Errorf("IAMPermissions is empty for %s", sdpItemType) } if len(meta.PredefinedRole) == 0 { t.Errorf("PredefinedRoles is empty for %s", sdpItemType) } role, ok := gcpshared.PredefinedRoles[meta.PredefinedRole] if !ok { t.Errorf("PredefinedRole %s is not in the PredefinedRoles map", meta.PredefinedRole) } foundPerm := false for _, perm := range role.IAMPermissions { if slices.Contains(meta.IAMPermissions, perm) { foundPerm = true } } if !foundPerm { t.Errorf("IAMPermissions %s is not in the PredefinedRole %s", meta.IAMPermissions, meta.PredefinedRole) } predefinedRoles[meta.PredefinedRole] = true }) } roles := make([]string, 0, len(predefinedRoles)) for r := range gcpshared.PredefinedRoles { roles = append(roles, r) } sort.Strings(roles) for _, r := range roles { fmt.Println("\"" + r + "\"") } } func Test_detectParentType(t *testing.T) { tests := []struct { name string parent string expectedType ParentType expectedError bool }{ { name: "empty parent", parent: "", expectedType: ParentTypeUnknown, expectedError: true, }, { name: "organization format", parent: "organizations/123456789012", expectedType: ParentTypeOrganization, expectedError: false, }, { name: "folder format", parent: "folders/987654321098", expectedType: ParentTypeFolder, expectedError: false, }, { name: "explicit project format", parent: "projects/my-project-id", expectedType: ParentTypeProject, expectedError: false, }, { name: "project id format - simple", parent: "my-project-id", expectedType: ParentTypeProject, expectedError: false, }, { name: "project id format - with numbers", parent: "my-project-123", expectedType: ParentTypeProject, expectedError: false, }, { name: "project id format - with dashes", parent: "my-project-test-123", expectedType: ParentTypeProject, expectedError: false, }, { name: "too short to be valid", parent: "short", expectedType: ParentTypeUnknown, expectedError: true, }, { name: "too long to be valid project", parent: "this-is-a-very-long-project-id-that-exceeds-the-thirty-character-limit", expectedType: ParentTypeUnknown, expectedError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parentType, err := detectParentType(tt.parent) if tt.expectedError && err == nil { t.Errorf("expected error but got none") } if !tt.expectedError && err != nil { t.Errorf("unexpected error: %v", err) } if parentType != tt.expectedType { t.Errorf("expected parent type %v, got %v", tt.expectedType, parentType) } }) } } func Test_normalizeParent(t *testing.T) { tests := []struct { name string parent string parentType ParentType expectedResult string expectedError bool }{ { name: "organization - already normalized", parent: "organizations/123456789012", parentType: ParentTypeOrganization, expectedResult: "organizations/123456789012", expectedError: false, }, { name: "organization - empty ID", parent: "organizations/", parentType: ParentTypeOrganization, expectedResult: "", expectedError: true, }, { name: "folder - already normalized", parent: "folders/987654321098", parentType: ParentTypeFolder, expectedResult: "folders/987654321098", expectedError: false, }, { name: "folder - empty ID", parent: "folders/", parentType: ParentTypeFolder, expectedResult: "", expectedError: true, }, { name: "project - explicit format", parent: "projects/my-project-id", parentType: ParentTypeProject, expectedResult: "my-project-id", expectedError: false, }, { name: "project - empty ID", parent: "projects/", parentType: ParentTypeProject, expectedResult: "", expectedError: true, }, { name: "project - just id", parent: "my-project-id", parentType: ParentTypeProject, expectedResult: "my-project-id", expectedError: false, }, { name: "unknown type", parent: "something", parentType: ParentTypeUnknown, expectedResult: "", expectedError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := normalizeParent(tt.parent, tt.parentType) if tt.expectedError && err == nil { t.Errorf("expected error but got none") } if !tt.expectedError && err != nil { t.Errorf("unexpected error: %v", err) } if result != tt.expectedResult { t.Errorf("expected result %q, got %q", tt.expectedResult, result) } }) } } // mockAdapter is a mock implementation of discovery.Adapter for testing type mockAdapter struct { shouldError bool errorMessage string callCount *atomic.Int32 } func newMockAdapter(projectID string, shouldError bool, errorMessage string) *mockAdapter { // projectID parameter is kept for backwards compatibility but not used anymore return &mockAdapter{ shouldError: shouldError, errorMessage: errorMessage, callCount: &atomic.Int32{}, } } func (m *mockAdapter) Type() string { return gcpshared.CloudResourceManagerProject.String() } func (m *mockAdapter) Name() string { return "mock-adapter" } func (m *mockAdapter) Scopes() []string { return []string{"*"} } func (m *mockAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: m.Type(), } } func (m *mockAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { m.callCount.Add(1) if m.shouldError { return nil, fmt.Errorf("%s", m.errorMessage) } // Return a mock item with the queried project ID // The query parameter contains the project ID being checked item := &sdp.Item{ Type: m.Type(), UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "projectId": structpb.NewStringValue(query), }, }, }, } return item, nil } func (m *mockAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return nil, fmt.Errorf("not implemented") } func (m *mockAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { return nil, fmt.Errorf("not implemented") } func (m *mockAdapter) GetCallCount() int32 { return m.callCount.Load() } func TestNewProjectHealthChecker(t *testing.T) { tests := []struct { name string projectIDs []string adapter discovery.Adapter cacheDuration time.Duration expectValid bool }{ { name: "valid inputs", projectIDs: []string{"project-1", "project-2"}, adapter: newMockAdapter("project-1", false, ""), cacheDuration: 1 * time.Minute, expectValid: true, }, { name: "empty project IDs", projectIDs: []string{}, adapter: newMockAdapter("project-1", false, ""), cacheDuration: 1 * time.Minute, expectValid: true, }, { name: "zero cache duration", projectIDs: []string{"project-1"}, adapter: newMockAdapter("project-1", false, ""), cacheDuration: 0, expectValid: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { checker := NewProjectHealthChecker(tt.projectIDs, tt.adapter, tt.cacheDuration) if checker == nil { t.Fatal("expected checker to be non-nil") return } if len(checker.projectIDs) != len(tt.projectIDs) { t.Errorf("expected %d project IDs, got %d", len(tt.projectIDs), len(checker.projectIDs)) } if checker.cacheDuration != tt.cacheDuration { t.Errorf("expected cache duration %v, got %v", tt.cacheDuration, checker.cacheDuration) } }) } } func TestProjectHealthChecker_Check_Success(t *testing.T) { ctx := context.Background() projectIDs := []string{"project-1", "project-2"} adapter := newMockAdapter("project-1", false, "") checker := NewProjectHealthChecker(projectIDs, adapter, 1*time.Minute) result, err := checker.Check(ctx) if err != nil { t.Errorf("expected no error, got %v", err) } if result.SuccessCount != 2 { t.Errorf("expected 2 successes, got %d", result.SuccessCount) } if result.FailureCount != 0 { t.Errorf("expected 0 failures, got %d", result.FailureCount) } if len(result.ProjectErrors) != 0 { t.Errorf("expected 0 project errors, got %d", len(result.ProjectErrors)) } } func TestProjectHealthChecker_Check_Failures(t *testing.T) { ctx := context.Background() projectIDs := []string{"project-1", "project-2", "project-3"} // Single adapter that will fail for project-2 and project-3 adapter := newMockAdapter("project-1", true, "permission denied") checker := NewProjectHealthChecker(projectIDs, adapter, 1*time.Minute) result, err := checker.Check(ctx) if err == nil { t.Error("expected error, got nil") } if result.SuccessCount != 0 { t.Errorf("expected 0 success, got %d", result.SuccessCount) } if result.FailureCount != 3 { t.Errorf("expected 3 failures, got %d", result.FailureCount) } if len(result.ProjectErrors) != 3 { t.Errorf("expected 3 project errors, got %d", len(result.ProjectErrors)) } if _, exists := result.ProjectErrors["project-1"]; !exists { t.Error("expected error for project-1") } if _, exists := result.ProjectErrors["project-2"]; !exists { t.Error("expected error for project-2") } if _, exists := result.ProjectErrors["project-3"]; !exists { t.Error("expected error for project-3") } } func TestProjectHealthChecker_Check_MissingAdapter(t *testing.T) { // This test is no longer relevant with a single multi-project adapter // The adapter now handles all projects, so there's no concept of a "missing" adapter for a specific project t.Skip("Test not applicable with single multi-project adapter pattern") } func TestProjectHealthChecker_Check_Caching(t *testing.T) { ctx := context.Background() projectIDs := []string{"project-1"} tests := []struct { name string cacheDuration time.Duration sleepBetween time.Duration expectCached bool }{ { name: "cache hit within duration", cacheDuration: 1 * time.Minute, sleepBetween: 100 * time.Millisecond, expectCached: true, }, { name: "cache miss after expiry", cacheDuration: 100 * time.Millisecond, sleepBetween: 200 * time.Millisecond, expectCached: false, }, { name: "zero cache duration always misses", cacheDuration: 0, sleepBetween: 0, expectCached: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create fresh mock adapter for each test mockAdpt := newMockAdapter("project-1", false, "") checker := NewProjectHealthChecker(projectIDs, mockAdpt, tt.cacheDuration) // First call _, err := checker.Check(ctx) if err != nil { t.Fatalf("unexpected error on first call: %v", err) } firstCallCount := mockAdpt.GetCallCount() if firstCallCount != 1 { t.Errorf("expected 1 call after first check, got %d", firstCallCount) } // Sleep if needed if tt.sleepBetween > 0 { time.Sleep(tt.sleepBetween) } // Second call _, err = checker.Check(ctx) if err != nil { t.Fatalf("unexpected error on second call: %v", err) } secondCallCount := mockAdpt.GetCallCount() if tt.expectCached { // Should still be 1 call (cached) if secondCallCount != 1 { t.Errorf("expected cached result (1 total call), got %d calls", secondCallCount) } } else { // Should be 2 calls (not cached) if secondCallCount != 2 { t.Errorf("expected non-cached result (2 total calls), got %d calls", secondCallCount) } } }) } } func TestProjectHealthChecker_Check_ConcurrentAccess(t *testing.T) { ctx := context.Background() projectIDs := []string{"project-1"} mockAdpt := newMockAdapter("project-1", false, "") checker := NewProjectHealthChecker(projectIDs, mockAdpt, 1*time.Minute) // Run multiple checks concurrently const concurrency = 10 var wg sync.WaitGroup errors := make(chan error, concurrency) for range concurrency { wg.Go(func() { _, err := checker.Check(ctx) if err != nil { errors <- err } }) } wg.Wait() close(errors) // Check if any errors occurred for err := range errors { t.Errorf("unexpected error during concurrent access: %v", err) } // The first goroutine should run the check, others should use cache // So we expect exactly 1 call callCount := mockAdpt.GetCallCount() if callCount != 1 { t.Errorf("expected 1 call with caching, got %d", callCount) } } func TestProjectPermissionCheckResult_FormatError(t *testing.T) { tests := []struct { name string result *ProjectPermissionCheckResult expectError bool expectContain []string }{ { name: "no failures", result: &ProjectPermissionCheckResult{ SuccessCount: 2, FailureCount: 0, ProjectErrors: map[string]error{}, }, expectError: false, }, { name: "single failure", result: &ProjectPermissionCheckResult{ SuccessCount: 1, FailureCount: 1, ProjectErrors: map[string]error{ "project-1": fmt.Errorf("permission denied"), }, }, expectError: true, expectContain: []string{"1 out of 2", "50.0%", "project-1", "permission denied"}, }, { name: "multiple failures", result: &ProjectPermissionCheckResult{ SuccessCount: 1, FailureCount: 2, ProjectErrors: map[string]error{ "project-1": fmt.Errorf("permission denied"), "project-2": fmt.Errorf("not found"), }, }, expectError: true, expectContain: []string{"2 out of 3", "66.7%", "project-1", "project-2"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.result.FormatError() if tt.expectError && err == nil { t.Error("expected error, got nil") } if !tt.expectError && err != nil { t.Errorf("expected no error, got: %v", err) } if err != nil { errStr := err.Error() for _, expected := range tt.expectContain { if !contains(errStr, expected) { t.Errorf("expected error to contain %q, got: %s", expected, errStr) } } } }) } } func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && (s[:len(substr)] == substr || contains(s[1:], substr)))) } // TestCriticalTerraformMappingsRegistered verifies that customer-critical Terraform // resource types are correctly registered in the adapter metadata. This test mirrors // the mapping table construction in cli/tfutils/plan_mapper.go — it loads all // registered adapter metadata, parses TerraformQueryMap entries, and checks that // each critical Terraform type resolves to the expected Overmind item type. // // If this test fails, the affected Terraform resources will show as "Unsupported" // (skipped) in the change analysis UI, meaning no blast radius or risk analysis. func TestCriticalTerraformMappingsRegistered(t *testing.T) { // Build the mapping table from all registered adapter metadata, exactly as // cli/tfutils/plan_mapper.go does at lines 168-190 type tfMapEntry struct { overmindType string method sdp.QueryMethod queryField string } mappings := make(map[string][]tfMapEntry) for _, metadata := range Metadata.AllAdapterMetadata() { if metadata.GetType() == "" { continue } for _, mapping := range metadata.GetTerraformMappings() { subs := strings.SplitN(mapping.GetTerraformQueryMap(), ".", 2) if len(subs) != 2 { continue } terraformType := subs[0] mappings[terraformType] = append(mappings[terraformType], tfMapEntry{ overmindType: metadata.GetType(), method: mapping.GetTerraformMethod(), queryField: subs[1], }) } } // Each entry defines a Terraform resource type that must be mapped, what // Overmind type it should resolve to, and which attribute is extracted from // the Terraform plan to perform the lookup. criticalMappings := []struct { terraformType string expectedType string expectedField string expectedMethod sdp.QueryMethod reason string // documents why this mapping is critical }{ // Core resource mappings { terraformType: "google_compute_instance", expectedType: gcpshared.ComputeInstance.String(), expectedField: "name", expectedMethod: sdp.QueryMethod_GET, reason: "Core compute resource — one of the most common GCP resources in Terraform", }, { terraformType: "google_compute_network", expectedType: gcpshared.ComputeNetwork.String(), expectedField: "name", expectedMethod: sdp.QueryMethod_GET, reason: "VPC networks are foundational infrastructure with wide blast radius", }, { terraformType: "google_compute_subnetwork", expectedType: gcpshared.ComputeSubnetwork.String(), expectedField: "name", expectedMethod: sdp.QueryMethod_GET, reason: "Subnets are critical networking resources", }, { terraformType: "google_storage_bucket", expectedType: gcpshared.StorageBucket.String(), expectedField: "name", expectedMethod: sdp.QueryMethod_GET, reason: "Storage buckets are one of the most common GCP resources", }, { terraformType: "google_pubsub_topic", expectedType: gcpshared.PubSubTopic.String(), expectedField: "name", expectedMethod: sdp.QueryMethod_GET, reason: "Pub/Sub topics are critical messaging infrastructure", }, { terraformType: "google_pubsub_subscription", expectedType: gcpshared.PubSubSubscription.String(), expectedField: "name", expectedMethod: sdp.QueryMethod_GET, reason: "Pub/Sub subscriptions are critical messaging infrastructure", }, // Previously broken mappings (fixed in PRs #3755 and #3782) { terraformType: "google_compute_region_instance_group_manager", expectedType: gcpshared.ComputeRegionInstanceGroupManager.String(), expectedField: "name", expectedMethod: sdp.QueryMethod_GET, reason: "Regional MIG — was missing before PR #3755; customer-reported issue", }, { terraformType: "google_kms_crypto_key", expectedType: gcpshared.CloudKMSCryptoKey.String(), expectedField: "id", expectedMethod: sdp.QueryMethod_SEARCH, reason: "KMS key — TerraformMappings() returned nil before PR #3782; customer-reported issue", }, // IAM binding mappings — these Terraform-only resources don't have // standalone GCP APIs, so they resolve to the parent resource for blast // radius analysis. // // Pub/Sub Subscription IAM { terraformType: "google_pubsub_subscription_iam_binding", expectedType: gcpshared.PubSubSubscription.String(), expectedField: "subscription", expectedMethod: sdp.QueryMethod_GET, reason: "IAM binding on subscription — resolves to parent subscription for blast radius", }, { terraformType: "google_pubsub_subscription_iam_member", expectedType: gcpshared.PubSubSubscription.String(), expectedField: "subscription", expectedMethod: sdp.QueryMethod_GET, reason: "IAM member on subscription — resolves to parent subscription for blast radius", }, { terraformType: "google_pubsub_subscription_iam_policy", expectedType: gcpshared.PubSubSubscription.String(), expectedField: "subscription", expectedMethod: sdp.QueryMethod_GET, reason: "IAM policy on subscription — resolves to parent subscription for blast radius", }, // Pub/Sub Topic IAM { terraformType: "google_pubsub_topic_iam_binding", expectedType: gcpshared.PubSubTopic.String(), expectedField: "topic", expectedMethod: sdp.QueryMethod_GET, reason: "IAM binding on topic — resolves to parent topic for blast radius", }, { terraformType: "google_pubsub_topic_iam_member", expectedType: gcpshared.PubSubTopic.String(), expectedField: "topic", expectedMethod: sdp.QueryMethod_GET, reason: "IAM member on topic — resolves to parent topic for blast radius", }, { terraformType: "google_pubsub_topic_iam_policy", expectedType: gcpshared.PubSubTopic.String(), expectedField: "topic", expectedMethod: sdp.QueryMethod_GET, reason: "IAM policy on topic — resolves to parent topic for blast radius", }, // BigQuery Dataset IAM { terraformType: "google_bigquery_dataset_iam_binding", expectedType: gcpshared.BigQueryDataset.String(), expectedField: "dataset_id", expectedMethod: sdp.QueryMethod_GET, reason: "IAM binding on dataset — resolves to parent dataset for blast radius", }, { terraformType: "google_bigquery_dataset_iam_member", expectedType: gcpshared.BigQueryDataset.String(), expectedField: "dataset_id", expectedMethod: sdp.QueryMethod_GET, reason: "IAM member on dataset — resolves to parent dataset for blast radius", }, { terraformType: "google_bigquery_dataset_iam_policy", expectedType: gcpshared.BigQueryDataset.String(), expectedField: "dataset_id", expectedMethod: sdp.QueryMethod_GET, reason: "IAM policy on dataset — resolves to parent dataset for blast radius", }, // BigQuery Table IAM — resolves via dataset_id (bare table_id would be // misinterpreted as a dataset ID by the SEARCH handler) { terraformType: "google_bigquery_table_iam_binding", expectedType: gcpshared.BigQueryTable.String(), expectedField: "dataset_id", expectedMethod: sdp.QueryMethod_SEARCH, reason: "IAM binding on table — resolves via dataset_id to list tables in affected dataset", }, { terraformType: "google_bigquery_table_iam_member", expectedType: gcpshared.BigQueryTable.String(), expectedField: "dataset_id", expectedMethod: sdp.QueryMethod_SEARCH, reason: "IAM member on table — resolves via dataset_id to list tables in affected dataset", }, { terraformType: "google_bigquery_table_iam_policy", expectedType: gcpshared.BigQueryTable.String(), expectedField: "dataset_id", expectedMethod: sdp.QueryMethod_SEARCH, reason: "IAM policy on table — resolves via dataset_id to list tables in affected dataset", }, // Bigtable Instance IAM { terraformType: "google_bigtable_instance_iam_binding", expectedType: gcpshared.BigTableAdminInstance.String(), expectedField: "instance", expectedMethod: sdp.QueryMethod_GET, reason: "IAM binding on instance — resolves to parent instance for blast radius", }, { terraformType: "google_bigtable_instance_iam_member", expectedType: gcpshared.BigTableAdminInstance.String(), expectedField: "instance", expectedMethod: sdp.QueryMethod_GET, reason: "IAM member on instance — resolves to parent instance for blast radius", }, { terraformType: "google_bigtable_instance_iam_policy", expectedType: gcpshared.BigTableAdminInstance.String(), expectedField: "instance", expectedMethod: sdp.QueryMethod_GET, reason: "IAM policy on instance — resolves to parent instance for blast radius", }, // Bigtable Table IAM — resolves via instance_name (the table attribute is // a bare name that the SEARCH handler would misinterpret as an instance name) { terraformType: "google_bigtable_table_iam_binding", expectedType: gcpshared.BigTableAdminTable.String(), expectedField: "instance_name", expectedMethod: sdp.QueryMethod_SEARCH, reason: "IAM binding on table — resolves via instance_name to list tables in affected instance", }, { terraformType: "google_bigtable_table_iam_member", expectedType: gcpshared.BigTableAdminTable.String(), expectedField: "instance_name", expectedMethod: sdp.QueryMethod_SEARCH, reason: "IAM member on table — resolves via instance_name to list tables in affected instance", }, { terraformType: "google_bigtable_table_iam_policy", expectedType: gcpshared.BigTableAdminTable.String(), expectedField: "instance_name", expectedMethod: sdp.QueryMethod_SEARCH, reason: "IAM policy on table — resolves via instance_name to list tables in affected instance", }, } for _, tc := range criticalMappings { t.Run(tc.terraformType, func(t *testing.T) { entries, ok := mappings[tc.terraformType] if !ok { t.Fatalf("Terraform type %q is NOT registered in any adapter metadata. "+ "This means it will show as 'Unsupported' in change analysis. Reason it's critical: %s", tc.terraformType, tc.reason) } // Verify at least one mapping resolves to the expected Overmind type found := false for _, entry := range entries { if entry.overmindType == tc.expectedType { found = true if entry.queryField != tc.expectedField { t.Errorf("Terraform type %q maps to %q but uses query field %q, expected %q", tc.terraformType, tc.expectedType, entry.queryField, tc.expectedField) } if entry.method != tc.expectedMethod { t.Errorf("Terraform type %q maps to %q but uses method %s, expected %s", tc.terraformType, tc.expectedType, entry.method, tc.expectedMethod) } break } } if !found { actualTypes := make([]string, 0, len(entries)) for _, e := range entries { actualTypes = append(actualTypes, e.overmindType) } t.Errorf("Terraform type %q is registered but resolves to %v, expected %q. "+ "Reason: %s", tc.terraformType, actualTypes, tc.expectedType, tc.reason) } }) } } ================================================ FILE: sources/gcp/setup/README.md ================================================ # GCP Source Setup for Overmind This repository provides tools to set up the necessary GCP permissions for the Overmind service account to inspect your GCP project resources. ## Purpose When setting up a GCP source in Overmind, you need to grant specific permissions to the Overmind service account. This repository contains a script that automates this process, ensuring the Overmind service account has the proper access to collect information about your GCP resources. ## Permissions Granted The script grants several read-only IAM roles to the Overmind service account. These permissions allow Overmind to inspect your GCP resources without making any changes to your project. For the exact permissions being granted, please refer to the [roles file](./overmind-gcp-roles.sh). These permissions allow Overmind to: - Inspect your GCP resources and their configurations - Review IAM permissions and security settings - Access resource hierarchy information The permissions are read-only and do not allow Overmind to make any changes to your GCP project. ## Usage You can run the script directly in Google Cloud Shell by clicking the button below: [![Open in Cloud Shell](https://gstatic.com/cloudssh/images/open-btn.svg)](https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/overmindtech/gcp-source-setup.git&cloudshell_open_in_editor=README.md&ephemeral=true&show=terminal&cloudshell_tutorial=tutorial.md) Alternatively, you can run the script manually within your terminal after cloning the repository: ```bash ./overmind-gcp-source-setup.sh ``` The script will expect two arguments: - ``: Your GCP project ID where Overmind will inspect resources. - ``: The email address of the Overmind service account that will be granted permissions. ## Complete Guide For a complete guide on setting up and configuring the GCP source in Overmind, please refer to the official documentation: [Overmind GCP Source Configuration Guide](https://docs.overmind.tech/sources/gcp/configuration) ================================================ FILE: sources/gcp/setup/scripts/overmind-gcp-roles.sh ================================================ # Define roles that can be applied at any level (org, folder, or project) ROLES=( "roles/browser" "roles/aiplatform.viewer" "roles/artifactregistry.reader" "roles/bigquery.metadataViewer" "roles/bigquery.user" "roles/bigtable.viewer" "roles/cloudbuild.builds.viewer" "roles/cloudfunctions.viewer" "roles/cloudkms.viewer" "roles/cloudsql.viewer" "roles/compute.viewer" "roles/container.viewer" "roles/dataform.viewer" "roles/dataplex.catalogViewer" "roles/dataplex.viewer" "roles/dataflow.viewer" "roles/dataproc.viewer" "roles/dns.reader" "roles/essentialcontacts.viewer" "roles/eventarc.viewer" "roles/file.viewer" "roles/logging.viewer" "roles/monitoring.viewer" "roles/orgpolicy.policyViewer" "roles/pubsub.viewer" "roles/redis.viewer" "roles/resourcemanager.tagViewer" "roles/run.viewer" "roles/secretmanager.viewer" "roles/securitycentermanagement.viewer" "roles/servicedirectory.viewer" "roles/serviceusage.serviceUsageViewer" "roles/spanner.viewer" "roles/storage.bucketViewer" "roles/storagetransfer.viewer" ) # Define roles that can only be applied at project level PROJECT_ONLY_ROLES=( "roles/iam.roleViewer" "roles/iam.serviceAccountViewer" ) ================================================ FILE: sources/gcp/setup/scripts/overmind-gcp-source-permission-check.sh ================================================ #!/bin/bash # Script to check if the Overmind service account has the necessary permissions # Can use command-line arguments or environment variables set -euo pipefail # Exit on error, undefined vars, and pipe failures # Display usage information function show_usage() { echo "Usage: $0 [options]" echo "Options:" echo " -p, --project-id PROJECT_ID GCP Project ID" echo " -s, --service-account SA_EMAIL Overmind service account email" echo " -h, --help Show this help message" echo "" echo "You can also set these values through environment variables:" echo " GCP_PROJECT_ID and GCP_OVERMIND_SA" exit 1 } # Parse command-line arguments while [[ $# -gt 0 ]]; do case "$1" in -p|--project-id) if [[ -n "${2:-}" ]]; then GCP_PROJECT_ID="$2" shift 2 else echo "ERROR: Value for --project-id is missing" show_usage fi ;; -s|--service-account) if [[ -n "${2:-}" ]]; then GCP_OVERMIND_SA="$2" shift 2 else echo "ERROR: Value for --service-account is missing" show_usage fi ;; -h|--help) show_usage ;; *) echo "ERROR: Unknown argument: $1" show_usage ;; esac done # Source environment variables from the local file if it exists and parameters weren't provided # shellcheck source=/dev/null if [[ (-z "${GCP_PROJECT_ID:-}" || -z "${GCP_OVERMIND_SA:-}") && -f ./.gcp-source-setup-env ]]; then source ./.gcp-source-setup-env echo "Successfully loaded environment variables from ./.gcp-source-setup-env" fi # Check if GCP_PROJECT_ID environment variable is set if [[ -z "${GCP_PROJECT_ID:-}" ]]; then echo "ERROR: GCP Project ID is not provided" echo "Please specify the project ID using the --project-id option or run the overmind-gcp-source-setup.sh script first" show_usage fi # Check if GCP_OVERMIND_SA environment variable is set if [[ -z "${GCP_OVERMIND_SA:-}" ]]; then echo "ERROR: Overmind service account email is not provided" echo "Please specify the service account using the --service-account option or run the overmind-gcp-source-setup.sh script first" show_usage fi echo "Checking permissions for service account: ${GCP_OVERMIND_SA}" echo "on project: ${GCP_PROJECT_ID}" echo "" # @generator:inline-start:overmind-gcp-roles.sh # This block is replaced with inlined role definitions during TypeScript generation source "$(dirname "$0")/overmind-gcp-roles.sh" # @generator:inline-end # Fetch the current IAM policy echo "Fetching current IAM policy for project ${GCP_PROJECT_ID}..." IAM_POLICY=$(gcloud projects get-iam-policy "${GCP_PROJECT_ID}" --format=json) # Check if fetch was successful if [[ -z "${IAM_POLICY}" ]]; then echo "ERROR: Failed to fetch IAM policy for project ${GCP_PROJECT_ID}" exit 1 fi # Create a temporary file for the policy TEMP_FILE=$(mktemp) echo "${IAM_POLICY}" > "${TEMP_FILE}" # Counter for roles check TOTAL_ROLES=${#ROLES[@]} FOUND_ROLES=0 MISSING_ROLES=0 echo "" echo "Checking for ${TOTAL_ROLES} required roles..." echo "----------------------------------------" for ROLE in "${ROLES[@]}"; do # Check if the role exists in the policy for the service account if grep -q "\"role\": \"${ROLE}\"" "${TEMP_FILE}" && \ jq -e --arg ROLE "$ROLE" --arg SA "serviceAccount:${GCP_OVERMIND_SA}" \ '.bindings[] | select(.role == $ROLE) | .members[] | select(. == $SA)' \ "${TEMP_FILE}" >/dev/null; then echo "✓ Role exists: ${ROLE}" ((FOUND_ROLES++)) else echo "✗ Role missing: ${ROLE}" ((MISSING_ROLES++)) fi done # Clean up rm "${TEMP_FILE}" echo "----------------------------------------" echo "Permission check completed:" echo " - Found roles: ${FOUND_ROLES}/${TOTAL_ROLES}" echo " - Missing roles: ${MISSING_ROLES}/${TOTAL_ROLES}" echo "" if [[ ${MISSING_ROLES} -eq 0 ]]; then echo "✅ All required permissions are correctly assigned to the Overmind service account." echo " Your GCP source is ready for Overmind to access." else echo "❌ Some required permissions are missing. Please run the setup script again:" echo " ./overmind-gcp-source-setup.sh" fi ================================================ FILE: sources/gcp/setup/scripts/overmind-gcp-source-setup-impersonation.sh ================================================ #!/bin/bash # Script to add IAM policy bindings to a service account in GCP # Takes GCP Parent (organizations/123, folders/456, or projects/my-project), Overmind service account and Impersonation service account as arguments # # Usage: ./overmind-gcp-source-setup-impersonation.sh # # NOTE: The service accounts should be the service account emails # presented in the Overmind application when creating a new GCP source. set -euo pipefail # Exit on error, undefined vars, and pipe failures # Check if all arguments are provided if [[ $# -ne 3 ]]; then echo "ERROR: All of the following arguments are required: parent, overmind service account email and impersonation service account email" echo "Usage: $0 " echo "Parent format: organizations/123, folders/456, or projects/my-project" exit 1 fi # Get arguments GCP_PARENT="$1" GCP_OVERMIND_SA="$2" GCP_IMPERSONATION_SA="$3" # Check if GCP_PARENT is empty if [[ -z "${GCP_PARENT}" ]]; then echo "ERROR: GCP Parent cannot be empty" exit 1 fi # Check if GCP_OVERMIND_SA is empty if [[ -z "${GCP_OVERMIND_SA}" ]]; then echo "ERROR: Overmind service account email cannot be empty" echo "NOTE: Use the service account email presented in the Overmind application when creating a GCP source" exit 1 fi # Check if GCP_IMPERSONATION_SA is empty if [[ -z "${GCP_IMPERSONATION_SA}" ]]; then echo "ERROR: Impersonation service account email cannot be empty" echo "NOTE: Use the service account email presented in the Impersonation application when creating a GCP source" exit 1 fi # Grant the necessary permissions to the Overmind Service Account to access the resources in the parent source "$(dirname "$0")/overmind-gcp-source-setup.sh" "${GCP_PARENT}" "${GCP_OVERMIND_SA}" echo "Impersonation Service Account: ${GCP_IMPERSONATION_SA}" # Extract project ID from impersonation service account email for the impersonation binding if [[ "${GCP_IMPERSONATION_SA}" =~ @([^.]+)\.iam\.gserviceaccount\.com$ ]]; then IMPERSONATION_PROJECT="${BASH_REMATCH[1]}" else echo "✗ Failed to extract project from impersonation service account email" exit 1 fi # Grant the necessary permissions to allow Overmind SA to impersonate your SA if gcloud iam service-accounts add-iam-policy-binding \ "${GCP_IMPERSONATION_SA}" \ --project "${IMPERSONATION_PROJECT}" \ --member="serviceAccount:${GCP_OVERMIND_SA}" \ --role="roles/iam.serviceAccountTokenCreator" \ --quiet > /dev/null 2>&1; then echo "✓ Successfully granted roles/iam.serviceAccountTokenCreator to allow Overmind SA to impersonate: ${GCP_IMPERSONATION_SA}" else echo "✗ Failed to grant roles/iam.serviceAccountTokenCreator" # Print the error output gcloud iam service-accounts add-iam-policy-binding \ "${GCP_IMPERSONATION_SA}" \ --project "${IMPERSONATION_PROJECT}" \ --member="serviceAccount:${GCP_OVERMIND_SA}" \ --role="roles/iam.serviceAccountTokenCreator" \ --quiet exit 1 fi # Save the variables to a local file for other scripts to use. This needs to be done after the source setup script is run to ensure the target file is not overwritten. echo "export GCP_IMPERSONATION_SA=\"${GCP_IMPERSONATION_SA}\"" >> ./.gcp-source-setup-env ================================================ FILE: sources/gcp/setup/scripts/overmind-gcp-source-setup.sh ================================================ #!/bin/bash # Script to add IAM policy bindings to a service account in GCP # Takes GCP Parent (organizations/123, folders/456, or projects/my-project) and Overmind service account as arguments # # Usage: ./overmind-gcp-source-setup.sh # # NOTE: The Overmind service account should be the service account email presented # in the Overmind application when creating a new GCP source. set -euo pipefail # Exit on error, undefined vars, and pipe failures # Check if both arguments are provided if [[ $# -ne 2 ]]; then echo "ERROR: Both parent and service account email are required" echo "Usage: $0 " echo "Parent format: organizations/123, folders/456, or projects/my-project" exit 1 fi # Get arguments GCP_PARENT="$1" GCP_OVERMIND_SA="$2" # Check if GCP_PARENT is empty if [[ -z "${GCP_PARENT}" ]]; then echo "ERROR: GCP Parent cannot be empty" exit 1 fi # Check if GCP_OVERMIND_SA is empty if [[ -z "${GCP_OVERMIND_SA}" ]]; then echo "ERROR: Overmind service account email cannot be empty" echo "NOTE: Use the service account email presented in the Overmind application when creating a GCP source" exit 1 fi # Parse parent to determine type and ID PARENT="${GCP_PARENT}" if [[ ${PARENT} =~ ^organizations?/([0-9]+)$ ]]; then PARENT_TYPE="organization" PARENT_ID="${BASH_REMATCH[1]}" elif [[ ${PARENT} =~ ^folders?/([0-9]+)$ ]]; then PARENT_TYPE="folder" PARENT_ID="${BASH_REMATCH[1]}" elif [[ ${PARENT} =~ ^projects?/([a-z][a-z0-9-]*[a-z0-9])$ ]]; then PARENT_TYPE="project" PARENT_ID="${BASH_REMATCH[1]}" else echo "✗ Invalid parent format: ${PARENT}" echo "Must be: organizations/123, folders/456, or projects/my-project" exit 1 fi echo "Detected parent type: ${PARENT_TYPE}" echo "Parent ID: ${PARENT_ID}" # Save the variables to a local file for other scripts to use echo "export GCP_PARENT=\"${GCP_PARENT}\"" > ./.gcp-source-setup-env echo "export GCP_PARENT_TYPE=\"${PARENT_TYPE}\"" >> ./.gcp-source-setup-env echo "export GCP_PARENT_ID=\"${PARENT_ID}\"" >> ./.gcp-source-setup-env echo "export GCP_OVERMIND_SA=\"${GCP_OVERMIND_SA}\"" >> ./.gcp-source-setup-env echo "Using GCP Parent: ${GCP_PARENT}" echo "Service Account: ${GCP_OVERMIND_SA}" # @generator:inline-start:overmind-gcp-roles.sh # This block is replaced with inlined role definitions during TypeScript generation source "$(dirname "$0")/overmind-gcp-roles.sh" # @generator:inline-end # For project-level parents, create custom role if [ "${PARENT_TYPE}" = "project" ]; then echo "Creating custom role for additional BigQuery and Spanner permissions..." if gcloud iam roles create overmindCustomRole \ --project="${PARENT_ID}" \ --title="Overmind Custom Role" \ --description="Custom role for Overmind service account with additional BigQuery and Spanner permissions" \ --permissions="bigquery.transfers.get,spanner.databases.get,spanner.databases.list" \ --quiet > /dev/null 2>&1; then echo "✓ Successfully created custom role: overmindCustomRole" else echo "ℹ Custom role may already exist, continuing..." fi fi # Display the roles that will be added echo "" echo "This script will assign the following predefined GCP roles to ${GCP_OVERMIND_SA} on the ${PARENT_TYPE} ${PARENT_ID}:" echo "" for ROLE in "${ROLES[@]}"; do echo " - ${ROLE}" done if [ "${PARENT_TYPE}" = "project" ]; then for ROLE in "${PROJECT_ONLY_ROLES[@]}"; do echo " - ${ROLE} (project-level only)" done echo " - projects/${PARENT_ID}/roles/overmindCustomRole (custom role with additional BigQuery and Spanner permissions)" fi echo "" echo "These permissions are read-only and allow Overmind to inspect your GCP resources without making any changes." echo "" # Ask for confirmation read -p "Do you want to continue? (Yes/No): " CONFIRMATION if [[ ! "$(echo "$CONFIRMATION" | tr '[:upper:]' '[:lower:]')" =~ ^(yes|y)$ ]]; then echo "Operation canceled by user." exit 0 fi # Counter for successful operations SUCCESS_COUNT=0 TOTAL_ROLES=${#ROLES[@]} echo "" echo "Starting to add IAM policy bindings..." echo "----------------------------------------" # Loop through each role and add the policy binding for ROLE in "${ROLES[@]}"; do echo "Adding role: ${ROLE}" # Determine the correct command based on parent type if [ "${PARENT_TYPE}" = "organization" ]; then CMD="gcloud organizations add-iam-policy-binding ${PARENT_ID}" elif [ "${PARENT_TYPE}" = "folder" ]; then CMD="gcloud resource-manager folders add-iam-policy-binding ${PARENT_ID}" else CMD="gcloud projects add-iam-policy-binding ${PARENT_ID}" fi if ${CMD} \ --member="serviceAccount:${GCP_OVERMIND_SA}" \ --role="${ROLE}" \ --quiet > /dev/null 2>&1; then echo "✓ Successfully added role: ${ROLE}" ((SUCCESS_COUNT++)) || true else echo "✗ Failed to add role: ${ROLE}" # Print the error output ${CMD} \ --member="serviceAccount:${GCP_OVERMIND_SA}" \ --role="${ROLE}" \ --quiet exit 1 fi done # Add project-only roles if parent is a project if [ "${PARENT_TYPE}" = "project" ]; then echo "Adding project-level-only IAM roles..." for ROLE in "${PROJECT_ONLY_ROLES[@]}"; do echo "Adding role: ${ROLE}" if gcloud projects add-iam-policy-binding "${PARENT_ID}" \ --member="serviceAccount:${GCP_OVERMIND_SA}" \ --role="${ROLE}" \ --quiet > /dev/null 2>&1; then echo "✓ Successfully added role: ${ROLE}" ((SUCCESS_COUNT++)) || true ((TOTAL_ROLES++)) || true else echo "✗ Failed to add role: ${ROLE}" # Print the error output gcloud projects add-iam-policy-binding "${PARENT_ID}" \ --member="serviceAccount:${GCP_OVERMIND_SA}" \ --role="${ROLE}" \ --quiet exit 1 fi done # Add custom role only for project-level parents echo "Adding custom role: projects/${PARENT_ID}/roles/overmindCustomRole" if gcloud projects add-iam-policy-binding "${PARENT_ID}" \ --member="serviceAccount:${GCP_OVERMIND_SA}" \ --role="projects/${PARENT_ID}/roles/overmindCustomRole" \ --quiet > /dev/null 2>&1; then echo "✓ Successfully added custom role" ((SUCCESS_COUNT++)) || true ((TOTAL_ROLES++)) || true else echo "✗ Failed to add custom role" exit 1 fi fi echo "----------------------------------------" echo "✓ All IAM policy bindings completed successfully!" echo "✓ Added ${SUCCESS_COUNT}/${TOTAL_ROLES} roles to service account: ${GCP_OVERMIND_SA}" echo "✓ Parent: ${GCP_PARENT}" echo "" echo "These variables have also been saved to ./.gcp-source-setup-env for other scripts to use." echo "You can use these variables in subsequent commands." ================================================ FILE: sources/gcp/setup/tutorial.md ================================================ # GCP Source Setup Tutorial ## Overview This tutorial will guide you through setting up the necessary permissions for the Overmind service account in your GCP project. ## Set up permissions Let's set up the required permissions for the Overmind service account. ### Step 1: Run the permissions script Run the shell command copied from the Overmind Create Source page. It should look something like this: ```bash ./overmind-gcp-source-setup.sh ``` This script will set up the necessary IAM permissions for the Overmind service account to access your GCP resources. ### Step 2: Verify the permissions After the script completes, you can verify that the permissions were set correctly by running the following command. The permission check script will automatically use the environment variables that were set by the setup script: ```bash ./overmind-gcp-source-permission-check.sh ``` This script will check if all the necessary permissions have been correctly assigned to the Overmind service account. ## What's Next You have successfully set up the necessary permissions for the Overmind service account. You can now: 1. Close this Cloud Shell session 2. Return to the Overmind application to continue your setup process ================================================ FILE: sources/gcp/shared/adapter-meta.go ================================================ package shared import ( "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) // SearchFilterFunc filters items returned by SEARCH. Takes the search query // and an SDP item; returns true to keep the item. Used for tag-based SEARCH // where the GCP API does not support server-side filtering. type SearchFilterFunc func(query string, item *sdp.Item) bool // ListFilterFunc filters items returned by LIST. Takes an SDP item and returns // true to keep the item. Used to filter out placeholder/phantom entries that // some GCP APIs return when using wildcard location queries. type ListFilterFunc func(item *sdp.Item) bool // LocationLevel defines at which level of the GCP hierarchy a resource is located. type LocationLevel string const ( ProjectLevel LocationLevel = "project" RegionalLevel LocationLevel = "regional" ZonalLevel LocationLevel = "zonal" ) // EndpointFunc is a function that generates an API endpoint URL given a query and location. type EndpointFunc func(query string, location LocationInfo) string // ListEndpointFunc is a function that generates a list endpoint URL for a given location. type ListEndpointFunc func(location LocationInfo) (string, error) // AdapterMeta contains metadata for a GCP dynamic adapter. type AdapterMeta struct { LocationLevel LocationLevel // GetEndpointFunc is a function that generates GET endpoint URLs. // It receives the query string and LocationInfo and returns the URL. GetEndpointFunc EndpointFunc // ListEndpointFunc is a function that generates list endpoint URLs. // It accepts LocationInfo directly for the multi-scope architecture. ListEndpointFunc ListEndpointFunc // SearchEndpointFunc is a function that generates SEARCH endpoint URLs. // It receives the query string and LocationInfo and returns the URL. SearchEndpointFunc EndpointFunc // We will normally generate the search description from the UniqueAttributeKeys // but we allow it to be overridden for specific adapters. SearchDescription string SDPAdapterCategory sdp.AdapterCategory UniqueAttributeKeys []string InDevelopment bool // If true, the adapter is in development and should not be used in production. IAMPermissions []string // List of IAM permissions required to access this resource. PredefinedRole string // Predefined role required to access this resource. NameSelector string // By default, it is `name`, but can be overridden for outlier cases // By default, we use the last item of the UniqueAttributeKeys. // However, there is an exception: https://cloud.google.com/dataproc/docs/reference/rest/v1/ListAutoscalingPoliciesResponse // Expected: `autoscalingPolicies` by convention, but the API returns `policies` ListResponseSelector string // SearchFilterFunc, if set, is applied after listing items during SEARCH // to keep only items matching the query. Used for tag-based SEARCH where // the API has no server-side filter. SearchFilterFunc SearchFilterFunc // ListFilterFunc, if set, is applied after fetching items during LIST // to filter out unwanted entries. Used to exclude placeholder/phantom // entries that some GCP APIs return with wildcard location queries. ListFilterFunc ListFilterFunc } // ============================================= // NEW PATTERN: Endpoint builder functions // These take a format string and return an EndpointFunc // ============================================= // ProjectLevelEndpointFuncWithSingleQuery returns a function that builds GET endpoint URLs for project-level resources. // Format string should have 2 %s placeholders: project ID and query. func ProjectLevelEndpointFuncWithSingleQuery(format string) EndpointFunc { if strings.Count(format, "%s") != 2 { panic(fmt.Sprintf("format string must contain 2 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } return fmt.Sprintf(format, location.ProjectID, query) } } // ProjectLevelEndpointFuncWithTwoQueries returns a function for project-level resources with composite query. // Format string should have 3 %s placeholders: project ID and 2 parts of the query. func ProjectLevelEndpointFuncWithTwoQueries(format string) EndpointFunc { if strings.Count(format, "%s") != 3 { panic(fmt.Sprintf("format string must contain 3 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } queryParts := strings.Split(query, shared.QuerySeparator) if len(queryParts) != 2 || queryParts[0] == "" || queryParts[1] == "" { return "" } return fmt.Sprintf(format, location.ProjectID, queryParts[0], queryParts[1]) } } // ProjectLevelEndpointFuncWithThreeQueries returns a function for project-level resources with 3-part query. // Format string should have 4 %s placeholders: project ID and 3 parts of the query. func ProjectLevelEndpointFuncWithThreeQueries(format string) EndpointFunc { if strings.Count(format, "%s") != 4 { panic(fmt.Sprintf("format string must contain 4 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } queryParts := strings.Split(query, shared.QuerySeparator) if len(queryParts) != 3 || queryParts[0] == "" || queryParts[1] == "" || queryParts[2] == "" { return "" } return fmt.Sprintf(format, location.ProjectID, queryParts[0], queryParts[1], queryParts[2]) } } // ProjectLevelEndpointFuncWithFourQueries returns a function for project-level resources with 4-part query. // Format string should have 5 %s placeholders: project ID and 4 parts of the query. func ProjectLevelEndpointFuncWithFourQueries(format string) EndpointFunc { if strings.Count(format, "%s") != 5 { panic(fmt.Sprintf("format string must contain 5 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } queryParts := strings.Split(query, shared.QuerySeparator) if len(queryParts) != 4 || queryParts[0] == "" || queryParts[1] == "" || queryParts[2] == "" || queryParts[3] == "" { return "" } return fmt.Sprintf(format, location.ProjectID, queryParts[0], queryParts[1], queryParts[2], queryParts[3]) } } // ZoneLevelEndpointFunc returns a function that builds GET endpoint URLs for zonal resources. // Format string should have 3 %s placeholders: project ID, zone, and query. func ZoneLevelEndpointFunc(format string) EndpointFunc { if strings.Count(format, "%s") != 3 { panic(fmt.Sprintf("format string must contain 3 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } return fmt.Sprintf(format, location.ProjectID, location.Zone, query) } } // ZoneLevelEndpointFuncWithTwoQueries returns a function for zonal resources with composite query. // Format string should have 4 %s placeholders: project ID, zone, and 2 parts of the query. func ZoneLevelEndpointFuncWithTwoQueries(format string) EndpointFunc { if strings.Count(format, "%s") != 4 { panic(fmt.Sprintf("format string must contain 4 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } queryParts := strings.Split(query, shared.QuerySeparator) if len(queryParts) != 2 || queryParts[0] == "" || queryParts[1] == "" { return "" } return fmt.Sprintf(format, location.ProjectID, location.Zone, queryParts[0], queryParts[1]) } } // RegionalLevelEndpointFunc returns a function that builds GET endpoint URLs for regional resources. // Format string should have 3 %s placeholders: project ID, region, and query. func RegionalLevelEndpointFunc(format string) EndpointFunc { if strings.Count(format, "%s") != 3 { panic(fmt.Sprintf("format string must contain 3 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } return fmt.Sprintf(format, location.ProjectID, location.Region, query) } } // RegionalLevelEndpointFuncWithTwoQueries returns a function for regional resources with composite query. // Format string should have 4 %s placeholders: project ID, region, and 2 parts of the query. func RegionalLevelEndpointFuncWithTwoQueries(format string) EndpointFunc { if strings.Count(format, "%s") != 4 { panic(fmt.Sprintf("format string must contain 4 %%s placeholders: %s", format)) } return func(query string, location LocationInfo) string { if query == "" { return "" } queryParts := strings.Split(query, shared.QuerySeparator) if len(queryParts) != 2 || queryParts[0] == "" || queryParts[1] == "" { return "" } return fmt.Sprintf(format, location.ProjectID, location.Region, queryParts[0], queryParts[1]) } } // ============================================= // LIST ENDPOINT FUNCTIONS // ============================================= // ProjectLevelListFunc returns a ListEndpointFunc for project-level resources. // Format string should have 1 %s placeholder: project ID. func ProjectLevelListFunc(format string) ListEndpointFunc { if strings.Count(format, "%s") != 1 { panic(fmt.Sprintf("format string must contain 1 %%s placeholder: %s", format)) } return func(location LocationInfo) (string, error) { if location.ProjectID == "" { return "", fmt.Errorf("project ID cannot be empty") } return fmt.Sprintf(format, location.ProjectID), nil } } // RegionLevelListFunc returns a ListEndpointFunc for regional resources. // Format string should have 2 %s placeholders: project ID and region. func RegionLevelListFunc(format string) ListEndpointFunc { if strings.Count(format, "%s") != 2 { panic(fmt.Sprintf("format string must contain 2 %%s placeholders: %s", format)) } return func(location LocationInfo) (string, error) { if location.ProjectID == "" || location.Region == "" { return "", fmt.Errorf("project ID and region cannot be empty") } return fmt.Sprintf(format, location.ProjectID, location.Region), nil } } // ZoneLevelListFunc returns a ListEndpointFunc for zonal resources. // Format string should have 2 %s placeholders: project ID and zone. func ZoneLevelListFunc(format string) ListEndpointFunc { if strings.Count(format, "%s") != 2 { panic(fmt.Sprintf("format string must contain 2 %%s placeholders: %s", format)) } return func(location LocationInfo) (string, error) { if location.ProjectID == "" || location.Zone == "" { return "", fmt.Errorf("project ID and zone cannot be empty") } return fmt.Sprintf(format, location.ProjectID, location.Zone), nil } } // SDPAssetTypeToAdapterMeta maps GCP asset types to their corresponding adapter metadata. // This map is populated during source initiation by individual adapter files. var SDPAssetTypeToAdapterMeta = map[shared.ItemType]AdapterMeta{} ================================================ FILE: sources/gcp/shared/adapter-meta_test.go ================================================ package shared import ( "testing" "github.com/overmindtech/cli/sources/shared" ) func TestSDPAssetTypeToAdapterMeta_GetEndpointFunc(t *testing.T) { tests := []struct { name string assetType shared.ItemType location LocationInfo query string expectedURL string }{ { name: "ComputeNetwork valid", assetType: ComputeNetwork, location: NewProjectLocation("proj"), query: "net", expectedURL: "https://compute.googleapis.com/compute/v1/projects/proj/global/networks/net", }, { name: "ComputeSubnetwork valid", assetType: ComputeSubnetwork, location: NewRegionalLocation("proj", "region"), query: "subnet", expectedURL: "https://compute.googleapis.com/compute/v1/projects/proj/regions/region/subnetworks/subnet", }, { name: "PubSubSubscription valid", assetType: PubSubSubscription, location: NewProjectLocation("proj"), query: "mysub", expectedURL: "https://pubsub.googleapis.com/v1/projects/proj/subscriptions/mysub", }, { name: "PubSubTopic valid", assetType: PubSubTopic, location: NewProjectLocation("proj"), query: "mytopic", expectedURL: "https://pubsub.googleapis.com/v1/projects/proj/topics/mytopic", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { meta, ok := SDPAssetTypeToAdapterMeta[tt.assetType] if !ok { t.Fatalf("assetType %v not found in SDPAssetTypeToAdapterMeta", tt.assetType) } if meta.GetEndpointFunc == nil { t.Fatalf("GetEndpointFunc is nil for asset type %v", tt.assetType) } gotURL := meta.GetEndpointFunc(tt.query, tt.location) if gotURL != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", gotURL, tt.expectedURL) } }) } } func TestSDPAssetTypeToAdapterMeta_ListEndpointFunc(t *testing.T) { tests := []struct { name string assetType shared.ItemType location LocationInfo expectedURL string expectErr bool }{ { name: "ComputeNetwork valid", assetType: ComputeNetwork, location: NewProjectLocation("proj"), expectedURL: "https://compute.googleapis.com/compute/v1/projects/proj/global/networks", }, { name: "ComputeNetwork missing param", assetType: ComputeNetwork, location: LocationInfo{}, expectErr: true, }, { name: "ComputeSubnetwork valid", assetType: ComputeSubnetwork, location: NewRegionalLocation("proj", "region"), expectedURL: "https://compute.googleapis.com/compute/v1/projects/proj/regions/region/subnetworks", }, { name: "ComputeSubnetwork missing region", assetType: ComputeSubnetwork, location: NewProjectLocation("proj"), expectErr: true, }, { name: "PubSubSubscription valid", assetType: PubSubSubscription, location: NewProjectLocation("proj"), expectedURL: "https://pubsub.googleapis.com/v1/projects/proj/subscriptions", }, { name: "PubSubTopic valid", assetType: PubSubTopic, location: NewProjectLocation("proj"), expectedURL: "https://pubsub.googleapis.com/v1/projects/proj/topics", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { meta, ok := SDPAssetTypeToAdapterMeta[tt.assetType] if !ok { t.Fatalf("assetType %v not found in SDPAssetTypeToAdapterMeta", tt.assetType) } if meta.ListEndpointFunc == nil { t.Skip("ListEndpointFunc not defined for this asset type") } gotURL, err := meta.ListEndpointFunc(tt.location) if tt.expectErr { if err == nil { t.Errorf("expected error but got none\n got: %v", gotURL) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if gotURL != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", gotURL, tt.expectedURL) } }) } } func TestSDPAssetTypeToAdapterMeta_SearchEndpointFunc(t *testing.T) { tests := []struct { name string assetType shared.ItemType location LocationInfo query string expectedURL string }{ { name: "ArtifactRegistryDockerImage valid", assetType: ArtifactRegistryDockerImage, location: NewProjectLocation("my-project"), query: "my-location|my-repo", expectedURL: "https://artifactregistry.googleapis.com/v1/projects/my-project/locations/my-location/repositories/my-repo/dockerImages", }, { name: "ArtifactRegistryDockerImage invalid query returns empty", assetType: ArtifactRegistryDockerImage, location: NewProjectLocation("my-project"), query: "my-location", // Missing repo part expectedURL: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { meta, ok := SDPAssetTypeToAdapterMeta[tt.assetType] if !ok { t.Fatalf("assetType %v not found in SDPAssetTypeToAdapterMeta", tt.assetType) } if meta.SearchEndpointFunc == nil { t.Skip("SearchEndpointFunc not defined for this asset type") } gotURL := meta.SearchEndpointFunc(tt.query, tt.location) if gotURL != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", gotURL, tt.expectedURL) } }) } } func TestProjectLevelGetEndpointFunc(t *testing.T) { tests := []struct { name string format string location LocationInfo query string expectedURL string }{ { name: "valid project and query", format: "https://example.com/projects/%s/resources/%s", location: NewProjectLocation("my-project"), query: "my-resource", expectedURL: "https://example.com/projects/my-project/resources/my-resource", }, { name: "empty query returns empty string", format: "https://example.com/projects/%s/resources/%s", location: NewProjectLocation("my-project"), query: "", expectedURL: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { endpointFunc := ProjectLevelEndpointFuncWithSingleQuery(tt.format) got := endpointFunc(tt.query, tt.location) if got != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", got, tt.expectedURL) } }) } } func TestProjectLevelGetEndpointFuncWithTwoQueries(t *testing.T) { tests := []struct { name string format string location LocationInfo query string expectedURL string }{ { name: "valid project and composite query", format: "https://example.com/projects/%s/parent-resources/%s/child-resources/%s", location: NewProjectLocation("my-project"), query: "foo|bar", expectedURL: "https://example.com/projects/my-project/parent-resources/foo/child-resources/bar", }, { name: "empty query returns empty string", format: "https://example.com/projects/%s/parent-resources/%s/child-resources/%s", location: NewProjectLocation("my-project"), query: "", expectedURL: "", }, { name: "query with only one part returns empty string", format: "https://example.com/projects/%s/parent-resources/%s/child-resources/%s", location: NewProjectLocation("my-project"), query: "foo", expectedURL: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { endpointFunc := ProjectLevelEndpointFuncWithTwoQueries(tt.format) got := endpointFunc(tt.query, tt.location) if got != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", got, tt.expectedURL) } }) } } func TestZoneLevelGetEndpointFunc(t *testing.T) { tests := []struct { name string format string location LocationInfo query string expectedURL string }{ { name: "valid project, zone and query", format: "https://example.com/projects/%s/zones/%s/resources/%s", location: NewZonalLocation("my-project", "my-zone"), query: "my-resource", expectedURL: "https://example.com/projects/my-project/zones/my-zone/resources/my-resource", }, { name: "empty query returns empty string", format: "https://example.com/projects/%s/zones/%s/resources/%s", location: NewZonalLocation("my-project", "my-zone"), query: "", expectedURL: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { endpointFunc := ZoneLevelEndpointFunc(tt.format) got := endpointFunc(tt.query, tt.location) if got != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", got, tt.expectedURL) } }) } } func TestRegionalLevelGetEndpointFunc(t *testing.T) { tests := []struct { name string format string location LocationInfo query string expectedURL string }{ { name: "valid project, region and query", format: "https://example.com/projects/%s/regions/%s/resources/%s", location: NewRegionalLocation("my-project", "my-region"), query: "my-resource", expectedURL: "https://example.com/projects/my-project/regions/my-region/resources/my-resource", }, { name: "empty query returns empty string", format: "https://example.com/projects/%s/regions/%s/resources/%s", location: NewRegionalLocation("my-project", "my-region"), query: "", expectedURL: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { endpointFunc := RegionalLevelEndpointFunc(tt.format) got := endpointFunc(tt.query, tt.location) if got != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", got, tt.expectedURL) } }) } } func TestEndpointFuncWithQueries_PanicsOnWrongFormat(t *testing.T) { tests := []struct { name string fn func(string) EndpointFunc format string }{ { name: "ProjectLevelGetEndpointFuncWithThreeQueries panics on wrong format", fn: ProjectLevelEndpointFuncWithThreeQueries, format: "https://example.com/projects/%s/resources/%s/child/%s", // 3 %s, should be 4 }, { name: "ProjectLevelGetEndpointFunc panics on wrong format", fn: ProjectLevelEndpointFuncWithSingleQuery, format: "https://example.com/projects/%s/resources", // 1 %s, should be 2 }, { name: "ZoneLevelGetEndpointFunc panics on wrong format", fn: ZoneLevelEndpointFunc, format: "https://example.com/projects/%s/zones/%s/resources", // 2 %s, should be 3 }, { name: "RegionalLevelGetEndpointFunc panics on wrong format", fn: RegionalLevelEndpointFunc, format: "https://example.com/projects/%s/regions/%s/resources", // 2 %s, should be 3 }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defer func() { if r := recover(); r == nil { t.Errorf("expected panic for wrong format, but no panic occurred (format: %v)", tt.format) } }() _ = tt.fn(tt.format) }) } } func Test_projectLevelListFunc(t *testing.T) { tests := []struct { name string format string location LocationInfo expectedURL string expectErr bool }{ { name: "valid project id", format: "https://example.com/projects/%s/resources", location: NewProjectLocation("my-project"), expectedURL: "https://example.com/projects/my-project/resources", }, { name: "empty project id", format: "https://example.com/projects/%s/resources", location: LocationInfo{}, expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fn := ProjectLevelListFunc(tt.format) got, err := fn(tt.location) if tt.expectErr { if err == nil { t.Errorf("expected error but got none\n got: %v", got) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if got != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", got, tt.expectedURL) } }) } } func Test_regionLevelListFunc(t *testing.T) { tests := []struct { name string format string location LocationInfo expectedURL string expectErr bool }{ { name: "valid project and region", format: "https://example.com/projects/%s/regions/%s/resources", location: NewRegionalLocation("my-project", "my-region"), expectedURL: "https://example.com/projects/my-project/regions/my-region/resources", }, { name: "empty project id", format: "https://example.com/projects/%s/regions/%s/resources", location: LocationInfo{Region: "my-region"}, expectErr: true, }, { name: "empty region", format: "https://example.com/projects/%s/regions/%s/resources", location: LocationInfo{ProjectID: "my-project"}, expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fn := RegionLevelListFunc(tt.format) got, err := fn(tt.location) if tt.expectErr { if err == nil { t.Errorf("expected error but got none\n got: %v", got) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if got != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", got, tt.expectedURL) } }) } } func Test_zoneLevelListFunc(t *testing.T) { tests := []struct { name string format string location LocationInfo expectedURL string expectErr bool }{ { name: "valid project and zone", format: "https://example.com/projects/%s/zones/%s/resources", location: NewZonalLocation("my-project", "my-zone"), expectedURL: "https://example.com/projects/my-project/zones/my-zone/resources", }, { name: "empty project id", format: "https://example.com/projects/%s/zones/%s/resources", location: LocationInfo{Zone: "my-zone"}, expectErr: true, }, { name: "empty zone", format: "https://example.com/projects/%s/zones/%s/resources", location: LocationInfo{ProjectID: "my-project"}, expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fn := ZoneLevelListFunc(tt.format) got, err := fn(tt.location) if tt.expectErr { if err == nil { t.Errorf("expected error but got none\n got: %v", got) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if got != tt.expectedURL { t.Errorf("unexpected URL:\n got: %v\n want: %v", got, tt.expectedURL) } }) } } ================================================ FILE: sources/gcp/shared/base.go ================================================ package shared import ( "context" "errors" "fmt" "slices" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/shared" ) // CollectFromStream executes a streaming function and collects results into a slice. // This allows non-streaming implementations (List, Search) to delegate to streaming // versions (ListStream, SearchStream) without code duplication. func CollectFromStream( ctx context.Context, streamFunc func(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey), ) ([]*sdp.Item, *sdp.QueryError) { stream := discovery.NewRecordingQueryResultStream() noOpCache := sdpcache.NewNoOpCache() emptyCacheKey := sdpcache.CacheKey{} streamFunc(ctx, stream, noOpCache, emptyCacheKey) errs := stream.GetErrors() if len(errs) > 0 { // Return first error (preserving existing behavior) var qErr *sdp.QueryError if errors.As(errs[0], &qErr) { return nil, qErr } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: errs[0].Error(), } } return stream.GetItems(), nil } // ZoneBase provides shared multi-scope behavior for zonal adapters. type ZoneBase struct { locations []LocationInfo *shared.Base } // NewZoneBase creates a ZoneBase that supports multiple zones. func NewZoneBase(locations []LocationInfo, category sdp.AdapterCategory, item shared.ItemType) *ZoneBase { for _, location := range locations { if !location.Zonal() { panic(fmt.Sprintf("NewZoneBase: location %s is not zonal", location.ToScope())) } } scopes := make([]string, 0, len(locations)) for _, location := range locations { scopes = append(scopes, location.ToScope()) } return &ZoneBase{ locations: locations, Base: shared.NewBase(category, item, scopes), } } // PredefinedRole implements the sources.WithPredefinedRole interface. // Individual adapters must override this method. func (z *ZoneBase) PredefinedRole() string { panic("PredefinedRole not implemented - adapter must override this method") } // LocationFromScope parses a scope string into a zonal LocationInfo. func (z *ZoneBase) LocationFromScope(scope string) (LocationInfo, error) { location, err := LocationFromScope(scope) if err != nil { return LocationInfo{}, fmt.Errorf("failed to parse scope %s: %w", scope, err) } if !location.Zonal() { return LocationInfo{}, fmt.Errorf("scope %s is not zonal", scope) } if slices.ContainsFunc(z.locations, location.Equals) { return location, nil } return LocationInfo{}, fmt.Errorf("scope %s not found in adapter locations", scope) } // ZoneFromScope returns a zone string from the scope for backward compatibility. func (z *ZoneBase) ZoneFromScope(scope string) (string, error) { location, err := z.LocationFromScope(scope) if err != nil { return "", err } return location.Zone, nil } // Locations returns the configured locations for this adapter. func (z *ZoneBase) Locations() []LocationInfo { return z.locations } // RegionBase provides shared multi-scope behavior for regional adapters. type RegionBase struct { locations []LocationInfo *shared.Base } // NewRegionBase creates a RegionBase that supports multiple regions. func NewRegionBase(locations []LocationInfo, category sdp.AdapterCategory, item shared.ItemType) *RegionBase { for _, location := range locations { if !location.Regional() { panic(fmt.Sprintf("NewRegionBase: location %s is not regional", location.ToScope())) } } scopes := make([]string, 0, len(locations)) for _, location := range locations { scopes = append(scopes, location.ToScope()) } return &RegionBase{ locations: locations, Base: shared.NewBase(category, item, scopes), } } // PredefinedRole implements the sources.WithPredefinedRole interface. // Individual adapters must override this method. func (r *RegionBase) PredefinedRole() string { panic("PredefinedRole not implemented - adapter must override this method") } // LocationFromScope parses a scope string into a regional LocationInfo. func (r *RegionBase) LocationFromScope(scope string) (LocationInfo, error) { location, err := LocationFromScope(scope) if err != nil { return LocationInfo{}, fmt.Errorf("failed to parse scope %s: %w", scope, err) } if !location.Regional() { return LocationInfo{}, fmt.Errorf("scope %s is not regional", scope) } if slices.ContainsFunc(r.locations, location.Equals) { return location, nil } return LocationInfo{}, fmt.Errorf("scope %s not found in adapter locations", scope) } // RegionFromScope returns a region string from the scope for backward compatibility. func (r *RegionBase) RegionFromScope(scope string) (string, error) { location, err := r.LocationFromScope(scope) if err != nil { return "", err } return location.Region, nil } // Locations returns the configured locations for this adapter. func (r *RegionBase) Locations() []LocationInfo { return r.locations } // ProjectBase provides shared behavior for project-scoped adapters. type ProjectBase struct { locations []LocationInfo *shared.Base } // NewProjectBase creates a ProjectBase that supports multiple projects. func NewProjectBase(locations []LocationInfo, category sdp.AdapterCategory, item shared.ItemType) *ProjectBase { return NewProjectBaseFromLocations(locations, category, item) } // NewProjectBase creates a ProjectBase that supports multiple projects. func NewProjectBaseFromLocations(locations []LocationInfo, category sdp.AdapterCategory, item shared.ItemType) *ProjectBase { scopes := make([]string, 0, len(locations)) for _, location := range locations { scopes = append(scopes, location.ToScope()) } return &ProjectBase{ locations: locations, Base: shared.NewBase(category, item, scopes), } } // PredefinedRole implements the sources.WithPredefinedRole interface. // Individual adapters must override this method. func (p *ProjectBase) PredefinedRole() string { panic("PredefinedRole not implemented - adapter must override this method") } // LocationFromScope parses a scope string into a project LocationInfo. func (p *ProjectBase) LocationFromScope(scope string) (LocationInfo, error) { location, err := LocationFromScope(scope) if err != nil { return LocationInfo{}, fmt.Errorf("failed to parse scope %s: %w", scope, err) } if !location.ProjectLevel() { return LocationInfo{}, fmt.Errorf("scope %s is not project-level", scope) } if slices.ContainsFunc(p.locations, location.Equals) { return location, nil } return LocationInfo{}, fmt.Errorf("scope %s not found in adapter locations", scope) } ================================================ FILE: sources/gcp/shared/big-query-clients.go ================================================ package shared import ( "context" "errors" "fmt" "cloud.google.com/go/bigquery" "google.golang.org/api/iterator" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" ) type BigQueryRoutineClient interface { Get(ctx context.Context, projectID, datasetID, routineID string) (*bigquery.RoutineMetadata, error) List(ctx context.Context, projectID, datasetID string, toSDPItem func(routine *bigquery.RoutineMetadata, datasetID, routineID string) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) } type bigQueryRoutineClient struct { client *bigquery.Client } func (b bigQueryRoutineClient) Get(ctx context.Context, projectID, datasetID, routineID string) (*bigquery.RoutineMetadata, error) { routine := b.client.DatasetInProject(projectID, datasetID).Routine(routineID) meta, err := routine.Metadata(ctx) if err != nil { return nil, fmt.Errorf("error getting metadata for routine %s: %w", routineID, err) } return meta, nil } func (b bigQueryRoutineClient) List(ctx context.Context, projectID string, datasetID string, toSDPItem func(routine *bigquery.RoutineMetadata, datasetID, routineID string) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { return nil, QueryError(fmt.Errorf("dataset %s not found in project %s", datasetID, projectID), projectID, BigQueryRoutine.String()) } routineIterator := ds.Routines(ctx) if routineIterator == nil { return nil, QueryError(fmt.Errorf("failed to create routine iterator for dataset %s in project %s", datasetID, projectID), projectID, BigQueryRoutine.String()) } var items []*sdp.Item for { routine, err := routineIterator.Next() if errors.Is(err, iterator.Done) { break } if err != nil { return nil, QueryError(fmt.Errorf("error iterating routines: %w", err), projectID, BigQueryRoutine.String()) } meta, err := routine.Metadata(ctx) if err != nil { return nil, QueryError(fmt.Errorf("error getting metadata for routine %s: %w", routine.RoutineID, err), projectID, BigQueryRoutine.String()) } item, sdpErr := toSDPItem(meta, routine.DatasetID, routine.RoutineID) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } return items, nil } func NewBigQueryRoutineClient(client *bigquery.Client) BigQueryRoutineClient { return &bigQueryRoutineClient{ client: client, } } //go:generate mockgen -destination=./mocks/mock_big_query_dataset_client.go -package=mocks -source=big-query-clients.go -imports=sdp=github.com/overmindtech/cli/go/sdp-go type BigQueryDatasetClient interface { Get(ctx context.Context, projectID, datasetID string) (*bigquery.DatasetMetadata, error) List(ctx context.Context, projectID string, toSDPItem func(ctx context.Context, dataset *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) ListStream(ctx context.Context, projectID string, stream discovery.QueryResultStream, toSDPItem func(ctx context.Context, dataset *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError)) } type bigQueryDatasetClient struct { client *bigquery.Client } func (b bigQueryDatasetClient) Get(ctx context.Context, projectID, datasetID string) (*bigquery.DatasetMetadata, error) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { return nil, fmt.Errorf("dataset %s not found in project %s", datasetID, projectID) } return ds.Metadata(ctx) } func (b bigQueryDatasetClient) List(ctx context.Context, projectID string, toSDPItem func(ctx context.Context, dataset *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { dsIterator := b.client.Datasets(ctx) if dsIterator == nil { return nil, QueryError(fmt.Errorf("failed to create dataset iterator for project %s", projectID), projectID, BigQueryDataset.String()) } dsIterator.ProjectID = projectID var items []*sdp.Item for { ds, err := dsIterator.Next() if errors.Is(err, iterator.Done) { break } if err != nil { return nil, QueryError(fmt.Errorf("error iterating datasets: %w", err), projectID, BigQueryDataset.String()) } meta, err := ds.Metadata(ctx) if err != nil { return nil, QueryError(fmt.Errorf("error getting metadata for dataset %s: %w", ds.DatasetID, err), projectID, BigQueryDataset.String()) } var sdpErr *sdp.QueryError item, sdpErr := toSDPItem(ctx, meta) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } return items, nil } func (b bigQueryDatasetClient) ListStream(ctx context.Context, projectID string, stream discovery.QueryResultStream, toSDPItem func(ctx context.Context, dataset *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError)) { dsIterator := b.client.Datasets(ctx) if dsIterator == nil { stream.SendError(QueryError(fmt.Errorf("failed to create dataset iterator for project %s", projectID), projectID, BigQueryDataset.String())) return } dsIterator.ProjectID = projectID for { ds, err := dsIterator.Next() if errors.Is(err, iterator.Done) { break } if err != nil { stream.SendError(QueryError(fmt.Errorf("error iterating datasets: %w", err), projectID, BigQueryDataset.String())) return } meta, err := ds.Metadata(ctx) if err != nil { stream.SendError(QueryError(fmt.Errorf("error getting metadata for dataset %s: %w", ds.DatasetID, err), projectID, BigQueryDataset.String())) continue } item, sdpErr := toSDPItem(ctx, meta) if sdpErr != nil { stream.SendError(sdpErr) continue } stream.SendItem(item) } } func NewBigQueryDatasetClient(client *bigquery.Client) BigQueryDatasetClient { return &bigQueryDatasetClient{ client: client, } } type BigQueryTableClient interface { Get(ctx context.Context, projectID, datasetID, tableID string) (*bigquery.TableMetadata, error) List(ctx context.Context, projectID, datasetID string, toSDPItem func(table *bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) ListStream(ctx context.Context, projectID, datasetID string, stream discovery.QueryResultStream, toSDPItem func(table *bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError)) } type bigQueryTableClient struct { client *bigquery.Client } func (b bigQueryTableClient) Get(ctx context.Context, projectID, datasetID, tableID string) (*bigquery.TableMetadata, error) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { return nil, fmt.Errorf("dataset %s not found in project %s", datasetID, projectID) } table := ds.Table(tableID) if table == nil { return nil, fmt.Errorf("table %s not found in dataset %s in project %s", tableID, datasetID, projectID) } return table.Metadata(ctx) } func (b bigQueryTableClient) List(ctx context.Context, projectID, datasetID string, toSDPItem func(table *bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { return nil, QueryError(fmt.Errorf("dataset %s not found in project %s", datasetID, projectID), projectID, BigQueryTable.String()) } tableIterator := ds.Tables(ctx) if tableIterator == nil { return nil, QueryError(fmt.Errorf("failed to create table iterator for dataset %s in project %s", datasetID, projectID), projectID, BigQueryTable.String()) } var items []*sdp.Item for { table, err := tableIterator.Next() if errors.Is(err, iterator.Done) { break } if err != nil { return nil, QueryError(fmt.Errorf("error iterating tables: %w", err), projectID, BigQueryTable.String()) } meta, err := table.Metadata(ctx) if err != nil { return nil, QueryError(fmt.Errorf("error getting metadata for table %s: %w", table.TableID, err), projectID, BigQueryTable.String()) } var sdpErr *sdp.QueryError item, sdpErr := toSDPItem(meta) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } return items, nil } func (b bigQueryTableClient) ListStream(ctx context.Context, projectID, datasetID string, stream discovery.QueryResultStream, toSDPItem func(table *bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError)) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { stream.SendError(QueryError(fmt.Errorf("dataset %s not found in project %s", datasetID, projectID), projectID, BigQueryTable.String())) return } tableIterator := ds.Tables(ctx) if tableIterator == nil { stream.SendError(QueryError(fmt.Errorf("failed to create table iterator for dataset %s in project %s", datasetID, projectID), projectID, BigQueryTable.String())) return } for { table, err := tableIterator.Next() if errors.Is(err, iterator.Done) { break } if err != nil { stream.SendError(QueryError(fmt.Errorf("error iterating tables: %w", err), projectID, BigQueryTable.String())) return } meta, err := table.Metadata(ctx) if err != nil { stream.SendError(QueryError(fmt.Errorf("error getting metadata for table %s: %w", table.TableID, err), projectID, BigQueryTable.String())) continue } item, sdpErr := toSDPItem(meta) if sdpErr != nil { stream.SendError(sdpErr) continue } stream.SendItem(item) } } func NewBigQueryTableClient(client *bigquery.Client) BigQueryTableClient { return &bigQueryTableClient{ client: client, } } type BigQueryModelClient interface { Get(ctx context.Context, projectID, datasetID, modelID string) (*bigquery.ModelMetadata, error) List(ctx context.Context, projectID, datasetID string, toSDPItem func(datasetID string, dataset *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) ListStream(ctx context.Context, projectID, datasetID string, stream discovery.QueryResultStream, toSDPItem func(datasetID string, dataset *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError)) } type bigQueryModelClient struct { client *bigquery.Client } func NewBigQueryModelClient(client *bigquery.Client) BigQueryModelClient { return &bigQueryModelClient{ client: client, } } func (b bigQueryModelClient) Get(ctx context.Context, projectID, datasetID, modelID string) (*bigquery.ModelMetadata, error) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { return nil, fmt.Errorf("dataset %s not found in project %s", datasetID, projectID) } model := ds.Model(modelID) if model == nil { return nil, fmt.Errorf("model %s not found in dataset %s in project %s", modelID, datasetID, projectID) } return model.Metadata(ctx) } func (b bigQueryModelClient) List(ctx context.Context, projectID, datasetID string, toSDPItem func(datasetID string, dataset *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { return nil, QueryError(fmt.Errorf("dataset %s not found in project %s", datasetID, projectID), projectID, BigQueryModel.String()) } modelIterator := ds.Models(ctx) if modelIterator == nil { return nil, QueryError(fmt.Errorf("failed to create model iterator for dataset %s in project %s", datasetID, projectID), projectID, BigQueryModel.String()) } var items []*sdp.Item for { model, err := modelIterator.Next() if errors.Is(err, iterator.Done) { break } if err != nil { return nil, QueryError(fmt.Errorf("error iterating models: %w", err), projectID, BigQueryModel.String()) } meta, err := model.Metadata(ctx) if err != nil { return nil, QueryError(fmt.Errorf("error getting metadata for model %s: %w", model.ModelID, err), projectID, BigQueryModel.String()) } var sdpErr *sdp.QueryError item, sdpErr := toSDPItem(datasetID, meta) if sdpErr != nil { return nil, sdpErr } items = append(items, item) } return items, nil } func (b bigQueryModelClient) ListStream(ctx context.Context, projectID, datasetID string, stream discovery.QueryResultStream, toSDPItem func(datasetID string, dataset *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError)) { ds := b.client.DatasetInProject(projectID, datasetID) if ds == nil { stream.SendError(QueryError(fmt.Errorf("dataset %s not found in project %s", datasetID, projectID), projectID, BigQueryModel.String())) return } modelIterator := ds.Models(ctx) if modelIterator == nil { stream.SendError(QueryError(fmt.Errorf("failed to create model iterator for dataset %s in project %s", datasetID, projectID), projectID, BigQueryModel.String())) return } for { model, err := modelIterator.Next() if errors.Is(err, iterator.Done) { break } if err != nil { stream.SendError(QueryError(fmt.Errorf("error iterating models: %w", err), projectID, BigQueryModel.String())) return } meta, err := model.Metadata(ctx) if err != nil { stream.SendError(QueryError(fmt.Errorf("error getting metadata for model %s: %w", model.ModelID, err), projectID, BigQueryModel.String())) continue } item, sdpErr := toSDPItem(datasetID, meta) if sdpErr != nil { stream.SendError(sdpErr) continue } stream.SendItem(item) } } ================================================ FILE: sources/gcp/shared/certificate-manager-clients.go ================================================ package shared //go:generate mockgen -destination=./mocks/mock_certificate_manager_certificate_client.go -package=mocks -source=certificate-manager-clients.go import ( "context" certificatemanager "cloud.google.com/go/certificatemanager/apiv1" certificatemanagerpb "cloud.google.com/go/certificatemanager/apiv1/certificatemanagerpb" "github.com/googleapis/gax-go/v2" ) // CertificateManagerCertificateClient interface for Certificate Manager Certificate operations type CertificateManagerCertificateClient interface { GetCertificate(ctx context.Context, req *certificatemanagerpb.GetCertificateRequest, opts ...gax.CallOption) (*certificatemanagerpb.Certificate, error) ListCertificates(ctx context.Context, req *certificatemanagerpb.ListCertificatesRequest, opts ...gax.CallOption) CertificateIterator } type CertificateIterator interface { Next() (*certificatemanagerpb.Certificate, error) } type certificateManagerCertificateClient struct { client *certificatemanager.Client } func (c *certificateManagerCertificateClient) GetCertificate(ctx context.Context, req *certificatemanagerpb.GetCertificateRequest, opts ...gax.CallOption) (*certificatemanagerpb.Certificate, error) { return c.client.GetCertificate(ctx, req, opts...) } func (c *certificateManagerCertificateClient) ListCertificates(ctx context.Context, req *certificatemanagerpb.ListCertificatesRequest, opts ...gax.CallOption) CertificateIterator { return c.client.ListCertificates(ctx, req, opts...) } // NewCertificateManagerCertificateClient creates a new CertificateManagerCertificateClient func NewCertificateManagerCertificateClient(client *certificatemanager.Client) CertificateManagerCertificateClient { return &certificateManagerCertificateClient{ client: client, } } ================================================ FILE: sources/gcp/shared/compute-clients.go ================================================ //go:generate mockgen -destination=./mocks/mock_compute_instance_client.go -package=mocks -source=compute-clients.go package shared import ( "context" compute "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" "github.com/googleapis/gax-go/v2" ) // ComputeInstanceIterator is an interface for iterating over compute instances type ComputeInstanceIterator interface { Next() (*computepb.Instance, error) } // InstancesScopedListPairIterator is an interface for iterating over aggregated list responses type InstancesScopedListPairIterator interface { Next() (compute.InstancesScopedListPair, error) } // ComputeInstanceClient is an interface for the Compute Instance client type ComputeInstanceClient interface { Get(ctx context.Context, req *computepb.GetInstanceRequest, opts ...gax.CallOption) (*computepb.Instance, error) List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) ComputeInstanceIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListInstancesRequest, opts ...gax.CallOption) InstancesScopedListPairIterator } type computeInstanceClient struct { instanceClient *compute.InstancesClient } // NewComputeInstanceClient creates a new ComputeInstanceClient func NewComputeInstanceClient(instanceClient *compute.InstancesClient) ComputeInstanceClient { return &computeInstanceClient{ instanceClient: instanceClient, } } // Get retrieves a compute instance func (c computeInstanceClient) Get(ctx context.Context, req *computepb.GetInstanceRequest, opts ...gax.CallOption) (*computepb.Instance, error) { return c.instanceClient.Get(ctx, req, opts...) } // List lists compute instances and returns an iterator func (c computeInstanceClient) List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) ComputeInstanceIterator { return c.instanceClient.List(ctx, req, opts...) } // AggregatedList lists compute instances across all zones using aggregated list func (c computeInstanceClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstancesRequest, opts ...gax.CallOption) InstancesScopedListPairIterator { return c.instanceClient.AggregatedList(ctx, req, opts...) } // ComputeAddressIterator is an interface for iterating over compute address type ComputeAddressIterator interface { Next() (*computepb.Address, error) } // AddressesScopedListPairIterator is an interface for iterating over aggregated address list responses type AddressesScopedListPairIterator interface { Next() (compute.AddressesScopedListPair, error) } // ComputeAddressClient is an interface for the Compute Engine Address client type ComputeAddressClient interface { Get(ctx context.Context, req *computepb.GetAddressRequest, opts ...gax.CallOption) (*computepb.Address, error) List(ctx context.Context, req *computepb.ListAddressesRequest, opts ...gax.CallOption) ComputeAddressIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListAddressesRequest, opts ...gax.CallOption) AddressesScopedListPairIterator } type computeAddressClient struct { addressClient *compute.AddressesClient } // NewComputeAddressClient creates a new ComputeAddressClient func NewComputeAddressClient(addressClient *compute.AddressesClient) ComputeAddressClient { return &computeAddressClient{ addressClient: addressClient, } } // Get retrieves a compute address func (c computeAddressClient) Get(ctx context.Context, req *computepb.GetAddressRequest, opts ...gax.CallOption) (*computepb.Address, error) { return c.addressClient.Get(ctx, req, opts...) } // List lists compute address and returns an iterator func (c computeAddressClient) List(ctx context.Context, req *computepb.ListAddressesRequest, opts ...gax.CallOption) ComputeAddressIterator { return c.addressClient.List(ctx, req, opts...) } // AggregatedList lists compute addresses across all regions using aggregated list func (c computeAddressClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListAddressesRequest, opts ...gax.CallOption) AddressesScopedListPairIterator { return c.addressClient.AggregatedList(ctx, req, opts...) } // ComputeImageIterator is an interface for iterating over compute images type ComputeImageIterator interface { Next() (*computepb.Image, error) } // ComputeImagesClient is an interface for the Compute Images client type ComputeImagesClient interface { Get(ctx context.Context, req *computepb.GetImageRequest, opts ...gax.CallOption) (*computepb.Image, error) GetFromFamily(ctx context.Context, req *computepb.GetFromFamilyImageRequest, opts ...gax.CallOption) (*computepb.Image, error) List(ctx context.Context, req *computepb.ListImagesRequest, opts ...gax.CallOption) ComputeImageIterator } type computeImagesClient struct { imageClient *compute.ImagesClient } // NewComputeImagesClient creates a new ComputeImagesClient func NewComputeImagesClient(imageClient *compute.ImagesClient) ComputeImagesClient { return &computeImagesClient{ imageClient: imageClient, } } // Get retrieves a compute image func (c computeImagesClient) Get(ctx context.Context, req *computepb.GetImageRequest, opts ...gax.CallOption) (*computepb.Image, error) { return c.imageClient.Get(ctx, req, opts...) } // GetFromFamily retrieves the latest image from an image family func (c computeImagesClient) GetFromFamily(ctx context.Context, req *computepb.GetFromFamilyImageRequest, opts ...gax.CallOption) (*computepb.Image, error) { return c.imageClient.GetFromFamily(ctx, req, opts...) } // List lists compute images and returns an iterator func (c computeImagesClient) List(ctx context.Context, req *computepb.ListImagesRequest, opts ...gax.CallOption) ComputeImageIterator { return c.imageClient.List(ctx, req, opts...) } // ComputeInstanceGroupManagerIterator is an interface for iterating over instance group managers type ComputeInstanceGroupManagerIterator interface { Next() (*computepb.InstanceGroupManager, error) } // InstanceGroupManagersScopedListPairIterator is an interface for iterating over aggregated instance group manager list responses type InstanceGroupManagersScopedListPairIterator interface { Next() (compute.InstanceGroupManagersScopedListPair, error) } // ComputeInstanceGroupManagerClient is an interface for the Compute Instance Group Manager client type ComputeInstanceGroupManagerClient interface { Get(ctx context.Context, req *computepb.GetInstanceGroupManagerRequest, opts ...gax.CallOption) (*computepb.InstanceGroupManager, error) List(ctx context.Context, req *computepb.ListInstanceGroupManagersRequest, opts ...gax.CallOption) ComputeInstanceGroupManagerIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListInstanceGroupManagersRequest, opts ...gax.CallOption) InstanceGroupManagersScopedListPairIterator } type computeInstanceGroupManagerClient struct { instanceGroupManagersClient *compute.InstanceGroupManagersClient } // NewComputeInstanceGroupManagerClient creates a new ComputeInstanceGroupManagerClient func NewComputeInstanceGroupManagerClient(instanceGroupManagersClient *compute.InstanceGroupManagersClient) ComputeInstanceGroupManagerClient { return &computeInstanceGroupManagerClient{ instanceGroupManagersClient: instanceGroupManagersClient, } } // Get retrieves a compute instance group manager func (c computeInstanceGroupManagerClient) Get(ctx context.Context, req *computepb.GetInstanceGroupManagerRequest, opts ...gax.CallOption) (*computepb.InstanceGroupManager, error) { return c.instanceGroupManagersClient.Get(ctx, req, opts...) } // List lists compute instance group managers and returns an iterator func (c computeInstanceGroupManagerClient) List(ctx context.Context, req *computepb.ListInstanceGroupManagersRequest, opts ...gax.CallOption) ComputeInstanceGroupManagerIterator { return c.instanceGroupManagersClient.List(ctx, req, opts...) } // AggregatedList lists compute instance group managers across all zones using aggregated list func (c computeInstanceGroupManagerClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstanceGroupManagersRequest, opts ...gax.CallOption) InstanceGroupManagersScopedListPairIterator { return c.instanceGroupManagersClient.AggregatedList(ctx, req, opts...) } // RegionInstanceGroupManagerIterator is an interface for iterating over regional instance group managers type RegionInstanceGroupManagerIterator interface { Next() (*computepb.InstanceGroupManager, error) } // RegionInstanceGroupManagerClient is an interface for the Compute Region Instance Group Manager client type RegionInstanceGroupManagerClient interface { Get(ctx context.Context, req *computepb.GetRegionInstanceGroupManagerRequest, opts ...gax.CallOption) (*computepb.InstanceGroupManager, error) List(ctx context.Context, req *computepb.ListRegionInstanceGroupManagersRequest, opts ...gax.CallOption) RegionInstanceGroupManagerIterator } type regionInstanceGroupManagerClient struct { regionInstanceGroupManagersClient *compute.RegionInstanceGroupManagersClient } // NewRegionInstanceGroupManagerClient creates a new RegionInstanceGroupManagerClient func NewRegionInstanceGroupManagerClient(regionInstanceGroupManagersClient *compute.RegionInstanceGroupManagersClient) RegionInstanceGroupManagerClient { return ®ionInstanceGroupManagerClient{ regionInstanceGroupManagersClient: regionInstanceGroupManagersClient, } } // Get retrieves a regional compute instance group manager func (c regionInstanceGroupManagerClient) Get(ctx context.Context, req *computepb.GetRegionInstanceGroupManagerRequest, opts ...gax.CallOption) (*computepb.InstanceGroupManager, error) { return c.regionInstanceGroupManagersClient.Get(ctx, req, opts...) } // List lists regional compute instance group managers and returns an iterator func (c regionInstanceGroupManagerClient) List(ctx context.Context, req *computepb.ListRegionInstanceGroupManagersRequest, opts ...gax.CallOption) RegionInstanceGroupManagerIterator { return c.regionInstanceGroupManagersClient.List(ctx, req, opts...) } type ForwardingRuleIterator interface { Next() (*computepb.ForwardingRule, error) } // ForwardingRulesScopedListPairIterator is an interface for iterating over aggregated forwarding rule list responses type ForwardingRulesScopedListPairIterator interface { Next() (compute.ForwardingRulesScopedListPair, error) } // ComputeForwardingRuleClient is an interface for the Compute Engine Forwarding Rule client type ComputeForwardingRuleClient interface { Get(ctx context.Context, req *computepb.GetForwardingRuleRequest, opts ...gax.CallOption) (*computepb.ForwardingRule, error) List(ctx context.Context, req *computepb.ListForwardingRulesRequest, opts ...gax.CallOption) ForwardingRuleIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListForwardingRulesRequest, opts ...gax.CallOption) ForwardingRulesScopedListPairIterator } type computeForwardingRuleClient struct { client *compute.ForwardingRulesClient } func (c computeForwardingRuleClient) Get(ctx context.Context, req *computepb.GetForwardingRuleRequest, opts ...gax.CallOption) (*computepb.ForwardingRule, error) { return c.client.Get(ctx, req, opts...) } func (c computeForwardingRuleClient) List(ctx context.Context, req *computepb.ListForwardingRulesRequest, opts ...gax.CallOption) ForwardingRuleIterator { return c.client.List(ctx, req, opts...) } // AggregatedList lists compute forwarding rules across all regions using aggregated list func (c computeForwardingRuleClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListForwardingRulesRequest, opts ...gax.CallOption) ForwardingRulesScopedListPairIterator { return c.client.AggregatedList(ctx, req, opts...) } // NewComputeForwardingRuleClient creates a new ComputeForwardingRuleClient func NewComputeForwardingRuleClient(forwardingRuleClient *compute.ForwardingRulesClient) ComputeForwardingRuleClient { return &computeForwardingRuleClient{ client: forwardingRuleClient, } } // Interface for interating over compute autoscalers. type ComputeAutoscalerIterator interface { Next() (*computepb.Autoscaler, error) } // AutoscalersScopedListPairIterator is an interface for iterating over aggregated autoscaler list responses type AutoscalersScopedListPairIterator interface { Next() (compute.AutoscalersScopedListPair, error) } // Interface for accessing compute autoscaler resources. type ComputeAutoscalerClient interface { Get(ctx context.Context, req *computepb.GetAutoscalerRequest, opts ...gax.CallOption) (*computepb.Autoscaler, error) List(ctx context.Context, req *computepb.ListAutoscalersRequest, opts ...gax.CallOption) ComputeAutoscalerIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListAutoscalersRequest, opts ...gax.CallOption) AutoscalersScopedListPairIterator } // Wrapper for a ComputeAutoscalerClient implementation. type computeAutoscalerClient struct { autoscalerClient *compute.AutoscalersClient } // Create a ComputeAutoscalerClient from a real GCP client. func NewComputeAutoscalerClient(autoscalerClient *compute.AutoscalersClient) ComputeAutoscalerClient { return &computeAutoscalerClient{ autoscalerClient: autoscalerClient, } } func (c computeAutoscalerClient) Get(ctx context.Context, req *computepb.GetAutoscalerRequest, opts ...gax.CallOption) (*computepb.Autoscaler, error) { return c.autoscalerClient.Get(ctx, req, opts...) } func (c computeAutoscalerClient) List(ctx context.Context, req *computepb.ListAutoscalersRequest, opts ...gax.CallOption) ComputeAutoscalerIterator { return c.autoscalerClient.List(ctx, req, opts...) } // AggregatedList lists compute autoscalers across all zones using aggregated list func (c computeAutoscalerClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListAutoscalersRequest, opts ...gax.CallOption) AutoscalersScopedListPairIterator { return c.autoscalerClient.AggregatedList(ctx, req, opts...) } // ComputeBackendServiceIterator is an interface for iterating over compute backend services type ComputeBackendServiceIterator interface { Next() (*computepb.BackendService, error) } // BackendServicesScopedListPairIterator is an interface for iterating over aggregated backend service list responses type BackendServicesScopedListPairIterator interface { Next() (compute.BackendServicesScopedListPair, error) } // ComputeBackendServiceClient is an interface for the Compute Engine Backend Service client type ComputeBackendServiceClient interface { Get(ctx context.Context, req *computepb.GetBackendServiceRequest, opts ...gax.CallOption) (*computepb.BackendService, error) List(ctx context.Context, req *computepb.ListBackendServicesRequest, opts ...gax.CallOption) ComputeBackendServiceIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListBackendServicesRequest, opts ...gax.CallOption) BackendServicesScopedListPairIterator } type computeBackendServiceClient struct { client *compute.BackendServicesClient } func (c computeBackendServiceClient) Get(ctx context.Context, req *computepb.GetBackendServiceRequest, opts ...gax.CallOption) (*computepb.BackendService, error) { return c.client.Get(ctx, req, opts...) } func (c computeBackendServiceClient) List(ctx context.Context, req *computepb.ListBackendServicesRequest, opts ...gax.CallOption) ComputeBackendServiceIterator { return c.client.List(ctx, req, opts...) } // AggregatedList lists compute backend services across all regions (global and regional) using aggregated list func (c computeBackendServiceClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListBackendServicesRequest, opts ...gax.CallOption) BackendServicesScopedListPairIterator { return c.client.AggregatedList(ctx, req, opts...) } // NewComputeBackendServiceClient creates a new ComputeBackendServiceClient func NewComputeBackendServiceClient(backendServiceClient *compute.BackendServicesClient) ComputeBackendServiceClient { return &computeBackendServiceClient{ client: backendServiceClient, } } // ComputeInstanceGroupIterator is an interface for iterating over compute instance groups type ComputeInstanceGroupIterator interface { Next() (*computepb.InstanceGroup, error) } // InstanceGroupsScopedListPairIterator is an interface for iterating over aggregated instance group list responses type InstanceGroupsScopedListPairIterator interface { Next() (compute.InstanceGroupsScopedListPair, error) } // ComputeInstanceGroupsClient is an interface for the Compute Engine Instance Groups client type ComputeInstanceGroupsClient interface { Get(ctx context.Context, req *computepb.GetInstanceGroupRequest, opts ...gax.CallOption) (*computepb.InstanceGroup, error) List(ctx context.Context, req *computepb.ListInstanceGroupsRequest, opts ...gax.CallOption) ComputeInstanceGroupIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListInstanceGroupsRequest, opts ...gax.CallOption) InstanceGroupsScopedListPairIterator } type computeInstanceGroupsClient struct { client *compute.InstanceGroupsClient } // NewComputeInstanceGroupsClient creates a new ComputeInstanceGroupsClient func NewComputeInstanceGroupsClient(instanceGroupsClient *compute.InstanceGroupsClient) ComputeInstanceGroupsClient { return &computeInstanceGroupsClient{ client: instanceGroupsClient, } } // Get retrieves a compute instance group func (c computeInstanceGroupsClient) Get(ctx context.Context, req *computepb.GetInstanceGroupRequest, opts ...gax.CallOption) (*computepb.InstanceGroup, error) { return c.client.Get(ctx, req, opts...) } // List lists compute instance groups and returns an iterator func (c computeInstanceGroupsClient) List(ctx context.Context, req *computepb.ListInstanceGroupsRequest, opts ...gax.CallOption) ComputeInstanceGroupIterator { return c.client.List(ctx, req, opts...) } // AggregatedList lists compute instance groups across all zones using aggregated list func (c computeInstanceGroupsClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstanceGroupsRequest, opts ...gax.CallOption) InstanceGroupsScopedListPairIterator { return c.client.AggregatedList(ctx, req, opts...) } // Interface for interating over compute node groups. type ComputeNodeGroupIterator interface { Next() (*computepb.NodeGroup, error) } // NodeGroupsScopedListPairIterator is an interface for iterating over aggregated node group list responses type NodeGroupsScopedListPairIterator interface { Next() (compute.NodeGroupsScopedListPair, error) } // Interface for accessing compute NodeGroup resources. type ComputeNodeGroupClient interface { Get(ctx context.Context, req *computepb.GetNodeGroupRequest, opts ...gax.CallOption) (*computepb.NodeGroup, error) List(ctx context.Context, req *computepb.ListNodeGroupsRequest, opts ...gax.CallOption) ComputeNodeGroupIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListNodeGroupsRequest, opts ...gax.CallOption) NodeGroupsScopedListPairIterator } // Wrapper for a ComputeNodeGroupClient implementation. type computeNodeGroupClient struct { nodeGroupClient *compute.NodeGroupsClient } // Create a ComputeNodeGroupClient from a real GCP client. func NewComputeNodeGroupClient(NodeGroupClient *compute.NodeGroupsClient) ComputeNodeGroupClient { return &computeNodeGroupClient{ nodeGroupClient: NodeGroupClient, } } func (c computeNodeGroupClient) Get(ctx context.Context, req *computepb.GetNodeGroupRequest, opts ...gax.CallOption) (*computepb.NodeGroup, error) { return c.nodeGroupClient.Get(ctx, req, opts...) } func (c computeNodeGroupClient) List(ctx context.Context, req *computepb.ListNodeGroupsRequest, opts ...gax.CallOption) ComputeNodeGroupIterator { return c.nodeGroupClient.List(ctx, req, opts...) } // AggregatedList lists compute node groups across all zones using aggregated list func (c computeNodeGroupClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListNodeGroupsRequest, opts ...gax.CallOption) NodeGroupsScopedListPairIterator { return c.nodeGroupClient.AggregatedList(ctx, req, opts...) } // ComputeHealthCheckIterator is an interface for iterating over compute health checks type ComputeHealthCheckIterator interface { Next() (*computepb.HealthCheck, error) } // HealthChecksScopedListPairIterator is an interface for iterating over aggregated health check list responses type HealthChecksScopedListPairIterator interface { Next() (compute.HealthChecksScopedListPair, error) } // ComputeHealthCheckClient is an interface for the Compute Engine Health Checks client type ComputeHealthCheckClient interface { Get(ctx context.Context, req *computepb.GetHealthCheckRequest, opts ...gax.CallOption) (*computepb.HealthCheck, error) List(ctx context.Context, req *computepb.ListHealthChecksRequest, opts ...gax.CallOption) ComputeHealthCheckIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListHealthChecksRequest, opts ...gax.CallOption) HealthChecksScopedListPairIterator } type computeHealthCheckClient struct { client *compute.HealthChecksClient } // NewComputeHealthCheckClient creates a new ComputeHealthCheckClient func NewComputeHealthCheckClient(healthChecksClient *compute.HealthChecksClient) ComputeHealthCheckClient { return &computeHealthCheckClient{ client: healthChecksClient, } } // Get retrieves a compute health check func (c computeHealthCheckClient) Get(ctx context.Context, req *computepb.GetHealthCheckRequest, opts ...gax.CallOption) (*computepb.HealthCheck, error) { return c.client.Get(ctx, req, opts...) } // List lists compute health checks and returns an iterator func (c computeHealthCheckClient) List(ctx context.Context, req *computepb.ListHealthChecksRequest, opts ...gax.CallOption) ComputeHealthCheckIterator { return c.client.List(ctx, req, opts...) } // AggregatedList lists compute health checks across all regions (global and regional) using aggregated list func (c computeHealthCheckClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListHealthChecksRequest, opts ...gax.CallOption) HealthChecksScopedListPairIterator { return c.client.AggregatedList(ctx, req, opts...) } // Interface for iterating over regional compute health checks. type ComputeRegionHealthCheckIterator interface { Next() (*computepb.HealthCheck, error) } // Interface for accessing regional compute HealthCheck resources. type ComputeRegionHealthCheckClient interface { Get(ctx context.Context, req *computepb.GetRegionHealthCheckRequest, opts ...gax.CallOption) (*computepb.HealthCheck, error) List(ctx context.Context, req *computepb.ListRegionHealthChecksRequest, opts ...gax.CallOption) ComputeRegionHealthCheckIterator } type computeRegionHealthCheckClient struct { client *compute.RegionHealthChecksClient } // NewComputeRegionHealthCheckClient creates a new ComputeRegionHealthCheckClient func NewComputeRegionHealthCheckClient(regionHealthChecksClient *compute.RegionHealthChecksClient) ComputeRegionHealthCheckClient { return &computeRegionHealthCheckClient{ client: regionHealthChecksClient, } } // Get retrieves a regional compute health check func (c computeRegionHealthCheckClient) Get(ctx context.Context, req *computepb.GetRegionHealthCheckRequest, opts ...gax.CallOption) (*computepb.HealthCheck, error) { return c.client.Get(ctx, req, opts...) } // List lists regional compute health checks and returns an iterator func (c computeRegionHealthCheckClient) List(ctx context.Context, req *computepb.ListRegionHealthChecksRequest, opts ...gax.CallOption) ComputeRegionHealthCheckIterator { return c.client.List(ctx, req, opts...) } // Interface for interating over compute node templates. type ComputeNodeTemplateIterator interface { Next() (*computepb.NodeTemplate, error) } // NodeTemplatesScopedListPairIterator is an interface for iterating over aggregated node template list responses type NodeTemplatesScopedListPairIterator interface { Next() (compute.NodeTemplatesScopedListPair, error) } // Interface for accessing compute NodeTemplate resources. type ComputeNodeTemplateClient interface { Get(ctx context.Context, req *computepb.GetNodeTemplateRequest, opts ...gax.CallOption) (*computepb.NodeTemplate, error) List(ctx context.Context, req *computepb.ListNodeTemplatesRequest, opts ...gax.CallOption) ComputeNodeTemplateIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListNodeTemplatesRequest, opts ...gax.CallOption) NodeTemplatesScopedListPairIterator } // Wrapper for a ComputeNodeTemplateClient implementation. type computeNodeTemplateClient struct { nodeTemplateClient *compute.NodeTemplatesClient } // Create a ComputeNodeTemplateClient from a real GCP client. func NewComputeNodeTemplateClient(NodeTemplateClient *compute.NodeTemplatesClient) ComputeNodeTemplateClient { return &computeNodeTemplateClient{ nodeTemplateClient: NodeTemplateClient, } } func (c computeNodeTemplateClient) Get(ctx context.Context, req *computepb.GetNodeTemplateRequest, opts ...gax.CallOption) (*computepb.NodeTemplate, error) { return c.nodeTemplateClient.Get(ctx, req, opts...) } func (c computeNodeTemplateClient) List(ctx context.Context, req *computepb.ListNodeTemplatesRequest, opts ...gax.CallOption) ComputeNodeTemplateIterator { return c.nodeTemplateClient.List(ctx, req, opts...) } // AggregatedList lists compute node templates across all regions using aggregated list func (c computeNodeTemplateClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListNodeTemplatesRequest, opts ...gax.CallOption) NodeTemplatesScopedListPairIterator { return c.nodeTemplateClient.AggregatedList(ctx, req, opts...) } // ComputeReservationIterator is an interface for iterating over compute reservations type ComputeReservationIterator interface { Next() (*computepb.Reservation, error) } // ReservationsScopedListPairIterator is an interface for iterating over aggregated reservation list responses type ReservationsScopedListPairIterator interface { Next() (compute.ReservationsScopedListPair, error) } // ComputeReservationClient is an interface for the Compute Engine Reservations client type ComputeReservationClient interface { Get(ctx context.Context, req *computepb.GetReservationRequest, opts ...gax.CallOption) (*computepb.Reservation, error) List(ctx context.Context, req *computepb.ListReservationsRequest, opts ...gax.CallOption) ComputeReservationIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListReservationsRequest, opts ...gax.CallOption) ReservationsScopedListPairIterator } type computeReservationClient struct { client *compute.ReservationsClient } // NewComputeReservationClient creates a new ComputeReservationClient func NewComputeReservationClient(reservationsClient *compute.ReservationsClient) ComputeReservationClient { return &computeReservationClient{ client: reservationsClient, } } // Get retrieves a compute reservation func (c computeReservationClient) Get(ctx context.Context, req *computepb.GetReservationRequest, opts ...gax.CallOption) (*computepb.Reservation, error) { return c.client.Get(ctx, req, opts...) } // List lists compute reservations and returns an iterator func (c computeReservationClient) List(ctx context.Context, req *computepb.ListReservationsRequest, opts ...gax.CallOption) ComputeReservationIterator { return c.client.List(ctx, req, opts...) } // AggregatedList lists compute reservations across all zones using aggregated list func (c computeReservationClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListReservationsRequest, opts ...gax.CallOption) ReservationsScopedListPairIterator { return c.client.AggregatedList(ctx, req, opts...) } // ComputeSecurityPolicyIterator is an interface for iterating over compute security policies type ComputeSecurityPolicyIterator interface { Next() (*computepb.SecurityPolicy, error) } // ComputeSecurityPolicyClient is an interface for the Compute Security Policies client type ComputeSecurityPolicyClient interface { Get(ctx context.Context, req *computepb.GetSecurityPolicyRequest, opts ...gax.CallOption) (*computepb.SecurityPolicy, error) List(ctx context.Context, req *computepb.ListSecurityPoliciesRequest, opts ...gax.CallOption) ComputeSecurityPolicyIterator } type computeSecurityPolicyClient struct { client *compute.SecurityPoliciesClient } // NewComputeSecurityPolicyClient creates a new ComputeSecurityPolicyClient func NewComputeSecurityPolicyClient(securityPolicyClient *compute.SecurityPoliciesClient) ComputeSecurityPolicyClient { return &computeSecurityPolicyClient{ client: securityPolicyClient, } } // Get retrieves a compute security policy func (c computeSecurityPolicyClient) Get(ctx context.Context, req *computepb.GetSecurityPolicyRequest, opts ...gax.CallOption) (*computepb.SecurityPolicy, error) { return c.client.Get(ctx, req, opts...) } // List lists compute security policies and returns an iterator func (c computeSecurityPolicyClient) List(ctx context.Context, req *computepb.ListSecurityPoliciesRequest, opts ...gax.CallOption) ComputeSecurityPolicyIterator { return c.client.List(ctx, req, opts...) } // ComputeInstantSnapshotIterator is an interface for iterating over compute instant snapshots type ComputeInstantSnapshotIterator interface { Next() (*computepb.InstantSnapshot, error) } // InstantSnapshotsScopedListPairIterator is an interface for iterating over aggregated instant snapshot list responses type InstantSnapshotsScopedListPairIterator interface { Next() (compute.InstantSnapshotsScopedListPair, error) } // ComputeInstantSnapshotsClient is an interface for the Compute Instant Snapshots client type ComputeInstantSnapshotsClient interface { Get(ctx context.Context, req *computepb.GetInstantSnapshotRequest, opts ...gax.CallOption) (*computepb.InstantSnapshot, error) List(ctx context.Context, req *computepb.ListInstantSnapshotsRequest, opts ...gax.CallOption) ComputeInstantSnapshotIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListInstantSnapshotsRequest, opts ...gax.CallOption) InstantSnapshotsScopedListPairIterator } type computeInstantSnapshotsClient struct { client *compute.InstantSnapshotsClient } // NewComputeInstantSnapshotsClient creates a new ComputeInstantSnapshotsClient func NewComputeInstantSnapshotsClient(instantSnapshotsClient *compute.InstantSnapshotsClient) ComputeInstantSnapshotsClient { return &computeInstantSnapshotsClient{ client: instantSnapshotsClient, } } // Get retrieves a compute instant snapshot func (c computeInstantSnapshotsClient) Get(ctx context.Context, req *computepb.GetInstantSnapshotRequest, opts ...gax.CallOption) (*computepb.InstantSnapshot, error) { return c.client.Get(ctx, req, opts...) } // List lists compute instant snapshots and returns an iterator func (c computeInstantSnapshotsClient) List(ctx context.Context, req *computepb.ListInstantSnapshotsRequest, opts ...gax.CallOption) ComputeInstantSnapshotIterator { return c.client.List(ctx, req, opts...) } // AggregatedList lists compute instant snapshots across all zones using aggregated list func (c computeInstantSnapshotsClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstantSnapshotsRequest, opts ...gax.CallOption) InstantSnapshotsScopedListPairIterator { return c.client.AggregatedList(ctx, req, opts...) } // ComputeDiskIterator is an interface for iterating over compute disks type ComputeDiskIterator interface { Next() (*computepb.Disk, error) } // DisksScopedListPairIterator is an interface for iterating over aggregated disk list responses type DisksScopedListPairIterator interface { Next() (compute.DisksScopedListPair, error) } // ComputeDiskClient is an interface for the Compute Engine Disk client type ComputeDiskClient interface { Get(ctx context.Context, req *computepb.GetDiskRequest, opts ...gax.CallOption) (*computepb.Disk, error) List(ctx context.Context, req *computepb.ListDisksRequest, opts ...gax.CallOption) ComputeDiskIterator AggregatedList(ctx context.Context, req *computepb.AggregatedListDisksRequest, opts ...gax.CallOption) DisksScopedListPairIterator } type computeDiskClient struct { client *compute.DisksClient } // NewComputeDiskClient creates a new ComputeDiskClient func NewComputeDiskClient(client *compute.DisksClient) ComputeDiskClient { return &computeDiskClient{ client: client, } } // Get retrieves a compute disk func (c computeDiskClient) Get(ctx context.Context, req *computepb.GetDiskRequest, opts ...gax.CallOption) (*computepb.Disk, error) { return c.client.Get(ctx, req, opts...) } // List lists compute disks and returns an iterator func (c computeDiskClient) List(ctx context.Context, req *computepb.ListDisksRequest, opts ...gax.CallOption) ComputeDiskIterator { return c.client.List(ctx, req, opts...) } // AggregatedList lists compute disks across all zones using aggregated list func (c computeDiskClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListDisksRequest, opts ...gax.CallOption) DisksScopedListPairIterator { return c.client.AggregatedList(ctx, req, opts...) } // ComputeMachineImageIterator is an interface for iterating over compute machine images type ComputeMachineImageIterator interface { Next() (*computepb.MachineImage, error) } // ComputeMachineImageClient is an interface for the Compute Engine Machine Images client type ComputeMachineImageClient interface { Get(ctx context.Context, req *computepb.GetMachineImageRequest, opts ...gax.CallOption) (*computepb.MachineImage, error) List(ctx context.Context, req *computepb.ListMachineImagesRequest, opts ...gax.CallOption) ComputeMachineImageIterator } type computeMachineImageClient struct { client *compute.MachineImagesClient } // NewComputeMachineImageClient creates a new ComputeMachineImageClient func NewComputeMachineImageClient(machineImageClient *compute.MachineImagesClient) ComputeMachineImageClient { return &computeMachineImageClient{ client: machineImageClient, } } // Get retrieves a compute machine image func (c computeMachineImageClient) Get(ctx context.Context, req *computepb.GetMachineImageRequest, opts ...gax.CallOption) (*computepb.MachineImage, error) { return c.client.Get(ctx, req, opts...) } // List lists compute machine images and returns an iterator func (c computeMachineImageClient) List(ctx context.Context, req *computepb.ListMachineImagesRequest, opts ...gax.CallOption) ComputeMachineImageIterator { return c.client.List(ctx, req, opts...) } // ComputeSnapshotIterator is an interface for iterating over compute snapshots type ComputeSnapshotIterator interface { Next() (*computepb.Snapshot, error) } // ComputeSnapshotsClient is an interface for the Compute Snapshots client type ComputeSnapshotsClient interface { Get(ctx context.Context, req *computepb.GetSnapshotRequest, opts ...gax.CallOption) (*computepb.Snapshot, error) List(ctx context.Context, req *computepb.ListSnapshotsRequest, opts ...gax.CallOption) ComputeSnapshotIterator } type computeSnapshotsClient struct { snapshotClient *compute.SnapshotsClient } // NewComputeSnapshotsClient creates a new ComputeSnapshotsClient func NewComputeSnapshotsClient(snapshotClient *compute.SnapshotsClient) ComputeSnapshotsClient { return &computeSnapshotsClient{ snapshotClient: snapshotClient, } } // Get retrieves a compute snapshot func (c computeSnapshotsClient) Get(ctx context.Context, req *computepb.GetSnapshotRequest, opts ...gax.CallOption) (*computepb.Snapshot, error) { return c.snapshotClient.Get(ctx, req, opts...) } // List lists compute snapshots and returns an iterator func (c computeSnapshotsClient) List(ctx context.Context, req *computepb.ListSnapshotsRequest, opts ...gax.CallOption) ComputeSnapshotIterator { return c.snapshotClient.List(ctx, req, opts...) } // ComputeRegionBackendServiceIterator is an interface for iterating over compute region backend services type ComputeRegionBackendServiceIterator interface { Next() (*computepb.BackendService, error) } // ComputeRegionBackendServiceClient is an interface for the Compute Engine Region Backend Service client type ComputeRegionBackendServiceClient interface { Get(ctx context.Context, req *computepb.GetRegionBackendServiceRequest, opts ...gax.CallOption) (*computepb.BackendService, error) List(ctx context.Context, req *computepb.ListRegionBackendServicesRequest, opts ...gax.CallOption) ComputeRegionBackendServiceIterator } type computeRegionBackendServiceClient struct { client *compute.RegionBackendServicesClient } func (c computeRegionBackendServiceClient) Get(ctx context.Context, req *computepb.GetRegionBackendServiceRequest, opts ...gax.CallOption) (*computepb.BackendService, error) { return c.client.Get(ctx, req, opts...) } func (c computeRegionBackendServiceClient) List(ctx context.Context, req *computepb.ListRegionBackendServicesRequest, opts ...gax.CallOption) ComputeRegionBackendServiceIterator { return c.client.List(ctx, req, opts...) } // NewComputeRegionBackendServiceClient creates a new ComputeRegionBackendServiceClient func NewComputeRegionBackendServiceClient(regionBackendServiceClient *compute.RegionBackendServicesClient) ComputeRegionBackendServiceClient { return &computeRegionBackendServiceClient{ client: regionBackendServiceClient, } } ================================================ FILE: sources/gcp/shared/cross_project_linking_test.go ================================================ package shared import ( "reflect" "testing" "github.com/overmindtech/cli/go/sdp-go" ) // TestProjectBaseLinkedItemQueryByName_CrossProject verifies that project-level // resources correctly extract the project ID from cross-project URIs func TestProjectBaseLinkedItemQueryByName_CrossProject(t *testing.T) { tests := []struct { name string projectID string query string want *sdp.LinkedItemQuery description string }{ { name: "Same project - simple resource name", projectID: "my-project", query: "my-image", description: "Simple resource name without project prefix", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: "my-image", Scope: "my-project", }, }, }, { name: "Same project - full resource URI", projectID: "my-project", query: "projects/my-project/global/images/my-image", description: "Full resource URI with same project as context", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: "my-image", Scope: "my-project", }, }, }, { name: "Cross-project - full resource URI", projectID: "box-dev-clamav", query: "projects/box-dev-baseos/global/images/family/pcs-clamav-box", description: "Cross-project reference - should extract project from URI (ENG-2271 bug fix)", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: "pcs-clamav-box", Scope: "box-dev-baseos", // Should use extracted project, not context project }, }, }, { name: "Cross-project - HTTPS URL", projectID: "my-project", query: "https://www.googleapis.com/compute/v1/projects/other-project/global/images/other-image", description: "Cross-project reference with HTTPS URL", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeImage.String(), Method: sdp.QueryMethod_GET, Query: "other-image", Scope: "other-project", // Should use extracted project, not context project }, }, }, { name: "Empty query", projectID: "my-project", query: "", description: "Empty query should return nil", want: nil, }, { name: "Empty project ID", projectID: "", query: "my-image", description: "Empty project ID should return nil", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { linkerFunc := ProjectBaseLinkedItemQueryByName(ComputeImage) got := linkerFunc(tt.projectID, "", tt.query) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ProjectBaseLinkedItemQueryByName() = %v, want %v\nDescription: %s", got, tt.want, tt.description) } }) } } // TestRegionBaseLinkedItemQueryByName_CrossProject verifies that regional // resources correctly extract the project ID from cross-project URIs func TestRegionBaseLinkedItemQueryByName_CrossProject(t *testing.T) { tests := []struct { name string projectID string fromItemScope string query string want *sdp.LinkedItemQuery description string }{ { name: "Same project - full resource URI", projectID: "my-project", fromItemScope: "my-project.us-central1", query: "projects/my-project/regions/us-central1/addresses/my-address", description: "Regional resource with same project", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeAddress.String(), Method: sdp.QueryMethod_GET, Query: "my-address", Scope: "my-project.us-central1", }, }, }, { name: "Cross-project - full resource URI", projectID: "my-project", fromItemScope: "my-project.us-central1", query: "projects/other-project/regions/europe-west1/addresses/other-address", description: "Cross-project regional resource - should extract project from URI", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeAddress.String(), Method: sdp.QueryMethod_GET, Query: "other-address", Scope: "other-project.europe-west1", // Should use extracted project, not context project }, }, }, { name: "Empty query", projectID: "my-project", fromItemScope: "my-project.us-central1", query: "", description: "Empty query should return nil", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { linkerFunc := RegionBaseLinkedItemQueryByName(ComputeAddress) got := linkerFunc(tt.projectID, tt.fromItemScope, tt.query) if !reflect.DeepEqual(got, tt.want) { t.Errorf("RegionBaseLinkedItemQueryByName() = %v, want %v\nDescription: %s", got, tt.want, tt.description) } }) } } // TestZoneBaseLinkedItemQueryByName_CrossProject verifies that zonal // resources correctly extract the project ID from cross-project URIs func TestZoneBaseLinkedItemQueryByName_CrossProject(t *testing.T) { tests := []struct { name string projectID string fromItemScope string query string want *sdp.LinkedItemQuery description string }{ { name: "Same project - full resource URI", projectID: "my-project", fromItemScope: "my-project.us-central1-a", query: "projects/my-project/zones/us-central1-a/disks/my-disk", description: "Zonal resource with same project", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: "my-disk", Scope: "my-project.us-central1-a", }, }, }, { name: "Cross-project - full resource URI", projectID: "my-project", fromItemScope: "my-project.us-central1-a", query: "projects/other-project/zones/europe-west1-b/disks/other-disk", description: "Cross-project zonal resource - should extract project from URI", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeDisk.String(), Method: sdp.QueryMethod_GET, Query: "other-disk", Scope: "other-project.europe-west1-b", // Should use extracted project, not context project }, }, }, { name: "Empty query", projectID: "my-project", fromItemScope: "my-project.us-central1-a", query: "", description: "Empty query should return nil", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { linkerFunc := ZoneBaseLinkedItemQueryByName(ComputeDisk) got := linkerFunc(tt.projectID, tt.fromItemScope, tt.query) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ZoneBaseLinkedItemQueryByName() = %v, want %v\nDescription: %s", got, tt.want, tt.description) } }) } } ================================================ FILE: sources/gcp/shared/errors.go ================================================ package shared import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/overmindtech/cli/go/sdp-go" ) // QueryError is a helper function to convert errors into sdp.QueryError func QueryError(err error, scope string, itemType string) *sdp.QueryError { // Check if the error is a gRPC `not_found` error if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { return &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), SourceName: "gcp-source", Scope: scope, ItemType: itemType, } } return &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), SourceName: "gcp-source", Scope: scope, ItemType: itemType, } } ================================================ FILE: sources/gcp/shared/gcp-http-client.go ================================================ package shared import ( "context" "fmt" "net/http" "cloud.google.com/go/auth/credentials" "cloud.google.com/go/auth/credentials/impersonate" "cloud.google.com/go/auth/httptransport" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // GCPHTTPClientWithOtel creates a new HTTP client for GCP with OpenTelemetry instrumentation. // If impersonationServiceAccountEmail is non-empty, it will impersonate that service account. func GCPHTTPClientWithOtel(ctx context.Context, impersonationServiceAccountEmail string) (*http.Client, error) { // Use default credentials creds, err := credentials.DetectDefault(&credentials.DetectOptions{ // Broad access to all GCP resources // It is restricted by the IAM permissions of the service account Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, }) if err != nil { return nil, fmt.Errorf("failed to detect default credentials: %w", err) } if impersonationServiceAccountEmail != "" { // Use impersonation credentials creds, err = impersonate.NewCredentials(&impersonate.CredentialsOptions{ TargetPrincipal: impersonationServiceAccountEmail, // Broad access to all GCP resources // It is restricted by the IAM permissions of the service account Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, // piggy-back on top of the detected default credentials Credentials: creds, }) if err != nil { return nil, fmt.Errorf("failed to create impersonated credentials: %w", err) } } gcpHTTPCli, err := httptransport.NewClient(&httptransport.Options{ Credentials: creds, BaseRoundTripper: otelhttp.NewTransport(nil), }) if err != nil { return nil, fmt.Errorf("failed to create HTTP client with credentials: %w", err) } return gcpHTTPCli, nil } ================================================ FILE: sources/gcp/shared/iam-clients.go ================================================ package shared import ( "context" admin "cloud.google.com/go/iam/admin/apiv1" "cloud.google.com/go/iam/admin/apiv1/adminpb" "github.com/googleapis/gax-go/v2" ) // IAMServiceAccountClient interface for IAM ServiceAccount operations type IAMServiceAccountClient interface { Get(ctx context.Context, req *adminpb.GetServiceAccountRequest, opts ...gax.CallOption) (*adminpb.ServiceAccount, error) List(ctx context.Context, req *adminpb.ListServiceAccountsRequest, opts ...gax.CallOption) IAMServiceAccountIterator } type IAMServiceAccountIterator interface { Next() (*adminpb.ServiceAccount, error) } type iamServiceAccountClient struct { client *admin.IamClient } func (c *iamServiceAccountClient) Get(ctx context.Context, req *adminpb.GetServiceAccountRequest, opts ...gax.CallOption) (*adminpb.ServiceAccount, error) { return c.client.GetServiceAccount(ctx, req, opts...) } func (c *iamServiceAccountClient) List(ctx context.Context, req *adminpb.ListServiceAccountsRequest, opts ...gax.CallOption) IAMServiceAccountIterator { return c.client.ListServiceAccounts(ctx, req, opts...) } // NewIAMServiceAccountClient creates a new IAMServiceAccountClient func NewIAMServiceAccountClient(client *admin.IamClient) IAMServiceAccountClient { return &iamServiceAccountClient{ client: client, } } // IAMServiceAccountKeyClient defines the interface for ServiceAccountKey operations type IAMServiceAccountKeyClient interface { Get(ctx context.Context, req *adminpb.GetServiceAccountKeyRequest, opts ...gax.CallOption) (*adminpb.ServiceAccountKey, error) Search(ctx context.Context, req *adminpb.ListServiceAccountKeysRequest, opts ...gax.CallOption) (*adminpb.ListServiceAccountKeysResponse, error) } type iamServiceAccountKeyClient struct { client *admin.IamClient } func (c iamServiceAccountKeyClient) Get(ctx context.Context, req *adminpb.GetServiceAccountKeyRequest, opts ...gax.CallOption) (*adminpb.ServiceAccountKey, error) { return c.client.GetServiceAccountKey(ctx, req, opts...) } func (c iamServiceAccountKeyClient) Search(ctx context.Context, req *adminpb.ListServiceAccountKeysRequest, opts ...gax.CallOption) (*adminpb.ListServiceAccountKeysResponse, error) { return c.client.ListServiceAccountKeys(ctx, req, opts...) } // NewIAMServiceAccountKeyClient creates a new IAMServiceAccountKeyClient func NewIAMServiceAccountKeyClient(client *admin.IamClient) IAMServiceAccountKeyClient { return &iamServiceAccountKeyClient{ client: client, } } ================================================ FILE: sources/gcp/shared/init_test.go ================================================ package shared_test import ( _ "github.com/overmindtech/cli/sources/gcp/dynamic/adapters" // Import all adapters to register them ) // This file ensures that all adapters are registered before running tests in the shared package. // The package is "shared_test" (not "shared") to avoid import cycle issues. ================================================ FILE: sources/gcp/shared/item-types.go ================================================ package shared import "github.com/overmindtech/cli/sources/shared" var ( ComputeInstance = shared.NewItemType(GCP, Compute, Instance) ComputeInstanceTemplate = shared.NewItemType(GCP, Compute, InstanceTemplate) ComputeMachineImage = shared.NewItemType(GCP, Compute, MachineImage) ComputeInstanceGroupManager = shared.NewItemType(GCP, Compute, InstanceGroupManager) ComputeRegionInstanceGroupManager = shared.NewItemType(GCP, Compute, RegionalInstanceGroupManager) ComputeSubnetwork = shared.NewItemType(GCP, Compute, Subnetwork) ComputeNetwork = shared.NewItemType(GCP, Compute, Network) ComputeImage = shared.NewItemType(GCP, Compute, Image) ComputeDisk = shared.NewItemType(GCP, Compute, Disk) ComputeDiskType = shared.NewItemType(GCP, Compute, DiskType) ComputeAutoscaler = shared.NewItemType(GCP, Compute, Autoscaler) ComputeResourcePolicy = shared.NewItemType(GCP, Compute, ResourcePolicy) ComputeSnapshot = shared.NewItemType(GCP, Compute, Snapshot) ComputeInstanceGroup = shared.NewItemType(GCP, Compute, InstanceGroup) ComputeFirewall = shared.NewItemType(GCP, Compute, Firewall) ComputeRoute = shared.NewItemType(GCP, Compute, Route) ComputeAddress = shared.NewItemType(GCP, Compute, Address) ComputeInstantSnapshot = shared.NewItemType(GCP, Compute, InstantSnapshot) ComputeReservation = shared.NewItemType(GCP, Compute, Reservation) ComputeHealthCheck = shared.NewItemType(GCP, Compute, HealthCheck) ComputeHttpHealthCheck = shared.NewItemType(GCP, Compute, HttpHealthCheck) ComputeNodeGroup = shared.NewItemType(GCP, Compute, NodeGroup) ComputeNodeTemplate = shared.NewItemType(GCP, Compute, NodeTemplate) ComputeBackendService = shared.NewItemType(GCP, Compute, BackendService) ComputeBackendBucket = shared.NewItemType(GCP, Compute, BackendBucket) ComputeSecurityPolicy = shared.NewItemType(GCP, Compute, SecurityPolicy) NetworkSecurityClientTlsPolicy = shared.NewItemType(GCP, NetworkSecurity, ClientTlsPolicy) NetworkServicesServiceLbPolicy = shared.NewItemType(GCP, NetworkServices, ServiceLbPolicy) NetworkServicesServiceBinding = shared.NewItemType(GCP, NetworkServices, ServiceBinding) ComputeForwardingRule = shared.NewItemType(GCP, Compute, ForwardingRule) ComputeGlobalForwardingRule = shared.NewItemType(GCP, Compute, GlobalForwardingRule) ComputeUrlMap = shared.NewItemType(GCP, Compute, UrlMap) ComputeTargetPool = shared.NewItemType(GCP, Compute, TargetPool) ComputeLicense = shared.NewItemType(GCP, Compute, License) CloudKMSCryptoKeyVersion = shared.NewItemType(GCP, CloudKMS, CryptoKeyVersion) ComputeRegionCommitment = shared.NewItemType(GCP, Compute, RegionCommitment) ComputeAcceleratorType = shared.NewItemType(GCP, Compute, AcceleratorType) ComputeRule = shared.NewItemType(GCP, Compute, Rule) IAMServiceAccountKey = shared.NewItemType(GCP, IAM, ServiceAccountKey) IAMServiceAccount = shared.NewItemType(GCP, IAM, ServiceAccount) BigQueryTable = shared.NewItemType(GCP, BigQuery, Table) BigQueryDataset = shared.NewItemType(GCP, BigQuery, Dataset) BigQueryDataTransferTransferConfig = shared.NewItemType(GCP, BigQueryDataTransfer, TransferConfig) BigQueryDataTransferTransferRun = shared.NewItemType(GCP, BigQueryDataTransfer, TransferRun) BigQueryDataTransferDataSource = shared.NewItemType(GCP, BigQueryDataTransfer, DataSource) BigQueryRoutine = shared.NewItemType(GCP, BigQuery, Routine) StorageTransferTransferJob = shared.NewItemType(GCP, StorageTransfer, TransferJob) StorageTransferTransferOperation = shared.NewItemType(GCP, StorageTransfer, TransferOperation) // https://cloud.google.com/storage-transfer/docs/reference/rest/v1/transferOperations/get StorageTransferAgentPool = shared.NewItemType(GCP, StorageTransfer, AgentPool) // https://cloud.google.com/storage-transfer/docs/reference/rest/v1/projects.agentPools/get PubSubSubscription = shared.NewItemType(GCP, PubSub, Subscription) PubSubTopic = shared.NewItemType(GCP, PubSub, Topic) PubSubSchema = shared.NewItemType(GCP, PubSub, Schema) CloudResourceManagerProject = shared.NewItemType(GCP, CloudResourceManager, Project) CloudResourceManagerFolder = shared.NewItemType(GCP, CloudResourceManager, Folder) CloudResourceManagerOrganization = shared.NewItemType(GCP, CloudResourceManager, Organization) CloudKMSKeyRing = shared.NewItemType(GCP, CloudKMS, KeyRing) IAMPolicy = shared.NewItemType(GCP, IAM, Policy) ComputeInstanceSettings = shared.NewItemType(GCP, Compute, InstanceSettings) ComputeProject = shared.NewItemType(GCP, Compute, Project) StorageBucket = shared.NewItemType(GCP, Storage, Bucket) StorageBucketAccessControl = shared.NewItemType(GCP, Storage, BucketAccessControl) StorageDefaultObjectAccessControl = shared.NewItemType(GCP, Storage, DefaultObjectAccessControl) StorageNotificationConfig = shared.NewItemType(GCP, Storage, NotificationConfig) StorageBucketIAMPolicy = shared.NewItemType(GCP, Storage, BucketIAMPolicy) ComputeNetworkAttachment = shared.NewItemType(GCP, Compute, NetworkAttachment) ComputeStoragePool = shared.NewItemType(GCP, Compute, StoragePool) ComputeStoragePoolType = shared.NewItemType(GCP, Compute, StoragePoolType) ComputeZone = shared.NewItemType(GCP, Compute, Zone) ComputeRegion = shared.NewItemType(GCP, Compute, Region) ComputeVpnTunnel = shared.NewItemType(GCP, Compute, VpnTunnel) ComputeNetworkPeering = shared.NewItemType(GCP, Compute, NetworkPeering) ComputeGateway = shared.NewItemType(GCP, Compute, Gateway) AIPlatformCustomJob = shared.NewItemType(GCP, AIPlatform, CustomJob) AIPlatformPipelineJob = shared.NewItemType(GCP, AIPlatform, PipelineJob) IAMRole = shared.NewItemType(GCP, IAM, Role) BigTableAdminAppProfile = shared.NewItemType(GCP, BigTableAdmin, AppProfile) BigTableAdminBackup = shared.NewItemType(GCP, BigTableAdmin, Backup) BigTableAdminTable = shared.NewItemType(GCP, BigTableAdmin, Table) CloudBuildBuild = shared.NewItemType(GCP, CloudBuild, Build) DataplexEntryGroup = shared.NewItemType(GCP, DataPlex, EntryGroup) DataplexAspectType = shared.NewItemType(GCP, DataPlex, AspectType) DataplexDataScan = shared.NewItemType(GCP, DataPlex, DataScan) DataplexEntity = shared.NewItemType(GCP, DataPlex, Entity) ServiceUsageService = shared.NewItemType(GCP, ServiceUsage, Service) RunRevision = shared.NewItemType(GCP, Run, Revision) SQLAdminBackup = shared.NewItemType(GCP, SqlAdmin, Backup) SQLAdminBackupRun = shared.NewItemType(GCP, SqlAdmin, BackupRun) SQLAdminDatabase = shared.NewItemType(GCP, SqlAdmin, Database) SQLAdminUser = shared.NewItemType(GCP, SqlAdmin, User) SQLAdminSSLCert = shared.NewItemType(GCP, SqlAdmin, SSLCertificate) MonitoringCustomDashboard = shared.NewItemType(GCP, Monitoring, CustomDashboard) MonitoringNotificationChannel = shared.NewItemType(GCP, Monitoring, NotificationChannel) ArtifactRegistryDockerImage = shared.NewItemType(GCP, ArtifactRegistry, DockerImage) ArtifactRegistryPackage = shared.NewItemType(GCP, ArtifactRegistry, Package) // https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.packages/get ArtifactRegistryPackageVersion = shared.NewItemType(GCP, ArtifactRegistry, PackageVersion) // https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.packages.versions/get ArtifactRegistryPackageTag = shared.NewItemType(GCP, ArtifactRegistry, PackageTag) // https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.packages.tags/get DataformRepository = shared.NewItemType(GCP, Dataform, Repository) DataformCompilationResult = shared.NewItemType(GCP, Dataform, CompilationResult) DataformWorkspace = shared.NewItemType(GCP, Dataform, Workspace) DataformWorkflowInvocation = shared.NewItemType(GCP, Dataform, WorkflowInvocation) ServiceDirectoryEndpoint = shared.NewItemType(GCP, ServiceDirectory, Endpoint) DNSManagedZone = shared.NewItemType(GCP, DNS, ManagedZone) CloudBillingBillingInfo = shared.NewItemType(GCP, CloudBilling, BillingInfo) EssentialContactsContact = shared.NewItemType(GCP, EssentialContacts, Contact) LoggingSavedQuery = shared.NewItemType(GCP, Logging, SavedQuery) LoggingBucket = shared.NewItemType(GCP, Logging, Bucket) LoggingLink = shared.NewItemType(GCP, Logging, Link) LoggingSink = shared.NewItemType(GCP, Logging, Sink) CloudKMSCryptoKey = shared.NewItemType(GCP, CloudKMS, CryptoKey) CloudKMSImportJob = shared.NewItemType(GCP, CloudKMS, ImportJob) NetworkConnectivityHub = shared.NewItemType(GCP, NetworkConnectivity, Hub) NetworkConnectivityInternalRange = shared.NewItemType(GCP, NetworkConnectivity, InternalRange) // https://cloud.google.com/network-connectivity/docs/reference/networkconnectivity/rest/v1/projects.locations.internalRanges/get ComputeFirewallPolicy = shared.NewItemType(GCP, Compute, FirewallPolicy) AIPlatformTensorBoard = shared.NewItemType(GCP, AIPlatform, TensorBoard) AIPlatformExperiment = shared.NewItemType(GCP, AIPlatform, Experiment) AIPlatformExperimentRun = shared.NewItemType(GCP, AIPlatform, ExperimentRun) AIPlatformModel = shared.NewItemType(GCP, AIPlatform, Model) AIPlatformEndpoint = shared.NewItemType(GCP, AIPlatform, Endpoint) AIPlatformModelDeploymentMonitoringJob = shared.NewItemType(GCP, AIPlatform, ModelDeploymentMonitoringJob) AIPlatformBatchPredictionJob = shared.NewItemType(GCP, AIPlatform, BatchPredictionJob) AIPlatformSchedule = shared.NewItemType(GCP, AIPlatform, Schedule) AIPlatformDeploymentResourcePool = shared.NewItemType(GCP, AIPlatform, DeploymentResourcePool) AIPlatformPersistentResource = shared.NewItemType(GCP, AIPlatform, PersistentResource) BigQueryConnection = shared.NewItemType(GCP, BigQuery, Connection) BigTableAdminCluster = shared.NewItemType(GCP, BigTableAdmin, Cluster) CloudBuildTrigger = shared.NewItemType(GCP, CloudBuild, Trigger) RunService = shared.NewItemType(GCP, Run, Service) RunWorkerPool = shared.NewItemType(GCP, Run, WorkerPool) EventarcTrigger = shared.NewItemType(GCP, Eventarc, Trigger) EventarcChannel = shared.NewItemType(GCP, Eventarc, Channel) // https://cloud.google.com/eventarc/docs/reference/rest/v1/projects.locations.channels/get WorkflowsWorkflow = shared.NewItemType(GCP, Workflows, Workflow) // https://cloud.google.com/workflows/docs/reference/rest/v1/projects.locations.workflows/get VPCAccessConnector = shared.NewItemType(GCP, VPCAccess, Connector) // https://cloud.google.com/vpc/docs/reference/vpcaccess/rest/v1/projects.locations.connectors/get SQLAdminInstance = shared.NewItemType(GCP, SqlAdmin, Instance) // https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/instances/get CloudBillingBillingAccount = shared.NewItemType(GCP, CloudBilling, BillingAccount) // https://cloud.google.com/billing/docs/reference/rest/v1/billingAccounts/get ContainerCluster = shared.NewItemType(GCP, Container, Cluster) // https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters/get ContainerNodePool = shared.NewItemType(GCP, Container, NodePool) ServiceDirectoryNamespace = shared.NewItemType(GCP, ServiceDirectory, Namespace) // https://cloud.google.com/service-directory/docs/reference/rest/v1/projects.locations.namespaces/get SecretManagerSecret = shared.NewItemType(GCP, SecretManager, Secret) // https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets/get SecretManagerSecretVersion = shared.NewItemType(GCP, SecretManager, SecretVersion) // https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets.versions/get CloudKMSEKMConnection = shared.NewItemType(GCP, CloudKMS, EKMConnection) // https://cloud.google.com/kms/docs/reference/rest/v1/projects.locations.ekmConnections/get ComputeRegionInstanceTemplate = shared.NewItemType(GCP, Compute, RegionalInstanceTemplate) BigTableAdminInstance = shared.NewItemType(GCP, BigTableAdmin, Instance) ServiceDirectoryService = shared.NewItemType(GCP, ServiceDirectory, Service) ArtifactRegistryRepository = shared.NewItemType(GCP, ArtifactRegistry, Repository) // https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories/get?rep_location=global SpannerDatabase = shared.NewItemType(GCP, Spanner, Database) // https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases/get SpannerInstance = shared.NewItemType(GCP, Spanner, Instance) SpannerInstanceConfig = shared.NewItemType(GCP, Spanner, InstanceConfig) SpannerBackup = shared.NewItemType(GCP, Spanner, Backup) SpannerBackupSchedule = shared.NewItemType(GCP, Spanner, BackupSchedule) // https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases.backupSchedules/get SpannerDatabaseRole = shared.NewItemType(GCP, Spanner, DatabaseRole) // https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases.databaseRoles/list SpannerDatabaseOperation = shared.NewItemType(GCP, Spanner, DatabaseOperation) // https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases.operations/get SpannerSession = shared.NewItemType(GCP, Spanner, Session) // https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.databases.sessions/get SpannerInstancePartition = shared.NewItemType(GCP, Spanner, InstancePartition) // https://cloud.google.com/spanner/docs/reference/rest/v1/projects.instances.instancePartitions/get BigQueryModel = shared.NewItemType(GCP, BigQuery, Model) ComputeNetworkEndpointGroup = shared.NewItemType(GCP, Compute, NetworkEndpointGroup) ComputeSSLCertificate = shared.NewItemType(GCP, Compute, SSLCertificate) ComputeGlobalAddress = shared.NewItemType(GCP, Compute, GlobalAddress) ComputeVpnGateway = shared.NewItemType(GCP, Compute, VpnGateway) ComputeRouter = shared.NewItemType(GCP, Compute, Router) AppEngineService = shared.NewItemType(GCP, AppEngine, Service) CloudFunctionsFunction = shared.NewItemType(GCP, CloudFunctions, Function) CloudResourceManagerTagValue = shared.NewItemType(GCP, CloudResourceManager, TagValue) CloudResourceManagerTagKey = shared.NewItemType(GCP, CloudResourceManager, TagKey) MonitoringAlertPolicy = shared.NewItemType(GCP, Monitoring, AlertPolicy) OrgPolicyPolicy = shared.NewItemType(GCP, OrgPolicy, Policy) DataprocCluster = shared.NewItemType(GCP, Dataproc, Cluster) // https://cloud.google.com/dataproc/docs/reference/rest/v1/projects.regions.clusters/get DataprocAutoscalingPolicy = shared.NewItemType(GCP, Dataproc, AutoscalingPolicy) DataprocMetastoreService = shared.NewItemType(GCP, Dataproc, MetastoreService) // https://cloud.google.com/dataproc-metastore/docs/reference/rest/v1/projects.locations.services/get ComputeInterconnectAttachment = shared.NewItemType(GCP, Compute, InterconnectAttachment) ComputeServiceAttachment = shared.NewItemType(GCP, Compute, ServiceAttachment) ComputeTargetHttpsProxy = shared.NewItemType(GCP, Compute, TargetHttpsProxy) ComputeRegionTargetHttpsProxy = shared.NewItemType(GCP, Compute, RegionTargetHttpsProxy) ComputeSSLPolicy = shared.NewItemType(GCP, Compute, SSLPolicy) ComputeTargetHttpProxy = shared.NewItemType(GCP, Compute, TargetHttpProxy) ComputeTargetTcpProxy = shared.NewItemType(GCP, Compute, TargetTcpProxy) ComputeTargetSslProxy = shared.NewItemType(GCP, Compute, TargetSslProxy) ComputeTargetVpnGateway = shared.NewItemType(GCP, Compute, TargetVpnGateway) ComputeTargetInstance = shared.NewItemType(GCP, Compute, TargetInstance) ComputePublicDelegatedPrefix = shared.NewItemType(GCP, Compute, PublicDelegatedPrefix) ComputePublicAdvertisedPrefix = shared.NewItemType(GCP, Compute, PublicAdvertisedPrefix) ComputeExternalVpnGateway = shared.NewItemType(GCP, Compute, ExternalVpnGateway) RedisInstance = shared.NewItemType(GCP, Redis, Instance) // https://cloud.google.com/memorystore/docs/redis/reference/rest/v1/projects.locations.instances/get SecurityCenterManagementSecurityCenterService = shared.NewItemType(GCP, SecurityCenterManagement, SecurityCenterService) // https://cloud.google.com/security-command-center/docs/reference/security-center-management/rest/v1/projects.locations.securityCenterServices/get SecurityCenterManagementSecurityHealthAnalyticsCustomModule = shared.NewItemType(GCP, SecurityCenterManagement, SecurityHealthAnalyticsCustomModule) // https://cloud.google.com/security-command-center/docs/reference/security-center-management/rest/v1/projects.locations.securityHealthAnalyticsCustomModules/get SecurityCenterManagementEventThreatDetectionCustomModule = shared.NewItemType(GCP, SecurityCenterManagement, EventThreatDetectionCustomModule) // https://cloud.google.com/security-command-center/docs/reference/security-center-management/rest/v1/projects.locations.eventThreatDetectionCustomModules/get SecurityCenterManagementEffectiveSecurityHealthAnalyticsCustomModule = shared.NewItemType(GCP, SecurityCenterManagement, EffectiveSecurityHealthAnalyticsCustomModule) // https://cloud.google.com/security-command-center/docs/reference/security-center-management/rest/v1/projects.locations.effectiveSecurityHealthAnalyticsCustomModules/get SecurityCenterManagementEffectiveEventThreatDetectionCustomModule = shared.NewItemType(GCP, SecurityCenterManagement, EffectiveEventThreatDetectionCustomModule) // https://cloud.google.com/security-command-center/docs/reference/security-center-management/rest/v1/projects.locations.effectiveEventThreatDetectionCustomModules/get FileInstance = shared.NewItemType(GCP, File, Instance) FileBackup = shared.NewItemType(GCP, File, Backup) CertificateManagerCertificateMap = shared.NewItemType(GCP, CertificateManager, CertificateMap) CertificateManagerCertificateMapEntry = shared.NewItemType(GCP, CertificateManager, CertificateMapEntry) CertificateManagerCertificate = shared.NewItemType(GCP, CertificateManager, Certificate) CertificateManagerDnsAuthorization = shared.NewItemType(GCP, CertificateManager, DnsAuthorization) CertificateManagerCertificateIssuanceConfig = shared.NewItemType(GCP, CertificateManager, CertificateIssuanceConfig) ComputeRoutePolicy = shared.NewItemType(GCP, Compute, RoutePolicy) // Router Route Policy child resource ComputeBgpRoute = shared.NewItemType(GCP, Compute, BgpRoute) // Router BGP Route child resource NetworkServicesMesh = shared.NewItemType(GCP, NetworkServices, Mesh) // https://cloud.google.com/service-mesh/docs/reference/network-services/rest/v1/projects.locations.meshes/get BinaryAuthorizationPlatformPolicy = shared.NewItemType(GCP, BinaryAuthorization, BinaryAuthorizationPolicy) // https://cloud.google.com/binary-authorization/docs/reference/rest/v1/projects.platforms.policies/get DataflowJob = shared.NewItemType(GCP, Dataflow, Job) // https://cloud.google.com/dataflow/docs/reference/rest/v1b3/projects.locations.jobs/get ) ================================================ FILE: sources/gcp/shared/kms-asset-loader.go ================================================ package shared import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "sync" "time" "cloud.google.com/go/kms/apiv1/kmspb" log "github.com/sirupsen/logrus" "golang.org/x/sync/singleflight" "google.golang.org/protobuf/encoding/protojson" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/sources/shared" ) // CloudKMSAssetLoader handles bulk loading of KMS resources via Cloud Asset API. // It fetches all KMS resources (KeyRings, CryptoKeys, CryptoKeyVersions) in a single // API call and stores them in sdpcache for efficient retrieval by adapters. type CloudKMSAssetLoader struct { httpClient *http.Client projectID string cache sdpcache.Cache sourceName string // TTL-aware reloading mu sync.Mutex lastLoadTime time.Time group singleflight.Group } // NewCloudKMSAssetLoader creates a new CloudKMSAssetLoader. func NewCloudKMSAssetLoader( httpClient *http.Client, projectID string, cache sdpcache.Cache, sourceName string, locations []LocationInfo, ) *CloudKMSAssetLoader { return &CloudKMSAssetLoader{ httpClient: httpClient, projectID: projectID, cache: cache, sourceName: sourceName, } } // EnsureLoaded triggers bulk load if cache TTL has expired. // Called by adapters on cache miss. func (l *CloudKMSAssetLoader) EnsureLoaded(ctx context.Context) error { l.mu.Lock() timeSinceLastLoad := time.Since(l.lastLoadTime) l.mu.Unlock() // If data was loaded recently, skip reload if timeSinceLastLoad < shared.DefaultCacheDuration { return nil } // Use singleflight to ensure only one load runs at a time // Concurrent callers wait for the same result _, err, _ := l.group.Do("load", func() (any, error) { // Double-check TTL after acquiring the flight l.mu.Lock() if time.Since(l.lastLoadTime) < shared.DefaultCacheDuration { l.mu.Unlock() return nil, nil } l.mu.Unlock() // Perform the bulk load if err := l.loadAll(ctx); err != nil { return nil, err } // Update last load time on success l.mu.Lock() l.lastLoadTime = time.Now() l.mu.Unlock() return nil, nil }) return err } // cloudAssetResponse represents the response from Cloud Asset API type cloudAssetResponse struct { Assets []cloudAsset `json:"assets"` NextPageToken string `json:"nextPageToken"` } // cloudAsset represents a single asset from Cloud Asset API type cloudAsset struct { Name string `json:"name"` AssetType string `json:"assetType"` Resource cloudResource `json:"resource"` Ancestors []string `json:"ancestors"` UpdateTime string `json:"updateTime"` } // cloudResource contains the actual resource data type cloudResource struct { Version string `json:"version"` DiscoveryDocumentURI string `json:"discoveryDocumentUri"` DiscoveryName string `json:"discoveryName"` Parent string `json:"parent"` Data json.RawMessage `json:"data"` } // loadAll fetches all KMS resources from Cloud Asset API and stores in sdpcache func (l *CloudKMSAssetLoader) loadAll(ctx context.Context) error { // Fetch all KMS assets assets, err := l.fetchAllAssets(ctx) if err != nil { return fmt.Errorf("failed to fetch KMS assets: %w", err) } // Track which resource types had items hasKeyRings := false hasCryptoKeys := false hasKeyVersions := false // Process and cache each asset for _, asset := range assets { switch asset.AssetType { case "cloudkms.googleapis.com/KeyRing": hasKeyRings = true if err := l.cacheKeyRing(ctx, asset); err != nil { // Log error but continue processing other assets log.WithContext(ctx).WithError(err).WithFields(log.Fields{ "ovm.kms.assetType": asset.AssetType, "ovm.kms.assetName": asset.Name, }).Warn("failed to cache KMS KeyRing") continue } case "cloudkms.googleapis.com/CryptoKey": hasCryptoKeys = true if err := l.cacheCryptoKey(ctx, asset); err != nil { log.WithContext(ctx).WithError(err).WithFields(log.Fields{ "ovm.kms.assetType": asset.AssetType, "ovm.kms.assetName": asset.Name, }).Warn("failed to cache KMS CryptoKey") continue } case "cloudkms.googleapis.com/CryptoKeyVersion": hasKeyVersions = true if err := l.cacheCryptoKeyVersion(ctx, asset); err != nil { log.WithContext(ctx).WithError(err).WithFields(log.Fields{ "ovm.kms.assetType": asset.AssetType, "ovm.kms.assetName": asset.Name, }).Warn("failed to cache KMS CryptoKeyVersion") continue } } } // For types with no items, store NOTFOUND error so cache.Lookup() returns cacheHit=true notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "No resources found in Cloud Asset API", } scope := l.projectID if !hasKeyRings { listCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_LIST, scope, CloudKMSKeyRing.String(), "") l.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, listCacheKey) } if !hasCryptoKeys { listCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_LIST, scope, CloudKMSCryptoKey.String(), "") l.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, listCacheKey) } if !hasKeyVersions { listCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_LIST, scope, CloudKMSCryptoKeyVersion.String(), "") l.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, listCacheKey) } return nil } // fetchAllAssets fetches all KMS assets from Cloud Asset API with pagination func (l *CloudKMSAssetLoader) fetchAllAssets(ctx context.Context) ([]cloudAsset, error) { var allAssets []cloudAsset pageToken := "" for { assets, nextToken, err := l.fetchAssetsPage(ctx, pageToken) if err != nil { return nil, err } allAssets = append(allAssets, assets...) if nextToken == "" { break } pageToken = nextToken } return allAssets, nil } // fetchAssetsPage fetches a single page of KMS assets func (l *CloudKMSAssetLoader) fetchAssetsPage(ctx context.Context, pageToken string) ([]cloudAsset, string, error) { // Build the Cloud Asset API URL baseURL := fmt.Sprintf("https://cloudasset.googleapis.com/v1/projects/%s/assets", l.projectID) params := url.Values{} params.Add("assetTypes", "cloudkms.googleapis.com/KeyRing") params.Add("assetTypes", "cloudkms.googleapis.com/CryptoKey") params.Add("assetTypes", "cloudkms.googleapis.com/CryptoKeyVersion") params.Set("contentType", "RESOURCE") if pageToken != "" { params.Set("pageToken", pageToken) } apiURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, "", fmt.Errorf("failed to create request: %w", err) } // Cloud Asset API requires quota project header req.Header.Set("X-Goog-User-Project", l.projectID) resp, err := l.httpClient.Do(req) if err != nil { return nil, "", fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, "", fmt.Errorf("Cloud Asset API returned status %d: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, "", fmt.Errorf("failed to read response body: %w", err) } var response cloudAssetResponse if err := json.Unmarshal(body, &response); err != nil { return nil, "", fmt.Errorf("failed to unmarshal response: %w", err) } return response.Assets, response.NextPageToken, nil } // cacheKeyRing converts a Cloud Asset to SDP Item and stores in cache func (l *CloudKMSAssetLoader) cacheKeyRing(ctx context.Context, asset cloudAsset) error { // Parse the resource data into KeyRing protobuf var keyRing kmspb.KeyRing if err := protojson.Unmarshal(asset.Resource.Data, &keyRing); err != nil { return fmt.Errorf("failed to unmarshal KeyRing: %w", err) } // Extract path parameters from the asset name // Format: //cloudkms.googleapis.com/projects/{project}/locations/{location}/keyRings/{keyRing} resourceName := extractResourceName(asset.Name) keyRingVals := ExtractPathParams(resourceName, "locations", "keyRings") if len(keyRingVals) != 2 || keyRingVals[0] == "" || keyRingVals[1] == "" { return fmt.Errorf("invalid KeyRing name: %s", asset.Name) } // Create unique attribute key (location|keyRingName) uniqueAttr := shared.CompositeLookupKey(keyRingVals...) // Convert to SDP Item attributes, err := shared.ToAttributesWithExclude(&keyRing) if err != nil { return fmt.Errorf("failed to convert KeyRing to attributes: %w", err) } if err := attributes.Set("uniqueAttr", uniqueAttr); err != nil { return fmt.Errorf("failed to set unique attribute: %w", err) } scope := l.projectID item := &sdp.Item{ Type: CloudKMSKeyRing.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Add linked item queries item.LinkedItemQueries = l.keyRingLinkedQueries(keyRingVals, scope) // Store in cache with GET cache key pattern (for individual lookups) getCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_GET, scope, CloudKMSKeyRing.String(), uniqueAttr) l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, getCacheKey) // Also store with LIST cache key (for listing all KeyRings) listCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_LIST, scope, CloudKMSKeyRing.String(), "") l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, listCacheKey) // Also store with SEARCH cache key (for searching by location) // KeyRing search is by location only location := keyRingVals[0] searchCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_SEARCH, scope, CloudKMSKeyRing.String(), location) l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, searchCacheKey) return nil } // cacheCryptoKey converts a Cloud Asset to SDP Item and stores in cache func (l *CloudKMSAssetLoader) cacheCryptoKey(ctx context.Context, asset cloudAsset) error { // Parse the resource data into CryptoKey protobuf var cryptoKey kmspb.CryptoKey if err := protojson.Unmarshal(asset.Resource.Data, &cryptoKey); err != nil { return fmt.Errorf("failed to unmarshal CryptoKey: %w", err) } // Extract path parameters // Format: //cloudkms.googleapis.com/projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey} resourceName := extractResourceName(asset.Name) values := ExtractPathParams(resourceName, "locations", "keyRings", "cryptoKeys") if len(values) != 3 || values[0] == "" || values[1] == "" || values[2] == "" { return fmt.Errorf("invalid CryptoKey name: %s", asset.Name) } // Create unique attribute key (location|keyRing|cryptoKey) uniqueAttr := shared.CompositeLookupKey(values...) // Convert to SDP Item attributes, err := shared.ToAttributesWithExclude(&cryptoKey, "labels") if err != nil { return fmt.Errorf("failed to convert CryptoKey to attributes: %w", err) } if err := attributes.Set("uniqueAttr", uniqueAttr); err != nil { return fmt.Errorf("failed to set unique attribute: %w", err) } scope := l.projectID item := &sdp.Item{ Type: CloudKMSCryptoKey.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, Tags: cryptoKey.GetLabels(), } // Add linked item queries item.LinkedItemQueries = l.cryptoKeyLinkedQueries(values, &cryptoKey, scope) // Store in cache with GET cache key (for individual lookups) getCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_GET, scope, CloudKMSCryptoKey.String(), uniqueAttr) l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, getCacheKey) // Also store with LIST cache key (for listing all CryptoKeys) listCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_LIST, scope, CloudKMSCryptoKey.String(), "") l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, listCacheKey) // Also store with SEARCH cache key (for searching by keyRing) // CryptoKey search is by location|keyRing location := values[0] keyRing := values[1] searchQuery := shared.CompositeLookupKey(location, keyRing) searchCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_SEARCH, scope, CloudKMSCryptoKey.String(), searchQuery) l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, searchCacheKey) return nil } // cacheCryptoKeyVersion converts a Cloud Asset to SDP Item and stores in cache func (l *CloudKMSAssetLoader) cacheCryptoKeyVersion(ctx context.Context, asset cloudAsset) error { // Parse the resource data into CryptoKeyVersion protobuf var keyVersion kmspb.CryptoKeyVersion if err := protojson.Unmarshal(asset.Resource.Data, &keyVersion); err != nil { return fmt.Errorf("failed to unmarshal CryptoKeyVersion: %w", err) } // Extract path parameters // Format: //cloudkms.googleapis.com/projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}/cryptoKeyVersions/{version} resourceName := extractResourceName(asset.Name) values := ExtractPathParams(resourceName, "locations", "keyRings", "cryptoKeys", "cryptoKeyVersions") if len(values) != 4 || values[0] == "" || values[1] == "" || values[2] == "" || values[3] == "" { return fmt.Errorf("invalid CryptoKeyVersion name: %s", asset.Name) } // Create unique attribute key (location|keyRing|cryptoKey|version) uniqueAttr := shared.CompositeLookupKey(values...) // Convert to SDP Item attributes, err := shared.ToAttributesWithExclude(&keyVersion) if err != nil { return fmt.Errorf("failed to convert CryptoKeyVersion to attributes: %w", err) } if err := attributes.Set("uniqueAttr", uniqueAttr); err != nil { return fmt.Errorf("failed to set unique attribute: %w", err) } scope := l.projectID item := &sdp.Item{ Type: CloudKMSCryptoKeyVersion.String(), UniqueAttribute: "uniqueAttr", Attributes: attributes, Scope: scope, } // Add linked item queries item.LinkedItemQueries = l.cryptoKeyVersionLinkedQueries(values, &keyVersion, scope) // Set health based on state item.Health = l.cryptoKeyVersionHealth(&keyVersion) // Store in cache with GET cache key (for individual lookups) getCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_GET, scope, CloudKMSCryptoKeyVersion.String(), uniqueAttr) l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, getCacheKey) // Also store with LIST cache key (for listing all CryptoKeyVersions) listCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_LIST, scope, CloudKMSCryptoKeyVersion.String(), "") l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, listCacheKey) // Also store with SEARCH cache key (for searching by cryptoKey) // CryptoKeyVersion search is by location|keyRing|cryptoKey location := values[0] keyRing := values[1] cryptoKeyName := values[2] searchQuery := shared.CompositeLookupKey(location, keyRing, cryptoKeyName) searchCacheKey := sdpcache.CacheKeyFromParts(l.sourceName, sdp.QueryMethod_SEARCH, scope, CloudKMSCryptoKeyVersion.String(), searchQuery) l.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, searchCacheKey) return nil } // extractResourceName extracts the resource name from Cloud Asset name // Example: //cloudkms.googleapis.com/projects/my-project/locations/global/keyRings/my-keyring // Returns: projects/my-project/locations/global/keyRings/my-keyring func extractResourceName(assetName string) string { // Remove the //cloudkms.googleapis.com/ prefix if len(assetName) > 2 && assetName[:2] == "//" { // Find the first / after the domain for i := 2; i < len(assetName); i++ { if assetName[i] == '/' { return assetName[i+1:] } } } return assetName } // keyRingLinkedQueries returns linked item queries for a KeyRing func (l *CloudKMSAssetLoader) keyRingLinkedQueries(keyRingVals []string, scope string) []*sdp.LinkedItemQuery { var queries []*sdp.LinkedItemQuery // Link to IAM Policy queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: IAMPolicy.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(keyRingVals...), Scope: scope, }, }) // Link to CryptoKeys in this KeyRing queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_SEARCH, Query: shared.CompositeLookupKey(keyRingVals[0], keyRingVals[1]), Scope: scope, }, }) return queries } // cryptoKeyLinkedQueries returns linked item queries for a CryptoKey func (l *CloudKMSAssetLoader) cryptoKeyLinkedQueries(values []string, cryptoKey *kmspb.CryptoKey, scope string) []*sdp.LinkedItemQuery { var queries []*sdp.LinkedItemQuery kmsLocation := values[0] keyRing := values[1] cryptoKeyName := values[2] // Link to IAM Policy queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: IAMPolicy.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(kmsLocation, keyRing, cryptoKeyName), Scope: scope, }, }) // Link to parent KeyRing queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSKeyRing.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(kmsLocation, keyRing), Scope: scope, }, }) // Link to all CryptoKeyVersions queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_SEARCH, Query: shared.CompositeLookupKey(kmsLocation, keyRing, cryptoKeyName), Scope: scope, }, }) // Link to primary CryptoKeyVersion if present if primary := cryptoKey.GetPrimary(); primary != nil { if name := primary.GetName(); name != "" { keyVersionVals := ExtractPathParams(name, "locations", "keyRings", "cryptoKeys", "cryptoKeyVersions") if len(keyVersionVals) == 4 && keyVersionVals[0] != "" && keyVersionVals[1] != "" && keyVersionVals[2] != "" && keyVersionVals[3] != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(keyVersionVals...), Scope: scope, }, }) } } // Link to ImportJob if present if importJob := primary.GetImportJob(); importJob != "" { importJobVals := ExtractPathParams(importJob, "locations", "keyRings", "importJobs") if len(importJobVals) == 3 && importJobVals[0] != "" && importJobVals[1] != "" && importJobVals[2] != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSImportJob.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(importJobVals...), Scope: scope, }, }) } } // Link to EKM Connection if applicable if protectionLevel := primary.GetProtectionLevel(); protectionLevel == kmspb.ProtectionLevel_EXTERNAL_VPC { if cryptoKeyBackend := cryptoKey.GetCryptoKeyBackend(); cryptoKeyBackend != "" { backendVals := ExtractPathParams(cryptoKeyBackend, "locations", "ekmConnections") if len(backendVals) == 2 && backendVals[0] != "" && backendVals[1] != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSEKMConnection.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(backendVals...), Scope: scope, }, }) } } } } return queries } // cryptoKeyVersionLinkedQueries returns linked item queries for a CryptoKeyVersion func (l *CloudKMSAssetLoader) cryptoKeyVersionLinkedQueries(values []string, keyVersion *kmspb.CryptoKeyVersion, scope string) []*sdp.LinkedItemQuery { var queries []*sdp.LinkedItemQuery // Link to parent CryptoKey queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values[0], values[1], values[2]), Scope: scope, }, }) // Link to ImportJob if present if importJob := keyVersion.GetImportJob(); importJob != "" { importJobVals := ExtractPathParams(importJob, "locations", "keyRings", "importJobs") if len(importJobVals) == 3 && importJobVals[0] != "" && importJobVals[1] != "" && importJobVals[2] != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSImportJob.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(importJobVals...), Scope: scope, }, }) } } // Link to EKM Connection if applicable if protectionLevel := keyVersion.GetProtectionLevel(); protectionLevel == kmspb.ProtectionLevel_EXTERNAL_VPC { if externalProtection := keyVersion.GetExternalProtectionLevelOptions(); externalProtection != nil { if ekmPath := externalProtection.GetEkmConnectionKeyPath(); ekmPath != "" { ekmVals := ExtractPathParams(ekmPath, "locations", "ekmConnections") if len(ekmVals) == 2 && ekmVals[0] != "" && ekmVals[1] != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSEKMConnection.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(ekmVals...), Scope: scope, }, }) } } } } return queries } // cryptoKeyVersionHealth returns the health status based on CryptoKeyVersion state func (l *CloudKMSAssetLoader) cryptoKeyVersionHealth(keyVersion *kmspb.CryptoKeyVersion) *sdp.Health { switch keyVersion.GetState() { case kmspb.CryptoKeyVersion_CRYPTO_KEY_VERSION_STATE_UNSPECIFIED: return sdp.Health_HEALTH_UNKNOWN.Enum() case kmspb.CryptoKeyVersion_PENDING_GENERATION, kmspb.CryptoKeyVersion_PENDING_IMPORT: return sdp.Health_HEALTH_PENDING.Enum() case kmspb.CryptoKeyVersion_ENABLED: return sdp.Health_HEALTH_OK.Enum() case kmspb.CryptoKeyVersion_DISABLED: return sdp.Health_HEALTH_WARNING.Enum() case kmspb.CryptoKeyVersion_DESTROYED, kmspb.CryptoKeyVersion_DESTROY_SCHEDULED: return sdp.Health_HEALTH_ERROR.Enum() case kmspb.CryptoKeyVersion_IMPORT_FAILED, kmspb.CryptoKeyVersion_GENERATION_FAILED, kmspb.CryptoKeyVersion_EXTERNAL_DESTRUCTION_FAILED: return sdp.Health_HEALTH_ERROR.Enum() case kmspb.CryptoKeyVersion_PENDING_EXTERNAL_DESTRUCTION: return sdp.Health_HEALTH_PENDING.Enum() default: return sdp.Health_HEALTH_UNKNOWN.Enum() } } // GetItem performs the cache-lookup-load-recheck pattern for GET queries. // Returns the item from cache, loading data if needed. func (l *CloudKMSAssetLoader) GetItem(ctx context.Context, scope, itemType, uniqueAttr string) (*sdp.Item, *sdp.QueryError) { // Check cache first cacheHit, _, cachedItems, cachedErr, done := l.cache.Lookup(ctx, l.sourceName, sdp.QueryMethod_GET, scope, itemType, uniqueAttr, false) if cacheHit { done() if cachedErr != nil { return nil, cachedErr } if len(cachedItems) > 0 { return cachedItems[0], nil } } // Cache miss - trigger lazy bulk load if err := l.EnsureLoaded(ctx); err != nil { done() return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to load KMS data from Cloud Asset API: %v", err), } } // Complete first lookup's pending work before second lookup to avoid self-deadlock done() // Re-check cache after bulk load cacheHit, _, cachedItems, cachedErr, done = l.cache.Lookup(ctx, l.sourceName, sdp.QueryMethod_GET, scope, itemType, uniqueAttr, false) defer done() if cacheHit { if cachedErr != nil { return nil, cachedErr } if len(cachedItems) > 0 { return cachedItems[0], nil } } // Item not found (may be newly created, Cloud Asset API has indexing delay) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%s %s not found (Cloud Asset API may have indexing delay for new resources)", itemType, uniqueAttr), } } // SearchItems performs the cache-lookup-load-recheck pattern for SEARCH queries. // Streams matching items from cache, loading data if needed. func (l *CloudKMSAssetLoader) SearchItems(ctx context.Context, stream discovery.QueryResultStream, scope, itemType, searchQuery string) { // Check cache first cacheHit, _, cachedItems, cachedErr, done := l.cache.Lookup(ctx, l.sourceName, sdp.QueryMethod_SEARCH, scope, itemType, searchQuery, false) if cacheHit { done() if cachedErr != nil { // For SEARCH, convert NOTFOUND to empty result if cachedErr.GetErrorType() == sdp.QueryError_NOTFOUND { return // Empty result is valid for SEARCH } stream.SendError(cachedErr) return } for _, item := range cachedItems { stream.SendItem(item) } return } // Cache miss - trigger lazy bulk load if err := l.EnsureLoaded(ctx); err != nil { done() stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to load KMS data from Cloud Asset API: %v", err), }) return } // Complete first lookup's pending work before second lookup to avoid self-deadlock done() // Re-check cache after bulk load cacheHit, _, cachedItems, cachedErr, done = l.cache.Lookup(ctx, l.sourceName, sdp.QueryMethod_SEARCH, scope, itemType, searchQuery, false) defer done() if cacheHit { if cachedErr != nil { // For SEARCH, convert NOTFOUND to empty result if cachedErr.GetErrorType() == sdp.QueryError_NOTFOUND { return // Empty result is valid for SEARCH } stream.SendError(cachedErr) return } for _, item := range cachedItems { stream.SendItem(item) } return } // No items found for this search - return empty result } // ListItems performs the cache-lookup-load-recheck pattern for LIST queries. // Streams all items of the given type from cache, loading data if needed. func (l *CloudKMSAssetLoader) ListItems(ctx context.Context, stream discovery.QueryResultStream, scope, itemType string) { // Check cache first (LIST cache key has empty query) cacheHit, _, cachedItems, cachedErr, done := l.cache.Lookup(ctx, l.sourceName, sdp.QueryMethod_LIST, scope, itemType, "", false) if cacheHit { done() if cachedErr != nil { // For LIST, convert NOTFOUND to empty result if cachedErr.GetErrorType() == sdp.QueryError_NOTFOUND { return // Empty result is valid for LIST } stream.SendError(cachedErr) return } for _, item := range cachedItems { stream.SendItem(item) } return } // Cache miss - trigger lazy bulk load if err := l.EnsureLoaded(ctx); err != nil { done() stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to load KMS data from Cloud Asset API: %v", err), }) return } // Complete first lookup's pending work before second lookup to avoid self-deadlock done() // Re-check cache after bulk load cacheHit, _, cachedItems, cachedErr, done = l.cache.Lookup(ctx, l.sourceName, sdp.QueryMethod_LIST, scope, itemType, "", false) defer done() if cacheHit { if cachedErr != nil { // For LIST, convert NOTFOUND to empty result if cachedErr.GetErrorType() == sdp.QueryError_NOTFOUND { return // Empty result is valid for LIST } stream.SendError(cachedErr) return } for _, item := range cachedItems { stream.SendItem(item) } return } // No items found - return empty result } ================================================ FILE: sources/gcp/shared/link-rules.go ================================================ package shared import ( "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) type Impact struct { ToSDPItemType shared.ItemType Description string IsParentToChild bool } var ( IPImpactBothWays = &Impact{ Description: "IP addresses and DNS names are tightly coupled with the source type. The linker automatically detects whether the value is an IP address or DNS name and creates the appropriate link. You can use either stdlib.NetworkIP or stdlib.NetworkDNS in the link rules - both will automatically detect the actual type.", ToSDPItemType: stdlib.NetworkIP, } SecurityPolicyImpactInOnly = &Impact{ Description: "Any change on the security policy impacts the source, but not the other way around.", ToSDPItemType: ComputeSecurityPolicy, } CryptoKeyImpactInOnly = &Impact{ Description: "If the crypto key is updated: The source may not be able to access encrypted data. If the source is updated: The crypto key remains unaffected.", ToSDPItemType: CloudKMSCryptoKey, } CryptoKeyVersionImpactInOnly = &Impact{ Description: "If the crypto key version is updated: The source may not be able to access encrypted data. If the source is updated: The crypto key version remains unaffected.", ToSDPItemType: CloudKMSCryptoKeyVersion, } IAMServiceAccountImpactInOnly = &Impact{ Description: "If the service account is updated: The source may not be able to access encrypted data. If the source is updated: The service account remains unaffected.", ToSDPItemType: IAMServiceAccount, } ResourcePolicyImpactInOnly = &Impact{ Description: "If the resource policy is updated: The source may not be able to access the resource as expected. If the source is updated: The resource policy remains unaffected.", ToSDPItemType: ComputeResourcePolicy, } ComputeNetworkImpactInOnly = &Impact{ Description: "If the Compute Network is updated: The source may lose connectivity or fail to run as expected. If the source is updated: The network remains unaffected.", ToSDPItemType: ComputeNetwork, } ComputeSubnetworkImpactInOnly = &Impact{ Description: "If the Compute Subnetwork is updated: The source may lose connectivity or fail to run as expected. If the source is updated: The subnetwork remains unaffected.", ToSDPItemType: ComputeSubnetwork, } ) // LinkRules maps item types to their link rules (attribute key -> target type metadata). // This map is populated during source initiation by individual adapter files. var LinkRules = map[shared.ItemType]map[string]*Impact{} ================================================ FILE: sources/gcp/shared/linker.go ================================================ package shared import ( "context" "fmt" "net" "regexp" "strings" "github.com/getsentry/sentry-go" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) // ItemTypeMeta holds metadata about an item type. type ItemTypeMeta struct { GCPAssetType string SDPAssetType shared.ItemType SDPCategory sdp.AdapterCategory SelfLink string TerraformMappings []*sdp.TerraformMapping } // ItemLookup is a map that associates item type keys (strings) with their metadata. type ItemLookup map[string]ItemTypeMeta // Linker is responsible for linking items based on their types and relationships. type Linker struct { sdpAssetTypeToAdapterMeta map[shared.ItemType]AdapterMeta manualAdapterLinker map[shared.ItemType]func(scope, fromItemScope, query string) *sdp.LinkedItemQuery } // NewLinker creates a new Linker instance with the provided item lookup and predefined mappings. func NewLinker() *Linker { return &Linker{ sdpAssetTypeToAdapterMeta: SDPAssetTypeToAdapterMeta, manualAdapterLinker: ManualAdapterLinksByAssetType, } } // networkTagKeys lists the attribute keys that carry GCP network tags. var networkTagKeys = map[string]bool{ "targetTags": true, "sourceTags": true, "tags": true, "tags.items": true, "properties.tags.items": true, } // IsNetworkTagKey returns true when the key is a known network-tag attribute. func IsNetworkTagKey(key string) bool { return networkTagKeys[key] } // isNetworkTag returns true when the key is a known network-tag attribute and // the value looks like a plain tag (no "/" — not a resource URI). func isNetworkTag(key, value string) bool { return networkTagKeys[key] && !strings.Contains(value, "/") } // AutoLink tries to find the item type of the TO item based on its GCP resource name. // If the item type is identified, it links the FROM item to the TO item. func (l *Linker) AutoLink(ctx context.Context, projectID string, fromSDPItem *sdp.Item, fromSDPItemType shared.ItemType, toItemGCPResourceName string, keys []string) { span := trace.SpanFromContext(ctx) key := strings.Join(keys, ".") if strings.HasPrefix(key, "selfLink") { // selfLink is a special case, we don't want to link to it return } lf := log.Fields{ "ovm.gcp.projectId": projectID, "ovm.gcp.fromItemType": fromSDPItemType.String(), "ovm.gcp.toItemResourceName": toItemGCPResourceName, "ovm.gcp.key": key, } // Network tag handling: detect plain tag values on known tag keys and // emit SEARCH-based links instead of the normal resource-path flow. if isNetworkTag(key, toItemGCPResourceName) { tag := strings.TrimSpace(toItemGCPResourceName) if tag == "" { return // skip empty/whitespace-only tags (R2) } switch fromSDPItemType { case ComputeFirewall, ComputeRoute: // Tag-based SEARCH lists all instances and instance templates in scope then filters; // may be slow in very large projects. fromSDPItem.LinkedItemQueries = append(fromSDPItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeInstance.String(), Method: sdp.QueryMethod_SEARCH, Query: tag, Scope: projectID, }, }, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeInstanceTemplate.String(), Method: sdp.QueryMethod_SEARCH, Query: tag, Scope: projectID, }, }, ) case ComputeInstance, ComputeInstanceTemplate: // Tag-based SEARCH lists all firewalls/routes in scope then filters; // may be slow in very large projects. fromSDPItem.LinkedItemQueries = append(fromSDPItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeFirewall.String(), Method: sdp.QueryMethod_SEARCH, Query: tag, Scope: projectID, }, }, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeRoute.String(), Method: sdp.QueryMethod_SEARCH, Query: tag, Scope: projectID, }, }, ) default: log.WithContext(ctx).WithFields(lf).Debug("network tag on unexpected item type, skipping") } return } impacts, ok := LinkRules[fromSDPItemType] if !ok { log.WithContext(ctx).WithFields(lf).Warnf("there are no link rules for the FROM item type") return } impact, ok := impacts[key] if !ok { if strings.Contains(toItemGCPResourceName, "/") && key != "name" { // There is a high chance that the item type is not recognized, so // store in otel for later analysis. This potentially overwrites the // values from previous calls to AutoLink in the same span, but we // don't want to spam honeycomb, so we only keep the last one. span.SetAttributes( attribute.Bool("ovm.gcp.autoLink.missingLink", true), attribute.String("ovm.gcp.autoLink.toItemResourceName", toItemGCPResourceName), attribute.String("ovm.gcp.autoLink.key", key), ) log.WithContext(ctx).WithFields(lf).Debug("possible missing link") } return } if linkFunc, ok := l.manualAdapterLinker[impact.ToSDPItemType]; ok { // Special handling for stdlib.NetworkIP and stdlib.NetworkDNS - detect both IP and DNS // This handles fields like "host" that could contain either an IP address or DNS name // You can specify either IP or DNS in the link rules, and it will automatically // detect which type the value actually is and create the appropriate link if impact.ToSDPItemType == stdlib.NetworkIP || impact.ToSDPItemType == stdlib.NetworkDNS { l.linkIPOrDNS(ctx, fromSDPItem, toItemGCPResourceName) return } linkedItemQuery := linkFunc(projectID, fromSDPItem.GetScope(), toItemGCPResourceName) if linkedItemQuery == nil { log.WithContext(ctx).WithFields(lf).Warn( "manual adapter linker failed to create a linked item query", ) return } fromSDPItem.LinkedItemQueries = append( fromSDPItem.LinkedItemQueries, linkedItemQuery, ) return } toSDPItemMeta, ok := l.sdpAssetTypeToAdapterMeta[impact.ToSDPItemType] if !ok { // This should never happen at runtime! log.WithContext(ctx).WithFields(lf).Warnf( "could not find adapter meta for %s", impact.ToSDPItemType.String(), ) return } qMethod := sdp.QueryMethod_GET keysToExtract := toSDPItemMeta.UniqueAttributeKeys if impact.IsParentToChild { // This is a link from parent to child. // In these cases, we remove the child source identifier from the query string. // I.e., for a link from spanner instance to all its databases: // The query should look like: "my-instance" // However, the `toSDPItemMeta.UniqueAttributeKeys` which is used for deciding what keys to be extracted from // the passed `toItemGCPResourceName` is for the database, because the link is for the spanner databases. // We need to remove the identifier for spanner database, because the parent source, `instance`, // does not have this information at all. // So, the unique attribute keys will become ["instances"] instead of ["instances", "databases"]. if len(keysToExtract) < 1 { log.WithContext(ctx).WithFields(lf).Errorf( "failed to construct a SEARCH linked item query from parent to child source", ) return } keysToExtract = keysToExtract[0 : len(keysToExtract)-1] // remove the last element, i.e., "databases" qMethod = sdp.QueryMethod_SEARCH // method will be SEARCH, because we are linking multiple sources. } var scope string var query string switch toSDPItemMeta.LocationLevel { case ProjectLevel: // Extract project ID from URI if present (for cross-project references) extractedProjectID := ExtractPathParam("projects", toItemGCPResourceName) if extractedProjectID != "" { scope = extractedProjectID } else { scope = projectID } values := ExtractPathParams(toItemGCPResourceName, keysToExtract...) if len(values) != len(keysToExtract) { log.WithContext(ctx).WithFields(lf).Warnf( "resource name is in unexpected format for project item", ) return } query = strings.Join(values, shared.QuerySeparator) case RegionalLevel: // Extract project ID from URI if present (for cross-project references) extractedProjectID := ExtractPathParam("projects", toItemGCPResourceName) if extractedProjectID != "" { projectID = extractedProjectID } keysToExtract = append(keysToExtract, "regions") values := ExtractPathParams(toItemGCPResourceName, keysToExtract...) if len(values) != len(keysToExtract) { log.WithContext(ctx).WithFields(lf).Warnf( "resource name is in unexpected format for regional item", ) return } scope = fmt.Sprintf("%s.%s", projectID, values[len(values)-1]) // e.g., "my-project.my-region" query = strings.Join(values[:len(values)-1], shared.QuerySeparator) // e.g., "my-instance" or "my-network" case ZonalLevel: // Extract project ID from URI if present (for cross-project references) extractedProjectID := ExtractPathParam("projects", toItemGCPResourceName) if extractedProjectID != "" { projectID = extractedProjectID } keysToExtract = append(keysToExtract, "zones") values := ExtractPathParams(toItemGCPResourceName, keysToExtract...) if len(values) != len(keysToExtract) { log.WithContext(ctx).WithFields(lf).Warnf( "resource name is in unexpected format for zonal item", ) return } scope = fmt.Sprintf("%s.%s", projectID, values[len(values)-1]) // e.g., "my-project.my-zone" query = strings.Join(values[:len(values)-1], shared.QuerySeparator) // e.g., "my-instance" or "my-network" default: sentry.CaptureException(fmt.Errorf("unsupported level %s", toSDPItemMeta.LocationLevel)) return } fromSDPItem.LinkedItemQueries = append(fromSDPItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: impact.ToSDPItemType.String(), Method: qMethod, Query: query, Scope: scope, }, }) } // linkIPOrDNS detects whether the value is an IP address or DNS name and creates // the appropriate linked item query. This is used for fields like "host" that // could contain either type of value. func (l *Linker) linkIPOrDNS(ctx context.Context, fromSDPItem *sdp.Item, toItemValue string) { if toItemValue == "" { return } // Check if it's an IP address first (more specific check) if isIPAddress(toItemValue) { fromSDPItem.LinkedItemQueries = append(fromSDPItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: toItemValue, Scope: "global", }, }) return } // Check if it's a DNS name if isDNSName(toItemValue) { fromSDPItem.LinkedItemQueries = append(fromSDPItem.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: toItemValue, Scope: "global", }, }) return } // If neither IP nor DNS, try the manual adapter linker as fallback if linkFunc, ok := l.manualAdapterLinker[stdlib.NetworkIP]; ok { linkedItemQuery := linkFunc("", fromSDPItem.GetScope(), toItemValue) if linkedItemQuery != nil { fromSDPItem.LinkedItemQueries = append( fromSDPItem.LinkedItemQueries, linkedItemQuery, ) } } } func isIPAddress(s string) bool { return net.ParseIP(s) != nil } func isDNSName(s string) bool { if isIPAddress(s) { return false } // Normalize to lowercase to ensure case-insensitivity and trim trailing dot s = strings.TrimSuffix(strings.ToLower(s), ".") // Must contain at least one dot and only valid DNS characters if strings.Contains(s, ".") && dnsNameRegexp.MatchString(s) { return true } return false } // Source: // https://stackoverflow.com/questions/10306690/what-is-a-regular-expression-which-will-match-a-valid-domain-name-without-a-subd/30007882#30007882 var dnsNameRegexp = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`) // determineScope determines the scope of the GCP resource based on its type and parts. // If it fails to determine the scope. func determineScope(ctx context.Context, projectID string, scope LocationLevel, lf log.Fields, toItemGCPResourceName string, parts []string) string { switch scope { case ProjectLevel: return projectID case RegionalLevel: if len(parts) < 4 { log.WithContext(ctx).WithFields(lf).Warnf( "resource name is in unexpected format for regional item %s", toItemGCPResourceName, ) return "" } return fmt.Sprintf("%s.%s", projectID, parts[len(parts)-3]) case ZonalLevel: if len(parts) < 4 { log.WithContext(ctx).WithFields(lf).Warnf( "resource name is in unexpected format for zonal item %s", toItemGCPResourceName, ) return "" } return fmt.Sprintf("%s.%s", projectID, parts[len(parts)-3]) default: return "" } } ================================================ FILE: sources/gcp/shared/linker_test.go ================================================ package shared import ( "context" "testing" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) func Test_isIPAddress(t *testing.T) { tests := []struct { name string s string want bool }{ { name: "valid IPv4", s: "192.168.1.1", want: true, }, { name: "valid IPv6", s: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", want: true, }, { name: "invalid IP - random string", s: "not.an.ip", want: false, }, { name: "empty string", s: "", want: false, }, { name: "hostname", s: "example.com", want: false, }, { name: "IPv4 with port", s: "127.0.0.1:80", want: false, }, { name: "IPv6 with brackets", s: "[2001:db8::1]", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isIPAddress(tt.s) if got != tt.want { t.Errorf("isIPAddress(%q) = %v, want %v", tt.s, got, tt.want) } }) } } func Test_isDNSName(t *testing.T) { tests := []struct { name string s string want bool }{ { name: "valid DNS name", s: "example.com", want: true, }, { name: "valid DNS name with subdomain", s: "sub.example.com", want: true, }, { name: "valid DNS name with hyphen", s: "my-site.example.com", want: true, }, { name: "valid DNS name with numbers", s: "123.example.com", want: true, }, { name: "single label (no dot)", s: "localhost", want: false, }, { name: "contains underscore (invalid)", s: "foo_bar.example.com", want: false, }, { name: "contains space (invalid)", s: "foo bar.example.com", want: false, }, { name: "empty string", s: "", want: false, }, { name: "valid IPv4 address", s: "192.168.1.1", want: false, }, { name: "valid IPv6 address", s: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", want: false, }, { name: "IPv4 with port", s: "127.0.0.1:80", want: false, }, { name: "DNS name with trailing dot - will be normalized", s: "example.com.", want: true, }, { name: "DNS name with multiple dots", s: "a.b.c.d.e.f.g.com", want: true, }, { name: "DNS name with only dots", s: "...", want: false, }, { name: "bracketed IPv6", s: "[2001:db8::1]", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isDNSName(tt.s) if got != tt.want { t.Errorf("isDNSName(%q) = %v, want %v", tt.s, got, tt.want) } }) } } func TestLinker_AutoLink(t *testing.T) { type args struct { fromSDPItemType shared.ItemType toItemGCPResourceName string toSDPItemType string keys []string } tests := []struct { name string args args }{ { name: "Auto link from ComputeRoute to IP via manual adapters", args: args{ fromSDPItemType: ComputeRoute, toItemGCPResourceName: "203.0.113.42", toSDPItemType: "ip", keys: []string{"nextHopIp"}, }, }, { name: "Auto link from ComputeInstanceTemplate to ComputeImage via dynamic adapters", args: args{ fromSDPItemType: ComputeInstanceTemplate, toItemGCPResourceName: "debian-cloud/debian-11", toSDPItemType: ComputeImage.String(), keys: []string{"properties", "disks", "initializeParams", "sourceImage"}, }, }, { name: "Auto link from ComputeInstanceTemplate to ComputeImage family via dynamic adapters", args: args{ fromSDPItemType: ComputeInstanceTemplate, toItemGCPResourceName: "projects/debian-cloud/global/images/family/debian-11", toSDPItemType: ComputeImage.String(), keys: []string{"properties", "disks", "initializeParams", "sourceImage"}, }, }, { name: "Auto link from ComputeInstanceTemplate to ComputeImage specific image via dynamic adapters", args: args{ fromSDPItemType: ComputeInstanceTemplate, toItemGCPResourceName: "projects/debian-cloud/global/images/debian-11-20240101", toSDPItemType: ComputeImage.String(), keys: []string{"properties", "disks", "initializeParams", "sourceImage"}, }, }, } projectID := "project-test" l := NewLinker() for _, tt := range tests { fromSDPItem := &sdp.Item{} t.Run(tt.name, func(t *testing.T) { l.AutoLink(context.TODO(), projectID, fromSDPItem, tt.args.fromSDPItemType, tt.args.toItemGCPResourceName, tt.args.keys) if len(fromSDPItem.GetLinkedItemQueries()) == 0 { t.Fatalf("Linker.AutoLink() did not return any linked items, expected at least one") } linkedItemQuery := fromSDPItem.GetLinkedItemQueries()[0] if linkedItemQuery.GetQuery() != nil && linkedItemQuery.GetQuery().GetType() != tt.args.toSDPItemType { t.Errorf("Linker.Link() returned linked item with type %s, expected %s", linkedItemQuery.GetQuery().GetType(), tt.args.toSDPItemType) } // For ComputeImage references, verify it uses SEARCH method (handles both family and specific images) if tt.args.toSDPItemType == ComputeImage.String() { if linkedItemQuery.GetQuery().GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("Linker.Link() returned linked item with method %s, expected SEARCH for ComputeImage references", linkedItemQuery.GetQuery().GetMethod()) } } }) } } func TestLinker_AutoLink_NetworkTags(t *testing.T) { projectID := "my-project" l := NewLinker() t.Run("Firewall targetTags → SEARCH ComputeInstance", func(t *testing.T) { item := &sdp.Item{} l.AutoLink(context.TODO(), projectID, item, ComputeFirewall, "web-server", []string{"targetTags"}) assertLinkedItemQuery(t, item, ComputeInstance.String(), sdp.QueryMethod_SEARCH, "web-server", projectID) }) t.Run("Firewall sourceTags → SEARCH ComputeInstance", func(t *testing.T) { item := &sdp.Item{} l.AutoLink(context.TODO(), projectID, item, ComputeFirewall, "nat-gateway", []string{"sourceTags"}) assertLinkedItemQuery(t, item, ComputeInstance.String(), sdp.QueryMethod_SEARCH, "nat-gateway", projectID) }) t.Run("Route tags → SEARCH ComputeInstance", func(t *testing.T) { item := &sdp.Item{} l.AutoLink(context.TODO(), projectID, item, ComputeRoute, "backend", []string{"tags"}) assertLinkedItemQuery(t, item, ComputeInstance.String(), sdp.QueryMethod_SEARCH, "backend", projectID) }) t.Run("Instance template tags.items → SEARCH ComputeFirewall and ComputeRoute", func(t *testing.T) { item := &sdp.Item{} l.AutoLink(context.TODO(), projectID, item, ComputeInstanceTemplate, "http-server", []string{"properties", "tags", "items"}) if len(item.GetLinkedItemQueries()) != 2 { t.Fatalf("expected 2 linked item queries, got %d", len(item.GetLinkedItemQueries())) } assertLinkedItemQuery(t, item, ComputeFirewall.String(), sdp.QueryMethod_SEARCH, "http-server", projectID) q2 := item.GetLinkedItemQueries()[1].GetQuery() if q2.GetType() != ComputeRoute.String() { t.Errorf("second query type = %s, want %s", q2.GetType(), ComputeRoute.String()) } if q2.GetMethod() != sdp.QueryMethod_SEARCH { t.Errorf("second query method = %s, want SEARCH", q2.GetMethod()) } }) t.Run("Empty tag is skipped", func(t *testing.T) { item := &sdp.Item{} l.AutoLink(context.TODO(), projectID, item, ComputeFirewall, " ", []string{"targetTags"}) if len(item.GetLinkedItemQueries()) != 0 { t.Fatalf("expected 0 linked item queries for empty tag, got %d", len(item.GetLinkedItemQueries())) } }) t.Run("URI value on tag key falls through to normal linking", func(t *testing.T) { item := &sdp.Item{} l.AutoLink(context.TODO(), projectID, item, ComputeRoute, "projects/my-project/zones/us-central1-a/instances/my-vm", []string{"tags"}) // Should NOT be treated as network tag (contains /), falls through to normal link rules for _, liq := range item.GetLinkedItemQueries() { if liq.GetQuery().GetMethod() == sdp.QueryMethod_SEARCH && liq.GetQuery().GetType() == ComputeInstance.String() && liq.GetQuery().GetQuery() == "projects/my-project/zones/us-central1-a/instances/my-vm" { t.Error("URI value on tag key should not produce a network-tag SEARCH link") } } }) } func assertLinkedItemQuery(t *testing.T, item *sdp.Item, expectedType string, expectedMethod sdp.QueryMethod, expectedQuery, expectedScope string) { t.Helper() for _, liq := range item.GetLinkedItemQueries() { q := liq.GetQuery() if q.GetType() == expectedType && q.GetMethod() == expectedMethod && q.GetQuery() == expectedQuery && q.GetScope() == expectedScope { return } } t.Errorf("did not find LinkedItemQuery{type=%s, method=%s, query=%s, scope=%s} in %d queries", expectedType, expectedMethod, expectedQuery, expectedScope, len(item.GetLinkedItemQueries())) } func Test_determineScope(t *testing.T) { type args struct { ctx context.Context projectID string locationLevel LocationLevel toItemGCPResourceName string parts []string } tests := []struct { name string args args want string }{ { name: "Project scope", args: args{ ctx: context.TODO(), projectID: "my-project", locationLevel: ProjectLevel, toItemGCPResourceName: "projects/my-project/global/networks/my-network", parts: []string{"projects", "my-project", "global", "networks", "my-network"}, }, want: "my-project", }, { name: "Regional scope", args: args{ ctx: context.TODO(), projectID: "my-project", locationLevel: RegionalLevel, toItemGCPResourceName: "projects/my-project/regions/us-central1/networks/my-network", parts: []string{"projects", "my-project", "regions", "us-central1", "networks", "my-network"}, }, want: "my-project.us-central1", }, { name: "Zonal scope", args: args{ ctx: context.TODO(), projectID: "my-project", locationLevel: ZonalLevel, toItemGCPResourceName: "projects/my-project/zones/us-central1-c/instances/my-instance", parts: []string{"projects", "my-project", "zones", "us-central1-c", "instances", "my-instance"}, }, want: "my-project.us-central1-c", }, { name: "Regional scope, invalid parts length", args: args{ ctx: context.TODO(), projectID: "my-project", locationLevel: RegionalLevel, toItemGCPResourceName: "projects/my-project", parts: []string{"projects", "my-project"}, }, want: "", }, { name: "Zonal scope, invalid parts length", args: args{ ctx: context.TODO(), projectID: "my-project", locationLevel: ZonalLevel, toItemGCPResourceName: "projects/my-project", parts: []string{"projects", "my-project"}, }, want: "", }, { name: "Unknown scope", args: args{ ctx: context.TODO(), projectID: "my-project", locationLevel: LocationLevel("unknown"), toItemGCPResourceName: "projects/my-project/zones/us-central1-c/instances/my-instance", parts: []string{"projects", "my-project", "zones", "us-central1-c", "instances", "my-instance"}, }, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := determineScope(tt.args.ctx, tt.args.projectID, tt.args.locationLevel, nil, tt.args.toItemGCPResourceName, tt.args.parts); got != tt.want { t.Errorf("determineScope() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: sources/gcp/shared/location_info.go ================================================ package shared import ( "fmt" "slices" "strings" ) // LocationInfo encapsulates location information for GCP resources. // It provides type-safe handling of different scope types (project, regional, zonal) // and simplifies scope validation and URL generation. type LocationInfo struct { ProjectID string Region string // Empty for project-level resources Zone string // Empty for project and regional resources } // LocationFromScope parses a scope string into a LocationInfo struct. // // Supported formats: // - Project scope: "project-id" // - Regional scope: "project-id.region" (e.g., "my-project.us-central1") // - Zonal scope: "project-id.zone" (e.g., "my-project.us-central1-a") // // Scope detection uses the dash count in the second component: // - 1 dash => region // - 2 dashes => zone func LocationFromScope(scope string) (LocationInfo, error) { if scope == "" { return LocationInfo{}, fmt.Errorf("scope cannot be empty") } parts := strings.Split(scope, ".") switch len(parts) { case 1: return LocationInfo{ ProjectID: parts[0], }, nil case 2: projectID := parts[0] location := parts[1] switch strings.Count(location, "-") { case 1: return LocationInfo{ ProjectID: projectID, Region: location, }, nil case 2: return LocationInfo{ ProjectID: projectID, Region: ZoneToRegion(location), Zone: location, }, nil default: return LocationInfo{}, fmt.Errorf("invalid location format: %q", location) } default: return LocationInfo{}, fmt.Errorf("invalid scope format: %q", scope) } } // ToScope converts LocationInfo back to scope string format. // If Zone is set, returns "project.zone". // If Region is set but Zone is empty, returns "project.region". // Otherwise, returns just the project ID. func (l LocationInfo) ToScope() string { if l.Zone != "" { return fmt.Sprintf("%s.%s", l.ProjectID, l.Zone) } if l.Region != "" { return fmt.Sprintf("%s.%s", l.ProjectID, l.Region) } return l.ProjectID } // LocationLevel returns the calculated scope type based on Zone and Region fields. // If Zone is set, returns ZonalLevel. // If Region is set (but Zone is empty), returns RegionalLevel. // Otherwise, returns ProjectLevel. func (l LocationInfo) LocationLevel() LocationLevel { if l.Zone != "" { return ZonalLevel } if l.Region != "" { return RegionalLevel } return ProjectLevel } // ProjectLevel returns true if this is a project-level location (no region or zone). func (l LocationInfo) ProjectLevel() bool { return l.Zone == "" && l.Region == "" } // Regional returns true if this is a regional location (has region but no zone). func (l LocationInfo) Regional() bool { return l.Region != "" && l.Zone == "" } // Zonal returns true if this is a zonal location (has zone). func (l LocationInfo) Zonal() bool { return l.Zone != "" } // Equals compares two LocationInfo instances for equality. func (l LocationInfo) Equals(other LocationInfo) bool { return l.ProjectID == other.ProjectID && l.Region == other.Region && l.Zone == other.Zone } // Validate checks if the LocationInfo has valid values. func (l LocationInfo) Validate() error { if l.ProjectID == "" { return fmt.Errorf("project ID cannot be empty") } // If zone is set, region should be derivable if l.Zone != "" && l.Region == "" { return fmt.Errorf("zone is set but region is empty") } return nil } // String returns a human-readable representation of the LocationInfo. func (l LocationInfo) String() string { return l.ToScope() } // NewProjectLocation creates a LocationInfo for a project-level resource. func NewProjectLocation(projectID string) LocationInfo { return LocationInfo{ ProjectID: projectID, } } // NewRegionalLocation creates a LocationInfo for a regional resource. func NewRegionalLocation(projectID, region string) LocationInfo { return LocationInfo{ ProjectID: projectID, Region: region, } } // NewZonalLocation creates a LocationInfo for a zonal resource. func NewZonalLocation(projectID, zone string) LocationInfo { return LocationInfo{ ProjectID: projectID, Region: ZoneToRegion(zone), Zone: zone, } } // LocationsToScopes converts a slice of LocationInfo to a slice of scope strings. func LocationsToScopes(locations []LocationInfo) []string { scopes := make([]string, 0, len(locations)) for _, loc := range locations { scopes = append(scopes, loc.ToScope()) } return scopes } // ValidateScopeForLocations checks if a scope string matches any of the configured locations. // Returns the matching LocationInfo if found, or an error if the scope is not valid for these locations. func ValidateScopeForLocations(scope string, locations []LocationInfo) (LocationInfo, error) { location, err := LocationFromScope(scope) if err != nil { return LocationInfo{}, fmt.Errorf("failed to parse scope %s: %w", scope, err) } for _, loc := range locations { if loc.Equals(location) { return location, nil } } return LocationInfo{}, fmt.Errorf("scope %s not found in configured locations", scope) } // ParseAggregatedListScope parses a scope key from aggregatedList response // Examples: // - "zones/us-central1-a" -> LocationInfo{ProjectID: projectID, Zone: "us-central1-a", Region: "us-central1"} // - "regions/us-central1" -> LocationInfo{ProjectID: projectID, Region: "us-central1"} func ParseAggregatedListScope(projectID, scopeKey string) (LocationInfo, error) { // Handle global scope (e.g., "global" for global resources like health checks) if scopeKey == "global" { return NewProjectLocation(projectID), nil } parts := strings.Split(scopeKey, "/") if len(parts) != 2 { return LocationInfo{}, fmt.Errorf("invalid scope key format: %s", scopeKey) } scopeType := parts[0] // "zones" or "regions" locationName := parts[1] switch scopeType { case "zones": return NewZonalLocation(projectID, locationName), nil case "regions": return NewRegionalLocation(projectID, locationName), nil default: return LocationInfo{}, fmt.Errorf("unsupported scope type: %s", scopeType) } } // GetProjectIDsFromLocations returns unique project IDs from one or more location slices. // This is useful for adapters that manage resources across multiple location types // (e.g., both global and regional resources). func GetProjectIDsFromLocations(locationSlices ...[]LocationInfo) []string { seen := make(map[string]bool) var projects []string for _, locations := range locationSlices { for _, loc := range locations { if !seen[loc.ProjectID] { seen[loc.ProjectID] = true projects = append(projects, loc.ProjectID) } } } return projects } // HasLocationInSlices checks if the given location exists in any of the provided location slices. // This is useful for adapters that need to validate locations across multiple slices // (e.g., filtering aggregatedList results to only configured locations). func HasLocationInSlices(loc LocationInfo, locationSlices ...[]LocationInfo) bool { for _, locations := range locationSlices { if slices.ContainsFunc(locations, loc.Equals) { return true } } return false } ================================================ FILE: sources/gcp/shared/location_info_test.go ================================================ package shared_test import ( "testing" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestLocationFromScope(t *testing.T) { tests := []struct { name string scope string wantProjectID string wantRegion string wantZone string wantLocationLevel gcpshared.LocationLevel wantErr bool }{ { name: "project scope", scope: "my-project", wantProjectID: "my-project", wantLocationLevel: gcpshared.ProjectLevel, }, { name: "regional scope", scope: "my-project.us-central1", wantProjectID: "my-project", wantRegion: "us-central1", wantLocationLevel: gcpshared.RegionalLevel, }, { name: "zonal scope", scope: "my-project.us-central1-a", wantProjectID: "my-project", wantRegion: "us-central1", wantZone: "us-central1-a", wantLocationLevel: gcpshared.ZonalLevel, }, { name: "empty scope", scope: "", wantErr: true, }, { name: "invalid scope has too many parts", scope: "a.b.c", wantErr: true, }, { name: "invalid location dash count", scope: "my-project.global", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Validate that the parsed result is consistent with LocationInfo // Also validates using LocationFromScope for consistency locationInfo, parseErr := gcpshared.LocationFromScope(tt.scope) if tt.wantErr { if parseErr == nil { t.Fatalf("LocationFromScope(%q) expected error but got none", tt.scope) } return } else { if parseErr != nil { t.Fatalf("LocationFromScope(%q) unexpected error: %v", tt.scope, parseErr) } } // Validate the LocationInfo if validateErr := locationInfo.Validate(); validateErr != nil { t.Errorf("LocationInfo.Validate() failed for scope %q: %v", tt.scope, validateErr) } // Verify consistency between LocationFromScope and LocationInfo if locationInfo.ProjectID != tt.wantProjectID { t.Errorf("ProjectID mismatch: LocationPartsFromScope=%q, LocationFromScope=%q", tt.wantProjectID, locationInfo.ProjectID) } if locationInfo.Region != tt.wantRegion { t.Errorf("Region mismatch: LocationPartsFromScope=%q, LocationFromScope=%q", tt.wantRegion, locationInfo.Region) } if locationInfo.Zone != tt.wantZone { t.Errorf("Zone mismatch: LocationPartsFromScope=%q, LocationFromScope=%q", tt.wantZone, locationInfo.Zone) } if locationInfo.LocationLevel() != tt.wantLocationLevel { t.Errorf("ScopeType mismatch: LocationPartsFromScope=%q, LocationFromScope=%q", tt.wantLocationLevel, locationInfo.LocationLevel()) } // Verify scope type detection is mutually exclusive switch tt.wantLocationLevel { case gcpshared.ProjectLevel: if locationInfo.Regional() || locationInfo.Zonal() { t.Errorf("Project scope should not be Regional or Zonal") } if !locationInfo.ProjectLevel() { t.Errorf("Project scope should be ProjectLevel") } case gcpshared.RegionalLevel: if !locationInfo.Regional() { t.Errorf("Regional scope should have Regional()=true") } if locationInfo.Zonal() || locationInfo.ProjectLevel() { t.Errorf("Regional scope should not be Zonal or ProjectLevel") } case gcpshared.ZonalLevel: if !locationInfo.Zonal() { t.Errorf("Zonal scope should have Zonal()=true") } if locationInfo.Regional() || locationInfo.ProjectLevel() { t.Errorf("Zonal scope should not be Regional or ProjectLevel") } } }) } } func TestGetProjectIDsFromLocations(t *testing.T) { tests := []struct { name string slices [][]gcpshared.LocationInfo expected []string }{ { name: "empty slices", slices: [][]gcpshared.LocationInfo{}, expected: nil, }, { name: "single empty slice", slices: [][]gcpshared.LocationInfo{{}}, expected: nil, }, { name: "single slice with one project", slices: [][]gcpshared.LocationInfo{ {gcpshared.NewZonalLocation("project-a", "us-central1-a")}, }, expected: []string{"project-a"}, }, { name: "single slice with multiple locations same project", slices: [][]gcpshared.LocationInfo{ { gcpshared.NewZonalLocation("project-a", "us-central1-a"), gcpshared.NewZonalLocation("project-a", "us-central1-b"), gcpshared.NewZonalLocation("project-a", "us-east1-a"), }, }, expected: []string{"project-a"}, }, { name: "single slice with multiple projects", slices: [][]gcpshared.LocationInfo{ { gcpshared.NewZonalLocation("project-a", "us-central1-a"), gcpshared.NewZonalLocation("project-b", "us-central1-a"), gcpshared.NewZonalLocation("project-c", "us-east1-a"), }, }, expected: []string{"project-a", "project-b", "project-c"}, }, { name: "multiple slices with overlapping projects", slices: [][]gcpshared.LocationInfo{ { gcpshared.NewProjectLocation("project-a"), gcpshared.NewProjectLocation("project-b"), }, { gcpshared.NewRegionalLocation("project-b", "us-central1"), gcpshared.NewRegionalLocation("project-c", "us-east1"), }, }, expected: []string{"project-a", "project-b", "project-c"}, }, { name: "multiple slices with no overlap", slices: [][]gcpshared.LocationInfo{ {gcpshared.NewZonalLocation("project-a", "us-central1-a")}, {gcpshared.NewRegionalLocation("project-b", "us-east1")}, }, expected: []string{"project-a", "project-b"}, }, { name: "preserves order of first occurrence", slices: [][]gcpshared.LocationInfo{ { gcpshared.NewProjectLocation("project-c"), gcpshared.NewProjectLocation("project-a"), }, { gcpshared.NewProjectLocation("project-b"), gcpshared.NewProjectLocation("project-a"), }, }, expected: []string{"project-c", "project-a", "project-b"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := gcpshared.GetProjectIDsFromLocations(tt.slices...) if len(result) != len(tt.expected) { t.Errorf("GetProjectIDsFromLocations() returned %d items, expected %d. Got: %v, expected: %v", len(result), len(tt.expected), result, tt.expected) return } for i, projectID := range result { if projectID != tt.expected[i] { t.Errorf("GetProjectIDsFromLocations()[%d] = %q, expected %q", i, projectID, tt.expected[i]) } } }) } } func TestHasLocationInSlices(t *testing.T) { tests := []struct { name string loc gcpshared.LocationInfo slices [][]gcpshared.LocationInfo expected bool }{ { name: "empty slices", loc: gcpshared.NewZonalLocation("project-a", "us-central1-a"), slices: [][]gcpshared.LocationInfo{}, expected: false, }, { name: "single empty slice", loc: gcpshared.NewZonalLocation("project-a", "us-central1-a"), slices: [][]gcpshared.LocationInfo{{}}, expected: false, }, { name: "location in first slice", loc: gcpshared.NewZonalLocation("project-a", "us-central1-a"), slices: [][]gcpshared.LocationInfo{ { gcpshared.NewZonalLocation("project-a", "us-central1-a"), gcpshared.NewZonalLocation("project-a", "us-central1-b"), }, { gcpshared.NewRegionalLocation("project-b", "us-east1"), }, }, expected: true, }, { name: "location in second slice", loc: gcpshared.NewRegionalLocation("project-b", "us-east1"), slices: [][]gcpshared.LocationInfo{ { gcpshared.NewZonalLocation("project-a", "us-central1-a"), }, { gcpshared.NewRegionalLocation("project-b", "us-east1"), }, }, expected: true, }, { name: "location in neither slice", loc: gcpshared.NewZonalLocation("project-c", "us-west1-a"), slices: [][]gcpshared.LocationInfo{ { gcpshared.NewZonalLocation("project-a", "us-central1-a"), }, { gcpshared.NewRegionalLocation("project-b", "us-east1"), }, }, expected: false, }, { name: "matching project but different region", loc: gcpshared.NewRegionalLocation("project-a", "us-east1"), slices: [][]gcpshared.LocationInfo{ { gcpshared.NewRegionalLocation("project-a", "us-central1"), }, }, expected: false, }, { name: "matching project and region but different zone", loc: gcpshared.NewZonalLocation("project-a", "us-central1-b"), slices: [][]gcpshared.LocationInfo{ { gcpshared.NewZonalLocation("project-a", "us-central1-a"), }, }, expected: false, }, { name: "exact match for project-level location", loc: gcpshared.NewProjectLocation("project-a"), slices: [][]gcpshared.LocationInfo{ { gcpshared.NewProjectLocation("project-a"), gcpshared.NewProjectLocation("project-b"), }, }, expected: true, }, { name: "project-level location not found when only regional exists", loc: gcpshared.NewProjectLocation("project-a"), slices: [][]gcpshared.LocationInfo{ { gcpshared.NewRegionalLocation("project-a", "us-central1"), }, }, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := gcpshared.HasLocationInSlices(tt.loc, tt.slices...) if result != tt.expected { t.Errorf("HasLocationInSlices(%v, ...) = %v, expected %v", tt.loc, result, tt.expected) } }) } } ================================================ FILE: sources/gcp/shared/logging-clients.go ================================================ //go:generate mockgen -destination=./mocks/mock_logging_config_client.go -package=mocks -source=logging-clients.go package shared import ( "context" logging "cloud.google.com/go/logging/apiv2" "cloud.google.com/go/logging/apiv2/loggingpb" ) type LoggingSinkIterator interface { Next() (*loggingpb.LogSink, error) } type LoggingConfigClient interface { ListSinks(ctx context.Context, request *loggingpb.ListSinksRequest) LoggingSinkIterator GetSink(ctx context.Context, req *loggingpb.GetSinkRequest) (*loggingpb.LogSink, error) } type loggingConfigClient struct { configCli *logging.ConfigClient } func (l loggingConfigClient) ListSinks(ctx context.Context, req *loggingpb.ListSinksRequest) LoggingSinkIterator { return l.configCli.ListSinks(ctx, req) } func (l loggingConfigClient) GetSink(ctx context.Context, req *loggingpb.GetSinkRequest) (*loggingpb.LogSink, error) { return l.configCli.GetSink(ctx, req) } // NewLoggingConfigClient creates a new logging config client func NewLoggingConfigClient(cli *logging.ConfigClient) LoggingConfigClient { return &loggingConfigClient{ configCli: cli, } } ================================================ FILE: sources/gcp/shared/manual-adapter-links.go ================================================ package shared import ( "context" "fmt" "strings" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/sdp-go" aws "github.com/overmindtech/cli/sources/aws/shared" "github.com/overmindtech/cli/sources/shared" "github.com/overmindtech/cli/sources/stdlib" ) func ZoneBaseLinkedItemQueryByName(sdpItem shared.ItemType) func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { return func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { name := LastPathComponent(query) zone := ExtractPathParam("zones", query) // Extract project ID from URI if present (for cross-project references) extractedProjectID := ExtractPathParam("projects", query) if extractedProjectID != "" { projectID = extractedProjectID } scope := fromItemScope if zone != "" { scope = fmt.Sprintf("%s.%s", projectID, zone) } if projectID != "" && scope != "" && name != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: sdpItem.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: scope, }, } } return nil } } func RegionBaseLinkedItemQueryByName(sdpItem shared.ItemType) func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { return func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { name := LastPathComponent(query) scope := fromItemScope region := ExtractPathParam("regions", query) // Extract project ID from URI if present (for cross-project references) extractedProjectID := ExtractPathParam("projects", query) if extractedProjectID != "" { projectID = extractedProjectID } if region != "" { scope = fmt.Sprintf("%s.%s", projectID, region) } if projectID != "" && region != "" && name != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: sdpItem.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: scope, }, } } return nil } } func ProjectBaseLinkedItemQueryByName(sdpItem shared.ItemType) func(projectID, _, query string) *sdp.LinkedItemQuery { return func(projectID, _, query string) *sdp.LinkedItemQuery { name := LastPathComponent(query) // Extract project ID from URI if present (for cross-project references) extractedProjectID := ExtractPathParam("projects", query) scope := projectID if extractedProjectID != "" { scope = extractedProjectID } if scope != "" && name != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: sdpItem.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: scope, }, } } return nil } } // ComputeImageLinker handles linking to compute images using SEARCH method. // SEARCH supports any format: full URIs, family names, or specific image names. // The adapter's Search method will intelligently detect the format and use the appropriate API. func ComputeImageLinker(projectID, _, query string) *sdp.LinkedItemQuery { // Extract project ID from the URI if present, otherwise use the provided projectID imageProjectID := ExtractPathParam("projects", query) if imageProjectID == "" { imageProjectID = projectID } // Extract the name/family (last component) name := LastPathComponent(query) if imageProjectID != "" && name != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeImage.String(), Method: sdp.QueryMethod_SEARCH, Query: query, // Pass the full query string so Search can detect the format Scope: imageProjectID, }, } } return nil } // ForwardingRuleTargetLinker handles polymorphic target field in forwarding rules. // The target field can reference multiple resource types (TargetHttpProxy, TargetHttpsProxy, // TargetTcpProxy, TargetSslProxy, TargetPool, TargetVpnGateway, TargetInstance, ServiceAttachment). // This function parses the URI to determine the target type and creates the appropriate link. // Supports both full HTTPS URLs and resource name formats. func ForwardingRuleTargetLinker(projectID, fromItemScope, targetURI string) *sdp.LinkedItemQuery { if targetURI == "" { return nil } // Determine target type from URI path var targetType shared.ItemType var scope string var query string // Extract the resource name (last component) name := LastPathComponent(targetURI) // Normalize URI - remove protocol and domain if present normalizedURI := targetURI if strings.HasPrefix(normalizedURI, "https://") { // Extract path from full URL: https://compute.googleapis.com/compute/v1/projects/{project}/global/targetHttpProxies/{proxy} if idx := strings.Index(normalizedURI, "/projects/"); idx != -1 { normalizedURI = normalizedURI[idx+1:] } } // Check URI path to determine target type (case-insensitive check for robustness) normalizedURI = strings.ToLower(normalizedURI) if strings.Contains(normalizedURI, "/targethttpproxies/") { targetType = ComputeTargetHttpProxy scope = projectID // Global resource query = name } else if strings.Contains(normalizedURI, "/targethttpsproxies/") { targetType = ComputeTargetHttpsProxy scope = projectID // Global resource query = name } else if strings.Contains(normalizedURI, "/targettcpproxies/") { targetType = ComputeTargetTcpProxy scope = projectID // Global resource query = name } else if strings.Contains(normalizedURI, "/targetsslproxies/") { targetType = ComputeTargetSslProxy scope = projectID // Global resource query = name } else if strings.Contains(normalizedURI, "/targetpools/") { targetType = ComputeTargetPool // Use original targetURI for path parameter extraction (case-sensitive) region := ExtractPathParam("regions", targetURI) if region != "" { scope = fmt.Sprintf("%s.%s", projectID, region) } else { scope = projectID } query = name } else if strings.Contains(normalizedURI, "/targetvpngateways/") { targetType = ComputeTargetVpnGateway // Use original targetURI for path parameter extraction (case-sensitive) region := ExtractPathParam("regions", targetURI) if region != "" { scope = fmt.Sprintf("%s.%s", projectID, region) } else { scope = projectID } query = name } else if strings.Contains(normalizedURI, "/targetinstances/") { targetType = ComputeTargetInstance // Use original targetURI for path parameter extraction (case-sensitive) zone := ExtractPathParam("zones", targetURI) if zone != "" { scope = fmt.Sprintf("%s.%s", projectID, zone) } else { scope = projectID } query = name } else if strings.Contains(normalizedURI, "/serviceattachments/") { targetType = ComputeServiceAttachment // Use original targetURI for path parameter extraction (case-sensitive) region := ExtractPathParam("regions", targetURI) if region != "" { scope = fmt.Sprintf("%s.%s", projectID, region) } else { scope = projectID } query = name } else { // Unknown target type return nil } if projectID != "" && scope != "" && query != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: targetType.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, }, } } return nil } // BackendServiceOrBucketLinker handles polymorphic backend service/bucket fields in URL maps. // The service field can reference either a BackendService (global or regional) or a BackendBucket (global). // This function parses the URI to determine the target type and creates the appropriate link. // Supports both full HTTPS URLs and resource name formats. func BackendServiceOrBucketLinker(projectID, fromItemScope, backendURI string) *sdp.LinkedItemQuery { if backendURI == "" { return nil } // Determine target type from URI path var targetType shared.ItemType var scope string var query string // Extract the resource name (last component) name := LastPathComponent(backendURI) // Normalize URI - remove protocol and domain if present normalizedURI := backendURI if strings.HasPrefix(normalizedURI, "https://") { // Extract path from full URL: https://compute.googleapis.com/compute/v1/projects/{project}/global/backendServices/{service} if idx := strings.Index(normalizedURI, "/projects/"); idx != -1 { normalizedURI = normalizedURI[idx+1:] } } // Check URI path to determine target type (case-insensitive check for robustness) normalizedURILower := strings.ToLower(normalizedURI) if strings.Contains(normalizedURILower, "/backendbuckets/") { // Backend Bucket (global, project-scoped) targetType = ComputeBackendBucket scope = projectID query = name } else if strings.Contains(normalizedURILower, "/backendservices/") { // Backend Service - always use same type, scope differentiates global vs regional targetType = ComputeBackendService // Use original backendURI for path parameter extraction (case-sensitive) region := ExtractPathParam("regions", backendURI) if region != "" { // Regional backend service - scope includes region scope = fmt.Sprintf("%s.%s", projectID, region) } else { // Global backend service - scope is project only scope = projectID } query = name } else { // Unknown backend type return nil } if projectID != "" && scope != "" && query != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: targetType.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, }, } } return nil } // HealthCheckLinker handles polymorphic health check fields in compute resources. // Health checks can be either global (project-scoped) or regional (project.region-scoped). // This function parses the URI to determine the scope and creates the appropriate link. // Supports both full HTTPS URLs and resource name formats. func HealthCheckLinker(projectID, fromItemScope, healthCheckURI string) *sdp.LinkedItemQuery { if healthCheckURI == "" { return nil } // Extract the resource name (last component) name := LastPathComponent(healthCheckURI) // Normalize URI - remove protocol and domain if present normalizedURI := healthCheckURI if strings.HasPrefix(normalizedURI, "https://") { // Extract path from full URL: https://compute.googleapis.com/compute/v1/projects/{project}/global/healthChecks/{name} if idx := strings.Index(normalizedURI, "/projects/"); idx != -1 { normalizedURI = normalizedURI[idx+1:] } } // Check URI path to determine scope (case-insensitive check for robustness) normalizedURILower := strings.ToLower(normalizedURI) if !strings.Contains(normalizedURILower, "/healthchecks/") { // Not a health check URL return nil } // Determine if it's regional or global var scope string region := ExtractPathParam("regions", healthCheckURI) if region != "" { // Regional health check - scope includes region scope = fmt.Sprintf("%s.%s", projectID, region) } else { // Global health check - scope is project only scope = projectID } if projectID != "" && scope != "" && name != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: name, Scope: scope, }, } } return nil } // AddressUsersLinker handles the polymorphic users field in Compute Address resources. // The users field contains an array of URLs referencing resources that are using the address. // This can include: forwarding rules (regional/global), instances, target VPN gateways, routers. // This function parses the URI to determine the resource type and creates the appropriate link. // Supports both full HTTPS URLs and resource name formats. func AddressUsersLinker(ctx context.Context, projectID, userURI string) *sdp.LinkedItemQuery { if userURI == "" { return nil } // Determine resource type from URI path var targetType shared.ItemType var scope string var query string // Extract the resource name (last component) name := LastPathComponent(userURI) // Normalize URI - remove protocol and domain if present normalizedURI := userURI if strings.HasPrefix(normalizedURI, "https://") { // Extract path from full URL: https://compute.googleapis.com/compute/v1/projects/{project}/regions/{region}/forwardingRules/{rule} if idx := strings.Index(normalizedURI, "/projects/"); idx != -1 { normalizedURI = normalizedURI[idx+1:] } } // Check URI path to determine resource type (case-insensitive check for robustness) normalizedURILower := strings.ToLower(normalizedURI) if strings.Contains(normalizedURILower, "/global/forwardingrules/") { // Global forwarding rule (project-scoped) targetType = ComputeGlobalForwardingRule scope = projectID query = name } else if strings.Contains(normalizedURILower, "/forwardingrules/") { // Regional forwarding rule targetType = ComputeForwardingRule // Use original userURI for path parameter extraction (case-sensitive) region := ExtractPathParam("regions", userURI) if region != "" { scope = fmt.Sprintf("%s.%s", projectID, region) } else { // Try to extract scope from URI using utility function extractedScope, err := ExtractScopeFromURI(ctx, userURI) if err == nil { scope = extractedScope } else { scope = projectID } } query = name } else if strings.Contains(normalizedURILower, "/instances/") { // VM Instance (zonal) targetType = ComputeInstance // Use original userURI for path parameter extraction (case-sensitive) zone := ExtractPathParam("zones", userURI) if zone != "" { scope = fmt.Sprintf("%s.%s", projectID, zone) } else { // Try to extract scope from URI using utility function extractedScope, err := ExtractScopeFromURI(ctx, userURI) if err == nil { scope = extractedScope } else { scope = projectID } } query = name } else if strings.Contains(normalizedURILower, "/targetvpngateways/") { // Target VPN Gateway (regional) targetType = ComputeTargetVpnGateway // Use original userURI for path parameter extraction (case-sensitive) region := ExtractPathParam("regions", userURI) if region != "" { scope = fmt.Sprintf("%s.%s", projectID, region) } else { // Try to extract scope from URI using utility function extractedScope, err := ExtractScopeFromURI(ctx, userURI) if err == nil { scope = extractedScope } else { scope = projectID } } query = name } else if strings.Contains(normalizedURILower, "/routers/") { // Router (regional) targetType = ComputeRouter // Use original userURI for path parameter extraction (case-sensitive) region := ExtractPathParam("regions", userURI) if region != "" { scope = fmt.Sprintf("%s.%s", projectID, region) } else { // Try to extract scope from URI using utility function extractedScope, err := ExtractScopeFromURI(ctx, userURI) if err == nil { scope = extractedScope } else { scope = projectID } } query = name } else { // Unknown resource type - log but don't fail log.Debugf("AddressUsersLinker: unknown resource type in users field: %s", userURI) return nil } if projectID != "" && scope != "" && query != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: targetType.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: scope, }, } } return nil } func AWSLinkByARN(awsItem string) func(_, _, arn string) *sdp.LinkedItemQuery { return func(_, _, arn string) *sdp.LinkedItemQuery { // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html#arns-syntax parts := strings.Split(arn, ":") if len(parts) < 5 { log.Warnf("invalid ARN: %s", arn) return nil } /* arn:partition:service:region:account-id:resource-id arn:partition:service:region:account-id:resource-type/resource-id arn:partition:service:region:account-id:resource-type:resource-id */ region := parts[3] accountID := parts[4] scope := accountID if region != "" { scope = fmt.Sprintf("%s.%s", accountID, region) } return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: awsItem, Method: sdp.QueryMethod_SEARCH, Query: arn, // By default, we search by the full ARN Scope: scope, }, } } } // ManualAdapterLinksByAssetType defines how to link a specific item type to its linked items. // This is used when the query that holds the linked item information is not a standard query for the dynamic adapter framework. // So we need to manually define how to create the linked item query based on the item type and the query string. // // Expects that the query will have all the necessary information to create the linked item query. var ManualAdapterLinksByAssetType = map[shared.ItemType]func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery{ ComputeInstance: ZoneBaseLinkedItemQueryByName(ComputeInstance), ComputeInstanceGroup: ZoneBaseLinkedItemQueryByName(ComputeInstanceGroup), ComputeInstanceGroupManager: ZoneBaseLinkedItemQueryByName(ComputeInstanceGroupManager), ComputeRegionInstanceGroupManager: RegionBaseLinkedItemQueryByName(ComputeRegionInstanceGroupManager), ComputeAutoscaler: ZoneBaseLinkedItemQueryByName(ComputeAutoscaler), ComputeDisk: ZoneBaseLinkedItemQueryByName(ComputeDisk), ComputeReservation: ZoneBaseLinkedItemQueryByName(ComputeReservation), ComputeNodeGroup: ZoneBaseLinkedItemQueryByName(ComputeNodeGroup), ComputeInstantSnapshot: ZoneBaseLinkedItemQueryByName(ComputeInstantSnapshot), ComputeMachineImage: ProjectBaseLinkedItemQueryByName(ComputeMachineImage), ComputeSecurityPolicy: ProjectBaseLinkedItemQueryByName(ComputeSecurityPolicy), ComputeSnapshot: ProjectBaseLinkedItemQueryByName(ComputeSnapshot), ComputeHealthCheck: HealthCheckLinker, // Handles both global and regional health checks ComputeBackendService: BackendServiceOrBucketLinker, // Handles both global and regional backend services, plus backend buckets ComputeImage: ComputeImageLinker, // Custom linker that uses SEARCH for all image references (handles both names and families) ComputeAddress: RegionBaseLinkedItemQueryByName(ComputeAddress), ComputeForwardingRule: RegionBaseLinkedItemQueryByName(ComputeForwardingRule), ComputeInterconnectAttachment: RegionBaseLinkedItemQueryByName(ComputeInterconnectAttachment), ComputeNodeTemplate: RegionBaseLinkedItemQueryByName(ComputeNodeTemplate), // Target proxy types (global, project-scoped) - use polymorphic linker for forwarding rule target field ComputeTargetHttpProxy: ForwardingRuleTargetLinker, ComputeTargetHttpsProxy: ForwardingRuleTargetLinker, ComputeTargetTcpProxy: ForwardingRuleTargetLinker, ComputeTargetSslProxy: ForwardingRuleTargetLinker, // Target pool (regional) - use polymorphic linker ComputeTargetPool: ForwardingRuleTargetLinker, // Target VPN Gateway (regional) - use polymorphic linker ComputeTargetVpnGateway: ForwardingRuleTargetLinker, // Target Instance (zonal) - use polymorphic linker ComputeTargetInstance: ForwardingRuleTargetLinker, // Service Attachment (regional) - use polymorphic linker ComputeServiceAttachment: ForwardingRuleTargetLinker, CloudKMSCryptoKeyVersion: func(projectID, _, keyName string) *sdp.LinkedItemQuery { location := ExtractPathParam("locations", keyName) keyRing := ExtractPathParam("keyRings", keyName) cryptoKey := ExtractPathParam("cryptoKeys", keyName) cryptoKeyVersion := ExtractPathParam("cryptoKeyVersions", keyName) if projectID != "" && location != "" && keyRing != "" && cryptoKey != "" && cryptoKeyVersion != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSCryptoKeyVersion.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(location, keyRing, cryptoKey, cryptoKeyVersion), Scope: projectID, }, } } return nil }, IAMServiceAccountKey: ProjectBaseLinkedItemQueryByName(IAMServiceAccountKey), IAMServiceAccount: ProjectBaseLinkedItemQueryByName(IAMServiceAccount), CloudKMSKeyRing: RegionBaseLinkedItemQueryByName(CloudKMSKeyRing), // ProjectFolderOrganizationLinker handles polymorphic project/folder/organization fields in resource names. // The name field can reference projects, folders, or organizations depending on the resource scope. // This function parses the name to determine the target type and creates the appropriate link. // This is registered for CloudResourceManagerProject but can detect and link to all three types. CloudResourceManagerProject: func(projectID, _, name string) *sdp.LinkedItemQuery { if name == "" { return nil } // Extract resource ID based on prefix - handle projects, folders, and organizations if strings.HasPrefix(name, "projects/") { projectIDFromName := ExtractPathParam("projects", name) if projectIDFromName != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudResourceManagerProject.String(), Method: sdp.QueryMethod_GET, Query: projectIDFromName, Scope: projectIDFromName, // Project scope uses project ID as scope }, } } } else if strings.HasPrefix(name, "folders/") { folderID := ExtractPathParam("folders", name) if folderID != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudResourceManagerFolder.String(), Method: sdp.QueryMethod_GET, Query: folderID, Scope: projectID, // Folder scope uses project ID (may need adjustment when folder adapter is created) }, } } } else if strings.HasPrefix(name, "organizations/") { orgID := ExtractPathParam("organizations", name) if orgID != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudResourceManagerOrganization.String(), Method: sdp.QueryMethod_GET, Query: orgID, Scope: projectID, // Organization scope uses project ID (may need adjustment when org adapter is created) }, } } } return nil }, CloudResourceManagerFolder: func(projectID, _, name string) *sdp.LinkedItemQuery { if name == "" { return nil } // Extract folder ID from name if strings.HasPrefix(name, "folders/") { folderID := ExtractPathParam("folders", name) if folderID != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudResourceManagerFolder.String(), Method: sdp.QueryMethod_GET, Query: folderID, Scope: projectID, // Folder scope uses project ID (may need adjustment when folder adapter is created) }, } } } return nil }, CloudResourceManagerOrganization: func(projectID, _, name string) *sdp.LinkedItemQuery { if name == "" { return nil } // Extract organization ID from name if strings.HasPrefix(name, "organizations/") { orgID := ExtractPathParam("organizations", name) if orgID != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudResourceManagerOrganization.String(), Method: sdp.QueryMethod_GET, Query: orgID, Scope: projectID, // Organization scope uses project ID (may need adjustment when org adapter is created) }, } } } return nil }, stdlib.NetworkIP: func(_, _, query string) *sdp.LinkedItemQuery { if query != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: query, Scope: "global", }, } } return nil }, stdlib.NetworkDNS: func(_, _, query string) *sdp.LinkedItemQuery { if query != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: query, Scope: "global", }, } } return nil }, stdlib.NetworkHTTP: func(_, _, query string) *sdp.LinkedItemQuery { if query != "" { // Extract the base URL (remove query parameters and fragments) httpURL := query if idx := strings.Index(httpURL, "?"); idx != -1 { httpURL = httpURL[:idx] } if idx := strings.Index(httpURL, "#"); idx != -1 { httpURL = httpURL[:idx] } if httpURL != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: httpURL, Scope: "global", }, } } } return nil }, CloudKMSCryptoKey: func(projectID, _, keyName string) *sdp.LinkedItemQuery { //"projects/{kms_project_id}/locations/{region}/keyRings/{key_region}/cryptoKeys/{key} values := ExtractPathParams(keyName, "locations", "keyRings", "cryptoKeys") if len(values) != 3 { return nil } location := values[0] keyRing := values[1] cryptoKey := values[2] if projectID != "" && location != "" && keyRing != "" && cryptoKey != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: CloudKMSCryptoKey.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(location, keyRing, cryptoKey), Scope: projectID, }, } } return nil }, BigQueryTable: func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { if query == "" { return nil } // Supported formats: // 1) //bigquery.googleapis.com/projects/PROJECT_ID/datasets/DATASET_ID/tables/TABLE_ID // See: https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.dataScans#DataSource // 2) projects/PROJECT_ID/datasets/DATASET_ID/tables/TABLE_ID // 3) {projectId}.{datasetId}.{tableId} // See: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions#bigqueryconfig // 4) bq://projectId or bq://projectId.bqDatasetId or bq://projectId.bqDatasetId.bqTableId // See: https://cloud.google.com/vertex-ai/docs/reference/rest/v1/BigQueryDestination // Try full URI format first: //bigquery.googleapis.com/projects/PROJECT_ID/datasets/DATASET_ID/tables/TABLE_ID if strings.HasPrefix(query, "//bigquery.googleapis.com/") || strings.HasPrefix(query, "https://bigquery.googleapis.com/") { values := ExtractPathParams(query, "projects", "datasets", "tables") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryTable.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values[1], values[2]), Scope: values[0], }, } } } // Try path format: projects/PROJECT_ID/datasets/DATASET_ID/tables/TABLE_ID if strings.HasPrefix(query, "projects/") { values := ExtractPathParams(query, "projects", "datasets", "tables") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryTable.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(values[1], values[2]), Scope: values[0], }, } } } // Try dot-separated format: {projectId}.{datasetId}.{tableId} or bq://projectId.bqDatasetId.bqTableId query = strings.TrimPrefix(query, "bq://") parts := strings.Split(query, ".") if len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryTable.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(parts[1], parts[2]), Scope: parts[0], }, } } return nil }, aws.KinesisStream: AWSLinkByARN("kinesis-stream"), aws.KinesisStreamConsumer: AWSLinkByARN("kinesis-stream-consumer"), aws.IAMRole: AWSLinkByARN("iam-role"), aws.MSKCluster: AWSLinkByARN("msk-cluster"), SQLAdminInstance: func(projectID, _, query string) *sdp.LinkedItemQuery { // Supported formats: // 1) {project}:{location}:{instance} (Cloud Run format) // See: https://cloud.google.com/run/docs/reference/rest/v2/Volume#cloudsqlinstance // 2) projects/{project}/instances/{instance} (full resource name) // 3) {instance} (simple instance name, uses projectID from context) // Try colon separator first parts := strings.Split(query, ":") if len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: SQLAdminInstance.String(), Method: sdp.QueryMethod_GET, Query: parts[2], Scope: parts[0], }, } } // Try slash separator (full resource name) if strings.Contains(query, "/") { values := ExtractPathParams(query, "projects", "instances") if len(values) == 2 && values[0] != "" && values[1] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: SQLAdminInstance.String(), Method: sdp.QueryMethod_GET, Query: values[1], Scope: values[0], }, } } } // Single word (simple instance name) - use projectID from context if !strings.Contains(query, ":") && !strings.Contains(query, "/") && query != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: SQLAdminInstance.String(), Method: sdp.QueryMethod_GET, Query: query, Scope: projectID, }, } } return nil }, BigQueryDataset: func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { // Supported formats: // 1) datasetId (e.g., "my_dataset") // 2) projects/{project}/datasets/{dataset} // 3) project:dataset (BigQuery FullID style) // 4) bigquery.googleapis.com/projects/{project}/datasets/{dataset} if query == "" { return nil } // Normalize URI formats (bigquery.googleapis.com/... or https://bigquery.googleapis.com/...) normalizedQuery := query if strings.Contains(query, ".googleapis.com/") { // Handle service destination formats: bigquery.googleapis.com/path parts := strings.SplitN(query, ".googleapis.com/", 2) if len(parts) > 1 { path := parts[1] // Strip version paths like /v1/, /v2/, /bigquery/v2/, etc. pathParts := strings.Split(path, "/") // Remove version paths (v1, v2, bigquery/v2, etc.) that appear before "projects" for i, part := range pathParts { if part == "projects" { normalizedQuery = strings.Join(pathParts[i:], "/") break } } } } else if strings.HasPrefix(query, "https://") || strings.HasPrefix(query, "http://") { // Handle HTTPS/HTTP URLs: https://bigquery.googleapis.com/bigquery/v2/projects/... uri := query[strings.Index(query, "://")+3:] parts := strings.SplitN(uri, "/", 2) if len(parts) > 1 { path := parts[1] // Strip version paths pathParts := strings.Split(path, "/") for i, part := range pathParts { if part == "projects" { normalizedQuery = strings.Join(pathParts[i:], "/") break } } } } // Try path-style: projects/{project}/datasets/{dataset} if strings.Contains(normalizedQuery, "projects/") && strings.Contains(normalizedQuery, "datasets/") { values := ExtractPathParams(normalizedQuery, "projects", "datasets") if len(values) == 2 && values[0] != "" && values[1] != "" { parsedProject := values[0] dataset := values[1] scope := parsedProject return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Query: dataset, Scope: scope, }, } } } // Try fullID style: project:dataset if strings.HasPrefix(query, "project:") { parts := strings.Split(query, ":") if len(parts) == 2 && parts[0] != "" && parts[1] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Query: parts[1], // dataset ID Scope: parts[0], // project ID }, } } } if strings.Contains(query, ":") || strings.Contains(query, "/") { // At this point we don't recognize the pattern. return nil } // Fallback: treat as datasetId in current project if projectID != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryDataset.String(), Method: sdp.QueryMethod_GET, Query: query, // dataset ID Scope: projectID, }, } } return nil }, BigQueryModel: func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { // Supported format: // projects/{project}/datasets/{dataset}/models/{model} if query == "" { return nil } if strings.HasPrefix(query, "projects/") { // Path-style values := ExtractPathParams(query, "projects", "datasets", "models") if len(values) == 3 && values[0] != "" && values[1] != "" && values[2] != "" { parsedProject := values[0] dataset := values[1] model := values[2] scope := parsedProject return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryModel.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(dataset, model), Scope: scope, }, } } } if strings.HasPrefix(query, "datasets/") { values := ExtractPathParams(query, "datasets", "models") if len(values) == 2 && values[0] != "" && values[1] != "" { scope := projectID dataset := values[0] model := values[1] return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: BigQueryModel.String(), Method: sdp.QueryMethod_GET, Query: shared.CompositeLookupKey(dataset, model), Scope: scope, }, } } } return nil }, StorageBucket: func(projectID, fromItemScope, query string) *sdp.LinkedItemQuery { if query == "" { return nil } // Supported formats: // 1) //storage.googleapis.com/projects/PROJECT_ID/buckets/BUCKET_ID // 2) gs://bucket-name // 3) gs://bucket-name/path/to/file // 4) bucket-name (without gs:// prefix) // Try full URI format first: //storage.googleapis.com/projects/PROJECT_ID/buckets/BUCKET_ID if strings.HasPrefix(query, "//storage.googleapis.com/") || strings.HasPrefix(query, "https://storage.googleapis.com/") { values := ExtractPathParams(query, "projects", "buckets") if len(values) == 2 && values[0] != "" && values[1] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: StorageBucket.String(), Method: sdp.QueryMethod_GET, Query: values[1], Scope: values[0], }, } } } // Try path format: projects/PROJECT_ID/buckets/BUCKET_ID if strings.HasPrefix(query, "projects/") { values := ExtractPathParams(query, "projects", "buckets") if len(values) == 2 && values[0] != "" && values[1] != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: StorageBucket.String(), Method: sdp.QueryMethod_GET, Query: values[1], Scope: values[0], }, } } } // Strip gs:// prefix if present query = strings.TrimPrefix(query, "gs://") // Extract bucket name (everything before the first slash) bucketName := query if before, _, ok := strings.Cut(query, "/"); ok { bucketName = before } // Validate bucket name is not empty if bucketName == "" { return nil } // Storage buckets are project-scoped if projectID != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: StorageBucket.String(), Method: sdp.QueryMethod_GET, Query: bucketName, Scope: projectID, }, } } return nil }, // StorageBucketIAMPolicy: link by bucket name using GET (one policy item per bucket). StorageBucketIAMPolicy: func(projectID, _, query string) *sdp.LinkedItemQuery { bucketName := query if before, _, ok := strings.Cut(query, "/"); ok { bucketName = before } if projectID == "" || bucketName == "" { return nil } return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: StorageBucketIAMPolicy.String(), Method: sdp.QueryMethod_GET, Query: bucketName, Scope: projectID, }, } }, // OrgPolicyPolicy name field can reference parent project, folder, or organization // This linker is registered for all three parent types since the name field can reference any of them // Format: projects/{project_number}/policies/{constraint} or // folders/{folder_id}/policies/{constraint} or // organizations/{organization_id}/policies/{constraint} CloudResourceManagerProject: func(projectID, _, policyName string) *sdp.LinkedItemQuery { return orgPolicyParentLinker(projectID, policyName) }, CloudResourceManagerFolder: func(projectID, _, policyName string) *sdp.LinkedItemQuery { return orgPolicyParentLinker(projectID, policyName) }, CloudResourceManagerOrganization: func(projectID, _, policyName string) *sdp.LinkedItemQuery { return orgPolicyParentLinker(projectID, policyName) }, } // orgPolicyParentLinker parses an org policy name to determine the parent resource type // and creates a linked item query for the appropriate parent (project, folder, or organization). // The policy name format is: projects/{project_number}/policies/{constraint} or // // folders/{folder_id}/policies/{constraint} or // organizations/{organization_id}/policies/{constraint} // // It also handles simple project references: projects/{project_id} (without /policies/) // In that case, the scope should be the current project (projectID), not the referenced project. func orgPolicyParentLinker(projectID, policyName string) *sdp.LinkedItemQuery { if policyName == "" { return nil } var targetType shared.ItemType var parentID string var scope string // Parse the policy name to determine parent type if strings.HasPrefix(policyName, "projects/") { // Check if this is a simple project reference (projects/{project_id}) or org policy (projects/{project_id}/policies/...) if strings.Contains(policyName, "/policies/") { // Org policy format: projects/{project_number}/policies/{constraint} values := ExtractPathParams(policyName, "projects") if len(values) >= 1 && values[0] != "" { targetType = CloudResourceManagerProject parentID = values[0] scope = parentID // For org policies, use the project ID as scope } } else { // Simple project reference: projects/{project_id} // Extract project ID and use current project as scope values := ExtractPathParams(policyName, "projects") if len(values) >= 1 && values[0] != "" { targetType = CloudResourceManagerProject parentID = values[0] scope = projectID // Use current project as scope when querying for another project } } } else if strings.HasPrefix(policyName, "folders/") { // Extract folder ID from: folders/{folder_id}/policies/{constraint} values := ExtractPathParams(policyName, "folders") if len(values) >= 1 && values[0] != "" { targetType = CloudResourceManagerFolder parentID = values[0] // Folders are organization-scoped, but we don't have org ID here // Use projectID as fallback scope (folder adapters will need to handle this) scope = projectID } } else if strings.HasPrefix(policyName, "organizations/") { // Extract organization ID from: organizations/{organization_id}/policies/{constraint} values := ExtractPathParams(policyName, "organizations") if len(values) >= 1 && values[0] != "" { targetType = CloudResourceManagerOrganization parentID = values[0] // Organizations are global-scoped scope = "global" } } if parentID != "" && scope != "" { return &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: targetType.String(), Method: sdp.QueryMethod_GET, Query: parentID, Scope: scope, }, } } return nil } ================================================ FILE: sources/gcp/shared/manual-adapter-links_test.go ================================================ package shared import ( "reflect" "testing" "github.com/overmindtech/cli/go/sdp-go" aws "github.com/overmindtech/cli/sources/aws/shared" "github.com/overmindtech/cli/sources/stdlib" ) func TestAWSLinkByARN(t *testing.T) { type args struct { awsItem string } tests := []struct { name string arn string args args want *sdp.LinkedItemQuery }{ { name: "Link by ARN for AWS IAM Role - global scope", arn: "arn:aws:iam::123456789012:role/MyRole", args: args{ awsItem: "iam-role", }, want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "iam-role", Method: sdp.QueryMethod_SEARCH, Query: "arn:aws:iam::123456789012:role/MyRole", Scope: "123456789012", }, }, }, { name: "Link by ARN for AWS KMS Key - region scope", arn: "arn:aws:kms:us-west-2:123456789012:key/abcd1234-56ef-78gh-90ij-klmnopqrstuv", args: args{ awsItem: "kms-key", }, want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "kms-key", Method: sdp.QueryMethod_SEARCH, Query: "arn:aws:kms:us-west-2:123456789012:key/abcd1234-56ef-78gh-90ij-klmnopqrstuv", Scope: "123456789012.us-west-2", // Region scope }, }, }, { name: "Malformed ARN", arn: "invalid-arn", args: args{ awsItem: "iam-role", }, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFunc := AWSLinkByARN(tt.args.awsItem) gotLIQ := gotFunc("", "", tt.arn) if !reflect.DeepEqual(gotLIQ, tt.want) { t.Errorf("AWSLinkByARN() = %v, want %v", gotLIQ, tt.want) } }) } } func TestForwardingRuleTargetLinker(t *testing.T) { projectID := "test-project" tests := []struct { name string targetURI string want *sdp.LinkedItemQuery }{ // Global Target HTTP Proxy tests { name: "Global Target HTTP Proxy - full HTTPS URL", targetURI: "https://www.googleapis.com/compute/v1/projects/test-project/global/targetHttpProxies/my-http-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetHttpProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-http-proxy", Scope: projectID, }, }, }, { name: "Global Target HTTP Proxy - resource name format", targetURI: "projects/test-project/global/targetHttpProxies/my-http-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetHttpProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-http-proxy", Scope: projectID, }, }, }, { name: "Global Target HTTP Proxy - compute.googleapis.com URL", targetURI: "https://compute.googleapis.com/compute/v1/projects/test-project/global/targetHttpProxies/my-http-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetHttpProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-http-proxy", Scope: projectID, }, }, }, // Global Target HTTPS Proxy tests { name: "Global Target HTTPS Proxy - full HTTPS URL", targetURI: "https://www.googleapis.com/compute/v1/projects/test-project/global/targetHttpsProxies/my-https-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetHttpsProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-https-proxy", Scope: projectID, }, }, }, { name: "Global Target HTTPS Proxy - resource name format", targetURI: "projects/test-project/global/targetHttpsProxies/my-https-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetHttpsProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-https-proxy", Scope: projectID, }, }, }, // Global Target TCP Proxy tests { name: "Global Target TCP Proxy - full HTTPS URL", targetURI: "https://www.googleapis.com/compute/v1/projects/test-project/global/targetTcpProxies/my-tcp-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetTcpProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-tcp-proxy", Scope: projectID, }, }, }, { name: "Global Target TCP Proxy - resource name format", targetURI: "projects/test-project/global/targetTcpProxies/my-tcp-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetTcpProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-tcp-proxy", Scope: projectID, }, }, }, // Global Target SSL Proxy tests { name: "Global Target SSL Proxy - full HTTPS URL", targetURI: "https://www.googleapis.com/compute/v1/projects/test-project/global/targetSslProxies/my-ssl-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetSslProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-ssl-proxy", Scope: projectID, }, }, }, { name: "Global Target SSL Proxy - resource name format", targetURI: "projects/test-project/global/targetSslProxies/my-ssl-proxy", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetSslProxy.String(), Method: sdp.QueryMethod_GET, Query: "my-ssl-proxy", Scope: projectID, }, }, }, // Regional Target Pool tests { name: "Regional Target Pool - full HTTPS URL", targetURI: "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-central1/targetPools/my-target-pool", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetPool.String(), Method: sdp.QueryMethod_GET, Query: "my-target-pool", Scope: "test-project.us-central1", }, }, }, { name: "Regional Target Pool - resource name format", targetURI: "projects/test-project/regions/us-central1/targetPools/my-target-pool", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetPool.String(), Method: sdp.QueryMethod_GET, Query: "my-target-pool", Scope: "test-project.us-central1", }, }, }, // Regional Target VPN Gateway tests { name: "Regional Target VPN Gateway - full HTTPS URL", targetURI: "https://www.googleapis.com/compute/v1/projects/test-project/regions/us-west1/targetVpnGateways/my-vpn-gateway", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetVpnGateway.String(), Method: sdp.QueryMethod_GET, Query: "my-vpn-gateway", Scope: "test-project.us-west1", }, }, }, { name: "Regional Target VPN Gateway - resource name format", targetURI: "projects/test-project/regions/us-west1/targetVpnGateways/my-vpn-gateway", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetVpnGateway.String(), Method: sdp.QueryMethod_GET, Query: "my-vpn-gateway", Scope: "test-project.us-west1", }, }, }, // Zonal Target Instance tests { name: "Zonal Target Instance - full HTTPS URL", targetURI: "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a/targetInstances/my-target-instance", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetInstance.String(), Method: sdp.QueryMethod_GET, Query: "my-target-instance", Scope: "test-project.us-central1-a", }, }, }, { name: "Zonal Target Instance - resource name format", targetURI: "projects/test-project/zones/us-central1-a/targetInstances/my-target-instance", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetInstance.String(), Method: sdp.QueryMethod_GET, Query: "my-target-instance", Scope: "test-project.us-central1-a", }, }, }, // Edge cases { name: "Empty target URI", targetURI: "", want: nil, }, { name: "Unknown target type", targetURI: "projects/test-project/global/unknownResources/unknown", want: nil, }, { name: "Malformed URI - no resource name (trailing slash)", targetURI: "projects/test-project/global/targetHttpProxies/", // LastPathComponent returns "targetHttpProxies" (the resource type) when URI ends with slash // This results in a link being created but with incorrect query value // TODO: This might need to be fixed to return nil for malformed URIs want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeTargetHttpProxy.String(), Method: sdp.QueryMethod_GET, Query: "targetHttpProxies", // LastPathComponent returns this from trailing slash Scope: projectID, }, }, }, { name: "URI without project path context", targetURI: "targetHttpProxies/my-proxy", // The function expects "/targetHttpProxies/" with slashes on both sides, // so this format won't match and returns nil want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ForwardingRuleTargetLinker(projectID, "", tt.targetURI) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ForwardingRuleTargetLinker() = %v, want %v", got, tt.want) } }) } } func TestNetworkDNSLinker(t *testing.T) { tests := []struct { name string query string want *sdp.LinkedItemQuery }{ { name: "Simple DNS name", query: "example.com", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: "example.com", Scope: "global", }, }, }, { name: "DNS name with subdomain", query: "api.example.com", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: "api.example.com", Scope: "global", }, }, }, { name: "Empty query", query: "", want: nil, }, } linkerFunc := ManualAdapterLinksByAssetType[stdlib.NetworkDNS] if linkerFunc == nil { t.Fatal("NetworkDNS linker function not found in ManualAdapterLinksByAssetType") } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := linkerFunc("", "", tt.query) if !reflect.DeepEqual(got, tt.want) { t.Errorf("NetworkDNSLinker() = %v, want %v", got, tt.want) } }) } } func TestMSKClusterLinkByARN(t *testing.T) { tests := []struct { name string arn string want *sdp.LinkedItemQuery }{ { name: "MSK Cluster ARN with region", arn: "arn:aws:kafka:us-east-1:123456789012:cluster/my-cluster/abcd1234-abcd-cafe-abab-9876543210ab-4", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "msk-cluster", Method: sdp.QueryMethod_SEARCH, Query: "arn:aws:kafka:us-east-1:123456789012:cluster/my-cluster/abcd1234-abcd-cafe-abab-9876543210ab-4", Scope: "123456789012.us-east-1", }, }, }, { name: "MSK Cluster ARN with different region", arn: "arn:aws:kafka:us-west-2:987654321098:cluster/prod-cluster/efgh5678-efgh-cafe-cdcd-1234567890ab-5", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "msk-cluster", Method: sdp.QueryMethod_SEARCH, Query: "arn:aws:kafka:us-west-2:987654321098:cluster/prod-cluster/efgh5678-efgh-cafe-cdcd-1234567890ab-5", Scope: "987654321098.us-west-2", }, }, }, { name: "Malformed ARN", arn: "invalid-arn", want: nil, }, { name: "Empty ARN", arn: "", want: nil, }, } linkerFunc := ManualAdapterLinksByAssetType[aws.MSKCluster] if linkerFunc == nil { t.Fatal("MSKCluster linker function not found in ManualAdapterLinksByAssetType") } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := linkerFunc("", "", tt.arn) if !reflect.DeepEqual(got, tt.want) { t.Errorf("MSKClusterLinkByARN() = %v, want %v", got, tt.want) } }) } } func TestHealthCheckLinker(t *testing.T) { projectID := "test-project" tests := []struct { name string healthCheckURI string want *sdp.LinkedItemQuery }{ // Global Health Check tests { name: "Global Health Check - full HTTPS URL", healthCheckURI: "https://compute.googleapis.com/compute/v1/projects/test-project/global/healthChecks/my-health-check", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: "my-health-check", Scope: projectID, }, }, }, { name: "Global Health Check - resource name format", healthCheckURI: "projects/test-project/global/healthChecks/my-health-check", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: "my-health-check", Scope: projectID, }, }, }, { name: "Global Health Check - www.googleapis.com URL", healthCheckURI: "https://www.googleapis.com/compute/v1/projects/test-project/global/healthChecks/my-health-check", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: "my-health-check", Scope: projectID, }, }, }, // Regional Health Check tests { name: "Regional Health Check - full HTTPS URL", healthCheckURI: "https://compute.googleapis.com/compute/v1/projects/test-project/regions/us-central1/healthChecks/my-regional-health-check", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: "my-regional-health-check", Scope: "test-project.us-central1", }, }, }, { name: "Regional Health Check - resource name format", healthCheckURI: "projects/test-project/regions/us-west1/healthChecks/my-regional-health-check", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: "my-regional-health-check", Scope: "test-project.us-west1", }, }, }, { name: "Regional Health Check - different region", healthCheckURI: "https://www.googleapis.com/compute/v1/projects/test-project/regions/europe-west1/healthChecks/eu-health-check", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: "eu-health-check", Scope: "test-project.europe-west1", }, }, }, // Edge cases { name: "Empty health check URI", healthCheckURI: "", want: nil, }, { name: "Not a health check URL", healthCheckURI: "projects/test-project/global/backendServices/my-backend-service", want: nil, }, { name: "Malformed URI - no resource name", healthCheckURI: "projects/test-project/global/healthChecks/", want: &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: ComputeHealthCheck.String(), Method: sdp.QueryMethod_GET, Query: "healthChecks", // LastPathComponent returns this from trailing slash Scope: projectID, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := HealthCheckLinker(projectID, "", tt.healthCheckURI) if !reflect.DeepEqual(got, tt.want) { t.Errorf("HealthCheckLinker() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: sources/gcp/shared/mocks/mock_big_query_dataset_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: big-query-clients.go // // Generated by this command: // // mockgen -destination=./mocks/mock_big_query_dataset_client.go -package=mocks -source=big-query-clients.go -imports=sdp=github.com/overmindtech/cli/go/sdp-go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" bigquery "cloud.google.com/go/bigquery" discovery "github.com/overmindtech/cli/go/discovery" sdp "github.com/overmindtech/cli/go/sdp-go" gomock "go.uber.org/mock/gomock" ) // MockBigQueryRoutineClient is a mock of BigQueryRoutineClient interface. type MockBigQueryRoutineClient struct { ctrl *gomock.Controller recorder *MockBigQueryRoutineClientMockRecorder isgomock struct{} } // MockBigQueryRoutineClientMockRecorder is the mock recorder for MockBigQueryRoutineClient. type MockBigQueryRoutineClientMockRecorder struct { mock *MockBigQueryRoutineClient } // NewMockBigQueryRoutineClient creates a new mock instance. func NewMockBigQueryRoutineClient(ctrl *gomock.Controller) *MockBigQueryRoutineClient { mock := &MockBigQueryRoutineClient{ctrl: ctrl} mock.recorder = &MockBigQueryRoutineClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBigQueryRoutineClient) EXPECT() *MockBigQueryRoutineClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBigQueryRoutineClient) Get(ctx context.Context, projectID, datasetID, routineID string) (*bigquery.RoutineMetadata, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, projectID, datasetID, routineID) ret0, _ := ret[0].(*bigquery.RoutineMetadata) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBigQueryRoutineClientMockRecorder) Get(ctx, projectID, datasetID, routineID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBigQueryRoutineClient)(nil).Get), ctx, projectID, datasetID, routineID) } // List mocks base method. func (m *MockBigQueryRoutineClient) List(ctx context.Context, projectID, datasetID string, toSDPItem func(*bigquery.RoutineMetadata, string, string) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, projectID, datasetID, toSDPItem) ret0, _ := ret[0].([]*sdp.Item) ret1, _ := ret[1].(*sdp.QueryError) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockBigQueryRoutineClientMockRecorder) List(ctx, projectID, datasetID, toSDPItem any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockBigQueryRoutineClient)(nil).List), ctx, projectID, datasetID, toSDPItem) } // MockBigQueryDatasetClient is a mock of BigQueryDatasetClient interface. type MockBigQueryDatasetClient struct { ctrl *gomock.Controller recorder *MockBigQueryDatasetClientMockRecorder isgomock struct{} } // MockBigQueryDatasetClientMockRecorder is the mock recorder for MockBigQueryDatasetClient. type MockBigQueryDatasetClientMockRecorder struct { mock *MockBigQueryDatasetClient } // NewMockBigQueryDatasetClient creates a new mock instance. func NewMockBigQueryDatasetClient(ctrl *gomock.Controller) *MockBigQueryDatasetClient { mock := &MockBigQueryDatasetClient{ctrl: ctrl} mock.recorder = &MockBigQueryDatasetClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBigQueryDatasetClient) EXPECT() *MockBigQueryDatasetClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBigQueryDatasetClient) Get(ctx context.Context, projectID, datasetID string) (*bigquery.DatasetMetadata, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, projectID, datasetID) ret0, _ := ret[0].(*bigquery.DatasetMetadata) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBigQueryDatasetClientMockRecorder) Get(ctx, projectID, datasetID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBigQueryDatasetClient)(nil).Get), ctx, projectID, datasetID) } // List mocks base method. func (m *MockBigQueryDatasetClient) List(ctx context.Context, projectID string, toSDPItem func(context.Context, *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, projectID, toSDPItem) ret0, _ := ret[0].([]*sdp.Item) ret1, _ := ret[1].(*sdp.QueryError) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockBigQueryDatasetClientMockRecorder) List(ctx, projectID, toSDPItem any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockBigQueryDatasetClient)(nil).List), ctx, projectID, toSDPItem) } // ListStream mocks base method. func (m *MockBigQueryDatasetClient) ListStream(ctx context.Context, projectID string, stream discovery.QueryResultStream, toSDPItem func(context.Context, *bigquery.DatasetMetadata) (*sdp.Item, *sdp.QueryError)) { m.ctrl.T.Helper() m.ctrl.Call(m, "ListStream", ctx, projectID, stream, toSDPItem) } // ListStream indicates an expected call of ListStream. func (mr *MockBigQueryDatasetClientMockRecorder) ListStream(ctx, projectID, stream, toSDPItem any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListStream", reflect.TypeOf((*MockBigQueryDatasetClient)(nil).ListStream), ctx, projectID, stream, toSDPItem) } // MockBigQueryTableClient is a mock of BigQueryTableClient interface. type MockBigQueryTableClient struct { ctrl *gomock.Controller recorder *MockBigQueryTableClientMockRecorder isgomock struct{} } // MockBigQueryTableClientMockRecorder is the mock recorder for MockBigQueryTableClient. type MockBigQueryTableClientMockRecorder struct { mock *MockBigQueryTableClient } // NewMockBigQueryTableClient creates a new mock instance. func NewMockBigQueryTableClient(ctrl *gomock.Controller) *MockBigQueryTableClient { mock := &MockBigQueryTableClient{ctrl: ctrl} mock.recorder = &MockBigQueryTableClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBigQueryTableClient) EXPECT() *MockBigQueryTableClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBigQueryTableClient) Get(ctx context.Context, projectID, datasetID, tableID string) (*bigquery.TableMetadata, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, projectID, datasetID, tableID) ret0, _ := ret[0].(*bigquery.TableMetadata) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBigQueryTableClientMockRecorder) Get(ctx, projectID, datasetID, tableID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBigQueryTableClient)(nil).Get), ctx, projectID, datasetID, tableID) } // List mocks base method. func (m *MockBigQueryTableClient) List(ctx context.Context, projectID, datasetID string, toSDPItem func(*bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, projectID, datasetID, toSDPItem) ret0, _ := ret[0].([]*sdp.Item) ret1, _ := ret[1].(*sdp.QueryError) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockBigQueryTableClientMockRecorder) List(ctx, projectID, datasetID, toSDPItem any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockBigQueryTableClient)(nil).List), ctx, projectID, datasetID, toSDPItem) } // ListStream mocks base method. func (m *MockBigQueryTableClient) ListStream(ctx context.Context, projectID, datasetID string, stream discovery.QueryResultStream, toSDPItem func(*bigquery.TableMetadata) (*sdp.Item, *sdp.QueryError)) { m.ctrl.T.Helper() m.ctrl.Call(m, "ListStream", ctx, projectID, datasetID, stream, toSDPItem) } // ListStream indicates an expected call of ListStream. func (mr *MockBigQueryTableClientMockRecorder) ListStream(ctx, projectID, datasetID, stream, toSDPItem any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListStream", reflect.TypeOf((*MockBigQueryTableClient)(nil).ListStream), ctx, projectID, datasetID, stream, toSDPItem) } // MockBigQueryModelClient is a mock of BigQueryModelClient interface. type MockBigQueryModelClient struct { ctrl *gomock.Controller recorder *MockBigQueryModelClientMockRecorder isgomock struct{} } // MockBigQueryModelClientMockRecorder is the mock recorder for MockBigQueryModelClient. type MockBigQueryModelClientMockRecorder struct { mock *MockBigQueryModelClient } // NewMockBigQueryModelClient creates a new mock instance. func NewMockBigQueryModelClient(ctrl *gomock.Controller) *MockBigQueryModelClient { mock := &MockBigQueryModelClient{ctrl: ctrl} mock.recorder = &MockBigQueryModelClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBigQueryModelClient) EXPECT() *MockBigQueryModelClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockBigQueryModelClient) Get(ctx context.Context, projectID, datasetID, modelID string) (*bigquery.ModelMetadata, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, projectID, datasetID, modelID) ret0, _ := ret[0].(*bigquery.ModelMetadata) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockBigQueryModelClientMockRecorder) Get(ctx, projectID, datasetID, modelID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBigQueryModelClient)(nil).Get), ctx, projectID, datasetID, modelID) } // List mocks base method. func (m *MockBigQueryModelClient) List(ctx context.Context, projectID, datasetID string, toSDPItem func(string, *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError)) ([]*sdp.Item, *sdp.QueryError) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx, projectID, datasetID, toSDPItem) ret0, _ := ret[0].([]*sdp.Item) ret1, _ := ret[1].(*sdp.QueryError) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockBigQueryModelClientMockRecorder) List(ctx, projectID, datasetID, toSDPItem any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockBigQueryModelClient)(nil).List), ctx, projectID, datasetID, toSDPItem) } // ListStream mocks base method. func (m *MockBigQueryModelClient) ListStream(ctx context.Context, projectID, datasetID string, stream discovery.QueryResultStream, toSDPItem func(string, *bigquery.ModelMetadata) (*sdp.Item, *sdp.QueryError)) { m.ctrl.T.Helper() m.ctrl.Call(m, "ListStream", ctx, projectID, datasetID, stream, toSDPItem) } // ListStream indicates an expected call of ListStream. func (mr *MockBigQueryModelClientMockRecorder) ListStream(ctx, projectID, datasetID, stream, toSDPItem any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListStream", reflect.TypeOf((*MockBigQueryModelClient)(nil).ListStream), ctx, projectID, datasetID, stream, toSDPItem) } ================================================ FILE: sources/gcp/shared/mocks/mock_certificate_manager_certificate_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: ./sources/gcp/shared/certificate-manager-clients.go // // Generated by this command: // // mockgen -destination=./sources/gcp/shared/mocks/mock_certificate_manager_certificate_client.go -package=mocks -source=./sources/gcp/shared/certificate-manager-clients.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" certificatemanagerpb "cloud.google.com/go/certificatemanager/apiv1/certificatemanagerpb" v2 "github.com/googleapis/gax-go/v2" shared "github.com/overmindtech/cli/sources/gcp/shared" gomock "go.uber.org/mock/gomock" ) // MockCertificateManagerCertificateClient is a mock of CertificateManagerCertificateClient interface. type MockCertificateManagerCertificateClient struct { ctrl *gomock.Controller recorder *MockCertificateManagerCertificateClientMockRecorder isgomock struct{} } // MockCertificateManagerCertificateClientMockRecorder is the mock recorder for MockCertificateManagerCertificateClient. type MockCertificateManagerCertificateClientMockRecorder struct { mock *MockCertificateManagerCertificateClient } // NewMockCertificateManagerCertificateClient creates a new mock instance. func NewMockCertificateManagerCertificateClient(ctrl *gomock.Controller) *MockCertificateManagerCertificateClient { mock := &MockCertificateManagerCertificateClient{ctrl: ctrl} mock.recorder = &MockCertificateManagerCertificateClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockCertificateManagerCertificateClient) EXPECT() *MockCertificateManagerCertificateClientMockRecorder { return m.recorder } // GetCertificate mocks base method. func (m *MockCertificateManagerCertificateClient) GetCertificate(ctx context.Context, req *certificatemanagerpb.GetCertificateRequest, opts ...v2.CallOption) (*certificatemanagerpb.Certificate, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "GetCertificate", varargs...) ret0, _ := ret[0].(*certificatemanagerpb.Certificate) ret1, _ := ret[1].(error) return ret0, ret1 } // GetCertificate indicates an expected call of GetCertificate. func (mr *MockCertificateManagerCertificateClientMockRecorder) GetCertificate(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificate", reflect.TypeOf((*MockCertificateManagerCertificateClient)(nil).GetCertificate), varargs...) } // ListCertificates mocks base method. func (m *MockCertificateManagerCertificateClient) ListCertificates(ctx context.Context, req *certificatemanagerpb.ListCertificatesRequest, opts ...v2.CallOption) shared.CertificateIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "ListCertificates", varargs...) ret0, _ := ret[0].(shared.CertificateIterator) return ret0 } // ListCertificates indicates an expected call of ListCertificates. func (mr *MockCertificateManagerCertificateClientMockRecorder) ListCertificates(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificates", reflect.TypeOf((*MockCertificateManagerCertificateClient)(nil).ListCertificates), varargs...) } // MockCertificateIterator is a mock of CertificateIterator interface. type MockCertificateIterator struct { ctrl *gomock.Controller recorder *MockCertificateIteratorMockRecorder isgomock struct{} } // MockCertificateIteratorMockRecorder is the mock recorder for MockCertificateIterator. type MockCertificateIteratorMockRecorder struct { mock *MockCertificateIterator } // NewMockCertificateIterator creates a new mock instance. func NewMockCertificateIterator(ctrl *gomock.Controller) *MockCertificateIterator { mock := &MockCertificateIterator{ctrl: ctrl} mock.recorder = &MockCertificateIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockCertificateIterator) EXPECT() *MockCertificateIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockCertificateIterator) Next() (*certificatemanagerpb.Certificate, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*certificatemanagerpb.Certificate) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockCertificateIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockCertificateIterator)(nil).Next)) } ================================================ FILE: sources/gcp/shared/mocks/mock_compute_instance_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: compute-clients.go // // Generated by this command: // // mockgen -destination=./mocks/mock_compute_instance_client.go -package=mocks -source=compute-clients.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" compute "cloud.google.com/go/compute/apiv1" computepb "cloud.google.com/go/compute/apiv1/computepb" gax "github.com/googleapis/gax-go/v2" shared "github.com/overmindtech/cli/sources/gcp/shared" gomock "go.uber.org/mock/gomock" ) // MockComputeInstanceIterator is a mock of ComputeInstanceIterator interface. type MockComputeInstanceIterator struct { ctrl *gomock.Controller recorder *MockComputeInstanceIteratorMockRecorder isgomock struct{} } // MockComputeInstanceIteratorMockRecorder is the mock recorder for MockComputeInstanceIterator. type MockComputeInstanceIteratorMockRecorder struct { mock *MockComputeInstanceIterator } // NewMockComputeInstanceIterator creates a new mock instance. func NewMockComputeInstanceIterator(ctrl *gomock.Controller) *MockComputeInstanceIterator { mock := &MockComputeInstanceIterator{ctrl: ctrl} mock.recorder = &MockComputeInstanceIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstanceIterator) EXPECT() *MockComputeInstanceIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeInstanceIterator) Next() (*computepb.Instance, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.Instance) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeInstanceIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeInstanceIterator)(nil).Next)) } // MockInstancesScopedListPairIterator is a mock of InstancesScopedListPairIterator interface. type MockInstancesScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockInstancesScopedListPairIteratorMockRecorder isgomock struct{} } // MockInstancesScopedListPairIteratorMockRecorder is the mock recorder for MockInstancesScopedListPairIterator. type MockInstancesScopedListPairIteratorMockRecorder struct { mock *MockInstancesScopedListPairIterator } // NewMockInstancesScopedListPairIterator creates a new mock instance. func NewMockInstancesScopedListPairIterator(ctrl *gomock.Controller) *MockInstancesScopedListPairIterator { mock := &MockInstancesScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockInstancesScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockInstancesScopedListPairIterator) EXPECT() *MockInstancesScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockInstancesScopedListPairIterator) Next() (compute.InstancesScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.InstancesScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockInstancesScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockInstancesScopedListPairIterator)(nil).Next)) } // MockComputeInstanceClient is a mock of ComputeInstanceClient interface. type MockComputeInstanceClient struct { ctrl *gomock.Controller recorder *MockComputeInstanceClientMockRecorder isgomock struct{} } // MockComputeInstanceClientMockRecorder is the mock recorder for MockComputeInstanceClient. type MockComputeInstanceClientMockRecorder struct { mock *MockComputeInstanceClient } // NewMockComputeInstanceClient creates a new mock instance. func NewMockComputeInstanceClient(ctrl *gomock.Controller) *MockComputeInstanceClient { mock := &MockComputeInstanceClient{ctrl: ctrl} mock.recorder = &MockComputeInstanceClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstanceClient) EXPECT() *MockComputeInstanceClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeInstanceClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstancesRequest, opts ...gax.CallOption) shared.InstancesScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.InstancesScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeInstanceClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeInstanceClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeInstanceClient) Get(ctx context.Context, req *computepb.GetInstanceRequest, opts ...gax.CallOption) (*computepb.Instance, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.Instance) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeInstanceClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeInstanceClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeInstanceClient) List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) shared.ComputeInstanceIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeInstanceIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeInstanceClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeInstanceClient)(nil).List), varargs...) } // MockComputeAddressIterator is a mock of ComputeAddressIterator interface. type MockComputeAddressIterator struct { ctrl *gomock.Controller recorder *MockComputeAddressIteratorMockRecorder isgomock struct{} } // MockComputeAddressIteratorMockRecorder is the mock recorder for MockComputeAddressIterator. type MockComputeAddressIteratorMockRecorder struct { mock *MockComputeAddressIterator } // NewMockComputeAddressIterator creates a new mock instance. func NewMockComputeAddressIterator(ctrl *gomock.Controller) *MockComputeAddressIterator { mock := &MockComputeAddressIterator{ctrl: ctrl} mock.recorder = &MockComputeAddressIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeAddressIterator) EXPECT() *MockComputeAddressIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeAddressIterator) Next() (*computepb.Address, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.Address) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeAddressIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeAddressIterator)(nil).Next)) } // MockAddressesScopedListPairIterator is a mock of AddressesScopedListPairIterator interface. type MockAddressesScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockAddressesScopedListPairIteratorMockRecorder isgomock struct{} } // MockAddressesScopedListPairIteratorMockRecorder is the mock recorder for MockAddressesScopedListPairIterator. type MockAddressesScopedListPairIteratorMockRecorder struct { mock *MockAddressesScopedListPairIterator } // NewMockAddressesScopedListPairIterator creates a new mock instance. func NewMockAddressesScopedListPairIterator(ctrl *gomock.Controller) *MockAddressesScopedListPairIterator { mock := &MockAddressesScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockAddressesScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockAddressesScopedListPairIterator) EXPECT() *MockAddressesScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockAddressesScopedListPairIterator) Next() (compute.AddressesScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.AddressesScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockAddressesScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockAddressesScopedListPairIterator)(nil).Next)) } // MockComputeAddressClient is a mock of ComputeAddressClient interface. type MockComputeAddressClient struct { ctrl *gomock.Controller recorder *MockComputeAddressClientMockRecorder isgomock struct{} } // MockComputeAddressClientMockRecorder is the mock recorder for MockComputeAddressClient. type MockComputeAddressClientMockRecorder struct { mock *MockComputeAddressClient } // NewMockComputeAddressClient creates a new mock instance. func NewMockComputeAddressClient(ctrl *gomock.Controller) *MockComputeAddressClient { mock := &MockComputeAddressClient{ctrl: ctrl} mock.recorder = &MockComputeAddressClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeAddressClient) EXPECT() *MockComputeAddressClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeAddressClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListAddressesRequest, opts ...gax.CallOption) shared.AddressesScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.AddressesScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeAddressClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeAddressClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeAddressClient) Get(ctx context.Context, req *computepb.GetAddressRequest, opts ...gax.CallOption) (*computepb.Address, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.Address) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeAddressClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeAddressClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeAddressClient) List(ctx context.Context, req *computepb.ListAddressesRequest, opts ...gax.CallOption) shared.ComputeAddressIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeAddressIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeAddressClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeAddressClient)(nil).List), varargs...) } // MockComputeImageIterator is a mock of ComputeImageIterator interface. type MockComputeImageIterator struct { ctrl *gomock.Controller recorder *MockComputeImageIteratorMockRecorder isgomock struct{} } // MockComputeImageIteratorMockRecorder is the mock recorder for MockComputeImageIterator. type MockComputeImageIteratorMockRecorder struct { mock *MockComputeImageIterator } // NewMockComputeImageIterator creates a new mock instance. func NewMockComputeImageIterator(ctrl *gomock.Controller) *MockComputeImageIterator { mock := &MockComputeImageIterator{ctrl: ctrl} mock.recorder = &MockComputeImageIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeImageIterator) EXPECT() *MockComputeImageIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeImageIterator) Next() (*computepb.Image, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.Image) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeImageIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeImageIterator)(nil).Next)) } // MockComputeImagesClient is a mock of ComputeImagesClient interface. type MockComputeImagesClient struct { ctrl *gomock.Controller recorder *MockComputeImagesClientMockRecorder isgomock struct{} } // MockComputeImagesClientMockRecorder is the mock recorder for MockComputeImagesClient. type MockComputeImagesClientMockRecorder struct { mock *MockComputeImagesClient } // NewMockComputeImagesClient creates a new mock instance. func NewMockComputeImagesClient(ctrl *gomock.Controller) *MockComputeImagesClient { mock := &MockComputeImagesClient{ctrl: ctrl} mock.recorder = &MockComputeImagesClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeImagesClient) EXPECT() *MockComputeImagesClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockComputeImagesClient) Get(ctx context.Context, req *computepb.GetImageRequest, opts ...gax.CallOption) (*computepb.Image, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.Image) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeImagesClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeImagesClient)(nil).Get), varargs...) } // GetFromFamily mocks base method. func (m *MockComputeImagesClient) GetFromFamily(ctx context.Context, req *computepb.GetFromFamilyImageRequest, opts ...gax.CallOption) (*computepb.Image, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "GetFromFamily", varargs...) ret0, _ := ret[0].(*computepb.Image) ret1, _ := ret[1].(error) return ret0, ret1 } // GetFromFamily indicates an expected call of GetFromFamily. func (mr *MockComputeImagesClientMockRecorder) GetFromFamily(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFromFamily", reflect.TypeOf((*MockComputeImagesClient)(nil).GetFromFamily), varargs...) } // List mocks base method. func (m *MockComputeImagesClient) List(ctx context.Context, req *computepb.ListImagesRequest, opts ...gax.CallOption) shared.ComputeImageIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeImageIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeImagesClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeImagesClient)(nil).List), varargs...) } // MockComputeInstanceGroupManagerIterator is a mock of ComputeInstanceGroupManagerIterator interface. type MockComputeInstanceGroupManagerIterator struct { ctrl *gomock.Controller recorder *MockComputeInstanceGroupManagerIteratorMockRecorder isgomock struct{} } // MockComputeInstanceGroupManagerIteratorMockRecorder is the mock recorder for MockComputeInstanceGroupManagerIterator. type MockComputeInstanceGroupManagerIteratorMockRecorder struct { mock *MockComputeInstanceGroupManagerIterator } // NewMockComputeInstanceGroupManagerIterator creates a new mock instance. func NewMockComputeInstanceGroupManagerIterator(ctrl *gomock.Controller) *MockComputeInstanceGroupManagerIterator { mock := &MockComputeInstanceGroupManagerIterator{ctrl: ctrl} mock.recorder = &MockComputeInstanceGroupManagerIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstanceGroupManagerIterator) EXPECT() *MockComputeInstanceGroupManagerIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeInstanceGroupManagerIterator) Next() (*computepb.InstanceGroupManager, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.InstanceGroupManager) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeInstanceGroupManagerIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeInstanceGroupManagerIterator)(nil).Next)) } // MockInstanceGroupManagersScopedListPairIterator is a mock of InstanceGroupManagersScopedListPairIterator interface. type MockInstanceGroupManagersScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockInstanceGroupManagersScopedListPairIteratorMockRecorder isgomock struct{} } // MockInstanceGroupManagersScopedListPairIteratorMockRecorder is the mock recorder for MockInstanceGroupManagersScopedListPairIterator. type MockInstanceGroupManagersScopedListPairIteratorMockRecorder struct { mock *MockInstanceGroupManagersScopedListPairIterator } // NewMockInstanceGroupManagersScopedListPairIterator creates a new mock instance. func NewMockInstanceGroupManagersScopedListPairIterator(ctrl *gomock.Controller) *MockInstanceGroupManagersScopedListPairIterator { mock := &MockInstanceGroupManagersScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockInstanceGroupManagersScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockInstanceGroupManagersScopedListPairIterator) EXPECT() *MockInstanceGroupManagersScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockInstanceGroupManagersScopedListPairIterator) Next() (compute.InstanceGroupManagersScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.InstanceGroupManagersScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockInstanceGroupManagersScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockInstanceGroupManagersScopedListPairIterator)(nil).Next)) } // MockComputeInstanceGroupManagerClient is a mock of ComputeInstanceGroupManagerClient interface. type MockComputeInstanceGroupManagerClient struct { ctrl *gomock.Controller recorder *MockComputeInstanceGroupManagerClientMockRecorder isgomock struct{} } // MockComputeInstanceGroupManagerClientMockRecorder is the mock recorder for MockComputeInstanceGroupManagerClient. type MockComputeInstanceGroupManagerClientMockRecorder struct { mock *MockComputeInstanceGroupManagerClient } // NewMockComputeInstanceGroupManagerClient creates a new mock instance. func NewMockComputeInstanceGroupManagerClient(ctrl *gomock.Controller) *MockComputeInstanceGroupManagerClient { mock := &MockComputeInstanceGroupManagerClient{ctrl: ctrl} mock.recorder = &MockComputeInstanceGroupManagerClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstanceGroupManagerClient) EXPECT() *MockComputeInstanceGroupManagerClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeInstanceGroupManagerClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstanceGroupManagersRequest, opts ...gax.CallOption) shared.InstanceGroupManagersScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.InstanceGroupManagersScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeInstanceGroupManagerClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeInstanceGroupManagerClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeInstanceGroupManagerClient) Get(ctx context.Context, req *computepb.GetInstanceGroupManagerRequest, opts ...gax.CallOption) (*computepb.InstanceGroupManager, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.InstanceGroupManager) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeInstanceGroupManagerClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeInstanceGroupManagerClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeInstanceGroupManagerClient) List(ctx context.Context, req *computepb.ListInstanceGroupManagersRequest, opts ...gax.CallOption) shared.ComputeInstanceGroupManagerIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeInstanceGroupManagerIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeInstanceGroupManagerClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeInstanceGroupManagerClient)(nil).List), varargs...) } // MockRegionInstanceGroupManagerIterator is a mock of RegionInstanceGroupManagerIterator interface. type MockRegionInstanceGroupManagerIterator struct { ctrl *gomock.Controller recorder *MockRegionInstanceGroupManagerIteratorMockRecorder isgomock struct{} } // MockRegionInstanceGroupManagerIteratorMockRecorder is the mock recorder for MockRegionInstanceGroupManagerIterator. type MockRegionInstanceGroupManagerIteratorMockRecorder struct { mock *MockRegionInstanceGroupManagerIterator } // NewMockRegionInstanceGroupManagerIterator creates a new mock instance. func NewMockRegionInstanceGroupManagerIterator(ctrl *gomock.Controller) *MockRegionInstanceGroupManagerIterator { mock := &MockRegionInstanceGroupManagerIterator{ctrl: ctrl} mock.recorder = &MockRegionInstanceGroupManagerIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockRegionInstanceGroupManagerIterator) EXPECT() *MockRegionInstanceGroupManagerIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockRegionInstanceGroupManagerIterator) Next() (*computepb.InstanceGroupManager, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.InstanceGroupManager) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockRegionInstanceGroupManagerIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockRegionInstanceGroupManagerIterator)(nil).Next)) } // MockRegionInstanceGroupManagerClient is a mock of RegionInstanceGroupManagerClient interface. type MockRegionInstanceGroupManagerClient struct { ctrl *gomock.Controller recorder *MockRegionInstanceGroupManagerClientMockRecorder isgomock struct{} } // MockRegionInstanceGroupManagerClientMockRecorder is the mock recorder for MockRegionInstanceGroupManagerClient. type MockRegionInstanceGroupManagerClientMockRecorder struct { mock *MockRegionInstanceGroupManagerClient } // NewMockRegionInstanceGroupManagerClient creates a new mock instance. func NewMockRegionInstanceGroupManagerClient(ctrl *gomock.Controller) *MockRegionInstanceGroupManagerClient { mock := &MockRegionInstanceGroupManagerClient{ctrl: ctrl} mock.recorder = &MockRegionInstanceGroupManagerClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockRegionInstanceGroupManagerClient) EXPECT() *MockRegionInstanceGroupManagerClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockRegionInstanceGroupManagerClient) Get(ctx context.Context, req *computepb.GetRegionInstanceGroupManagerRequest, opts ...gax.CallOption) (*computepb.InstanceGroupManager, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.InstanceGroupManager) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockRegionInstanceGroupManagerClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRegionInstanceGroupManagerClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockRegionInstanceGroupManagerClient) List(ctx context.Context, req *computepb.ListRegionInstanceGroupManagersRequest, opts ...gax.CallOption) shared.RegionInstanceGroupManagerIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.RegionInstanceGroupManagerIterator) return ret0 } // List indicates an expected call of List. func (mr *MockRegionInstanceGroupManagerClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRegionInstanceGroupManagerClient)(nil).List), varargs...) } // MockForwardingRuleIterator is a mock of ForwardingRuleIterator interface. type MockForwardingRuleIterator struct { ctrl *gomock.Controller recorder *MockForwardingRuleIteratorMockRecorder isgomock struct{} } // MockForwardingRuleIteratorMockRecorder is the mock recorder for MockForwardingRuleIterator. type MockForwardingRuleIteratorMockRecorder struct { mock *MockForwardingRuleIterator } // NewMockForwardingRuleIterator creates a new mock instance. func NewMockForwardingRuleIterator(ctrl *gomock.Controller) *MockForwardingRuleIterator { mock := &MockForwardingRuleIterator{ctrl: ctrl} mock.recorder = &MockForwardingRuleIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockForwardingRuleIterator) EXPECT() *MockForwardingRuleIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockForwardingRuleIterator) Next() (*computepb.ForwardingRule, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.ForwardingRule) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockForwardingRuleIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockForwardingRuleIterator)(nil).Next)) } // MockForwardingRulesScopedListPairIterator is a mock of ForwardingRulesScopedListPairIterator interface. type MockForwardingRulesScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockForwardingRulesScopedListPairIteratorMockRecorder isgomock struct{} } // MockForwardingRulesScopedListPairIteratorMockRecorder is the mock recorder for MockForwardingRulesScopedListPairIterator. type MockForwardingRulesScopedListPairIteratorMockRecorder struct { mock *MockForwardingRulesScopedListPairIterator } // NewMockForwardingRulesScopedListPairIterator creates a new mock instance. func NewMockForwardingRulesScopedListPairIterator(ctrl *gomock.Controller) *MockForwardingRulesScopedListPairIterator { mock := &MockForwardingRulesScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockForwardingRulesScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockForwardingRulesScopedListPairIterator) EXPECT() *MockForwardingRulesScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockForwardingRulesScopedListPairIterator) Next() (compute.ForwardingRulesScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.ForwardingRulesScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockForwardingRulesScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockForwardingRulesScopedListPairIterator)(nil).Next)) } // MockComputeForwardingRuleClient is a mock of ComputeForwardingRuleClient interface. type MockComputeForwardingRuleClient struct { ctrl *gomock.Controller recorder *MockComputeForwardingRuleClientMockRecorder isgomock struct{} } // MockComputeForwardingRuleClientMockRecorder is the mock recorder for MockComputeForwardingRuleClient. type MockComputeForwardingRuleClientMockRecorder struct { mock *MockComputeForwardingRuleClient } // NewMockComputeForwardingRuleClient creates a new mock instance. func NewMockComputeForwardingRuleClient(ctrl *gomock.Controller) *MockComputeForwardingRuleClient { mock := &MockComputeForwardingRuleClient{ctrl: ctrl} mock.recorder = &MockComputeForwardingRuleClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeForwardingRuleClient) EXPECT() *MockComputeForwardingRuleClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeForwardingRuleClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListForwardingRulesRequest, opts ...gax.CallOption) shared.ForwardingRulesScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.ForwardingRulesScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeForwardingRuleClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeForwardingRuleClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeForwardingRuleClient) Get(ctx context.Context, req *computepb.GetForwardingRuleRequest, opts ...gax.CallOption) (*computepb.ForwardingRule, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.ForwardingRule) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeForwardingRuleClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeForwardingRuleClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeForwardingRuleClient) List(ctx context.Context, req *computepb.ListForwardingRulesRequest, opts ...gax.CallOption) shared.ForwardingRuleIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ForwardingRuleIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeForwardingRuleClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeForwardingRuleClient)(nil).List), varargs...) } // MockComputeAutoscalerIterator is a mock of ComputeAutoscalerIterator interface. type MockComputeAutoscalerIterator struct { ctrl *gomock.Controller recorder *MockComputeAutoscalerIteratorMockRecorder isgomock struct{} } // MockComputeAutoscalerIteratorMockRecorder is the mock recorder for MockComputeAutoscalerIterator. type MockComputeAutoscalerIteratorMockRecorder struct { mock *MockComputeAutoscalerIterator } // NewMockComputeAutoscalerIterator creates a new mock instance. func NewMockComputeAutoscalerIterator(ctrl *gomock.Controller) *MockComputeAutoscalerIterator { mock := &MockComputeAutoscalerIterator{ctrl: ctrl} mock.recorder = &MockComputeAutoscalerIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeAutoscalerIterator) EXPECT() *MockComputeAutoscalerIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeAutoscalerIterator) Next() (*computepb.Autoscaler, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.Autoscaler) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeAutoscalerIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeAutoscalerIterator)(nil).Next)) } // MockAutoscalersScopedListPairIterator is a mock of AutoscalersScopedListPairIterator interface. type MockAutoscalersScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockAutoscalersScopedListPairIteratorMockRecorder isgomock struct{} } // MockAutoscalersScopedListPairIteratorMockRecorder is the mock recorder for MockAutoscalersScopedListPairIterator. type MockAutoscalersScopedListPairIteratorMockRecorder struct { mock *MockAutoscalersScopedListPairIterator } // NewMockAutoscalersScopedListPairIterator creates a new mock instance. func NewMockAutoscalersScopedListPairIterator(ctrl *gomock.Controller) *MockAutoscalersScopedListPairIterator { mock := &MockAutoscalersScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockAutoscalersScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockAutoscalersScopedListPairIterator) EXPECT() *MockAutoscalersScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockAutoscalersScopedListPairIterator) Next() (compute.AutoscalersScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.AutoscalersScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockAutoscalersScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockAutoscalersScopedListPairIterator)(nil).Next)) } // MockComputeAutoscalerClient is a mock of ComputeAutoscalerClient interface. type MockComputeAutoscalerClient struct { ctrl *gomock.Controller recorder *MockComputeAutoscalerClientMockRecorder isgomock struct{} } // MockComputeAutoscalerClientMockRecorder is the mock recorder for MockComputeAutoscalerClient. type MockComputeAutoscalerClientMockRecorder struct { mock *MockComputeAutoscalerClient } // NewMockComputeAutoscalerClient creates a new mock instance. func NewMockComputeAutoscalerClient(ctrl *gomock.Controller) *MockComputeAutoscalerClient { mock := &MockComputeAutoscalerClient{ctrl: ctrl} mock.recorder = &MockComputeAutoscalerClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeAutoscalerClient) EXPECT() *MockComputeAutoscalerClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeAutoscalerClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListAutoscalersRequest, opts ...gax.CallOption) shared.AutoscalersScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.AutoscalersScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeAutoscalerClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeAutoscalerClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeAutoscalerClient) Get(ctx context.Context, req *computepb.GetAutoscalerRequest, opts ...gax.CallOption) (*computepb.Autoscaler, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.Autoscaler) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeAutoscalerClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeAutoscalerClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeAutoscalerClient) List(ctx context.Context, req *computepb.ListAutoscalersRequest, opts ...gax.CallOption) shared.ComputeAutoscalerIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeAutoscalerIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeAutoscalerClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeAutoscalerClient)(nil).List), varargs...) } // MockComputeBackendServiceIterator is a mock of ComputeBackendServiceIterator interface. type MockComputeBackendServiceIterator struct { ctrl *gomock.Controller recorder *MockComputeBackendServiceIteratorMockRecorder isgomock struct{} } // MockComputeBackendServiceIteratorMockRecorder is the mock recorder for MockComputeBackendServiceIterator. type MockComputeBackendServiceIteratorMockRecorder struct { mock *MockComputeBackendServiceIterator } // NewMockComputeBackendServiceIterator creates a new mock instance. func NewMockComputeBackendServiceIterator(ctrl *gomock.Controller) *MockComputeBackendServiceIterator { mock := &MockComputeBackendServiceIterator{ctrl: ctrl} mock.recorder = &MockComputeBackendServiceIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeBackendServiceIterator) EXPECT() *MockComputeBackendServiceIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeBackendServiceIterator) Next() (*computepb.BackendService, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.BackendService) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeBackendServiceIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeBackendServiceIterator)(nil).Next)) } // MockBackendServicesScopedListPairIterator is a mock of BackendServicesScopedListPairIterator interface. type MockBackendServicesScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockBackendServicesScopedListPairIteratorMockRecorder isgomock struct{} } // MockBackendServicesScopedListPairIteratorMockRecorder is the mock recorder for MockBackendServicesScopedListPairIterator. type MockBackendServicesScopedListPairIteratorMockRecorder struct { mock *MockBackendServicesScopedListPairIterator } // NewMockBackendServicesScopedListPairIterator creates a new mock instance. func NewMockBackendServicesScopedListPairIterator(ctrl *gomock.Controller) *MockBackendServicesScopedListPairIterator { mock := &MockBackendServicesScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockBackendServicesScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockBackendServicesScopedListPairIterator) EXPECT() *MockBackendServicesScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockBackendServicesScopedListPairIterator) Next() (compute.BackendServicesScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.BackendServicesScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockBackendServicesScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockBackendServicesScopedListPairIterator)(nil).Next)) } // MockComputeBackendServiceClient is a mock of ComputeBackendServiceClient interface. type MockComputeBackendServiceClient struct { ctrl *gomock.Controller recorder *MockComputeBackendServiceClientMockRecorder isgomock struct{} } // MockComputeBackendServiceClientMockRecorder is the mock recorder for MockComputeBackendServiceClient. type MockComputeBackendServiceClientMockRecorder struct { mock *MockComputeBackendServiceClient } // NewMockComputeBackendServiceClient creates a new mock instance. func NewMockComputeBackendServiceClient(ctrl *gomock.Controller) *MockComputeBackendServiceClient { mock := &MockComputeBackendServiceClient{ctrl: ctrl} mock.recorder = &MockComputeBackendServiceClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeBackendServiceClient) EXPECT() *MockComputeBackendServiceClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeBackendServiceClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListBackendServicesRequest, opts ...gax.CallOption) shared.BackendServicesScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.BackendServicesScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeBackendServiceClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeBackendServiceClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeBackendServiceClient) Get(ctx context.Context, req *computepb.GetBackendServiceRequest, opts ...gax.CallOption) (*computepb.BackendService, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.BackendService) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeBackendServiceClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeBackendServiceClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeBackendServiceClient) List(ctx context.Context, req *computepb.ListBackendServicesRequest, opts ...gax.CallOption) shared.ComputeBackendServiceIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeBackendServiceIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeBackendServiceClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeBackendServiceClient)(nil).List), varargs...) } // MockComputeInstanceGroupIterator is a mock of ComputeInstanceGroupIterator interface. type MockComputeInstanceGroupIterator struct { ctrl *gomock.Controller recorder *MockComputeInstanceGroupIteratorMockRecorder isgomock struct{} } // MockComputeInstanceGroupIteratorMockRecorder is the mock recorder for MockComputeInstanceGroupIterator. type MockComputeInstanceGroupIteratorMockRecorder struct { mock *MockComputeInstanceGroupIterator } // NewMockComputeInstanceGroupIterator creates a new mock instance. func NewMockComputeInstanceGroupIterator(ctrl *gomock.Controller) *MockComputeInstanceGroupIterator { mock := &MockComputeInstanceGroupIterator{ctrl: ctrl} mock.recorder = &MockComputeInstanceGroupIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstanceGroupIterator) EXPECT() *MockComputeInstanceGroupIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeInstanceGroupIterator) Next() (*computepb.InstanceGroup, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.InstanceGroup) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeInstanceGroupIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeInstanceGroupIterator)(nil).Next)) } // MockInstanceGroupsScopedListPairIterator is a mock of InstanceGroupsScopedListPairIterator interface. type MockInstanceGroupsScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockInstanceGroupsScopedListPairIteratorMockRecorder isgomock struct{} } // MockInstanceGroupsScopedListPairIteratorMockRecorder is the mock recorder for MockInstanceGroupsScopedListPairIterator. type MockInstanceGroupsScopedListPairIteratorMockRecorder struct { mock *MockInstanceGroupsScopedListPairIterator } // NewMockInstanceGroupsScopedListPairIterator creates a new mock instance. func NewMockInstanceGroupsScopedListPairIterator(ctrl *gomock.Controller) *MockInstanceGroupsScopedListPairIterator { mock := &MockInstanceGroupsScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockInstanceGroupsScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockInstanceGroupsScopedListPairIterator) EXPECT() *MockInstanceGroupsScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockInstanceGroupsScopedListPairIterator) Next() (compute.InstanceGroupsScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.InstanceGroupsScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockInstanceGroupsScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockInstanceGroupsScopedListPairIterator)(nil).Next)) } // MockComputeInstanceGroupsClient is a mock of ComputeInstanceGroupsClient interface. type MockComputeInstanceGroupsClient struct { ctrl *gomock.Controller recorder *MockComputeInstanceGroupsClientMockRecorder isgomock struct{} } // MockComputeInstanceGroupsClientMockRecorder is the mock recorder for MockComputeInstanceGroupsClient. type MockComputeInstanceGroupsClientMockRecorder struct { mock *MockComputeInstanceGroupsClient } // NewMockComputeInstanceGroupsClient creates a new mock instance. func NewMockComputeInstanceGroupsClient(ctrl *gomock.Controller) *MockComputeInstanceGroupsClient { mock := &MockComputeInstanceGroupsClient{ctrl: ctrl} mock.recorder = &MockComputeInstanceGroupsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstanceGroupsClient) EXPECT() *MockComputeInstanceGroupsClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeInstanceGroupsClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstanceGroupsRequest, opts ...gax.CallOption) shared.InstanceGroupsScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.InstanceGroupsScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeInstanceGroupsClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeInstanceGroupsClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeInstanceGroupsClient) Get(ctx context.Context, req *computepb.GetInstanceGroupRequest, opts ...gax.CallOption) (*computepb.InstanceGroup, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.InstanceGroup) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeInstanceGroupsClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeInstanceGroupsClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeInstanceGroupsClient) List(ctx context.Context, req *computepb.ListInstanceGroupsRequest, opts ...gax.CallOption) shared.ComputeInstanceGroupIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeInstanceGroupIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeInstanceGroupsClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeInstanceGroupsClient)(nil).List), varargs...) } // MockComputeNodeGroupIterator is a mock of ComputeNodeGroupIterator interface. type MockComputeNodeGroupIterator struct { ctrl *gomock.Controller recorder *MockComputeNodeGroupIteratorMockRecorder isgomock struct{} } // MockComputeNodeGroupIteratorMockRecorder is the mock recorder for MockComputeNodeGroupIterator. type MockComputeNodeGroupIteratorMockRecorder struct { mock *MockComputeNodeGroupIterator } // NewMockComputeNodeGroupIterator creates a new mock instance. func NewMockComputeNodeGroupIterator(ctrl *gomock.Controller) *MockComputeNodeGroupIterator { mock := &MockComputeNodeGroupIterator{ctrl: ctrl} mock.recorder = &MockComputeNodeGroupIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeNodeGroupIterator) EXPECT() *MockComputeNodeGroupIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeNodeGroupIterator) Next() (*computepb.NodeGroup, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.NodeGroup) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeNodeGroupIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeNodeGroupIterator)(nil).Next)) } // MockNodeGroupsScopedListPairIterator is a mock of NodeGroupsScopedListPairIterator interface. type MockNodeGroupsScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockNodeGroupsScopedListPairIteratorMockRecorder isgomock struct{} } // MockNodeGroupsScopedListPairIteratorMockRecorder is the mock recorder for MockNodeGroupsScopedListPairIterator. type MockNodeGroupsScopedListPairIteratorMockRecorder struct { mock *MockNodeGroupsScopedListPairIterator } // NewMockNodeGroupsScopedListPairIterator creates a new mock instance. func NewMockNodeGroupsScopedListPairIterator(ctrl *gomock.Controller) *MockNodeGroupsScopedListPairIterator { mock := &MockNodeGroupsScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockNodeGroupsScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockNodeGroupsScopedListPairIterator) EXPECT() *MockNodeGroupsScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockNodeGroupsScopedListPairIterator) Next() (compute.NodeGroupsScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.NodeGroupsScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockNodeGroupsScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockNodeGroupsScopedListPairIterator)(nil).Next)) } // MockComputeNodeGroupClient is a mock of ComputeNodeGroupClient interface. type MockComputeNodeGroupClient struct { ctrl *gomock.Controller recorder *MockComputeNodeGroupClientMockRecorder isgomock struct{} } // MockComputeNodeGroupClientMockRecorder is the mock recorder for MockComputeNodeGroupClient. type MockComputeNodeGroupClientMockRecorder struct { mock *MockComputeNodeGroupClient } // NewMockComputeNodeGroupClient creates a new mock instance. func NewMockComputeNodeGroupClient(ctrl *gomock.Controller) *MockComputeNodeGroupClient { mock := &MockComputeNodeGroupClient{ctrl: ctrl} mock.recorder = &MockComputeNodeGroupClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeNodeGroupClient) EXPECT() *MockComputeNodeGroupClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeNodeGroupClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListNodeGroupsRequest, opts ...gax.CallOption) shared.NodeGroupsScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.NodeGroupsScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeNodeGroupClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeNodeGroupClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeNodeGroupClient) Get(ctx context.Context, req *computepb.GetNodeGroupRequest, opts ...gax.CallOption) (*computepb.NodeGroup, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.NodeGroup) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeNodeGroupClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeNodeGroupClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeNodeGroupClient) List(ctx context.Context, req *computepb.ListNodeGroupsRequest, opts ...gax.CallOption) shared.ComputeNodeGroupIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeNodeGroupIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeNodeGroupClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeNodeGroupClient)(nil).List), varargs...) } // MockComputeHealthCheckIterator is a mock of ComputeHealthCheckIterator interface. type MockComputeHealthCheckIterator struct { ctrl *gomock.Controller recorder *MockComputeHealthCheckIteratorMockRecorder isgomock struct{} } // MockComputeHealthCheckIteratorMockRecorder is the mock recorder for MockComputeHealthCheckIterator. type MockComputeHealthCheckIteratorMockRecorder struct { mock *MockComputeHealthCheckIterator } // NewMockComputeHealthCheckIterator creates a new mock instance. func NewMockComputeHealthCheckIterator(ctrl *gomock.Controller) *MockComputeHealthCheckIterator { mock := &MockComputeHealthCheckIterator{ctrl: ctrl} mock.recorder = &MockComputeHealthCheckIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeHealthCheckIterator) EXPECT() *MockComputeHealthCheckIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeHealthCheckIterator) Next() (*computepb.HealthCheck, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.HealthCheck) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeHealthCheckIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeHealthCheckIterator)(nil).Next)) } // MockHealthChecksScopedListPairIterator is a mock of HealthChecksScopedListPairIterator interface. type MockHealthChecksScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockHealthChecksScopedListPairIteratorMockRecorder isgomock struct{} } // MockHealthChecksScopedListPairIteratorMockRecorder is the mock recorder for MockHealthChecksScopedListPairIterator. type MockHealthChecksScopedListPairIteratorMockRecorder struct { mock *MockHealthChecksScopedListPairIterator } // NewMockHealthChecksScopedListPairIterator creates a new mock instance. func NewMockHealthChecksScopedListPairIterator(ctrl *gomock.Controller) *MockHealthChecksScopedListPairIterator { mock := &MockHealthChecksScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockHealthChecksScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockHealthChecksScopedListPairIterator) EXPECT() *MockHealthChecksScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockHealthChecksScopedListPairIterator) Next() (compute.HealthChecksScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.HealthChecksScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockHealthChecksScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockHealthChecksScopedListPairIterator)(nil).Next)) } // MockComputeHealthCheckClient is a mock of ComputeHealthCheckClient interface. type MockComputeHealthCheckClient struct { ctrl *gomock.Controller recorder *MockComputeHealthCheckClientMockRecorder isgomock struct{} } // MockComputeHealthCheckClientMockRecorder is the mock recorder for MockComputeHealthCheckClient. type MockComputeHealthCheckClientMockRecorder struct { mock *MockComputeHealthCheckClient } // NewMockComputeHealthCheckClient creates a new mock instance. func NewMockComputeHealthCheckClient(ctrl *gomock.Controller) *MockComputeHealthCheckClient { mock := &MockComputeHealthCheckClient{ctrl: ctrl} mock.recorder = &MockComputeHealthCheckClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeHealthCheckClient) EXPECT() *MockComputeHealthCheckClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeHealthCheckClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListHealthChecksRequest, opts ...gax.CallOption) shared.HealthChecksScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.HealthChecksScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeHealthCheckClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeHealthCheckClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeHealthCheckClient) Get(ctx context.Context, req *computepb.GetHealthCheckRequest, opts ...gax.CallOption) (*computepb.HealthCheck, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.HealthCheck) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeHealthCheckClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeHealthCheckClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeHealthCheckClient) List(ctx context.Context, req *computepb.ListHealthChecksRequest, opts ...gax.CallOption) shared.ComputeHealthCheckIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeHealthCheckIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeHealthCheckClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeHealthCheckClient)(nil).List), varargs...) } // MockComputeRegionHealthCheckIterator is a mock of ComputeRegionHealthCheckIterator interface. type MockComputeRegionHealthCheckIterator struct { ctrl *gomock.Controller recorder *MockComputeRegionHealthCheckIteratorMockRecorder isgomock struct{} } // MockComputeRegionHealthCheckIteratorMockRecorder is the mock recorder for MockComputeRegionHealthCheckIterator. type MockComputeRegionHealthCheckIteratorMockRecorder struct { mock *MockComputeRegionHealthCheckIterator } // NewMockComputeRegionHealthCheckIterator creates a new mock instance. func NewMockComputeRegionHealthCheckIterator(ctrl *gomock.Controller) *MockComputeRegionHealthCheckIterator { mock := &MockComputeRegionHealthCheckIterator{ctrl: ctrl} mock.recorder = &MockComputeRegionHealthCheckIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeRegionHealthCheckIterator) EXPECT() *MockComputeRegionHealthCheckIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeRegionHealthCheckIterator) Next() (*computepb.HealthCheck, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.HealthCheck) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeRegionHealthCheckIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeRegionHealthCheckIterator)(nil).Next)) } // MockComputeRegionHealthCheckClient is a mock of ComputeRegionHealthCheckClient interface. type MockComputeRegionHealthCheckClient struct { ctrl *gomock.Controller recorder *MockComputeRegionHealthCheckClientMockRecorder isgomock struct{} } // MockComputeRegionHealthCheckClientMockRecorder is the mock recorder for MockComputeRegionHealthCheckClient. type MockComputeRegionHealthCheckClientMockRecorder struct { mock *MockComputeRegionHealthCheckClient } // NewMockComputeRegionHealthCheckClient creates a new mock instance. func NewMockComputeRegionHealthCheckClient(ctrl *gomock.Controller) *MockComputeRegionHealthCheckClient { mock := &MockComputeRegionHealthCheckClient{ctrl: ctrl} mock.recorder = &MockComputeRegionHealthCheckClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeRegionHealthCheckClient) EXPECT() *MockComputeRegionHealthCheckClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockComputeRegionHealthCheckClient) Get(ctx context.Context, req *computepb.GetRegionHealthCheckRequest, opts ...gax.CallOption) (*computepb.HealthCheck, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.HealthCheck) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeRegionHealthCheckClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeRegionHealthCheckClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeRegionHealthCheckClient) List(ctx context.Context, req *computepb.ListRegionHealthChecksRequest, opts ...gax.CallOption) shared.ComputeRegionHealthCheckIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeRegionHealthCheckIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeRegionHealthCheckClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeRegionHealthCheckClient)(nil).List), varargs...) } // MockComputeNodeTemplateIterator is a mock of ComputeNodeTemplateIterator interface. type MockComputeNodeTemplateIterator struct { ctrl *gomock.Controller recorder *MockComputeNodeTemplateIteratorMockRecorder isgomock struct{} } // MockComputeNodeTemplateIteratorMockRecorder is the mock recorder for MockComputeNodeTemplateIterator. type MockComputeNodeTemplateIteratorMockRecorder struct { mock *MockComputeNodeTemplateIterator } // NewMockComputeNodeTemplateIterator creates a new mock instance. func NewMockComputeNodeTemplateIterator(ctrl *gomock.Controller) *MockComputeNodeTemplateIterator { mock := &MockComputeNodeTemplateIterator{ctrl: ctrl} mock.recorder = &MockComputeNodeTemplateIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeNodeTemplateIterator) EXPECT() *MockComputeNodeTemplateIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeNodeTemplateIterator) Next() (*computepb.NodeTemplate, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.NodeTemplate) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeNodeTemplateIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeNodeTemplateIterator)(nil).Next)) } // MockNodeTemplatesScopedListPairIterator is a mock of NodeTemplatesScopedListPairIterator interface. type MockNodeTemplatesScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockNodeTemplatesScopedListPairIteratorMockRecorder isgomock struct{} } // MockNodeTemplatesScopedListPairIteratorMockRecorder is the mock recorder for MockNodeTemplatesScopedListPairIterator. type MockNodeTemplatesScopedListPairIteratorMockRecorder struct { mock *MockNodeTemplatesScopedListPairIterator } // NewMockNodeTemplatesScopedListPairIterator creates a new mock instance. func NewMockNodeTemplatesScopedListPairIterator(ctrl *gomock.Controller) *MockNodeTemplatesScopedListPairIterator { mock := &MockNodeTemplatesScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockNodeTemplatesScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockNodeTemplatesScopedListPairIterator) EXPECT() *MockNodeTemplatesScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockNodeTemplatesScopedListPairIterator) Next() (compute.NodeTemplatesScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.NodeTemplatesScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockNodeTemplatesScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockNodeTemplatesScopedListPairIterator)(nil).Next)) } // MockComputeNodeTemplateClient is a mock of ComputeNodeTemplateClient interface. type MockComputeNodeTemplateClient struct { ctrl *gomock.Controller recorder *MockComputeNodeTemplateClientMockRecorder isgomock struct{} } // MockComputeNodeTemplateClientMockRecorder is the mock recorder for MockComputeNodeTemplateClient. type MockComputeNodeTemplateClientMockRecorder struct { mock *MockComputeNodeTemplateClient } // NewMockComputeNodeTemplateClient creates a new mock instance. func NewMockComputeNodeTemplateClient(ctrl *gomock.Controller) *MockComputeNodeTemplateClient { mock := &MockComputeNodeTemplateClient{ctrl: ctrl} mock.recorder = &MockComputeNodeTemplateClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeNodeTemplateClient) EXPECT() *MockComputeNodeTemplateClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeNodeTemplateClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListNodeTemplatesRequest, opts ...gax.CallOption) shared.NodeTemplatesScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.NodeTemplatesScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeNodeTemplateClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeNodeTemplateClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeNodeTemplateClient) Get(ctx context.Context, req *computepb.GetNodeTemplateRequest, opts ...gax.CallOption) (*computepb.NodeTemplate, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.NodeTemplate) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeNodeTemplateClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeNodeTemplateClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeNodeTemplateClient) List(ctx context.Context, req *computepb.ListNodeTemplatesRequest, opts ...gax.CallOption) shared.ComputeNodeTemplateIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeNodeTemplateIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeNodeTemplateClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeNodeTemplateClient)(nil).List), varargs...) } // MockComputeReservationIterator is a mock of ComputeReservationIterator interface. type MockComputeReservationIterator struct { ctrl *gomock.Controller recorder *MockComputeReservationIteratorMockRecorder isgomock struct{} } // MockComputeReservationIteratorMockRecorder is the mock recorder for MockComputeReservationIterator. type MockComputeReservationIteratorMockRecorder struct { mock *MockComputeReservationIterator } // NewMockComputeReservationIterator creates a new mock instance. func NewMockComputeReservationIterator(ctrl *gomock.Controller) *MockComputeReservationIterator { mock := &MockComputeReservationIterator{ctrl: ctrl} mock.recorder = &MockComputeReservationIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeReservationIterator) EXPECT() *MockComputeReservationIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeReservationIterator) Next() (*computepb.Reservation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.Reservation) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeReservationIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeReservationIterator)(nil).Next)) } // MockReservationsScopedListPairIterator is a mock of ReservationsScopedListPairIterator interface. type MockReservationsScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockReservationsScopedListPairIteratorMockRecorder isgomock struct{} } // MockReservationsScopedListPairIteratorMockRecorder is the mock recorder for MockReservationsScopedListPairIterator. type MockReservationsScopedListPairIteratorMockRecorder struct { mock *MockReservationsScopedListPairIterator } // NewMockReservationsScopedListPairIterator creates a new mock instance. func NewMockReservationsScopedListPairIterator(ctrl *gomock.Controller) *MockReservationsScopedListPairIterator { mock := &MockReservationsScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockReservationsScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockReservationsScopedListPairIterator) EXPECT() *MockReservationsScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockReservationsScopedListPairIterator) Next() (compute.ReservationsScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.ReservationsScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockReservationsScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockReservationsScopedListPairIterator)(nil).Next)) } // MockComputeReservationClient is a mock of ComputeReservationClient interface. type MockComputeReservationClient struct { ctrl *gomock.Controller recorder *MockComputeReservationClientMockRecorder isgomock struct{} } // MockComputeReservationClientMockRecorder is the mock recorder for MockComputeReservationClient. type MockComputeReservationClientMockRecorder struct { mock *MockComputeReservationClient } // NewMockComputeReservationClient creates a new mock instance. func NewMockComputeReservationClient(ctrl *gomock.Controller) *MockComputeReservationClient { mock := &MockComputeReservationClient{ctrl: ctrl} mock.recorder = &MockComputeReservationClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeReservationClient) EXPECT() *MockComputeReservationClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeReservationClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListReservationsRequest, opts ...gax.CallOption) shared.ReservationsScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.ReservationsScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeReservationClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeReservationClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeReservationClient) Get(ctx context.Context, req *computepb.GetReservationRequest, opts ...gax.CallOption) (*computepb.Reservation, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.Reservation) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeReservationClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeReservationClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeReservationClient) List(ctx context.Context, req *computepb.ListReservationsRequest, opts ...gax.CallOption) shared.ComputeReservationIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeReservationIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeReservationClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeReservationClient)(nil).List), varargs...) } // MockComputeSecurityPolicyIterator is a mock of ComputeSecurityPolicyIterator interface. type MockComputeSecurityPolicyIterator struct { ctrl *gomock.Controller recorder *MockComputeSecurityPolicyIteratorMockRecorder isgomock struct{} } // MockComputeSecurityPolicyIteratorMockRecorder is the mock recorder for MockComputeSecurityPolicyIterator. type MockComputeSecurityPolicyIteratorMockRecorder struct { mock *MockComputeSecurityPolicyIterator } // NewMockComputeSecurityPolicyIterator creates a new mock instance. func NewMockComputeSecurityPolicyIterator(ctrl *gomock.Controller) *MockComputeSecurityPolicyIterator { mock := &MockComputeSecurityPolicyIterator{ctrl: ctrl} mock.recorder = &MockComputeSecurityPolicyIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeSecurityPolicyIterator) EXPECT() *MockComputeSecurityPolicyIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeSecurityPolicyIterator) Next() (*computepb.SecurityPolicy, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.SecurityPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeSecurityPolicyIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeSecurityPolicyIterator)(nil).Next)) } // MockComputeSecurityPolicyClient is a mock of ComputeSecurityPolicyClient interface. type MockComputeSecurityPolicyClient struct { ctrl *gomock.Controller recorder *MockComputeSecurityPolicyClientMockRecorder isgomock struct{} } // MockComputeSecurityPolicyClientMockRecorder is the mock recorder for MockComputeSecurityPolicyClient. type MockComputeSecurityPolicyClientMockRecorder struct { mock *MockComputeSecurityPolicyClient } // NewMockComputeSecurityPolicyClient creates a new mock instance. func NewMockComputeSecurityPolicyClient(ctrl *gomock.Controller) *MockComputeSecurityPolicyClient { mock := &MockComputeSecurityPolicyClient{ctrl: ctrl} mock.recorder = &MockComputeSecurityPolicyClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeSecurityPolicyClient) EXPECT() *MockComputeSecurityPolicyClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockComputeSecurityPolicyClient) Get(ctx context.Context, req *computepb.GetSecurityPolicyRequest, opts ...gax.CallOption) (*computepb.SecurityPolicy, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.SecurityPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeSecurityPolicyClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeSecurityPolicyClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeSecurityPolicyClient) List(ctx context.Context, req *computepb.ListSecurityPoliciesRequest, opts ...gax.CallOption) shared.ComputeSecurityPolicyIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeSecurityPolicyIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeSecurityPolicyClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeSecurityPolicyClient)(nil).List), varargs...) } // MockComputeInstantSnapshotIterator is a mock of ComputeInstantSnapshotIterator interface. type MockComputeInstantSnapshotIterator struct { ctrl *gomock.Controller recorder *MockComputeInstantSnapshotIteratorMockRecorder isgomock struct{} } // MockComputeInstantSnapshotIteratorMockRecorder is the mock recorder for MockComputeInstantSnapshotIterator. type MockComputeInstantSnapshotIteratorMockRecorder struct { mock *MockComputeInstantSnapshotIterator } // NewMockComputeInstantSnapshotIterator creates a new mock instance. func NewMockComputeInstantSnapshotIterator(ctrl *gomock.Controller) *MockComputeInstantSnapshotIterator { mock := &MockComputeInstantSnapshotIterator{ctrl: ctrl} mock.recorder = &MockComputeInstantSnapshotIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstantSnapshotIterator) EXPECT() *MockComputeInstantSnapshotIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeInstantSnapshotIterator) Next() (*computepb.InstantSnapshot, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.InstantSnapshot) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeInstantSnapshotIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeInstantSnapshotIterator)(nil).Next)) } // MockInstantSnapshotsScopedListPairIterator is a mock of InstantSnapshotsScopedListPairIterator interface. type MockInstantSnapshotsScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockInstantSnapshotsScopedListPairIteratorMockRecorder isgomock struct{} } // MockInstantSnapshotsScopedListPairIteratorMockRecorder is the mock recorder for MockInstantSnapshotsScopedListPairIterator. type MockInstantSnapshotsScopedListPairIteratorMockRecorder struct { mock *MockInstantSnapshotsScopedListPairIterator } // NewMockInstantSnapshotsScopedListPairIterator creates a new mock instance. func NewMockInstantSnapshotsScopedListPairIterator(ctrl *gomock.Controller) *MockInstantSnapshotsScopedListPairIterator { mock := &MockInstantSnapshotsScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockInstantSnapshotsScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockInstantSnapshotsScopedListPairIterator) EXPECT() *MockInstantSnapshotsScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockInstantSnapshotsScopedListPairIterator) Next() (compute.InstantSnapshotsScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.InstantSnapshotsScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockInstantSnapshotsScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockInstantSnapshotsScopedListPairIterator)(nil).Next)) } // MockComputeInstantSnapshotsClient is a mock of ComputeInstantSnapshotsClient interface. type MockComputeInstantSnapshotsClient struct { ctrl *gomock.Controller recorder *MockComputeInstantSnapshotsClientMockRecorder isgomock struct{} } // MockComputeInstantSnapshotsClientMockRecorder is the mock recorder for MockComputeInstantSnapshotsClient. type MockComputeInstantSnapshotsClientMockRecorder struct { mock *MockComputeInstantSnapshotsClient } // NewMockComputeInstantSnapshotsClient creates a new mock instance. func NewMockComputeInstantSnapshotsClient(ctrl *gomock.Controller) *MockComputeInstantSnapshotsClient { mock := &MockComputeInstantSnapshotsClient{ctrl: ctrl} mock.recorder = &MockComputeInstantSnapshotsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeInstantSnapshotsClient) EXPECT() *MockComputeInstantSnapshotsClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeInstantSnapshotsClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstantSnapshotsRequest, opts ...gax.CallOption) shared.InstantSnapshotsScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.InstantSnapshotsScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeInstantSnapshotsClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeInstantSnapshotsClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeInstantSnapshotsClient) Get(ctx context.Context, req *computepb.GetInstantSnapshotRequest, opts ...gax.CallOption) (*computepb.InstantSnapshot, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.InstantSnapshot) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeInstantSnapshotsClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeInstantSnapshotsClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeInstantSnapshotsClient) List(ctx context.Context, req *computepb.ListInstantSnapshotsRequest, opts ...gax.CallOption) shared.ComputeInstantSnapshotIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeInstantSnapshotIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeInstantSnapshotsClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeInstantSnapshotsClient)(nil).List), varargs...) } // MockComputeDiskIterator is a mock of ComputeDiskIterator interface. type MockComputeDiskIterator struct { ctrl *gomock.Controller recorder *MockComputeDiskIteratorMockRecorder isgomock struct{} } // MockComputeDiskIteratorMockRecorder is the mock recorder for MockComputeDiskIterator. type MockComputeDiskIteratorMockRecorder struct { mock *MockComputeDiskIterator } // NewMockComputeDiskIterator creates a new mock instance. func NewMockComputeDiskIterator(ctrl *gomock.Controller) *MockComputeDiskIterator { mock := &MockComputeDiskIterator{ctrl: ctrl} mock.recorder = &MockComputeDiskIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeDiskIterator) EXPECT() *MockComputeDiskIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeDiskIterator) Next() (*computepb.Disk, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.Disk) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeDiskIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeDiskIterator)(nil).Next)) } // MockDisksScopedListPairIterator is a mock of DisksScopedListPairIterator interface. type MockDisksScopedListPairIterator struct { ctrl *gomock.Controller recorder *MockDisksScopedListPairIteratorMockRecorder isgomock struct{} } // MockDisksScopedListPairIteratorMockRecorder is the mock recorder for MockDisksScopedListPairIterator. type MockDisksScopedListPairIteratorMockRecorder struct { mock *MockDisksScopedListPairIterator } // NewMockDisksScopedListPairIterator creates a new mock instance. func NewMockDisksScopedListPairIterator(ctrl *gomock.Controller) *MockDisksScopedListPairIterator { mock := &MockDisksScopedListPairIterator{ctrl: ctrl} mock.recorder = &MockDisksScopedListPairIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDisksScopedListPairIterator) EXPECT() *MockDisksScopedListPairIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockDisksScopedListPairIterator) Next() (compute.DisksScopedListPair, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(compute.DisksScopedListPair) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockDisksScopedListPairIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockDisksScopedListPairIterator)(nil).Next)) } // MockComputeDiskClient is a mock of ComputeDiskClient interface. type MockComputeDiskClient struct { ctrl *gomock.Controller recorder *MockComputeDiskClientMockRecorder isgomock struct{} } // MockComputeDiskClientMockRecorder is the mock recorder for MockComputeDiskClient. type MockComputeDiskClientMockRecorder struct { mock *MockComputeDiskClient } // NewMockComputeDiskClient creates a new mock instance. func NewMockComputeDiskClient(ctrl *gomock.Controller) *MockComputeDiskClient { mock := &MockComputeDiskClient{ctrl: ctrl} mock.recorder = &MockComputeDiskClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeDiskClient) EXPECT() *MockComputeDiskClientMockRecorder { return m.recorder } // AggregatedList mocks base method. func (m *MockComputeDiskClient) AggregatedList(ctx context.Context, req *computepb.AggregatedListDisksRequest, opts ...gax.CallOption) shared.DisksScopedListPairIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "AggregatedList", varargs...) ret0, _ := ret[0].(shared.DisksScopedListPairIterator) return ret0 } // AggregatedList indicates an expected call of AggregatedList. func (mr *MockComputeDiskClientMockRecorder) AggregatedList(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregatedList", reflect.TypeOf((*MockComputeDiskClient)(nil).AggregatedList), varargs...) } // Get mocks base method. func (m *MockComputeDiskClient) Get(ctx context.Context, req *computepb.GetDiskRequest, opts ...gax.CallOption) (*computepb.Disk, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.Disk) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeDiskClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeDiskClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeDiskClient) List(ctx context.Context, req *computepb.ListDisksRequest, opts ...gax.CallOption) shared.ComputeDiskIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeDiskIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeDiskClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeDiskClient)(nil).List), varargs...) } // MockComputeMachineImageIterator is a mock of ComputeMachineImageIterator interface. type MockComputeMachineImageIterator struct { ctrl *gomock.Controller recorder *MockComputeMachineImageIteratorMockRecorder isgomock struct{} } // MockComputeMachineImageIteratorMockRecorder is the mock recorder for MockComputeMachineImageIterator. type MockComputeMachineImageIteratorMockRecorder struct { mock *MockComputeMachineImageIterator } // NewMockComputeMachineImageIterator creates a new mock instance. func NewMockComputeMachineImageIterator(ctrl *gomock.Controller) *MockComputeMachineImageIterator { mock := &MockComputeMachineImageIterator{ctrl: ctrl} mock.recorder = &MockComputeMachineImageIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeMachineImageIterator) EXPECT() *MockComputeMachineImageIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeMachineImageIterator) Next() (*computepb.MachineImage, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.MachineImage) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeMachineImageIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeMachineImageIterator)(nil).Next)) } // MockComputeMachineImageClient is a mock of ComputeMachineImageClient interface. type MockComputeMachineImageClient struct { ctrl *gomock.Controller recorder *MockComputeMachineImageClientMockRecorder isgomock struct{} } // MockComputeMachineImageClientMockRecorder is the mock recorder for MockComputeMachineImageClient. type MockComputeMachineImageClientMockRecorder struct { mock *MockComputeMachineImageClient } // NewMockComputeMachineImageClient creates a new mock instance. func NewMockComputeMachineImageClient(ctrl *gomock.Controller) *MockComputeMachineImageClient { mock := &MockComputeMachineImageClient{ctrl: ctrl} mock.recorder = &MockComputeMachineImageClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeMachineImageClient) EXPECT() *MockComputeMachineImageClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockComputeMachineImageClient) Get(ctx context.Context, req *computepb.GetMachineImageRequest, opts ...gax.CallOption) (*computepb.MachineImage, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.MachineImage) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeMachineImageClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeMachineImageClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeMachineImageClient) List(ctx context.Context, req *computepb.ListMachineImagesRequest, opts ...gax.CallOption) shared.ComputeMachineImageIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeMachineImageIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeMachineImageClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeMachineImageClient)(nil).List), varargs...) } // MockComputeSnapshotIterator is a mock of ComputeSnapshotIterator interface. type MockComputeSnapshotIterator struct { ctrl *gomock.Controller recorder *MockComputeSnapshotIteratorMockRecorder isgomock struct{} } // MockComputeSnapshotIteratorMockRecorder is the mock recorder for MockComputeSnapshotIterator. type MockComputeSnapshotIteratorMockRecorder struct { mock *MockComputeSnapshotIterator } // NewMockComputeSnapshotIterator creates a new mock instance. func NewMockComputeSnapshotIterator(ctrl *gomock.Controller) *MockComputeSnapshotIterator { mock := &MockComputeSnapshotIterator{ctrl: ctrl} mock.recorder = &MockComputeSnapshotIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeSnapshotIterator) EXPECT() *MockComputeSnapshotIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeSnapshotIterator) Next() (*computepb.Snapshot, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.Snapshot) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeSnapshotIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeSnapshotIterator)(nil).Next)) } // MockComputeSnapshotsClient is a mock of ComputeSnapshotsClient interface. type MockComputeSnapshotsClient struct { ctrl *gomock.Controller recorder *MockComputeSnapshotsClientMockRecorder isgomock struct{} } // MockComputeSnapshotsClientMockRecorder is the mock recorder for MockComputeSnapshotsClient. type MockComputeSnapshotsClientMockRecorder struct { mock *MockComputeSnapshotsClient } // NewMockComputeSnapshotsClient creates a new mock instance. func NewMockComputeSnapshotsClient(ctrl *gomock.Controller) *MockComputeSnapshotsClient { mock := &MockComputeSnapshotsClient{ctrl: ctrl} mock.recorder = &MockComputeSnapshotsClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeSnapshotsClient) EXPECT() *MockComputeSnapshotsClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockComputeSnapshotsClient) Get(ctx context.Context, req *computepb.GetSnapshotRequest, opts ...gax.CallOption) (*computepb.Snapshot, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.Snapshot) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeSnapshotsClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeSnapshotsClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeSnapshotsClient) List(ctx context.Context, req *computepb.ListSnapshotsRequest, opts ...gax.CallOption) shared.ComputeSnapshotIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeSnapshotIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeSnapshotsClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeSnapshotsClient)(nil).List), varargs...) } // MockComputeRegionBackendServiceIterator is a mock of ComputeRegionBackendServiceIterator interface. type MockComputeRegionBackendServiceIterator struct { ctrl *gomock.Controller recorder *MockComputeRegionBackendServiceIteratorMockRecorder isgomock struct{} } // MockComputeRegionBackendServiceIteratorMockRecorder is the mock recorder for MockComputeRegionBackendServiceIterator. type MockComputeRegionBackendServiceIteratorMockRecorder struct { mock *MockComputeRegionBackendServiceIterator } // NewMockComputeRegionBackendServiceIterator creates a new mock instance. func NewMockComputeRegionBackendServiceIterator(ctrl *gomock.Controller) *MockComputeRegionBackendServiceIterator { mock := &MockComputeRegionBackendServiceIterator{ctrl: ctrl} mock.recorder = &MockComputeRegionBackendServiceIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeRegionBackendServiceIterator) EXPECT() *MockComputeRegionBackendServiceIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockComputeRegionBackendServiceIterator) Next() (*computepb.BackendService, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*computepb.BackendService) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockComputeRegionBackendServiceIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockComputeRegionBackendServiceIterator)(nil).Next)) } // MockComputeRegionBackendServiceClient is a mock of ComputeRegionBackendServiceClient interface. type MockComputeRegionBackendServiceClient struct { ctrl *gomock.Controller recorder *MockComputeRegionBackendServiceClientMockRecorder isgomock struct{} } // MockComputeRegionBackendServiceClientMockRecorder is the mock recorder for MockComputeRegionBackendServiceClient. type MockComputeRegionBackendServiceClientMockRecorder struct { mock *MockComputeRegionBackendServiceClient } // NewMockComputeRegionBackendServiceClient creates a new mock instance. func NewMockComputeRegionBackendServiceClient(ctrl *gomock.Controller) *MockComputeRegionBackendServiceClient { mock := &MockComputeRegionBackendServiceClient{ctrl: ctrl} mock.recorder = &MockComputeRegionBackendServiceClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockComputeRegionBackendServiceClient) EXPECT() *MockComputeRegionBackendServiceClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockComputeRegionBackendServiceClient) Get(ctx context.Context, req *computepb.GetRegionBackendServiceRequest, opts ...gax.CallOption) (*computepb.BackendService, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*computepb.BackendService) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockComputeRegionBackendServiceClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeRegionBackendServiceClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockComputeRegionBackendServiceClient) List(ctx context.Context, req *computepb.ListRegionBackendServicesRequest, opts ...gax.CallOption) shared.ComputeRegionBackendServiceIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.ComputeRegionBackendServiceIterator) return ret0 } // List indicates an expected call of List. func (mr *MockComputeRegionBackendServiceClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockComputeRegionBackendServiceClient)(nil).List), varargs...) } ================================================ FILE: sources/gcp/shared/mocks/mock_iam_clients.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: iam-clients.go // // Generated by this command: // // mockgen -destination=./mocks/mock_iam_clients.go -package=mocks -source=iam-clients.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" adminpb "cloud.google.com/go/iam/admin/apiv1/adminpb" gax "github.com/googleapis/gax-go/v2" shared "github.com/overmindtech/cli/sources/gcp/shared" gomock "go.uber.org/mock/gomock" ) // MockIAMServiceAccountClient is a mock of IAMServiceAccountClient interface. type MockIAMServiceAccountClient struct { ctrl *gomock.Controller recorder *MockIAMServiceAccountClientMockRecorder isgomock struct{} } // MockIAMServiceAccountClientMockRecorder is the mock recorder for MockIAMServiceAccountClient. type MockIAMServiceAccountClientMockRecorder struct { mock *MockIAMServiceAccountClient } // NewMockIAMServiceAccountClient creates a new mock instance. func NewMockIAMServiceAccountClient(ctrl *gomock.Controller) *MockIAMServiceAccountClient { mock := &MockIAMServiceAccountClient{ctrl: ctrl} mock.recorder = &MockIAMServiceAccountClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockIAMServiceAccountClient) EXPECT() *MockIAMServiceAccountClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockIAMServiceAccountClient) Get(ctx context.Context, req *adminpb.GetServiceAccountRequest, opts ...gax.CallOption) (*adminpb.ServiceAccount, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*adminpb.ServiceAccount) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockIAMServiceAccountClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIAMServiceAccountClient)(nil).Get), varargs...) } // List mocks base method. func (m *MockIAMServiceAccountClient) List(ctx context.Context, req *adminpb.ListServiceAccountsRequest, opts ...gax.CallOption) shared.IAMServiceAccountIterator { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(shared.IAMServiceAccountIterator) return ret0 } // List indicates an expected call of List. func (mr *MockIAMServiceAccountClientMockRecorder) List(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIAMServiceAccountClient)(nil).List), varargs...) } // MockIAMServiceAccountIterator is a mock of IAMServiceAccountIterator interface. type MockIAMServiceAccountIterator struct { ctrl *gomock.Controller recorder *MockIAMServiceAccountIteratorMockRecorder isgomock struct{} } // MockIAMServiceAccountIteratorMockRecorder is the mock recorder for MockIAMServiceAccountIterator. type MockIAMServiceAccountIteratorMockRecorder struct { mock *MockIAMServiceAccountIterator } // NewMockIAMServiceAccountIterator creates a new mock instance. func NewMockIAMServiceAccountIterator(ctrl *gomock.Controller) *MockIAMServiceAccountIterator { mock := &MockIAMServiceAccountIterator{ctrl: ctrl} mock.recorder = &MockIAMServiceAccountIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockIAMServiceAccountIterator) EXPECT() *MockIAMServiceAccountIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockIAMServiceAccountIterator) Next() (*adminpb.ServiceAccount, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*adminpb.ServiceAccount) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockIAMServiceAccountIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockIAMServiceAccountIterator)(nil).Next)) } // MockIAMServiceAccountKeyClient is a mock of IAMServiceAccountKeyClient interface. type MockIAMServiceAccountKeyClient struct { ctrl *gomock.Controller recorder *MockIAMServiceAccountKeyClientMockRecorder isgomock struct{} } // MockIAMServiceAccountKeyClientMockRecorder is the mock recorder for MockIAMServiceAccountKeyClient. type MockIAMServiceAccountKeyClientMockRecorder struct { mock *MockIAMServiceAccountKeyClient } // NewMockIAMServiceAccountKeyClient creates a new mock instance. func NewMockIAMServiceAccountKeyClient(ctrl *gomock.Controller) *MockIAMServiceAccountKeyClient { mock := &MockIAMServiceAccountKeyClient{ctrl: ctrl} mock.recorder = &MockIAMServiceAccountKeyClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockIAMServiceAccountKeyClient) EXPECT() *MockIAMServiceAccountKeyClientMockRecorder { return m.recorder } // Get mocks base method. func (m *MockIAMServiceAccountKeyClient) Get(ctx context.Context, req *adminpb.GetServiceAccountKeyRequest, opts ...gax.CallOption) (*adminpb.ServiceAccountKey, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(*adminpb.ServiceAccountKey) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockIAMServiceAccountKeyClientMockRecorder) Get(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIAMServiceAccountKeyClient)(nil).Get), varargs...) } // Search mocks base method. func (m *MockIAMServiceAccountKeyClient) Search(ctx context.Context, req *adminpb.ListServiceAccountKeysRequest, opts ...gax.CallOption) (*adminpb.ListServiceAccountKeysResponse, error) { m.ctrl.T.Helper() varargs := []any{ctx, req} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Search", varargs...) ret0, _ := ret[0].(*adminpb.ListServiceAccountKeysResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Search indicates an expected call of Search. func (mr *MockIAMServiceAccountKeyClientMockRecorder) Search(ctx, req any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, req}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockIAMServiceAccountKeyClient)(nil).Search), varargs...) } ================================================ FILE: sources/gcp/shared/mocks/mock_logging_config_client.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: logging-clients.go // // Generated by this command: // // mockgen -destination=./mocks/mock_logging_config_client.go -package=mocks -source=logging-clients.go // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" loggingpb "cloud.google.com/go/logging/apiv2/loggingpb" shared "github.com/overmindtech/cli/sources/gcp/shared" gomock "go.uber.org/mock/gomock" ) // MockLoggingSinkIterator is a mock of LoggingSinkIterator interface. type MockLoggingSinkIterator struct { ctrl *gomock.Controller recorder *MockLoggingSinkIteratorMockRecorder isgomock struct{} } // MockLoggingSinkIteratorMockRecorder is the mock recorder for MockLoggingSinkIterator. type MockLoggingSinkIteratorMockRecorder struct { mock *MockLoggingSinkIterator } // NewMockLoggingSinkIterator creates a new mock instance. func NewMockLoggingSinkIterator(ctrl *gomock.Controller) *MockLoggingSinkIterator { mock := &MockLoggingSinkIterator{ctrl: ctrl} mock.recorder = &MockLoggingSinkIteratorMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLoggingSinkIterator) EXPECT() *MockLoggingSinkIteratorMockRecorder { return m.recorder } // Next mocks base method. func (m *MockLoggingSinkIterator) Next() (*loggingpb.LogSink, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Next") ret0, _ := ret[0].(*loggingpb.LogSink) ret1, _ := ret[1].(error) return ret0, ret1 } // Next indicates an expected call of Next. func (mr *MockLoggingSinkIteratorMockRecorder) Next() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockLoggingSinkIterator)(nil).Next)) } // MockLoggingConfigClient is a mock of LoggingConfigClient interface. type MockLoggingConfigClient struct { ctrl *gomock.Controller recorder *MockLoggingConfigClientMockRecorder isgomock struct{} } // MockLoggingConfigClientMockRecorder is the mock recorder for MockLoggingConfigClient. type MockLoggingConfigClientMockRecorder struct { mock *MockLoggingConfigClient } // NewMockLoggingConfigClient creates a new mock instance. func NewMockLoggingConfigClient(ctrl *gomock.Controller) *MockLoggingConfigClient { mock := &MockLoggingConfigClient{ctrl: ctrl} mock.recorder = &MockLoggingConfigClientMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLoggingConfigClient) EXPECT() *MockLoggingConfigClientMockRecorder { return m.recorder } // GetSink mocks base method. func (m *MockLoggingConfigClient) GetSink(ctx context.Context, req *loggingpb.GetSinkRequest) (*loggingpb.LogSink, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSink", ctx, req) ret0, _ := ret[0].(*loggingpb.LogSink) ret1, _ := ret[1].(error) return ret0, ret1 } // GetSink indicates an expected call of GetSink. func (mr *MockLoggingConfigClientMockRecorder) GetSink(ctx, req any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSink", reflect.TypeOf((*MockLoggingConfigClient)(nil).GetSink), ctx, req) } // ListSinks mocks base method. func (m *MockLoggingConfigClient) ListSinks(ctx context.Context, request *loggingpb.ListSinksRequest) shared.LoggingSinkIterator { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListSinks", ctx, request) ret0, _ := ret[0].(shared.LoggingSinkIterator) return ret0 } // ListSinks indicates an expected call of ListSinks. func (mr *MockLoggingConfigClientMockRecorder) ListSinks(ctx, request any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSinks", reflect.TypeOf((*MockLoggingConfigClient)(nil).ListSinks), ctx, request) } ================================================ FILE: sources/gcp/shared/models.go ================================================ package shared import ( "github.com/overmindtech/cli/sources/shared" ) const GCP shared.Source = "gcp" // APIs const ( Compute shared.API = "compute" Container shared.API = "container" NetworkSecurity shared.API = "network-security" NetworkServices shared.API = "network-services" CloudKMS shared.API = "cloud-kms" IAM shared.API = "iam" BigQuery shared.API = "big-query" BigQueryDataTransfer shared.API = "big-query-data-transfer" PubSub shared.API = "pub-sub" CloudResourceManager shared.API = "cloud-resource-manager" AIPlatform shared.API = "ai-platform" BigTableAdmin shared.API = "big-table-admin" CloudBuild shared.API = "cloud-build" DataPlex shared.API = "dataplex" ServiceUsage shared.API = "service-usage" Run shared.API = "run" SqlAdmin shared.API = "sql-admin" Monitoring shared.API = "monitoring" ArtifactRegistry shared.API = "artifact-registry" Dataform shared.API = "dataform" Storage shared.API = "storage" StorageTransfer shared.API = "storage-transfer" ServiceDirectory shared.API = "service-directory" DNS shared.API = "dns" CloudBilling shared.API = "cloud-billing" EssentialContacts shared.API = "essential-contacts" Logging shared.API = "logging" NetworkConnectivity shared.API = "network-connectivity" VPCAccess shared.API = "vpc-access" SecretManager shared.API = "secret-manager" Spanner shared.API = "spanner" AppEngine shared.API = "app-engine" CloudFunctions shared.API = "cloud-functions" Eventarc shared.API = "eventarc" // Added for Eventarc triggers Workflows shared.API = "workflows" // Added for Workflows (workflowexecutions.googleapis.com) OrgPolicy shared.API = "orgpolicy" // Added for Org Policy (orgpolicy.googleapis.com) Dataproc shared.API = "dataproc" // Added for Dataproc (dataproc.googleapis.com) Redis shared.API = "redis" // Added for Redis (redis.googleapis.com) SecurityCenterManagement shared.API = "security-center-management" // Added for Security Center Management (securitycentermanagement.googleapis.com) File shared.API = "file" // Added for File (file.googleapis.com) CertificateManager shared.API = "certificate-manager" // Added for Certificate Manager (certificatemanager.googleapis.com) BinaryAuthorization shared.API = "binary-authorization" // Added for Binary Authorization (binaryauthorization.googleapis.com) Dataflow shared.API = "dataflow" // Added for Dataflow (dataflow.googleapis.com) ) // Resources const ( Instance shared.Resource = "instance" Cluster shared.Resource = "cluster" Disk shared.Resource = "disk" Network shared.Resource = "network" NodeGroup shared.Resource = "node-group" NodeTemplate shared.Resource = "node-template" NodePool shared.Resource = "node-pool" Subnetwork shared.Resource = "subnetwork" Address shared.Resource = "address" ForwardingRule shared.Resource = "forwarding-rule" BackendService shared.Resource = "backend-service" BackendBucket shared.Resource = "backend-bucket" UrlMap shared.Resource = "url-map" Autoscaler shared.Resource = "autoscaler" InstanceGroupManager shared.Resource = "instance-group-manager" RegionalInstanceGroupManager shared.Resource = "regional-instance-group-manager" SecurityPolicy shared.Resource = "security-policy" ClientTlsPolicy shared.Resource = "client-tls-policy" ServiceLbPolicy shared.Resource = "service-lb-policy" ServiceBinding shared.Resource = "service-binding" InstanceTemplate shared.Resource = "instance-template" RegionalInstanceTemplate shared.Resource = "regional-instance-template" InstanceGroup shared.Resource = "instance-group" TargetPool shared.Resource = "target-pool" ResourcePolicy shared.Resource = "resource-policy" HealthCheck shared.Resource = "health-check" HttpHealthCheck shared.Resource = "http-health-check" RegionCommitment shared.Resource = "region-commitment" Reservation shared.Resource = "reservation" MachineType shared.Resource = "machine-type" AcceleratorType shared.Resource = "accelerator-type" Rule shared.Resource = "security-policy-rule" InstantSnapshot shared.Resource = "instant-snapshot" Image shared.Resource = "image" Snapshot shared.Resource = "snapshot" License shared.Resource = "license" CryptoKeyVersion shared.Resource = "crypto-key-version" DiskType shared.Resource = "disk-type" MachineImage shared.Resource = "machine-image" Zone shared.Resource = "zone" Region shared.Resource = "region" Firewall shared.Resource = "firewall" Route shared.Resource = "route" ServiceAccountKey shared.Resource = "service-account-key" ServiceAccount shared.Resource = "service-account" Table shared.Resource = "table" Dataset shared.Resource = "dataset" Subscription shared.Resource = "subscription" Topic shared.Resource = "topic" Schema shared.Resource = "schema" Project shared.Resource = "project" Folder shared.Resource = "folder" Organization shared.Resource = "organization" CryptoKey shared.Resource = "crypto-key" EKMConnection shared.Resource = "ekm-connection" ImportJob shared.Resource = "import-job" Policy shared.Resource = "policy" KeyRing shared.Resource = "key-ring" InstanceSettings shared.Resource = "instance-settings" Bucket shared.Resource = "bucket" BucketIAMPolicy shared.Resource = "bucket-iam-policy" BucketAccessControl shared.Resource = "bucket-access-control" DefaultObjectAccessControl shared.Resource = "default-object-access-control" NotificationConfig shared.Resource = "storage-notification-config" NetworkAttachment shared.Resource = "network-attachment" StoragePool shared.Resource = "storage-pool" StoragePoolType shared.Resource = "storage-pool-type" VpnTunnel shared.Resource = "vpn-tunnel" NetworkPeering shared.Resource = "network-peering" Gateway shared.Resource = "gateway" CustomJob shared.Resource = "custom-job" PipelineJob shared.Resource = "pipeline-job" Schedule shared.Resource = "schedule" Role shared.Resource = "role" AppProfile shared.Resource = "app-profile" Backup shared.Resource = "backup" Build shared.Resource = "build" EntryGroup shared.Resource = "entry-group" AspectType shared.Resource = "aspect-type" DataScan shared.Resource = "data-scan" Entity shared.Resource = "entity" Service shared.Resource = "service" Revision shared.Resource = "revision" BackupRun shared.Resource = "backup-run" CustomDashboard shared.Resource = "custom-dashboard" NotificationChannel shared.Resource = "notification-channel" DockerImage shared.Resource = "docker-image" Package shared.Resource = "package" PackageVersion shared.Resource = "package-version" PackageTag shared.Resource = "package-tag" Repository shared.Resource = "repository" Endpoint shared.Resource = "endpoint" ManagedZone shared.Resource = "managed-zone" BillingInfo shared.Resource = "billing-info" Contact shared.Resource = "contact" SavedQuery shared.Resource = "saved-query" Link shared.Resource = "link" Sink shared.Resource = "sink" Hub shared.Resource = "hub" FirewallPolicy shared.Resource = "firewall-policy" TensorBoard shared.Resource = "tensor-board" Experiment shared.Resource = "experiment" ExperimentRun shared.Resource = "experiment-run" Model shared.Resource = "model" ModelDeploymentMonitoringJob shared.Resource = "model-deployment-monitoring-job" BatchPredictionJob shared.Resource = "batch-prediction-job" DeploymentResourcePool shared.Resource = "deployment-resource-pool" PersistentResource shared.Resource = "persistent-resource" Connection shared.Resource = "connection" Trigger shared.Resource = "trigger" Channel shared.Resource = "channel" // Eventarc Channel Connector shared.Resource = "connector" Workflow shared.Resource = "workflow" // Workflows Workflow BillingAccount shared.Resource = "billing-account" Namespace shared.Resource = "namespace" Secret shared.Resource = "secret" SecretVersion shared.Resource = "secret-version" InstanceConfig shared.Resource = "instance-config" Database shared.Resource = "database" BackupSchedule shared.Resource = "backup-schedule" DatabaseRole shared.Resource = "database-role" User shared.Resource = "user" DatabaseOperation shared.Resource = "database-operation" Session shared.Resource = "session" InstancePartition shared.Resource = "instance-partition" NetworkEndpointGroup shared.Resource = "network-endpoint-group" SSLCertificate shared.Resource = "ssl-certificate" GlobalAddress shared.Resource = "global-address" VpnGateway shared.Resource = "vpn-gateway" Router shared.Resource = "router" GlobalForwardingRule shared.Resource = "global-forwarding-rule" Function shared.Resource = "function" WorkerPool shared.Resource = "worker-pool" TagValue shared.Resource = "tag-value" TagKey shared.Resource = "tag-key" AlertPolicy shared.Resource = "alert-policy" AutoscalingPolicy shared.Resource = "autoscaling-policy" InterconnectAttachment shared.Resource = "interconnect-attachment" ServiceAttachment shared.Resource = "service-attachment" TargetHttpsProxy shared.Resource = "target-https-proxy" RegionTargetHttpsProxy shared.Resource = "region-target-https-proxy" SSLPolicy shared.Resource = "ssl-policy" TargetHttpProxy shared.Resource = "target-http-proxy" TargetTcpProxy shared.Resource = "target-tcp-proxy" TargetSslProxy shared.Resource = "target-ssl-proxy" TargetVpnGateway shared.Resource = "target-vpn-gateway" TargetInstance shared.Resource = "target-instance" PublicDelegatedPrefix shared.Resource = "public-delegated-prefix" PublicAdvertisedPrefix shared.Resource = "public-advertised-prefix" ExternalVpnGateway shared.Resource = "external-vpn-gateway" TransferConfig shared.Resource = "transfer-config" TransferRun shared.Resource = "transfer-run" DataSource shared.Resource = "data-source" Routine shared.Resource = "routine" TransferJob shared.Resource = "transfer-job" TransferOperation shared.Resource = "transfer-operation" // Storage Transfer Transfer Operation (child resource) AgentPool shared.Resource = "agent-pool" // Storage Transfer Agent Pool SecurityCenterService shared.Resource = "security-center-service" // Used by Security Center Management SecurityHealthAnalyticsCustomModule shared.Resource = "security-health-analytics-custom-module" // Security Center Management Security Health Analytics Custom Module EventThreatDetectionCustomModule shared.Resource = "event-threat-detection-custom-module" // Security Center Management Event Threat Detection Custom Module EffectiveSecurityHealthAnalyticsCustomModule shared.Resource = "effective-security-health-analytics-custom-module" // Security Center Management Effective Security Health Analytics Custom Module EffectiveEventThreatDetectionCustomModule shared.Resource = "effective-event-threat-detection-custom-module" // Security Center Management Effective Event Threat Detection Custom Module CertificateMap shared.Resource = "certificate-map" // Certificate Manager Certificate Map CertificateMapEntry shared.Resource = "certificate-map-entry" // Certificate Manager Certificate Map Entry Certificate shared.Resource = "certificate" // Certificate Manager Certificate DnsAuthorization shared.Resource = "dns-authorization" // Certificate Manager DNS Authorization CertificateIssuanceConfig shared.Resource = "certificate-issuance-config" // Certificate Manager Certificate Issuance Config InternalRange shared.Resource = "internal-range" // Network Connectivity API Internal Range RoutePolicy shared.Resource = "route-policy" // Router Route Policy child resource BgpRoute shared.Resource = "bgp-route" // Router BGP Route child resource MetastoreService shared.Resource = "metastore-service" // Dataproc Metastore Service CompilationResult shared.Resource = "compilation-result" // Dataform Compilation Result child resource Workspace shared.Resource = "workspace" // Dataform Workspace child resource WorkflowInvocation shared.Resource = "workflow-invocation" // Dataform Workflow Invocation child resource Mesh shared.Resource = "mesh" // Network Services API Mesh BinaryAuthorizationPolicy shared.Resource = "binary-authorization-policy" // Binary Authorization API Platform Policy Job shared.Resource = "job" // Dataflow Job ) ================================================ FILE: sources/gcp/shared/network-security-clients.go ================================================ package shared import ( "context" "cloud.google.com/go/networksecurity/apiv1beta1" "cloud.google.com/go/networksecurity/apiv1beta1/networksecuritypb" "github.com/googleapis/gax-go/v2" ) type NetworkSecurityClientTlsPolicyClient interface { Get(ctx context.Context, req *networksecuritypb.GetClientTlsPolicyRequest, opts ...gax.CallOption) (*networksecuritypb.ClientTlsPolicy, error) List(ctx context.Context, req *networksecuritypb.ListClientTlsPoliciesRequest, opts ...gax.CallOption) NetworkSecurityClientTlsPolicyIterator } type NetworkSecurityClientTlsPolicyIterator interface { Next() (*networksecuritypb.ClientTlsPolicy, error) } type networkSecurityClientTlsPolicyClient struct { client *networksecurity.Client } func (c networkSecurityClientTlsPolicyClient) Get(ctx context.Context, req *networksecuritypb.GetClientTlsPolicyRequest, opts ...gax.CallOption) (*networksecuritypb.ClientTlsPolicy, error) { return c.client.GetClientTlsPolicy(ctx, req, opts...) } func (c networkSecurityClientTlsPolicyClient) List(ctx context.Context, req *networksecuritypb.ListClientTlsPoliciesRequest, opts ...gax.CallOption) NetworkSecurityClientTlsPolicyIterator { return c.client.ListClientTlsPolicies(ctx, req, opts...) } // NewNetworkSecurityClientTlsPolicyClient creates a new NetworkSecurityClientTlsPolicyClient func NewNetworkSecurityClientTlsPolicyClient(client *networksecurity.Client) NetworkSecurityClientTlsPolicyClient { return &networkSecurityClientTlsPolicyClient{ client: client, } } ================================================ FILE: sources/gcp/shared/predefined-roles.go ================================================ package shared type WithPredefinedRole interface { PredefinedRole() string } type role struct { Role string Link string IAMPermissions []string } // PredefinedRoles is a map of predefined roles for the GCP source. // The IAMPermissions field contains the exact permissions from adapter metadata that require this role. var PredefinedRoles = map[string]role{ "roles/aiplatform.viewer": { Role: "roles/aiplatform.viewer", Link: "https://cloud.google.com/iam/docs/roles-permissions/aiplatform#aiplatform.viewer", IAMPermissions: []string{ "aiplatform.batchPredictionJobs.get", "aiplatform.batchPredictionJobs.list", "aiplatform.customJobs.get", "aiplatform.customJobs.list", "aiplatform.endpoints.get", "aiplatform.endpoints.list", "aiplatform.modelDeploymentMonitoringJobs.get", "aiplatform.modelDeploymentMonitoringJobs.list", "aiplatform.models.get", "aiplatform.models.list", "aiplatform.pipelineJobs.get", "aiplatform.pipelineJobs.list", "resourcemanager.projects.get", }, }, "roles/artifactregistry.reader": { Role: "roles/artifactregistry.reader", Link: "https://cloud.google.com/iam/docs/roles-permissions/artifactregistry#artifactregistry.reader", IAMPermissions: []string{ "artifactregistry.dockerimages.get", "artifactregistry.dockerimages.list", "artifactregistry.repositories.get", "artifactregistry.repositories.list", }, }, "overmind_custom_role": { // Custom role for Overmind with permissions not available in a single least-privilege predefined role. // Created in deploy/sources.tf. Includes read-only Storage Bucket IAM (getIamPolicy) and BigQuery/Spanner extras. Role: "overmind_custom_role", Link: "deploy/sources.tf", IAMPermissions: []string{ "bigquery.transfers.get", "spanner.databases.get", "spanner.databases.list", "storage.buckets.getIamPolicy", }, }, "roles/bigquery.metadataViewer": { Role: "roles/bigquery.metadataViewer", Link: "https://cloud.google.com/iam/docs/roles-permissions/bigquery#bigquery.metadataViewer", IAMPermissions: []string{ "bigquery.datasets.get", "bigquery.models.getMetadata", "bigquery.models.list", "bigquery.tables.get", "bigquery.tables.list", "bigquery.routines.get", "bigquery.routines.list", }, }, "roles/bigtable.viewer": { Role: "roles/bigtable.viewer", // Provides no data access. Intended as a minimal set of permissions to access the Google Cloud console for Bigtable. Link: "https://cloud.google.com/iam/docs/roles-permissions/bigtable#bigtable.viewer", IAMPermissions: []string{ "bigtable.clusters.get", "bigtable.clusters.list", "bigtable.instances.get", "bigtable.instances.list", "bigtable.appProfiles.get", "bigtable.appProfiles.list", "bigtable.tables.get", "bigtable.tables.list", "bigtable.backups.get", "bigtable.backups.list", }, }, "roles/certificatemanager.viewer": { Role: "roles/certificatemanager.viewer", // Read-only access to Certificate Manager resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/certificatemanager#certificatemanager.viewer", IAMPermissions: []string{ "certificatemanager.certs.get", "certificatemanager.certs.list", }, }, "roles/cloudfunctions.viewer": { Role: "roles/cloudfunctions.viewer", // Read-only access to functions and locations. Link: "https://cloud.google.com/iam/docs/roles-permissions/cloudfunctions#cloudfunctions.viewer", IAMPermissions: []string{ "cloudfunctions.functions.get", "cloudfunctions.functions.list", }, }, "roles/resourcemanager.tagViewer": { Role: "roles/resourcemanager.tagViewer", // Access to list Tags and their associations with resources Link: "https://cloud.google.com/iam/docs/roles-permissions/resourcemanager#resourcemanager.tagViewer", IAMPermissions: []string{ "resourcemanager.projects.get", "resourcemanager.tagKeys.get", "resourcemanager.tagKeys.list", "resourcemanager.tagValues.get", "resourcemanager.tagValues.list", }, }, "roles/compute.viewer": { Role: "roles/compute.viewer", // Read-only access to get and list Compute Engine resources, without being able to read the data stored on them. Link: "https://cloud.google.com/iam/docs/roles-permissions/compute#compute.viewer", IAMPermissions: []string{ "compute.acceleratorTypes.get", "compute.acceleratorTypes.list", "compute.addresses.get", "compute.addresses.list", "compute.autoscalers.get", "compute.autoscalers.list", "compute.backendServices.get", "compute.backendServices.list", "compute.commitments.get", "compute.commitments.list", "compute.diskTypes.get", "compute.diskTypes.list", "compute.disks.get", "compute.disks.list", "compute.externalVpnGateways.get", "compute.externalVpnGateways.list", "compute.firewalls.get", "compute.firewalls.list", "compute.forwardingRules.get", "compute.forwardingRules.list", "compute.healthChecks.get", "compute.healthChecks.list", "compute.httpHealthChecks.get", "compute.httpHealthChecks.list", "compute.images.get", "compute.images.list", "compute.instanceGroupManagers.get", "compute.instanceGroupManagers.list", "compute.instanceGroups.get", "compute.instanceGroups.list", "compute.instanceTemplates.get", "compute.instanceTemplates.list", "compute.instances.get", "compute.instances.list", "compute.instantSnapshots.get", "compute.instantSnapshots.list", "compute.licenses.get", "compute.licenses.list", "compute.machineImages.get", "compute.machineImages.list", "compute.networkEndpointGroups.get", "compute.networkEndpointGroups.list", "compute.networks.get", "compute.networks.list", "compute.nodeGroups.get", "compute.nodeGroups.list", "compute.nodeTemplates.get", "compute.nodeTemplates.list", "compute.projects.get", "compute.publicDelegatedPrefixes.get", "compute.publicDelegatedPrefixes.list", "compute.regionBackendServices.get", "compute.regionBackendServices.list", "compute.regionHealthChecks.get", "compute.regionHealthChecks.list", "compute.regionInstanceGroupManagers.get", "compute.regionInstanceGroupManagers.list", "compute.reservations.get", "compute.reservations.list", "compute.resourcePolicies.get", "compute.resourcePolicies.list", "compute.routers.get", "compute.routers.list", "compute.routes.get", "compute.routes.list", "compute.securityPolicies.get", "compute.securityPolicies.list", "compute.snapshots.get", "compute.snapshots.list", "compute.sslCertificates.get", "compute.sslCertificates.list", "compute.sslPolicies.get", "compute.sslPolicies.list", "compute.storagePools.get", "compute.storagePools.list", "compute.subnetworks.get", "compute.subnetworks.list", "compute.targetHttpProxies.get", "compute.targetHttpProxies.list", "compute.targetHttpsProxies.get", "compute.targetHttpsProxies.list", "compute.targetPools.get", "compute.targetPools.list", "compute.urlMaps.get", "compute.urlMaps.list", "compute.vpnGateways.get", "compute.vpnGateways.list", "compute.vpnTunnels.get", "compute.vpnTunnels.list", "compute.machineTypes.get", "compute.machineTypes.list", }, }, "roles/container.viewer": { Role: "roles/container.viewer", Link: "https://cloud.google.com/iam/docs/roles-permissions/container#container.viewer", IAMPermissions: []string{ "container.clusters.get", "container.clusters.list", }, }, "roles/dataflow.viewer": { Role: "roles/dataflow.viewer", Link: "https://cloud.google.com/iam/docs/roles-permissions/dataflow#dataflow.viewer", IAMPermissions: []string{ "dataflow.jobs.get", "dataflow.jobs.list", }, }, "roles/dataproc.viewer": { Role: "roles/dataproc.viewer", // Provides read-only access to Dataproc resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/dataproc#dataproc.viewer", IAMPermissions: []string{ "dataproc.autoscalingPolicies.get", "dataproc.autoscalingPolicies.list", "dataproc.clusters.get", "dataproc.clusters.list", }, }, "roles/monitoring.viewer": { Role: "roles/monitoring.viewer", // Provides read-only access to get and list information about all monitoring data and configurations. Link: "https://cloud.google.com/iam/docs/roles-permissions/monitoring#monitoring.viewer", IAMPermissions: []string{ "monitoring.alertPolicies.get", "monitoring.alertPolicies.list", "monitoring.dashboards.get", "monitoring.dashboards.list", "monitoring.notificationChannels.get", "monitoring.notificationChannels.list", }, }, "roles/redis.viewer": { Role: "roles/redis.viewer", // Read-only access to Redis instances and related resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/redis#redis.viewer", IAMPermissions: []string{ "redis.instances.get", "redis.instances.list", }, }, "roles/run.viewer": { Role: "roles/run.viewer", // Can view the state of all Cloud Run resources, including IAM policies. Link: "https://cloud.google.com/iam/docs/roles-permissions/run#run.viewer", IAMPermissions: []string{ "run.revisions.get", "run.revisions.list", "run.services.get", "run.services.list", "run.workerPools.get", "run.workerPools.list", }, }, "roles/secretmanager.viewer": { Role: "roles/secretmanager.viewer", // Allows viewing metadata of all Secret Manager resources Link: "https://cloud.google.com/iam/docs/roles-permissions/secretmanager#secretmanager.viewer", IAMPermissions: []string{ "secretmanager.secrets.get", "secretmanager.secrets.list", }, }, "roles/spanner.viewer": { Role: "roles/spanner.viewer", /* A principal with this role can: - View all Spanner instances (but cannot modify instances). - View all Spanner databases (but cannot modify or read from databases). */ Link: "https://cloud.google.com/iam/docs/roles-permissions/spanner#spanner.viewer", IAMPermissions: []string{ "spanner.instanceConfigs.get", "spanner.instanceConfigs.list", "spanner.instances.get", "spanner.instances.list", }, }, "roles/cloudsql.viewer": { Role: "roles/cloudsql.viewer", // Provides read-only access to Cloud SQL resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/cloudsql#cloudsql.viewer", IAMPermissions: []string{ "cloudsql.backupRuns.get", "cloudsql.backupRuns.list", "cloudsql.instances.get", "cloudsql.instances.list", }, }, "roles/storagetransfer.viewer": { Role: "roles/storagetransfer.viewer", // Read access to storage transfer jobs and operations. Link: "https://cloud.google.com/iam/docs/roles-permissions/storagetransfer#storagetransfer.viewer", IAMPermissions: []string{ "storagetransfer.jobs.get", "storagetransfer.jobs.list", }, }, "roles/storage.bucketViewer": { Role: "roles/storage.bucketViewer", // Grants permission to view buckets and their metadata, excluding IAM policies. // This role is in Beta mode, but we don't have any alternatives. Link: "https://cloud.google.com/iam/docs/roles-permissions/storage#storage.bucketViewer", IAMPermissions: []string{ "storage.buckets.get", "storage.buckets.list", }, }, "roles/pubsub.viewer": { Role: "roles/pubsub.viewer", // Provides access to view topics and subscriptions. Link: "https://cloud.google.com/iam/docs/roles-permissions/pubsub#pubsub.viewer", IAMPermissions: []string{ "pubsub.subscriptions.get", "pubsub.subscriptions.list", "pubsub.topics.get", "pubsub.topics.list", }, }, "roles/dataplex.viewer": { Role: "roles/dataplex.viewer", // Read access to Dataplex Universal Catalog resources, except for catalog resources like entries, entry groups, and glossaries. Link: "https://cloud.google.com/iam/docs/roles-permissions/dataplex#dataplex.viewer", IAMPermissions: []string{ "dataplex.dataScans.get", "dataplex.dataScans.list", }, }, "roles/dataplex.catalogViewer": { Role: "roles/dataplex.catalogViewer", // Read access to catalog resources, including entries, entry groups, and glossaries. Can view IAM policies on catalog resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/dataplex#dataplex.catalogViewer", IAMPermissions: []string{ "dataplex.aspectTypes.get", "dataplex.aspectTypes.list", "dataplex.entryGroups.get", "dataplex.entryGroups.list", }, }, "roles/iam.roleViewer": { Role: "roles/iam.roleViewer", // Provides read access to all custom roles in the project. Link: "https://cloud.google.com/iam/docs/roles-permissions/iam#iam.roleViewer", IAMPermissions: []string{ "iam.roles.get", "iam.roles.list", }, }, "roles/iam.serviceAccountViewer": { Role: "roles/iam.serviceAccountViewer", Link: "https://cloud.google.com/iam/docs/roles-permissions/iam#iam.serviceAccountViewer", IAMPermissions: []string{ "iam.serviceAccountKeys.get", "iam.serviceAccountKeys.list", "iam.serviceAccounts.get", "iam.serviceAccounts.list", }, }, "roles/dns.reader": { Role: "roles/dns.reader", Link: "https://cloud.google.com/iam/docs/roles-permissions/dns#dns.reader", IAMPermissions: []string{ "dns.managedZones.get", "dns.managedZones.list", }, }, "roles/logging.viewer": { Role: "roles/logging.viewer", // Provides access to view logs. Link: "https://cloud.google.com/iam/docs/roles-permissions/logging#logging.viewer", IAMPermissions: []string{ "logging.buckets.get", "logging.buckets.list", "logging.links.get", "logging.links.list", "logging.queries.getShared", "logging.queries.listShared", "logging.sinks.get", "logging.sinks.list", }, }, "roles/serviceusage.serviceUsageViewer": { Role: "roles/serviceusage.serviceUsageViewer", // Ability to inspect service states and operations for a consumer project. Link: "https://cloud.google.com/iam/docs/roles-permissions/serviceusage#serviceusage.serviceUsageViewer", IAMPermissions: []string{ "serviceusage.services.get", "serviceusage.services.list", }, }, "roles/servicedirectory.viewer": { Role: "roles/servicedirectory.viewer", // View Service Directory resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/servicedirectory#servicedirectory.viewer", IAMPermissions: []string{ "servicedirectory.endpoints.get", "servicedirectory.endpoints.list", "servicedirectory.services.get", "servicedirectory.services.list", }, }, "roles/eventarc.viewer": { Role: "roles/eventarc.viewer", // Can view the state of all Eventarc resources, including IAM policies. Link: "https://cloud.google.com/iam/docs/roles-permissions/eventarc#eventarc.viewer", IAMPermissions: []string{ "eventarc.triggers.get", "eventarc.triggers.list", }, }, "roles/orgpolicy.policyViewer": { Role: "roles/orgpolicy.policyViewer", Link: "https://cloud.google.com/iam/docs/roles-permissions/orgpolicy#orgpolicy.policyViewer", IAMPermissions: []string{ "orgpolicy.policy.get", "orgpolicy.policies.list", }, }, "roles/essentialcontacts.viewer": { Role: "roles/essentialcontacts.viewer", // Viewer for all essential contacts Link: "https://cloud.google.com/iam/docs/roles-permissions/essentialcontacts#essentialcontacts.viewer", IAMPermissions: []string{ "essentialcontacts.contacts.get", "essentialcontacts.contacts.list", }, }, "roles/file.viewer": { Role: "roles/file.viewer", // Read-only access to Filestore instances and related resources. // This role is in Beta mode, but we don't have any alternatives. Link: "https://cloud.google.com/iam/docs/roles-permissions/file#file.viewer", IAMPermissions: []string{ "file.instances.get", "file.instances.list", }, }, "roles/securitycentermanagement.viewer": { Role: "roles/securitycentermanagement.viewer", // Readonly access to Cloud Security Command Center services and custom modules configuration. Link: "https://cloud.google.com/iam/docs/roles-permissions/securitycentermanagement#securitycentermanagement.viewer", IAMPermissions: []string{ "securitycentermanagement.securityCenterServices.get", "securitycentermanagement.securityCenterServices.list", }, }, "roles/cloudbuild.builds.viewer": { Role: "roles/cloudbuild.builds.viewer", // Provides access to view builds. Link: "https://cloud.google.com/iam/docs/roles-permissions/cloudbuild#cloudbuild.builds.viewer", IAMPermissions: []string{ "cloudbuild.builds.get", "cloudbuild.builds.list", }, }, "roles/dataform.viewer": { Role: "roles/dataform.viewer", // Read-only access to all Dataform resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/dataform#dataform.viewer", IAMPermissions: []string{ "dataform.repositories.get", "dataform.repositories.list", }, }, "roles/cloudkms.viewer": { Role: "roles/cloudkms.viewer", // Read-only access to Cloud KMS resources. Link: "https://cloud.google.com/iam/docs/roles-permissions/cloudkms#cloudkms.viewer", IAMPermissions: []string{ "cloudkms.cryptoKeys.get", "cloudkms.cryptoKeys.list", "cloudkms.cryptoKeyVersions.get", "cloudkms.cryptoKeyVersions.list", "cloudkms.keyRings.get", "cloudkms.keyRings.list", "cloudkms.locations.list", }, }, "roles/cloudasset.viewer": { Role: "roles/cloudasset.viewer", // Read-only access to Cloud Asset Inventory. Link: "https://cloud.google.com/iam/docs/roles-permissions/cloudasset#cloudasset.viewer", IAMPermissions: []string{ "cloudasset.assets.listResource", }, }, } ================================================ FILE: sources/gcp/shared/storage-iam.go ================================================ package shared import ( "context" "errors" "cloud.google.com/go/storage" ) // ErrStorageClientNotInitialized is returned when the Storage client was not initialized (e.g. when enumerating adapters without initGCPClients). var ErrStorageClientNotInitialized = errors.New("storage client not initialized") // BucketIAMBinding represents one IAM binding (role + members, optionally with a condition) in a bucket's policy. // The adapter emits one item per bucket (the full policy); bindings are serialized in that item's bindings array. type BucketIAMBinding struct { Role string Members []string ConditionExpression string // CEL expression; empty if no condition ConditionTitle string // optional; empty if no condition or not set ConditionDescription string // optional; empty if no condition or not set } // StorageBucketIAMPolicyGetter retrieves the IAM policy for a GCS bucket as a slice of bindings. // See: https://cloud.google.com/storage/docs/json_api/v1/buckets/getIamPolicy type StorageBucketIAMPolicyGetter interface { GetBucketIAMPolicy(ctx context.Context, bucketName string) ([]BucketIAMBinding, error) } // storageBucketIAMPolicyGetterImpl implements StorageBucketIAMPolicyGetter using the Storage client. type storageBucketIAMPolicyGetterImpl struct { client *storage.Client } // GetBucketIAMPolicy returns the IAM policy for the given bucket. func (g *storageBucketIAMPolicyGetterImpl) GetBucketIAMPolicy(ctx context.Context, bucketName string) ([]BucketIAMBinding, error) { if g.client == nil { return nil, ErrStorageClientNotInitialized } policy3, err := g.client.Bucket(bucketName).IAM().V3().Policy(ctx) if err != nil { return nil, err } out := make([]BucketIAMBinding, 0, len(policy3.Bindings)) for _, b := range policy3.Bindings { condExpr := "" condTitle := "" condDesc := "" if b.GetCondition() != nil { condExpr = b.GetCondition().GetExpression() condTitle = b.GetCondition().GetTitle() condDesc = b.GetCondition().GetDescription() } out = append(out, BucketIAMBinding{ Role: b.GetRole(), Members: b.GetMembers(), ConditionExpression: condExpr, ConditionTitle: condTitle, ConditionDescription: condDesc, }) } return out, nil } // NewStorageBucketIAMPolicyGetter creates a getter that uses the given Storage client. func NewStorageBucketIAMPolicyGetter(client *storage.Client) StorageBucketIAMPolicyGetter { return &storageBucketIAMPolicyGetterImpl{client: client} } ================================================ FILE: sources/gcp/shared/terraform-mappings.go ================================================ package shared import ( "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/sources/shared" ) type TerraformMapping struct { Reference string Description string Mappings []*sdp.TerraformMapping } // SDPAssetTypeToTerraformMappings maps GCP asset types to their terraform mappings. // This map is populated during source initiation by individual adapter files. var SDPAssetTypeToTerraformMappings = map[shared.ItemType]TerraformMapping{} ================================================ FILE: sources/gcp/shared/terraform-mappings_test.go ================================================ package shared import ( "testing" ) func TestMissingMappings(t *testing.T) { for sdpItemType := range SDPAssetTypeToAdapterMeta { if SDPAssetTypeToAdapterMeta[sdpItemType].InDevelopment { t.Logf("Skipping %s as it is in development", sdpItemType) continue } if _, ok := SDPAssetTypeToTerraformMappings[sdpItemType]; !ok { t.Errorf("Missing Terraform mapping for %s", sdpItemType) } } } ================================================ FILE: sources/gcp/shared/utils.go ================================================ package shared import ( "context" "fmt" "strings" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // RecordExtractScopeFromURIError records an error from ExtractScopeFromURI to the span. // This should be called whenever ExtractScopeFromURI returns an error to help with observability. func RecordExtractScopeFromURIError(ctx context.Context, uri string, err error) { span := trace.SpanFromContext(ctx) if span.IsRecording() { span.RecordError(err, trace.WithAttributes( attribute.String("ovm.gcp.extractScopeFromURI.uri", uri), attribute.String("ovm.gcp.extractScopeFromURI.error", err.Error()), )) } } // RegionalScope constructs a regional scope string from project ID and region. func RegionalScope(projectID, region string) string { return fmt.Sprintf("%s.%s", projectID, region) } // ZonalScope constructs a zonal scope string from project ID and zone. func ZonalScope(projectID, zone string) string { return fmt.Sprintf("%s.%s", projectID, zone) } // LastPathComponent extracts the last component from a GCP resource URL. // If the input does not contain a "/", it returns the input itself. // If the input is empty or only slashes, it returns an empty string. func LastPathComponent(url string) string { if url == "" { return "" } parts := strings.Split(url, "/") for i := len(parts) - 1; i >= 0; i-- { if parts[i] != "" { return parts[i] } } return "" } // ExtractPathParam extracts the value following a given key from a GCP resource name. // For example, for input="projects/my-proj/locations/global/keyRings/my-kr/cryptoKeys/my-key" // and key="cryptoKeys", it will return "my-key". func ExtractPathParam(key, input string) string { parts := strings.Split(input, "/") for i, part := range parts { if part == key && len(parts) > i+1 { return parts[i+1] } } return "" } // ExtractPathParams extracts values following specified keys from a GCP resource name. // It returns a slice of values in the order of the keys provided. // // For example, for input="projects/my-proj/locations/global/keyRings/my-kr/cryptoKeys/my-key" // and keys=["keyRings", "cryptoKeys"], it will return ["my-kr", "my-key"]. // If a key is not found, it will not be included in the results. // // If it fails to extract any values, it returns an empty slice. // // If it's a single part and no results were found for the given key(s), it returns the input itself. // input => "my-managed-dns-zone", keys => "managedZones", output => ["my-managed-dns-zone"] func ExtractPathParams(input string, keys ...string) []string { parts := strings.Split(input, "/") results := make([]string, 0, len(keys)) for k := 0; k <= len(keys)-1; k++ { key := keys[k] for i, part := range parts { if part == key && len(parts) > i+1 { results = append(results, parts[i+1]) break } } } // if it's a single part and no results were found, return the part itself if len(results) == 0 && len(parts) == 1 && parts[0] != "" { return []string{parts[0]} } return results } // ExtractPathParamsWithCount extracts path parameters from a fully qualified GCP resource name. // It returns the last `count` path parameters from the input string. // // For example, for input="projects/my-proj/locations/global/keyRings/my-kr/cryptoKeys/my-key" // and count=2, it will return ["my-kr", "my-key"]. func ExtractPathParamsWithCount(input string, count int) []string { if count <= 0 || input == "" { return nil } parts := strings.Split(strings.Trim(input, "/"), "/") if len(parts) < 2*count { return nil } var result []string for i := count - 1; i >= 0; i-- { step := 1 + 2*i result = append(result, parts[len(parts)-step]) } return result } // ZoneToRegion converts a GCP zone to a region. // The fully-qualified name for a zone is made up of -. // For example, the fully qualified name for zone a in region us-central1 is us-central1-a. // https://cloud.google.com/compute/docs/regions-zones#identifying_a_region_or_zone func ZoneToRegion(zone string) string { parts := strings.Split(zone, "-") if len(parts) < 2 { return "" } return strings.Join(parts[:len(parts)-1], "-") } // isProjectNumber returns true if the project identifier appears to be a // GCP project number (all digits) rather than a project ID. Project IDs // must start with a letter per GCP rules. // // We use a simple loop instead of a regex (e.g., `^\d+$`) because: // - It's more idiomatic Go for simple character validation // - Avoids regex compilation/matching overhead (even pre-compiled) // - More readable for maintainers unfamiliar with regex // - Sufficient for the straightforward "all digits" check func isProjectNumber(projectID string) bool { if projectID == "" { return false } for _, r := range projectID { if r < '0' || r > '9' { return false } } return true } // ExtractScopeFromURI extracts the scope from a GCP resource URI. // It supports various URL formats including full HTTPS URLs, full resource names, // service destination formats, and bare paths. // // Examples: // - Zonal scope: "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/my-disk" → "my-project.us-central1-a" // - Regional scope: "projects/my-project/regions/us-central1/subnetworks/my-subnet" → "my-project.us-central1" // - Project scope: "https://www.googleapis.com/compute/v1/projects/my-project/global/networks/my-network" → "my-project" // // The function determines scope based on the location specifiers found in the path: // - If zones/{zone} or locations/{zone-format} is found → zonal scope (project.zone) // - If regions/{region} or locations/{region-format} is found (and no zone) → regional scope (project.region) // - If global keyword is found, or only project is found → project scope (project) // // Returns an error if: // - The project ID cannot be determined // - Conflicting location specifiers are found (e.g., both zones and regions) // - The URI format is invalid // // If an error occurs, it is automatically recorded to the span from the context for observability. func ExtractScopeFromURI(ctx context.Context, uri string) (string, error) { if uri == "" { err := fmt.Errorf("URI is empty") RecordExtractScopeFromURIError(ctx, uri, err) return "", err } // Extract the path portion from various URL formats path := extractPathFromURI(uri) // Extract project, region, zone, and location from the path projectID := ExtractPathParam("projects", path) zone := ExtractPathParam("zones", path) region := ExtractPathParam("regions", path) location := ExtractPathParam("locations", path) // Check for global keyword hasGlobal := strings.Contains(path, "/global/") || location == "global" // Handle special case: projects/_/buckets (project placeholder, cannot determine scope) if projectID == "_" { err := fmt.Errorf("cannot determine scope from URI with project placeholder: %s", uri) RecordExtractScopeFromURIError(ctx, uri, err) return "", err } // Validate project is present (unless it's the special _ case, already handled) if projectID == "" { err := fmt.Errorf("cannot determine scope: project ID not found in URI: %s", uri) RecordExtractScopeFromURIError(ctx, uri, err) return "", err } // When URI uses project number instead of project ID, we cannot map to // adapter scopes (which use project IDs). Return wildcard so the query // is broadcast to all adapters. if isProjectNumber(projectID) { return "*", nil } // Check for conflicting location specifiers if zone != "" && region != "" { err := fmt.Errorf("cannot determine scope: both zones and regions found in URI: %s", uri) RecordExtractScopeFromURIError(ctx, uri, err) return "", err } if zone != "" && location != "" { err := fmt.Errorf("cannot determine scope: both zones and locations found in URI: %s", uri) RecordExtractScopeFromURIError(ctx, uri, err) return "", err } // Determine scope based on location specifiers found // Priority: zone > region > project (global) // Zonal scope: zones/{zone} or locations/{zone-format} if zone != "" { return ZonalScope(projectID, zone), nil } if location != "" && location != "global" { // Check if location is zone-format using ZoneToRegion // If ZoneToRegion returns a non-empty region, the location is a zone if extractedRegion := ZoneToRegion(location); extractedRegion != "" { // Location is zone-format return ZonalScope(projectID, location), nil } // Location is region-format return RegionalScope(projectID, location), nil } // Regional scope: regions/{region} if region != "" { return RegionalScope(projectID, region), nil } // Project scope: global keyword or no location specifiers if hasGlobal || location == "global" { return projectID, nil } // Project scope: only project found, no location specifiers return projectID, nil } // extractPathFromURI extracts the resource path from various GCP URI formats. // It handles: // - Full HTTPS URLs: https://www.googleapis.com/compute/v1/projects/... // - Service-specific HTTPS URLs: https://compute.googleapis.com/compute/v1/projects/... // - Full resource names: //compute.googleapis.com/projects/... // - Service destination formats: pubsub.googleapis.com/projects/... // - Bare paths: projects/... func extractPathFromURI(uri string) string { // Remove query parameters and fragments if idx := strings.IndexAny(uri, "?#"); idx != -1 { uri = uri[:idx] } // Handle full resource names: //service.googleapis.com/path if strings.HasPrefix(uri, "//") { // Find the path after the domain parts := strings.SplitN(uri[2:], "/", 2) if len(parts) > 1 { return parts[1] } return "" } // Handle HTTPS/HTTP URLs: https://domain/path or http://domain/path if strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://") { // Remove protocol uri = uri[strings.Index(uri, "://")+3:] // Find the path after the domain parts := strings.SplitN(uri, "/", 2) if len(parts) > 1 { path := parts[1] // Strip version paths like /v1/, /v2/, /compute/v1/, /bigquery/v2/, etc. // These appear after the domain and before the resource path // Pattern: /{service}/v{version}/ or /v{version}/ path = stripVersionPath(path) return path } return "" } // Handle service destination formats: service.googleapis.com/path // These don't have a protocol prefix if strings.Contains(uri, ".googleapis.com/") { parts := strings.SplitN(uri, ".googleapis.com/", 2) if len(parts) > 1 { path := parts[1] path = stripVersionPath(path) return path } } // Bare path: projects/... (use as-is) return uri } // stripVersionPath removes version paths from the beginning of a path. // Examples: // - "v1/projects/..." → "projects/..." // - "compute/v1/projects/..." → "projects/..." // - "bigquery/v2/projects/..." → "projects/..." func stripVersionPath(path string) string { parts := strings.Split(path, "/") if len(parts) == 0 { return path } // Check for version pattern at the start // Pattern 1: /v{version}/ (e.g., v1, v2) if len(parts) > 0 && strings.HasPrefix(parts[0], "v") && len(parts[0]) == 2 { // Skip version part if len(parts) > 1 { return strings.Join(parts[1:], "/") } return "" } // Pattern 2: /{service}/v{version}/ (e.g., compute/v1, bigquery/v2) if len(parts) > 1 && strings.HasPrefix(parts[1], "v") && len(parts[1]) == 2 { // Skip service and version parts if len(parts) > 2 { return strings.Join(parts[2:], "/") } return "" } return path } ================================================ FILE: sources/gcp/shared/utils_test.go ================================================ package shared_test import ( "context" "reflect" "testing" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" ) func TestLastPathComponent(t *testing.T) { tests := []struct { input string expected string }{ { input: "projects/test-project/zones/us-central1-a/disks/my-disk", expected: "my-disk", }, { input: "projects/test-project/zones/us-central1-a", expected: "us-central1-a", }, { input: "my-disk", expected: "my-disk", }, { input: "", expected: "", }, { input: "/", expected: "", }, { input: "////", expected: "", }, { input: "foo/bar/baz", expected: "baz", }, } for _, tc := range tests { actual := gcpshared.LastPathComponent(tc.input) if actual != tc.expected { t.Errorf("LastPathComponent(%q) = %q; want %q", tc.input, actual, tc.expected) } } } func TestExtractPathParam(t *testing.T) { tests := []struct { name string key string input string expected string }{ // ExtractLocation cases { name: "ExtractLocation: Valid input with location", key: "locations", input: "projects/proj/locations/us-central1/keyRings/my-ring/cryptoKeys/my-key/cryptoKeyVersions/3", expected: "us-central1", }, { name: "ExtractLocation: Different region", key: "locations", input: "projects/proj/locations/europe-west1/keyRings/ring/cryptoKeys/key/cryptoKeyVersions/5", expected: "europe-west1", }, { name: "ExtractLocation: No location in path", key: "locations", input: "projects/proj/keyRings/ring/cryptoKeys/key", expected: "", }, { name: "ExtractLocation: Empty input", key: "locations", input: "", expected: "", }, { name: "ExtractLocation: Malformed input", key: "locations", input: "this-is-not-a-kms-path", expected: "", }, // ExtractKeyRing cases { name: "ExtractKeyRing: Valid input with key ring", key: "keyRings", input: "projects/proj/locations/us/keyRings/ring/cryptoKeys/key/cryptoKeyVersions/1", expected: "ring", }, { name: "ExtractKeyRing: Different key ring", key: "keyRings", input: "projects/proj/locations/europe/keyRings/test-ring/cryptoKeys/key/cryptoKeyVersions/1", expected: "test-ring", }, { name: "ExtractKeyRing: Missing keyRings segment", key: "keyRings", input: "projects/proj/locations/loc/cryptoKeys/key", expected: "", }, { name: "ExtractKeyRing: Empty input", key: "keyRings", input: "", expected: "", }, { name: "ExtractKeyRing: Malformed path", key: "keyRings", input: "keyRings", expected: "", }, // ExtractCryptoKey cases { name: "ExtractCryptoKey: Valid input", key: "cryptoKeys", input: "projects/proj/locations/loc/keyRings/ring/cryptoKeys/key/cryptoKeyVersions/1", expected: "key", }, { name: "ExtractCryptoKey: Another valid input", key: "cryptoKeys", input: "projects/a/locations/b/keyRings/r/cryptoKeys/my-key/cryptoKeyVersions/2", expected: "my-key", }, { name: "ExtractCryptoKey: Missing cryptoKeys segment", key: "cryptoKeys", input: "projects/p/locations/l/keyRings/r/cryptoKeyVersions/1", expected: "", }, { name: "ExtractCryptoKey: Empty input", key: "cryptoKeys", input: "", expected: "", }, { name: "ExtractCryptoKey: Malformed string", key: "cryptoKeys", input: "cryptoKeyVersions", expected: "", }, // ExtractCryptoKeyVersion cases (as ExtractResourcePart) { name: "ExtractCryptoKeyVersion: Valid input", key: "cryptoKeyVersions", input: "projects/proj/locations/loc/keyRings/ring/cryptoKeys/key/cryptoKeyVersions/3", expected: "3", }, { name: "ExtractCryptoKeyVersion: Different version", key: "cryptoKeyVersions", input: "projects/a/locations/b/keyRings/r/cryptoKeys/key/cryptoKeyVersions/7", expected: "7", }, { name: "ExtractCryptoKeyVersion: Missing version segment", key: "cryptoKeyVersions", input: "projects/p/locations/l/keyRings/r/cryptoKeys/key", expected: "", }, { name: "ExtractCryptoKeyVersion: Empty input", key: "cryptoKeyVersions", input: "", expected: "", }, { name: "ExtractCryptoKeyVersion: Malformed string", key: "cryptoKeyVersions", input: "cryptoKeyVersions", expected: "", }, // ExtractZone cases (as ExtractResourcePart) { name: "Valid input with zone", input: "https://www.googleapis.com/compute/v1/projects/project-test/zones/us-central1-a/disks/integration-test-instance", expected: "us-central1-a", key: "zones", }, { name: "Valid input with different zone", input: "https://www.googleapis.com/compute/v1/projects/project-test/zones/europe-west1-b/disks/integration-test-instance", expected: "europe-west1-b", key: "zones", }, { name: "Valid input shortened", input: "zones/zone/disks/disk", expected: "zone", key: "zones", }, { name: "Input without zones", input: "https://www.googleapis.com/compute/v1/projects/project-test/regions/us-central1/subnetworks/default", expected: "", key: "zones", }, { name: "Empty input", input: "", expected: "", key: "zones", }, { name: "Malformed input", input: "invalid-string-without-zones", expected: "", key: "zones", }, // ExtractRegions cases (as ExtractResourcePart) { name: "Valid input with region", input: "https://www.googleapis.com/compute/v1/projects/project-test/regions/us-central1/subnetworks/default", expected: "us-central1", key: "regions", }, { name: "Valid input with different region", input: "https://www.googleapis.com/compute/v1/projects/project-test/regions/europe-west1/subnetworks/default", expected: "europe-west1", key: "regions", }, { name: "Valid input shortened", input: "regions/region/subnetworks/subnetwork", expected: "region", key: "regions", }, { name: "Input without regions", input: "https://www.googleapis.com/compute/v1/projects/project-test/zones/us-central1-a/instances/instance-1", expected: "", key: "regions", }, { name: "Empty input", input: "", expected: "", key: "regions", }, { name: "Malformed input", input: "invalid-string-without-regions", expected: "", key: "regions", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := gcpshared.ExtractPathParam(tt.key, tt.input) if result != tt.expected { t.Errorf("ExtractPathParam(%q, %q) = %q; want %q", tt.input, tt.key, result, tt.expected) } }) } } func TestExtractPathParams(t *testing.T) { tests := []struct { name string input string keys []string expected []string }{ { name: "single key present", input: "projects/proj/locations/us-central1/keyRings/my-ring", keys: []string{"locations"}, expected: []string{"us-central1"}, }, { name: "multiple keys, both present", input: "projects/proj/locations/us-central1/keyRings/my-ring/cryptoKeys/my-key", keys: []string{"keyRings", "cryptoKeys"}, expected: []string{"my-ring", "my-key"}, }, { name: "multiple keys, one missing", input: "projects/proj/locations/us-central1/keyRings/my-ring", keys: []string{"keyRings", "cryptoKeys"}, expected: []string{"my-ring"}, }, { name: "all keys missing", input: "projects/proj/locations/us-central1", keys: []string{"foo", "bar"}, expected: []string{}, }, { name: "empty input", input: "", keys: []string{"locations"}, expected: []string{}, }, { name: "empty keys", input: "projects/proj/locations/us-central1/keyRings/my-ring", keys: []string{}, expected: []string{}, }, { name: "key at end, no value", input: "projects/proj/locations", keys: []string{"locations"}, expected: []string{}, }, { name: "multiple keys, both present, reverse order", input: "projects/proj/locations/us-central1/keyRings/my-ring/cryptoKeys/my-key", keys: []string{"locations", "cryptoKeys"}, expected: []string{"us-central1", "my-key"}, }, { name: "default with no keys in it", input: "default", keys: []string{"subnetworks"}, expected: []string{"default"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := gcpshared.ExtractPathParams(tt.input, tt.keys...) if len(result) != len(tt.expected) { t.Errorf("ExtractPathParams(%q, %v) returned %d results, want %d", tt.input, tt.keys, len(result), len(tt.expected)) } for i := range result { if result[i] != tt.expected[i] { t.Errorf("ExtractPathParams(%q, %v)[%d] = %q; want %q", tt.input, tt.keys, i, result[i], tt.expected[i]) } } }) } } func TestExtractPathParamsWithCount(t *testing.T) { type args struct { input string count int } tests := []struct { name string args args want []string }{ { name: "Extract last 2 path params", args: args{ input: "projects/my-proj/locations/global/keyRings/my-kr/cryptoKeys/my-key", count: 2, }, want: []string{"my-kr", "my-key"}, }, { name: "Extract last 2 path params with slash in suffix and prefix", args: args{ input: "/projects/my-proj/locations/global/keyRings/my-kr/cryptoKeys/my-key/", count: 2, }, want: []string{"my-kr", "my-key"}, }, { name: "Extract last 3 path params", args: args{ input: "projects/my-proj/locations/global/keyRings/my-kr/cryptoKeys/my-key", count: 3, }, want: []string{"global", "my-kr", "my-key"}, }, { name: "Extract from compute path", args: args{ input: "projects/test-project/zones/us-central1-a/instances/test-instance", count: 2, }, want: []string{"us-central1-a", "test-instance"}, }, { name: "Extract more params than exist", args: args{ input: "projects/my-proj/locations/global", count: 5, }, want: nil, }, { name: "Extract exact number of components", args: args{ input: "a/b/c", count: 3, }, want: nil, }, { name: "Extract with count=0", args: args{ input: "a/b/c", count: 0, }, want: nil, }, { name: "Extract with empty input", args: args{ input: "", count: 2, }, want: nil, }, { name: "Extract with trailing slash", args: args{ input: "a/b/c/", count: 2, }, want: nil, }, { name: "Extract with only slashes", args: args{ input: "///", count: 2, }, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := gcpshared.ExtractPathParamsWithCount(tt.args.input, tt.args.count); !reflect.DeepEqual(got, tt.want) { t.Errorf("ExtractPathParamsWithCount() = %v, want %v", got, tt.want) } }) } } func TestZoneToRegion(t *testing.T) { tests := []struct { name string zone string expected string }{ { name: "Valid zone with region us-central1-a", zone: "us-central1-a", expected: "us-central1", }, { name: "Valid zone with region europe-west1-b", zone: "europe-west1-b", expected: "europe-west1", }, { name: "Empty zone", zone: "", expected: "", }, { name: "Zone with no dash", zone: "uscentral1", expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := gcpshared.ZoneToRegion(tt.zone) if result != tt.expected { t.Errorf("ZoneToRegion(%q) = %q; expected %q", tt.zone, result, tt.expected) } }) } } func TestExtractScopeFromURI(t *testing.T) { tests := []struct { name string uri string expected string expectError bool }{ // Zonal scope - Full HTTPS URLs { name: "Zonal scope - Full HTTPS URL with www.googleapis.com", uri: "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/my-disk", expected: "my-project.us-central1-a", }, { name: "Zonal scope - Full HTTPS URL with service-specific domain", uri: "https://compute.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/instances/my-instance", expected: "my-project.us-central1-a", }, { name: "Zonal scope - Full resource name with // prefix", uri: "//compute.googleapis.com/projects/my-project/zones/us-central1-a/disks/my-disk", expected: "my-project.us-central1-a", }, { name: "Zonal scope - Locations with zone format", uri: "projects/my-project/locations/us-central1-a/functions/my-function", expected: "my-project.us-central1-a", }, { name: "Zonal scope - Bare path", uri: "projects/my-project/zones/us-central1-a/instances/my-instance", expected: "my-project.us-central1-a", }, { name: "Zonal scope - Different zone", uri: "projects/my-project/zones/europe-west1-b/disks/my-disk", expected: "my-project.europe-west1-b", }, // Regional scope - Full HTTPS URLs { name: "Regional scope - Full HTTPS URL with regions", uri: "https://www.googleapis.com/compute/v1/projects/my-project/regions/us-central1/subnetworks/my-subnet", expected: "my-project.us-central1", }, { name: "Regional scope - Locations with region format", uri: "projects/my-project/locations/us-central1/services/my-service", expected: "my-project.us-central1", }, { name: "Regional scope - Bare path with regions", uri: "projects/my-project/regions/us-central1/addresses/my-address", expected: "my-project.us-central1", }, { name: "Regional scope - Different region", uri: "projects/my-project/regions/europe-west1/subnetworks/my-subnet", expected: "my-project.europe-west1", }, // Project scope - Global keyword { name: "Project scope - Global keyword in path", uri: "https://www.googleapis.com/compute/v1/projects/my-project/global/networks/my-network", expected: "my-project", }, { name: "Project scope - Global keyword in bare path", uri: "projects/my-project/global/images/my-image", expected: "my-project", }, { name: "Project scope - Locations global", uri: "projects/my-project/locations/global/keyRings/my-keyring", expected: "my-project", }, // Project scope - No location specifier { name: "Project scope - No location specifier (topics)", uri: "projects/my-project/topics/my-topic", expected: "my-project", }, { name: "Project scope - Service destination format (pubsub)", uri: "pubsub.googleapis.com/projects/my-project/topics/my-topic", expected: "my-project", }, { name: "Project scope - Service destination format (bigquery)", uri: "bigquery.googleapis.com/projects/my-project/datasets/my-dataset", expected: "my-project", }, { name: "Project scope - Full HTTPS URL with BigQuery", uri: "https://bigquery.googleapis.com/bigquery/v2/projects/my-project/datasets/my-dataset", expected: "my-project", }, { name: "Project scope - Full HTTPS URL with Pub/Sub", uri: "https://pubsub.googleapis.com/v1/projects/my-project/topics/my-topic", expected: "my-project", }, // Project number cases (wildcard scope) { name: "Project number - Global resource", uri: "projects/96771641962/global/instanceTemplates/my-template", expected: "*", }, { name: "Project number - Regional resource", uri: "projects/96771641962/regions/us-central1/subnetworks/my-subnet", expected: "*", }, { name: "Project number - Zonal resource", uri: "projects/96771641962/zones/us-central1-a/disks/my-disk", expected: "*", }, { name: "Project number - Short numeric", uri: "projects/123/global/networks/my-network", expected: "*", }, // Error cases { name: "Error - Empty URI", uri: "", expectError: true, }, { name: "Error - No project (zones only)", uri: "zones/us-central1-a/disks/my-disk", expectError: true, }, { name: "Error - No project (malformed)", uri: "my-resource", expectError: true, }, { name: "Error - Project placeholder (_)", uri: "projects/_/buckets/my-bucket", expectError: true, }, { name: "Error - Both zones and regions present", uri: "projects/my-project/zones/us-central1-a/regions/us-central1/disks/my-disk", expectError: true, }, { name: "Error - Both zones and locations present", uri: "projects/my-project/zones/us-central1-a/locations/us-central1/services/my-service", expectError: true, }, { name: "Error - No project in storage URL", uri: "storage.googleapis.com/my-bucket/my-object", expectError: true, }, // Edge cases with query parameters and fragments { name: "Zonal scope - URL with query parameters", uri: "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/disks/my-disk?alt=json", expected: "my-project.us-central1-a", }, { name: "Regional scope - URL with fragment", uri: "projects/my-project/regions/us-central1/subnetworks/my-subnet#section", expected: "my-project.us-central1", }, // Additional test cases from plan { name: "Zonal scope - Cloud Functions with locations", uri: "projects/my-project/locations/us-central1-b/functions/my-function", expected: "my-project.us-central1-b", }, { name: "Regional scope - Cloud Run with locations", uri: "projects/my-project/locations/us-central1/services/my-service", expected: "my-project.us-central1", }, { name: "Project scope - Cloud KMS with locations/global", uri: "projects/my-project/locations/global/keyRings/my-keyring", expected: "my-project", }, { name: "Project scope - IAM service account", uri: "https://iam.googleapis.com/v1/projects/my-project/serviceAccounts/my-service-account", expected: "my-project", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := gcpshared.ExtractScopeFromURI(context.Background(), tt.uri) if tt.expectError { if err == nil { t.Errorf("ExtractScopeFromURI(%q) expected error but got none. Result: %q", tt.uri, result) } } else { if err != nil { t.Errorf("ExtractScopeFromURI(%q) unexpected error: %v", tt.uri, err) } if result != tt.expected { t.Errorf("ExtractScopeFromURI(%q) = %q; expected %q", tt.uri, result, tt.expected) } } }) } } ================================================ FILE: sources/shared/base.go ================================================ package shared import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" ) // Base is a struct that holds fundamental pieces for creating an adapter. type Base struct { category sdp.AdapterCategory itemType ItemType scopes []string } // NewBase creates a new Base instance with the provided parameters and options. func NewBase( category sdp.AdapterCategory, item ItemType, scopes []string, ) *Base { base := &Base{ category: category, itemType: item, scopes: scopes, } return base } // Category returns the adapter category. func (u *Base) Category() sdp.AdapterCategory { return u.category } // Type returns a string representation of the type, combining source family, API, and resource. func (u *Base) Type() string { return u.itemType.String() } // Name returns the name of the adapter. func (u *Base) Name() string { return fmt.Sprintf("%s-adapter", u.Type()) } // PotentialLinks returns a map of potential links for the itemType. func (*Base) PotentialLinks() map[ItemType]bool { return nil } // AdapterMetadata returns the adapter metadata. // This can be created from the wrapper. // Otherwise, it will be generated when transforming the wrapper to an adapter. func (u *Base) AdapterMetadata() *sdp.AdapterMetadata { return nil } // TerraformMappings returns a slice of Terraform mappings for the itemType. // This is optional. func (u *Base) TerraformMappings() []*sdp.TerraformMapping { return nil } // Scopes returns a slice of strings representing the scopes for the itemType. func (u *Base) Scopes() []string { return u.scopes } // ItemType returns the itemType which the adapter is created for. func (u *Base) ItemType() ItemType { return u.itemType } // IAMPermissions returns a slice of IAM permissions required for the adapter. // This is optional, not all adapters will implement this. func (u *Base) IAMPermissions() []string { return nil } ================================================ FILE: sources/shared/shared.go ================================================ package shared import ( "fmt" "strings" "time" "golang.org/x/text/cases" "golang.org/x/text/language" ) const ( QuerySeparator = "|" DefaultCacheDuration = 1 * time.Hour ) // ItemType is an interface that defines the methods for an ItemTypeInstance. // It is used to represent the type of item in the system. // It provides methods to get the string representation of the item type and a human-readable version of it. // ItemTypeInstance is a concrete implementation of the ItemType interface. // I.e, an ItemTypeInstance can represent an AWS EC2 instance, a GCP Compute Engine disk, etc. type ItemType interface { // String returns the string representation of the ItemType. This is used in adapter type and name. String() string // Readable returns a human-readable string representation of the ItemType. This is used in method descriptions. Readable() string } // Source represents the source of the item. It is usually the name of the // source, e.g. "aws", "gcp", "azure", etc. type Source string // API represents the supported API from the source. It is usually the name of the // API, e.g. "ec2", "s3", "compute-engine", etc. type API string // Resource represents the supported resource from the source. It is usually the name of the // resource, e.g. "instance", "bucket", "disk", etc. type Resource string // ItemTypeInstance represents the type of item. It is a combination of the Source, API and Resource. type ItemTypeInstance struct { Source Source API API Resource Resource } // String returns the string representation of the ItemTypeInstance. func (i ItemTypeInstance) String() string { return fmt.Sprintf("%s-%s-%s", i.Source, i.API, i.Resource) } // Readable returns a human-readable string representation of the ItemTypeInstance. // For example, "AWS Ec2-Instance" or "GCP Compute Disk". func (i ItemTypeInstance) Readable() string { // Split the name by hyphens parts := strings.Split(i.String(), "-") // Capitalize the first part entirely if len(parts) > 0 { parts[0] = strings.ToUpper(parts[0]) } // Capitalize the first letter of the remaining parts c := cases.Title(language.English) for i := 1; i < len(parts); i++ { parts[i] = c.String(parts[i]) } // Join the parts with spaces return strings.Join(parts, " ") } // NewItemType creates a new ItemTypeInstance from the given Source, API and Resource. func NewItemType(source Source, api API, resource Resource) ItemTypeInstance { return ItemTypeInstance{ Source: source, API: api, Resource: resource, } } // ItemTypeLookup is a struct that contains the ItemType and the string used to // look it up. // If it defines looking up an aws instance by "name" it will be // ItemTypeLookup{By: "name", ItemType: ItemType{Source: aws.Source, API: aws.EC2, Resource: aws.Instance}} type ItemTypeLookup struct { By string ItemType ItemType } func (i ItemTypeLookup) Readable() string { return fmt.Sprintf( "%s-%s", i.ItemType.String(), i.By, ) } // NewItemTypeLookup creates a new ItemTypeLookup from the given string and ItemType. func NewItemTypeLookup(by string, itemType ItemType) ItemTypeLookup { return ItemTypeLookup{ By: by, ItemType: itemType, } } // NewItemTypesSet is convenience function that creates a set of item types. func NewItemTypesSet(items ...ItemType) map[ItemType]bool { m := make(map[ItemType]bool, len(items)) for _, item := range items { m[item] = true } return m } ================================================ FILE: sources/shared/testing.go ================================================ package shared import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "testing" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/reflect/protoreflect" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" ) // RunStaticTests runs static tests on the given adapter and item. // It validates the adapter and item, and runs the provided query tests for linked items and potential links. func RunStaticTests(t *testing.T, adapter discovery.Adapter, item *sdp.Item, queryTests QueryTests) { if adapter == nil { t.Fatal("adapter is nil") } ValidateAdapter(t, adapter) if item == nil { t.Fatal("item is nil") } if item.Validate() != nil { t.Fatalf("Item %s failed validation: %v", item.GetType(), item.Validate()) } if queryTests == nil { t.Skipf("Skipping test because no query test provided") } queryTests.Execute(t, item, adapter) } type Validate interface { Validate() error } func ValidateAdapter(t *testing.T, adapter discovery.Adapter) { if adapter == nil { t.Fatal("adapter is nil") } // Test the adapter a, ok := adapter.(Validate) if !ok { t.Fatalf("Adapter %s does not implement Validate", adapter.Name()) } if err := a.Validate(); err != nil { t.Fatalf("Adapter %s failed validation: %v", adapter.Name(), err) } } // QueryTest is a struct that defines the expected properties of a linked item query. type QueryTest struct { ExpectedType string ExpectedMethod sdp.QueryMethod ExpectedQuery string ExpectedScope string } type QueryTests []QueryTest // TestLinkedItems tests the linked item queries of an item for the expected properties. func (i QueryTests) TestLinkedItems(t *testing.T, item *sdp.Item) { if item == nil { t.Fatal("item is nil") } if item.GetLinkedItemQueries() == nil { t.Fatal("item.GetLinkedItemQueries() is nil") } if len(i) != len(item.GetLinkedItemQueries()) { t.Errorf("expected %d linked item query test cases, got %d", len(item.GetLinkedItemQueries()), len(i)) } linkedItemQueries := make(map[string]*sdp.LinkedItemQuery, len(i)) for _, lir := range item.GetLinkedItemQueries() { queryK := queryKey(lir.GetQuery().GetType(), lir.GetQuery().GetQuery()) if _, ok := linkedItemQueries[queryK]; ok { t.Fatalf("linked item query %s for %s already exists in actual linked item queries", lir.GetQuery().GetType(), lir.GetQuery().GetQuery()) } linkedItemQueries[queryK] = lir } for _, test := range i { queryK := queryKey(test.ExpectedType, test.ExpectedQuery) gotLiq, ok := linkedItemQueries[queryK] if !ok { t.Fatalf("linked item query %s for %s not found in actual linked item queries", test.ExpectedType, test.ExpectedQuery) } if test.ExpectedScope != gotLiq.GetQuery().GetScope() { t.Errorf("for the linked item query %s of %s, expected scope %s, got %s", test.ExpectedQuery, test.ExpectedType, test.ExpectedScope, gotLiq.GetQuery().GetScope()) } if test.ExpectedType != gotLiq.GetQuery().GetType() { t.Errorf("for the linked item query %s, expected type %s, got %s", test.ExpectedQuery, test.ExpectedType, gotLiq.GetQuery().GetType()) } if test.ExpectedMethod != gotLiq.GetQuery().GetMethod() { t.Errorf("for the linked item query %s of %s, expected method %s, got %s", test.ExpectedQuery, test.ExpectedType, test.ExpectedMethod, gotLiq.GetQuery().GetMethod()) } } } // TestPotentialLinks tests the potential links of an adapter for the given item. func (i QueryTests) TestPotentialLinks(t *testing.T, item *sdp.Item, adapter discovery.Adapter) { if adapter == nil { t.Fatal("adapter is nil") } if adapter.Metadata() == nil { t.Fatal("adapter.Metadata() is nil") } if adapter.Metadata().GetPotentialLinks() == nil { t.Fatal("adapter.Metadata().GetPotentialLinks() is nil") } potentialLinks := make(map[string]bool, len(i)) for _, l := range adapter.Metadata().GetPotentialLinks() { potentialLinks[l] = true } if item == nil { t.Fatal("item is nil") } for _, test := range i { if _, ok := potentialLinks[test.ExpectedType]; !ok { t.Fatalf("linked item type %s not found in potential links", test.ExpectedType) } } } func (i QueryTests) Execute(t *testing.T, item *sdp.Item, adapter discovery.Adapter) { t.Run("LinkedItemQueries", func(t *testing.T) { i.TestLinkedItems(t, item) }) t.Run("PotentialLinks", func(t *testing.T) { i.TestPotentialLinks(t, item, adapter) }) } func queryKey(itemType, query string) string { return fmt.Sprintf("%s/%s", itemType, query) } type mockRoundTripper struct { responses map[string]*http.Response } func newMockRoundTripper(responses map[string]*http.Response) *mockRoundTripper { return &mockRoundTripper{ responses: responses, } } func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { resp, ok := m.responses[req.URL.String()] if !ok { return &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader(`{"error": "Not found"}`)), Header: make(http.Header), }, nil } // Clone the response body since it will be closed by the caller bodyBytes, _ := io.ReadAll(resp.Body) resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(bodyBytes)) return resp, nil } // mockHTTPResponse converts an input to an io.ReadCloser // for use in HTTP response mocking func mockHTTPResponse(input any) io.ReadCloser { var data []byte var err error if msg, ok := input.(interface { ProtoReflect() protoreflect.Message }); ok { data, err = protojson.Marshal(msg) } else { data, err = json.Marshal(input) } if err != nil { // For test helpers, it's reasonable to panic on marshaling errors panic(fmt.Sprintf("Failed to marshal instance input: %v", err)) } return io.NopCloser(bytes.NewReader(data)) } // MockResponse is a struct that defines the expected response for a mocked HTTP call. // It includes the status code and the body of the response. // Body can be any type, but it is typically a struct that can be marshaled to JSON. type MockResponse struct { StatusCode int Body any } // NewMockHTTPClientProvider creates a new mock HTTP client provider with the given expected calls and responses. // The expectedCallAndResponse map should have the URL as the key and a MockResponse as the value. func NewMockHTTPClientProvider(expectedCallAndResponse map[string]MockResponse) *http.Client { cp := make(map[string]*http.Response, len(expectedCallAndResponse)) for url, resp := range expectedCallAndResponse { body := mockHTTPResponse(resp.Body) cp[url] = &http.Response{ StatusCode: resp.StatusCode, Body: body, } } return &http.Client{ Transport: newMockRoundTripper(cp), } } ================================================ FILE: sources/shared/util.go ================================================ package shared import ( "strings" "github.com/overmindtech/cli/go/sdp-go" ) // ToAttributesWithExclude converts an interface to SDP attributes using the `sdp.ToAttributesSorted` // function, and also allows the user to exclude certain top-level fields from // the resulting attributes func ToAttributesWithExclude(i any, exclusions ...string) (*sdp.ItemAttributes, error) { attrs, err := sdp.ToAttributesViaJson(i) if err != nil { return nil, err } for _, exclusion := range exclusions { if s := attrs.GetAttrStruct(); s != nil { delete(s.GetFields(), exclusion) } } return attrs, nil } // CompositeLookupKey creates a composite lookup key from multiple query parts. // It joins the parts using the default separator "|" // // Example usage: // // key := CompositeLookupKey("part1", "part2", "part3") // Output: "part1|part2|part3" func CompositeLookupKey(queryParts ...string) string { // Join the query parts with the default separator "|" return strings.Join(queryParts, QuerySeparator) } ================================================ FILE: sources/shared/util_test.go ================================================ package shared import ( "testing" ) func TestCompositeLookupKey(t *testing.T) { tests := []struct { name string queryParts []string expected string }{ { name: "Single query part", queryParts: []string{"part1"}, expected: "part1", }, { name: "Multiple query parts", queryParts: []string{"part1", "part2", "part3"}, expected: "part1|part2|part3", }, { name: "Empty query parts", queryParts: []string{}, expected: "", }, { name: "Query parts with empty strings", queryParts: []string{"part1", "", "part3"}, expected: "part1||part3", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := CompositeLookupKey(tt.queryParts...) if result != tt.expected { t.Errorf("CompositeLookupKey(%v) = %q; want %q", tt.queryParts, result, tt.expected) } }) } } ================================================ FILE: sources/snapshot/README.md ================================================ # Snapshot Source A discovery source that serves items from a snapshot file or URL, enabling local testing with fixed data and deterministic re-runs of v6 investigation jobs. ## Overview The snapshot source loads a snapshot file (JSON or protobuf format) at startup and responds to NATS discovery queries (GET, LIST, SEARCH) with items from that snapshot. This enables: - **Local testing**: Run backend services (gateway, api-server, NATS) locally with consistent snapshot data - **Deterministic v6 re-runs**: Re-run change analysis and blast radius calculations with the same snapshot data - **Consistent exploration**: Query the same fixed data set repeatedly for debugging and testing ## Features - **Snapshot loading**: Loads snapshots from local files or HTTP(S) URLs (JSON or protobuf format) - **Format detection**: Automatically detects JSON (`.json`) or protobuf (`.pb`) format - **Wildcard scope support**: Single adapter handles all types and scopes in the snapshot - **Full query support**: Implements GET, LIST, and SEARCH query methods - **In-memory indexing**: Fast lookups by type, scope, GUN, or query string - **Comprehensive tests**: Unit tests for loader, index, and adapter components ## Usage ### Configuration The snapshot source requires a snapshot file or URL to be specified: **Environment variables:** - `SNAPSHOT_SOURCE` or `SNAPSHOT_PATH` or `SNAPSHOT_URL` - Path to snapshot file or HTTP(S) URL - Standard discovery engine config (NATS connection, auth, etc.) **Command-line flags:** ```bash --snapshot-source # Path to snapshot file or URL (required) --log # Log level (default: info) --json-log # JSON logging (default: true) --health-check-port # Health check port (default: 8089) ``` ### Running with Docker #### Build the Docker image Build the snapshot source Docker image: ```bash docker buildx bake snapshot ``` Or build directly with docker build: ```bash docker build -f sources/snapshot/build/package/Dockerfile \ --build-arg BUILD_VERSION=dev \ --build-arg BUILD_COMMIT=$(git rev-parse HEAD) \ -t snapshot-source:local . ``` #### Run the Docker container Run the container with a mounted snapshot file: **Local/dev environment (unauthenticated):** ```bash docker run --rm \ -v /path/to/snapshot.json:/data/snapshot.json:ro \ -e SNAPSHOT_SOURCE=/data/snapshot.json \ -e OVERMIND_MANAGED_SOURCE=true \ -e NATS_SERVICE_HOST=nats \ -e NATS_SERVICE_PORT=4222 \ -e ALLOW_UNAUTHENTICATED=true \ --network=host \ ghcr.io/overmindtech/workspace/snapshot-source:dev ``` > ⚠️ **WARNING**: `ALLOW_UNAUTHENTICATED=true` is for local/dev testing only. Do not use in production. **Production environment (authenticated):** ```bash docker run --rm \ -v /path/to/snapshot.json:/data/snapshot.json:ro \ -e SNAPSHOT_SOURCE=/data/snapshot.json \ -e OVERMIND_MANAGED_SOURCE=true \ -e API_KEY=your-api-key \ -e NATS_SERVICE_HOST=nats \ -e NATS_SERVICE_PORT=4222 \ --network=host \ ghcr.io/overmindtech/workspace/snapshot-source:dev ``` Or use with docker-compose (local/dev): ```yaml services: snapshot-source: image: ghcr.io/overmindtech/workspace/snapshot-source:dev volumes: - ./snapshot.json:/data/snapshot.json:ro environment: SNAPSHOT_SOURCE: /data/snapshot.json OVERMIND_MANAGED_SOURCE: "true" NATS_SERVICE_HOST: nats NATS_SERVICE_PORT: 4222 ALLOW_UNAUTHENTICATED: "true" # WARNING: local/dev only depends_on: - nats ``` For production, replace `ALLOW_UNAUTHENTICATED: "true"` with `API_KEY: ${API_KEY}` and set the API key via environment variable or secrets management. #### Health check The container exposes health check endpoints on port 8089: ```bash # Liveness probe - checks NATS connection curl http://localhost:8089/healthz/alive # Readiness probe - checks adapter initialization curl http://localhost:8089/healthz/ready ``` ### Running Locally #### Option 1: With backend services (recommended) 1. Start backend services (gateway, api-server, NATS) in devcontainer or via docker-compose 2. Run the snapshot source: ```bash ALLOW_UNAUTHENTICATED=true \ SNAPSHOT_SOURCE=/workspace/services/api-server/service/changeanalysis/testdata/snapshot.json \ NATS_SERVICE_HOST=nats \ NATS_SERVICE_PORT=4222 \ go run ./sources/snapshot/main.go --log=debug --json-log=false ``` #### Option 2: Using VS Code launch configuration Use the provided launch configurations in `.vscode/launch.json`: - **"snapshot-source (with backend)"**: For use when backend services are running - **"snapshot-source (standalone)"**: For standalone debugging with local NATS Update the `SNAPSHOT_SOURCE` environment variable in the launch config to point to your snapshot file. #### Option 3: Load snapshot from URL ```bash ALLOW_UNAUTHENTICATED=true \ SNAPSHOT_SOURCE=https://gateway-host/area51/snapshots/{uuid}/json \ NATS_SERVICE_HOST=nats \ NATS_SERVICE_PORT=4222 \ go run ./sources/snapshot/main.go ``` ### Query Behavior The snapshot source implements a **wildcard scope adapter** that handles all types and scopes: - **LIST**: Returns all items in the snapshot (or filtered by scope if scope != "*") - **GET**: Finds an item by its globally unique name (GUN) or unique attribute value - **SEARCH**: Searches items by regex pattern on globally unique name Example queries via the gateway: ``` LIST *.* # Returns all 179 items in test snapshot GET *.* # Gets specific item by GUN SEARCH *.* # Finds items matching pattern ``` ## Implementation Details ### Architecture ``` sources/snapshot/ ├── main.go # Entrypoint ├── cmd/ │ └── root.go # Cobra CLI setup, viper config └── adapters/ ├── loader.go # Snapshot loading (file/URL) ├── index.go # In-memory indexing ├── adapter.go # Discovery adapter implementation └── main.go # Adapter initialization ``` ### Snapshot Index The source builds in-memory indices for efficient querying: - **By GUN**: Map of `GloballyUniqueName` → `*Item` for fast GET lookups - **By type/scope**: Nested map for filtering by type and scope - **All items**: Full list for wildcard LIST queries ### Adapter Strategy The snapshot source uses **Option B from the design doc**: a single adapter with wildcard type (`*`) and wildcard scope (`*`). This adapter: - Reports `Type() = "*"` and `Scopes() = ["*"]` - Implements `WildcardScopeAdapter` interface - Handles all query types (GET, LIST, SEARCH) across all types and scopes in the snapshot This differs from "one adapter per (type, scope)" because the gateway's query expansion expects adapters to report specific types. The wildcard approach lets us serve any item from the snapshot regardless of type or scope. ## Testing Run unit tests: ```bash cd sources/snapshot/adapters go test -v ``` Test snapshot loading: ```bash cd sources/snapshot go run main.go --snapshot-source=/path/to/snapshot.json --help ``` Verify with real snapshot: ```bash cd sources/snapshot go test -run TestLoadSnapshotFromFile -v ./adapters ``` ## Example: Using with v6 Investigations 1. Download a snapshot from Area 51 or use an existing test snapshot 2. Start backend services locally (gateway, api-server, NATS) 3. Start the snapshot source pointing at your snapshot file 4. Run a v6 investigation - it will query from the snapshot instead of live sources 5. Re-run with the same snapshot for consistent, deterministic results ## Troubleshooting **Error: "snapshot has no items"** - Verify the snapshot file is valid protobuf and contains items - Check file path or URL is correct **Error: "api-key must be set"** - Set `ALLOW_UNAUTHENTICATED=true` for local testing - Or provide a valid API key via `API_KEY` env var **Error: "could not connect to NATS"** - Verify NATS is running at the configured host/port - Check `NATS_SERVICE_HOST` and `NATS_SERVICE_PORT` are correct ## Related Documentation - **Linear issue**: [ENG-2577](https://linear.app/overmind/issue/ENG-2577) - **Snapshot protobuf**: `sdp/snapshots.proto` - **Discovery engine**: `go/discovery/` - **Test snapshots**: - JSON format (recommended): `services/api-server/service/changeanalysis/testdata/snapshot.json` - Protobuf format (legacy): `services/api-server/service/changeanalysis/testdata/snapshot.pb` ================================================ FILE: sources/snapshot/adapters/adapter.go ================================================ package adapters import ( "context" "fmt" "regexp" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/proto" ) // SnapshotAdapter is a discovery adapter that serves items of a single type // from a snapshot. One adapter is created per type found in the snapshot so // that the discovery engine can route specific-type GET/SEARCH queries // correctly. type SnapshotAdapter struct { index *SnapshotIndex itemType string scopes []string metadata *sdp.AdapterMetadata } // NewSnapshotAdapter creates a new per-type adapter backed by the shared index. func NewSnapshotAdapter(index *SnapshotIndex, itemType string, scopes []string) *SnapshotAdapter { return &SnapshotAdapter{ index: index, itemType: itemType, scopes: scopes, metadata: lookupAdapterMetadata(itemType, scopes), } } func cloneItems(items []*sdp.Item) []*sdp.Item { out := make([]*sdp.Item, len(items)) for i, item := range items { out[i] = proto.Clone(item).(*sdp.Item) } return out } func (a *SnapshotAdapter) Type() string { return a.itemType } func (a *SnapshotAdapter) Name() string { return fmt.Sprintf("snapshot-%s", a.itemType) } func (a *SnapshotAdapter) Scopes() []string { return a.scopes } func (a *SnapshotAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { log.WithFields(log.Fields{ "scope": scope, "type": a.itemType, "query": query, }).Debug("SnapshotAdapter.Get called") // Try GUN lookup first (includes type in the GUN so it's already scoped) item := a.index.GetByGUN(query) if item != nil && item.GetType() == a.itemType { if scope == "*" || item.GetScope() == scope { return cloneItems([]*sdp.Item{item})[0], nil } } // Fall back to unique attribute value match within this type for _, candidateItem := range a.index.GetItemsByTypeAndScope(a.itemType, scope) { if candidateItem.UniqueAttributeValue() == query { return cloneItems([]*sdp.Item{candidateItem})[0], nil } } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("item not found: scope=%s, type=%s, query=%s", scope, a.itemType, query), Scope: scope, } } func (a *SnapshotAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { log.WithFields(log.Fields{ "scope": scope, "type": a.itemType, }).Debug("SnapshotAdapter.List called") return cloneItems(a.index.GetItemsByTypeAndScope(a.itemType, scope)), nil } // Search searches for items of this type by regex on GUN and includes 1-hop // neighbors that also match this type and scope. func (a *SnapshotAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { log.WithFields(log.Fields{ "scope": scope, "type": a.itemType, "query": query, }).Debug("SnapshotAdapter.Search called") regex, err := regexp.Compile(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("invalid regex pattern: %v", err), Scope: scope, } } candidates := a.index.GetItemsByTypeAndScope(a.itemType, scope) var primaryMatches []*sdp.Item for _, item := range candidates { if regex.MatchString(item.GloballyUniqueName()) { primaryMatches = append(primaryMatches, item) } } seen := make(map[string]bool, len(primaryMatches)) for _, item := range primaryMatches { seen[item.GloballyUniqueName()] = true } var neighborMatches []*sdp.Item for _, item := range primaryMatches { for _, neighbor := range a.index.NeighborItems(item) { if neighbor.GetType() != a.itemType { continue } if scope != "*" && neighbor.GetScope() != scope { continue } gun := neighbor.GloballyUniqueName() if !seen[gun] { seen[gun] = true neighborMatches = append(neighborMatches, neighbor) } } } result := make([]*sdp.Item, 0, len(primaryMatches)+len(neighborMatches)) result = append(result, primaryMatches...) result = append(result, neighborMatches...) return cloneItems(result), nil } func (a *SnapshotAdapter) Metadata() *sdp.AdapterMetadata { return a.metadata } ================================================ FILE: sources/snapshot/adapters/adapter_test.go ================================================ package adapters import ( "context" "errors" "testing" "github.com/overmindtech/cli/go/sdp-go" ) func createTestAdapters(t *testing.T) map[string]*SnapshotAdapter { t.Helper() snapshot := createTestSnapshot() index, err := NewSnapshotIndex(snapshot) if err != nil { t.Fatalf("Failed to create test index: %v", err) } adapters := make(map[string]*SnapshotAdapter) for _, typ := range index.GetAllTypes() { scopes := index.GetScopesForType(typ) adapters[typ] = NewSnapshotAdapter(index, typ, scopes) } return adapters } func TestAdapterType(t *testing.T) { adapters := createTestAdapters(t) ec2 := adapters["ec2-instance"] if ec2.Type() != "ec2-instance" { t.Errorf("Expected type 'ec2-instance', got '%s'", ec2.Type()) } s3 := adapters["s3-bucket"] if s3.Type() != "s3-bucket" { t.Errorf("Expected type 's3-bucket', got '%s'", s3.Type()) } } func TestAdapterName(t *testing.T) { adapters := createTestAdapters(t) if adapters["ec2-instance"].Name() != "snapshot-ec2-instance" { t.Errorf("Expected name 'snapshot-ec2-instance', got '%s'", adapters["ec2-instance"].Name()) } } func TestAdapterScopes(t *testing.T) { adapters := createTestAdapters(t) ec2Scopes := adapters["ec2-instance"].Scopes() if len(ec2Scopes) != 2 { t.Fatalf("Expected 2 scopes for ec2-instance, got %d: %v", len(ec2Scopes), ec2Scopes) } scopeSet := map[string]bool{} for _, s := range ec2Scopes { scopeSet[s] = true } if !scopeSet["us-east-1"] || !scopeSet["us-west-2"] { t.Errorf("Expected scopes [us-east-1, us-west-2], got %v", ec2Scopes) } s3Scopes := adapters["s3-bucket"].Scopes() if len(s3Scopes) != 1 || s3Scopes[0] != "global" { t.Errorf("Expected scopes [global], got %v", s3Scopes) } } func TestAdapterGet(t *testing.T) { adapters := createTestAdapters(t) ec2 := adapters["ec2-instance"] ctx := context.Background() // Get by unique attribute value with wildcard scope item, err := ec2.Get(ctx, "*", "i-12345", false) if err != nil { t.Fatalf("Get failed: %v", err) } if item == nil || item.UniqueAttributeValue() != "i-12345" { t.Errorf("Expected 'i-12345', got '%v'", item) } // Get by GUN item, err = ec2.Get(ctx, "*", "us-east-1.ec2-instance.i-12345", false) if err != nil { t.Fatalf("Get by GUN failed: %v", err) } if item == nil { t.Fatal("Expected item by GUN, got nil") } // Get with specific scope item, err = ec2.Get(ctx, "us-east-1", "i-12345", false) if err != nil { t.Fatalf("Get with specific scope failed: %v", err) } if item == nil { t.Fatal("Expected item, got nil") } // Not found _, err = ec2.Get(ctx, "*", "nonexistent", false) if err == nil { t.Error("Expected error for non-existent item") } var queryErr *sdp.QueryError if !errors.As(err, &queryErr) || queryErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("Expected NOTFOUND, got %v", err) } // Scope mismatch: requesting us-west-2 for an item in us-east-1 _, err = ec2.Get(ctx, "us-west-2", "us-east-1.ec2-instance.i-12345", false) if err == nil { t.Fatal("Expected error when scope doesn't match GUN scope") } if !errors.As(err, &queryErr) || queryErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("Expected NOTFOUND, got %v", err) } // Same GUN with matching scope works item, err = ec2.Get(ctx, "us-east-1", "us-east-1.ec2-instance.i-12345", false) if err != nil || item == nil || item.GetScope() != "us-east-1" { t.Errorf("Get with matching scope should work: err=%v item=%v", err, item) } // Cross-type: ec2 adapter should not return s3-bucket items _, err = ec2.Get(ctx, "*", "my-test-bucket", false) if err == nil { t.Error("ec2 adapter should not find s3-bucket items") } } func TestAdapterList(t *testing.T) { adapters := createTestAdapters(t) ctx := context.Background() // ec2 adapter lists its 2 items items, err := adapters["ec2-instance"].List(ctx, "*", false) if err != nil { t.Fatalf("List failed: %v", err) } if len(items) != 2 { t.Errorf("Expected 2 ec2-instance items, got %d", len(items)) } // Verify linked items are preserved var ec2East *sdp.Item for _, item := range items { if item.GloballyUniqueName() == "us-east-1.ec2-instance.i-12345" { ec2East = item break } } if ec2East == nil { t.Fatal("Expected to find ec2 instance i-12345") } linked := ec2East.GetLinkedItems() if len(linked) != 1 { t.Fatalf("Expected 1 linked item, got %d", len(linked)) } ref := linked[0].GetItem() if ref.GetType() != "s3-bucket" || ref.GetUniqueAttributeValue() != "my-test-bucket" { t.Errorf("Unexpected linked item reference: %v", ref) } // List with specific scope items, err = adapters["ec2-instance"].List(ctx, "us-east-1", false) if err != nil { t.Fatalf("List with specific scope failed: %v", err) } if len(items) != 1 { t.Errorf("Expected 1 item for us-east-1, got %d", len(items)) } // s3 adapter lists its 1 item items, err = adapters["s3-bucket"].List(ctx, "*", false) if err != nil { t.Fatalf("s3 List failed: %v", err) } if len(items) != 1 { t.Errorf("Expected 1 s3-bucket item, got %d", len(items)) } // List with nonexistent scope items, err = adapters["ec2-instance"].List(ctx, "nonexistent", false) if err != nil { t.Fatalf("List with nonexistent scope failed: %v", err) } if len(items) != 0 { t.Errorf("Expected 0 items, got %d", len(items)) } } func TestAdapterSearch(t *testing.T) { adapters := createTestAdapters(t) ec2 := adapters["ec2-instance"] ctx := context.Background() // Search matching both ec2 instances (neighbor s3-bucket is different type, not included) items, err := ec2.Search(ctx, "*", ".*ec2-instance.*", false) if err != nil { t.Fatalf("Search failed: %v", err) } if len(items) != 2 { t.Errorf("Expected 2 ec2-instance items, got %d", len(items)) } // Search with specific scope items, err = ec2.Search(ctx, "us-east-1", ".*ec2-instance.*", false) if err != nil { t.Fatalf("Search with specific scope failed: %v", err) } if len(items) != 1 { t.Errorf("Expected 1 item in us-east-1, got %d", len(items)) } // Search that matches nothing items, err = ec2.Search(ctx, "*", "nonexistent-xyz", false) if err != nil { t.Fatalf("Search no match failed: %v", err) } if len(items) != 0 { t.Errorf("Expected 0 items, got %d", len(items)) } // Invalid regex _, err = ec2.Search(ctx, "*", "[invalid(regex", false) if err == nil { t.Error("Expected error for invalid regex") } var queryErr *sdp.QueryError if !errors.As(err, &queryErr) || queryErr.GetErrorType() != sdp.QueryError_OTHER { t.Errorf("Expected OTHER error, got %v", err) } } func TestAdapterMetadata(t *testing.T) { adapters := createTestAdapters(t) // ec2-instance should get metadata from the catalog ec2Meta := adapters["ec2-instance"].Metadata() if ec2Meta == nil { t.Fatal("Expected metadata, got nil") } if ec2Meta.GetType() != "ec2-instance" { t.Errorf("Expected type 'ec2-instance', got '%s'", ec2Meta.GetType()) } if ec2Meta.GetCategory() != sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION { t.Errorf("Expected COMPUTE_APPLICATION category, got %v", ec2Meta.GetCategory()) } if ec2Meta.GetDescriptiveName() != "EC2 Instance" { t.Errorf("Expected descriptive name 'EC2 Instance', got '%s'", ec2Meta.GetDescriptiveName()) } methods := ec2Meta.GetSupportedQueryMethods() if !methods.GetGet() || !methods.GetList() || !methods.GetSearch() { t.Error("Expected all query methods to be supported for ec2-instance") } // s3-bucket should also get catalog metadata s3Meta := adapters["s3-bucket"].Metadata() if s3Meta.GetType() != "s3-bucket" { t.Errorf("Expected type 's3-bucket', got '%s'", s3Meta.GetType()) } } func TestNewSnapshotAdapter(t *testing.T) { snapshot := createTestSnapshot() index, _ := NewSnapshotIndex(snapshot) adapter := NewSnapshotAdapter(index, "ec2-instance", []string{"us-east-1", "us-west-2"}) if adapter == nil { t.Fatal("Expected adapter, got nil") return } if adapter.index != index { t.Error("Expected adapter to store index reference") } if adapter.itemType != "ec2-instance" { t.Errorf("Expected type 'ec2-instance', got '%s'", adapter.itemType) } } func TestAdapterMetadataFallback(t *testing.T) { snapshot := createTestSnapshot() index, _ := NewSnapshotIndex(snapshot) // Use a type not in the catalog to test fallback adapter := NewSnapshotAdapter(index, "unknown-type-xyz", []string{"test"}) meta := adapter.Metadata() if meta.GetType() != "unknown-type-xyz" { t.Errorf("Expected type 'unknown-type-xyz', got '%s'", meta.GetType()) } if meta.GetCategory() != sdp.AdapterCategory_ADAPTER_CATEGORY_OTHER { t.Errorf("Expected OTHER category for unknown type, got %v", meta.GetCategory()) } } ================================================ FILE: sources/snapshot/adapters/catalog.go ================================================ package adapters import ( "encoding/json" "io/fs" adapterdata "github.com/overmindtech/cli/docs.overmind.tech/docs/sources" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" ) type catalogQueryMethods struct { Get bool `json:"get"` GetDescription string `json:"getDescription"` List bool `json:"list"` ListDescription string `json:"listDescription"` Search bool `json:"search"` SearchDescription string `json:"searchDescription"` } type catalogEntry struct { Type string `json:"type"` Category int32 `json:"category"` DescriptiveName string `json:"descriptiveName"` PotentialLinks []string `json:"potentialLinks"` SupportedQueryMethods catalogQueryMethods `json:"supportedQueryMethods"` } var adapterCatalog map[string]*catalogEntry func init() { adapterCatalog = make(map[string]*catalogEntry) err := fs.WalkDir(adapterdata.Files, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } data, readErr := adapterdata.Files.ReadFile(path) if readErr != nil { log.WithError(readErr).WithField("path", path).Warn("Failed to read adapter data file") return nil } var entry catalogEntry if jsonErr := json.Unmarshal(data, &entry); jsonErr != nil { log.WithError(jsonErr).WithField("path", path).Warn("Failed to parse adapter data file") return nil } if entry.Type != "" { adapterCatalog[entry.Type] = &entry } return nil }) if err != nil { log.WithError(err).Error("Failed to walk embedded adapter data") } } // lookupAdapterMetadata returns AdapterMetadata for the given type by looking // up the embedded catalog. Falls back to sensible defaults when the type is not // in the catalog. func lookupAdapterMetadata(itemType string, scopes []string) *sdp.AdapterMetadata { entry, ok := adapterCatalog[itemType] if !ok { return &sdp.AdapterMetadata{ Type: itemType, DescriptiveName: itemType, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, List: true, Search: true, }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_OTHER, } } potentialLinks := make([]string, len(entry.PotentialLinks)) copy(potentialLinks, entry.PotentialLinks) return &sdp.AdapterMetadata{ Type: itemType, DescriptiveName: entry.DescriptiveName, SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: entry.SupportedQueryMethods.Get, GetDescription: entry.SupportedQueryMethods.GetDescription, List: entry.SupportedQueryMethods.List, ListDescription: entry.SupportedQueryMethods.ListDescription, Search: entry.SupportedQueryMethods.Search, SearchDescription: entry.SupportedQueryMethods.SearchDescription, }, PotentialLinks: potentialLinks, Category: sdp.AdapterCategory(entry.Category), } } ================================================ FILE: sources/snapshot/adapters/index.go ================================================ package adapters import ( "fmt" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" ) // SnapshotIndex maintains in-memory indices for efficient snapshot querying type SnapshotIndex struct { // All items in the snapshot allItems []*sdp.Item // Index by GloballyUniqueName for fast GET lookups byGUN map[string]*sdp.Item // Index by type and scope for filtering byTypeScope map[string]map[string][]*sdp.Item // Edges from the snapshot (for future use) edges []*sdp.Edge } // NewSnapshotIndex builds indices from a snapshot func NewSnapshotIndex(snapshot *sdp.Snapshot) (*SnapshotIndex, error) { if snapshot == nil || snapshot.GetProperties() == nil { return nil, fmt.Errorf("snapshot or properties is nil") } items := snapshot.GetProperties().GetItems() edges := snapshot.GetProperties().GetEdges() index := &SnapshotIndex{ allItems: items, byGUN: make(map[string]*sdp.Item), byTypeScope: make(map[string]map[string][]*sdp.Item), edges: edges, } // Build indices for _, item := range items { gun := item.GloballyUniqueName() index.byGUN[gun] = item itemType := item.GetType() scope := item.GetScope() if index.byTypeScope[itemType] == nil { index.byTypeScope[itemType] = make(map[string][]*sdp.Item) } index.byTypeScope[itemType][scope] = append(index.byTypeScope[itemType][scope], item) } // Hydrate each item's LinkedItems from the snapshot edges so that // callers (explore view, etc.) see the graph relationships directly on // the returned items instead of having to cross-reference the separate // edge list. index.hydrateLinkedItems() log.WithFields(log.Fields{ "total_items": len(items), "total_edges": len(edges), "types": len(index.byTypeScope), }).Info("Snapshot index built") return index, nil } // hydrateLinkedItems populates each item's LinkedItems field from the snapshot // edges. For each edge, the item matching edge.From gets a LinkedItem pointing // to edge.To. Edges whose From item is not in the snapshot are skipped. func (idx *SnapshotIndex) hydrateLinkedItems() { // Build a map from item reference key → existing LinkedItem targets so // we don't add duplicates when the item already carries some LinkedItems. type refKey struct { scope, typ, uav string } existingLinks := make(map[refKey]map[refKey]bool) for _, item := range idx.allItems { key := refKey{item.GetScope(), item.GetType(), item.UniqueAttributeValue()} set := make(map[refKey]bool) for _, li := range item.GetLinkedItems() { r := li.GetItem() if r != nil { set[refKey{r.GetScope(), r.GetType(), r.GetUniqueAttributeValue()}] = true } } existingLinks[key] = set } for _, edge := range idx.edges { from := edge.GetFrom() to := edge.GetTo() if from == nil || to == nil { continue } item := idx.GetByReference(from) if item == nil { continue } fromKey := refKey{item.GetScope(), item.GetType(), item.UniqueAttributeValue()} toKey := refKey{to.GetScope(), to.GetType(), to.GetUniqueAttributeValue()} if existingLinks[fromKey][toKey] { continue } item.LinkedItems = append(item.LinkedItems, &sdp.LinkedItem{ Item: to, }) existingLinks[fromKey][toKey] = true } } // GetAllItems returns all items in the snapshot func (idx *SnapshotIndex) GetAllItems() []*sdp.Item { return idx.allItems } // GetByGUN retrieves an item by its GloballyUniqueName func (idx *SnapshotIndex) GetByGUN(gun string) *sdp.Item { return idx.byGUN[gun] } // GetByReference retrieves an item by its Reference using the GUN index. func (idx *SnapshotIndex) GetByReference(ref *sdp.Reference) *sdp.Item { if ref == nil { return nil } return idx.byGUN[ref.GloballyUniqueName()] } // GetAllTypes returns all unique types in the snapshot func (idx *SnapshotIndex) GetAllTypes() []string { types := make([]string, 0, len(idx.byTypeScope)) for itemType := range idx.byTypeScope { types = append(types, itemType) } return types } // GetScopesForType returns all unique scopes that contain items of the given type. func (idx *SnapshotIndex) GetScopesForType(itemType string) []string { scopeMap, ok := idx.byTypeScope[itemType] if !ok { return nil } scopes := make([]string, 0, len(scopeMap)) for s := range scopeMap { scopes = append(scopes, s) } return scopes } // GetItemsByTypeAndScope returns items matching the given type and scope. // A wildcard ("*") scope returns all items of that type. func (idx *SnapshotIndex) GetItemsByTypeAndScope(itemType, scope string) []*sdp.Item { scopeMap, ok := idx.byTypeScope[itemType] if !ok { return nil } if scope == "*" { var all []*sdp.Item for _, items := range scopeMap { all = append(all, items...) } return all } return scopeMap[scope] } // EdgesFrom returns all edges whose From reference equals ref. func (idx *SnapshotIndex) EdgesFrom(ref *sdp.Reference) []*sdp.Edge { if ref == nil { return nil } var out []*sdp.Edge for _, e := range idx.edges { if e.GetFrom() != nil && e.GetFrom().IsEqual(ref) { out = append(out, e) } } return out } // EdgesTo returns all edges whose To reference equals ref. func (idx *SnapshotIndex) EdgesTo(ref *sdp.Reference) []*sdp.Edge { if ref == nil { return nil } var out []*sdp.Edge for _, e := range idx.edges { if e.GetTo() != nil && e.GetTo().IsEqual(ref) { out = append(out, e) } } return out } // NeighborItems returns items that are connected to the given item by any edge // (as From or To). Each item is returned at most once. Items not present in // the snapshot are skipped. func (idx *SnapshotIndex) NeighborItems(item *sdp.Item) []*sdp.Item { if item == nil { return nil } ref := item.Reference() seen := make(map[string]bool) var out []*sdp.Item for _, e := range idx.EdgesFrom(ref) { if e.GetTo() != nil { other := idx.GetByReference(e.GetTo()) if other != nil { gun := other.GloballyUniqueName() if !seen[gun] { seen[gun] = true out = append(out, other) } } } } for _, e := range idx.EdgesTo(ref) { if e.GetFrom() != nil { other := idx.GetByReference(e.GetFrom()) if other != nil { gun := other.GloballyUniqueName() if !seen[gun] { seen[gun] = true out = append(out, other) } } } } return out } ================================================ FILE: sources/snapshot/adapters/index_test.go ================================================ package adapters import ( "testing" "github.com/overmindtech/cli/go/sdp-go" ) func createTestSnapshot() *sdp.Snapshot { attrs1, _ := sdp.ToAttributesViaJson(map[string]any{ "instanceId": "i-12345", "name": "test-instance", }) attrs2, _ := sdp.ToAttributesViaJson(map[string]any{ "instanceId": "i-67890", "name": "test-instance-2", }) attrs3, _ := sdp.ToAttributesViaJson(map[string]any{ "bucketName": "my-test-bucket", }) return &sdp.Snapshot{ Properties: &sdp.SnapshotProperties{ Name: "test-snapshot", Items: []*sdp.Item{ { Type: "ec2-instance", UniqueAttribute: "instanceId", Attributes: attrs1, Scope: "us-east-1", }, { Type: "ec2-instance", UniqueAttribute: "instanceId", Attributes: attrs2, Scope: "us-west-2", }, { Type: "s3-bucket", UniqueAttribute: "bucketName", Attributes: attrs3, Scope: "global", }, }, Edges: []*sdp.Edge{ { From: &sdp.Reference{ Type: "ec2-instance", UniqueAttributeValue: "i-12345", Scope: "us-east-1", }, To: &sdp.Reference{ Type: "s3-bucket", UniqueAttributeValue: "my-test-bucket", Scope: "global", }, }, }, }, } } func TestNewSnapshotIndex(t *testing.T) { snapshot := createTestSnapshot() index, err := NewSnapshotIndex(snapshot) if err != nil { t.Fatalf("NewSnapshotIndex failed: %v", err) } if index == nil { t.Fatal("Expected index to be non-nil") return } // Verify all items are indexed allItems := index.GetAllItems() if len(allItems) != 3 { t.Errorf("Expected 3 items, got %d", len(allItems)) } // Verify edges are stored if len(index.edges) != 1 { t.Errorf("Expected 1 edge, got %d", len(index.edges)) } } func TestLinkedItemsHydrated(t *testing.T) { snapshot := createTestSnapshot() index, err := NewSnapshotIndex(snapshot) if err != nil { t.Fatalf("NewSnapshotIndex failed: %v", err) } // The ec2-instance i-12345 is the From side of the edge to s3-bucket ec2 := index.GetByGUN("us-east-1.ec2-instance.i-12345") if ec2 == nil { t.Fatal("expected to find ec2 instance") } linked := ec2.GetLinkedItems() if len(linked) != 1 { t.Fatalf("Expected 1 linked item on ec2 instance, got %d", len(linked)) } ref := linked[0].GetItem() if ref.GetType() != "s3-bucket" || ref.GetUniqueAttributeValue() != "my-test-bucket" || ref.GetScope() != "global" { t.Errorf("Unexpected linked item reference: %v", ref) } // The s3-bucket is only on the To side of the edge, so it should have no LinkedItems bucket := index.GetByGUN("global.s3-bucket.my-test-bucket") if bucket == nil { t.Fatal("expected to find s3 bucket") } if len(bucket.GetLinkedItems()) != 0 { t.Errorf("Expected 0 linked items on bucket (it is only a To target), got %d", len(bucket.GetLinkedItems())) } // The us-west-2 instance has no edges at all ec2West := index.GetByGUN("us-west-2.ec2-instance.i-67890") if ec2West == nil { t.Fatal("expected to find us-west-2 ec2 instance") } if len(ec2West.GetLinkedItems()) != 0 { t.Errorf("Expected 0 linked items on us-west-2 instance, got %d", len(ec2West.GetLinkedItems())) } } func TestGetByGUN(t *testing.T) { snapshot := createTestSnapshot() index, _ := NewSnapshotIndex(snapshot) // Test getting item by GUN gun := "us-east-1.ec2-instance.i-12345" item := index.GetByGUN(gun) if item == nil { t.Fatalf("Expected to find item with GUN %s", gun) } if item.UniqueAttributeValue() != "i-12345" { t.Errorf("Expected unique attribute 'i-12345', got '%s'", item.UniqueAttributeValue()) } // Test non-existent GUN item = index.GetByGUN("nonexistent.type.query") if item != nil { t.Error("Expected nil for non-existent GUN") } } func TestGetByReference(t *testing.T) { snapshot := createTestSnapshot() index, _ := NewSnapshotIndex(snapshot) // Test getting item by reference ref := &sdp.Reference{ Type: "ec2-instance", UniqueAttributeValue: "i-12345", Scope: "us-east-1", } item := index.GetByReference(ref) if item == nil { t.Fatal("Expected to find item by reference") } if item.UniqueAttributeValue() != "i-12345" { t.Errorf("Expected unique attribute 'i-12345', got '%s'", item.UniqueAttributeValue()) } } func TestGetAllTypes(t *testing.T) { snapshot := createTestSnapshot() index, _ := NewSnapshotIndex(snapshot) types := index.GetAllTypes() if len(types) != 2 { t.Errorf("Expected 2 unique types, got %d", len(types)) } // Verify expected types exist typeMap := make(map[string]bool) for _, itemType := range types { typeMap[itemType] = true } expectedTypes := []string{"ec2-instance", "s3-bucket"} for _, expected := range expectedTypes { if !typeMap[expected] { t.Errorf("Expected type '%s' not found", expected) } } } func TestEdgesFromAndEdgesTo(t *testing.T) { snapshot := createTestSnapshot() index, _ := NewSnapshotIndex(snapshot) refFrom := &sdp.Reference{ Type: "ec2-instance", UniqueAttributeValue: "i-12345", Scope: "us-east-1", } refTo := &sdp.Reference{ Type: "s3-bucket", UniqueAttributeValue: "my-test-bucket", Scope: "global", } fromEdges := index.EdgesFrom(refFrom) if len(fromEdges) != 1 { t.Errorf("Expected 1 edge from ec2-instance i-12345, got %d", len(fromEdges)) } if len(fromEdges) > 0 && !fromEdges[0].GetTo().IsEqual(refTo) { t.Error("EdgesFrom: expected To reference to be s3-bucket my-test-bucket") } toEdges := index.EdgesTo(refTo) if len(toEdges) != 1 { t.Errorf("Expected 1 edge to s3-bucket my-test-bucket, got %d", len(toEdges)) } if len(toEdges) > 0 && !toEdges[0].GetFrom().IsEqual(refFrom) { t.Error("EdgesTo: expected From reference to be ec2-instance i-12345") } // No edges from the bucket (it only appears as To) fromBucket := index.EdgesFrom(refTo) if len(fromBucket) != 0 { t.Errorf("Expected 0 edges from bucket, got %d", len(fromBucket)) } // No edges to the us-east-1 instance (it only appears as From in this snapshot) toInstance := index.EdgesTo(refFrom) if len(toInstance) != 0 { t.Errorf("Expected 0 edges to us-east-1 instance, got %d", len(toInstance)) } } func TestNeighborItems(t *testing.T) { snapshot := createTestSnapshot() index, _ := NewSnapshotIndex(snapshot) ec2East := index.GetByGUN("us-east-1.ec2-instance.i-12345") if ec2East == nil { t.Fatal("expected to find us-east-1 ec2 instance") } neighbors := index.NeighborItems(ec2East) if len(neighbors) != 1 { t.Fatalf("Expected 1 neighbor of us-east-1 ec2 instance, got %d", len(neighbors)) } if neighbors[0].GloballyUniqueName() != "global.s3-bucket.my-test-bucket" { t.Errorf("Expected neighbor to be s3-bucket, got %s", neighbors[0].GloballyUniqueName()) } bucket := index.GetByGUN("global.s3-bucket.my-test-bucket") if bucket == nil { t.Fatal("expected to find s3 bucket") } neighbors = index.NeighborItems(bucket) if len(neighbors) != 1 { t.Fatalf("Expected 1 neighbor of s3 bucket, got %d", len(neighbors)) } if neighbors[0].GloballyUniqueName() != "us-east-1.ec2-instance.i-12345" { t.Errorf("Expected neighbor to be ec2-instance i-12345, got %s", neighbors[0].GloballyUniqueName()) } // us-west-2 instance has no edges ec2West := index.GetByGUN("us-west-2.ec2-instance.i-67890") if ec2West == nil { t.Fatal("expected to find us-west-2 ec2 instance") } neighbors = index.NeighborItems(ec2West) if len(neighbors) != 0 { t.Errorf("Expected 0 neighbors for us-west-2 instance, got %d", len(neighbors)) } } func TestNewSnapshotIndexNilSnapshot(t *testing.T) { _, err := NewSnapshotIndex(nil) if err == nil { t.Error("Expected error for nil snapshot, got nil") } } func TestNewSnapshotIndexNilProperties(t *testing.T) { snapshot := &sdp.Snapshot{} _, err := NewSnapshotIndex(snapshot) if err == nil { t.Error("Expected error for nil properties, got nil") } } ================================================ FILE: sources/snapshot/adapters/loader.go ================================================ package adapters import ( "bytes" "context" "fmt" "io" "net/http" "os" "strings" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) // LoadSnapshot loads a snapshot from a URL or local file path func LoadSnapshot(ctx context.Context, source string) (*sdp.Snapshot, error) { var data []byte var err error // Determine if source is a URL or file path if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { log.WithField("url", source).Info("Loading snapshot from URL") data, err = loadSnapshotFromURL(ctx, source) if err != nil { return nil, fmt.Errorf("failed to load snapshot from URL: %w", err) } } else { log.WithField("path", source).Info("Loading snapshot from file") data, err = loadSnapshotFromFile(source) if err != nil { return nil, fmt.Errorf("failed to load snapshot from file: %w", err) } } // Unmarshal the data (detect JSON vs protobuf format) snapshot := &sdp.Snapshot{} trimmed := bytes.TrimSpace(data) if len(trimmed) > 0 && trimmed[0] == '{' { // JSON format if err := protojson.Unmarshal(data, snapshot); err != nil { return nil, fmt.Errorf("failed to unmarshal snapshot JSON: %w", err) } log.Info("Loaded snapshot from JSON format") } else { // Protobuf format if err := proto.Unmarshal(data, snapshot); err != nil { return nil, fmt.Errorf("failed to unmarshal snapshot protobuf: %w", err) } log.Info("Loaded snapshot from protobuf format") } if snapshot.GetProperties() == nil { return nil, fmt.Errorf("snapshot has no properties") } items := len(snapshot.GetProperties().GetItems()) edges := len(snapshot.GetProperties().GetEdges()) log.WithFields(log.Fields{ "items": items, "edges": edges, }).Info("Snapshot loaded successfully") return snapshot, nil } // loadSnapshotFromURL loads snapshot data from an HTTP(S) URL func loadSnapshotFromURL(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create HTTP request: %w", err) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP request returned status %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return data, nil } // loadSnapshotFromFile loads snapshot data from a local file func loadSnapshotFromFile(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } return data, nil } ================================================ FILE: sources/snapshot/adapters/loader_test.go ================================================ package adapters import ( "context" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/overmindtech/cli/go/sdp-go" "google.golang.org/protobuf/proto" ) func TestLoadSnapshotFromFile(t *testing.T) { // Create a test snapshot attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "test-item", }) snapshot := &sdp.Snapshot{ Properties: &sdp.SnapshotProperties{ Name: "test-snapshot", Items: []*sdp.Item{ { Type: "test-type", UniqueAttribute: "name", Attributes: attrs, Scope: "test-scope", }, }, }, } // Marshal to bytes data, err := proto.Marshal(snapshot) if err != nil { t.Fatalf("Failed to marshal test snapshot: %v", err) } // Write to temp file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test-snapshot.pb") if err := os.WriteFile(tmpFile, data, 0o644); err != nil { t.Fatalf("Failed to write test snapshot file: %v", err) } // Test loading ctx := context.Background() loaded, err := LoadSnapshot(ctx, tmpFile) if err != nil { t.Fatalf("LoadSnapshot failed: %v", err) } if loaded.GetProperties().GetName() != "test-snapshot" { t.Errorf("Expected snapshot name 'test-snapshot', got '%s'", loaded.GetProperties().GetName()) } if len(loaded.GetProperties().GetItems()) != 1 { t.Errorf("Expected 1 item, got %d", len(loaded.GetProperties().GetItems())) } } func TestLoadSnapshotFromURL(t *testing.T) { // Create a test snapshot attrs, _ := sdp.ToAttributesViaJson(map[string]any{ "name": "test-item", }) snapshot := &sdp.Snapshot{ Properties: &sdp.SnapshotProperties{ Name: "test-snapshot-url", Items: []*sdp.Item{ { Type: "test-type", UniqueAttribute: "name", Attributes: attrs, Scope: "test-scope", }, }, }, } // Marshal to bytes data, err := proto.Marshal(snapshot) if err != nil { t.Fatalf("Failed to marshal test snapshot: %v", err) } // Create test HTTP server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write(data) })) defer server.Close() // Test loading from URL ctx := context.Background() loaded, err := LoadSnapshot(ctx, server.URL) if err != nil { t.Fatalf("LoadSnapshot from URL failed: %v", err) } if loaded.GetProperties().GetName() != "test-snapshot-url" { t.Errorf("Expected snapshot name 'test-snapshot-url', got '%s'", loaded.GetProperties().GetName()) } } func TestLoadSnapshotEmptyItems(t *testing.T) { // Create a snapshot with no items (e.g. revlink warmup for account with no sources) snapshot := &sdp.Snapshot{ Properties: &sdp.SnapshotProperties{ Name: "empty-snapshot", Items: []*sdp.Item{}, }, } // Marshal to bytes data, err := proto.Marshal(snapshot) if err != nil { t.Fatalf("Failed to marshal test snapshot: %v", err) } // Write to temp file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "empty-snapshot.pb") if err := os.WriteFile(tmpFile, data, 0o644); err != nil { t.Fatalf("Failed to write test snapshot file: %v", err) } // Empty snapshots are allowed (e.g. for benchmarking or accounts with no discovered infra) ctx := context.Background() loaded, err := LoadSnapshot(ctx, tmpFile) if err != nil { t.Fatalf("LoadSnapshot with empty items should succeed: %v", err) } if len(loaded.GetProperties().GetItems()) != 0 { t.Errorf("Expected 0 items, got %d", len(loaded.GetProperties().GetItems())) } if loaded.GetProperties().GetName() != "empty-snapshot" { t.Errorf("Expected name 'empty-snapshot', got %q", loaded.GetProperties().GetName()) } } func TestLoadSnapshotFileNotFound(t *testing.T) { ctx := context.Background() _, err := LoadSnapshot(ctx, "/nonexistent/file.pb") if err == nil { t.Error("Expected error for nonexistent file, got nil") } } func TestLoadSnapshotInvalidProtobuf(t *testing.T) { // Write invalid protobuf data tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "invalid.pb") if err := os.WriteFile(tmpFile, []byte("invalid protobuf data"), 0o644); err != nil { t.Fatalf("Failed to write invalid data: %v", err) } // Test loading - should fail ctx := context.Background() _, err := LoadSnapshot(ctx, tmpFile) if err == nil { t.Error("Expected error for invalid protobuf, got nil") } } ================================================ FILE: sources/snapshot/adapters/main.go ================================================ package adapters import ( "context" "fmt" "github.com/overmindtech/cli/go/discovery" log "github.com/sirupsen/logrus" ) // InitializeAdapters loads a snapshot and registers one adapter per type found // in the snapshot data. Each adapter carries the correct category and metadata // from the embedded adapter catalog so that the discovery engine can route // specific-type GET/SEARCH queries to it. func InitializeAdapters(ctx context.Context, e *discovery.Engine, snapshotSource string) error { snapshot, err := LoadSnapshot(ctx, snapshotSource) if err != nil { return fmt.Errorf("failed to load snapshot: %w", err) } index, err := NewSnapshotIndex(snapshot) if err != nil { return fmt.Errorf("failed to build snapshot index: %w", err) } types := index.GetAllTypes() adapters := make([]discovery.Adapter, 0, len(types)) for _, typ := range types { scopes := index.GetScopesForType(typ) adapters = append(adapters, NewSnapshotAdapter(index, typ, scopes)) } if err := e.AddAdapters(adapters...); err != nil { return fmt.Errorf("failed to add snapshot adapters: %w", err) } log.WithFields(log.Fields{ "items": len(snapshot.GetProperties().GetItems()), "edges": len(snapshot.GetProperties().GetEdges()), "types": len(types), "adapters": len(adapters), }).Info("Snapshot adapters initialized successfully") return nil } ================================================ FILE: sources/snapshot/build/package/Dockerfile ================================================ # Build the source binary FROM golang:1.26.2-alpine3.23 AS builder ARG TARGETOS ARG TARGETARCH ARG BUILD_VERSION ARG BUILD_COMMIT # required for generating the version descriptor RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg \ go mod download COPY go/ go/ COPY sources/ sources/ COPY docs.overmind.tech/docs/sources/ docs.overmind.tech/docs/sources/ # Build RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source sources/snapshot/main.go FROM alpine:3.23.4 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 ENTRYPOINT ["/source"] ================================================ FILE: sources/snapshot/cmd/root.go ================================================ package cmd import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/logging" "github.com/overmindtech/cli/go/tracing" "github.com/overmindtech/cli/sources/snapshot/adapters" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" ) var cfgFile string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "snapshot-source", Short: "Discovery source that serves data from a snapshot file", SilenceUsage: true, Long: `Snapshot source loads a snapshot from a file or URL and responds to discovery queries with items from that snapshot. This enables local testing with fixed data and deterministic re-runs of v6 investigations.`, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() defer tracing.LogRecoverToReturn(ctx, "snapshot-source.root") // Get snapshot source (required) snapshotSource := viper.GetString("snapshot-source") if snapshotSource == "" { return fmt.Errorf("snapshot-source is required (use --snapshot-source or SNAPSHOT_SOURCE env var)") } log.WithField("snapshot-source", snapshotSource).Info("Starting snapshot source") // Get engine config engineConfig, err := discovery.EngineConfigFromViper("snapshot", tracing.Version()) if err != nil { log.WithError(err).Error("Could not get engine config from viper") return fmt.Errorf("could not get engine config from viper: %w", err) } // Create a basic engine first e, err := discovery.NewEngine(engineConfig) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not create engine") return fmt.Errorf("could not create engine: %w", err) } // Start HTTP server for health checks before initialization healthCheckPort := viper.GetInt("health-check-port") e.ServeHealthProbes(healthCheckPort) // Start the engine (NATS connection) before adapter init so heartbeats work err = e.Start(ctx) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not start engine") return fmt.Errorf("could not start engine: %w", err) } // Snapshot adapters load from files/URLs which may fail, so we use // the initialization pattern with error handling err = adapters.InitializeAdapters(ctx, e, snapshotSource) if err != nil { initErr := fmt.Errorf("could not initialize snapshot adapters: %w", err) log.WithError(initErr).Error("Snapshot source initialization failed - pod will stay running with error status") e.SetInitError(initErr) sentry.CaptureException(initErr) } else { e.MarkAdaptersInitialized() // Start() already launched the heartbeat loop, so StartSendingHeartbeats // is a no-op here. Send an immediate heartbeat so the API server learns // the source is healthy without waiting for the next tick. if err := e.SendHeartbeat(ctx, nil); err != nil { log.WithError(err).Warn("Failed to send post-init heartbeat") } } <-ctx.Done() log.Info("Stopping engine") err = e.Stop() if err != nil { log.WithError(err).Error("Could not stop engine") return fmt.Errorf("could not stop engine: %w", err) } log.Info("Stopped") return nil }, } // Execute adds all child commands to the root command and sets flags // appropriately. This is called by main.main(). It only needs to happen once to // the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } func init() { cobra.OnInitialize(initConfig) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. var logLevel string // General config options rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "/etc/srcman/config/source.yaml", "config file path") rootCmd.PersistentFlags().StringVar(&logLevel, "log", "info", "Set the log level. Valid values: panic, fatal, error, warn, info, debug, trace") cobra.CheckErr(viper.BindEnv("log", "SNAPSHOT_LOG", "LOG")) // fallback to global config // Snapshot-specific config rootCmd.PersistentFlags().String("snapshot-source", "", "Path to snapshot file or URL to load (required). Can be a local file path or http(s) URL.") cobra.CheckErr(viper.BindEnv("snapshot-source", "SNAPSHOT_SOURCE", "SNAPSHOT_PATH", "SNAPSHOT_URL")) // engine config options discovery.AddEngineFlags(rootCmd) rootCmd.PersistentFlags().IntP("health-check-port", "", 8089, "The port that the health check should run on") cobra.CheckErr(viper.BindEnv("health-check-port", "SNAPSHOT_HEALTH_CHECK_PORT", "HEALTH_CHECK_PORT", "SNAPSHOT_SERVICE_PORT", "SERVICE_PORT")) // new names + backwards compat // tracing rootCmd.PersistentFlags().String("honeycomb-api-key", "", "If specified, configures opentelemetry libraries to submit traces to honeycomb") cobra.CheckErr(viper.BindEnv("honeycomb-api-key", "SNAPSHOT_HONEYCOMB_API_KEY", "HONEYCOMB_API_KEY")) // fallback to global config rootCmd.PersistentFlags().String("sentry-dsn", "", "If specified, configures sentry libraries to capture errors") cobra.CheckErr(viper.BindEnv("sentry-dsn", "SNAPSHOT_SENTRY_DSN", "SENTRY_DSN")) // fallback to global config rootCmd.PersistentFlags().String("run-mode", "release", "Set the run mode for this service, 'release', 'debug' or 'test'. Defaults to 'release'.") rootCmd.PersistentFlags().Bool("json-log", true, "Set to false to emit logs as text for easier reading in development.") cobra.CheckErr(viper.BindEnv("json-log", "SNAPSHOT_SOURCE_JSON_LOG", "JSON_LOG")) // fallback to global config // Bind these to viper cobra.CheckErr(viper.BindPFlags(rootCmd.PersistentFlags())) // Run this before we do anything to set up the loglevel rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if lvl, err := log.ParseLevel(logLevel); err == nil { log.SetLevel(lvl) } else { log.SetLevel(log.InfoLevel) log.WithFields(log.Fields{ "error": err, }).Error("Could not parse log level") } log.AddHook(TerminationLogHook{}) // Bind flags that haven't been set to the values from viper of we have them var bindErr error cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { // Bind the flag to viper only if it has a non-empty default if f.DefValue != "" || f.Changed { if err := viper.BindPFlag(f.Name, f); err != nil { bindErr = err } } }) if bindErr != nil { log.WithError(bindErr).Error("Could not bind flag to viper") return fmt.Errorf("could not bind flag to viper: %w", bindErr) } if viper.GetBool("json-log") { logging.ConfigureLogrusJSON(log.StandardLogger()) } if err := tracing.InitTracerWithUpstreams("snapshot-source", viper.GetString("honeycomb-api-key"), viper.GetString("sentry-dsn")); err != nil { log.WithError(err).Error("could not init tracer") return fmt.Errorf("could not init tracer: %w", err) } return nil } // shut down tracing at the end of the process rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { tracing.ShutdownTracer(context.Background()) } } // initConfig reads in config file and ENV variables if set. func initConfig() { viper.SetConfigFile(cfgFile) replacer := strings.NewReplacer("-", "_") viper.SetEnvKeyReplacer(replacer) // Do not set env prefix so APP, API_KEY, NATS_* etc. are read the same as other sources (aws, gcp). // Snapshot-specific options use explicit BindEnv (e.g. SNAPSHOT_SOURCE) in flag init. viper.AutomaticEnv() // read in environment variables that match // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil { log.Infof("Using config file: %v", viper.ConfigFileUsed()) } } // TerminationLogHook A hook that logs fatal errors to the termination log type TerminationLogHook struct{} func (t TerminationLogHook) Levels() []log.Level { return []log.Level{log.FatalLevel} } func (t TerminationLogHook) Fire(e *log.Entry) error { // shutdown tracing first to ensure all spans are flushed tracing.ShutdownTracer(context.Background()) tLog, err := os.OpenFile("/dev/termination-log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } var message string message = e.Message for k, v := range e.Data { message = fmt.Sprintf("%v %v=%v", message, k, v) } _, err = tLog.WriteString(message) return err } ================================================ FILE: sources/snapshot/main.go ================================================ package main import ( _ "go.uber.org/automaxprocs" "github.com/overmindtech/cli/sources/snapshot/cmd" ) func main() { cmd.Execute() } ================================================ FILE: sources/stdlib/items.go ================================================ package stdlib import ( "github.com/overmindtech/cli/sources/shared" stdlibshared "github.com/overmindtech/cli/sources/stdlib/shared" ) type ItemType struct { shared.ItemTypeInstance } // String returns the string representation of the ItemType // This is created for backwards compatibility // Currently, it returns the resource name only without the source and API func (i ItemType) String() string { return string(i.Resource) } var ( NetworkIP = ItemType{ItemTypeInstance: shared.NewItemType(stdlibshared.Stdlib, stdlibshared.Network, stdlibshared.IP)} NetworkDNS = ItemType{ItemTypeInstance: shared.NewItemType(stdlibshared.Stdlib, stdlibshared.Network, stdlibshared.DNS)} NetworkHTTP = ItemType{ItemTypeInstance: shared.NewItemType(stdlibshared.Stdlib, stdlibshared.Network, stdlibshared.HTTP)} ) ================================================ FILE: sources/stdlib/shared/models.go ================================================ package shared import "github.com/overmindtech/cli/sources/shared" const ( Stdlib shared.Source = "std-lib" ) const ( Network shared.API = "network" ) const ( IP shared.Resource = "ip" DNS shared.Resource = "dns" HTTP shared.Resource = "http" ) ================================================ FILE: sources/transformer.go ================================================ package sources import ( "context" "errors" "fmt" "slices" "strings" "buf.build/go/protovalidate" log "github.com/sirupsen/logrus" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" azureshared "github.com/overmindtech/cli/sources/azure/shared" gcpshared "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) // ItemTypeLookups is a slice of ItemTypeLookup. type ItemTypeLookups []shared.ItemTypeLookup // ReadableFormat returns a readable format of the ItemTypeLookups func (lookups ItemTypeLookups) ReadableFormat() string { var readableLookups []string for _, lookup := range lookups { readableLookups = append(readableLookups, lookup.Readable()) } return strings.Join(readableLookups, shared.QuerySeparator) } // Wrapper defines the base interface for resource wrappers. type Wrapper interface { Scopes() []string GetLookups() ItemTypeLookups Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) Type() string Name() string ItemType() shared.ItemType TerraformMappings() []*sdp.TerraformMapping Category() sdp.AdapterCategory PotentialLinks() map[shared.ItemType]bool AdapterMetadata() *sdp.AdapterMetadata IAMPermissions() []string } // ListableWrapper defines an optional interface for resources that support listing. type ListableWrapper interface { Wrapper List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) } // ListStreamableWrapper defines an interface for resources that support listing with streaming. type ListStreamableWrapper interface { Wrapper ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) } // SearchableWrapper defines an optional interface for resources that support searching. type SearchableWrapper interface { Wrapper SearchLookups() []ItemTypeLookups Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) } // SearchStreamableWrapper defines an interface for resources that support searching with streaming. type SearchStreamableWrapper interface { Wrapper SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) } // WildcardScopeWrapper is an optional interface that wrappers can implement // to declare they can handle "*" wildcard scopes efficiently. type WildcardScopeWrapper interface { Wrapper SupportsWildcardScope() bool } // SearchableListableWrapper defines an interface for resources that support both searching and listing. type SearchableListableWrapper interface { SearchableWrapper ListableWrapper } // StandardAdapter defines the standard interface for adapters. type StandardAdapter interface { Validate() error discovery.Adapter } // WrapperToAdapter converts a Wrapper to a StandardAdapter. func WrapperToAdapter(wrapper Wrapper, cache sdpcache.Cache) StandardAdapter { core := standardAdapterCore{ wrapper: wrapper, cache: cache, } core.sourceType = "unknown" it, ok := wrapper.ItemType().(shared.ItemTypeInstance) if ok { core.sourceType = string(it.Source) } // Check if wrapper supports both List and Search - if so, return standardSearchableListableAdapterImpl if listable, listOk := wrapper.(ListableWrapper); listOk { if searchable, searchOk := wrapper.(SearchableWrapper); searchOk { listableImpl := &standardListableAdapterImpl{ listable: listable, } searchableImpl := &standardSearchableAdapterImpl{ searchable: searchable, } // Check for streaming capabilities if listStreamable, ok := wrapper.(ListStreamableWrapper); ok { listableImpl.listStreamable = listStreamable } if searchStreamable, ok := wrapper.(SearchStreamableWrapper); ok { searchableImpl.searchStreamable = searchStreamable } // Set the core for delegate implementations listableImpl.standardAdapterCore = core searchableImpl.standardAdapterCore = core a := &standardSearchableListableAdapterImpl{ listableImpl: listableImpl, searchableImpl: searchableImpl, standardAdapterCore: core, } if err := a.Validate(); err != nil { panic(fmt.Sprintf("failed to validate adapter: %v", err)) } return a } // Listable only a := &standardListableAdapterImpl{ standardAdapterCore: core, listable: listable, } if listStreamable, ok := wrapper.(ListStreamableWrapper); ok { a.listStreamable = listStreamable } if err := a.Validate(); err != nil { panic(fmt.Sprintf("failed to validate adapter: %v", err)) } return a } // Check if wrapper is searchable only - return standardSearchableAdapterImpl if searchable, ok := wrapper.(SearchableWrapper); ok { a := &standardSearchableAdapterImpl{ standardAdapterCore: core, searchable: searchable, } if searchStreamable, ok := wrapper.(SearchStreamableWrapper); ok { a.searchStreamable = searchStreamable } if err := a.Validate(); err != nil { panic(fmt.Sprintf("failed to validate adapter: %v", err)) } return a } // For non-listable, non-searchable wrappers, return standardAdapterImpl a := &standardAdapterImpl{ standardAdapterCore: core, } if err := a.Validate(); err != nil { panic(fmt.Sprintf("failed to validate adapter: %v", err)) } return a } type standardAdapterCore struct { wrapper Wrapper sourceType string cache sdpcache.Cache // This is mandatory } type standardAdapterImpl struct { standardAdapterCore } type standardListableAdapterImpl struct { listable ListableWrapper listStreamable ListStreamableWrapper standardAdapterCore } type standardSearchableAdapterImpl struct { searchable SearchableWrapper searchStreamable SearchStreamableWrapper standardAdapterCore } type standardSearchableListableAdapterImpl struct { listableImpl *standardListableAdapterImpl searchableImpl *standardSearchableAdapterImpl standardAdapterCore } // Standard Adapter Core methods // ***************************** // Type returns the type of the adapter. func (s *standardAdapterCore) Type() string { return s.wrapper.Type() } // Name returns the name of the adapter. func (s *standardAdapterCore) Name() string { return s.wrapper.Name() } // Scopes returns the scopes of the adapter. func (s *standardAdapterCore) Scopes() []string { return s.wrapper.Scopes() } func (s *standardAdapterCore) validateScopes(scope string) error { // Allow wildcard scope if the wrapper supports it if scope == "*" { if ws, ok := s.wrapper.(WildcardScopeWrapper); ok && ws.SupportsWildcardScope() { return nil } } if slices.Contains(s.Scopes(), scope) { return nil } return &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: fmt.Sprintf("requested scope %v does not match any adapter scope %v", scope, s.Scopes()), } } // NOTFOUND caching contract (applies to all adapters using this transformer, including manual adapters): // we only cache when the result is "not found" (not timeouts or other errors). When a second call hits // the cache, we return the same response and error as a fresh not-found call (e.g. Get: nil item + same // error message; List/Search: empty slice + nil error). No behavior change. // // IsNotFound returns true if err is a QueryError with ErrorType NOTFOUND. func IsNotFound(err error) bool { var qe *sdp.QueryError if errors.As(err, &qe) { return qe.GetErrorType() == sdp.QueryError_NOTFOUND } return false } // Get retrieves a single item with a given scope and query. func (s *standardAdapterCore) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if err := s.validateScopes(scope); err != nil { return nil, err } cacheHit, ck, cachedItem, qErr, done := s.cache.Lookup( ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into nil result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return nil, qErr } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": s.sourceType, "ovm.source.adapter": s.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_GET.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") return nil, qErr } if cacheHit && len(cachedItem) > 0 { return cachedItem[0], nil } var queryParts []string if s.sourceType == string(azureshared.Azure) && strings.HasPrefix(query, "/subscriptions/") { // Terraform mapping may pass full Azure resource ID; extract query parts by type. if azureshared.GetResourceIDPathKeys(s.wrapper.Type()) == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("no path keys defined for resource type %s to extract from query %s", s.wrapper.Type(), query), } } queryParts = azureshared.ExtractPathParamsFromResourceIDByType(s.wrapper.Type(), query) if queryParts == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("failed to extract query parts from resource ID for resource type %s (invalid or unsupported format): %s", s.wrapper.Type(), query), } } } else { queryParts = strings.Split(query, shared.QuerySeparator) } if len(queryParts) != len(s.wrapper.GetLookups()) { return nil, fmt.Errorf( "invalid query format: %s, expected: %s", query, s.wrapper.GetLookups().ReadableFormat(), ) } item, err := s.wrapper.Get(ctx, scope, queryParts...) if err != nil { // Only cache NOTFOUND so lookup behaviour is unchanged for timeouts/other errors if IsNotFound(err) { s.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) } return nil, err } if item == nil { // Cache not-found when item is nil notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%s not found for query '%s'", s.Type(), query), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, ck) return nil, notFoundErr } // Store in cache after successful get s.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) return item, nil } // Standard Adapter Implementation // ******************************* // Metadata returns the metadata of the adapter. // It uses the wrapper's metadata if available, otherwise constructs it based on the wrapper's type and capabilities. func (s *standardAdapterImpl) Metadata() *sdp.AdapterMetadata { if s.wrapper.AdapterMetadata() != nil { return s.wrapper.AdapterMetadata() } supportedQueryMethods := &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: fmt.Sprintf( "Get %s by \"%s\"", s.wrapper.ItemType().Readable(), s.wrapper.GetLookups().ReadableFormat(), ), } a := &sdp.AdapterMetadata{ Type: s.wrapper.Type(), Category: s.wrapper.Category(), DescriptiveName: s.wrapper.ItemType().Readable(), TerraformMappings: s.wrapper.TerraformMappings(), SupportedQueryMethods: supportedQueryMethods, } if s.wrapper.PotentialLinks() != nil { a.PotentialLinks = []string{} for link := range s.wrapper.PotentialLinks() { a.PotentialLinks = append(a.PotentialLinks, link.String()) } } return a } // Validate checks if the adapter is valid. func (s *standardAdapterImpl) Validate() error { if s.cache == nil { return fmt.Errorf("cache is not initialized") } if s.sourceType == string(gcpshared.GCP) { // Validate predefined role and IAM permissions consistency if err := validatePredefinedRole(s.wrapper); err != nil { return err } } return protovalidate.Validate(s.Metadata()) } // Listable Adapter Implementation // ****************************** // List retrieves all items in a given scope. func (s *standardListableAdapterImpl) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if err := s.validateScopes(scope); err != nil { return nil, err } if s.listable == nil { log.WithField("adapter", s.Name()).Debug("list operation not supported") return nil, nil } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup( ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.Type(), "", ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": s.sourceType, "ovm.source.adapter": s.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_LIST.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") return nil, qErr } if cacheHit { return cachedItems, nil } items, err := s.listable.List(ctx, scope) if err != nil { // Only cache NOTFOUND so lookup behaviour is unchanged for timeouts/other errors if IsNotFound(err) { s.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) } return nil, err } if len(items) == 0 { // Cache not-found when no items were found notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found in scope %s", s.Type(), scope), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, ck) return items, nil } for _, item := range items { s.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) } return items, nil } func (s *standardListableAdapterImpl) ListStream(ctx context.Context, scope string, ignoreCache bool, stream discovery.QueryResultStream) { if err := s.validateScopes(scope); err != nil { stream.SendError(err) return } if s.listStreamable == nil { log.WithField("adapter", s.Name()).Debug("list stream operation not supported") return } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup( ctx, s.Name(), sdp.QueryMethod_LIST, scope, s.Type(), "", ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": s.sourceType, "ovm.source.adapter": s.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_LIST.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } s.listStreamable.ListStream(ctx, stream, s.cache, ck, scope) } // Metadata returns the metadata of the listable adapter. func (s *standardListableAdapterImpl) Metadata() *sdp.AdapterMetadata { if s.wrapper.AdapterMetadata() != nil { return s.wrapper.AdapterMetadata() } a := &sdp.AdapterMetadata{ Type: s.wrapper.Type(), Category: s.wrapper.Category(), DescriptiveName: s.wrapper.ItemType().Readable(), TerraformMappings: s.wrapper.TerraformMappings(), SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: fmt.Sprintf( "Get %s by \"%s\"", s.wrapper.ItemType().Readable(), s.wrapper.GetLookups().ReadableFormat(), ), List: true, ListDescription: fmt.Sprintf( "List all %s items", s.wrapper.ItemType().Readable(), ), }, } if s.wrapper.PotentialLinks() != nil { a.PotentialLinks = []string{} for link := range s.wrapper.PotentialLinks() { a.PotentialLinks = append(a.PotentialLinks, link.String()) } } return a } // Validate checks if the listable adapter is valid. func (s *standardListableAdapterImpl) Validate() error { if s.cache == nil { return fmt.Errorf("cache is not initialized") } if s.sourceType == string(gcpshared.GCP) { // Validate predefined role and IAM permissions consistency if err := validatePredefinedRole(s.wrapper); err != nil { return err } } return protovalidate.Validate(s.Metadata()) } // SupportsWildcardScope delegates to the wrapper if it implements WildcardScopeWrapper func (s *standardListableAdapterImpl) SupportsWildcardScope() bool { if ws, ok := s.wrapper.(WildcardScopeWrapper); ok { return ws.SupportsWildcardScope() } return false } // Searchable Adapter Implementation // ********************************* // Search retrieves items based on a search query. func (s *standardSearchableAdapterImpl) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if err := s.validateScopes(scope); err != nil { return nil, err } var queryParts []string if s.sourceType == string(gcpshared.GCP) && strings.HasPrefix(query, "projects/") { // This must be a terraform query in the format of: // projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} // // Extract the relevant parts from the query // We need to extract the path parameters based on the number of lookups queryParts = gcpshared.ExtractPathParamsWithCount(query, len(s.wrapper.GetLookups())) if len(queryParts) != len(s.wrapper.GetLookups()) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to handle terraform mapping from query %s for %s", query, s.wrapper.ItemType().Readable(), ), } } item, err := s.Get(ctx, scope, shared.CompositeLookupKey(queryParts...), ignoreCache) if err != nil { return nil, fmt.Errorf("failed to get item from terraform mapping: %w", err) } return []*sdp.Item{item}, nil } if s.sourceType == string(azureshared.Azure) && strings.HasPrefix(query, "/subscriptions/") { // This must be a terraform query in Azure resource ID format: // /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}/queueServices/default/queues/{queue} // // Extract the relevant parts from the resource ID based on the resource type. // Distinguish "unknown type" (no path keys) from "extraction failed" (malformed or unsupported ID format). if azureshared.GetResourceIDPathKeys(s.wrapper.Type()) == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "no path keys defined for resource type %s to extract from terraform query %s", s.wrapper.Type(), query, ), } } queryParts = azureshared.ExtractPathParamsFromResourceIDByType(s.wrapper.Type(), query) if queryParts == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to extract query parts from resource ID for resource type %s (invalid or unsupported format): %s", s.wrapper.Type(), query, ), } } if len(queryParts) != len(s.wrapper.GetLookups()) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to handle terraform mapping from query %s for %s: extracted %d parts, expected %d", query, s.wrapper.ItemType().Readable(), len(queryParts), len(s.wrapper.GetLookups()), ), } } item, err := s.Get(ctx, scope, shared.CompositeLookupKey(queryParts...), ignoreCache) if err != nil { return nil, fmt.Errorf("failed to get item from terraform mapping: %w", err) } return []*sdp.Item{item}, nil } if s.searchable == nil { log.WithField("adapter", s.Name()).Debug("search operation not supported") return nil, nil } // This must be a regular query in the format of: // {{datasetName}}|{{tableName}} queryParts = strings.Split(query, shared.QuerySeparator) // Determine which search lookups to use searchLookups := s.searchable.SearchLookups() var validQuery bool for _, kw := range searchLookups { if len(kw) == len(queryParts) { validQuery = true break } continue } if !validQuery { return nil, fmt.Errorf( "invalid search query format: %s, expected: %s", query, expectedSearchQueryFormat(searchLookups), ) } // Check cache before searching cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup( ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return []*sdp.Item{}, nil } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": s.sourceType, "ovm.source.adapter": s.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_SEARCH.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") return nil, qErr } if cacheHit { return cachedItems, nil } items, err := s.searchable.Search(ctx, scope, queryParts...) if err != nil { // Only cache NOTFOUND so lookup behaviour is unchanged for timeouts/other errors if IsNotFound(err) { s.cache.StoreUnavailableItem(ctx, err, shared.DefaultCacheDuration, ck) } return nil, err } if len(items) == 0 { // Cache not-found when no items were found notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found for search query '%s'", s.Type(), query), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, ck) return items, nil } for _, item := range items { s.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) } return items, nil } func (s *standardSearchableAdapterImpl) SearchStream(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { if err := s.validateScopes(scope); err != nil { stream.SendError(err) return } cacheHit, ck, cachedItems, qErr, done := s.cache.Lookup( ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query, ignoreCache, ) defer done() if qErr != nil { // For better semantics, convert cached NOTFOUND into empty result if qErr.GetErrorType() == sdp.QueryError_NOTFOUND { return } log.WithContext(ctx).WithFields(log.Fields{ "ovm.source.type": s.sourceType, "ovm.source.adapter": s.Name(), "ovm.source.scope": scope, "ovm.source.method": sdp.QueryMethod_SEARCH.String(), "ovm.source.cache-key": ck, }).WithError(qErr).Info("returning cached query error") stream.SendError(qErr) return } if cacheHit { for _, item := range cachedItems { stream.SendItem(item) } return } var queryParts []string if s.sourceType == string(gcpshared.GCP) && strings.HasPrefix(query, "projects/") { // This must be a terraform query in the format of: // projects/{{project}}/datasets/{{dataset}}/tables/{{name}} // projects/{{project}}/serviceAccounts/{{account}}/keys/{{key}} // // Extract the relevant parts from the query // We need to extract the path parameters based on the number of lookups queryParts = gcpshared.ExtractPathParamsWithCount(query, len(s.wrapper.GetLookups())) if len(queryParts) != len(s.wrapper.GetLookups()) { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to handle terraform mapping from query %s for %s", query, s.wrapper.ItemType().Readable(), ), }) return } item, err := s.Get(ctx, scope, shared.CompositeLookupKey(queryParts...), ignoreCache) if err != nil { stream.SendError(fmt.Errorf("failed to get item from terraform mapping: %w", err)) return } s.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) stream.SendItem(item) return } if s.sourceType == string(azureshared.Azure) && strings.HasPrefix(query, "/subscriptions/") { // This must be a terraform query in Azure resource ID format: // /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}/queueServices/default/queues/{queue} // // Extract the relevant parts from the resource ID based on the resource type. // Distinguish "unknown type" (no path keys) from "extraction failed" (malformed or unsupported ID format). if azureshared.GetResourceIDPathKeys(s.wrapper.Type()) == nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "no path keys defined for resource type %s to extract from terraform query %s", s.wrapper.Type(), query, ), }) return } queryParts = azureshared.ExtractPathParamsFromResourceIDByType(s.wrapper.Type(), query) if queryParts == nil { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to extract query parts from resource ID for resource type %s (invalid or unsupported format): %s", s.wrapper.Type(), query, ), }) return } if len(queryParts) != len(s.wrapper.GetLookups()) { stream.SendError(&sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf( "failed to handle terraform mapping from query %s for %s: extracted %d parts, expected %d", query, s.wrapper.ItemType().Readable(), len(queryParts), len(s.wrapper.GetLookups()), ), }) return } item, err := s.Get(ctx, scope, shared.CompositeLookupKey(queryParts...), ignoreCache) if err != nil { stream.SendError(fmt.Errorf("failed to get item from terraform mapping: %w", err)) return } s.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) stream.SendItem(item) return } // This must be a regular query in the format of: // {{datasetName}}|{{tableName}} queryParts = strings.Split(query, shared.QuerySeparator) // Determine which search lookups to use searchLookups := s.searchable.SearchLookups() var validQuery bool for _, kw := range searchLookups { if len(kw) == len(queryParts) { validQuery = true break } continue } if !validQuery { stream.SendError(fmt.Errorf( "invalid search query format: %s, expected: %s", query, expectedSearchQueryFormat(searchLookups), )) return } if s.searchStreamable == nil { // No streaming implementation; fall back to the batch Search method // and send items individually. Without this, wrappers that implement // SearchableWrapper but not SearchStreamableWrapper would silently // return zero items because the engine always prefers SearchStream. items, qErr := s.searchable.Search(ctx, scope, queryParts...) if qErr != nil { if IsNotFound(qErr) { s.cache.StoreUnavailableItem(ctx, qErr, shared.DefaultCacheDuration, ck) } stream.SendError(qErr) return } if len(items) == 0 { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("no %s found for search query '%s'", s.Type(), query), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, shared.DefaultCacheDuration, ck) return } for _, item := range items { s.cache.StoreItem(ctx, item, shared.DefaultCacheDuration, ck) stream.SendItem(item) } return } s.searchStreamable.SearchStream(ctx, stream, s.cache, ck, scope, queryParts...) } // Metadata returns the metadata of the searchable adapter. func (s *standardSearchableAdapterImpl) Metadata() *sdp.AdapterMetadata { if s.wrapper.AdapterMetadata() != nil { return s.wrapper.AdapterMetadata() } a := &sdp.AdapterMetadata{ Type: s.wrapper.Type(), Category: s.wrapper.Category(), DescriptiveName: s.wrapper.ItemType().Readable(), TerraformMappings: s.wrapper.TerraformMappings(), SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: fmt.Sprintf( "Get %s by \"%s\"", s.wrapper.ItemType().Readable(), s.wrapper.GetLookups().ReadableFormat(), ), Search: true, SearchDescription: fmt.Sprintf( "Search for %s by \"%s\"", s.wrapper.ItemType().Readable(), expectedSearchQueryFormat(s.searchable.SearchLookups()), ), }, } if s.wrapper.PotentialLinks() != nil { a.PotentialLinks = []string{} for link := range s.wrapper.PotentialLinks() { a.PotentialLinks = append(a.PotentialLinks, link.String()) } } return a } // Validate checks if the searchable adapter is valid. func (s *standardSearchableAdapterImpl) Validate() error { if s.cache == nil { return fmt.Errorf("cache is not initialized") } if s.sourceType == string(gcpshared.GCP) { // Validate predefined role and IAM permissions consistency if err := validatePredefinedRole(s.wrapper); err != nil { return err } } return protovalidate.Validate(s.Metadata()) } // Searchable and Listable Adapter Implementation // ********************************************** // Metadata returns the metadata of the searchable+listable adapter. func (s *standardSearchableListableAdapterImpl) Metadata() *sdp.AdapterMetadata { if s.wrapper.AdapterMetadata() != nil { return s.wrapper.AdapterMetadata() } supportedQueryMethods := &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: fmt.Sprintf( "Get %s by \"%s\"", s.wrapper.ItemType().Readable(), s.wrapper.GetLookups().ReadableFormat(), ), List: true, ListDescription: fmt.Sprintf( "List all %s items", s.wrapper.ItemType().Readable()), Search: true, SearchDescription: fmt.Sprintf( "Search for %s by \"%s\"", s.wrapper.ItemType().Readable(), expectedSearchQueryFormat(s.searchableImpl.searchable.SearchLookups()), ), } a := &sdp.AdapterMetadata{ Type: s.wrapper.Type(), Category: s.wrapper.Category(), DescriptiveName: s.wrapper.ItemType().Readable(), TerraformMappings: s.wrapper.TerraformMappings(), SupportedQueryMethods: supportedQueryMethods, } if s.wrapper.PotentialLinks() != nil { a.PotentialLinks = []string{} for link := range s.wrapper.PotentialLinks() { a.PotentialLinks = append(a.PotentialLinks, link.String()) } } return a } // Validate checks if the searchable+listable adapter is valid. func (s *standardSearchableListableAdapterImpl) Validate() error { if s.cache == nil { return fmt.Errorf("cache is not initialized") } if s.sourceType == string(gcpshared.GCP) { // Validate predefined role and IAM permissions consistency if err := validatePredefinedRole(s.wrapper); err != nil { return err } } return protovalidate.Validate(s.Metadata()) } // List delegates to the listable implementation. func (s *standardSearchableListableAdapterImpl) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return s.listableImpl.List(ctx, scope, ignoreCache) } // ListStream delegates to the listable implementation. func (s *standardSearchableListableAdapterImpl) ListStream(ctx context.Context, scope string, ignoreCache bool, stream discovery.QueryResultStream) { s.listableImpl.ListStream(ctx, scope, ignoreCache, stream) } // Search delegates to the searchable implementation. func (s *standardSearchableListableAdapterImpl) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { return s.searchableImpl.Search(ctx, scope, query, ignoreCache) } // SearchStream delegates to the searchable implementation. func (s *standardSearchableListableAdapterImpl) SearchStream(ctx context.Context, scope string, query string, ignoreCache bool, stream discovery.QueryResultStream) { s.searchableImpl.SearchStream(ctx, scope, query, ignoreCache, stream) } // SupportsWildcardScope delegates to the listable implementation. func (s *standardSearchableListableAdapterImpl) SupportsWildcardScope() bool { return s.listableImpl.SupportsWildcardScope() } // expectedSearchQueryFormat generates a readable format for the search query. func expectedSearchQueryFormat(keywords []ItemTypeLookups) string { var readableKeywords []string for _, keyword := range keywords { readableKeywords = append(readableKeywords, keyword.ReadableFormat()) } return strings.Join(readableKeywords, "\" or \"") } // validatePredefinedRole validates that the wrapper's predefined role and IAM permissions are consistent func validatePredefinedRole(wrapper Wrapper) error { pdr, ok := wrapper.(gcpshared.WithPredefinedRole) if !ok { return fmt.Errorf("gcp predefined role not supported") } pRole := pdr.PredefinedRole() iamPermissions := wrapper.IAMPermissions() // Predefined role must be specified if pRole == "" { return fmt.Errorf("wrapper %s must specify a predefined role", wrapper.Type()) } // Check if the predefined role exists in the map role, exists := gcpshared.PredefinedRoles[pRole] if !exists { return fmt.Errorf("predefined role %s is not found in PredefinedRoles map", pRole) } // Check if all IAM permissions from the wrapper exist in the predefined role's IAMPermissions for _, perm := range iamPermissions { found := slices.Contains(role.IAMPermissions, perm) if !found { return fmt.Errorf("IAM permission %s from wrapper is not included in predefined role %s IAMPermissions", perm, pRole) } } return nil } ================================================ FILE: sources/transformer_test.go ================================================ package sources import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" aws "github.com/overmindtech/cli/sources/aws/shared" gcp "github.com/overmindtech/cli/sources/gcp/shared" "github.com/overmindtech/cli/sources/shared" ) func TestItemTypeReadableFormat(t *testing.T) { tests := []struct { name string input shared.ItemType expected string }{ { name: "Three parts input", input: shared.NewItemType(gcp.GCP, gcp.Compute, gcp.Instance), expected: "GCP Compute Instance", }, { name: "Three parts input", input: shared.NewItemType(aws.AWS, aws.APIGateway, aws.RESTAPI), expected: "AWS Api Gateway Rest Api", // Note that this is only testing the fallback rendering, // adapter implementors will have to supply a custom descriptive name, // like "Amazon API Gateway REST API" in the `AdapterMetadata`. }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := tt.input.Readable() if actual != tt.expected { t.Errorf("readableFormat(%q) = %q; expected %q", tt.input, actual, tt.expected) } }) } } // errorReturningListableWrapper is a test wrapper that returns an error from List() // to simulate the scenario where the underlying API call fails. type errorReturningListableWrapper struct { callCount atomic.Int32 itemType shared.ItemType scope string } func (w *errorReturningListableWrapper) Scopes() []string { return []string{w.scope} } func (w *errorReturningListableWrapper) GetLookups() ItemTypeLookups { return ItemTypeLookups{ shared.NewItemTypeLookup("id", w.itemType), } } func (w *errorReturningListableWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "not implemented", } } func (w *errorReturningListableWrapper) Type() string { return w.itemType.String() } func (w *errorReturningListableWrapper) Name() string { return "error-returning-adapter" } func (w *errorReturningListableWrapper) ItemType() shared.ItemType { return w.itemType } func (w *errorReturningListableWrapper) TerraformMappings() []*sdp.TerraformMapping { return nil } func (w *errorReturningListableWrapper) Category() sdp.AdapterCategory { return sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION } func (w *errorReturningListableWrapper) PotentialLinks() map[shared.ItemType]bool { return nil } func (w *errorReturningListableWrapper) AdapterMetadata() *sdp.AdapterMetadata { return nil } func (w *errorReturningListableWrapper) IAMPermissions() []string { return nil } // List returns an error to trigger the bug where pending work is not canceled func (w *errorReturningListableWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { w.callCount.Add(1) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "simulated list error", Scope: scope, SourceName: w.Name(), ItemType: w.Type(), } } // TestListErrorCausesCacheHang tests that when List() returns an error, // done() is called so that concurrent waiters are woken up // immediately rather than hanging until their context timeout. // // This test will FAIL when the bug is present because: // - Second goroutine will take ~200ms waiting for timeout // - Test expects second goroutine to complete quickly (<100ms) // // This test will PASS after the bug is fixed because: // - First goroutine calls done() on error // - Second goroutine is woken immediately and completes quickly func TestListErrorCausesCacheHang(t *testing.T) { ctx := context.Background() cache := sdpcache.NewCache(ctx) if boltCache, ok := cache.(*sdpcache.BoltCache); ok { defer func() { _ = boltCache.CloseAndDestroy() }() } scope := "test-scope" itemType := shared.NewItemType("test", "test", "test") mockWrapper := &errorReturningListableWrapper{ itemType: itemType, scope: scope, } adapter := WrapperToAdapter(mockWrapper, cache) var wg sync.WaitGroup var firstErr error var secondErr error var firstDuration time.Duration var secondDuration time.Duration // First goroutine: calls List(), gets cache miss, underlying returns error wg.Go(func() { start := time.Now() _, firstErr = adapter.(interface { List(context.Context, string, bool) ([]*sdp.Item, error) }).List(ctx, scope, false) firstDuration = time.Since(start) }) // Give first goroutine time to start and hit the error time.Sleep(50 * time.Millisecond) // Second goroutine: calls List() after first has hit error // Should be woken immediately by done() and retry quickly wg.Go(func() { // Use a timeout to prevent infinite hang if bug exists ctx2, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() start := time.Now() _, secondErr = adapter.(interface { List(context.Context, string, bool) ([]*sdp.Item, error) }).List(ctx2, scope, false) secondDuration = time.Since(start) }) wg.Wait() // Both goroutines should get errors if firstErr == nil { t.Fatal("Expected first goroutine to get an error, got nil") } if secondErr == nil { t.Fatal("Expected second goroutine to get an error, got nil") } // First goroutine should complete quickly (the List error is immediate) if firstDuration > 100*time.Millisecond { t.Errorf("First goroutine took too long: %v", firstDuration) } // CRITICAL ASSERTION: Second goroutine should complete quickly // With the bug: takes ~200ms+ waiting for timeout // With the fix: takes <100ms because done() wakes it immediately if secondDuration > 100*time.Millisecond { t.Errorf("Second goroutine took too long (%v), indicating pending work was not cancelled. "+ "Expected <100ms after done() wakes waiting goroutines.", secondDuration) t.Logf("BUG PRESENT: First goroutine returned error without calling done()") t.Logf(" First: completed in %v", firstDuration) t.Logf(" Second: hung for %v waiting on pending work timeout", secondDuration) t.Logf(" List() called %d times", mockWrapper.callCount.Load()) } // We only cache NOTFOUND; this wrapper returns QueryError_OTHER so the error is not cached. // Both goroutines call List() (callCount == 2). The important assertion is timing above: // second goroutine completes quickly because done() wakes it, then it retries and gets the same error. callCount := mockWrapper.callCount.Load() if callCount != 2 { t.Errorf("Expected List to be called twice (error is not cached), was called %d times", callCount) } t.Logf("Test results:") t.Logf(" First goroutine: %v", firstDuration) t.Logf(" Second goroutine: %v", secondDuration) t.Logf(" List() calls: %d", callCount) } // notFoundCachingWrapper returns nil/empty from Get/List/Search to test NOTFOUND caching. type notFoundCachingWrapper struct { getCallCount atomic.Int32 listCallCount atomic.Int32 searchCallCount atomic.Int32 itemType shared.ItemType scope string } func (w *notFoundCachingWrapper) Scopes() []string { return []string{w.scope} } func (w *notFoundCachingWrapper) GetLookups() ItemTypeLookups { return ItemTypeLookups{shared.NewItemTypeLookup("id", w.itemType)} } func (w *notFoundCachingWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { w.getCallCount.Add(1) return nil, nil } func (w *notFoundCachingWrapper) Type() string { return w.itemType.String() } func (w *notFoundCachingWrapper) Name() string { return "notfound-caching-adapter" } func (w *notFoundCachingWrapper) ItemType() shared.ItemType { return w.itemType } func (w *notFoundCachingWrapper) TerraformMappings() []*sdp.TerraformMapping { return nil } func (w *notFoundCachingWrapper) Category() sdp.AdapterCategory { return sdp.AdapterCategory_ADAPTER_CATEGORY_COMPUTE_APPLICATION } func (w *notFoundCachingWrapper) PotentialLinks() map[shared.ItemType]bool { return nil } func (w *notFoundCachingWrapper) AdapterMetadata() *sdp.AdapterMetadata { return nil } func (w *notFoundCachingWrapper) IAMPermissions() []string { return nil } func (w *notFoundCachingWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { w.listCallCount.Add(1) return []*sdp.Item{}, nil } func (w *notFoundCachingWrapper) SearchLookups() []ItemTypeLookups { return []ItemTypeLookups{{shared.NewItemTypeLookup("id", w.itemType)}} } func (w *notFoundCachingWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { w.searchCallCount.Add(1) return []*sdp.Item{}, nil } // TestGetNilCachesNotFound tests that when wrapper Get returns (nil, nil), the adapter // caches NOTFOUND and a second Get returns the cached error without calling the wrapper again. func TestGetNilCachesNotFound(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() scope := "test-scope" // Use AWS item type so adapter validation does not require GCP predefined role. itemType := shared.NewItemType(aws.AWS, aws.APIGateway, aws.RESTAPI) wrapper := ¬FoundCachingWrapper{itemType: itemType, scope: scope} adapter := WrapperToAdapter(wrapper, cache) // First Get: miss, wrapper returns (nil, nil), adapter caches NOTFOUND item, err := adapter.Get(ctx, scope, "query1", false) if item != nil { t.Errorf("first Get: expected nil item, got %v", item) } if err == nil { t.Fatal("first Get: expected NOTFOUND error, got nil") } var qErr *sdp.QueryError if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("first Get: expected NOTFOUND, got %v", err) } if wrapper.getCallCount.Load() != 1 { t.Errorf("first Get: expected 1 Get call, got %d", wrapper.getCallCount.Load()) } // Second Get: should hit cache, wrapper not called again item, err = adapter.Get(ctx, scope, "query1", false) if item != nil { t.Errorf("second Get: expected nil item, got %v", item) } if err == nil { t.Fatal("second Get: expected NOTFOUND error, got nil") } if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("second Get: expected NOTFOUND, got %v", err) } if wrapper.getCallCount.Load() != 1 { t.Errorf("second Get: expected still 1 Get call (cache hit), got %d", wrapper.getCallCount.Load()) } } // TestListEmptyCachesNotFound tests that when wrapper List returns ([], nil), the adapter // caches NOTFOUND and a second List returns empty from cache without calling the wrapper again. func TestListEmptyCachesNotFound(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() scope := "test-scope" // Use AWS item type so adapter validation does not require GCP predefined role. itemType := shared.NewItemType(aws.AWS, aws.APIGateway, aws.RESTAPI) wrapper := ¬FoundCachingWrapper{itemType: itemType, scope: scope} adapter := WrapperToAdapter(wrapper, cache).(interface { List(context.Context, string, bool) ([]*sdp.Item, error) }) // First List: miss, wrapper returns ([], nil), adapter caches NOTFOUND items, err := adapter.List(ctx, scope, false) if err != nil { t.Fatalf("first List: unexpected error %v", err) } if items == nil { t.Error("first List: expected non-nil empty slice, got nil") } if len(items) != 0 { t.Errorf("first List: expected 0 items, got %d", len(items)) } if wrapper.listCallCount.Load() != 1 { t.Errorf("first List: expected 1 List call, got %d", wrapper.listCallCount.Load()) } // Second List: should hit cache, wrapper not called again items, err = adapter.List(ctx, scope, false) if err != nil { t.Fatalf("second List: unexpected error %v", err) } if items == nil { t.Error("second List: expected non-nil empty slice, got nil") } if len(items) != 0 { t.Errorf("second List: expected 0 items, got %d", len(items)) } if wrapper.listCallCount.Load() != 1 { t.Errorf("second List: expected still 1 List call (cache hit), got %d", wrapper.listCallCount.Load()) } } // TestSearchEmptyCachesNotFound tests that when wrapper Search returns ([], nil), the adapter // caches NOTFOUND and a second Search returns empty from cache without calling the wrapper again. func TestSearchEmptyCachesNotFound(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() scope := "test-scope" // Use AWS item type so adapter validation does not require GCP predefined role. itemType := shared.NewItemType(aws.AWS, aws.APIGateway, aws.RESTAPI) wrapper := ¬FoundCachingWrapper{itemType: itemType, scope: scope} adapter := WrapperToAdapter(wrapper, cache).(interface { Search(context.Context, string, string, bool) ([]*sdp.Item, error) }) query := "id1" // First Search: miss, wrapper returns ([], nil), adapter caches NOTFOUND items, err := adapter.Search(ctx, scope, query, false) if err != nil { t.Fatalf("first Search: unexpected error %v", err) } if items == nil { t.Error("first Search: expected non-nil empty slice, got nil") } if len(items) != 0 { t.Errorf("first Search: expected 0 items, got %d", len(items)) } if wrapper.searchCallCount.Load() != 1 { t.Errorf("first Search: expected 1 Search call, got %d", wrapper.searchCallCount.Load()) } // Second Search: should hit cache, wrapper not called again items, err = adapter.Search(ctx, scope, query, false) if err != nil { t.Fatalf("second Search: unexpected error %v", err) } if items == nil { t.Error("second Search: expected non-nil empty slice, got nil") } if len(items) != 0 { t.Errorf("second Search: expected 0 items, got %d", len(items)) } if wrapper.searchCallCount.Load() != 1 { t.Errorf("second Search: expected still 1 Search call (cache hit), got %d", wrapper.searchCallCount.Load()) } } // TestGetNOTFOUNDCacheHitMatchesLiveNOTFOUND asserts response parity: a NOTFOUND cache hit returns // the same (item, error) as a fresh NOTFOUND — nil item and identical error type and error message. func TestGetNOTFOUNDCacheHitMatchesLiveNOTFOUND(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() scope := "test-scope" itemType := shared.NewItemType(aws.AWS, aws.APIGateway, aws.RESTAPI) wrapper := ¬FoundCachingWrapper{itemType: itemType, scope: scope} adapter := WrapperToAdapter(wrapper, cache) query := "query1" // Live NOTFOUND liveItem, liveErr := adapter.Get(ctx, scope, query, false) // Cache NOTFOUND (second call hits cache) cacheItem, cacheErr := adapter.Get(ctx, scope, query, false) // Same item: both nil if liveItem != nil || cacheItem != nil { t.Errorf("both responses must have nil item: live=%v cache=%v", liveItem, cacheItem) } // Same error semantics: both NOTFOUND with same message var liveQE, cacheQE *sdp.QueryError if !errors.As(liveErr, &liveQE) || !errors.As(cacheErr, &cacheQE) { t.Fatalf("both errors must be QueryError: live=%v cache=%v", liveErr, cacheErr) } if liveQE.GetErrorType() != sdp.QueryError_NOTFOUND || cacheQE.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("both must be NOTFOUND: live=%v cache=%v", liveQE.GetErrorType(), cacheQE.GetErrorType()) } if liveQE.GetErrorString() != cacheQE.GetErrorString() { t.Errorf("error string must match: live=%q cache=%q", liveQE.GetErrorString(), cacheQE.GetErrorString()) } } // TestListNOTFOUNDCacheHitMatchesLiveNOTFOUND asserts response parity: a NOTFOUND cache hit for List // returns the same (items, error) as a fresh not-found — empty slice and nil error. func TestListNOTFOUNDCacheHitMatchesLiveNOTFOUND(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() scope := "test-scope" itemType := shared.NewItemType(aws.AWS, aws.APIGateway, aws.RESTAPI) wrapper := ¬FoundCachingWrapper{itemType: itemType, scope: scope} adapter := WrapperToAdapter(wrapper, cache).(interface { List(context.Context, string, bool) ([]*sdp.Item, error) }) liveItems, liveErr := adapter.List(ctx, scope, false) cacheItems, cacheErr := adapter.List(ctx, scope, false) if liveErr != nil || cacheErr != nil { t.Errorf("both must return nil error: live=%v cache=%v", liveErr, cacheErr) } if liveItems == nil || cacheItems == nil { t.Errorf("both must return non-nil slice: live=%v cache=%v", liveItems, cacheItems) } if len(liveItems) != 0 || len(cacheItems) != 0 { t.Errorf("both must return empty slice: live len=%d cache len=%d", len(liveItems), len(cacheItems)) } } // TestSearchNOTFOUNDCacheHitMatchesLiveNOTFOUND asserts response parity: a NOTFOUND cache hit for Search // returns the same (items, error) as a fresh not-found — empty slice and nil error. func TestSearchNOTFOUNDCacheHitMatchesLiveNOTFOUND(t *testing.T) { ctx := context.Background() cache := sdpcache.NewMemoryCache() scope := "test-scope" itemType := shared.NewItemType(aws.AWS, aws.APIGateway, aws.RESTAPI) wrapper := ¬FoundCachingWrapper{itemType: itemType, scope: scope} adapter := WrapperToAdapter(wrapper, cache).(interface { Search(context.Context, string, string, bool) ([]*sdp.Item, error) }) query := "id1" liveItems, liveErr := adapter.Search(ctx, scope, query, false) cacheItems, cacheErr := adapter.Search(ctx, scope, query, false) if liveErr != nil || cacheErr != nil { t.Errorf("both must return nil error: live=%v cache=%v", liveErr, cacheErr) } if liveItems == nil || cacheItems == nil { t.Errorf("both must return non-nil slice: live=%v cache=%v", liveItems, cacheItems) } if len(liveItems) != 0 || len(cacheItems) != 0 { t.Errorf("both must return empty slice: live len=%d cache len=%d", len(liveItems), len(cacheItems)) } } ================================================ FILE: stdlib-source/adapters/certificate.go ================================================ package adapters import ( "context" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/pem" "errors" "fmt" "strings" "github.com/overmindtech/cli/go/sdp-go" ) // CertToName Returns the name of a cert as a string. This is in the format of: // // {Subject.CommonName} (SHA-256: {fingerprint}) func CertToName(cert *x509.Certificate) string { sum := sha256.Sum256(cert.Raw) hexString := toHex(sum[:]) return fmt.Sprintf( "%v (SHA-256: %v)", cert.Subject.CommonName, hexString, ) } // toHex converts bytes to their uppercase hex representation, separated by colons func toHex(b []byte) string { if len(b) == 0 { return "" } buf := make([]byte, 0, 3*len(b)) x := buf[1*len(b) : 3*len(b)] hex.Encode(x, b) for i := 0; i < len(x); i += 2 { buf = append(buf, x[i], x[i+1], ':') } s := strings.TrimSuffix(string(buf), ":") return strings.ToUpper(s) } // CertificateAdapter This adapter only responds to Search() requests. See the // docs for the Search() method for more info type CertificateAdapter struct{} // Type The type of items that this adapter is capable of finding func (s *CertificateAdapter) Type() string { return "certificate" } // Descriptive name for the adapter, used in logging and metadata func (s *CertificateAdapter) Name() string { return "stdlib-certificate" } func (s *CertificateAdapter) Metadata() *sdp.AdapterMetadata { return certificateMetadata } var certificateMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "Certificate", Type: "certificate", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Search: true, SearchDescription: "Takes a full certificate, or certificate bundle as input in PEM encoded format", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) // List of scopes that this adapter is capable of find items for. If the // adapter supports all scopes the special value `AllScopes` ("*") // should be used func (s *CertificateAdapter) Scopes() []string { return []string{ "global", // This is a reserved word meaning that the items should be considered globally unique } } // Get This adapter does not respond to Get() requests. The logic here is that // there are many places we might find a certificate, for example after making a // HTTP connection, sitting on disk, after making a database connection, etc. // Rather than implement a adapter that knows how to make each of these // connections, instead we have created this adapter which takes the cert itself // as an input to Search() and parses it and returns the info func (s *CertificateAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "certificate only responds to Search() requests. Consult the documentation", SourceName: s.Name(), Scope: scope, ItemType: s.Type(), } } // List Is not implemented for HTTP as this would require scanning many // endpoints or something, doesn't really make sense func (s *CertificateAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) return items, nil } // Search This method takes a full certificate, or certificate bundle as input // (in PEM encoded format), parses them, and returns a items, one for each // certificate that was found func (s *CertificateAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { var errors []error var items []*sdp.Item bundle, err := decodePem(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } // Range over all the parsed certs for _, b := range bundle.Certificate { var cert *x509.Certificate var err error var attributes *sdp.ItemAttributes cert, err = x509.ParseCertificate(b) if err != nil { errors = append(errors, err) // Skip this cert continue } attributes, err = sdp.ToAttributes(map[string]any{ "issuer": cert.Issuer.String(), "subject": cert.Subject.String(), "notBefore": cert.NotBefore.String(), "notAfter": cert.NotAfter.String(), "signatureAlgorithm": cert.SignatureAlgorithm.String(), "signature": toHex(cert.Signature), "publicKeyAlgorithm": cert.PublicKeyAlgorithm.String(), // This needs to be a string as the number could be way too large to // fit in JSON or Protobuf "serialNumber": toHex(cert.SerialNumber.Bytes()), "keyUsage": getKeyUsage(cert.KeyUsage), "extendedKeyUsage": getExtendedKeyUsage(cert.ExtKeyUsage), "version": cert.Version, "basicConstraints": map[string]any{ "CA": cert.IsCA, "pathLen": cert.MaxPathLen, }, "subjectKeyIdentifier": toHex(cert.SubjectKeyId), "authorityKeyIdentifier": toHex(cert.AuthorityKeyId), }) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } if len(cert.OCSPServer) > 0 { attributes.Set("ocspServer", strings.Join(cert.OCSPServer, ",")) } if len(cert.IssuingCertificateURL) > 0 { attributes.Set("issuingCertificateURL", strings.Join(cert.IssuingCertificateURL, ",")) } if len(cert.CRLDistributionPoints) > 0 { attributes.Set("CRLDistributionPoints", cert.CRLDistributionPoints) } if len(cert.DNSNames) > 0 { attributes.Set("dnsNames", cert.DNSNames) } if len(cert.IPAddresses) > 0 { attributes.Set("ipAddresses", cert.IPAddresses) } if len(cert.URIs) > 0 { attributes.Set("uris", cert.URIs) } if len(cert.PermittedDNSDomains) > 0 { attributes.Set("permittedDNSDomains", cert.PermittedDNSDomains) } if len(cert.ExcludedDNSDomains) > 0 { attributes.Set("excludedDNSDomains", cert.ExcludedDNSDomains) } if len(cert.PermittedIPRanges) > 0 { attributes.Set("permittedIPRanges", cert.PermittedIPRanges) } if len(cert.ExcludedIPRanges) > 0 { attributes.Set("excludedIPRanges", cert.ExcludedIPRanges) } if len(cert.PermittedEmailAddresses) > 0 { attributes.Set("permittedEmailAddresses", cert.PermittedEmailAddresses) } if len(cert.ExcludedEmailAddresses) > 0 { attributes.Set("excludedEmailAddresses", cert.ExcludedEmailAddresses) } if len(cert.PermittedURIDomains) > 0 { attributes.Set("permittedURIDomains", cert.PermittedURIDomains) } if len(cert.ExcludedURIDomains) > 0 { attributes.Set("excludedURIDomains", cert.ExcludedURIDomains) } if len(cert.PolicyIdentifiers) > 0 { objectIdentifiers := make([]string, len(cert.PolicyIdentifiers)) for i := range len(cert.PolicyIdentifiers) { objectIdentifiers[i] = cert.PolicyIdentifiers[i].String() } attributes.Set("policyIdentifiers", objectIdentifiers) } item := sdp.Item{ Type: "certificate", UniqueAttribute: "subject", Attributes: attributes, Scope: scope, } items = append(items, &item) // If not self signed, add a link to the issuer if cert.Issuer.String() != cert.Subject.String() { // Even though this adapter doesn't support Get() requests, this will // still work for linking as long as the referenced cert has been // included in the bundle since the cache will correctly return the // Get() request when it is run item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "certificate", Method: sdp.QueryMethod_GET, Query: cert.Issuer.String(), Scope: scope, }, }) } } // If all failed return an error if len(errors) == len(bundle.Certificate) { return items, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("parsing all certs failed, errors: %v", errors), Scope: scope, } } return items, nil } func decodePem(certInput string) (tls.Certificate, error) { var bundle tls.Certificate certPEMBlock := []byte(certInput) var certDERBlock *pem.Block for { certDERBlock, certPEMBlock = pem.Decode(certPEMBlock) if certDERBlock == nil { break } if certDERBlock.Type == "CERTIFICATE" { bundle.Certificate = append(bundle.Certificate, certDERBlock.Bytes) } } if len(bundle.Certificate) == 0 { return bundle, errors.New("no certificates could be parsed") } return bundle, nil } // Weight Returns the priority weighting of items returned by this adapter. // This is used to resolve conflicts where two adapters of the same type // return an item for a GET request. In this instance only one item can be // sen on, so the one with the higher weight value will win. func (s *CertificateAdapter) Weight() int { return 100 } // getKeyUsage Converts the key usage from an integer to an array of valid // usages. This is done by using a bitwise and to cover the binary number to the // usage based on its mask e.g. 000010010 (18) would be ContentCommitment and // KeyAgreement func getKeyUsage(usage x509.KeyUsage) []string { usageStrings := make([]string, 0) // Uses the same string values as openssl's // https://github.com/openssl/openssl/blob/1c0eede9827b0962f1d752fa4ab5d436fa039da4/crypto/x509/v3_bitst.c#L28-L39 if (usage & x509.KeyUsageDigitalSignature) == x509.KeyUsageDigitalSignature { usageStrings = append(usageStrings, "Digital Signature") } if (usage & x509.KeyUsageContentCommitment) == x509.KeyUsageContentCommitment { usageStrings = append(usageStrings, "Non Repudiation") } if (usage & x509.KeyUsageKeyEncipherment) == x509.KeyUsageKeyEncipherment { usageStrings = append(usageStrings, "Key Encipherment") } if (usage & x509.KeyUsageDataEncipherment) == x509.KeyUsageDataEncipherment { usageStrings = append(usageStrings, "Data Encipherment") } if (usage & x509.KeyUsageKeyAgreement) == x509.KeyUsageKeyAgreement { usageStrings = append(usageStrings, "Key Agreement") } if (usage & x509.KeyUsageCertSign) == x509.KeyUsageCertSign { usageStrings = append(usageStrings, "Certificate Sign") } if (usage & x509.KeyUsageCRLSign) == x509.KeyUsageCRLSign { usageStrings = append(usageStrings, "CRL Sign") } if (usage & x509.KeyUsageEncipherOnly) == x509.KeyUsageEncipherOnly { usageStrings = append(usageStrings, "Encipher Only") } if (usage & x509.KeyUsageDecipherOnly) == x509.KeyUsageDecipherOnly { usageStrings = append(usageStrings, "Decipher Only") } return usageStrings } // getExtendedKeyUsage Gets the list of extended usage, using the same working // as openssl does as much as possible // // See: // https://github.com/openssl/openssl/blob/b0c1214e1e82bc4c98eadd11d368b4ba9ffa202c/crypto/objects/obj_dat.h func getExtendedKeyUsage(usage []x509.ExtKeyUsage) []string { usageStrings := make([]string, 0) for _, use := range usage { switch use { case x509.ExtKeyUsageAny: usageStrings = append(usageStrings, "Any Extended Key Usage") case x509.ExtKeyUsageServerAuth: usageStrings = append(usageStrings, "TLS Web Server Authentication") case x509.ExtKeyUsageClientAuth: usageStrings = append(usageStrings, "TLS Web Client Authentication") case x509.ExtKeyUsageCodeSigning: usageStrings = append(usageStrings, "Code Signing") case x509.ExtKeyUsageEmailProtection: usageStrings = append(usageStrings, "E-mail Protection") case x509.ExtKeyUsageIPSECEndSystem: usageStrings = append(usageStrings, "IPSec End System") case x509.ExtKeyUsageIPSECTunnel: usageStrings = append(usageStrings, "IPSec Tunnel") case x509.ExtKeyUsageIPSECUser: usageStrings = append(usageStrings, "IPSec User") case x509.ExtKeyUsageTimeStamping: usageStrings = append(usageStrings, "Time Stamping") case x509.ExtKeyUsageOCSPSigning: usageStrings = append(usageStrings, "OCSP Signing") case x509.ExtKeyUsageMicrosoftServerGatedCrypto: usageStrings = append(usageStrings, "Microsoft Server Gated Crypto") case x509.ExtKeyUsageNetscapeServerGatedCrypto: usageStrings = append(usageStrings, "Netscape Server Gated Crypto") case x509.ExtKeyUsageMicrosoftCommercialCodeSigning: usageStrings = append(usageStrings, "Microsoft Commercial Code Signing") case x509.ExtKeyUsageMicrosoftKernelCodeSigning: usageStrings = append(usageStrings, "Kernel Mode Code Signing") default: usageStrings = append(usageStrings, fmt.Sprint(use)) } } return usageStrings } ================================================ FILE: stdlib-source/adapters/certificate_test.go ================================================ package adapters import ( "context" "fmt" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" ) var chain = `-----BEGIN CERTIFICATE----- MIIG5jCCBc6gAwIBAgIQAze5KDR8YKauxa2xIX84YDANBgkqhkiG9w0BAQUFADBs MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j ZSBFViBSb290IENBMB4XDTA3MTEwOTEyMDAwMFoXDTIxMTExMDAwMDAwMFowaTEL MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 LmRpZ2ljZXJ0LmNvbTEoMCYGA1UEAxMfRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug RVYgQ0EtMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPOWYth1bhn/ PzR8SU8xfg0ETpmB4rOFVZEwscCvcLssqOcYqj9495BoUoYBiJfiOwZlkKq9ZXbC 7L4QWzd4g2B1Rca9dKq2n6Q6AVAXxDlpufFP74LByvNK28yeUE9NQKM6kOeGZrzw PnYoTNF1gJ5qNRQ1A57bDIzCKK1Qss72kaPDpQpYSfZ1RGy6+c7pqzoC4E3zrOJ6 4GAiBTyC01Li85xH+DvYskuTVkq/cKs+6WjIHY9YHSpNXic9rQpZL1oRIEDZaARo LfTAhAsKG3jf7RpY3PtBWm1r8u0c7lwytlzs16YDMqbo3rcoJ1mIgP97rYlY1R4U pPKwcNSgPqcCAwEAAaOCA4UwggOBMA4GA1UdDwEB/wQEAwIBhjA7BgNVHSUENDAy BggrBgEFBQcDAQYIKwYBBQUHAwIGCCsGAQUFBwMDBggrBgEFBQcDBAYIKwYBBQUH AwgwggHEBgNVHSAEggG7MIIBtzCCAbMGCWCGSAGG/WwCATCCAaQwOgYIKwYBBQUH AgEWLmh0dHA6Ly93d3cuZGlnaWNlcnQuY29tL3NzbC1jcHMtcmVwb3NpdG9yeS5o dG0wggFkBggrBgEFBQcCAjCCAVYeggFSAEEAbgB5ACAAdQBzAGUAIABvAGYAIAB0 AGgAaQBzACAAQwBlAHIAdABpAGYAaQBjAGEAdABlACAAYwBvAG4AcwB0AGkAdAB1 AHQAZQBzACAAYQBjAGMAZQBwAHQAYQBuAGMAZQAgAG8AZgAgAHQAaABlACAARABp AGcAaQBDAGUAcgB0ACAARQBWACAAQwBQAFMAIABhAG4AZAAgAHQAaABlACAAUgBl AGwAeQBpAG4AZwAgAFAAYQByAHQAeQAgAEEAZwByAGUAZQBtAGUAbgB0ACAAdwBo AGkAYwBoACAAbABpAG0AaQB0ACAAbABpAGEAYgBpAGwAaQB0AHkAIABhAG4AZAAg AGEAcgBlACAAaQBuAGMAbwByAHAAbwByAGEAdABlAGQAIABoAGUAcgBlAGkAbgAg AGIAeQAgAHIAZQBmAGUAcgBlAG4AYwBlAC4wEgYDVR0TAQH/BAgwBgEB/wIBADCB gwYIKwYBBQUHAQEEdzB1MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2Vy dC5jb20wTQYIKwYBBQUHMAKGQWh0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NBQ2Vy dHMvRGlnaUNlcnRIaWdoQXNzdXJhbmNlRVZSb290Q0EuY3J0MIGPBgNVHR8EgYcw gYQwQKA+oDyGOmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEhpZ2hB c3N1cmFuY2VFVlJvb3RDQS5jcmwwQKA+oDyGOmh0dHA6Ly9jcmw0LmRpZ2ljZXJ0 LmNvbS9EaWdpQ2VydEhpZ2hBc3N1cmFuY2VFVlJvb3RDQS5jcmwwHQYDVR0OBBYE FExYyyXwQU9S9CjIgUObpqig5pLlMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSYJhoI Au9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQBMeheHKF0XvLIyc7/NLvVYMR3wsXFU nNabZ5PbLwM+Fm8eA8lThKNWYB54lBuiqG+jpItSkdfdXJW777UWSemlQk808kf/ roF/E1S3IMRwFcuBCoHLdFfcnN8kpCkMGPAc5K4HM+zxST5Vz25PDVR708noFUjU xbvcNRx3RQdIRYW9135TuMAW2ZXNi419yWBP0aKb49Aw1rRzNubS+QOy46T15bg+ BEkAui6mSnKDcp33C4ypieez12Qf1uNgywPE3IjpnSUBAHHLA7QpYCWP+UbRe3Gu zVMSW4SOwg/H7ZMZ2cn6j1g0djIvruFQFGHUqFijyDATI+/GJYw2jxyA -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm +9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep +OkuE6N36B9K -----END CERTIFICATE-----` func TestCertificateGet(t *testing.T) { src := CertificateAdapter{} _, err := src.Get(context.Background(), "global", "foo", false) if err == nil { t.Error("expected error but got none") } } func TestCertificateList(t *testing.T) { src := CertificateAdapter{} items, err := src.List(context.Background(), "global", false) if err != nil { t.Error(err) } if len(items) != 0 { t.Errorf("expected no items, got %v", items) } } type CertTest struct { Attribute string Expected any } func (c *CertTest) Run(t *testing.T, cert *sdp.Item) { t.Run(fmt.Sprintf("Validating %v", c.Attribute), func(t *testing.T) { if x, err := cert.GetAttributes().Get(c.Attribute); err == nil { if fmt.Sprint(x) != fmt.Sprint(c.Expected) { t.Errorf("%v mismatch, expected %v, got %v", c.Attribute, c.Expected, x) } } else { t.Errorf("expected to find cert has a %v", c.Attribute) } }) discovery.TestValidateItem(t, cert) } func TestCertificateSearch(t *testing.T) { src := CertificateAdapter{} certs, err := src.Search(context.Background(), "global", chain, false) if err != nil { t.Fatal(err) } tests := []CertTest{ { Attribute: "subject", Expected: "CN=DigiCert High Assurance EV CA-1,OU=www.digicert.com,O=DigiCert Inc,C=US", }, { Attribute: "issuer", Expected: "CN=DigiCert High Assurance EV Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US", }, { Attribute: "signatureAlgorithm", Expected: "SHA1-RSA", }, { Attribute: "issuingCertificateURL", Expected: "http://www.digicert.com/CACerts/DigiCertHighAssuranceEVRootCA.crt", }, { Attribute: "policyIdentifiers", Expected: []string{ "2.16.840.1.114412.2.1", }, }, { Attribute: "extendedKeyUsage", Expected: []string{ "TLS Web Server Authentication", "TLS Web Client Authentication", "Code Signing", "E-mail Protection", "Time Stamping", }, }, { Attribute: "subjectKeyIdentifier", Expected: "4C:58:CB:25:F0:41:4F:52:F4:28:C8:81:43:9B:A6:A8:A0:E6:92:E5", }, { Attribute: "authorityKeyIdentifier", Expected: "B1:3E:C3:69:03:F8:BF:47:01:D4:98:26:1A:08:02:EF:63:64:2B:C3", }, { Attribute: "CRLDistributionPoints", Expected: []string{ "http://crl3.digicert.com/DigiCertHighAssuranceEVRootCA.crl", "http://crl4.digicert.com/DigiCertHighAssuranceEVRootCA.crl", }, }, { Attribute: "ocspServer", Expected: "http://ocsp.digicert.com", }, { Attribute: "notBefore", Expected: "2007-11-09 12:00:00 +0000 UTC", }, { Attribute: "publicKeyAlgorithm", Expected: "RSA", }, { Attribute: "basicConstraints", Expected: map[string]any{ "pathLen": float64(0), "CA": true, }, }, { Attribute: "version", Expected: float64(3), }, { Attribute: "serialNumber", Expected: "03:37:B9:28:34:7C:60:A6:AE:C5:AD:B1:21:7F:38:60", }, { Attribute: "notAfter", Expected: "2021-11-10 00:00:00 +0000 UTC", }, { Attribute: "keyUsage", Expected: []string{ "Digital Signature", "Certificate Sign", "CRL Sign", }, }, } for _, test := range tests { test.Run(t, certs[0]) } } ================================================ FILE: stdlib-source/adapters/dns.go ================================================ package adapters import ( "context" "errors" "fmt" "net" "sort" "strings" "time" "github.com/cenkalti/backoff/v5" "github.com/miekg/dns" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // DNSAdapter struct on which all methods are registered type DNSAdapter struct { // List of DNS server to use in order ot preference. They should be in the // format "ip:port" Servers []string // Whether to perform reverse lookups on IP addresses ReverseLookup bool client dns.Client cache sdpcache.Cache // This is mandatory } // NewDNSAdapterForHealthCheck creates a DNSAdapter with a NoOpCache for use in health checks. // This is useful when you need a DNSAdapter but don't need caching functionality. func NewDNSAdapterForHealthCheck() *DNSAdapter { return &DNSAdapter{ cache: sdpcache.NewNoOpCache(), } } const dnsCacheDuration = 5 * time.Minute // maxOperationTimeout is the maximum time any single DNS Get/Search operation can take. // This prevents slow DNS queries from degrading overall system performance. const maxOperationTimeout = 30 * time.Second var DefaultServers = []string{ "169.254.169.253:53", // Route 53 default resolver. See https://docs.aws.amazon.com/vpc/latest/userguide/AmazonDNS-concepts.html#AmazonDNS "1.1.1.1:53", "8.8.8.8:53", "8.8.4.4:53", } const ( ItemType = "dns" UniqueAttribute = "name" ) var ErrNoServersAvailable = errors.New("no dns servers available") // Type is the type of items that this returns func (d *DNSAdapter) Type() string { return "dns" } // Name Returns the name of the backend func (d *DNSAdapter) Name() string { return "stdlib-dns" } // Weighting of duplicate adapters func (d *DNSAdapter) Weight() int { return 100 } func (d *DNSAdapter) GetServers() []string { if len(d.Servers) == 0 { return DefaultServers } return d.Servers } func (d *DNSAdapter) Metadata() *sdp.AdapterMetadata { return dnsMetadata } var dnsMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "DNS Entry", Type: "dns", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "A DNS A or AAAA entry to look up", SearchDescription: "A DNS name (or IP for reverse DNS), this will perform a recursive search and return all results. It is recommended that you always use the SEARCH method", }, PotentialLinks: []string{"dns", "ip", "rdap-domain"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) // List of scopes that this adapter is capable of find items for func (d *DNSAdapter) Scopes() []string { return []string{ // DNS entries *should* be globally unique "global", } } // Get retrieves a single DNS item by name. // The operation is capped at maxOperationTimeout (30s) regardless of the caller's context deadline. func (d *DNSAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { // Enforce maximum timeout for this operation ctx, cancel := context.WithTimeout(ctx, maxOperationTimeout) defer cancel() if scope != "global" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "DNS queries only supported in global scope", Scope: scope, SourceName: d.Name(), ItemType: d.Type(), } } // Check for IP addresses and do nothing if net.ParseIP(query) != nil { return &sdp.Item{}, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%v is already an IP address, no DNS entry will be found", query), SourceName: d.Name(), Scope: scope, ItemType: d.Type(), } } var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() cacheHit, ck, cachedItems, qErr, done = d.cache.Lookup(ctx, d.Name(), sdp.QueryMethod_GET, scope, d.Type(), query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { if len(cachedItems) > 0 { return cachedItems[0], nil } else { return nil, nil } } // This won't work for CNAMEs since the linked query logic needs to be // different and we're only querying for A and AAAA. Realistically people // should be using Search() now anyway items, err := d.MakeQuery(ctx, query) if err != nil { // makeQueryImpl returns NOTFOUND when no A/AAAA records exist; cache it to avoid repeated lookups var qe *sdp.QueryError if errors.As(err, &qe) && qe.GetErrorType() == sdp.QueryError_NOTFOUND { d.cache.StoreUnavailableItem(ctx, qe, dnsCacheDuration, ck) } return nil, err } if len(items) == 0 { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no DNS records found", Scope: scope, SourceName: d.Name(), ItemType: d.Type(), ResponderName: d.Name(), } d.cache.StoreUnavailableItem(ctx, notFoundErr, dnsCacheDuration, ck) return nil, notFoundErr } d.cache.StoreItem(ctx, items[0], dnsCacheDuration, ck) return items[0], nil } // List calls back to the ListFunction to find all items func (d *DNSAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "global" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "DNS queries only supported in global scope", Scope: scope, SourceName: d.Name(), ItemType: d.Type(), } } return make([]*sdp.Item, 0), nil } type DNSRecord struct { Name string Target string Type string } // Search performs a DNS lookup for a name or reverse lookup for an IP. // The operation is capped at maxOperationTimeout (30s) regardless of the caller's context deadline. func (d *DNSAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { // Enforce maximum timeout for this operation ctx, cancel := context.WithTimeout(ctx, maxOperationTimeout) defer cancel() if scope != "global" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "DNS queries only supported in global scope", Scope: scope, SourceName: d.Name(), ItemType: d.Type(), } } // Check cache before making query var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() if !ignoreCache { cacheHit, _, cachedItems, qErr, done = d.cache.Lookup(ctx, d.Name(), sdp.QueryMethod_SEARCH, scope, d.Type(), query, ignoreCache) defer done() if qErr != nil { // Cached NOTFOUND: return same (nil, error) as fresh lookup for consistency return nil, qErr } if cacheHit { return cachedItems, nil } ck = sdpcache.CacheKeyFromParts(d.Name(), sdp.QueryMethod_SEARCH, scope, d.Type(), query) } else { ck = sdpcache.CacheKeyFromParts(d.Name(), sdp.QueryMethod_SEARCH, scope, d.Type(), query) } if net.ParseIP(query) != nil { if d.ReverseLookup { // If it's an IP then we want to run a reverse lookup items, err := d.MakeReverseQuery(ctx, query) if err != nil { // Only cache NOTFOUND to avoid repeated lookups; do not cache transient errors (e.g. timeouts). var qe *sdp.QueryError if errors.As(err, &qe) && qe.GetErrorType() == sdp.QueryError_NOTFOUND { d.cache.StoreUnavailableItem(ctx, err, dnsCacheDuration, ck) } return nil, err } if len(items) == 0 { // Cache NOTFOUND for empty results; return (nil, error) so cache hit returns same as fresh. notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no reverse DNS records found", Scope: "global", SourceName: d.Name(), ItemType: d.Type(), ResponderName: d.Name(), } d.cache.StoreUnavailableItem(ctx, notFoundErr, dnsCacheDuration, ck) return nil, notFoundErr } for _, item := range items { d.cache.StoreItem(ctx, item, dnsCacheDuration, ck) } return items, nil } else { // If disabled, return nothing. This does not need caching, as no // lookups are performed. return []*sdp.Item{}, nil } } items, err := d.MakeQuery(ctx, query) if err != nil { // Only cache NOTFOUND to avoid repeated lookups; return (nil, error) so cache hit returns same as fresh. var qe *sdp.QueryError if errors.As(err, &qe) && qe.GetErrorType() == sdp.QueryError_NOTFOUND { d.cache.StoreUnavailableItem(ctx, err, dnsCacheDuration, ck) } return nil, err } // MakeQuery never returns (nil, 0 items): makeQueryImpl returns NOTFOUND when there are no A/AAAA answers, and when there are answers it only groups CNAME/A/AAAA so at least one item is produced. for _, item := range items { d.cache.StoreItem(ctx, item, dnsCacheDuration, ck) } return items, nil } // retryDNSQuery handles retrying DNS queries with backoff and server rotation func (d *DNSAdapter) retryDNSQuery(ctx context.Context, queryFn func(context.Context, string) ([]*sdp.Item, error)) ([]*sdp.Item, error) { b := backoff.NewExponentialBackOff() b.InitialInterval = 100 * time.Millisecond b.MaxInterval = 500 * time.Millisecond var items []*sdp.Item var i int var server string operation := func() (any, error) { if i >= len(d.GetServers()) { i = 0 } ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() server = d.GetServers()[i] var err error items, err = queryFn(ctx, server) if err != nil { i++ // Move to next server on error if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "temporary failure") { return nil, err // Retry on timeout } return nil, backoff.Permanent(err) } return nil, nil } _, err := backoff.Retry(ctx, operation, backoff.WithBackOff(b), backoff.WithMaxElapsedTime(30*time.Second), ) span := trace.SpanFromContext(ctx) span.SetAttributes( attribute.String("ovm.dns.server", server), ) if err != nil { return nil, err } return items, nil } // Updated MakeQuery func (d *DNSAdapter) MakeQuery(ctx context.Context, query string) ([]*sdp.Item, error) { return d.retryDNSQuery(ctx, func(ctx context.Context, server string) ([]*sdp.Item, error) { return d.makeQueryImpl(ctx, query, server) }) } // Updated MakeReverseQuery func (d *DNSAdapter) MakeReverseQuery(ctx context.Context, query string) ([]*sdp.Item, error) { return d.retryDNSQuery(ctx, func(ctx context.Context, server string) ([]*sdp.Item, error) { return d.makeReverseQueryImpl(ctx, query, server) }) } func (d *DNSAdapter) makeReverseQueryImpl(ctx context.Context, query string, server string) ([]*sdp.Item, error) { arpa, err := dns.ReverseAddr(query) if err != nil { return nil, err } // Create the query msg := dns.Msg{ Question: []dns.Question{ { Name: arpa, Qclass: dns.ClassINET, Qtype: dns.TypePTR, }, }, MsgHdr: dns.MsgHdr{ Opcode: dns.OpcodeQuery, RecursionDesired: true, }, } r, _, err := d.client.ExchangeContext(ctx, &msg, server) if err != nil { return nil, err } items := make([]*sdp.Item, 0) for _, rr := range r.Answer { if ptr, ok := rr.(*dns.PTR); ok { newItems, err := d.MakeQuery(ctx, ptr.Ptr) if err != nil { return nil, err } items = append(items, newItems...) } } return items, nil } // trimDnsSuffix Trims the trailing dot from a name to make it more user friendly func trimDnsSuffix(name string) string { if strings.HasSuffix(name, ".") { return name[:len(name)-1] } return name } func (d *DNSAdapter) makeQueryImpl(ctx context.Context, query string, server string) ([]*sdp.Item, error) { // Create the query msg := dns.Msg{ Question: []dns.Question{ { Name: dns.Fqdn(query), Qclass: dns.ClassINET, Qtype: dns.TypeA, }, }, MsgHdr: dns.MsgHdr{ Opcode: dns.OpcodeQuery, RecursionDesired: true, }, } r, _, err := d.client.ExchangeContext(ctx, &msg, server) if err != nil { return nil, err } // Also query for AAAA msg.Question[0].Qtype = dns.TypeAAAA r2, _, err := d.client.ExchangeContext(ctx, &msg, server) if err != nil { return nil, err } answers := make([]dns.RR, 0) answers = append(answers, r.Answer...) answers = append(answers, r2.Answer...) if len(answers) == 0 { // This means nothing was found return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no A or AAAA records found", Scope: "global", SourceName: d.Name(), ItemType: d.Type(), } } ag := GroupAnswers(answers) items := make([]*sdp.Item, 0) var item *sdp.Item var attrs *sdp.ItemAttributes // Iterate over the groups and convert for _, r := range ag.CNAME { if cname, ok := r.(*dns.CNAME); ok { // Strip trailing dot as while it's *technically* correct, it's // annoying to have to deal with name := trimDnsSuffix(cname.Hdr.Name) target := trimDnsSuffix(cname.Target) attrs, err = sdp.ToAttributes(map[string]any{ "name": name, "type": "CNAME", "ttl": cname.Hdr.Ttl, "target": target, }) if err != nil { return nil, err } item = &sdp.Item{ Type: ItemType, UniqueAttribute: UniqueAttribute, Scope: "global", Attributes: attrs, // TODO(LIQs): update this method to return the data as edges; fixup all callers LinkedItems: []*sdp.LinkedItem{ { Item: &sdp.Reference{ Type: ItemType, UniqueAttributeValue: target, Scope: "global", }, }, }, LinkedItemQueries: []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "rdap-domain", Method: sdp.QueryMethod_SEARCH, Query: name, Scope: "global", }, }, }, } items = append(items, item) } } // Convert A & AAAA records by group for name, rs := range ag.Address { // Strip trailing dot as while it's *technically* correct, it's // annoying to have to deal with name = trimDnsSuffix(name) item, err := AToItem(name, rs) if err != nil { return nil, err } items = append(items, item) } return items, nil } type AnswerGroup struct { CNAME map[string]dns.RR Address map[string][]dns.RR } // GroupAnswers Groups the DNS answers so they they can be turned into // individual items. This is required because some types (such as A records) can // return man records for the same name and these need to be grouped to avoid // duplicate items func GroupAnswers(answers []dns.RR) *AnswerGroup { ag := AnswerGroup{ CNAME: make(map[string]dns.RR), Address: make(map[string][]dns.RR), } for _, answer := range answers { if hdr := answer.Header(); hdr != nil { switch hdr.Rrtype { case dns.TypeCNAME: // We should only get one CNAME per request, but since we have // done A and AAAA requests we could have duplicates, use a map // to avoid this ag.CNAME[hdr.Name] = answer case dns.TypeA, dns.TypeAAAA: // Create the map entry if required if _, ok := ag.Address[hdr.Name]; !ok { ag.Address[hdr.Name] = make([]dns.RR, 0) } ag.Address[hdr.Name] = append(ag.Address[hdr.Name], answer) } } } return &ag } // AToItem Converts a set of A or AAAA records to an item func AToItem(name string, records []dns.RR) (*sdp.Item, error) { recordAttrs := make([]map[string]any, 0) liq := make([]*sdp.LinkedItemQuery, 0) for _, r := range records { if hdr := r.Header(); hdr != nil { var ip net.IP var typ string if a, ok := r.(*dns.A); ok { typ = "A" ip = a.A } else if aaaa, ok := r.(*dns.AAAA); ok { typ = "AAAA" ip = aaaa.AAAA } recordAttrs = append(recordAttrs, map[string]any{ "ttl": hdr.Ttl, "type": typ, "ip": ip.String(), }) liq = append(liq, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: ip.String(), Scope: "global", }, }) } } // Sort records to ensure they are consistent sort.Slice(recordAttrs, func(i, j int) bool { return fmt.Sprint(i) < fmt.Sprint(j) }) attrs, err := sdp.ToAttributes(map[string]any{ "name": name, "type": "address", "records": recordAttrs, }) if err != nil { return nil, err } item := sdp.Item{ Type: ItemType, UniqueAttribute: UniqueAttribute, Scope: "global", Attributes: attrs, LinkedItemQueries: liq, } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-domain", Method: sdp.QueryMethod_SEARCH, Query: name, Scope: "global", }, }) return &item, nil } ================================================ FILE: stdlib-source/adapters/dns_test.go ================================================ package adapters import ( "context" "errors" "net" "testing" "time" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestSearch(t *testing.T) { t.Parallel() s := DNSAdapter{ cache: sdpcache.NewNoOpCache(), Servers: []string{ "1.1.1.1:53", "8.8.8.8:53", }, } t.Run("with a bad DNS name", func(t *testing.T) { _, err := s.Search(context.Background(), "global", "not.real.overmind.tech", false) if err == nil { t.Error("expected error for non-existent name") } var qe *sdp.QueryError if !errors.As(err, &qe) || qe.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected NOTFOUND error, got %v", err) } }) t.Run("with one.one.one.one", func(t *testing.T) { items, err := s.Search(context.Background(), "global", "one.one.one.one", false) if err != nil { t.Error(err) } if len(items) != 1 { t.Errorf("expected 1 item, got %v", len(items)) } // Make sure 1.1.1.1 is in there var foundV4 bool var foundV6 bool for _, item := range items { for _, q := range item.GetLinkedItemQueries() { if q.GetQuery().GetQuery() == "1.1.1.1" { foundV4 = true } if q.GetQuery().GetQuery() == "2606:4700:4700::1111" { foundV6 = true } } } if !foundV4 { t.Error("could not find 1.1.1.1 in linked item queries") } if !foundV6 { t.Error("could not find 2606:4700:4700::1111 in linked item queries") } discovery.TestValidateItems(t, items) }) t.Run("Search returns same NOTFOUND for first and second call", func(t *testing.T) { // First call (fresh NOTFOUND) and second call (cached NOTFOUND) must return the same: nil items, same error cache := sdpcache.NewMemoryCache() cachedSrc := DNSAdapter{cache: cache, Servers: s.Servers} query := "not.real.overmind.tech" first, err1 := cachedSrc.Search(context.Background(), "global", query, false) if err1 == nil { t.Fatal("first Search: expected NOTFOUND error, got nil") } if first != nil { t.Errorf("first Search: expected nil items, got len=%d", len(first)) } var qe *sdp.QueryError if !errors.As(err1, &qe) || qe.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("first Search: expected NOTFOUND, got %v", err1) } firstErrStr := err1.Error() second, err2 := cachedSrc.Search(context.Background(), "global", query, false) if err2 == nil { t.Fatal("second Search: expected NOTFOUND error, got nil") } if second != nil { t.Errorf("second Search: expected nil items, got len=%d", len(second)) } if !errors.As(err2, &qe) || qe.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("second Search: expected NOTFOUND, got %v", err2) } if err2.Error() != firstErrStr { t.Errorf("first and second Search must return same error message: first %q, second %q", firstErrStr, err2.Error()) } }) t.Run("with an IP and therefore reverse DNS", func(t *testing.T) { s.ReverseLookup = true items, err := s.Search(context.Background(), "global", "1.1.1.1", false) if err != nil { t.Error(err) } // Make sure 1.1.1.1 is in there var foundV4 bool var foundV6 bool for _, item := range items { for _, q := range item.GetLinkedItemQueries() { if q.GetQuery().GetQuery() == "1.1.1.1" { foundV4 = true } if q.GetQuery().GetQuery() == "2606:4700:4700::1111" { foundV6 = true } } } if !foundV4 { t.Error("could not find 1.1.1.1 in linked item queries") } if !foundV6 { t.Error("could not find 2606:4700:4700::1111 in linked item queries") } discovery.TestValidateItems(t, items) }) } func TestDnsGet(t *testing.T) { t.Parallel() var conn net.Conn var err error // Check that we actually have an internet connection, if not there is not // point running this test dialer := &net.Dialer{} conn, err = dialer.DialContext(t.Context(), "tcp", "one.one.one.one:443") conn.Close() if err != nil { t.Skip("No internet connection detected") } src := DNSAdapter{ cache: sdpcache.NewNoOpCache(), } t.Run("working request", func(t *testing.T) { item, err := src.Get(context.Background(), "global", "one.one.one.one", false) if err != nil { t.Fatal(err) } discovery.TestValidateItem(t, item) }) t.Run("bad dns entry", func(t *testing.T) { _, err := src.Get(context.Background(), "global", "something.does.not.exist.please.testing", false) if err == nil { t.Error("expected error but got nil") } var e *sdp.QueryError if !errors.As(err, &e) { t.Errorf("expected error type to be *sdp.QueryError, got %T", err) } }) t.Run("GET returns NOTFOUND when cache has NOTFOUND", func(t *testing.T) { cache := sdpcache.NewMemoryCache() cachedSrc := DNSAdapter{cache: cache} query := "cached.notfound.get.example" // Pre-seed cache with NOTFOUND (simulates a previous Get that got 0 records) ck := sdpcache.CacheKeyFromParts(cachedSrc.Name(), sdp.QueryMethod_GET, "global", cachedSrc.Type(), query) notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "no DNS records found", Scope: "global", SourceName: cachedSrc.Name(), ItemType: cachedSrc.Type(), } cache.StoreUnavailableItem(context.Background(), notFoundErr, dnsCacheDuration, ck) // Get should return cached NOTFOUND without doing a DNS lookup item, err := cachedSrc.Get(context.Background(), "global", query, false) if item != nil { t.Errorf("expected nil item, got %v", item) } if err == nil { t.Fatal("expected NOTFOUND error, got nil") } var qErr *sdp.QueryError if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected NOTFOUND, got %v", err) } // Second Get: should still return cached NOTFOUND (same response as first) firstErrStr := err.Error() item, err = cachedSrc.Get(context.Background(), "global", query, false) if item != nil { t.Errorf("second Get: expected nil item, got %v", item) } if err == nil { t.Fatal("second Get: expected NOTFOUND error, got nil") } if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("second Get: expected NOTFOUND, got %v", err) } if err.Error() != firstErrStr { t.Errorf("first and second Get must return same error message: first %q, second %q", firstErrStr, err.Error()) } }) t.Run("bad scope", func(t *testing.T) { _, err := src.Get(context.Background(), "something.local.test", "something.does.not.exist.please.testing", false) if err == nil { t.Error("expected error but got nil") } var e *sdp.QueryError if !errors.As(err, &e) { t.Errorf("expected error type to be *sdp.QueryError, got %T", err) } }) t.Run("with a CNAME", func(t *testing.T) { // When we do a Get on a CNAME, I wan it to work, but only return the // first thing item, err := src.Get(context.Background(), "global", "www.github.com", false) if err != nil { t.Fatal(err) } target := item.GetAttributes().GetAttrStruct().GetFields()["target"].GetStringValue() if target != "github.com" { t.Errorf("expected target to be github.com, got %v", target) } t.Log(item) }) } // TestGetTimeout verifies that Get enforces the maximum timeout by checking // that the adapter's timeout takes precedence over a longer caller timeout func TestGetTimeout(t *testing.T) { if testing.Short() { t.Skip("Skipping timeout test in short mode") } src := DNSAdapter{ cache: sdpcache.NewNoOpCache(), // Use a non-existent DNS server to force timeout Servers: []string{"192.0.2.1:53"}, // TEST-NET-1, guaranteed to be unroutable } // Create a context with a very long deadline to verify adapter's internal timeout takes precedence ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() start := time.Now() _, err := src.Get(ctx, "global", "test.example.com", false) elapsed := time.Since(start) // The operation should fail (no response from DNS server) if err == nil { t.Error("expected error but got nil") } // The operation should complete around the maxOperationTimeout (30s), not the caller's 10 minutes // Allow generous buffer for CI variance and different network behaviors if elapsed > 35*time.Second { t.Errorf("Get took %v, expected around 30s (max 35s for variance), timeout may not be properly enforced", elapsed) } // Don't assert minimum duration as TEST-NET may fail fast in some environments // The key assertion is that it completes in ~30s, not 10 minutes } // TestSearchTimeoutContext verifies that Search properly wraps the context with a timeout func TestSearchTimeoutContext(t *testing.T) { t.Parallel() src := DNSAdapter{ cache: sdpcache.NewNoOpCache(), } // Create a context with a very long deadline to ensure Search creates its own timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() // Use a valid, fast DNS query to verify the timeout wrapper doesn't break normal operation items, err := src.Search(ctx, "global", "one.one.one.one", false) // Should succeed with the fast query if err != nil { t.Errorf("expected no error for valid query, got: %v", err) } // Should return at least one item for this known DNS name if len(items) == 0 { t.Error("expected at least one DNS item for one.one.one.one") } } // TestListBehavior verifies that List returns an empty slice without making DNS queries func TestListBehavior(t *testing.T) { t.Parallel() src := DNSAdapter{ cache: sdpcache.NewNoOpCache(), } ctx := context.Background() // List should return an empty slice without making any DNS queries items, err := src.List(ctx, "global", false) // List should succeed with empty results if err != nil { t.Errorf("expected no error but got: %v", err) } if len(items) != 0 { t.Errorf("expected empty list, got %d items", len(items)) } } // TestTimeoutShorterThanCaller verifies that a short caller timeout is respected func TestTimeoutShorterThanCaller(t *testing.T) { t.Parallel() src := DNSAdapter{ cache: sdpcache.NewNoOpCache(), // Use a non-existent DNS server to force timeout Servers: []string{"192.0.2.1:53"}, // TEST-NET-1, guaranteed to be unroutable } // Create a context with a 2s deadline (shorter than the adapter's 30s max) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() start := time.Now() _, err := src.Get(ctx, "global", "test.example.com", false) elapsed := time.Since(start) // The operation should fail (no response from DNS server) if err == nil { t.Error("expected error but got nil") } // The operation should complete in roughly 2 seconds (the caller's timeout), not 30s // Allow some buffer for processing time (4s max) if elapsed > 4*time.Second { t.Errorf("Get took %v, expected around 2s (max 4s)", elapsed) } // Verify it's a context deadline exceeded error if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("expected context.DeadlineExceeded error, got: %v", err) } } ================================================ FILE: stdlib-source/adapters/http.go ================================================ package adapters import ( "context" "crypto/tls" "encoding/pem" "fmt" "net" "net/http" "net/url" "runtime" "strings" "time" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "google.golang.org/protobuf/types/known/structpb" ) const USER_AGENT_VERSION = "0.1" // linkLocalRange represents the IPv4 link-local address range (169.254.0.0/16) // This includes the EC2 metadata service IP (169.254.169.254) and is blocked // to prevent DNS rebinding attacks and unauthorized metadata service access. var linkLocalRange = &net.IPNet{ IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32), } // isLinkLocalIP checks if an IP address is in the link-local range (169.254.0.0/16) func isLinkLocalIP(ip net.IP) bool { if ip == nil { return false } // Convert IPv4-mapped IPv6 addresses to IPv4 ip = ip.To4() if ip == nil { return false } return linkLocalRange.Contains(ip) } // validateHostname checks if a hostname resolves to a link-local IP address. // This prevents DNS rebinding attacks where a hostname resolves to the EC2 // metadata service or other link-local addresses. func validateHostname(ctx context.Context, hostname string) error { // First check if the hostname is already an IP address if ip := net.ParseIP(hostname); ip != nil { if isLinkLocalIP(ip) { return fmt.Errorf("access to link-local address range (169.254.0.0/16) is blocked for security reasons") } return nil } // Resolve the hostname to check if it resolves to a link-local IP resolver := net.DefaultResolver ips, err := resolver.LookupIPAddr(ctx, hostname) if err != nil { // If DNS resolution fails, we can't validate, but we should still // allow the request to proceed (it will fail later if needed) // This prevents blocking legitimate requests due to transient DNS issues //nolint:nilerr // Intentionally allowing request to proceed if DNS resolution fails return nil } // Check all resolved IPs for _, ipAddr := range ips { if isLinkLocalIP(ipAddr.IP) { return fmt.Errorf("hostname %s resolves to link-local address %s (169.254.0.0/16), which is blocked for security reasons", hostname, ipAddr.IP) } } return nil } type HTTPAdapter struct { cache sdpcache.Cache // This is mandatory } const httpCacheDuration = 5 * time.Minute // Type The type of items that this adapter is capable of finding func (s *HTTPAdapter) Type() string { return "http" } // Descriptive name for the adapter, used in logging and metadata func (s *HTTPAdapter) Name() string { return "stdlib-http" } // Metadata Returns metadata about the adapter func (s *HTTPAdapter) Metadata() *sdp.AdapterMetadata { return httpMetadata } var httpMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "HTTP Endpoint", Type: "http", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "A HTTP endpoint to run a `HEAD` request against", Search: true, SearchDescription: "A HTTP URL to search for. Query parameters and fragments will be stripped from the URL before processing.", }, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, PotentialLinks: []string{"ip", "dns", "certificate", "http"}, }) // List of scopes that this adapter is capable of find items for. If the // adapter supports all scopes the special value `AllScopes` ("*") // should be used func (s *HTTPAdapter) Scopes() []string { return []string{ "global", // This is a reserved word meaning that the items should be considered globally unique } } // Get Get a single item with a given scope and query. The item returned // should have a UniqueAttributeValue that matches the `query` parameter. The // ctx parameter contains a golang Context object which should be used to allow // this adapter to timeout or be cancelled when executing potentially // long-running actions func (s *HTTPAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "global" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "http is only supported in the 'global' scope", Scope: scope, } } // Validate that the URL doesn't contain query parameters or fragments parsedURL, err := url.Parse(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("invalid URL: %v", err), Scope: scope, } } if parsedURL.RawQuery != "" || parsedURL.Fragment != "" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: "GET method requires clean URLs without query parameters or fragments. Use SEARCH method for URLs with query parameters or fragments.", Scope: scope, } } // Validate hostname to prevent access to link-local addresses (including EC2 metadata service) hostname := parsedURL.Hostname() if hostname != "" { if err := validateHostname(ctx, hostname); err != nil { ck := sdpcache.CacheKeyFromParts(s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query) err = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } s.cache.StoreUnavailableItem(ctx, err, httpCacheDuration, ck) return nil, err } } var cacheHit bool var ck sdpcache.CacheKey var cachedItems []*sdp.Item var qErr *sdp.QueryError var done func() cacheHit, ck, cachedItems, qErr, done = s.cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if qErr != nil { return nil, qErr } if cacheHit { // Get only caches a single item or NOTFOUND (via StoreUnavailableItem). Guard against empty slice for defensive safety (e.g. cache corruption). if len(cachedItems) > 0 { return cachedItems[0], nil } return nil, nil } // Create a client that skips TLS verification since we will want to get the // details of the TLS connection rather than stop if it's not trusted. Since // we are only running a HEAD request this is unlikely to be a problem tr := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, //nolint:gosec // G402 (TLS skip verify): intentional—adapter inspects TLS certificate details via HEAD request, not trusting the content }, } client := &http.Client{ Transport: tr, // Don't follow redirects, just return the status code directly CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } req, err := http.NewRequestWithContext(ctx, http.MethodHead, query, http.NoBody) if err != nil { err = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } s.cache.StoreUnavailableItem(ctx, err, httpCacheDuration, ck) return nil, err } req.Header.Add("User-Agent", fmt.Sprintf("Overmind/%v (%v/%v)", USER_AGENT_VERSION, runtime.GOOS, runtime.GOARCH)) req.Header.Add("Accept", "*/*") var res *http.Response res, err = client.Do(req) if err != nil { err = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } s.cache.StoreUnavailableItem(ctx, err, httpCacheDuration, ck) return nil, err } // Clean up connections once we're done defer client.CloseIdleConnections() defer res.Body.Close() // Treat HTTP 404 and 410 as not-found; cache to avoid repeated requests. if res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone { notFoundErr := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("HTTP %s for %s", res.Status, query), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), ResponderName: s.Name(), } s.cache.StoreUnavailableItem(ctx, notFoundErr, httpCacheDuration, ck) return nil, notFoundErr } // Convert headers from map[string][]string to map[string]string. This means // that headers that were returned many times will end up with their values // comma-separated headersMap := make(map[string]string) for header, values := range res.Header { headersMap[header] = strings.Join(values, ", ") } // Convert the attributes from a golang map, to the structure required for // the SDP protocol attributes, err := sdp.ToAttributes(map[string]any{ "name": query, "status": res.StatusCode, "statusString": res.Status, "proto": res.Proto, "headers": headersMap, "transferEncoding": res.Request.TransferEncoding, }) if err != nil { err = &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } s.cache.StoreUnavailableItem(ctx, err, httpCacheDuration, ck) return nil, err } item := sdp.Item{ Type: "http", UniqueAttribute: "name", Attributes: attributes, Scope: "global", } if ip := net.ParseIP(req.URL.Hostname()); ip != nil { // If the host is an IP, add a linked item to that IP address item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: ip.String(), Scope: "global", }, }) } else { // If the host is not an ip, try to resolve via DNS item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: req.URL.Hostname(), Scope: "global", }, }) } if tlsState := res.TLS; tlsState != nil { var version string // Extract TLS version as a string switch tlsState.Version { case tls.VersionTLS10: version = "TLSv1.0" case tls.VersionTLS11: version = "TLSv1.1" case tls.VersionTLS12: version = "TLSv1.2" case tls.VersionTLS13: version = "TLSv1.3" default: version = "unknown" } attributes.Set("tls", map[string]any{ "version": version, "certificate": CertToName(tlsState.PeerCertificates[0]), "serverName": tlsState.ServerName, }) if len(tlsState.PeerCertificates) > 0 { // Create a PEM bundle and then linked item request var certs []string for _, cert := range tlsState.PeerCertificates { block := pem.Block{ Type: "CERTIFICATE", Bytes: cert.Raw, } certs = append(certs, string(pem.EncodeToMemory(&block))) } item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "certificate", Method: sdp.QueryMethod_SEARCH, Query: strings.Join(certs, "\n"), Scope: scope, }, }) } } // Detect redirect and add a linked item for the redirect target if res.StatusCode >= 300 && res.StatusCode < 400 { if loc := res.Header.Get("Location"); loc != "" { item.Attributes.AttrStruct.Fields["location"] = &structpb.Value{ Kind: &structpb.Value_StringValue{ StringValue: loc, }, } locURL, err := url.Parse(loc) if err != nil { item.Attributes.AttrStruct.Fields["location-error"] = &structpb.Value{ Kind: &structpb.Value_StringValue{ StringValue: err.Error(), }, } } else { // Resolve relative URLs against the original request URL resolvedURL := parsedURL.ResolveReference(locURL) // Validate redirect target to prevent redirects to link-local addresses redirectHostname := resolvedURL.Hostname() if redirectHostname != "" { if err := validateHostname(ctx, redirectHostname); err != nil { // Don't fail the entire request, but mark the redirect as invalid item.Attributes.AttrStruct.Fields["location-error"] = &structpb.Value{ Kind: &structpb.Value_StringValue{ StringValue: fmt.Sprintf("redirect blocked: %v", err), }, } } else { // Don't include query string and fragment in the linked item. // This leads to too many items, like auth redirect errors, that // do not provide value. resolvedURL.Fragment = "" resolvedURL.RawQuery = "" item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "http", Method: sdp.QueryMethod_SEARCH, Query: resolvedURL.String(), Scope: scope, }, }) } } } } } s.cache.StoreItem(ctx, &item, httpCacheDuration, ck) return &item, nil } // Search takes a URL, strips query parameters and fragments, and returns the HTTP item func (s *HTTPAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "global" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "http is only supported in the 'global' scope", Scope: scope, } } // Parse the URL and strip query parameters and fragments parsedURL, err := url.Parse(query) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("invalid URL: %v", err), Scope: scope, } } // Strip query parameters and fragments parsedURL.RawQuery = "" parsedURL.Fragment = "" cleanURL := parsedURL.String() // Use the existing Get method to retrieve the item item, err := s.Get(ctx, scope, cleanURL, ignoreCache) if err != nil { // Return (nil, error) for NOTFOUND so cache hit and fresh lookup behave the same return nil, err } if item == nil { // Get can return (nil, nil) on the defensive path when cache reports hit but cachedItems is empty (e.g. cache corruption). return []*sdp.Item{}, nil } return []*sdp.Item{item}, nil } // List is not implemented for HTTP as this would require scanning infinitely many // endpoints or something, doesn't really make sense func (s *HTTPAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { items := make([]*sdp.Item, 0) return items, nil } // Weight Returns the priority weighting of items returned by this adapter. // This is used to resolve conflicts where two adapters of the same type // return an item for a GET request. In this instance only one item can be // sen on, so the one with the higher weight value will win. func (s *HTTPAdapter) Weight() int { return 100 } ================================================ FILE: stdlib-source/adapters/http_test.go ================================================ package adapters import ( "context" "errors" "fmt" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) const TestHTTPTimeout = 3 * time.Second type TestHTTPServer struct { TLSServer *httptest.Server HTTPServer *httptest.Server NotFoundPage string // A page that returns a 404 InternalServerErrorPage string // A page that returns a 500 RedirectPage string // A page that returns a 301 RedirectPageRelative string // A page that returns a 301 with relative location RedirectPageLinkLocal string // A page that returns a 301 redirecting to link-local address SlowPage string // A page that takes longer than the timeout to respond OKPage string // A page that returns a 200 OKPageNoTLS string // A page that returns a 200 without TLS Host string Port string } func NewTestServer() (*TestHTTPServer, error) { sm := http.NewServeMux() sm.Handle("/404", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, err := w.Write([]byte("not found innit")) if err != nil { return } })) sm.Handle("/500", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, err := w.Write([]byte("yeah nah innit")) if err != nil { return } })) sm.Handle("/301", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Location", "https://www.google.com?foo=bar#baz") w.WriteHeader(http.StatusMovedPermanently) })) sm.Handle("/301-relative", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Location", "/redirected?param=value#fragment") w.WriteHeader(http.StatusMovedPermanently) })) sm.Handle("/301-link-local", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Location", "http://169.254.169.254/latest/meta-data/") w.WriteHeader(http.StatusMovedPermanently) })) sm.Handle("/200", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := w.Write([]byte("ok innit")) if err != nil { return } })) sm.Handle("/slow", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) _, err := w.Write([]byte("ok innit")) if err != nil { return } })) tlsServer := httptest.NewTLSServer(sm) httpServer := httptest.NewServer(sm) host, port, err := net.SplitHostPort(tlsServer.Listener.Addr().String()) if err != nil { return nil, err } return &TestHTTPServer{ TLSServer: tlsServer, HTTPServer: httpServer, NotFoundPage: fmt.Sprintf("%v/404", tlsServer.URL), InternalServerErrorPage: fmt.Sprintf("%v/500", tlsServer.URL), RedirectPage: fmt.Sprintf("%v/301", tlsServer.URL), RedirectPageRelative: fmt.Sprintf("%v/301-relative", tlsServer.URL), RedirectPageLinkLocal: fmt.Sprintf("%v/301-link-local", tlsServer.URL), OKPage: fmt.Sprintf("%v/200", tlsServer.URL), OKPageNoTLS: fmt.Sprintf("%v/200", httpServer.URL), SlowPage: fmt.Sprintf("%v/slow", tlsServer.URL), Host: host, Port: port, }, nil } func (t *TestHTTPServer) Close() { if t.TLSServer != nil { t.TLSServer.Close() } if t.HTTPServer != nil { t.HTTPServer.Close() } } func TestHTTPGet(t *testing.T) { src := HTTPAdapter{ cache: sdpcache.NewNoOpCache(), } server, err := NewTestServer() if err != nil { t.Fatal(err) } defer server.TLSServer.Close() t.Run("With a specified port and dns name", func(t *testing.T) { // Use localhost with /200 so we get an item and exercise DNS link; root path returns 404 which we now treat as NOTFOUND url := fmt.Sprintf("https://localhost:%s/200", server.Port) item, err := src.Get(context.Background(), "global", url, false) if err != nil { t.Fatal(err) } var dnsFound bool for _, link := range item.GetLinkedItemQueries() { switch link.GetQuery().GetType() { case "dns": dnsFound = true if link.GetQuery().GetQuery() != "localhost" { t.Errorf("expected dns query to be localhost, got %v", link.GetQuery()) } } } if !dnsFound { t.Error("link to dns not found") } discovery.TestValidateItem(t, item) }) t.Run("With an IP", func(t *testing.T) { item, err := src.Get(context.Background(), "global", server.OKPage, false) if err != nil { t.Fatal(err) } var ipFound bool for _, link := range item.GetLinkedItemQueries() { switch link.GetQuery().GetType() { case "ip": ipFound = true if link.GetQuery().GetQuery() != "127.0.0.1" { t.Errorf("expected dns query to be 127.0.0.1, got %v", link.GetQuery()) } } } if !ipFound { t.Error("link to ip not found") } discovery.TestValidateItem(t, item) }) t.Run("With a 404", func(t *testing.T) { // 404 is cached as NOTFOUND; no item returned item, err := src.Get(context.Background(), "global", server.NotFoundPage, false) if item != nil { t.Errorf("expected nil item for 404, got %v", item) } if err == nil { t.Fatal("expected NOTFOUND error for 404, got nil") } var qErr *sdp.QueryError if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("expected NOTFOUND error for 404, got %v", err) } }) t.Run("404 NOTFOUND is cached and second Get does not hit server", func(t *testing.T) { var count int mux := http.NewServeMux() mux.HandleFunc("/404", func(w http.ResponseWriter, _ *http.Request) { count++ w.WriteHeader(http.StatusNotFound) }) srv := httptest.NewTLSServer(mux) defer srv.Close() cachedSrc := HTTPAdapter{cache: sdpcache.NewMemoryCache()} url404 := srv.URL + "/404" // First call: 404 is cached as NOTFOUND item, err := cachedSrc.Get(context.Background(), "global", url404, false) if item != nil { t.Errorf("first Get: expected nil item, got %v", item) } if err == nil { t.Fatal("first Get: expected NOTFOUND error, got nil") } var qErr *sdp.QueryError if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("first Get: expected NOTFOUND, got %v", err) } if count != 1 { t.Errorf("first Get: expected 1 request, got %d", count) } firstErrStr := err.Error() // Second call: should hit cache, no new request; same response as first (nil item, NOTFOUND, same message) item, err = cachedSrc.Get(context.Background(), "global", url404, false) if item != nil { t.Errorf("second Get: expected nil item, got %v", item) } if err == nil { t.Fatal("second Get: expected NOTFOUND error, got nil") } if !errors.As(err, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("second Get: expected NOTFOUND, got %v", err) } if err.Error() != firstErrStr { t.Errorf("first and second Get must return same error message: first %q, second %q", firstErrStr, err.Error()) } if count != 1 { t.Errorf("second Get: expected no new request (count still 1), got %d", count) } }) t.Run("Search 404 returns same NOTFOUND for first and second call", func(t *testing.T) { var count int mux := http.NewServeMux() mux.HandleFunc("/404", func(w http.ResponseWriter, _ *http.Request) { count++ w.WriteHeader(http.StatusNotFound) }) srv := httptest.NewTLSServer(mux) defer srv.Close() cachedSrc := HTTPAdapter{cache: sdpcache.NewMemoryCache()} url404 := srv.URL + "/404" first, err1 := cachedSrc.Search(context.Background(), "global", url404, false) if err1 == nil { t.Fatal("first Search: expected NOTFOUND error, got nil") } if first != nil { t.Errorf("first Search: expected nil items, got len=%d", len(first)) } var qErr *sdp.QueryError if !errors.As(err1, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("first Search: expected NOTFOUND, got %v", err1) } if count != 1 { t.Errorf("first Search: expected 1 request, got %d", count) } firstErrStr := err1.Error() second, err2 := cachedSrc.Search(context.Background(), "global", url404, false) if err2 == nil { t.Fatal("second Search: expected NOTFOUND error, got nil") } if second != nil { t.Errorf("second Search: expected nil items, got len=%d", len(second)) } if !errors.As(err2, &qErr) || qErr.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("second Search: expected NOTFOUND, got %v", err2) } if err2.Error() != firstErrStr { t.Errorf("first and second Search must return same error message: first %q, second %q", firstErrStr, err2.Error()) } if count != 1 { t.Errorf("second Search: expected no new request (count still 1), got %d", count) } }) t.Run("With a timeout", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() item, err := src.Get(ctx, "global", server.SlowPage, false) if err == nil { t.Errorf("Expected timeout but got %v", item.String()) } }) t.Run("With a 500 error", func(t *testing.T) { item, err := src.Get(context.Background(), "global", server.InternalServerErrorPage, false) if err != nil { t.Fatal(err) } var status any status, err = item.GetAttributes().Get("status") if err != nil { t.Fatal(err) } if status != float64(500) { t.Errorf("expected status to be 500, got: %v", status) } discovery.TestValidateItem(t, item) }) t.Run("With a 301 redirect", func(t *testing.T) { item, err := src.Get(context.Background(), "global", server.RedirectPage, false) if err != nil { t.Fatal(err) } var status any status, err = item.GetAttributes().Get("status") if err != nil { t.Fatal(err) } if status != float64(301) { t.Errorf("expected status to be 301, got: %v", status) } liqs := item.GetLinkedItemQueries() if len(liqs) != 3 { t.Errorf("expected linked items for redirected location, ip, and dns, got %v: %v", len(liqs), liqs) } for l := range liqs { // Look for the linked item with the http query to the redirect // location, check that the query and fragment have been stripped. if liqs[l].GetQuery().GetType() == "http" { if liqs[l].GetQuery().GetQuery() != "https://www.google.com" { t.Errorf("expected linked item query to be https://www.google.com, got %v", liqs[l].GetQuery().GetQuery()) } } } discovery.TestValidateItem(t, item) }) t.Run("With a 301 redirect with relative location", func(t *testing.T) { item, err := src.Get(context.Background(), "global", server.RedirectPageRelative, false) if err != nil { t.Fatal(err) } var status any status, err = item.GetAttributes().Get("status") if err != nil { t.Fatal(err) } if status != float64(301) { t.Errorf("Expected status to be 301, got: %v", status) } // Check that the location header contains the relative URL var location any location, err = item.GetAttributes().Get("location") if err != nil { t.Fatal(err) } if location != "/redirected?param=value#fragment" { t.Errorf("Expected location to be /redirected?param=value#fragment, got: %v", location) } // Check that the linked item has the resolved absolute URL liqs := item.GetLinkedItemQueries() if len(liqs) != 3 { t.Errorf("expected linked items for redirected location, ip, and dns, got %v: %v", len(liqs), liqs) } // Extract the base URL from the test server URL expectedResolvedURL := "https://" + net.JoinHostPort("127.0.0.1", server.Port) + "/redirected" for l := range liqs { // Look for the linked item with the http query to the redirect // location, check that the relative URL was resolved to absolute. if liqs[l].GetQuery().GetType() == "http" { if liqs[l].GetQuery().GetQuery() != expectedResolvedURL { t.Errorf("expected linked item query to be %s, got %v", expectedResolvedURL, liqs[l].GetQuery().GetQuery()) } } } discovery.TestValidateItem(t, item) }) t.Run("With no TLS", func(t *testing.T) { item, err := src.Get(context.Background(), "global", server.OKPageNoTLS, false) if err != nil { t.Fatal(err) } _, err = item.GetAttributes().Get("tls") if err == nil { t.Error("Expected to not find TLS info") } discovery.TestValidateItem(t, item) }) t.Run("With query parameters should return error", func(t *testing.T) { urlWithQuery := server.OKPage + "?param=value" _, err := src.Get(context.Background(), "global", urlWithQuery, false) if err == nil { t.Error("Expected error for URL with query parameters, got nil") } }) t.Run("With fragment should return error", func(t *testing.T) { urlWithFragment := server.OKPage + "#fragment" _, err := src.Get(context.Background(), "global", urlWithFragment, false) if err == nil { t.Error("Expected error for URL with fragment, got nil") } }) t.Run("With both query parameters and fragment should return error", func(t *testing.T) { urlWithBoth := server.OKPage + "?param=value#fragment" _, err := src.Get(context.Background(), "global", urlWithBoth, false) if err == nil { t.Error("Expected error for URL with query parameters and fragment, got nil") } }) t.Run("With link-local IP address should be blocked", func(t *testing.T) { // Test direct access to EC2 metadata service IP metadataURL := "http://169.254.169.254/latest/meta-data/" _, err := src.Get(context.Background(), "global", metadataURL, false) if err == nil { t.Error("Expected error for link-local IP address, got nil") } // Verify the error message mentions link-local blocking if err != nil { errStr := err.Error() if errStr == "" { t.Error("Expected error message, got empty string") } // Check that it's a QueryError with the right error type var qErr *sdp.QueryError if errors.As(err, &qErr) { if qErr.GetErrorType() != sdp.QueryError_OTHER { t.Errorf("Expected error type OTHER, got %v", qErr.GetErrorType()) } if !strings.Contains(qErr.GetErrorString(), "link-local") { t.Errorf("Expected error message to mention 'link-local', got: %s", qErr.GetErrorString()) } } } }) t.Run("With other link-local IP addresses should be blocked", func(t *testing.T) { // Test other IPs in the 169.254.0.0/16 range testIPs := []string{ "http://169.254.0.1/", "http://169.254.1.1/", "http://169.254.255.255/", } for _, testIP := range testIPs { _, err := src.Get(context.Background(), "global", testIP, false) if err == nil { t.Errorf("Expected error for link-local IP %s, got nil", testIP) } } }) t.Run("With redirect to link-local address should be blocked", func(t *testing.T) { // Test that redirects to link-local addresses are blocked item, err := src.Get(context.Background(), "global", server.RedirectPageLinkLocal, false) if err != nil { t.Fatal(err) } // The request should succeed, but the redirect should be marked as blocked var locationError any locationError, err = item.GetAttributes().Get("location-error") if err != nil { t.Fatal("Expected location-error attribute for blocked redirect") } locationErrorStr := locationError.(string) if !strings.Contains(locationErrorStr, "redirect blocked") { t.Errorf("Expected location-error to contain 'redirect blocked', got: %s", locationErrorStr) } if !strings.Contains(locationErrorStr, "link-local") { t.Errorf("Expected location-error to mention 'link-local', got: %s", locationErrorStr) } // Verify that no linked item query was created for the blocked redirect liqs := item.GetLinkedItemQueries() for _, liq := range liqs { if liq.GetQuery().GetType() == "http" { if strings.Contains(liq.GetQuery().GetQuery(), "169.254") { t.Errorf("Expected no linked item query for blocked link-local redirect, got: %s", liq.GetQuery().GetQuery()) } } } }) } func TestHTTPSearch(t *testing.T) { src := HTTPAdapter{ cache: sdpcache.NewNoOpCache(), } server, err := NewTestServer() if err != nil { t.Fatal(err) } defer server.TLSServer.Close() t.Run("With query parameters and fragments", func(t *testing.T) { // Test URL with query parameters and fragments testURL := server.OKPage + "?param1=value1¶m2=value2#fragment" items, err := src.Search(context.Background(), "global", testURL, false) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("Expected 1 item, got %d", len(items)) } item := items[0] // The unique attribute should be the clean URL without query params and fragments expectedCleanURL := server.OKPage if item.UniqueAttributeValue() != expectedCleanURL { t.Errorf("Expected unique attribute to be %s, got %s", expectedCleanURL, item.UniqueAttributeValue()) } // Verify the item has the expected status (200 for OK page) var status any status, err = item.GetAttributes().Get("status") if err != nil { t.Fatal(err) } if status != float64(200) { t.Errorf("Expected status to be 200, got: %v", status) } discovery.TestValidateItem(t, item) }) t.Run("With invalid URL", func(t *testing.T) { invalidURL := "not-a-valid-url" _, err := src.Search(context.Background(), "global", invalidURL, false) if err == nil { t.Error("Expected error for invalid URL, got nil") } }) t.Run("With wrong scope", func(t *testing.T) { _, err := src.Search(context.Background(), "wrong-scope", server.OKPage, false) if err == nil { t.Error("Expected error for wrong scope, got nil") } }) } ================================================ FILE: stdlib-source/adapters/ip.go ================================================ package adapters import ( "context" "fmt" "net" "github.com/overmindtech/cli/go/sdp-go" ) // IPAdapter struct on which all methods are registered type IPAdapter struct{} // Type is the type of items that this returns func (bc *IPAdapter) Type() string { return "ip" } // Name Returns the name of the backend func (bc *IPAdapter) Name() string { return "stdlib-ip" } // Weighting of duplicate adapters func (s *IPAdapter) Weight() int { return 100 } func (s *IPAdapter) Metadata() *sdp.AdapterMetadata { return ipMetadata } var ipMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "IP Address", Type: "ip", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "An ipv4 or ipv6 address", }, PotentialLinks: []string{"dns", "rdap-ip-network"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) // List of scopes that this adapter is capable of find items for func (s *IPAdapter) Scopes() []string { return []string{ // This supports all scopes since there might be local IPs that need // to have a different scope. E.g. 127.0.0.1 is a different logical // address per computer since it refers to "itself" This means we // definitely don't want all thing that reference 127.0.0.1 linked // together, only those in the same scope // // TODO: Make a recommendation for what the scope should be when // looking up an IP in the local range. It's possible that an org could // have the address (10.2.56.1) assigned to many devices (hopefully not, // but I have seen it happen) and we would therefore want those IPs to // have different scopes as they don't refer to the same thing sdp.WILDCARD, } } // Get gets information about a single IP This expects an IP in a format that // can be parsed by net.ParseIP() such as "192.0.2.1", "2001:db8::68" or // "::ffff:192.0.2.1". It returns some useful information about that IP but this // is all just information that is inherent in the IP itself, it doesn't look // anything up externally // // The purpose of this is mainly to provide a node in the graph that many things // can be linked to, rather than being particularly useful on its own func (bc *IPAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { var ip net.IP var err error var attributes *sdp.ItemAttributes var isGlobalIP bool ip = net.ParseIP(query) if ip == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%v is not a valid IP", query), SourceName: bc.Name(), ItemType: bc.Type(), Scope: scope, } } isGlobalIP = IsGlobalScopeIP(ip) // If the query was executed with a wildcard, and the scope is global, we // might was well set it. If it's not then we have no way to determine the // scope so we need to return an error if scope == sdp.WILDCARD { if isGlobalIP { scope = "global" } else { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%v is not a globally-unique IP and therefore could exist in every scope. Query with a wildcard does not work for non-global IPs", query), Scope: scope, SourceName: bc.Name(), ItemType: bc.Type(), } } } if scope == "global" { if !IsGlobalScopeIP(ip) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%v is not a valid ip withing the global scope. It must be request with some other scope", query), Scope: scope, SourceName: bc.Name(), ItemType: bc.Type(), } } } else { // If the scope is non-global, ensure that the IP is not globally unique unique if IsGlobalScopeIP(ip) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("%v is a globally-unique IP and therefore only exists in the global scope. Note that private IP ranges are also considered 'global' for convenience", query), Scope: scope, SourceName: bc.Name(), ItemType: bc.Type(), } } } attributes, err = sdp.ToAttributes(map[string]any{ "ip": ip.String(), "unspecified": ip.IsUnspecified(), "loopback": ip.IsLoopback(), "private": ip.IsPrivate(), "multicast": ip.IsMulticast(), "interfaceLocalMulticast": ip.IsInterfaceLocalMulticast(), "linkLocalMulticast": ip.IsLinkLocalMulticast(), "linkLocalUnicast": ip.IsLinkLocalUnicast(), }) if err != nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: err.Error(), Scope: scope, } } return &sdp.Item{ Type: "ip", UniqueAttribute: "ip", Attributes: attributes, Scope: scope, LinkedItemQueries: []*sdp.LinkedItemQuery{ // Reverse DNS { Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: ip.String(), Scope: "global", }, }, { // RDAP Query: &sdp.Query{ Type: "rdap-ip-network", Method: sdp.QueryMethod_SEARCH, Query: ip.String(), Scope: "global", }, }, }, }, nil } // List Returns an empty list as returning all possible IP addresses would be // unproductive func (bc *IPAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "global" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "IP queries only supported in global scope", Scope: scope, } } return make([]*sdp.Item, 0), nil } // IsGlobalScopeIP Returns whether or not the IP should be considered valid // withing the global scope according to the following logic: // // Non-Global: // // * LinkLocalMulticast // * LinkLocalUnicast // * InterfaceLocalMulticast // * Loopback // // Global: // // * Private // * Other (All non-reserved addresses) func IsGlobalScopeIP(ip net.IP) bool { return !ip.IsLinkLocalMulticast() && !ip.IsLinkLocalUnicast() && !ip.IsInterfaceLocalMulticast() && !ip.IsLoopback() } ================================================ FILE: stdlib-source/adapters/ip_cache.go ================================================ package adapters import ( "context" "net" "sync" "time" "github.com/google/btree" ) type entry[EntryType any] struct { Network *net.IPNet // The CIDR this entry is for Expiry time.Time // When this entry expires Object EntryType // The actual stored object } type IPCache[EntryType any] struct { storage *btree.BTreeG[entry[EntryType]] mu sync.RWMutex } func NewIPCache[EntryType any]() *IPCache[EntryType] { return &IPCache[EntryType]{ storage: btree.NewG[entry[EntryType]](2, func(a, b entry[EntryType]) bool { // Sort by the network mask number i.e. /8, /16, /24, etc in numeric // order. This means if we want to find the most specific CIDR that // contains an IP, we can just iterate through the tree in descending // order aSize, _ := a.Network.Mask.Size() bSize, _ := b.Network.Mask.Size() return aSize < bSize }), } } // Stores an object in the cache for the given duration. The "Key" is the CIDR func (c *IPCache[EntryType]) Store(cidr *net.IPNet, object EntryType, duration time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.storage.ReplaceOrInsert(entry[EntryType]{ Network: cidr, Expiry: time.Now().Add(duration), Object: object, }) } // Searched for the most specific CIDR that contains the specified IP func (c *IPCache[EntryType]) SearchIP(ip net.IP) (EntryType, bool) { c.mu.RLock() defer c.mu.RUnlock() var found *entry[EntryType] var object EntryType // Iterate through the tree in descending order c.storage.Descend(func(current entry[EntryType]) bool { if current.Network.Contains(ip) { found = ¤t return false } return true }) if found != nil { object = found.Object return object, true } return object, false } // Search the cache for the specified CIDR func (c *IPCache[EntryType]) SearchCIDR(cidr *net.IPNet) (EntryType, bool) { c.mu.RLock() defer c.mu.RUnlock() var found *entry[EntryType] var object EntryType // Iterate through the tree in descending order c.storage.Descend(func(current entry[EntryType]) bool { if current.Network.String() == cidr.String() { found = ¤t return false } return true }) if found != nil { object = found.Object return object, true } return object, false } // Finds items that have expired and removes them from the cache, returns the // number of expired items. You need to pass in the current time, this will // usually be time.Now() but it can be useful to pass in a fixed time for // testing func (c *IPCache[EntryType]) Expire(now time.Time) int { c.mu.Lock() defer c.mu.Unlock() var expired int c.storage.Ascend(func(current entry[EntryType]) bool { if current.Expiry.Before(now) { c.storage.Delete(current) expired++ } return true }) return expired } // Starts a goroutine that will periodically check for expired items and removes // them from the cache. You can pass in a context to cancel the goroutine and // stop the purging func (c *IPCache[EntryType]) StartPurger(ctx context.Context, interval time.Duration) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: c.Expire(time.Now()) case <-ctx.Done(): return } } }() } ================================================ FILE: stdlib-source/adapters/ip_cache_test.go ================================================ package adapters import ( "net" "testing" "time" ) func TestIPCaching(t *testing.T) { cache := NewIPCache[string]() // Store a number of ranges ranges := []struct { Range string Value string }{ { Range: "10.0.0.0/24", Value: "super-local", }, { // Goes up to 10.0.63.255 Range: "10.0.0.0/18", Value: "semi-local", }, { Range: "10.0.0.0/8", Value: "local", }, } for _, r := range ranges { _, network, err := net.ParseCIDR(r.Range) if err != nil { t.Fatal(err) } cache.Store(network, r.Value, 10*time.Minute) } expectations := []struct { IP string Value string }{ { IP: "10.0.0.1", Value: "super-local", }, { IP: "10.0.20.20", Value: "semi-local", }, { IP: "10.23.54.76", Value: "local", }, } for _, e := range expectations { ip := net.ParseIP(e.IP) value, ok := cache.SearchIP(ip) if !ok { t.Fatal("Expected to find a value") } if value != e.Value { t.Errorf("Expected to find %v, got %v", e.Value, value) } } // Test for something that should not exist ip := net.ParseIP("86.4.78.2") _, ok := cache.SearchIP(ip) if ok { t.Error("Expected not to find a value for a public IP") } } func TestIPCachePurge(t *testing.T) { cache := NewIPCache[string]() start := time.Now() // Store a number of ranges _, a, _ := net.ParseCIDR("10.0.0.0/24") cache.Store(a, "super-local", 1*time.Second) _, b, _ := net.ParseCIDR("10.0.0.0/18") cache.Store(b, "semi-local", 2*time.Second) _, c, _ := net.ParseCIDR("10.0.0.0/8") cache.Store(c, "local", 3*time.Second) // Lookup a local IP, this should be served from the most local cache // entry result, found := cache.SearchIP(net.ParseIP("10.0.0.1")) if !found { t.Fatal("Expected to find a value") } if result != "super-local" { t.Errorf("Expected to find super-local, got %v", result) } // Expire the first (most specific) entry numExpired := cache.Expire(start.Add(1100 * time.Millisecond)) if numExpired != 1 { t.Errorf("Expected 1 entry to expire, got %v", numExpired) } // Lookup a local IP, this should be served from the next most local cache // entry result, found = cache.SearchIP(net.ParseIP("10.0.0.1")) if !found { t.Fatal("Expected to find a value") } if result != "semi-local" { t.Errorf("Expected to find semi-local, got %v", result) } // Expire the second entry numExpired = cache.Expire(start.Add(2100 * time.Millisecond)) if numExpired != 1 { t.Errorf("Expected 1 entry to expire, got %v", numExpired) } // Lookup a local IP, this should be served from the local entry result, found = cache.SearchIP(net.ParseIP("10.0.0.1")) if !found { t.Fatal("Expected to find a value") } if result != "local" { t.Errorf("Expected to find local, got %v", result) } // Expire the third entry numExpired = cache.Expire(start.Add(3100 * time.Millisecond)) if numExpired != 1 { t.Errorf("Expected 1 entry to expire, got %v", numExpired) } // Lookup a local IP the cache should now be empty _, found = cache.SearchIP(net.ParseIP("10.0.0.1")) if found { t.Fatal("Expected not to find a value") } } func TestParseIPWithCIDR(t *testing.T) { ip := net.ParseIP("10.0.0.1/32") t.Log(ip) } ================================================ FILE: stdlib-source/adapters/ip_test.go ================================================ package adapters import ( "context" "regexp" "testing" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" ) func TestIPGet(t *testing.T) { src := IPAdapter{} t.Run("with ipv4 address", func(t *testing.T) { item, err := src.Get(context.Background(), "global", "213.21.3.187", false) if err != nil { t.Fatal(err) } if private, err := item.GetAttributes().Get("private"); err == nil { if private != false { t.Error("Expected itemAttributes.private to be false") } } else { t.Error("could not find 'private' attribute") } discovery.TestValidateItem(t, item) }) t.Run("with ipv6 address", func(t *testing.T) { item, err := src.Get(context.Background(), "global", "2a01:4b00:8602:b600:5523:ce8d:dafc:3243", false) if err != nil { t.Fatal(err) } if private, err := item.GetAttributes().Get("private"); err == nil { if private != false { t.Error("Expected itemAttributes.private to be false") } } else { t.Error("could not find 'private' attribute") } discovery.TestValidateItem(t, item) }) t.Run("with invalid address", func(t *testing.T) { _, err := src.Get(context.Background(), "global", "this is not valid", false) if err == nil { t.Error("expected error") } else { if matched, _ := regexp.MatchString("this is not valid", err.Error()); !matched { t.Errorf("expected error to contain 'this is not valid', got: %v", err) } } }) t.Run("with ipv4 link-local address", func(t *testing.T) { t.Run("in the global scope", func(t *testing.T) { // Link-local addresses are not guaranteed to be unique beyond their // network segment, therefore routers do not forward packets with // link-local adapter or destination addresses. This means that it // doesn't make sense to have a "global" link-local address as it's // not truly global _, err := src.Get(context.Background(), "global", "169.254.1.25", false) if err == nil { t.Error("expected error but got nil") } }) t.Run("in another scope", func(t *testing.T) { item, err := src.Get(context.Background(), "some.computer", "169.254.1.25", false) if err != nil { t.Fatal(err) } if item.GetScope() != "some.computer" { t.Errorf("expected scope to be some.computer, got %v", item.GetScope()) } if llu, err := item.GetAttributes().Get("linkLocalUnicast"); err != nil || llu == false { t.Errorf("expected linkLocalUnicast to be false, got %v", llu) } discovery.TestValidateItem(t, item) }) }) t.Run("with ipv4 private address", func(t *testing.T) { t.Run("in the global scope", func(t *testing.T) { item, err := src.Get(context.Background(), "global", "10.0.4.5", false) if err != nil { t.Fatal(err) } if p, err := item.GetAttributes().Get("private"); err != nil || p == false { t.Errorf("expected p to be true, got %v", p) } discovery.TestValidateItem(t, item) }) t.Run("in another scope", func(t *testing.T) { _, err := src.Get(context.Background(), "some.computer", "10.0.4.5", false) if err == nil { t.Error("expected error but got nil") } }) }) t.Run("with ipv4 loopback address", func(t *testing.T) { t.Run("in the global scope", func(t *testing.T) { // Link-local addresses are not guaranteed to be unique beyond their // network segment, therefore routers do not forward packets with // link-local adapter or destination addresses. This means that it // doesn't make sense to have a "global" link-local address as it's // not truly global _, err := src.Get(context.Background(), "global", "127.0.0.1", false) if err == nil { t.Error("expected error but got nil") } }) t.Run("in another scope", func(t *testing.T) { item, err := src.Get(context.Background(), "some.computer", "127.0.0.1", false) if err != nil { t.Fatal(err) } if item.GetScope() != "some.computer" { t.Errorf("expected scope to be some.computer, got %v", item.GetScope()) } if loopback, err := item.GetAttributes().Get("loopback"); err != nil || loopback == false { t.Errorf("expected loopback to be false, got %v", loopback) } discovery.TestValidateItem(t, item) }) }) t.Run("with ipv6 link-local address", func(t *testing.T) { t.Run("in the global scope", func(t *testing.T) { // Link-local addresses are not guaranteed to be unique beyond their // network segment, therefore routers do not forward packets with // link-local adapter or destination addresses. This means that it // doesn't make sense to have a "global" link-local address as it's // not truly global _, err := src.Get(context.Background(), "global", "fe80::a70f:3a:338b:4801", false) if err == nil { t.Error("expected error but got nil") } }) t.Run("in another scope", func(t *testing.T) { item, err := src.Get(context.Background(), "some.computer", "fe80::a70f:3a:338b:4801", false) if err != nil { t.Fatal(err) } if item.GetScope() != "some.computer" { t.Errorf("expected scope to be some.computer, got %v", item.GetScope()) } if llu, err := item.GetAttributes().Get("linkLocalUnicast"); err != nil || llu == false { t.Errorf("expected linkLocalUnicast top be false, got %v", llu) } discovery.TestValidateItem(t, item) }) }) t.Run("with ipv6 private address", func(t *testing.T) { t.Run("in the global scope", func(t *testing.T) { item, err := src.Get(context.Background(), "global", "fd12:3456:789a:1::1", false) if err != nil { t.Fatal(err) } if p, err := item.GetAttributes().Get("private"); err != nil || p == false { t.Errorf("expected p to be true, got %v", p) } discovery.TestValidateItem(t, item) }) t.Run("in another scope", func(t *testing.T) { _, err := src.Get(context.Background(), "some.computer", "fd12:3456:789a:1::1", false) if err == nil { t.Error("expected error but got nil") } }) }) t.Run("with ipv6 loopback address", func(t *testing.T) { t.Run("in the global scope", func(t *testing.T) { // Link-local addresses are not guaranteed to be unique beyond their // network segment, therefore routers do not forward packets with // link-local adapter or destination addresses. This means that it // doesn't make sense to have a "global" link-local address as it's // not truly global _, err := src.Get(context.Background(), "global", "::1", false) if err == nil { t.Error("expected error but got nil") } }) t.Run("in another scope", func(t *testing.T) { item, err := src.Get(context.Background(), "some.computer", "::1", false) if err != nil { t.Fatal(err) } if item.GetScope() != "some.computer" { t.Errorf("expected scope to be some.computer, got %v", item.GetScope()) } if loopback, err := item.GetAttributes().Get("loopback"); err != nil || loopback == false { t.Errorf("expected loopback to be false, got %v", loopback) } discovery.TestValidateItem(t, item) }) }) t.Run("with a wildcard scope", func(t *testing.T) { item, err := src.Get(context.Background(), sdp.WILDCARD, "213.21.3.187", false) if err != nil { t.Fatal(err) } if private, err := item.GetAttributes().Get("private"); err == nil { if private != false { t.Error("Expected itemAttributes.private to be false") } } else { t.Error("could not find 'private' attribute") } discovery.TestValidateItem(t, item) }) } ================================================ FILE: stdlib-source/adapters/main.go ================================================ package adapters import ( "context" "errors" "net/url" "reflect" "regexp" "time" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" "github.com/overmindtech/cli/stdlib-source/adapters/test" _ "embed" ) var Metadata = sdp.AdapterMetadataList{} // Cache duration for RDAP adapters, these things shouldn't change very often const RdapCacheDuration = 30 * time.Minute // InitializeAdapters adds stdlib adapters to an existing engine. This allows the engine // to be created and serve health probes even if adapter initialization fails. // // Stdlib adapters rarely fail during initialization, but this pattern maintains consistency // with other sources and allows for future error handling improvements. func InitializeAdapters(ctx context.Context, e *discovery.Engine, reverseDNS bool) error { // Create a shared cache for all adapters in this source sharedCache := sdpcache.NewCache(ctx) // Add the base adapters adapters := []discovery.Adapter{ &CertificateAdapter{}, &DNSAdapter{ ReverseLookup: reverseDNS, cache: sharedCache, }, &HTTPAdapter{ cache: sharedCache, }, &IPAdapter{}, &test.TestDogAdapter{}, &test.TestFoodAdapter{}, &test.TestGroupAdapter{}, &test.TestHobbyAdapter{}, &test.TestLocationAdapter{}, &test.TestPersonAdapter{}, &test.TestRegionAdapter{}, // RDAP adapters are disabled because they return a large amount of data // that isn't very helpful. We're keeping the code in place so we can // decide later if it's worth re-enabling them. See Linear issue ENG-1390. // // &RdapIPNetworkAdapter{ // ClientFac: newRdapClient, // Cache: sdpcache.NewCache(), // IPCache: NewIPCache[*rdap.IPNetwork](), // }, // &RdapASNAdapter{ // ClientFac: newRdapClient, // Cache: sdpcache.NewCache(), // }, // &RdapDomainAdapter{ // ClientFac: newRdapClient, // Cache: sdpcache.NewCache(), // }, // &RdapEntityAdapter{ // ClientFac: newRdapClient, // Cache: sdpcache.NewCache(), // }, // &RdapNameserverAdapter{ // ClientFac: newRdapClient, // Cache: sdpcache.NewCache(), // }, } return e.AddAdapters(adapters...) } // newRdapClient Creates a new RDAP client using otelhttp.DefaultClient. rdap is suspected to not be thread safe, so we create a new client for each request // func newRdapClient() *rdap.Client { // return &rdap.Client{ // HTTP: otelhttp.DefaultClient, // } // } // Wraps an RDAP error in an SDP error, correctly checking for things like 404s func wrapRdapError(err error, scope string) error { if err == nil { return nil } var rdapError *rdap.ClientError if ok := errors.As(err, &rdapError); ok { if rdapError.Type == rdap.ObjectDoesNotExist { return &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: err.Error(), ItemType: "rdap", Scope: scope, } } } return err } // Extracts SDP queries from a list of entities func extractEntityLinks(entities []rdap.Entity) []*sdp.LinkedItemQuery { queries := make([]*sdp.LinkedItemQuery, 0) for _, entity := range entities { var selfLink string // Loop over the links until you find the self link for _, link := range entity.Links { if link.Rel == "self" { selfLink = link.Href break } } if selfLink != "" { queries = append(queries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-entity", Method: sdp.QueryMethod_SEARCH, Query: selfLink, Scope: "global", }, }) } } return queries } var rdapUrlRegex = regexp.MustCompile(`^(https?:\/\/.+)\/(ip|nameserver|entity|autnum|domain)\/([^\/]+)$`) type RDAPUrl struct { // The path to the root where queries should be run i.e. // https://rdap.apnic.net ServerRoot *url.URL // The type of query to run i.e. ip, nameserver, entity, autnum, domain Type string // The query to run i.e. 1.1.1.1 Query string } // Parses an RDAP URL and returns the important components func parseRdapUrl(rdapUrl string) (*RDAPUrl, error) { matches := rdapUrlRegex.FindStringSubmatch(rdapUrl) if len(matches) != 4 { return nil, errors.New("Invalid RDAP URL") } serverRoot, err := url.Parse(matches[1]) if err != nil { return nil, err } return &RDAPUrl{ ServerRoot: serverRoot, Type: matches[2], Query: matches[3], }, nil } var RDAPTransforms = sdp.AddDefaultTransforms(sdp.TransformMap{ reflect.TypeFor[rdap.Link](): func(i any) any { // We only want to return the href for links link, ok := i.(rdap.Link) if ok { return link.Href } return "" }, reflect.TypeFor[rdap.VCard](): func(i any) any { vcard, ok := i.(rdap.VCard) if ok { // Convert a vCard to a map as it's much more readable vCardDetails := make(map[string]string) if name := vcard.Name(); name != "" { vCardDetails["Name"] = name } if pOBox := vcard.POBox(); pOBox != "" { vCardDetails["POBox"] = pOBox } if extendedAddress := vcard.ExtendedAddress(); extendedAddress != "" { vCardDetails["ExtendedAddress"] = extendedAddress } if streetAddress := vcard.StreetAddress(); streetAddress != "" { vCardDetails["StreetAddress"] = streetAddress } if locality := vcard.Locality(); locality != "" { vCardDetails["Locality"] = locality } if region := vcard.Region(); region != "" { vCardDetails["Region"] = region } if postalCode := vcard.PostalCode(); postalCode != "" { vCardDetails["PostalCode"] = postalCode } if country := vcard.Country(); country != "" { vCardDetails["Country"] = country } if tel := vcard.Tel(); tel != "" { vCardDetails["Tel"] = tel } if fax := vcard.Fax(); fax != "" { vCardDetails["Fax"] = fax } if email := vcard.Email(); email != "" { vCardDetails["Email"] = email } if org := vcard.Org(); org != "" { vCardDetails["Org"] = org } return vCardDetails } return nil }, reflect.TypeFor[*rdap.DecodeData](): func(i any) any { // Exclude these return nil }, }) ================================================ FILE: stdlib-source/adapters/main_test.go ================================================ package adapters import ( "net/http" "testing" "github.com/openrdap/rdap" "github.com/openrdap/rdap/bootstrap" ) func testRdapClient(t *testing.T) *rdap.Client { return &rdap.Client{ HTTP: http.DefaultClient, Bootstrap: &bootstrap.Client{ Verbose: func(text string) { t.Log(text) }, }, Verbose: func(text string) { t.Log(text) }, } } ================================================ FILE: stdlib-source/adapters/rdap-asn.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type RdapASNAdapter struct { ClientFac func() *rdap.Client Cache sdpcache.Cache } // Type is the type of items that this returns func (s *RdapASNAdapter) Type() string { return "rdap-asn" } // Name Returns the name of the backend func (s *RdapASNAdapter) Name() string { return "rdap" } func (s *RdapASNAdapter) Metadata() *sdp.AdapterMetadata { return rdapAsnMetadata } var rdapAsnMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "Autonomous System Number (ASN)", Type: "rdap-asn", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, GetDescription: "Get an ASN by handle i.e. \"AS15169\"", }, PotentialLinks: []string{"rdap-entity"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) // Weighting of duplicate adapters func (s *RdapASNAdapter) Weight() int { return 100 } func (s *RdapASNAdapter) Scopes() []string { return []string{ "global", } } func (s *RdapASNAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { hit, ck, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { if len(items) > 0 { return items[0], nil } } // Strip the AS prefix query = strings.TrimPrefix(query, "AS") request := &rdap.Request{ Type: rdap.AutnumRequest, Query: query, } request = request.WithContext(ctx) response, err := s.ClientFac().Do(request) if err != nil { err = wrapRdapError(err, scope) s.Cache.StoreUnavailableItem(ctx, err, RdapCacheDuration, ck) return nil, err } if response.Object == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, ErrorString: "No ASN found", SourceName: s.Name(), ItemType: s.Type(), } } asn, ok := response.Object.(*rdap.Autnum) if !ok { return nil, fmt.Errorf("Unexpected response type: %T", response.Object) } attributes, err := sdp.ToAttributesCustom(map[string]any{ "conformance": asn.Conformance, "objectClassName": asn.ObjectClassName, "notices": asn.Notices, "handle": asn.Handle, "startAutnum": asn.StartAutnum, "endAutnum": asn.EndAutnum, "ipVersion": asn.IPVersion, "name": asn.Name, "type": asn.Type, "status": asn.Status, "country": asn.Country, "remarks": asn.Remarks, "links": asn.Links, "port43": asn.Port43, "events": asn.Events, }, true, RDAPTransforms) if err != nil { return nil, err } item := &sdp.Item{ Type: s.Type(), UniqueAttribute: "handle", Attributes: attributes, Scope: scope, } // Link the entities item.LinkedItemQueries = extractEntityLinks(asn.Entities) s.Cache.StoreItem(ctx, item, RdapCacheDuration, ck) return item, nil } func (s *RdapASNAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, ErrorString: "ASNs cannot be listed, use the GET method instead", } } ================================================ FILE: stdlib-source/adapters/rdap-asn_test.go ================================================ package adapters import ( "context" "testing" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdpcache" ) func TestASNAdapterGet(t *testing.T) { t.Parallel() src := &RdapASNAdapter{ ClientFac: func() *rdap.Client { return testRdapClient(t) }, Cache: sdpcache.NewNoOpCache(), } item, err := src.Get(context.Background(), "global", "AS15169", false) if err != nil { t.Fatal(err) } err = item.Validate() if err != nil { t.Error(err) } } ================================================ FILE: stdlib-source/adapters/rdap-domain.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type RdapDomainAdapter struct { ClientFac func() *rdap.Client Cache sdpcache.Cache } // Type is the type of items that this returns func (s *RdapDomainAdapter) Type() string { return "rdap-domain" } // Name Returns the name of the backend func (s *RdapDomainAdapter) Name() string { return "rdap" } func (s *RdapDomainAdapter) Metadata() *sdp.AdapterMetadata { return rdapDomainMetadata } var rdapDomainMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "RDAP Domain", Type: "rdap-domain", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ SearchDescription: "Search for a domain record by the domain name e.g. \"www.google.com\"", Search: true, }, PotentialLinks: []string{"dns", "rdap-nameserver", "rdap-entity", "rdap-ip-network"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) // Weighting of duplicate adapters func (s *RdapDomainAdapter) Weight() int { return 100 } func (s *RdapDomainAdapter) Scopes() []string { return []string{ "global", } } func (s *RdapDomainAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { // While we can't actually run GET queries, we can return them if they are // cached hit, _, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { if len(items) > 0 { return items[0], nil } } return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "Domains can't be queried by handle, use the SEARCH method instead", Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } } func (s *RdapDomainAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "Domains listed, use the SEARCH method instead", Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } } // Search for the most specific domain that contains the specified domain. The // input should be something like "www.google.com". This will first search for // "www.google.com", then "google.com", then "com" func (s *RdapDomainAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { // Strip the trailing dot if it exists query = strings.TrimSuffix(query, ".") hit, ck, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { return items, nil } // Split the query into subdomains sections := strings.Split(query, ".") // Start by querying the whole domain, then go down from there, however // don't query for the top-level domain as it won't return anything useful for i := range len(sections) - 1 { domainName := strings.Join(sections[i:], ".") request := &rdap.Request{ Type: rdap.DomainRequest, Query: domainName, } request = request.WithContext(ctx) response, err := s.ClientFac().Do(request) if err != nil { // If there was an error, continue to the next domain continue } if response.Object == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "Empty domain response", Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } } domain, ok := response.Object.(*rdap.Domain) if !ok { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: fmt.Sprintf("Unexpected response type %T", response.Object), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } } attributes, err := sdp.ToAttributesCustom(map[string]any{ "conformance": domain.Conformance, "events": domain.Events, "handle": domain.Handle, "ldhName": domain.LDHName, "links": domain.Links, "notices": domain.Notices, "objectClassName": domain.ObjectClassName, "port43": domain.Port43, "publicIDs": domain.PublicIDs, "remarks": domain.Remarks, "secureDNS": domain.SecureDNS, "status": domain.Status, "unicodeName": domain.UnicodeName, "variants": domain.Variants, }, true, RDAPTransforms) if err != nil { return nil, err } item := &sdp.Item{ Type: s.Type(), UniqueAttribute: "handle", Attributes: attributes, Scope: scope, } // Link to nameservers for _, nameServer := range domain.Nameservers { // Look through the HTTP responses until we find one var parsed *RDAPUrl for _, httpResponse := range response.HTTP { if httpResponse.URL != "" { parsed, err = parseRdapUrl(httpResponse.URL) if err == nil { break } } } // Reconstruct the required query URL if parsed != nil { newURL := parsed.ServerRoot.JoinPath("/nameserver/" + nameServer.LDHName) item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-nameserver", Method: sdp.QueryMethod_SEARCH, Query: newURL.String(), Scope: "global", }, }) } } // Link to entities item.LinkedItemQueries = append(item.LinkedItemQueries, extractEntityLinks(domain.Entities)...) // Link to IP Network if network := domain.Network; network != nil { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-ip-network", Method: sdp.QueryMethod_SEARCH, Query: network.StartAddress, Scope: "global", }, }) } if err != nil { return nil, err } s.Cache.StoreItem(ctx, item, RdapCacheDuration, ck) return []*sdp.Item{item}, nil } err := &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("No domain found for %s", query), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } s.Cache.StoreUnavailableItem(ctx, err, RdapCacheDuration, ck) return nil, err } ================================================ FILE: stdlib-source/adapters/rdap-domain_test.go ================================================ package adapters import ( "context" "testing" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdpcache" ) func TestDomainAdapterGet(t *testing.T) { t.Parallel() src := &RdapDomainAdapter{ ClientFac: func() *rdap.Client { return testRdapClient(t) }, Cache: sdpcache.NewNoOpCache(), } t.Run("without a dot", func(t *testing.T) { items, err := src.Search(context.Background(), "global", "reddit.map.fastly.net", false) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatal("Expected 1 item") } item := items[0] err = item.Validate() if err != nil { t.Error(err) } }) t.Run("with a dot", func(t *testing.T) { items, err := src.Search(context.Background(), "global", "reddit.map.fastly.net.", false) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatal("Expected 1 item") } item := items[0] err = item.Validate() if err != nil { t.Error(err) } }) } ================================================ FILE: stdlib-source/adapters/rdap-entity.go ================================================ package adapters import ( "context" "fmt" "net/url" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type RdapEntityAdapter struct { ClientFac func() *rdap.Client Cache sdpcache.Cache } // Type is the type of items that this returns func (s *RdapEntityAdapter) Type() string { return "rdap-entity" } // Name Returns the name of the backend func (s *RdapEntityAdapter) Name() string { return "rdap" } // Weighting of duplicate adapters func (s *RdapEntityAdapter) Weight() int { return 100 } func (s *RdapEntityAdapter) Metadata() *sdp.AdapterMetadata { return rdapEntityMetadata } var rdapEntityMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "RDAP Entity", Type: "rdap-entity", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Get: true, Search: true, GetDescription: "Get an entity by its handle. This method is discouraged as it's not reliable since entity bootstrapping isn't comprehensive", SearchDescription: "Search for an entity by its URL e.g. https://rdap.apnic.net/entity/AIC3-AP", }, PotentialLinks: []string{"rdap-asn"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_SECURITY, }) func (s *RdapEntityAdapter) Scopes() []string { return []string{ "global", } } // Gets an entity by its handle, note that this might not work as entity // bootstrapping in RDAP isn't comprehensive and might not be able to find the // correct registry to search func (s *RdapEntityAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { hit, ck, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { if len(items) > 0 { return items[0], nil } } return s.runEntityRequest(ctx, query, nil, scope, ck) } func (s *RdapEntityAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return nil, nil } // Search for an entity by its URL e.g. https://rdap.apnic.net/entity/AIC3-AP. // This is required because despite the work on bootstrapping in RFC 8521 it's // still not reliable enough to always resolve entities. However when we get // linked to an entity it should always have a link to itself, so we should be // able to do a lookup using that which will also tell us which server to use // for the lookup func (s *RdapEntityAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { hit, ck, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { return items, nil } // Parse the URL parsed, err := parseRdapUrl(query) if err != nil { return nil, err } if parsed.Type != "entity" { return nil, fmt.Errorf("Expected URL to lookup entity, got %s", parsed.Type) } // Run the entity request item, err := s.runEntityRequest(ctx, parsed.Query, parsed.ServerRoot, scope, ck) if err != nil { return nil, err } return []*sdp.Item{item}, nil } // Runs the entity request and converts into the SDP version of an entity func (s *RdapEntityAdapter) runEntityRequest(ctx context.Context, query string, server *url.URL, scope string, cacheKey sdpcache.CacheKey) (*sdp.Item, error) { request := &rdap.Request{ Type: rdap.EntityRequest, Query: query, Server: server, } request = request.WithContext(ctx) response, err := s.ClientFac().Do(request) if err != nil { err = wrapRdapError(err, scope) s.Cache.StoreUnavailableItem(ctx, err, RdapCacheDuration, cacheKey) return nil, err } if response.Object == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, ErrorString: fmt.Sprintf("No entity found for %s", query), SourceName: s.Name(), ItemType: s.Type(), } } entity, ok := response.Object.(*rdap.Entity) if !ok { return nil, fmt.Errorf("Expected Entity, got %T", response.Object) } attributes, err := sdp.ToAttributesCustom(map[string]any{ "asEventActor": entity.AsEventActor, "conformance": entity.Conformance, "events": entity.Events, "handle": entity.Handle, "links": entity.Links, "notices": entity.Notices, "objectClassName": entity.ObjectClassName, "port43": entity.Port43, "publicIDs": entity.PublicIDs, "remarks": entity.Remarks, "roles": entity.Roles, "status": entity.Status, "vCard": entity.VCard, }, true, RDAPTransforms) if err != nil { return nil, err } item := &sdp.Item{ Type: s.Type(), UniqueAttribute: "handle", Attributes: attributes, Scope: scope, } // Link to related entities item.LinkedItemQueries = extractEntityLinks(entity.Entities) // Don't link to related networks as there are entities with hundreds of // networks and there isn't a reasonable use case that would involve // traversing these // Link to related ASNs for _, autnum := range entity.Autnums { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "rdap-asn", Method: sdp.QueryMethod_GET, Query: autnum.Handle, Scope: scope, }, }) } s.Cache.StoreItem(ctx, item, RdapCacheDuration, cacheKey) return item, nil } ================================================ FILE: stdlib-source/adapters/rdap-entity_test.go ================================================ package adapters import ( "context" "errors" "testing" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) func TestEntityAdapterSearch(t *testing.T) { t.Parallel() realUrls := []string{ "https://rdap.apnic.net/entity/AIC3-AP", "https://rdap.apnic.net/entity/IRT-APNICRANDNET-AU", "https://rdap.arin.net/registry/entity/HPINC-Z", } src := &RdapEntityAdapter{ ClientFac: func() *rdap.Client { return testRdapClient(t) }, Cache: sdpcache.NewNoOpCache(), } for _, realUrl := range realUrls { t.Run(realUrl, func(t *testing.T) { items, err := src.Search(context.Background(), "global", realUrl, false) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("Expected 1 item, got %v", len(items)) } item := items[0] err = item.Validate() if err != nil { t.Error(err) } }) } t.Run("not found", func(t *testing.T) { _, err := src.Search(context.Background(), "global", "https://rdap.apnic.net/entity/NOTFOUND", false) if err == nil { t.Fatal("Expected error") } var sdpError *sdp.QueryError if ok := errors.As(err, &sdpError); ok { if sdpError.GetErrorType() != sdp.QueryError_NOTFOUND { t.Errorf("Expected QueryError_NOTFOUND, got %v", sdpError.GetErrorType()) } } else { t.Fatalf("Expected QueryError, got %T", err) } }) } ================================================ FILE: stdlib-source/adapters/rdap-ip-network.go ================================================ package adapters import ( "context" "fmt" "net" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type RdapIPNetworkAdapter struct { ClientFac func() *rdap.Client Cache sdpcache.Cache IPCache *IPCache[*rdap.IPNetwork] } // Type is the type of items that this returns func (s *RdapIPNetworkAdapter) Type() string { return "rdap-ip-network" } // Name Returns the name of the adapter func (s *RdapIPNetworkAdapter) Name() string { return "rdap" } // Weighting of duplicate adapters func (s *RdapIPNetworkAdapter) Weight() int { return 100 } func (s *RdapIPNetworkAdapter) Scopes() []string { return []string{ "global", } } func (s *RdapIPNetworkAdapter) Metadata() *sdp.AdapterMetadata { return rdapIPNetworkMetadata } var rdapIPNetworkMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "RDAP IP Network", Type: "rdap-ip-network", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Search: true, SearchDescription: "Search for the most specific network that contains the specified IP or CIDR", }, PotentialLinks: []string{"rdap-entity"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) func (s *RdapIPNetworkAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { hit, _, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { if len(items) > 0 { return items[0], nil } } // This adapter doesn't technically support the GET method (since you can't // use the handle to query for an IP network) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, ErrorString: "IP networks can't be queried by handle, use the SEARCH method instead", } } func (s *RdapIPNetworkAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, ErrorString: "IP networks cannot be listed, use the SEARCH method instead", } } // Search for the most specific network that contains the specified IP or CIDR func (s *RdapIPNetworkAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { hit, ck, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { return items, nil } // Second layer of caching means that we cn look up an IP, and if there is // anything in the cache that covers a range that IP is in, it will hit // the cache var ipNetwork *rdap.IPNetwork // See which type of argument we have and parse it if ip := net.ParseIP(query); ip != nil { // Check if the IP is in the cache ipNetwork, hit = s.IPCache.SearchIP(ip) } else if _, network, err := net.ParseCIDR(query); err == nil { // Check if the CIDR is in the cache ipNetwork, hit = s.IPCache.SearchCIDR(network) } else { return nil, fmt.Errorf("Invalid IP or CIDR: %v", query) } if !hit { // If we didn't hit the cache, then actually execute the query request := &rdap.Request{ Type: rdap.IPRequest, Query: query, } request = request.WithContext(ctx) response, err := s.ClientFac().Do(request) if err != nil { err = wrapRdapError(err, scope) s.Cache.StoreUnavailableItem(ctx, err, RdapCacheDuration, ck) return nil, err } if response.Object == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("No IP Network found for %s", query), Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } } var ok bool ipNetwork, ok = response.Object.(*rdap.IPNetwork) if !ok { return nil, fmt.Errorf("Expected IPNetwork, got %T", response.Object) } // Calculate the CIDR for this network network, err := calculateNetwork(ipNetwork.StartAddress, ipNetwork.EndAddress) if err != nil { return nil, err } // Cache this network s.IPCache.Store(network, ipNetwork, RdapCacheDuration) } attributes, err := sdp.ToAttributesCustom(map[string]any{ "conformance": ipNetwork.Conformance, "country": ipNetwork.Country, "endAddress": ipNetwork.EndAddress, "events": ipNetwork.Events, "handle": ipNetwork.Handle, "ipVersion": ipNetwork.IPVersion, "links": ipNetwork.Links, "name": ipNetwork.Name, "notices": ipNetwork.Notices, "objectClassName": ipNetwork.ObjectClassName, "parentHandle": ipNetwork.ParentHandle, "port43": ipNetwork.Port43, "remarks": ipNetwork.Remarks, "startAddress": ipNetwork.StartAddress, "status": ipNetwork.Status, "type": ipNetwork.Type, }, true, RDAPTransforms) if err != nil { return nil, err } item := &sdp.Item{ Type: s.Type(), UniqueAttribute: "handle", Attributes: attributes, Scope: scope, } // Loop over the entities and create linkedin item queries item.LinkedItemQueries = extractEntityLinks(ipNetwork.Entities) s.Cache.StoreItem(ctx, item, RdapCacheDuration, ck) return []*sdp.Item{item}, nil } // Calculates the network (like a CIDR) from a given start and end IP func calculateNetwork(startIP, endIP string) (*net.IPNet, error) { // Parse start and end IP addresses start := net.ParseIP(startIP) if start == nil { return nil, fmt.Errorf("Invalid start IP address: %s", startIP) } end := net.ParseIP(endIP) if end == nil { return nil, fmt.Errorf("Invalid end IP address: %s", endIP) } // Calculate the CIDR prefix length var prefixLen int for i := range start { startByte := start[i] endByte := end[i] if startByte != endByte { // Find the differing bit position diffBit := startByte ^ endByte // Count the number of consecutive zero bits in the differing byte for j := 7; j >= 0; j-- { if (diffBit & (1 << uint(j))) != 0 { break } prefixLen++ } break } prefixLen += 8 } mask := net.CIDRMask(int(prefixLen), 128) // Calculate the network address network := net.IPNet{ IP: start, Mask: mask, } return &network, nil } ================================================ FILE: stdlib-source/adapters/rdap-ip-network_test.go ================================================ package adapters import ( "context" "testing" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdpcache" ) func TestIpNetworkAdapterSearch(t *testing.T) { t.Parallel() src := &RdapIPNetworkAdapter{ ClientFac: func() *rdap.Client { return testRdapClient(t) }, Cache: sdpcache.NewMemoryCache(), IPCache: NewIPCache[*rdap.IPNetwork](), } items, err := src.Search(context.Background(), "global", "1.1.1.1", false) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatalf("Expected 1 item, got %v", len(items)) } item := items[0] if item.UniqueAttributeValue() != "1.1.1.0 - 1.1.1.255" { t.Errorf("Expected unique attribute value to be 1.1.1.0 - 1.1.1.0 - 1.1.1.255, got %v", item.UniqueAttributeValue()) } if len(item.GetLinkedItemQueries()) != 3 { t.Errorf("Expected 3 linked items, got %v", len(item.GetLinkedItemQueries())) } // Then run a get for that same thing and hit the cache _, err = src.Get(context.Background(), "global", item.UniqueAttributeValue(), false) if err != nil { t.Fatal(err) } } func TestCalculateNetwork(t *testing.T) { t.Parallel() tests := []struct { Start string End string Expected string }{ { Start: "10.0.0.0", End: "10.0.0.255", Expected: "10.0.0.0/24", }, { Start: "10.0.0.0", End: "10.0.0.7", Expected: "10.0.0.0/29", }, } for _, test := range tests { network, err := calculateNetwork(test.Start, test.End) if err != nil { t.Fatal(err) } if network.String() != test.Expected { t.Errorf("Expected network to be %v, got %v", test.Expected, network.String()) } } } ================================================ FILE: stdlib-source/adapters/rdap-nameserver.go ================================================ package adapters import ( "context" "fmt" "strings" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdp-go" "github.com/overmindtech/cli/go/sdpcache" ) type RdapNameserverAdapter struct { ClientFac func() *rdap.Client Cache sdpcache.Cache } // Type is the type of items that this returns func (s *RdapNameserverAdapter) Type() string { return "rdap-nameserver" } // Name Returns the name of the adapter func (s *RdapNameserverAdapter) Name() string { return "rdap" } // Weighting of duplicate adapters func (s *RdapNameserverAdapter) Weight() int { return 100 } func (s *RdapNameserverAdapter) Metadata() *sdp.AdapterMetadata { return rdapNameserverMetadata } var rdapNameserverMetadata = Metadata.Register(&sdp.AdapterMetadata{ DescriptiveName: "RDAP Nameserver", Type: "rdap-nameserver", SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{ Search: true, SearchDescription: "Search for the RDAP entry for a nameserver by its full URL e.g. \"https://rdap.verisign.com/com/v1/nameserver/NS4.GOOGLE.COM\"", }, PotentialLinks: []string{"dns", "ip", "rdap-entity"}, Category: sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, }) func (s *RdapNameserverAdapter) Scopes() []string { return []string{ "global", } } func (s *RdapNameserverAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { // Check the cache for GET requests, if we don't hit the cache then there is // nothing we can do though hit, _, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_GET, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { if len(items) > 0 { return items[0], nil } } // This adapter doesn't technically support the GET method (since you can't // use the handle to query for an IP network) return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "Nameservers can't be queried by handle, use the SEARCH method instead", Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } } func (s *RdapNameserverAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: "Nameservers cannot be listed, use the SEARCH method instead", Scope: scope, SourceName: s.Name(), ItemType: s.Type(), } } // Search for the nameserver using the full RDAP URL. This is required since // nameserver queries are not capable of being bootstrapped and we need to know // which nameserver to query from the beginning. Fortunately domain queries can // be bootstrapped, so we can use the domain query to find the nameserver in the // link func (s *RdapNameserverAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { hit, ck, items, sdpErr, done := s.Cache.Lookup(ctx, s.Name(), sdp.QueryMethod_SEARCH, scope, s.Type(), query, ignoreCache) defer done() if sdpErr != nil { return nil, sdpErr } if hit { return items, nil } parsed, err := parseRdapUrl(query) if err != nil { return nil, err } request := &rdap.Request{ Type: rdap.NameserverRequest, Query: parsed.Query, Server: parsed.ServerRoot, } request.WithContext(ctx) response, err := s.ClientFac().Do(request) if err != nil { err = wrapRdapError(err, scope) s.Cache.StoreUnavailableItem(ctx, err, RdapCacheDuration, ck) return nil, err } if response.Object == nil { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, ErrorString: fmt.Sprintf("No nameserver found for %s", query), SourceName: s.Name(), Scope: scope, ItemType: s.Type(), } } nameserver, ok := response.Object.(*rdap.Nameserver) if !ok { return nil, fmt.Errorf("Expected Nameserver, got %T", response.Object) } attributes, err := sdp.ToAttributesCustom(map[string]any{ "conformance": nameserver.Conformance, "objectClassName": nameserver.ObjectClassName, "notices": nameserver.Notices, "handle": nameserver.Handle, "ldhName": nameserver.LDHName, "unicodeName": nameserver.UnicodeName, "ipAddresses": nameserver.IPAddresses, "status": nameserver.Status, "remarks": nameserver.Remarks, "links": nameserver.Links, "port43": nameserver.Port43, "events": nameserver.Events, }, true, RDAPTransforms) if err != nil { return nil, err } item := &sdp.Item{ Type: s.Type(), UniqueAttribute: "ldhName", Attributes: attributes, Scope: scope, } // Link entities item.LinkedItemQueries = append(item.LinkedItemQueries, extractEntityLinks(nameserver.Entities)...) // Nameservers are resolvable in DNS too item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "dns", Method: sdp.QueryMethod_SEARCH, Query: strings.ToLower(nameserver.LDHName), Scope: "global", }, }) // Link IP addresses if nameserver.IPAddresses != nil { allIPs := append(nameserver.IPAddresses.V4, nameserver.IPAddresses.V6...) for _, ip := range allIPs { item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ Query: &sdp.Query{ Type: "ip", Method: sdp.QueryMethod_GET, Query: ip, Scope: "global", }, }) } } s.Cache.StoreItem(ctx, item, RdapCacheDuration, ck) return []*sdp.Item{item}, nil } ================================================ FILE: stdlib-source/adapters/rdap-nameserver_test.go ================================================ package adapters import ( "context" "testing" "github.com/openrdap/rdap" "github.com/overmindtech/cli/go/sdpcache" ) func TestNameserverAdapterSearch(t *testing.T) { t.Parallel() src := &RdapNameserverAdapter{ ClientFac: func() *rdap.Client { return testRdapClient(t) }, Cache: sdpcache.NewNoOpCache(), } items, err := src.Search(context.Background(), "global", "https://rdap.verisign.com/com/v1/nameserver/NS4.GOOGLE.COM", false) if err != nil { t.Fatal(err) } if len(items) != 1 { t.Fatal("Expected 1 item") } item := items[0] err = item.Validate() if err != nil { t.Error(err) } } ================================================ FILE: stdlib-source/adapters/test/data.go ================================================ package test import ( "fmt" "sync/atomic" "time" "github.com/overmindtech/cli/go/sdp-go" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) // This test data is designed to provide a full-featured graph to exercise all // parts of the system. The graph is as follows: // // +----------+ +--------+ // | knitting | | admins | // +----------+ +--------+ // | // | // v // +--------------+ b +--------+ b // | motorcycling | <-- | dylan | -+ // +--------------+ +--------+ | // |b | // L | // vb | // +--------+ b +--------+ | // | kibble | <-- | manny | | // +--------+ +--------+ | // |b | // S S // v | // +--------+ <+ // HOBBIES <--S-- | london | +------+ // +--------+ --S--> | soho | // |b b +------+ // | // vb // +----+ // | gb | // +----+ // // arrows indicate edge directions. `b` annotations indicate blast radius // propagation. `L` indicates a LIST edge, `S` indicates a SEARCH edge. // this global atomic variable keeps track of the generation count for test // items. It is increased every time a new item is created, and is used to // ensure that users of the test-adapter can determine that queries have hit the // actual adapter and were not cached. var generation atomic.Int32 // createTestItem Creates a simple item for testing func createTestItem(typ, value string) *sdp.Item { thisGen := generation.Add(1) return &sdp.Item{ Type: typ, UniqueAttribute: "name", Attributes: &sdp.ItemAttributes{ AttrStruct: &structpb.Struct{ Fields: map[string]*structpb.Value{ "name": { Kind: &structpb.Value_StringValue{ StringValue: value, }, }, "generation": { Kind: &structpb.Value_NumberValue{ // good enough for google, good enough for testing NumberValue: float64(thisGen), }, }, }, }, }, Metadata: &sdp.Metadata{ SourceName: fmt.Sprintf("test-%v-adapter", typ), Timestamp: timestamppb.Now(), SourceDuration: durationpb.New(time.Second), SourceDurationPerItem: durationpb.New(time.Second), Hidden: true, }, Scope: "test", // TODO(LIQs): delete empty data LinkedItemQueries: []*sdp.LinkedItemQuery{}, LinkedItems: []*sdp.LinkedItem{}, } } func admins() *sdp.Item { i := createTestItem("test-group", "test-admins") // TODO(LIQs): convert to returning edges i.LinkedItemQueries = []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "test-person", Method: sdp.QueryMethod_GET, Query: "test-dylan", Scope: "test", }, }, } return i } func dylan() *sdp.Item { i := createTestItem("test-person", "test-dylan") i.LinkedItemQueries = []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "test-dog", Method: sdp.QueryMethod_LIST, Scope: "test", }, }, { Query: &sdp.Query{ Type: "test-hobby", Method: sdp.QueryMethod_GET, Query: "test-motorcycling", Scope: "test", }, }, { Query: &sdp.Query{ Type: "test-location", Method: sdp.QueryMethod_SEARCH, Query: "test-london", Scope: "test", }, }, } return i } func manny() *sdp.Item { i := createTestItem("test-dog", "test-manny") i.LinkedItemQueries = []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "test-location", Method: sdp.QueryMethod_SEARCH, Query: "test-london", Scope: "test", }, }, { Query: &sdp.Query{ Type: "test-food", Method: sdp.QueryMethod_GET, Query: "test-kibble", Scope: "test", }, }, } return i } func kibble() *sdp.Item { return createTestItem("test-food", "test-kibble") } func motorcycling() *sdp.Item { return createTestItem("test-hobby", "test-motorcycling") } func knitting() *sdp.Item { return createTestItem("test-hobby", "test-knitting") } func london() *sdp.Item { l := createTestItem("test-location", "test-london") l.LinkedItemQueries = []*sdp.LinkedItemQuery{ { Query: &sdp.Query{ Type: "test-region", Method: sdp.QueryMethod_GET, Query: "test-gb", Scope: "test", }, }, { Query: &sdp.Query{ Type: "test-hobby", Method: sdp.QueryMethod_SEARCH, Query: "*", Scope: "test", }, }, { Query: &sdp.Query{ Type: "test-location", Method: sdp.QueryMethod_SEARCH, Query: "test-soho", Scope: "test", }, }, } return l } func soho() *sdp.Item { l := createTestItem("test-location", "test-soho") l.LinkedItemQueries = []*sdp.LinkedItemQuery{} return l } func gb() *sdp.Item { return createTestItem("test-region", "test-gb") } ================================================ FILE: stdlib-source/adapters/test/testdog.go ================================================ package test import ( "context" "github.com/overmindtech/cli/go/sdp-go" ) // TestDogAdapter An adapter of `dog` items for automated tests. type TestDogAdapter struct{} // Type is the type of items that this returns func (s *TestDogAdapter) Type() string { return "test-dog" } // Name Returns the name of the backend func (s *TestDogAdapter) Name() string { return "stdlib-test-dog" } // Metadata Returns the metadata for the adapter func (s *TestDogAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: s.Name(), } } // Weighting of duplicate adapters func (s *TestDogAdapter) Weight() int { return 100 } // List of scopes that this adapter is capable of find items for func (s *TestDogAdapter) Scopes() []string { return []string{ "test", } } func (s *TestDogAdapter) Hidden() bool { return true } // Gets a single item. This expects a name func (d *TestDogAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "test-manny": return manny(), nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } func (d *TestDogAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } return []*sdp.Item{manny()}, nil } func (d *TestDogAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "", "*", "test-manny": return []*sdp.Item{manny()}, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } ================================================ FILE: stdlib-source/adapters/test/testfood.go ================================================ package test import ( "context" "github.com/overmindtech/cli/go/sdp-go" ) // TestFoodAdapter A adapter of `food` items for automated tests. type TestFoodAdapter struct{} // Type is the type of items that this returns func (s *TestFoodAdapter) Type() string { return "test-food" } // Name Returns the name of the backend func (s *TestFoodAdapter) Name() string { return "stdlib-test-food" } func (s *TestFoodAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: s.Name(), } } // Weighting of duplicate adapters func (s *TestFoodAdapter) Weight() int { return 100 } // List of scopes that this adapter is capable of find items for func (s *TestFoodAdapter) Scopes() []string { return []string{ "test", } } func (s *TestFoodAdapter) Hidden() bool { return true } // Gets a single item. This expects a name func (d *TestFoodAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "test-kibble": return kibble(), nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } func (d *TestFoodAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } return []*sdp.Item{kibble()}, nil } func (d *TestFoodAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "", "*", "test-kibble": return []*sdp.Item{kibble()}, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } ================================================ FILE: stdlib-source/adapters/test/testgroup.go ================================================ package test import ( "context" "github.com/overmindtech/cli/go/sdp-go" ) // TestGroupAdapter A adapter of `group` items for automated tests. type TestGroupAdapter struct{} // Type is the type of items that this returns func (s *TestGroupAdapter) Type() string { return "test-group" } // Name Returns the name of the backend func (s *TestGroupAdapter) Name() string { return "stdlib-test-group" } // Weighting of duplicate adapters func (s *TestGroupAdapter) Weight() int { return 100 } func (s *TestGroupAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: s.Name(), } } // List of scopes that this adapter is capable of find items for func (s *TestGroupAdapter) Scopes() []string { return []string{ "test", } } func (s *TestGroupAdapter) Hidden() bool { return true } // Gets a single item. This expects a name func (d *TestGroupAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "test-admins": return admins(), nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } func (d *TestGroupAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } return []*sdp.Item{admins()}, nil } func (d *TestGroupAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "", "*", "test-admins": return []*sdp.Item{admins()}, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } ================================================ FILE: stdlib-source/adapters/test/testhobby.go ================================================ package test import ( "context" "github.com/overmindtech/cli/go/sdp-go" ) // TestHobbyAdapter A adapter of `hobby` items for automated tests. type TestHobbyAdapter struct{} // Type is the type of items that this returns func (s *TestHobbyAdapter) Type() string { return "test-hobby" } // Name Returns the name of the backend func (s *TestHobbyAdapter) Name() string { return "stdlib-test-hobby" } func (s *TestHobbyAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: s.Name(), } } // Weighting of duplicate adapters func (s *TestHobbyAdapter) Weight() int { return 100 } // List of scopes that this adapter is capable of find items for func (s *TestHobbyAdapter) Scopes() []string { return []string{ "test", } } func (s *TestHobbyAdapter) Hidden() bool { return true } // Gets a single item. This expects a name func (d *TestHobbyAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "test-motorcycling": return motorcycling(), nil case "test-knitting": return knitting(), nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } func (d *TestHobbyAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } return []*sdp.Item{motorcycling(), knitting()}, nil } func (d *TestHobbyAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "", "*": return []*sdp.Item{motorcycling(), knitting()}, nil case "test-motorcycling": return []*sdp.Item{motorcycling()}, nil case "test-knitting": return []*sdp.Item{knitting()}, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } ================================================ FILE: stdlib-source/adapters/test/testlocation.go ================================================ package test import ( "context" "github.com/overmindtech/cli/go/sdp-go" ) // TestLocationAdapter A adapter of `location` items for automated tests. type TestLocationAdapter struct{} // Type is the type of items that this returns func (s *TestLocationAdapter) Type() string { return "test-location" } // Name Returns the name of the backend func (s *TestLocationAdapter) Name() string { return "stdlib-test-location" } // Weighting of duplicate adapters func (s *TestLocationAdapter) Weight() int { return 100 } func (s *TestLocationAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: s.Name(), } } // List of scopes that this adapter is capable of find items for func (s *TestLocationAdapter) Scopes() []string { return []string{ "test", } } func (s *TestLocationAdapter) Hidden() bool { return true } // Gets a single item. This expects a name func (d *TestLocationAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "test-london": return london(), nil case "test-soho": return soho(), nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } func (d *TestLocationAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } return []*sdp.Item{london(), soho()}, nil } func (d *TestLocationAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "", "*": return []*sdp.Item{london(), soho()}, nil case "test-london": return []*sdp.Item{london()}, nil case "test-soho": return []*sdp.Item{soho()}, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } ================================================ FILE: stdlib-source/adapters/test/testperson.go ================================================ package test import ( "context" "github.com/overmindtech/cli/go/sdp-go" ) // TestPersonAdapter A adapter of `person` items for automated tests. type TestPersonAdapter struct{} // Type is the type of items that this returns func (s *TestPersonAdapter) Type() string { return "test-person" } // Name Returns the name of the backend func (s *TestPersonAdapter) Name() string { return "stdlib-test-person" } // Weighting of duplicate adapters func (s *TestPersonAdapter) Weight() int { return 100 } // List of scopes that this adapter is capable of find items for func (s *TestPersonAdapter) Scopes() []string { return []string{ "test", } } func (s *TestPersonAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: s.Name(), } } func (s *TestPersonAdapter) Hidden() bool { return true } // Gets a single item. This expects a name func (d *TestPersonAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "test-dylan": return dylan(), nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } func (d *TestPersonAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } return []*sdp.Item{dylan()}, nil } func (d *TestPersonAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "", "*", "test-dylan": return []*sdp.Item{dylan()}, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } ================================================ FILE: stdlib-source/adapters/test/testregion.go ================================================ package test import ( "context" "github.com/overmindtech/cli/go/sdp-go" ) // TestRegionAdapter A adapter of `region` items for automated tests. type TestRegionAdapter struct{} // Type is the type of items that this returns func (s *TestRegionAdapter) Type() string { return "test-region" } // Name Returns the name of the backend func (s *TestRegionAdapter) Name() string { return "stdlib-test-region" } // Weighting of duplicate adapters func (s *TestRegionAdapter) Weight() int { return 100 } // List of scopes that this adapter is capable of find items for func (s *TestRegionAdapter) Scopes() []string { return []string{ "test", } } func (s *TestRegionAdapter) Metadata() *sdp.AdapterMetadata { return &sdp.AdapterMetadata{ Type: s.Type(), DescriptiveName: s.Name(), } } func (s *TestRegionAdapter) Hidden() bool { return true } // Gets a single item. This expects a name func (d *TestRegionAdapter) Get(ctx context.Context, scope string, query string, ignoreCache bool) (*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "test-gb": return gb(), nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } func (d *TestRegionAdapter) List(ctx context.Context, scope string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } return []*sdp.Item{gb()}, nil } func (d *TestRegionAdapter) Search(ctx context.Context, scope string, query string, ignoreCache bool) ([]*sdp.Item, error) { if scope != "test" { return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOSCOPE, ErrorString: "test queries only supported in 'test' scope", Scope: scope, } } switch query { case "", "*", "test-gb": return []*sdp.Item{gb()}, nil default: return nil, &sdp.QueryError{ ErrorType: sdp.QueryError_NOTFOUND, Scope: scope, } } } ================================================ FILE: stdlib-source/build/package/Dockerfile ================================================ # Build the source binary FROM golang:1.26.2-alpine3.23 AS builder ARG TARGETOS ARG TARGETARCH ARG BUILD_VERSION ARG BUILD_COMMIT # required for accessing the private dependencies and generating version descriptor RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg \ go mod download COPY go/ go/ COPY stdlib-source/ stdlib-source/ # Build RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source stdlib-source/main.go FROM alpine:3.23.4 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 ENTRYPOINT ["/source"] ================================================ FILE: stdlib-source/cmd/root.go ================================================ package cmd import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/getsentry/sentry-go" "github.com/overmindtech/cli/go/discovery" "github.com/overmindtech/cli/go/logging" "github.com/overmindtech/cli/stdlib-source/adapters" "github.com/overmindtech/cli/go/tracing" "github.com/spf13/cobra" "github.com/spf13/pflag" log "github.com/sirupsen/logrus" "github.com/spf13/viper" ) var cfgFile string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "stdlib-source", Short: "Standard library of remotely accessible items", SilenceUsage: true, Long: `Gets details of items that are globally scoped (usually) and able to be queried without authentication. `, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() defer tracing.LogRecoverToReturn(ctx, "stdlib-source.root") // get engine config engineConfig, err := discovery.EngineConfigFromViper("stdlib", tracing.Version()) if err != nil { log.WithError(err).Error("Could not get engine config from viper") return fmt.Errorf("could not get engine config from viper: %w", err) } reverseDNS := viper.GetBool("reverse-dns") log.WithFields(log.Fields{ "reverse-dns": reverseDNS, }).Info("Got config") // Create a basic engine first e, err := discovery.NewEngine(engineConfig) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not create engine") return fmt.Errorf("could not create engine: %w", err) } // Start HTTP server for health checks before initialization healthCheckPort := viper.GetInt("health-check-port") healthCheckDNSAdapter := adapters.NewDNSAdapterForHealthCheck() // Set up health checks if e.EngineConfig.HeartbeatOptions == nil { e.EngineConfig.HeartbeatOptions = &discovery.HeartbeatOptions{} } // ReadinessCheck verifies the DNS adapter is working // Timeout is handled by SendHeartbeat, HTTP handlers rely on request context e.SetReadinessCheck(func(ctx context.Context) error { _, err := healthCheckDNSAdapter.Search(ctx, "global", "www.google.com", true) if err != nil { return fmt.Errorf("test dns lookup failed: %w", err) } return nil }) e.ServeHealthProbes(healthCheckPort) // Start the engine (NATS connection) before adapter init so heartbeats work err = e.Start(ctx) if err != nil { sentry.CaptureException(err) log.WithError(err).Error("Could not start engine") return fmt.Errorf("could not start engine: %w", err) } // Stdlib adapters are all in-memory (no external API calls), so no // InitialiseAdapters retry wrapper needed — just use SetInitError on failure. err = adapters.InitializeAdapters(ctx, e, reverseDNS) if err != nil { initErr := fmt.Errorf("could not initialize stdlib adapters: %w", err) log.WithError(initErr).Error("Stdlib source initialization failed - pod will stay running with error status") e.SetInitError(initErr) sentry.CaptureException(initErr) } else { e.MarkAdaptersInitialized() // Start() already launched the heartbeat loop, so StartSendingHeartbeats // is a no-op here. Send an immediate heartbeat so the API server learns // the source is healthy without waiting for the next tick. if err := e.SendHeartbeat(ctx, nil); err != nil { log.WithError(err).Warn("Failed to send post-init heartbeat") } } <-ctx.Done() log.Info("Stopping engine") err = e.Stop() if err != nil { log.WithError(err).Error("Could not stop engine") return fmt.Errorf("could not stop engine: %w", err) } log.Info("Stopped") return nil }, } // Execute adds all child commands to the root command and sets flags // appropriately. This is called by main.main(). It only needs to happen once to // the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } func init() { cobra.OnInitialize(initConfig) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. var logLevel string // General config options rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "/etc/srcman/config/source.yaml", "config file path") rootCmd.PersistentFlags().StringVar(&logLevel, "log", "info", "Set the log level. Valid values: panic, fatal, error, warn, info, debug, trace") cobra.CheckErr(viper.BindEnv("log", "STDLIB_LOG", "LOG")) // fallback to global config rootCmd.PersistentFlags().Bool("reverse-dns", false, "If true, will perform reverse DNS lookups on IP addresses") // engine config options discovery.AddEngineFlags(rootCmd) rootCmd.PersistentFlags().IntP("health-check-port", "", 8089, "The port that the health check should run on") cobra.CheckErr(viper.BindEnv("health-check-port", "STDLIB_HEALTH_CHECK_PORT", "HEALTH_CHECK_PORT", "STDLIB_SERVICE_PORT", "SERVICE_PORT")) // new names + backwards compat // tracing rootCmd.PersistentFlags().String("honeycomb-api-key", "", "If specified, configures opentelemetry libraries to submit traces to honeycomb") cobra.CheckErr(viper.BindEnv("honeycomb-api-key", "STDLIB_HONEYCOMB_API_KEY", "HONEYCOMB_API_KEY")) // fallback to global config rootCmd.PersistentFlags().String("sentry-dsn", "", "If specified, configures sentry libraries to capture errors") cobra.CheckErr(viper.BindEnv("sentry-dsn", "STDLIB_SENTRY_DSN", "SENTRY_DSN")) // fallback to global config rootCmd.PersistentFlags().String("run-mode", "release", "Set the run mode for this service, 'release', 'debug' or 'test'. Defaults to 'release'.") rootCmd.PersistentFlags().Bool("json-log", true, "Set to false to emit logs as text for easier reading in development.") cobra.CheckErr(viper.BindEnv("json-log", "STDLIB_SOURCE_JSON_LOG", "JSON_LOG")) // fallback to global config // Bind these to viper cobra.CheckErr(viper.BindPFlags(rootCmd.PersistentFlags())) // Run this before we do anything to set up the loglevel rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if lvl, err := log.ParseLevel(logLevel); err == nil { log.SetLevel(lvl) } else { log.SetLevel(log.InfoLevel) log.WithFields(log.Fields{ "error": err, }).Error("Could not parse log level") } log.AddHook(TerminationLogHook{}) // Bind flags that haven't been set to the values from viper of we have them var bindErr error cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { // Bind the flag to viper only if it has a non-empty default if f.DefValue != "" || f.Changed { if err := viper.BindPFlag(f.Name, f); err != nil { bindErr = err } } }) if bindErr != nil { log.WithError(bindErr).Error("Could not bind flag to viper") return fmt.Errorf("could not bind flag to viper: %w", bindErr) } if viper.GetBool("json-log") { logging.ConfigureLogrusJSON(log.StandardLogger()) } if err := tracing.InitTracerWithUpstreams("stdlib-source", viper.GetString("honeycomb-api-key"), viper.GetString("sentry-dsn")); err != nil { log.WithError(err).Error("could not init tracer") return fmt.Errorf("could not init tracer: %w", err) } return nil } // shut down tracing at the end of the process rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { tracing.ShutdownTracer(context.Background()) } } // initConfig reads in config file and ENV variables if set. func initConfig() { viper.SetConfigFile(cfgFile) replacer := strings.NewReplacer("-", "_") viper.SetEnvKeyReplacer(replacer) viper.SetEnvPrefix("STDLIB") viper.AutomaticEnv() // read in environment variables that match // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil { log.Infof("Using config file: %v", viper.ConfigFileUsed()) } } // TerminationLogHook A hook that logs fatal errors to the termination log type TerminationLogHook struct{} func (t TerminationLogHook) Levels() []log.Level { return []log.Level{log.FatalLevel} } func (t TerminationLogHook) Fire(e *log.Entry) error { // shutdown tracing first to ensure all spans are flushed tracing.ShutdownTracer(context.Background()) tLog, err := os.OpenFile("/dev/termination-log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } var message string message = e.Message for k, v := range e.Data { message = fmt.Sprintf("%v %v=%v", message, k, v) } _, err = tLog.WriteString(message) return err } ================================================ FILE: stdlib-source/main.go ================================================ /* Copyright © 2021 {AUTHOR} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "github.com/overmindtech/cli/stdlib-source/cmd" _ "go.uber.org/automaxprocs" ) func main() { cmd.Execute() } ================================================ FILE: tfutils/aws_config.go ================================================ package tfutils import ( "bytes" "context" "fmt" "maps" "net/http" "net/url" "os" "path/filepath" "strings" "github.com/aws/aws-sdk-go-v2/aws" awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/terraform-config-inspect/tfconfig" "github.com/mitchellh/go-homedir" "github.com/zclconf/go-cty/cty" ctyjson "github.com/zclconf/go-cty/cty/json" "golang.org/x/net/http/httpproxy" ) // A minimal struct that will decode the bare minimum to allow us to avoid // looking at things we don't want to type basicProviderFile struct { Providers []genericProvider `hcl:"provider,block"` Remain hcl.Body `hcl:",remain"` } // Bare minimum provider block that allows us to parse the provider name and // nothing else, then pass the remaining to a more specific scope type genericProvider struct { Name string `hcl:"name,label"` Remain hcl.Body `hcl:",remain"` } // This struct allows us to parse any HCL file that contains an AWS provider // using the gohcl library. type ProviderFile struct { Providers []AWSProvider `hcl:"provider,block"` // Throw any additional stuff into here so it doesn't fail Remain hcl.Body `hcl:",remain"` } // This struct represents an AWS provider block in a terraform file. It is // intended to be used with the gohcl library to parse HCL files. // // The fields are based on the AWS provider configuration documentation: // https://registry.terraform.io/providers/hashicorp/aws/latest/docs#provider-configuration type AWSProvider struct { Name string `hcl:"name,label" yaml:"name,omitempty"` Alias string `hcl:"alias,optional" yaml:"alias,omitempty"` AccessKey string `hcl:"access_key,optional" yaml:"access_key,omitempty"` SecretKey string `hcl:"secret_key,optional" yaml:"secret_key,omitempty"` Token string `hcl:"token,optional" yaml:"token,omitempty"` Region string `hcl:"region,optional" yaml:"region,omitempty"` CustomCABundle string `hcl:"custom_ca_bundle,optional" yaml:"custom_ca_bundle,omitempty"` EC2MetadataServiceEndpoint string `hcl:"ec2_metadata_service_endpoint,optional" yaml:"ec2_metadata_service_endpoint,omitempty"` EC2MetadataServiceEndpointMode string `hcl:"ec2_metadata_service_endpoint_mode,optional" yaml:"ec2_metadata_service_endpoint_mode,omitempty"` SkipMetadataAPICheck bool `hcl:"skip_metadata_api_check,optional" yaml:"skip_metadata_api_check,omitempty"` HTTPProxy string `hcl:"http_proxy,optional" yaml:"http_proxy,omitempty"` HTTPSProxy string `hcl:"https_proxy,optional" yaml:"https_proxy,omitempty"` NoProxy string `hcl:"no_proxy,optional" yaml:"no_proxy,omitempty"` MaxRetries int `hcl:"max_retries,optional" yaml:"max_retries,omitempty"` Profile string `hcl:"profile,optional" yaml:"profile,omitempty"` RetryMode string `hcl:"retry_mode,optional" yaml:"retry_mode,omitempty"` SharedConfigFiles []string `hcl:"shared_config_files,optional" yaml:"shared_config_files,omitempty"` SharedCredentialsFiles []string `hcl:"shared_credentials_files,optional" yaml:"shared_credentials_files,omitempty"` UseDualStackEndpoint bool `hcl:"use_dualstack_endpoint,optional" yaml:"use_dualstack_endpoint,omitempty"` UseFIPSEndpoint bool `hcl:"use_fips_endpoint,optional" yaml:"use_fips_endpoint,omitempty"` AssumeRole *AssumeRole `hcl:"assume_role,block" yaml:"assume_role,omitempty"` AssumeRoleWithWebIdentity *AssumeRoleWithWebIdentity `hcl:"assume_role_with_web_identity,block" yaml:"assume_role_with_web_identity,omitempty"` // Throw any additional stuff into here so it doesn't fail Remain hcl.Body `hcl:",remain" yaml:"-"` } // Fields that are used for assuming a role, see: // https://registry.terraform.io/providers/hashicorp/aws/latest/docs#assuming-an-iam-role type AssumeRole struct { Duration string `hcl:"duration,optional" yaml:"duration,omitempty"` ExternalID string `hcl:"external_id,optional" yaml:"external_id,omitempty"` Policy string `hcl:"policy,optional" yaml:"policy,omitempty"` PolicyARNs []string `hcl:"policy_arns,optional" yaml:"policy_arns,omitempty"` RoleARN string `hcl:"role_arn,optional" yaml:"role_arn,omitempty"` SessionName string `hcl:"session_name,optional" yaml:"session_name,omitempty"` SourceIdentity string `hcl:"source_identity,optional" yaml:"source_identity,omitempty"` Tags map[string]string `hcl:"tags,optional" yaml:"tags,omitempty"` TransitiveTagKeys []string `hcl:"transitive_tag_keys,optional" yaml:"transitive_tag_keys,omitempty"` // Throw any additional stuff into here so it doesn't fail Remain hcl.Body `hcl:",remain" yaml:"-"` } // Fields that are used for assuming a role with web identity, see: // https://registry.terraform.io/providers/hashicorp/aws/latest/docs#assuming-an-iam-role-using-a-web-identity type AssumeRoleWithWebIdentity struct { Duration string `hcl:"duration,optional" yaml:"duration,omitempty"` Policy string `hcl:"policy,optional" yaml:"policy,omitempty"` PolicyARNs []string `hcl:"policy_arns,optional" yaml:"policy_arns,omitempty"` RoleARN string `hcl:"role_arn,optional" yaml:"role_arn,omitempty"` SessionName string `hcl:"session_name,optional" yaml:"session_name,omitempty"` WebIdentityToken string `hcl:"web_identity_token,optional" yaml:"web_identity_token,omitempty"` WebIdentityTokenFile string `hcl:"web_identity_token_file,optional" yaml:"web_identity_token_file,omitempty"` // Throw any additional stuff into here so it doesn't fail Remain hcl.Body `hcl:",remain" yaml:"-"` } // restore the default value to a cty value after tfconfig has // passed it through JSON to "void the caller needing to deal with // cty" func ctyFromTfconfig(v any) cty.Value { switch def := v.(type) { case bool: return cty.BoolVal(def) case float64: return cty.NumberFloatVal(def) case int: return cty.NumberIntVal(int64(def)) case string: return cty.StringVal(def) case []any: d := make([]cty.Value, 0, len(def)) for _, v := range def { d = append(d, ctyFromTfconfig(v)) } return cty.ListVal(d) case map[string]any: d := map[string]cty.Value{} for k, v := range def { d[k] = ctyFromTfconfig(v) } return cty.ObjectVal(d) default: return cty.NilVal } } // Loads the eval context in the same way that Terraform does, this means it // supports TF_VAR_* environment variables, terraform.tfvars, // terraform.tfvars.json, *.auto.tfvars, and *.auto.tfvars.json files, and -var // and -var-file arguments. These are processed in the order that Terraform uses // and should result in the same set of variables being loaded. // // The args parameter should contain the raw arguments that were passed to // terraform. This includes: -var and -var-file arguments, and should be passed // as a list of strings. // // The env parameter should contain the environment variables that were present // when Terraform was run. These should be passed as a []strings (from // `os.Environ()`), variables beginning with TF_VAR_ will be used. func LoadEvalContext(args []string, env []string) (*hcl.EvalContext, error) { // Note that Terraform has a hierarchy of variable sources, which we need // to respect, with later sources taking precedence over earlier ones: // // * Environment variables // * The terraform.tfvars file, if present. // * The terraform.tfvars.json file, if present. // * Any *.auto.tfvars or *.auto.tfvars.json files, processed in lexical // order of their filenames. // * Any -var and -var-file options on the command line, in the order they // are provided. (This includes variables set by an HCP Terraform workspace.) evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } // Parse variable declarations from the Terraform configuration. This will // supply any default values from variables that are declared in the root // module. mod, diags := tfconfig.LoadModule(".") if diags.HasErrors() { return nil, fmt.Errorf("error loading terraform module: %w", diags) } if mod.Diagnostics.HasErrors() { return nil, fmt.Errorf("loaded terraform module with errors: %w", mod.Diagnostics) } vars := map[string]cty.Value{} for _, v := range mod.Variables { if v.Default != nil { vars[v.Name] = ctyFromTfconfig(v.Default) } } evalCtx.Variables["var"] = cty.ObjectVal(vars) // Parse environment variables. Note that if a root module variable uses a // type constraint to require a complex value (list, set, map, object, or // tuple), Terraform will instead attempt to parse its value using the same // syntax used within variable definitions files, which requires careful // attention to the string escaping rules in your shell: // // ```shell // export TF_VAR_availability_zone_names='["us-west-1b","us-west-1d"]' // ``` // for _, envVar := range env { // If the key starts with TF_VAR_, we need to strip that off, and we // also want to filter on only these variables if strings.HasPrefix(envVar, "TF_VAR_") { err := ParseFlagValue(envVar[7:], &evalCtx) if err != nil { return nil, err } } else { continue } } // Parse the terraform.tfvars file, if present. if _, err := os.Stat("terraform.tfvars"); err == nil { // Parse the HCL file err = ParseTFVarsFile("terraform.tfvars", &evalCtx) if err != nil { return nil, err } } // Parse the terraform.tfvars.json file, if present. if _, err := os.Stat("terraform.tfvars.json"); err == nil { // Parse the JSON file err = ParseTFVarsJSONFile("terraform.tfvars.json", &evalCtx) if err != nil { return nil, err } } // Parse *.auto.tfvars or *.auto.tfvars.json files, processed in lexical // order of their filenames. matches, _ := filepath.Glob("*.auto.tfvars") for _, file := range matches { // Parse the HCL file err := ParseTFVarsFile(file, &evalCtx) if err != nil { return nil, err } } matches, _ = filepath.Glob("*.auto.tfvars.json") for _, file := range matches { // Parse the JSON file err := ParseTFVarsJSONFile(file, &evalCtx) if err != nil { return nil, err } } // Parse vars from args, this means the var files and raw vars, in the order // they are provided err := ParseVarsArgs(args, &evalCtx) if err != nil { return nil, err } return &evalCtx, nil } // Parses a given TF Vars file into the given eval context func ParseTFVarsFile(file string, dest *hcl.EvalContext) error { // Read the file b, err := os.ReadFile(file) if err != nil { return fmt.Errorf("error reading terraform vars file: %w", err) } // Parse the HCL file parser := hclparse.NewParser() parsedFile, diag := parser.ParseHCL(b, file) if diag.HasErrors() { return fmt.Errorf("error parsing terraform vars file: %w", diag) } // Decode the body var vars map[string]cty.Value diag = gohcl.DecodeBody(parsedFile.Body, nil, &vars) if diag.HasErrors() { return fmt.Errorf("error decoding terraform vars file: %w", diag) } // Merge the vars into the eval context setVariables(dest, vars) return nil } // setVariable sets a variable in the given eval context func setVariable(dest *hcl.EvalContext, key string, value cty.Value) { variablesValue, ok := dest.Variables["var"] if !ok { variablesValue = cty.EmptyObjectVal } variables := variablesValue.AsValueMap() if variables == nil { variables = map[string]cty.Value{} } variables[key] = value dest.Variables["var"] = cty.ObjectVal(variables) } // setVariables sets multiple variables in the given eval context func setVariables(dest *hcl.EvalContext, variables map[string]cty.Value) { variablesValue, ok := dest.Variables["var"] if !ok { variablesValue = cty.EmptyObjectVal } variablesDest := variablesValue.AsValueMap() if variablesDest == nil { variablesDest = map[string]cty.Value{} } maps.Copy(variablesDest, variables) dest.Variables["var"] = cty.ObjectVal(variablesDest) } // Parses a given TF Vars JSON file into the given eval context. In this each // key becomes a variable as par the Hashicorp docs: // https://developer.hashicorp.com/terraform/language/values/variables#variable-definitions-tfvars-files func ParseTFVarsJSONFile(file string, dest *hcl.EvalContext) error { // Read the file b, err := os.ReadFile(file) if err != nil { return fmt.Errorf("error reading terraform vars file: %w", err) } // Read the type structure form the file ctyType, err := ctyjson.ImpliedType(b) if err != nil { return fmt.Errorf("error unmarshalling terraform vars file: %w", err) } // Unmarshal the values ctyValue, err := ctyjson.Unmarshal(b, ctyType) if err != nil { return fmt.Errorf("error unmarshalling terraform vars file: %w", err) } // Extract the variables for k, v := range ctyValue.AsValueMap() { setVariable(dest, k, v) } return nil } // Parses either a `json` or `tfvars` formatted vars file ands adds these // variables to the context func ParseVarsFile(path string, dest *hcl.EvalContext) error { switch { case strings.HasSuffix(path, ".json"): return ParseTFVarsJSONFile(path, dest) case strings.HasSuffix(path, ".tfvars"): return ParseTFVarsFile(path, dest) default: return fmt.Errorf("unsupported vars file format: %s", path) } } // Parses the os.Args for -var and -var-file arguments and adds them to the eval // context. func ParseVarsArgs(args []string, dest *hcl.EvalContext) error { // We are going to parse the whole argument as HCL here since you can // include arrays, maps etc. for i, arg := range args { // normalize `--foo` arguments to `-foo` if strings.HasPrefix(arg, "--") { arg = arg[1:] } switch { case strings.HasPrefix(arg, "-var="): err := ParseFlagValue(arg[5:], dest) if err != nil { return err } case arg == "-var": // If the flag is just -var, we need to use the next arg as the value // and skip this one if i+1 < len(args) { err := ParseFlagValue(args[i+1], dest) if err != nil { return err } } else { continue } case strings.HasPrefix(arg, "-var-file="): err := ParseVarsFile(arg[10:], dest) if err != nil { return err } case arg == "-var-file": // If the flag is just -var-file, we need to use the next arg as the value // and skip this one if i+1 < len(args) { err := ParseVarsFile(args[i+1], dest) if err != nil { return err } } else { continue } default: continue } } return nil } // Parses the value of a -var flag. The value should already be extracted here // i.e. the text after the = sign, or after the space if the = sign isn't used, // so you should be passing in "foo=var" or "[1,2,3]" etc. // // Terraform allows a user to specify string values without quotes, // which isn't valid HCL, but everything else needs to be valid HCL. For // example you can set a string like this: // // -var="foo=bar" // // But this isn't valid HCL since the string isn't quoted. However if // you want to set a list, map etc, you need to use valid HCL syntax. // e.g. // // -var="foo=[1,2,3]" // // In order to handle this we're going to try to parse as HCL, then // fall back to basic string parsing if that doesn't work, which seems // to be how the Terraform works func ParseFlagValue(value string, dest *hcl.EvalContext) error { err := func() error { // Parse argument as HCL parser := hclparse.NewParser() parsedFile, diag := parser.ParseHCL([]byte(value), "") if diag.HasErrors() { return fmt.Errorf("error parsing terraform vars file: %w", diag) } // Decode the body var vars map[string]cty.Value diag = gohcl.DecodeBody(parsedFile.Body, nil, &vars) if diag.HasErrors() { return fmt.Errorf("error decoding terraform vars file: %w", diag) } // Merge the vars into the eval context setVariables(dest, vars) return nil }() if err != nil { // Fall back to string parsing parts := strings.SplitN(value, "=", 2) if len(parts) != 2 { return fmt.Errorf("invalid variable argument: %s", value) } setVariable(dest, parts[0], cty.StringVal(parts[1])) } return nil } type ProviderResult struct { Provider *AWSProvider Error error FilePath string } // ParseAWSProviders scans for .tf files and extracts AWS provider configurations. // The search behavior is controlled by the recursive flag: when false, only the // provided directory is scanned via a simple glob; when true, the directory is // walked recursively while skipping dot-directories (e.g., .terraform). func ParseAWSProviders(terraformDir string, evalContext *hcl.EvalContext, recursive bool) ([]ProviderResult, error) { files, err := FindTerraformFiles(terraformDir, recursive) if err != nil { return nil, err } parser := hclparse.NewParser() results := make([]ProviderResult, 0) // Iterate over the files for _, file := range files { b, err := os.ReadFile(file) if err != nil { results = append(results, ProviderResult{ Error: fmt.Errorf("error reading terraform file: (%v) %w", file, err), FilePath: file, }) continue } // Parse the HCL file parsedFile, diag := parser.ParseHCL(b, file) if diag.HasErrors() { results = append(results, ProviderResult{ Error: fmt.Errorf("error parsing terraform file: (%v) %w", file, diag), FilePath: file, }) continue } // First decode really minimally to find just the AWS providers basicFile := basicProviderFile{} diag = gohcl.DecodeBody(parsedFile.Body, evalContext, &basicFile) if diag.HasErrors() { results = append(results, ProviderResult{ Error: fmt.Errorf("error decoding terraform file: (%v) %w", file, diag), FilePath: file, }) continue } for _, genericProvider := range basicFile.Providers { switch genericProvider.Name { case "aws": awsProvider := AWSProvider{ // Since this was already decoded we need to use it here Name: genericProvider.Name, } diag = gohcl.DecodeBody(genericProvider.Remain, evalContext, &awsProvider) if diag.HasErrors() { results = append(results, ProviderResult{ Error: fmt.Errorf("error decoding terraform file: (%v) %w", file, diag), FilePath: file, }) continue } else { results = append(results, ProviderResult{ Provider: &awsProvider, FilePath: file, }) } } } } return results, nil } // FindTerraformFiles returns a list of Terraform files under terraformDir. // When recursive is false, it uses a simple glob for "*.tf" in the directory. // When recursive is true, it walks the directory tree and collects .tf files, // skipping any dot-prefixed subdirectories (e.g., .terraform). func FindTerraformFiles(terraformDir string, recursive bool) ([]string, error) { if !recursive { return filepath.Glob(filepath.Join(terraformDir, "*.tf")) } files := []string{} err := filepath.Walk(terraformDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } // If this is a subdirectory starting with a dot, skip it entirely if info.IsDir() && path != terraformDir && strings.HasPrefix(filepath.Base(path), ".") { return filepath.SkipDir } if info.IsDir() { return nil } // Only include .tf files if strings.HasSuffix(path, ".tf") { files = append(files, path) } return nil }) if err != nil { return nil, fmt.Errorf("error walking directory %s: %w", terraformDir, err) } return files, nil } // ConfigFromProvider creates an aws.Config from an AWSProvider that uses the // provided HTTP client. This client will be modified with proxy settings if // they are present in the provider. func ConfigFromProvider(ctx context.Context, provider AWSProvider) (aws.Config, error) { var options []func(*config.LoadOptions) error if provider.AccessKey != "" { options = append(options, config.WithCredentialsProvider(credentials.StaticCredentialsProvider{ Value: aws.Credentials{ AccessKeyID: provider.AccessKey, SecretAccessKey: provider.SecretKey, SessionToken: provider.Token, }, })) } if provider.Region != "" { options = append(options, config.WithRegion(provider.Region)) } if provider.CustomCABundle != "" { bundlePath := os.ExpandEnv(provider.CustomCABundle) bundlePath, err := homedir.Expand(bundlePath) if err != nil { return aws.Config{}, fmt.Errorf("expanding custom CA bundle path: %w", err) } bundle, err := os.ReadFile(bundlePath) if err != nil { return aws.Config{}, fmt.Errorf("reading custom CA bundle: %w", err) } options = append(options, config.WithCustomCABundle(bytes.NewReader(bundle))) } if provider.EC2MetadataServiceEndpoint != "" { options = append(options, config.WithEC2IMDSEndpoint(provider.EC2MetadataServiceEndpoint)) } if provider.EC2MetadataServiceEndpointMode != "" { var mode imds.EndpointModeState switch { case len(provider.EC2MetadataServiceEndpointMode) == 0: mode = imds.EndpointModeStateUnset case strings.EqualFold(provider.EC2MetadataServiceEndpointMode, "IPv6"): mode = imds.EndpointModeStateIPv4 case strings.EqualFold(provider.EC2MetadataServiceEndpointMode, "IPv4"): mode = imds.EndpointModeStateIPv6 default: return aws.Config{}, fmt.Errorf("unknown EC2 IMDS endpoint mode, must be either IPv6 or IPv4") } options = append(options, config.WithEC2IMDSEndpointMode(mode)) } if provider.SkipMetadataAPICheck { options = append(options, config.WithEC2IMDSClientEnableState(imds.ClientDisabled)) } proxyConfig := httpproxy.FromEnvironment() if provider.HTTPProxy != "" { proxyConfig.HTTPProxy = provider.HTTPProxy } if provider.HTTPSProxy != "" { proxyConfig.HTTPSProxy = provider.HTTPSProxy } if provider.NoProxy != "" { proxyConfig.NoProxy = provider.NoProxy } // Always append the HTTP client that is configured with all our required // proxy settings // TODO: Can we inherit a transport here for things like OTEL? httpClient := awshttp.NewBuildableClient() httpClient.WithTransportOptions(func(t *http.Transport) { t.Proxy = func(r *http.Request) (*url.URL, error) { return proxyConfig.ProxyFunc()(r.URL) } }) options = append(options, config.WithHTTPClient(httpClient)) if provider.MaxRetries != 0 { options = append(options, config.WithRetryMaxAttempts(provider.MaxRetries)) } if provider.Profile != "" { options = append(options, config.WithSharedConfigProfile(provider.Profile)) } if provider.RetryMode != "" { switch { case strings.EqualFold(provider.RetryMode, "standard"): options = append(options, config.WithRetryMode(aws.RetryModeStandard)) case strings.EqualFold(provider.RetryMode, "adaptive"): options = append(options, config.WithRetryMode(aws.RetryModeAdaptive)) default: return aws.Config{}, fmt.Errorf("unknown retry mode: %s. Must be 'standard' or 'adaptive'", provider.RetryMode) } } if len(provider.SharedConfigFiles) != 0 { options = append(options, config.WithSharedConfigFiles(provider.SharedConfigFiles)) } if len(provider.SharedCredentialsFiles) != 0 { options = append(options, config.WithSharedCredentialsFiles(provider.SharedCredentialsFiles)) } if provider.UseDualStackEndpoint { options = append(options, config.WithUseDualStackEndpoint(aws.DualStackEndpointStateEnabled)) } if provider.UseFIPSEndpoint { options = append(options, config.WithUseFIPSEndpoint(aws.FIPSEndpointStateEnabled)) } return config.LoadDefaultConfig(ctx, options...) } ================================================ FILE: tfutils/aws_config_test.go ================================================ package tfutils import ( "context" "os" "path/filepath" "strings" "testing" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" ) func TestParseAWSProviders(t *testing.T) { t.Run("non-recursive only current directory", func(t *testing.T) { results, err := ParseAWSProviders("testdata", nil, false) if err != nil { t.Fatalf("Error parsing AWS results: %v", err) } // Expect 3 providers from providers.tf only if len(results) != 3 { t.Fatalf("Expected 3 results (non-recursive), got %d", len(results)) } }) t.Run("recursive finds providers in subdirectories", func(t *testing.T) { results, err := ParseAWSProviders("testdata", nil, true) if err != nil { t.Fatalf("Error parsing AWS results: %v", err) } // Expect 5 results when recursive: // - 3 from providers.tf // - 1 from subfolder/more_providers.tf // - 1 from config_from_provider/test.tf if len(results) != 5 { t.Fatalf("Expected 5 results (recursive), got %d", len(results)) } // Count providers by their characteristics to make test order-independent var foundUsEast1, foundAssumeRole, foundEverything, foundSubdir, foundConfigTest int for _, result := range results { if result.Provider == nil { continue } if result.Provider.Region == "us-east-1" && result.Provider.Alias == "" { foundUsEast1++ } if result.Provider.Alias == "assume_role" && result.Provider.AssumeRole != nil { foundAssumeRole++ if result.Provider.AssumeRole.RoleARN != "arn:aws:iam::123456789012:role/ROLE_NAME" { t.Errorf("Expected role arn arn:aws:iam::123456789012:role/ROLE_NAME, got %s", result.Provider.AssumeRole.RoleARN) } if result.Provider.AssumeRole.SessionName != "SESSION_NAME" { t.Errorf("Expected session name SESSION_NAME, got %s", result.Provider.AssumeRole.SessionName) } if result.Provider.AssumeRole.ExternalID != "EXTERNAL_ID" { t.Errorf("Expected external id EXTERNAL_ID, got %s", result.Provider.AssumeRole.ExternalID) } } if result.Provider.Alias == "everything" { foundEverything++ if strings.Contains(result.FilePath, "config_from_provider") { foundConfigTest++ } } if result.Provider.Alias == "subdir" && result.Provider.Region == "us-west-2" { foundSubdir++ if !strings.Contains(result.FilePath, "subfolder") { t.Errorf("Expected subdir provider to be in subfolder, got path: %s", result.FilePath) } } } if foundUsEast1 != 1 { t.Errorf("Expected to find 1 us-east-1 provider, found %d", foundUsEast1) } if foundAssumeRole != 1 { t.Errorf("Expected to find 1 assume_role provider, found %d", foundAssumeRole) } if foundEverything != 2 { // One from providers.tf and one from config_from_provider/test.tf t.Errorf("Expected to find 2 'everything' providers, found %d", foundEverything) } if foundSubdir != 1 { t.Errorf("Expected to find 1 subdir provider, found %d", foundSubdir) } if foundConfigTest != 1 { t.Errorf("Expected to find 1 provider in config_from_provider, found %d", foundConfigTest) } }) } func TestConfigFromProvider(t *testing.T) { t.Setenv("AWS_PROFILE", "") // Make sure the providers we have created can all be turned into configs // without any issues results, err := ParseAWSProviders("testdata/config_from_provider", nil, false) if err != nil { t.Fatalf("Error parsing AWS providers: %v", err) } for _, provider := range results { _, err := ConfigFromProvider(context.Background(), *provider.Provider) if err != nil { t.Errorf("Error converting provider to config: %v", err) } } } func TestParseTFVarsFile(t *testing.T) { t.Run("with a good file", func(t *testing.T) { evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } err := ParseTFVarsFile("testdata/test_vars.tfvars", &evalCtx) if err != nil { t.Fatalf("Error parsing TF vars file: %v", err) } if !evalCtx.Variables["var"].Type().IsObjectType() { t.Errorf("Expected var to be an object, got %s", evalCtx.Variables["var"].Type()) } variables := evalCtx.Variables["var"].AsValueMap() if variables["simple_string"].Type() != cty.String { t.Errorf("Expected simple_string to be a string, got %s", variables["simple_string"].Type()) } if variables["simple_string"].AsString() != "example_string" { t.Errorf("Expected simple_string to be example_string, got %s", variables["simple_string"].AsString()) } if variables["example_number"].Type() != cty.Number { t.Errorf("Expected example_number to be a number, got %s", variables["example_number"].Type()) } if variables["example_number"].AsBigFloat().String() != "42" { t.Errorf("Expected example_number to be 42, got %s", variables["example_number"].AsBigFloat().String()) } if variables["example_boolean"].Type() != cty.Bool { t.Errorf("Expected example_boolean to be a bool, got %s", variables["example_boolean"].Type()) } if values := variables["example_list"].AsValueSlice(); len(values) == 3 { if values[0].AsString() != "item1" { t.Errorf("Expected first item to be item1, got %s", values[0].AsString()) } } else { t.Errorf("Expected example_list to have 3 elements, got %d", len(values)) } if m := variables["example_map"].AsValueMap(); len(m) == 2 { if m["key1"].AsString() != "value1" { t.Errorf("Expected key1 to be value1, got %s", m["key1"].AsString()) } } else { t.Errorf("Expected example_map to have 2 elements, got %d", len(m)) } }) t.Run("with a file that doesn't exist", func(t *testing.T) { evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } err := ParseTFVarsFile("testdata/nonexistent.tfvars", &evalCtx) if err == nil { t.Fatalf("Expected error parsing nonexistent file, got nil") } }) t.Run("with a file that has invalid syntax", func(t *testing.T) { evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } err := ParseTFVarsFile("testdata/invalid_vars.tfvars", &evalCtx) if err == nil { t.Fatalf("Expected error parsing invalid syntax file, got nil") } }) } func TestParseTFVarsJSONFile(t *testing.T) { t.Run("with a good file", func(t *testing.T) { evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } err := ParseTFVarsJSONFile("testdata/tfvars.json", &evalCtx) if err != nil { t.Fatalf("Error parsing TF vars file: %v", err) } if !evalCtx.Variables["var"].Type().IsObjectType() { t.Errorf("Expected var to be an object, got %s", evalCtx.Variables["var"].Type()) } variables := evalCtx.Variables["var"].AsValueMap() if variables["string"].Type() != cty.String { t.Errorf("Expected string to be a string, got %s", variables["string"].Type()) } if variables["string"].AsString() != "example_string" { t.Errorf("Expected string to be example_string, got %s", variables["string"].AsString()) } if values := variables["list"].AsValueSlice(); len(values) == 2 { if values[0].AsString() != "item1" { t.Errorf("Expected first item to be item1, got %s", values[0].AsString()) } } else { t.Errorf("Expected list to have 2 elements, got %d", len(values)) } }) t.Run("with a file that doesn't exist", func(t *testing.T) { evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } err := ParseTFVarsJSONFile("testdata/nonexistent.json", &evalCtx) if err == nil { t.Fatalf("Expected error parsing nonexistent file, got nil") } }) } func TestParseFlagValue(t *testing.T) { // There are a number of ways to supply ags, for example: // // terraform apply // terraform apply -var "image_id=ami-abc123" // terraform apply -var 'name=value' // terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro" // terraform apply -var='image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}' tests := []struct { Name string Value string }{ { Name: "with =", Value: "image_id=ami-abc123", }, { Name: "with a space", Value: "image_id=ami-abc123", }, { Name: "with a list", Value: "image_id_list=[\"ami-abc123\",\"ami-def456\"]", }, { Name: "with a map", Value: "image_id_map={\"us-east-1\":\"ami-abc123\",\"us-east-2\":\"ami-def456\"}", }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } err := ParseFlagValue(test.Value, &evalCtx) if err != nil { t.Fatalf("Error parsing vars args: %v", err) } }) } } func TestParseVarsArgs(t *testing.T) { tests := []struct { Name string Args []string }{ { Name: "with a single var", Args: []string{"-var", "image_id=ami-abc123"}, }, { Name: "with multiple vars", Args: []string{"-var", "image_id=ami-abc123", "-var", "instance_type=t2.micro"}, }, { Name: "with a vars file", Args: []string{"-var-file", "testdata/test_vars.tfvars"}, }, { Name: "with a vars json file", Args: []string{"-var-file", "testdata/tfvars.json"}, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { evalCtx := hcl.EvalContext{ Variables: make(map[string]cty.Value), } err := ParseVarsArgs(test.Args, &evalCtx) if err != nil { t.Fatalf("Error parsing vars args: %v", err) } }) } } func TestLoadEvalContext(t *testing.T) { args := []string{ "plan", "-var", "image_id=args", "--var", "instance_type=t2.micro", "-var-file", "testdata/tfvars.json", "-var-file=testdata/test_vars.tfvars", } env := []string{ "TF_VAR_something=else", "TF_VAR_image_id=environment", } evalCtx, err := LoadEvalContext(args, env) if err != nil { t.Fatal(err) } t.Log(evalCtx) variables := evalCtx.Variables["var"].AsValueMap() if variables["instance_type"].AsString() != "t2.micro" { t.Errorf("Expected instance_type to be t2.micro, got %s", variables["instance_type"].AsString()) } if variables["something"].AsString() != "else" { t.Errorf("Expected something to be else, got %s", variables["something"].AsString()) } if variables["image_id"].AsString() != "args" { t.Errorf("Expected image_id to be args, got %s", variables["image_id"].AsString()) } } func TestParseAWSProvidersWithSubmodules(t *testing.T) { // Test parsing providers in nested modules if _, err := os.Stat("testdata_nested_modules"); err != nil { t.Skip("skipping: test fixture 'testdata_nested_modules' not present") } results, err := ParseAWSProviders("testdata_nested_modules", nil, true) if err != nil { t.Errorf("Error parsing AWS providers in nested modules: %v", err) } // We expect 4 providers: // 1. Root module (us-east-1) // 2. VPC module (us-west-2) // 3. EC2 module (eu-west-1 with assume_role) // 4. Nested submodule (ap-southeast-1) if len(results) != 4 { t.Fatalf("Expected 4 providers in nested modules, got %d", len(results)) } // Map to track found providers by region providersByRegion := make(map[string]*ProviderResult) for i := range results { result := &results[i] if result.Error != nil { t.Errorf("Error in result for file %s: %v", result.FilePath, result.Error) continue } if result.Provider != nil { providersByRegion[result.Provider.Region] = result } } // Verify root provider if rootProvider, ok := providersByRegion["us-east-1"]; ok { if !strings.Contains(rootProvider.FilePath, "main.tf") { t.Errorf("Expected root provider to be in main.tf, got %s", rootProvider.FilePath) } } else { t.Errorf("Expected to find provider with region us-east-1") } // Verify VPC module provider if vpcProvider, ok := providersByRegion["us-west-2"]; ok { if vpcProvider.Provider.Alias != "vpc_module" { t.Errorf("Expected VPC provider alias to be vpc_module, got %s", vpcProvider.Provider.Alias) } if !strings.Contains(vpcProvider.FilePath, "modules/vpc/providers.tf") { t.Errorf("Expected VPC provider to be in modules/vpc/providers.tf, got %s", vpcProvider.FilePath) } } else { t.Errorf("Expected to find provider with region us-west-2") } // Verify EC2 module provider with assume role if ec2Provider, ok := providersByRegion["eu-west-1"]; ok { if ec2Provider.Provider.Alias != "ec2_module" { t.Errorf("Expected EC2 provider alias to be ec2_module, got %s", ec2Provider.Provider.Alias) } if ec2Provider.Provider.AssumeRole == nil { t.Errorf("Expected EC2 provider to have assume_role configuration") } else if ec2Provider.Provider.AssumeRole.RoleARN != "arn:aws:iam::987654321098:role/EC2ModuleRole" { t.Errorf("Expected EC2 provider role ARN to be arn:aws:iam::987654321098:role/EC2ModuleRole, got %s", ec2Provider.Provider.AssumeRole.RoleARN) } if !strings.Contains(ec2Provider.FilePath, "modules/ec2/providers.tf") { t.Errorf("Expected EC2 provider to be in modules/ec2/providers.tf, got %s", ec2Provider.FilePath) } } else { t.Errorf("Expected to find provider with region eu-west-1") } // Verify deeply nested provider if nestedProvider, ok := providersByRegion["ap-southeast-1"]; ok { if nestedProvider.Provider.Alias != "nested_provider" { t.Errorf("Expected nested provider alias to be nested_provider, got %s", nestedProvider.Provider.Alias) } if nestedProvider.Provider.AccessKey != "nested-access-key" { t.Errorf("Expected nested provider access key to be nested-access-key, got %s", nestedProvider.Provider.AccessKey) } if !strings.Contains(nestedProvider.FilePath, "modules/ec2/nested_submodule/providers.tf") { t.Errorf("Expected nested provider to be in modules/ec2/nested_submodule/providers.tf, got %s", nestedProvider.FilePath) } } else { t.Errorf("Expected to find provider with region ap-southeast-1") } } func TestParseAWSProviders_RecursiveNestedExample(t *testing.T) { t.Parallel() tempDir := t.TempDir() mustWrite := func(relPath, content string) { full := filepath.Join(tempDir, relPath) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatalf("mkdir failed for %s: %v", filepath.Dir(full), err) } if err := os.WriteFile(full, []byte(content), 0o644); err != nil { t.Fatalf("write failed for %s: %v", full, err) } } // Root provider mustWrite("providers.tf", ` provider "aws" { region = "us-east-1" default_tags { tags = { Environment = "production", ManagedBy = "terraform" } } } `) // modules/networking provider mustWrite("modules/networking/providers.tf", ` provider "aws" { alias = "networking" region = "us-west-2" default_tags { tags = { Module = "networking", Team = "infrastructure" } } } `) // modules/networking/vpc provider with assume_role mustWrite("modules/networking/vpc/providers.tf", ` provider "aws" { alias = "vpc_endpoints" region = "us-west-2" assume_role { role_arn = "arn:aws:iam::123456789012:role/VPCEndpointManager" session_name = "vpc-endpoint-management" } } `) // modules/compute providers (two providers) mustWrite("modules/compute/providers.tf", ` provider "aws" { alias = "compute" region = "eu-west-1" default_tags { tags = { Module = "compute", Team = "platform" } } } provider "aws" { alias = "shared_resources" region = "eu-west-1" assume_role { role_arn = "arn:aws:iam::987654321098:role/SharedResourceAccess" } } `) // modules/compute/eks provider with assume_role and external_id mustWrite("modules/compute/eks/providers.tf", ` provider "aws" { alias = "eks_admin" region = "eu-west-1" assume_role { role_arn = "arn:aws:iam::123456789012:role/EKSClusterAdmin" session_name = "eks-cluster-management" external_id = "eks-external-id" } } `) results, err := ParseAWSProviders(tempDir, nil, true) if err != nil { t.Fatalf("ParseAWSProviders recursive failed: %v", err) } if len(results) != 6 { t.Fatalf("Expected 6 providers discovered, got %d", len(results)) } // Validate presence and key attributes found := map[string]bool{} for _, r := range results { if r.Provider == nil { continue } key := r.Provider.Region + ":" + r.Provider.Alias found[key] = true // Check assume_role details where expected switch r.Provider.Alias { case "vpc_endpoints": if r.Provider.AssumeRole == nil || r.Provider.AssumeRole.RoleARN != "arn:aws:iam::123456789012:role/VPCEndpointManager" { t.Errorf("vpc_endpoints provider missing/incorrect assume_role") } case "shared_resources": if r.Provider.AssumeRole == nil || r.Provider.AssumeRole.RoleARN != "arn:aws:iam::987654321098:role/SharedResourceAccess" { t.Errorf("shared_resources provider missing/incorrect assume_role") } case "eks_admin": if r.Provider.AssumeRole == nil || r.Provider.AssumeRole.RoleARN != "arn:aws:iam::123456789012:role/EKSClusterAdmin" { t.Errorf("eks_admin provider missing/incorrect assume_role") } } } // Expect these specific providers expectedKeys := []string{ "us-east-1:", // root "us-west-2:networking", "us-west-2:vpc_endpoints", "eu-west-1:compute", "eu-west-1:shared_resources", "eu-west-1:eks_admin", } for _, k := range expectedKeys { if !found[k] { t.Errorf("Expected provider %s not found", k) } } } ================================================ FILE: tfutils/azure_config.go ================================================ package tfutils import ( "fmt" "os" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclparse" ) // AzureProvider represents an Azure provider block in terraform files // Based on: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs#argument-reference type AzureProvider struct { Name string `hcl:"name,label" yaml:"name,omitempty"` Alias string `hcl:"alias,optional" yaml:"alias,omitempty"` SubscriptionID string `hcl:"subscription_id,optional" yaml:"subscription_id,omitempty"` TenantID string `hcl:"tenant_id,optional" yaml:"tenant_id,omitempty"` ClientID string `hcl:"client_id,optional" yaml:"client_id,omitempty"` ClientSecret string `hcl:"client_secret,optional" yaml:"client_secret,omitempty"` Environment string `hcl:"environment,optional" yaml:"environment,omitempty"` // Throw any additional stuff into here so it doesn't fail // This includes the required 'features' block and other optional blocks Remain hcl.Body `hcl:",remain" yaml:"-"` } // AzureProviderResult holds the result of parsing an Azure provider type AzureProviderResult struct { Provider *AzureProvider Error error FilePath string } // ParseAzureProviders scans for .tf files and extracts Azure provider configurations // (azurerm). When recursive is false, only the provided directory is scanned; // when true, the directory is walked recursively while skipping dot-directories // (e.g., .terraform). func ParseAzureProviders(terraformDir string, evalContext *hcl.EvalContext, recursive bool) ([]AzureProviderResult, error) { files, err := FindTerraformFiles(terraformDir, recursive) if err != nil { return nil, err } parser := hclparse.NewParser() results := make([]AzureProviderResult, 0) // Iterate over the files for _, file := range files { b, err := os.ReadFile(file) if err != nil { results = append(results, AzureProviderResult{ Error: fmt.Errorf("error reading terraform file: (%v) %w", file, err), FilePath: file, }) continue } // Parse the HCL file parsedFile, diag := parser.ParseHCL(b, file) if diag.HasErrors() { results = append(results, AzureProviderResult{ Error: fmt.Errorf("error parsing terraform file: (%v) %w", file, diag), FilePath: file, }) continue } // First decode really minimally to find just the Azure providers basicFile := basicProviderFile{} diag = gohcl.DecodeBody(parsedFile.Body, evalContext, &basicFile) if diag.HasErrors() { results = append(results, AzureProviderResult{ Error: fmt.Errorf("error decoding terraform file: (%v) %w", file, diag), FilePath: file, }) continue } for _, genericProvider := range basicFile.Providers { switch genericProvider.Name { case "azurerm": azureProvider := AzureProvider{ // Since this was already decoded we need to use it here Name: genericProvider.Name, } diag = gohcl.DecodeBody(genericProvider.Remain, evalContext, &azureProvider) if diag.HasErrors() { results = append(results, AzureProviderResult{ Error: fmt.Errorf("error decoding terraform file: (%v) %w", file, diag), FilePath: file, }) continue } else { results = append(results, AzureProviderResult{ Provider: &azureProvider, FilePath: file, }) } } } } return results, nil } // AzureConfig holds configuration for Azure source type AzureConfig struct { SubscriptionID string TenantID string ClientID string Alias string // Store alias for engine naming } // ConfigFromAzureProvider creates an AzureConfig from an AzureProvider. // If subscription_id is not set in the provider, it falls back to environment variables // (ARM_SUBSCRIPTION_ID or AZURE_SUBSCRIPTION_ID), matching the behavior of the // Azure Terraform provider. func ConfigFromAzureProvider(provider AzureProvider) (*AzureConfig, error) { config := &AzureConfig{ SubscriptionID: provider.SubscriptionID, TenantID: provider.TenantID, ClientID: provider.ClientID, Alias: provider.Alias, } // Fall back to environment variables if subscription_id not set in provider // ARM_SUBSCRIPTION_ID is used by the Azure Terraform provider // AZURE_SUBSCRIPTION_ID is used by the Azure SDK if config.SubscriptionID == "" { config.SubscriptionID = os.Getenv("ARM_SUBSCRIPTION_ID") } if config.SubscriptionID == "" { config.SubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID") } // Similarly for tenant_id and client_id if config.TenantID == "" { config.TenantID = os.Getenv("ARM_TENANT_ID") } if config.TenantID == "" { config.TenantID = os.Getenv("AZURE_TENANT_ID") } if config.ClientID == "" { config.ClientID = os.Getenv("ARM_CLIENT_ID") } if config.ClientID == "" { config.ClientID = os.Getenv("AZURE_CLIENT_ID") } if config.SubscriptionID == "" { return nil, fmt.Errorf("Azure provider must specify subscription_id (or set ARM_SUBSCRIPTION_ID/AZURE_SUBSCRIPTION_ID environment variable)") } return config, nil } ================================================ FILE: tfutils/azure_config_test.go ================================================ package tfutils import ( "os" "path/filepath" "testing" "github.com/hashicorp/hcl/v2" ) func TestParseAzureProviders(t *testing.T) { t.Parallel() tests := []struct { name string terraformFile string expectedCount int expectedErrors int }{ { name: "single azurerm provider", terraformFile: ` provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000001" tenant_id = "00000000-0000-0000-0000-000000000002" features {} }`, expectedCount: 1, expectedErrors: 0, }, { name: "multiple azurerm providers", terraformFile: ` provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000001" tenant_id = "00000000-0000-0000-0000-000000000002" features {} } provider "azurerm" { alias = "secondary" subscription_id = "00000000-0000-0000-0000-000000000003" tenant_id = "00000000-0000-0000-0000-000000000004" features {} }`, expectedCount: 2, expectedErrors: 0, }, { name: "azurerm provider with client_id", terraformFile: ` provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000001" tenant_id = "00000000-0000-0000-0000-000000000002" client_id = "00000000-0000-0000-0000-000000000003" features {} }`, expectedCount: 1, expectedErrors: 0, }, { name: "mixed providers with non-Azure", terraformFile: ` provider "aws" { region = "us-east-1" } provider "google" { project = "test-project" region = "us-central1" } provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000001" features {} }`, expectedCount: 1, expectedErrors: 0, }, { name: "azurerm provider with environment", terraformFile: ` provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000001" environment = "usgovernment" features {} }`, expectedCount: 1, expectedErrors: 0, }, { name: "azurerm provider minimal config", terraformFile: ` provider "azurerm" { features {} }`, expectedCount: 1, expectedErrors: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Create temporary directory and file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.tf") err := os.WriteFile(tmpFile, []byte(tt.terraformFile), 0644) if err != nil { t.Fatalf("Failed to write test file: %v", err) } // Parse providers results, err := ParseAzureProviders(tmpDir, &hcl.EvalContext{}, false) if err != nil { t.Fatalf("ParseAzureProviders failed: %v", err) } // Count valid and error results validCount := 0 errorCount := 0 for _, result := range results { if result.Error != nil { errorCount++ } else { validCount++ } } if validCount != tt.expectedCount { t.Errorf("Expected %d valid providers, got %d", tt.expectedCount, validCount) } if errorCount != tt.expectedErrors { t.Errorf("Expected %d error providers, got %d", tt.expectedErrors, errorCount) } }) } } func TestParseAzureProvidersRecursive(t *testing.T) { t.Parallel() // Create temporary directory structure tmpDir := t.TempDir() subDir := filepath.Join(tmpDir, "submodule") err := os.MkdirAll(subDir, 0755) if err != nil { t.Fatalf("Failed to create subdirectory: %v", err) } // Main provider file mainTF := ` provider "azurerm" { subscription_id = "00000000-0000-0000-0000-000000000001" features {} }` err = os.WriteFile(filepath.Join(tmpDir, "main.tf"), []byte(mainTF), 0644) if err != nil { t.Fatalf("Failed to write main.tf: %v", err) } // Submodule provider file subTF := ` provider "azurerm" { alias = "secondary" subscription_id = "00000000-0000-0000-0000-000000000002" features {} }` err = os.WriteFile(filepath.Join(subDir, "providers.tf"), []byte(subTF), 0644) if err != nil { t.Fatalf("Failed to write submodule providers.tf: %v", err) } // Non-recursive should find only main results, err := ParseAzureProviders(tmpDir, &hcl.EvalContext{}, false) if err != nil { t.Fatalf("ParseAzureProviders (non-recursive) failed: %v", err) } if len(results) != 1 { t.Errorf("Non-recursive: expected 1 provider, got %d", len(results)) } // Recursive should find both results, err = ParseAzureProviders(tmpDir, &hcl.EvalContext{}, true) if err != nil { t.Fatalf("ParseAzureProviders (recursive) failed: %v", err) } if len(results) != 2 { t.Errorf("Recursive: expected 2 providers, got %d", len(results)) } } func TestConfigFromAzureProvider(t *testing.T) { // Note: These tests are not parallel because they modify environment variables t.Run("valid provider with all fields", func(t *testing.T) { provider := AzureProvider{ SubscriptionID: "00000000-0000-0000-0000-000000000001", TenantID: "00000000-0000-0000-0000-000000000002", ClientID: "00000000-0000-0000-0000-000000000003", Alias: "test", } config, err := ConfigFromAzureProvider(provider) if err != nil { t.Fatalf("Unexpected error: %v", err) } if config.SubscriptionID != "00000000-0000-0000-0000-000000000001" { t.Errorf("Expected subscription_id '00000000-0000-0000-0000-000000000001', got '%s'", config.SubscriptionID) } if config.TenantID != "00000000-0000-0000-0000-000000000002" { t.Errorf("Expected tenant_id '00000000-0000-0000-0000-000000000002', got '%s'", config.TenantID) } if config.ClientID != "00000000-0000-0000-0000-000000000003" { t.Errorf("Expected client_id '00000000-0000-0000-0000-000000000003', got '%s'", config.ClientID) } if config.Alias != "test" { t.Errorf("Expected alias 'test', got '%s'", config.Alias) } }) t.Run("valid provider with only subscription_id", func(t *testing.T) { provider := AzureProvider{ SubscriptionID: "00000000-0000-0000-0000-000000000001", } config, err := ConfigFromAzureProvider(provider) if err != nil { t.Fatalf("Unexpected error: %v", err) } if config.SubscriptionID != "00000000-0000-0000-0000-000000000001" { t.Errorf("Expected subscription_id '00000000-0000-0000-0000-000000000001', got '%s'", config.SubscriptionID) } }) t.Run("missing subscription_id with no env vars", func(t *testing.T) { // Clear relevant env vars os.Unsetenv("ARM_SUBSCRIPTION_ID") os.Unsetenv("AZURE_SUBSCRIPTION_ID") provider := AzureProvider{ TenantID: "00000000-0000-0000-0000-000000000002", } _, err := ConfigFromAzureProvider(provider) if err == nil { t.Error("Expected error but got none") } }) t.Run("fallback to ARM_SUBSCRIPTION_ID env var", func(t *testing.T) { // Set ARM_SUBSCRIPTION_ID os.Setenv("ARM_SUBSCRIPTION_ID", "env-subscription-arm") defer os.Unsetenv("ARM_SUBSCRIPTION_ID") os.Unsetenv("AZURE_SUBSCRIPTION_ID") provider := AzureProvider{ TenantID: "tenant-from-provider", } config, err := ConfigFromAzureProvider(provider) if err != nil { t.Fatalf("Unexpected error: %v", err) } if config.SubscriptionID != "env-subscription-arm" { t.Errorf("Expected subscription_id 'env-subscription-arm', got '%s'", config.SubscriptionID) } }) t.Run("fallback to AZURE_SUBSCRIPTION_ID env var", func(t *testing.T) { // Set AZURE_SUBSCRIPTION_ID (ARM_ takes precedence, so unset it) os.Unsetenv("ARM_SUBSCRIPTION_ID") os.Setenv("AZURE_SUBSCRIPTION_ID", "env-subscription-azure") defer os.Unsetenv("AZURE_SUBSCRIPTION_ID") provider := AzureProvider{ TenantID: "tenant-from-provider", } config, err := ConfigFromAzureProvider(provider) if err != nil { t.Fatalf("Unexpected error: %v", err) } if config.SubscriptionID != "env-subscription-azure" { t.Errorf("Expected subscription_id 'env-subscription-azure', got '%s'", config.SubscriptionID) } }) t.Run("provider subscription_id takes precedence over env var", func(t *testing.T) { os.Setenv("ARM_SUBSCRIPTION_ID", "env-subscription") defer os.Unsetenv("ARM_SUBSCRIPTION_ID") provider := AzureProvider{ SubscriptionID: "provider-subscription", } config, err := ConfigFromAzureProvider(provider) if err != nil { t.Fatalf("Unexpected error: %v", err) } if config.SubscriptionID != "provider-subscription" { t.Errorf("Expected subscription_id 'provider-subscription', got '%s'", config.SubscriptionID) } }) t.Run("tenant_id and client_id fallback to env vars", func(t *testing.T) { os.Setenv("ARM_SUBSCRIPTION_ID", "sub") os.Setenv("ARM_TENANT_ID", "env-tenant") os.Setenv("ARM_CLIENT_ID", "env-client") defer func() { os.Unsetenv("ARM_SUBSCRIPTION_ID") os.Unsetenv("ARM_TENANT_ID") os.Unsetenv("ARM_CLIENT_ID") }() provider := AzureProvider{} config, err := ConfigFromAzureProvider(provider) if err != nil { t.Fatalf("Unexpected error: %v", err) } if config.TenantID != "env-tenant" { t.Errorf("Expected tenant_id 'env-tenant', got '%s'", config.TenantID) } if config.ClientID != "env-client" { t.Errorf("Expected client_id 'env-client', got '%s'", config.ClientID) } }) } func TestParseAzureProviderValues(t *testing.T) { t.Parallel() terraformFile := ` provider "azurerm" { subscription_id = "sub-123" tenant_id = "tenant-456" client_id = "client-789" alias = "primary" environment = "public" features {} }` tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.tf") err := os.WriteFile(tmpFile, []byte(terraformFile), 0644) if err != nil { t.Fatalf("Failed to write test file: %v", err) } results, err := ParseAzureProviders(tmpDir, &hcl.EvalContext{}, false) if err != nil { t.Fatalf("ParseAzureProviders failed: %v", err) } if len(results) != 1 { t.Fatalf("Expected 1 result, got %d", len(results)) } result := results[0] if result.Error != nil { t.Fatalf("Unexpected error: %v", result.Error) } provider := result.Provider if provider.SubscriptionID != "sub-123" { t.Errorf("Expected subscription_id 'sub-123', got '%s'", provider.SubscriptionID) } if provider.TenantID != "tenant-456" { t.Errorf("Expected tenant_id 'tenant-456', got '%s'", provider.TenantID) } if provider.ClientID != "client-789" { t.Errorf("Expected client_id 'client-789', got '%s'", provider.ClientID) } if provider.Alias != "primary" { t.Errorf("Expected alias 'primary', got '%s'", provider.Alias) } if provider.Environment != "public" { t.Errorf("Expected environment 'public', got '%s'", provider.Environment) } if provider.Name != "azurerm" { t.Errorf("Expected name 'azurerm', got '%s'", provider.Name) } } ================================================ FILE: tfutils/gcp_config.go ================================================ package tfutils import ( "fmt" "os" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclparse" ) // GCPProvider represents a GCP provider block in terraform files // Based on: https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference type GCPProvider struct { Name string `hcl:"name,label" yaml:"name,omitempty"` Alias string `hcl:"alias,optional" yaml:"alias,omitempty"` Credentials string `hcl:"credentials,optional" yaml:"credentials,omitempty"` AccessToken string `hcl:"access_token,optional" yaml:"access_token,omitempty"` ImpersonateServiceAccount string `hcl:"impersonate_service_account,optional" yaml:"impersonate_service_account,omitempty"` Project string `hcl:"project,optional" yaml:"project,omitempty"` Region string `hcl:"region,optional" yaml:"region,omitempty"` Zone string `hcl:"zone,optional" yaml:"zone,omitempty"` BillingProject string `hcl:"billing_project,optional" yaml:"billing_project,omitempty"` UserProjectOverride bool `hcl:"user_project_override,optional" yaml:"user_project_override,omitempty"` RequestTimeout string `hcl:"request_timeout,optional" yaml:"request_timeout,omitempty"` RequestReason string `hcl:"request_reason,optional" yaml:"request_reason,omitempty"` Scopes []string `hcl:"scopes,optional" yaml:"scopes,omitempty"` DefaultLabels map[string]string `hcl:"default_labels,optional" yaml:"default_labels,omitempty"` AddTerraformAttributionLabel bool `hcl:"add_terraform_attribution_label,optional" yaml:"add_terraform_attribution_label,omitempty"` // Throw any additional stuff into here so it doesn't fail Remain hcl.Body `hcl:",remain" yaml:"-"` } type GCPProviderResult struct { Provider *GCPProvider Error error FilePath string } // ParseGCPProviders scans for .tf files and extracts GCP provider configurations // (google and google-beta). When recursive is false, only the provided directory // is scanned; when true, the directory is walked recursively while skipping // dot-directories (e.g., .terraform). func ParseGCPProviders(terraformDir string, evalContext *hcl.EvalContext, recursive bool) ([]GCPProviderResult, error) { files, err := FindTerraformFiles(terraformDir, recursive) if err != nil { return nil, err } parser := hclparse.NewParser() results := make([]GCPProviderResult, 0) // Iterate over the files for _, file := range files { b, err := os.ReadFile(file) if err != nil { results = append(results, GCPProviderResult{ Error: fmt.Errorf("error reading terraform file: (%v) %w", file, err), FilePath: file, }) continue } // Parse the HCL file parsedFile, diag := parser.ParseHCL(b, file) if diag.HasErrors() { results = append(results, GCPProviderResult{ Error: fmt.Errorf("error parsing terraform file: (%v) %w", file, diag), FilePath: file, }) continue } // First decode really minimally to find just the GCP providers basicFile := basicProviderFile{} diag = gohcl.DecodeBody(parsedFile.Body, evalContext, &basicFile) if diag.HasErrors() { results = append(results, GCPProviderResult{ Error: fmt.Errorf("error decoding terraform file: (%v) %w", file, diag), FilePath: file, }) continue } for _, genericProvider := range basicFile.Providers { switch genericProvider.Name { case "google", "google-beta": gcpProvider := GCPProvider{ // Since this was already decoded we need to use it here Name: genericProvider.Name, } diag = gohcl.DecodeBody(genericProvider.Remain, evalContext, &gcpProvider) if diag.HasErrors() { results = append(results, GCPProviderResult{ Error: fmt.Errorf("error decoding terraform file: (%v) %w", file, diag), FilePath: file, }) continue } else { results = append(results, GCPProviderResult{ Provider: &gcpProvider, FilePath: file, }) } } } } return results, nil } // GCPConfig holds configuration for GCP source type GCPConfig struct { ProjectID string Regions []string Zones []string Alias string // Store alias for engine naming } // ConfigFromGCPProvider creates a GCPConfig from a GCPProvider func ConfigFromGCPProvider(provider GCPProvider) (*GCPConfig, error) { config := &GCPConfig{ ProjectID: provider.Project, Regions: []string{}, Zones: []string{}, Alias: provider.Alias, } if provider.Region != "" { config.Regions = append(config.Regions, provider.Region) } if provider.Zone != "" { config.Zones = append(config.Zones, provider.Zone) } if config.ProjectID == "" { return nil, fmt.Errorf("GCP provider must specify a project") } return config, nil } ================================================ FILE: tfutils/gcp_config_test.go ================================================ package tfutils import ( "os" "path/filepath" "testing" "github.com/hashicorp/hcl/v2" ) func TestParseGCPProviders(t *testing.T) { tests := []struct { name string terraformFile string expectedCount int expectedErrors int }{ { name: "single google provider", terraformFile: ` provider "google" { project = "test-project" region = "us-central1" }`, expectedCount: 1, expectedErrors: 0, }, { name: "multiple google providers", terraformFile: ` provider "google" { project = "test-project-1" region = "us-central1" } provider "google" { alias = "west" project = "test-project-2" region = "us-west1" }`, expectedCount: 2, expectedErrors: 0, }, { name: "google-beta provider", terraformFile: ` provider "google-beta" { project = "test-project" region = "us-central1" }`, expectedCount: 1, expectedErrors: 0, }, { name: "mixed providers with non-GCP", terraformFile: ` provider "aws" { region = "us-east-1" } provider "google" { project = "test-project" region = "us-central1" } provider "azurerm" { features {} }`, expectedCount: 1, expectedErrors: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create temporary directory and file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.tf") err := os.WriteFile(tmpFile, []byte(tt.terraformFile), 0644) if err != nil { t.Fatalf("Failed to write test file: %v", err) } // Parse providers results, err := ParseGCPProviders(tmpDir, &hcl.EvalContext{}, false) if err != nil { t.Fatalf("ParseGCPProviders failed: %v", err) } // Count valid and error results validCount := 0 errorCount := 0 for _, result := range results { if result.Error != nil { errorCount++ } else { validCount++ } } if validCount != tt.expectedCount { t.Errorf("Expected %d valid providers, got %d", tt.expectedCount, validCount) } if errorCount != tt.expectedErrors { t.Errorf("Expected %d error providers, got %d", tt.expectedErrors, errorCount) } }) } } func TestConfigFromGCPProvider(t *testing.T) { tests := []struct { name string provider GCPProvider expectError bool expectProj string expectRegs int expectZones int }{ { name: "valid provider with region and zone", provider: GCPProvider{ Project: "test-project", Region: "us-central1", Zone: "us-central1-a", Alias: "test", }, expectError: false, expectProj: "test-project", expectRegs: 1, expectZones: 1, }, { name: "valid provider with only project", provider: GCPProvider{ Project: "test-project", }, expectError: false, expectProj: "test-project", expectRegs: 0, expectZones: 0, }, { name: "missing project", provider: GCPProvider{ Region: "us-central1", }, expectError: true, }, { name: "empty project", provider: GCPProvider{ Project: "", Region: "us-central1", }, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config, err := ConfigFromGCPProvider(tt.provider) if tt.expectError && err == nil { t.Error("Expected error but got none") } if !tt.expectError && err != nil { t.Errorf("Unexpected error: %v", err) } if !tt.expectError { if config.ProjectID != tt.expectProj { t.Errorf("Expected project %s, got %s", tt.expectProj, config.ProjectID) } if len(config.Regions) != tt.expectRegs { t.Errorf("Expected %d regions, got %d", tt.expectRegs, len(config.Regions)) } if len(config.Zones) != tt.expectZones { t.Errorf("Expected %d zones, got %d", tt.expectZones, len(config.Zones)) } if config.Alias != tt.provider.Alias { t.Errorf("Expected alias %s, got %s", tt.provider.Alias, config.Alias) } } }) } } ================================================ FILE: tfutils/plan.go ================================================ package tfutils import ( "encoding/json" "regexp" "strconv" "strings" ) // NOTE: These definitions are copied from the // https://pkg.go.dev/github.com/hashicorp/terraform/internal/command/jsonplan // package, which is internal so should be imported directly. Hence why we have // copied them here // Plan is the top-level representation of the json format of a plan. It includes // the complete config and current state. type Plan struct { FormatVersion string `json:"format_version,omitempty"` TerraformVersion string `json:"terraform_version,omitempty"` Variables Variables `json:"variables,omitempty"` PlannedValues StateValues `json:"planned_values"` // ResourceDrift and ResourceChanges are sorted in a user-friendly order // that is undefined at this time, but consistent. ResourceDrift []ResourceChange `json:"resource_drift,omitempty"` ResourceChanges []ResourceChange `json:"resource_changes,omitempty"` OutputChanges map[string]Change `json:"output_changes,omitempty"` PriorState State `json:"prior_state"` Config planConfig `json:"configuration"` RelevantAttributes []ResourceAttr `json:"relevant_attributes,omitempty"` Checks json.RawMessage `json:"checks,omitempty"` Timestamp string `json:"timestamp,omitempty"` Errored bool `json:"errored"` } // Config represents the complete configuration source type planConfig struct { ProviderConfigs map[string]ProviderConfig `json:"provider_config,omitempty"` RootModule ConfigModule `json:"root_module"` } // ProviderConfig describes all of the provider configurations throughout the // configuration tree, flattened into a single map for convenience since // provider configurations are the one concept in Terraform that can span across // module boundaries. type ProviderConfig struct { Name string `json:"name,omitempty"` FullName string `json:"full_name,omitempty"` Alias string `json:"alias,omitempty"` VersionConstraint string `json:"version_constraint,omitempty"` ModuleAddress string `json:"module_address,omitempty"` Expressions map[string]any `json:"expressions,omitempty"` } type ConfigModule struct { Outputs map[string]output `json:"outputs,omitempty"` // Resources are sorted in a user-friendly order that is undefined at this // time, but consistent. Resources []ConfigResource `json:"resources,omitempty"` ModuleCalls map[string]moduleCall `json:"module_calls,omitempty"` Variables variables `json:"variables,omitempty"` } var escapeRegex = regexp.MustCompile(`\${([\w\.\[\]]*)}`) // Digs for a config resource in this module or its children func (m ConfigModule) DigResource(address string) *ConfigResource { addressSections := strings.Split(address, ".") if len(addressSections) == 0 { return nil } if addressSections[0] == "module" { // If it's addressed to a module, then we need to dig into that module if len(addressSections) < 2 { return nil } moduleName := addressSections[1] if module, ok := m.ModuleCalls[moduleName]; ok { // Dig through the correct module return module.Module.DigResource(strings.Join(addressSections[2:], ".")) } } else { // If the address has brackets, than we need to extract the index and // return the resource at that index indexMatches := indexBrackets.FindStringSubmatch(address) var desiredIndex int var err error if len(indexMatches) == 0 { // Return the first result desiredIndex = 0 } else { desiredIndex, err = strconv.Atoi(indexMatches[1]) if err != nil { return nil } } // Remove the [] from the address if it exists address = indexBrackets.ReplaceAllString(address, "") // Look through the current module currentIndex := 0 for _, r := range m.Resources { if r.Address == address { if currentIndex == desiredIndex { return &r } currentIndex++ } } } return nil } type moduleCall struct { Source string `json:"source,omitempty"` Expressions map[string]any `json:"expressions,omitempty"` CountExpression *expression `json:"count_expression,omitempty"` ForEachExpression *expression `json:"for_each_expression,omitempty"` Module ConfigModule `json:"module"` VersionConstraint string `json:"version_constraint,omitempty"` DependsOn []string `json:"depends_on,omitempty"` } // variables is the JSON representation of the variables provided to the current // plan. type variables map[string]*variable type variable struct { Default json.RawMessage `json:"default,omitempty"` Description string `json:"description,omitempty"` Sensitive bool `json:"sensitive,omitempty"` } // Resource is the representation of a resource in the config type ConfigResource struct { // Address is the absolute resource address Address string `json:"address,omitempty"` // Mode can be "managed" or "data" Mode string `json:"mode,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` // ProviderConfigKey is the key into "provider_configs" (shown above) for // the provider configuration that this resource is associated with. // // NOTE: If a given resource is in a ModuleCall, and the provider was // configured outside of the module (in a higher level configuration file), // the ProviderConfigKey will not match a key in the ProviderConfigs map. ProviderConfigKey string `json:"provider_config_key,omitempty"` // Provisioners is an optional field which describes any provisioners. // Connection info will not be included here. Provisioners []provisioner `json:"provisioners,omitempty"` // Expressions" describes the resource-type-specific content of the // configuration block. Expressions map[string]any `json:"expressions,omitempty"` // SchemaVersion indicates which version of the resource type schema the // "values" property conforms to. SchemaVersion uint64 `json:"schema_version"` // CountExpression and ForEachExpression describe the expressions given for // the corresponding meta-arguments in the resource configuration block. // These are omitted if the corresponding argument isn't set. CountExpression *expression `json:"count_expression,omitempty"` ForEachExpression *expression `json:"for_each_expression,omitempty"` DependsOn []string `json:"depends_on,omitempty"` } type output struct { Sensitive bool `json:"sensitive,omitempty"` Expression expression `json:"expression"` DependsOn []string `json:"depends_on,omitempty"` Description string `json:"description,omitempty"` } type provisioner struct { Type string `json:"type,omitempty"` Expressions map[string]any `json:"expressions,omitempty"` } // expression represents any unparsed expression type expression struct { // "constant_value" is set only if the expression contains no references to // other objects, in which case it gives the resulting constant value. This // is mapped as for the individual values in the common value // representation. ConstantValue json.RawMessage `json:"constant_value,omitempty"` // Alternatively, "references" will be set to a list of references in the // expression. Multi-step references will be unwrapped and duplicated for // each significant traversal step, allowing callers to more easily // recognize the objects they care about without attempting to parse the // expressions. Callers should only use string equality checks here, since // the syntax may be extended in future releases. References []string `json:"references,omitempty"` } // Variables is the JSON representation of the variables provided to the current // plan. type Variables map[string]*Variable type Variable struct { Value json.RawMessage `json:"value,omitempty"` } // StateValues is the common representation of resolved values for both the // prior state (which is always complete) and the planned new state. type StateValues struct { Outputs map[string]Output `json:"outputs,omitempty"` RootModule Module `json:"root_module"` } // Get a specific resource from this module or its children func (m Module) DigResource(address string) *Resource { // Look through the current module for _, r := range m.Resources { if r.Address == address { return &r } } // Look through children for _, child := range m.ChildModules { resource := child.DigResource(address) if resource != nil { return resource } } return nil } // Module is the representation of a module in state. This can be the root // module or a child module. type Module struct { // Resources are sorted in a user-friendly order that is undefined at this // time, but consistent. Resources []Resource `json:"resources,omitempty"` // Address is the absolute module address, omitted for the root module Address string `json:"address,omitempty"` // Each module object can optionally have its own nested "child_modules", // recursively describing the full module tree. ChildModules []Module `json:"child_modules,omitempty"` } // Resource is the representation of a resource in the json plan type Resource struct { // Address is the absolute resource address Address string `json:"address,omitempty"` // Mode can be "managed" or "data" Mode string `json:"mode,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` // ProviderName allows the property "type" to be interpreted unambiguously // in the unusual situation where a provider offers a resource type whose // name does not start with its own name, such as the "googlebeta" provider // offering "google_compute_instance". ProviderName string `json:"provider_name,omitempty"` // SchemaVersion indicates which version of the resource type schema the // "values" property conforms to. SchemaVersion uint64 `json:"schema_version"` // AttributeValues is the JSON representation of the attribute values of the // resource, whose structure depends on the resource type schema. Any // unknown values are omitted or set to null, making them indistinguishable // from absent values. AttributeValues AttributeValues `json:"values,omitempty"` // SensitiveValues is similar to AttributeValues, but with all sensitive // values replaced with true, and all non-sensitive leaf values omitted. SensitiveValues json.RawMessage `json:"sensitive_values,omitempty"` } // AttributeValues is the JSON representation of the attribute values of the // resource, whose structure depends on the resource type schema. type AttributeValues map[string]any var indexBrackets = regexp.MustCompile(`\[(\d+)\]`) // Digs through the attribute values to find the value at the given key. This // supports nested keys i.e. "foo.bar" and arrays i.e. "foo[0]" func (av AttributeValues) Dig(key string) (any, bool) { sections := strings.Split(key, ".") if len(sections) == 0 { return nil, false } // Get the first section section := sections[0] // Check for an index indexMatches := indexBrackets.FindStringSubmatch(section) var value any var ok bool if len(indexMatches) == 0 { // No index, just get the value value, ok = av[section] if !ok { return nil, false } } else { // Get the index index, err := strconv.Atoi(indexMatches[1]) if err != nil { return nil, false } // Get the value keyName := indexBrackets.ReplaceAllString(section, "") arr, ok := av[keyName] if !ok { return nil, false } // Check if the value is an array array, ok := arr.([]any) if !ok { return nil, false } // Check if the index is in range if index < 0 || index >= len(array) { return nil, false } value = array[index] } // If there are no more sections, then we're done if len(sections) == 1 { return value, true } // If there are more sections, then we need to dig deeper childMap, ok := value.(map[string]any) if !ok { return nil, false } childAttributeValues := AttributeValues(childMap) return childAttributeValues.Dig(strings.Join(sections[1:], ".")) } type Output struct { Sensitive bool `json:"sensitive"` Type json.RawMessage `json:"type,omitempty"` Value json.RawMessage `json:"value,omitempty"` } // ResourceChange is a description of an individual change action that Terraform // plans to use to move from the prior state to a new state matching the // configuration. type ResourceChange struct { // Address is the absolute resource address Address string `json:"address,omitempty"` // PreviousAddress is the absolute address that this resource instance had // at the conclusion of a previous run. // // This will typically be omitted, but will be present if the previous // resource instance was subject to a "moved" block that we handled in the // process of creating this plan. // // Note that this behavior diverges from the internal plan data structure, // where the previous address is set equal to the current address in the // common case, rather than being omitted. PreviousAddress string `json:"previous_address,omitempty"` // ModuleAddress is the module portion of the above address. Omitted if the // instance is in the root module. ModuleAddress string `json:"module_address,omitempty"` // "managed" or "data" Mode string `json:"mode,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Index json.RawMessage `json:"index,omitempty"` ProviderName string `json:"provider_name,omitempty"` // "deposed", if set, indicates that this action applies to a "deposed" // object of the given instance rather than to its "current" object. Omitted // for changes to the current object. Deposed string `json:"deposed,omitempty"` // Change describes the change that will be made to this object Change Change `json:"change"` // ActionReason is a keyword representing some optional extra context // for why the actions in Change.Actions were chosen. // // This extra detail is only for display purposes, to help a UI layer // present some additional explanation to a human user. The possible // values here might grow and change over time, so any consumer of this // information should be resilient to encountering unrecognized values // and treat them as an unspecified reason. ActionReason string `json:"action_reason,omitempty"` } // Change is the representation of a proposed change for an object. type Change struct { // Actions are the actions that will be taken on the object selected by the // properties below. Valid actions values are: // ["no-op"] // ["create"] // ["read"] // ["update"] // ["delete", "create"] // ["create", "delete"] // ["delete"] // The two "replace" actions are represented in this way to allow callers to // e.g. just scan the list for "delete" to recognize all three situations // where the object will be deleted, allowing for any new deletion // combinations that might be added in future. Actions []string `json:"actions,omitempty"` // Before and After are representations of the object value both before and // after the action. For ["create"] and ["delete"] actions, either "before" // or "after" is unset (respectively). For ["no-op"], the before and after // values are identical. The "after" value will be incomplete if there are // values within it that won't be known until after apply. Before json.RawMessage `json:"before,omitempty"` After json.RawMessage `json:"after,omitempty"` // AfterUnknown is an object value with similar structure to After, but // with all unknown leaf values replaced with true, and all known leaf // values omitted. This can be combined with After to reconstruct a full // value after the action, including values which will only be known after // apply. AfterUnknown json.RawMessage `json:"after_unknown,omitempty"` // BeforeSensitive and AfterSensitive are object values with similar // structure to Before and After, but with all sensitive leaf values // replaced with true, and all non-sensitive leaf values omitted. These // objects should be combined with Before and After to prevent accidental // display of sensitive values in user interfaces. BeforeSensitive json.RawMessage `json:"before_sensitive,omitempty"` AfterSensitive json.RawMessage `json:"after_sensitive,omitempty"` // ReplacePaths is an array of arrays representing a set of paths into the // object value which resulted in the action being "replace". This will be // omitted if the action is not replace, or if no paths caused the // replacement (for example, if the resource was tainted). Each path // consists of one or more steps, each of which will be a number or a // string. ReplacePaths json.RawMessage `json:"replace_paths,omitempty"` // Importing contains the import metadata about this operation. If importing // is present (ie. not null) then the change is an import operation in // addition to anything mentioned in the actions field. The actual contents // of the Importing struct is subject to change, so downstream consumers // should treat any values in here as strictly optional. Importing *Importing `json:"importing,omitempty"` // GeneratedConfig contains any HCL config generated for this resource // during planning as a string. // // If this is populated, then Importing should also be populated but this // might change in the future. However, nNot all Importing changes will // contain generated config. GeneratedConfig string `json:"generated_config,omitempty"` } // Importing is a nested object for the resource import metadata. type Importing struct { // The original ID of this resource used to target it as part of planned // import operation. ID string `json:"id,omitempty"` } // ResourceAttr contains the address and attribute of an external for the // RelevantAttributes in the plan. type ResourceAttr struct { Resource string `json:"resource"` Attr json.RawMessage `json:"attribute"` } // State is the top-level representation of the json format of a terraform // state. type State struct { FormatVersion string `json:"format_version,omitempty"` TerraformVersion string `json:"terraform_version,omitempty"` Values *StateValues `json:"values,omitempty"` Checks json.RawMessage `json:"checks,omitempty"` } ================================================ FILE: tfutils/plan_mapper.go ================================================ package tfutils import ( "context" "encoding/json" "fmt" "os" "slices" "strings" "time" "github.com/getsentry/sentry-go" "github.com/google/uuid" awsAdapters "github.com/overmindtech/cli/aws-source/adapters" "github.com/overmindtech/cli/go/sdp-go" k8sAdapters "github.com/overmindtech/cli/k8s-source/adapters" azureAdapters "github.com/overmindtech/cli/sources/azure/proc" gcpAdapters "github.com/overmindtech/cli/sources/gcp/proc" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) type MapStatus int func (m MapStatus) String() string { switch m { case MapStatusSuccess: return "success" case MapStatusNotEnoughInfo: return "not enough info" case MapStatusUnsupported: return "unsupported" case MapStatusPendingCreation: return "pending creation" default: return "unknown" } } const ( MapStatusSuccess MapStatus = iota MapStatusNotEnoughInfo MapStatusUnsupported MapStatusPendingCreation ) const KnownAfterApply = `(known after apply)` type PlannedChangeMapResult struct { // The full name of the resource in the Terraform plan TerraformName string // The terraform resource type TerraformType string // The status of the mapping Status MapStatus // The message that should be printed next to the status e.g. "mapped" or // "missing arn" Message string *sdp.MappedItemDiff } type PlanMappingResult struct { Results []PlannedChangeMapResult RemovedSecrets int } func (r *PlanMappingResult) NumSuccess() int { return r.numStatus(MapStatusSuccess) } func (r *PlanMappingResult) NumNotEnoughInfo() int { return r.numStatus(MapStatusNotEnoughInfo) } func (r *PlanMappingResult) NumUnsupported() int { return r.numStatus(MapStatusUnsupported) } func (r *PlanMappingResult) NumPendingCreation() int { return r.numStatus(MapStatusPendingCreation) } func (r *PlanMappingResult) NumTotal() int { return len(r.Results) } func (r *PlanMappingResult) GetItemDiffs() []*sdp.MappedItemDiff { diffs := make([]*sdp.MappedItemDiff, 0) for _, result := range r.Results { if result.MappedItemDiff != nil { diffs = append(diffs, result.MappedItemDiff) } } return diffs } func (r *PlanMappingResult) numStatus(status MapStatus) int { count := 0 for _, result := range r.Results { if result.Status == status { count++ } } return count } func MappedItemDiffsFromPlanFile(ctx context.Context, fileName string, scope string, lf log.Fields) (*PlanMappingResult, error) { planData, err := os.ReadFile(fileName) if err != nil { log.WithContext(ctx).WithError(err).WithFields(lf).Error("Failed to read terraform plan") return nil, err } // Check if this is a JSON plan file if !isJSONPlanFile(planData) { return nil, fmt.Errorf("plan file '%s' appears to be in binary format, but Overmind only supports JSON plan files.\n\nTo fix this, convert your binary plan to JSON format:\n 1. Using OpenTofu: tofu show -json %s > plan.json\n 2. Using Terraform: terraform show -json %s > plan.json\n 3. Then run: overmind changes submit-plan plan.json", fileName, fileName, fileName) } return MappedItemDiffsFromPlan(ctx, planData, fileName, scope, lf) } type TfMapData struct { // The overmind type name OvermindType string // The method that the query should use Method sdp.QueryMethod // The field within the resource that should be queried for QueryField string } // MappedItemDiffsFromPlan takes a plan JSON, file name, and log fields as input // and returns the mapping results and an error. It parses the plan JSON, // extracts resource changes, and creates mapped item differences for each // resource change. It also generates mapping queries based on the resource type // and current resource values. The function categorizes the mapped item // differences into supported and unsupported changes. Finally, it logs the // number of supported and unsupported changes and returns the mapped item // differences. The `scope` determines the scope of the resources that will be // generated, not the queries. These will always have a scope of `*` func MappedItemDiffsFromPlan(ctx context.Context, planJson []byte, fileName string, scope string, lf log.Fields) (*PlanMappingResult, error) { // Create a span for this since we're going to be attaching events to it when things fail to map span := trace.SpanFromContext(ctx) defer span.End() // Check that we haven't been passed a state file if isStateFile(planJson) { return nil, fmt.Errorf("'%v' appears to be a state file, not a plan file", fileName) } // Load mapping data from the sources and convert into a map so that we can // index by Terraform type adapterMetadata := awsAdapters.Metadata.AllAdapterMetadata() adapterMetadata = append(adapterMetadata, k8sAdapters.Metadata.AllAdapterMetadata()...) adapterMetadata = append(adapterMetadata, gcpAdapters.Metadata.AllAdapterMetadata()...) adapterMetadata = append(adapterMetadata, azureAdapters.Metadata.AllAdapterMetadata()...) // These mappings are from the terraform type, to required mapping data mappings := make(map[string][]TfMapData) for _, metadata := range adapterMetadata { if metadata.GetType() == "" { continue } for _, mapping := range metadata.GetTerraformMappings() { // Extract the query field and type from the mapping subs := strings.SplitN(mapping.GetTerraformQueryMap(), ".", 2) if len(subs) != 2 { log.WithContext(ctx).WithFields(lf).WithField("terraform-query-map", mapping.GetTerraformQueryMap()).Warn("Skipping mapping with invalid query map") continue } terraformType := subs[0] queryField := subs[1] // Add the mapping details mappings[terraformType] = append(mappings[terraformType], TfMapData{ OvermindType: metadata.GetType(), Method: mapping.GetTerraformMethod(), QueryField: queryField, }) } } var plan Plan err := json.Unmarshal(planJson, &plan) if err != nil { return nil, fmt.Errorf("failed to parse '%v': %w", fileName, err) } results := PlanMappingResult{ Results: make([]PlannedChangeMapResult, 0), RemovedSecrets: countSensitiveValuesInConfig(plan.Config.RootModule) + countSensitiveValuesInState(plan.PlannedValues.RootModule), } // for all managed resources: for _, resourceChange := range plan.ResourceChanges { if len(resourceChange.Change.Actions) == 0 || resourceChange.Change.Actions[0] == "no-op" || resourceChange.Mode == "data" { // skip resources with no changes and data updates continue } itemDiff, err := itemDiffFromResourceChange(resourceChange, scope) if err != nil { return nil, fmt.Errorf("failed to create item diff for resource change: %w", err) } // Get the Terraform mappings for this specific type relevantMappings, ok := mappings[resourceChange.Type] if !ok { log.WithContext(ctx).WithFields(lf).WithField("terraform-address", resourceChange.Address).Debug("Skipping unmapped resource") results.Results = append(results.Results, PlannedChangeMapResult{ TerraformName: resourceChange.Address, TerraformType: resourceChange.Type, Status: MapStatusUnsupported, Message: "unsupported", MappedItemDiff: &sdp.MappedItemDiff{ Item: itemDiff, MappingQuery: nil, // unmapped item has no mapping query }, }) continue } var currentResource *Resource // Look for the resource in the prior values first, since this is // the *previous* state we're like to be able to find it in the // actual infra if plan.PriorState.Values != nil { currentResource = plan.PriorState.Values.RootModule.DigResource(resourceChange.Address) } // If we didn't find it, look in the planned values if currentResource == nil { currentResource = plan.PlannedValues.RootModule.DigResource(resourceChange.Address) } if currentResource == nil { log.WithContext(ctx). WithFields(lf). WithField("terraform-address", resourceChange.Address). Warn("Skipping resource without values") continue } results.Results = append(results.Results, mapResourceToQuery(itemDiff, currentResource, relevantMappings)) } // Attach failed mappings to the span for _, result := range results.Results { switch result.Status { case MapStatusUnsupported, MapStatusNotEnoughInfo, MapStatusPendingCreation: span.AddEvent("UnmappedResource", trace.WithAttributes( attribute.String("ovm.climap.status", result.Status.String()), attribute.String("ovm.climap.message", result.Message), attribute.String("ovm.climap.terraform-name", result.TerraformName), attribute.String("ovm.climap.terraform-type", result.TerraformType), )) case MapStatusSuccess: // Don't include these } } return &results, nil } // Maps a resource to an Overmind query, or at least tries to given the provided // mappings. If there are multiple valid queries, the first one will be used. // // In the future we might allow for multiple queries to be returned, this work // will be tracked here: https://github.com/overmindtech/workspace/sdp/issues/272 func mapResourceToQuery(itemDiff *sdp.ItemDiff, terraformResource *Resource, mappings []TfMapData) PlannedChangeMapResult { attemptedMappings := make([]string, 0) if len(mappings) == 0 { mappingStatus := sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_UNSUPPORTED return PlannedChangeMapResult{ TerraformName: terraformResource.Address, TerraformType: terraformResource.Type, Status: MapStatusUnsupported, Message: "unsupported", MappedItemDiff: &sdp.MappedItemDiff{ Item: itemDiff, MappingQuery: nil, // unmapped item has no mapping query MappingStatus: &mappingStatus, }, } } for _, mapping := range mappings { // See if the query field exists in the resource. If it doesn't then we // will continue to the next mapping query, ok := terraformResource.AttributeValues.Dig(mapping.QueryField) if ok { // If the query field exists, we will create a query u := uuid.New() newQuery := &sdp.Query{ Type: mapping.OvermindType, Method: mapping.Method, Query: fmt.Sprintf("%v", query), Scope: "*", RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, UUID: u[:], Deadline: timestamppb.New(time.Now().Add(60 * time.Second)), } // Set the type of item to the Overmind-supported type rather than // the Terraform one if itemDiff.GetBefore() != nil { itemDiff.Before.Type = mapping.OvermindType } if itemDiff.GetAfter() != nil { itemDiff.After.Type = mapping.OvermindType } mappingStatus := sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_SUCCESS return PlannedChangeMapResult{ TerraformName: terraformResource.Address, TerraformType: terraformResource.Type, Status: MapStatusSuccess, Message: "mapped", MappedItemDiff: &sdp.MappedItemDiff{ Item: itemDiff, MappingQuery: newQuery, MappingStatus: &mappingStatus, }, } } // It it wasn't successful, add the mapping to the list of attempted // mappings attemptedMappings = append(attemptedMappings, mapping.QueryField) } // If we get to this point, we haven't found a mapping message := fmt.Sprintf("missing mapping attribute: %v", strings.Join(attemptedMappings, ", ")) // Check if this is a newly created resource - these don't exist yet so missing // attributes are expected, not an error if itemDiff.GetStatus() == sdp.ItemDiffStatus_ITEM_DIFF_STATUS_CREATED { mappingStatus := sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_PENDING_CREATION return PlannedChangeMapResult{ TerraformName: terraformResource.Address, TerraformType: terraformResource.Type, Status: MapStatusPendingCreation, Message: "pending creation", MappedItemDiff: &sdp.MappedItemDiff{ Item: itemDiff, MappingQuery: nil, // unmapped item has no mapping query MappingStatus: &mappingStatus, // No MappingError - this is expected, not an error }, } } // For other statuses (REPLACED, UPDATED, DELETED), missing attributes are a real error mappingStatus := sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_ERROR return PlannedChangeMapResult{ TerraformName: terraformResource.Address, TerraformType: terraformResource.Type, Status: MapStatusNotEnoughInfo, Message: message, MappedItemDiff: &sdp.MappedItemDiff{ Item: itemDiff, MappingQuery: nil, // unmapped item has no mapping query MappingStatus: &mappingStatus, MappingError: &sdp.QueryError{ ErrorType: sdp.QueryError_OTHER, ErrorString: message, }, }, } } // isJSONPlanFile checks if the supplied bytes are valid JSON that could be a plan file. // This is used to determine if we need to convert a binary plan or if it's already JSON. func isJSONPlanFile(bytes []byte) bool { var jsonValue any err := json.Unmarshal(bytes, &jsonValue) if err != nil { return false } // If it's valid JSON, we can try to parse it as a plan return true } // Checks if the supplied JSON bytes are a state file. It's a common mistake to // pass a state file to Overmind rather than a plan file since the commands to // create them are similar func isStateFile(bytes []byte) bool { fields := make(map[string]any) err := json.Unmarshal(bytes, &fields) if err != nil { return false } if _, exists := fields["values"]; exists { return true } return false } func countSensitiveValuesInConfig(m ConfigModule) int { removedSecrets := 0 for _, v := range m.Variables { if v.Sensitive { removedSecrets++ } } for _, o := range m.Outputs { if o.Sensitive { removedSecrets++ } } for _, c := range m.ModuleCalls { removedSecrets += countSensitiveValuesInConfig(c.Module) } return removedSecrets } func countSensitiveValuesInState(m Module) int { removedSecrets := 0 for _, r := range m.Resources { removedSecrets += countSensitiveValuesInResource(r) } for _, c := range m.ChildModules { removedSecrets += countSensitiveValuesInState(c) } return removedSecrets } // follow itemAttributesFromResourceChangeData and maskSensitiveData // implementation to count sensitive values func countSensitiveValuesInResource(r Resource) int { // sensitiveMsg can be a bool or a map[string]any var isSensitive bool err := json.Unmarshal(r.SensitiveValues, &isSensitive) if err == nil && isSensitive { return 1 // one very large secret } else if err != nil { // only try parsing as map if parsing as bool failed var sensitive map[string]any err = json.Unmarshal(r.SensitiveValues, &sensitive) if err != nil { return 0 } return countSensitiveAttributes(r.AttributeValues, sensitive) } return 0 } func countSensitiveAttributes(attributes, sensitive any) int { if sensitive == true { return 1 } else if sensitiveMap, ok := sensitive.(map[string]any); ok { if attributesMap, ok := attributes.(map[string]any); ok { result := 0 for k, v := range attributesMap { result += countSensitiveAttributes(v, sensitiveMap[k]) } return result } else { return 1 } } else if sensitiveArr, ok := sensitive.([]any); ok { if attributesArr, ok := attributes.([]any); ok { if len(sensitiveArr) != len(attributesArr) { return 1 } result := 0 for i, v := range attributesArr { result += countSensitiveAttributes(v, sensitiveArr[i]) } return result } else { return 1 } } return 0 } // Converts a ResourceChange form a terraform plan to an ItemDiff in SDP format. func itemDiffFromResourceChange(resourceChange ResourceChange, scope string) (*sdp.ItemDiff, error) { status := sdp.ItemDiffStatus_ITEM_DIFF_STATUS_UNSPECIFIED if slices.Equal(resourceChange.Change.Actions, []string{"no-op"}) || slices.Equal(resourceChange.Change.Actions, []string{"read"}) { status = sdp.ItemDiffStatus_ITEM_DIFF_STATUS_UNCHANGED } else if slices.Equal(resourceChange.Change.Actions, []string{"create"}) { status = sdp.ItemDiffStatus_ITEM_DIFF_STATUS_CREATED } else if slices.Equal(resourceChange.Change.Actions, []string{"update"}) { status = sdp.ItemDiffStatus_ITEM_DIFF_STATUS_UPDATED } else if slices.Equal(resourceChange.Change.Actions, []string{"delete", "create"}) { status = sdp.ItemDiffStatus_ITEM_DIFF_STATUS_REPLACED } else if slices.Equal(resourceChange.Change.Actions, []string{"create", "delete"}) { status = sdp.ItemDiffStatus_ITEM_DIFF_STATUS_REPLACED } else if slices.Equal(resourceChange.Change.Actions, []string{"delete"}) { status = sdp.ItemDiffStatus_ITEM_DIFF_STATUS_DELETED } beforeAttributes, err := itemAttributesFromResourceChangeData(resourceChange.Change.Before, resourceChange.Change.BeforeSensitive) if err != nil { return nil, fmt.Errorf("failed to parse before attributes: %w", err) } afterAttributes, err := itemAttributesFromResourceChangeData(resourceChange.Change.After, resourceChange.Change.AfterSensitive) if err != nil { return nil, fmt.Errorf("failed to parse after attributes: %w", err) } err = handleKnownAfterApply(beforeAttributes, afterAttributes, resourceChange.Change.AfterUnknown) if err != nil { return nil, fmt.Errorf("failed to remove known after apply fields: %w", err) } result := &sdp.ItemDiff{ // Item: filled in by item mapping in UpdatePlannedChanges Status: status, } // shorten the address by removing the type prefix if and only if it is the // first part. Longer terraform addresses created in modules will not be // shortened to avoid confusion. trimmedAddress, _ := strings.CutPrefix(resourceChange.Address, fmt.Sprintf("%v.", resourceChange.Type)) if beforeAttributes != nil { result.Before = &sdp.Item{ Type: resourceChange.Type, UniqueAttribute: "terraform_name", Attributes: beforeAttributes, Scope: scope, } err = result.GetBefore().GetAttributes().Set("terraform_name", trimmedAddress) if err != nil { // since Address is a string, this should never happen sentry.CaptureException(fmt.Errorf("failed to set terraform_name '%v' on before attributes: %w", trimmedAddress, err)) } err = result.GetBefore().GetAttributes().Set("terraform_address", resourceChange.Address) if err != nil { // since Address is a string, this should never happen sentry.CaptureException(fmt.Errorf("failed to set terraform_address of type %T (%v) on before attributes: %w", resourceChange.Address, resourceChange.Address, err)) } } if afterAttributes != nil { result.After = &sdp.Item{ Type: resourceChange.Type, UniqueAttribute: "terraform_name", Attributes: afterAttributes, Scope: scope, } err = result.GetAfter().GetAttributes().Set("terraform_name", trimmedAddress) if err != nil { // since Address is a string, this should never happen sentry.CaptureException(fmt.Errorf("failed to set terraform_name '%v' on after attributes: %w", trimmedAddress, err)) } err = result.GetAfter().GetAttributes().Set("terraform_address", resourceChange.Address) if err != nil { // since Address is a string, this should never happen sentry.CaptureException(fmt.Errorf("failed to set terraform_address of type %T (%v) on after attributes: %w", resourceChange.Address, resourceChange.Address, err)) } } return result, nil } func itemAttributesFromResourceChangeData(attributesMsg, sensitiveMsg json.RawMessage) (*sdp.ItemAttributes, error) { var attributes map[string]any err := json.Unmarshal(attributesMsg, &attributes) if err != nil { return nil, fmt.Errorf("failed to parse attributes: %w", err) } // sensitiveMsg can be a bool or a map[string]any var isSensitive bool err = json.Unmarshal(sensitiveMsg, &isSensitive) if err == nil && isSensitive { attributes = maskAllData(attributes) } else if err != nil { // only try parsing as map if parsing as bool failed var sensitive map[string]any err = json.Unmarshal(sensitiveMsg, &sensitive) if err != nil { return nil, fmt.Errorf("failed to parse sensitive: %w", err) } attributes = maskSensitiveData(attributes, sensitive).(map[string]any) } return sdp.ToAttributesSorted(attributes) } // maskAllData masks every entry in attributes as redacted func maskAllData(attributes map[string]any) map[string]any { for k, v := range attributes { if mv, ok := v.(map[string]any); ok { attributes[k] = maskAllData(mv) } else { attributes[k] = "(sensitive value)" } } return attributes } // maskSensitiveData masks every entry in attributes that is set to true in sensitive. returns the redacted attributes func maskSensitiveData(attributes, sensitive any) any { if sensitive == true { return "(sensitive value)" } else if sensitiveMap, ok := sensitive.(map[string]any); ok { if attributesMap, ok := attributes.(map[string]any); ok { result := map[string]any{} for k, v := range attributesMap { result[k] = maskSensitiveData(v, sensitiveMap[k]) } return result } else { return "(sensitive value) (type mismatch)" } } else if sensitiveArr, ok := sensitive.([]any); ok { if attributesArr, ok := attributes.([]any); ok { if len(sensitiveArr) != len(attributesArr) { return "(sensitive value) (len mismatch)" } result := make([]any, len(attributesArr)) for i, v := range attributesArr { result[i] = maskSensitiveData(v, sensitiveArr[i]) } return result } else { return "(sensitive value) (type mismatch)" } } return attributes } // Finds fields from the `before` and `after` attributes that are known after // apply and replaces the "after" value with the string "(known after apply)" func handleKnownAfterApply(before, after *sdp.ItemAttributes, afterUnknown json.RawMessage) error { var afterUnknownInterface any err := json.Unmarshal(afterUnknown, &afterUnknownInterface) if err != nil { return fmt.Errorf("could not unmarshal `after_unknown` from plan: %w", err) } // Convert the parent struct to a value so that we can treat them all the // same when we recurse beforeValue := structpb.Value{ Kind: &structpb.Value_StructValue{ StructValue: before.GetAttrStruct(), }, } afterValue := structpb.Value{ Kind: &structpb.Value_StructValue{ StructValue: after.GetAttrStruct(), }, } err = insertKnownAfterApply(&beforeValue, &afterValue, afterUnknownInterface) if err != nil { return fmt.Errorf("failed to remove known after apply fields: %w", err) } return nil } // Inserts the text "(known after apply)" in place of null values in the planned // "after" values for fields that are known after apply. By default these are // `null` which produces a bad diff, so we replace them with (known after apply) // to more accurately mirror what Terraform does in the CLI func insertKnownAfterApply(before, after *structpb.Value, afterUnknown any) error { switch afterUnknown := afterUnknown.(type) { case map[string]any: for k, v := range afterUnknown { if v == true { if afterFields := after.GetStructValue().GetFields(); afterFields != nil { // Insert this in the after fields even if it doesn't exist. // This is because sometimes you will get a plan that only // has a before value for a know after apply field, so we // want to still make sure it shows up afterFields[k] = &structpb.Value{ Kind: &structpb.Value_StringValue{ StringValue: KnownAfterApply, }, } } } else if v == false { // Do nothing continue } else { // Recurse into the nested fields err := insertKnownAfterApply(before.GetStructValue().GetFields()[k], after.GetStructValue().GetFields()[k], v) if err != nil { return err } } } case []any: for i, v := range afterUnknown { if v == true { // If this value in a slice is true, set the corresponding value // in after to (know after apply) if after.GetListValue() != nil && len(after.GetListValue().GetValues()) > i { after.GetListValue().Values[i] = &structpb.Value{ Kind: &structpb.Value_StringValue{ StringValue: KnownAfterApply, }, } } } else if v == false { // Do nothing continue } else { // Make sure that the before and after both actually have a // valid list item at this position, if they don't we can just // pass `nil` to the `removeUnknownFields` function and it'll // handle it beforeListValues := before.GetListValue().GetValues() afterListValues := after.GetListValue().GetValues() var nestedBeforeValue *structpb.Value var nestedAfterValue *structpb.Value if len(beforeListValues) > i { nestedBeforeValue = beforeListValues[i] } if len(afterListValues) > i { nestedAfterValue = afterListValues[i] } err := insertKnownAfterApply(nestedBeforeValue, nestedAfterValue, v) if err != nil { return err } } } default: return nil } return nil } ================================================ FILE: tfutils/plan_mapper_test.go ================================================ package tfutils import ( "context" "encoding/json" "fmt" "os" "strconv" "strings" "testing" "github.com/overmindtech/cli/go/sdp-go" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" "github.com/xiam/dig" ) func TestWithStateFile(t *testing.T) { _, err := MappedItemDiffsFromPlanFile(context.Background(), "testdata/state.json", "scope", log.Fields{}) if err == nil { t.Error("Expected error when running with state file, got none") } } func TestMapResourceToQuery_PendingCreation(t *testing.T) { t.Parallel() tests := []struct { name string itemDiffStatus sdp.ItemDiffStatus hasMappings bool expectedMapStatus MapStatus expectedMappingStatus sdp.MappedItemMappingStatus expectMappingError bool }{ { name: "CREATED with missing attributes - pending creation", itemDiffStatus: sdp.ItemDiffStatus_ITEM_DIFF_STATUS_CREATED, hasMappings: true, expectedMapStatus: MapStatusPendingCreation, expectedMappingStatus: sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_PENDING_CREATION, expectMappingError: false, }, { name: "UPDATED with missing attributes - error", itemDiffStatus: sdp.ItemDiffStatus_ITEM_DIFF_STATUS_UPDATED, hasMappings: true, expectedMapStatus: MapStatusNotEnoughInfo, expectedMappingStatus: sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_ERROR, expectMappingError: true, }, { name: "DELETED with missing attributes - error", itemDiffStatus: sdp.ItemDiffStatus_ITEM_DIFF_STATUS_DELETED, hasMappings: true, expectedMapStatus: MapStatusNotEnoughInfo, expectedMappingStatus: sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_ERROR, expectMappingError: true, }, { name: "REPLACED with missing attributes - error", itemDiffStatus: sdp.ItemDiffStatus_ITEM_DIFF_STATUS_REPLACED, hasMappings: true, expectedMapStatus: MapStatusNotEnoughInfo, expectedMappingStatus: sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_ERROR, expectMappingError: true, }, { name: "No mappings - unsupported", itemDiffStatus: sdp.ItemDiffStatus_ITEM_DIFF_STATUS_CREATED, hasMappings: false, expectedMapStatus: MapStatusUnsupported, expectedMappingStatus: sdp.MappedItemMappingStatus_MAPPED_ITEM_MAPPING_STATUS_UNSUPPORTED, expectMappingError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create an itemDiff with the specified status itemDiff := &sdp.ItemDiff{ Status: tt.itemDiffStatus, } // Create a terraform resource without the mapping attribute (simulating missing id/arn) terraformResource := &Resource{ Address: "test_resource.example", Type: "test_resource", AttributeValues: AttributeValues{ // No "id" field - simulating missing mapping attribute "name": "test-name", }, } // Setup mappings - empty if testing unsupported, otherwise include one var mappings []TfMapData if tt.hasMappings { mappings = []TfMapData{ { OvermindType: "test-type", Method: sdp.QueryMethod_GET, QueryField: "id", // This field doesn't exist in AttributeValues }, } } // Call the function result := mapResourceToQuery(itemDiff, terraformResource, mappings) // Verify the MapStatus if result.Status != tt.expectedMapStatus { t.Errorf("Expected MapStatus %v, got %v", tt.expectedMapStatus, result.Status) } // Verify the MappingStatus if result.MappedItemDiff.GetMappingStatus() != tt.expectedMappingStatus { t.Errorf("Expected MappingStatus %v, got %v", tt.expectedMappingStatus, result.MappedItemDiff.GetMappingStatus()) } // Verify MappingError presence if tt.expectMappingError && result.MappedItemDiff.GetMappingError() == nil { t.Error("Expected MappingError to be set, but it was nil") } if !tt.expectMappingError && result.MappedItemDiff.GetMappingError() != nil { t.Errorf("Expected MappingError to be nil, but got: %v", result.MappedItemDiff.GetMappingError()) } // Verify MappingQuery is nil (no query should be created when mapping fails) if result.MappedItemDiff.GetMappingQuery() != nil { t.Errorf("Expected MappingQuery to be nil, but got: %v", result.MappedItemDiff.GetMappingQuery()) } }) } } func TestExtractProviderNameFromConfigKey(t *testing.T) { tests := []struct { ConfigKey string Expected string }{ { ConfigKey: "kubernetes", Expected: "kubernetes", }, { ConfigKey: "module.core:kubernetes", Expected: "kubernetes", }, } for _, test := range tests { t.Run(test.ConfigKey, func(t *testing.T) { actual := extractProviderNameFromConfigKey(test.ConfigKey) if actual != test.Expected { t.Errorf("Expected %v, got %v", test.Expected, actual) } }) } } func TestMappedItemDiffsFromPlan(t *testing.T) { results, err := MappedItemDiffsFromPlanFile(context.Background(), "testdata/plan.json", "scope", log.Fields{}) if err != nil { t.Error(err) } if results.RemovedSecrets != 16 { t.Errorf("Expected 16 secrets, got %v", results.RemovedSecrets) } if len(results.Results) != 5 { t.Errorf("Expected 5 changes, got %v:", len(results.Results)) for _, diff := range results.Results { t.Errorf(" %v", diff) } } var nats_box_deployment *sdp.MappedItemDiff var api_server_deployment *sdp.MappedItemDiff var aws_iam_policy *sdp.MappedItemDiff var secret *sdp.MappedItemDiff for _, result := range results.Results { item := result.GetItem().GetBefore() if item == nil && result.GetItem().GetAfter() != nil { item = result.GetItem().GetAfter() } if item == nil { t.Errorf("Expected any of before/after items to be set, but there's nothing: %v", result) continue } // t.Logf("item: %v", item.Attributes.AttrStruct.Fields["terraform_address"].GetStringValue()) if item.GetAttributes().GetAttrStruct().GetFields()["terraform_address"].GetStringValue() == "kubernetes_deployment.nats_box" { if nats_box_deployment != nil { t.Errorf("Found multiple nats_box_deployment: %v, %v", nats_box_deployment, result) } nats_box_deployment = result.MappedItemDiff } else if item.GetAttributes().GetAttrStruct().GetFields()["terraform_address"].GetStringValue() == "kubernetes_deployment.api_server" { if api_server_deployment != nil { t.Errorf("Found multiple api_server_deployment: %v, %v", api_server_deployment, result) } api_server_deployment = result.MappedItemDiff } else if item.GetType() == "iam-policy" { if aws_iam_policy != nil { t.Errorf("Found multiple aws_iam_policy: %v, %v", aws_iam_policy, result) } aws_iam_policy = result.MappedItemDiff } else if item.GetType() == "Secret" { if secret != nil { t.Errorf("Found multiple secrets: %v, %v", secret, result) } secret = result.MappedItemDiff } } // check nats_box_deployment t.Logf("nats_box_deployment: %v", nats_box_deployment) if nats_box_deployment == nil { t.Fatalf("Expected nats_box_deployment to be set, but it's not") } if nats_box_deployment.GetItem().GetStatus() != sdp.ItemDiffStatus_ITEM_DIFF_STATUS_DELETED { t.Errorf("Expected nats_box_deployment status to be 'deleted', but it's '%v'", nats_box_deployment.GetItem().GetStatus()) } if nats_box_deployment.GetMappingQuery().GetType() != "Deployment" { t.Errorf("Expected nats_box_deployment query type to be 'Deployment', got '%v'", nats_box_deployment.GetMappingQuery().GetType()) } if nats_box_deployment.GetMappingQuery().GetQuery() != "nats-box" { t.Errorf("Expected nats_box_deployment query to be 'nats-box', got '%v'", nats_box_deployment.GetMappingQuery().GetQuery()) } if nats_box_deployment.GetMappingQuery().GetScope() != "*" { t.Errorf("Expected nats_box_deployment query scope to be '*', got '%v'", nats_box_deployment.GetMappingQuery().GetScope()) } if nats_box_deployment.GetItem().GetBefore().GetScope() != "scope" { t.Errorf("Expected nats_box_deployment before item scope to be 'scope', got '%v'", nats_box_deployment.GetItem().GetBefore().GetScope()) } if nats_box_deployment.GetMappingQuery().GetType() != "Deployment" { t.Errorf("Expected nats_box_deployment query type to be 'Deployment', got '%v'", nats_box_deployment.GetMappingQuery().GetType()) } if nats_box_deployment.GetItem().GetBefore().GetType() != "Deployment" { t.Errorf("Expected nats_box_deployment before item type to be 'Deployment', got '%v'", nats_box_deployment.GetItem().GetBefore().GetType()) } if nats_box_deployment.GetMappingQuery().GetQuery() != "nats-box" { t.Errorf("Expected nats_box_deployment query query to be 'nats-box', got '%v'", nats_box_deployment.GetMappingQuery().GetQuery()) } // check api_server_deployment t.Logf("api_server_deployment: %v", api_server_deployment) if api_server_deployment == nil { t.Fatalf("Expected api_server_deployment to be set, but it's not") } if api_server_deployment.GetItem().GetStatus() != sdp.ItemDiffStatus_ITEM_DIFF_STATUS_UPDATED { t.Errorf("Expected api_server_deployment status to be 'updated', but it's '%v'", api_server_deployment.GetItem().GetStatus()) } if api_server_deployment.GetMappingQuery().GetType() != "Deployment" { t.Errorf("Expected api_server_deployment query type to be 'Deployment', got '%v'", api_server_deployment.GetMappingQuery().GetType()) } if api_server_deployment.GetMappingQuery().GetQuery() != "api-server" { t.Errorf("Expected api_server_deployment query to be 'api-server', got '%v'", api_server_deployment.GetMappingQuery().GetQuery()) } if api_server_deployment.GetMappingQuery().GetScope() != "*" { t.Errorf("Expected api_server_deployment query scope to be '*', got '%v'", api_server_deployment.GetMappingQuery().GetScope()) } if api_server_deployment.GetItem().GetBefore().GetScope() != "scope" { t.Errorf("Expected api_server_deployment before item scope to be 'scope', got '%v'", api_server_deployment.GetItem().GetBefore().GetScope()) } if api_server_deployment.GetMappingQuery().GetType() != "Deployment" { t.Errorf("Expected api_server_deployment query type to be 'Deployment', got '%v'", api_server_deployment.GetMappingQuery().GetType()) } if api_server_deployment.GetItem().GetBefore().GetType() != "Deployment" { t.Errorf("Expected api_server_deployment before item type to be 'Deployment', got '%v'", api_server_deployment.GetItem().GetBefore().GetType()) } if api_server_deployment.GetMappingQuery().GetQuery() != "api-server" { t.Errorf("Expected api_server_deployment query query to be 'api-server', got '%v'", api_server_deployment.GetMappingQuery().GetQuery()) } // check aws_iam_policy t.Logf("aws_iam_policy: %v", aws_iam_policy) if aws_iam_policy == nil { t.Fatalf("Expected aws_iam_policy to be set, but it's not") } if aws_iam_policy.GetItem().GetStatus() != sdp.ItemDiffStatus_ITEM_DIFF_STATUS_UPDATED { t.Errorf("Expected aws_iam_policy status to be 'updated', but it's %v", aws_iam_policy.GetItem().GetStatus()) } if aws_iam_policy.GetMappingQuery().GetType() != "iam-policy" { t.Errorf("Expected aws_iam_policy query type to be 'iam-policy', got '%v'", aws_iam_policy.GetMappingQuery().GetType()) } if aws_iam_policy.GetMappingQuery().GetQuery() != "arn:aws:iam::123456789012:policy/test-alb-ingress" { t.Errorf("Expected aws_iam_policy query to be 'arn:aws:iam::123456789012:policy/test-alb-ingress', got '%v'", aws_iam_policy.GetMappingQuery().GetQuery()) } if aws_iam_policy.GetMappingQuery().GetScope() != "*" { t.Errorf("Expected aws_iam_policy query scope to be '*', got '%v'", aws_iam_policy.GetMappingQuery().GetScope()) } if aws_iam_policy.GetItem().GetBefore().GetScope() != "scope" { t.Errorf("Expected aws_iam_policy before item scope to be 'scope', got '%v'", aws_iam_policy.GetItem().GetBefore().GetScope()) } if aws_iam_policy.GetMappingQuery().GetType() != "iam-policy" { t.Errorf("Expected aws_iam_policy query type to be 'iam-policy', got '%v'", aws_iam_policy.GetMappingQuery().GetType()) } if aws_iam_policy.GetItem().GetBefore().GetType() != "iam-policy" { t.Errorf("Expected aws_iam_policy before item type to be 'iam-policy', got '%v'", aws_iam_policy.GetItem().GetBefore().GetType()) } if aws_iam_policy.GetMappingQuery().GetQuery() != "arn:aws:iam::123456789012:policy/test-alb-ingress" { t.Errorf("Expected aws_iam_policy query query to be 'arn:aws:iam::123456789012:policy/test-alb-ingress', got '%v'", aws_iam_policy.GetMappingQuery().GetQuery()) } // check secret t.Logf("secret: %v", secret) if secret == nil { t.Fatalf("Expected secret to be set, but it's not") } if secret.GetMappingQuery().GetScope() != "*" { t.Errorf("Expected secret query scope to be '*', got '%v'", secret.GetMappingQuery().GetScope()) } // In a secret the "data" field is known after apply, but we don't *know* // that it's definitely going to change, so this should be (known after apply) dataVal, _ := secret.GetItem().GetAfter().GetAttributes().Get("data") if dataVal != KnownAfterApply { t.Errorf("Expected secret data to be known after apply, got '%v'", dataVal) } } func TestMapResourceToQuery(t *testing.T) { type mapTest struct { TestName string Resource *Resource Mappings []TfMapData ExpectedQuery *sdp.Query ExpectedStatus MapStatus } deploymentResource := Resource{ Address: "kubernetes_deployment.nats_box", Mode: "managed", Type: "kubernetes_deployment", Name: "nats_box", ProviderName: "kubernetes", SchemaVersion: 0, AttributeValues: AttributeValues{ "metadata": []any{ map[string]any{ "namespace": "default", "name": "nats-box", }, }, }, SensitiveValues: json.RawMessage{}, } tests := []mapTest{ { TestName: "nested k8s deployment", ExpectedQuery: &sdp.Query{ Type: "Deployment", Query: "nats-box", }, ExpectedStatus: MapStatusSuccess, Resource: &deploymentResource, Mappings: []TfMapData{ { OvermindType: "Deployment", Method: sdp.QueryMethod_GET, QueryField: "metadata[0].name", }, }, }, { TestName: "with no mappings", Resource: &deploymentResource, Mappings: []TfMapData{}, ExpectedQuery: nil, ExpectedStatus: MapStatusUnsupported, }, { TestName: "with mappings that don't work", Resource: &deploymentResource, Mappings: []TfMapData{ { OvermindType: "Deployment", Method: sdp.QueryMethod_GET, QueryField: "metadata[0].foo", }, }, ExpectedQuery: nil, ExpectedStatus: MapStatusNotEnoughInfo, }, } for _, test := range tests { t.Run(test.TestName, func(t *testing.T) { result := mapResourceToQuery(nil, test.Resource, test.Mappings) if result.Status != test.ExpectedStatus { t.Errorf("Expected status to be %v, got %v", test.ExpectedStatus, result.Status) } if test.ExpectedQuery != nil { if result.MappedItemDiff == nil { t.Errorf("Expected mapped item diff to be set, but it's not") } if result.MappedItemDiff.GetMappingQuery().GetType() != test.ExpectedQuery.GetType() { t.Errorf("Expected type to be %v, got %v", test.ExpectedQuery.GetType(), result.MappedItemDiff.GetMappingQuery().GetType()) } if result.MappedItemDiff.GetMappingQuery().GetQuery() != test.ExpectedQuery.GetQuery() { t.Errorf("Expected query to be %v, got %v", test.ExpectedQuery.GetQuery(), result.MappedItemDiff.GetMappingQuery().GetQuery()) } } }) } } func TestPlanMappingResultNumFuncs(t *testing.T) { result := PlanMappingResult{ Results: []PlannedChangeMapResult{ { Status: MapStatusSuccess, }, { Status: MapStatusSuccess, }, { Status: MapStatusNotEnoughInfo, }, { Status: MapStatusUnsupported, }, { Status: MapStatusPendingCreation, }, }, } if result.NumSuccess() != 2 { t.Errorf("Expected 2 success, got %v", result.NumSuccess()) } if result.NumNotEnoughInfo() != 1 { t.Errorf("Expected 1 not enough info, got %v", result.NumNotEnoughInfo()) } if result.NumUnsupported() != 1 { t.Errorf("Expected 1 unsupported, got %v", result.NumUnsupported()) } if result.NumPendingCreation() != 1 { t.Errorf("Expected 1 pending creation, got %v", result.NumPendingCreation()) } // Sum of individual counts should equal NumTotal sum := result.NumSuccess() + result.NumNotEnoughInfo() + result.NumUnsupported() + result.NumPendingCreation() if sum != result.NumTotal() { t.Errorf("Sum of status counts (%v) should equal NumTotal (%v)", sum, result.NumTotal()) } } func TestInterpolateScope(t *testing.T) { t.Run("with no interpolation", func(t *testing.T) { t.Parallel() result, err := interpolateScope("foo", map[string]any{}) if err != nil { t.Error(err) } if result != "foo" { t.Errorf("Expected result to be foo, got %s", result) } }) t.Run("with a single variable", func(t *testing.T) { t.Parallel() result, err := interpolateScope("${outputs.overmind_kubernetes_cluster_name}", map[string]any{ "outputs": map[string]any{ "overmind_kubernetes_cluster_name": "foo", }, }) if err != nil { t.Error(err) } if result != "foo" { t.Errorf("Expected result to be foo, got %s", result) } }) t.Run("with multiple variables", func(t *testing.T) { t.Parallel() result, err := interpolateScope("${outputs.overmind_kubernetes_cluster_name}.${values.metadata.namespace}", map[string]any{ "outputs": map[string]any{ "overmind_kubernetes_cluster_name": "foo", }, "values": map[string]any{ "metadata": map[string]any{ "namespace": "bar", }, }, }) if err != nil { t.Error(err) } if result != "foo.bar" { t.Errorf("Expected result to be foo.bar, got %s", result) } }) t.Run("with a variable that doesn't exist", func(t *testing.T) { t.Parallel() _, err := interpolateScope("${outputs.overmind_kubernetes_cluster_name}", map[string]any{}) if err == nil { t.Error("Expected error, got nil") } }) } // note that these tests need to allocate the input map for every test to avoid // false positives from maskSensitiveData mutating the data func TestMaskSensitiveData(t *testing.T) { t.Parallel() t.Run("empty", func(t *testing.T) { t.Parallel() got := maskSensitiveData(map[string]any{}, map[string]any{}) require.Equal(t, map[string]any{}, got) }) t.Run("easy", func(t *testing.T) { t.Parallel() require.Equal(t, map[string]any{ "foo": "bar", }, maskSensitiveData( map[string]any{ "foo": "bar", }, map[string]any{})) require.Equal(t, map[string]any{ "foo": "(sensitive value)", }, maskSensitiveData( map[string]any{ "foo": "bar", }, map[string]any{"foo": true})) }) t.Run("deep", func(t *testing.T) { t.Parallel() require.Equal(t, map[string]any{ "foo": map[string]any{"key": "bar"}, }, maskSensitiveData( map[string]any{ "foo": map[string]any{"key": "bar"}, }, map[string]any{})) require.Equal(t, map[string]any{ "foo": "(sensitive value)", }, maskSensitiveData( map[string]any{ "foo": map[string]any{"key": "bar"}, }, map[string]any{"foo": true})) require.Equal(t, map[string]any{ "foo": map[string]any{"key": "(sensitive value)"}, }, maskSensitiveData( map[string]any{ "foo": map[string]any{"key": "bar"}, }, map[string]any{"foo": map[string]any{"key": true}})) }) t.Run("arrays", func(t *testing.T) { t.Parallel() require.Equal(t, map[string]any{ "foo": []any{"one", "two"}, }, maskSensitiveData( map[string]any{ "foo": []any{"one", "two"}, }, map[string]any{})) require.Equal(t, map[string]any{ "foo": "(sensitive value)", }, maskSensitiveData( map[string]any{ "foo": []any{"one", "two"}, }, map[string]any{"foo": true})) require.Equal(t, map[string]any{ "foo": []any{"one", "(sensitive value)"}, }, maskSensitiveData( map[string]any{ "foo": []any{"one", "two"}, }, map[string]any{"foo": []any{false, true}})) }) } func TestHandleKnownAfterApply(t *testing.T) { before, err := sdp.ToAttributes(map[string]any{ "string_value": "foo", "int_value": 42, "bool_value": true, "float_value": 3.14, "data": "secret", // Known after apply but doesn't exist in the "after" map, this happens sometimes "list_value": []any{ "foo", "bar", }, "map_value": map[string]any{ "foo": "bar", "bar": "baz", }, "map_value2": map[string]any{ "ding": map[string]any{ "foo": "bar", }, }, "nested_list": []any{ []any{}, []any{ "foo", "bar", }, }, }) if err != nil { t.Fatal(err) } after, err := sdp.ToAttributes(map[string]any{ "string_value": "bar", // I want to see a diff here "int_value": nil, // These are going to be known after apply "bool_value": nil, // These are going to be known after apply "float_value": 3.14, "list_value": []any{ "foo", "bar", "baz", // So is this one }, "map_value": map[string]any{ // This whole thing will be known after apply "foo": "bar", }, "map_value2": map[string]any{ "ding": map[string]any{ "foo": nil, // This will be known after apply }, }, "nested_list": []any{ []any{ "foo", }, }, }) if err != nil { t.Fatal(err) } afterUnknown := json.RawMessage(`{ "int_value": true, "bool_value": true, "float_value": false, "data": true, "list_value": [ false, false, true ], "map_value": true, "map_value2": { "ding": { "foo": true } }, "nested_list": [ [ false, true ], [ false, true ] ] }`) err = handleKnownAfterApply(before, after, afterUnknown) if err != nil { t.Fatal(err) } beforeJSON, err := json.MarshalIndent(before, "", " ") if err != nil { t.Fatal(err) } afterJSON, err := json.MarshalIndent(after, "", " ") if err != nil { t.Fatal(err) } fmt.Println("BEFORE:") fmt.Println(string(beforeJSON)) fmt.Println("\n\nAFTER:") fmt.Println(string(afterJSON)) if val, _ := after.Get("int_value"); val != KnownAfterApply { t.Errorf("expected int_value to be %v, got %v", KnownAfterApply, val) } if val, _ := after.Get("bool_value"); val != KnownAfterApply { t.Errorf("expected bool_value to be %v, got %v", KnownAfterApply, val) } i, err := after.Get("list_value") if err != nil { t.Error(err) } if list, ok := i.([]any); ok { if list[2] != KnownAfterApply { t.Errorf("expected third string_value to be %v, got %v", KnownAfterApply, list[2]) } } else { t.Error("list_value is not a string slice") } if val, _ := after.Get("data"); val != KnownAfterApply { t.Errorf("expected data to be %v, got %v", KnownAfterApply, val) } } // Returns the name of the provider from the config key. If the resource isn't // in a module, the ProviderConfigKey will be something like "kubernetes", // however if it's in a module it's be something like // "module.something:kubernetes". In both scenarios we want to return // "kubernetes" func extractProviderNameFromConfigKey(providerConfigKey string) string { sections := strings.Split(providerConfigKey, ":") return sections[len(sections)-1] } // interpolateScope Will interpolate variables in the scope string. These // variables can come from the following places: // // * `outputs` - These are the outputs from the plan // * `values` - These are the values from the resource in question // // Interpolation is done using the Terraform interpolation syntax: // https://www.terraform.io/docs/configuration/interpolation.html func interpolateScope(scope string, data map[string]any) (string, error) { // Find all instances of ${} in the Scope matches := escapeRegex.FindAllStringSubmatch(scope, -1) interpolated := scope for _, match := range matches { // The first match is the entire string, the second match is the // variable name variableName := match[1] value := terraformDig(&data, variableName) if value == nil { return "", fmt.Errorf("variable '%v' not found", variableName) } // Convert the value to a string valueString, ok := value.(string) if !ok { return "", fmt.Errorf("variable '%v' is not a string", variableName) } interpolated = strings.Replace(interpolated, match[0], valueString, 1) } return interpolated, nil } // Digs through a map using the same logic that terraform does i.e. foo.bar[0] func terraformDig(srcMapPtr any, path string) any { // Split the path on each period parts := strings.Split(path, ".") if len(parts) == 0 { return nil } // Check for an index in this section indexMatches := indexBrackets.FindStringSubmatch(parts[0]) var value any if len(indexMatches) == 0 { // No index, just get the value value = dig.Interface(srcMapPtr, parts[0]) } else { // strip the brackets keyName := indexBrackets.ReplaceAllString(parts[0], "") // Get the index index, err := strconv.Atoi(indexMatches[1]) if err != nil { return nil } // Get the value arr, ok := dig.Interface(srcMapPtr, keyName).([]any) if !ok { return nil } // Check if the index is in range if index < 0 || index >= len(arr) { return nil } value = arr[index] } if len(parts) == 1 { return value } else { // Force it to another map[string]interface{} valueMap := make(map[string]any) if mapString, ok := value.(map[string]string); ok { for k, v := range mapString { valueMap[k] = v } } else if mapInterface, ok := value.(map[string]any); ok { valueMap = mapInterface } else if mapAttributeValues, ok := value.(AttributeValues); ok { valueMap = mapAttributeValues } else { return nil } return terraformDig(&valueMap, strings.Join(parts[1:], ".")) } } func TestIsJSONPlanFile(t *testing.T) { tests := []struct { name string input []byte expected bool }{ { name: "valid JSON object", input: []byte(`{"format_version": "1.0", "terraform_version": "1.0.0"}`), expected: true, }, { name: "valid JSON array", input: []byte(`[{"key": "value"}]`), expected: true, }, { name: "valid JSON string", input: []byte(`"hello world"`), expected: true, }, { name: "valid JSON number", input: []byte(`42`), expected: true, }, { name: "valid JSON boolean", input: []byte(`true`), expected: true, }, { name: "valid JSON null", input: []byte(`null`), expected: true, }, { name: "invalid JSON - binary data", input: []byte{0x50, 0x4B, 0x03, 0x04}, // ZIP file header expected: false, }, { name: "invalid JSON - incomplete", input: []byte(`{"incomplete":`), expected: false, }, { name: "empty input", input: []byte(``), expected: false, }, { name: "non-JSON text", input: []byte(`this is not json`), expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isJSONPlanFile(tt.input) require.Equal(t, tt.expected, result, "isJSONPlanFile(%q) = %v, want %v", string(tt.input), result, tt.expected) }) } } func TestMappedItemDiffsFromPlanFileWithJSON(t *testing.T) { // Test that existing JSON plan files still work result, err := MappedItemDiffsFromPlanFile(context.Background(), "testdata/plan.json", "test-scope", log.Fields{}) // This should work if the test data exists and is valid JSON if err != nil { // If the test data doesn't exist or is invalid, that's okay for this test // We're mainly testing that the JSON path is taken t.Logf("Expected error for test data: %v", err) } else { require.NotNil(t, result) } } func TestMappedItemDiffsFromPlanFileWithRealBinaryPlan(t *testing.T) { // Test with the real binary plan file we created binaryPlanPath := "testdata/binary-plan.tfplan" // Check if the test file exists if _, err := os.Stat(binaryPlanPath); os.IsNotExist(err) { t.Skip("Skipping test: real binary plan file not found. Run 'make test-binary-plan' to generate it.") } // Test that the binary version is detected correctly and returns a clear error t.Run("Binary_plan_detection", func(t *testing.T) { // Read the binary plan file binaryData, err := os.ReadFile(binaryPlanPath) require.NoError(t, err) // Verify it's detected as binary (not JSON) isJSON := isJSONPlanFile(binaryData) require.False(t, isJSON, "Binary plan should not be detected as JSON") t.Logf("Binary plan correctly detected as non-JSON (size: %d bytes)", len(binaryData)) }) // Test that the binary plan returns a clear error message t.Run("Binary_plan_error", func(t *testing.T) { _, err := MappedItemDiffsFromPlanFile(context.Background(), binaryPlanPath, "test-scope", log.Fields{}) // We expect this to fail with a clear error message require.Error(t, err) require.Contains(t, err.Error(), "appears to be in binary format, but Overmind only supports JSON plan files") require.Contains(t, err.Error(), "tofu show -json") require.Contains(t, err.Error(), "terraform show -json") require.Contains(t, err.Error(), "overmind changes submit-plan plan.json") t.Logf("Binary plan correctly rejected with clear error message") }) } ================================================ FILE: tfutils/repo_to_scope.go ================================================ package tfutils import ( "net/url" ) // This converts a repo value to a scope that can be used for Terraform changes // that aren't mapped to a specific resource. Even if we can't map these // changes, we want the GloballyUniqueName to sill be unique, so we need to // include the repo as it's common for customers to have many repos or // workspaces that could have a clashing names in Terraform. Think of a resource // like "aws_instance.app_server". This is a common name and absolutely could // clash with another resource in another repo or workspace. func RepoToScope(repo string) string { // If repo is empty, use a fallback scope to ensure items have a scope if repo == "" { return "terraform_plan" } parsed, err := url.Parse(repo) if err != nil { return repo } // Remove the scheme (http, https, etc.) if it exists return parsed.Host + parsed.Path } ================================================ FILE: tfutils/repo_to_scope_test.go ================================================ package tfutils import "testing" func TestRepoToScope(t *testing.T) { tests := []struct { name string repo string expected string }{ { name: "https URL", repo: "https://github.com/overmindtech/workspace", expected: "github.com/overmindtech/workspace", }, { name: "http URL", repo: "http://github.com/overmindtech/workspace", expected: "github.com/overmindtech/workspace", }, { name: "URL without protocol", repo: "github.com/overmindtech/workspace", expected: "github.com/overmindtech/workspace", }, { name: "GitLab https URL", repo: "https://gitlab.com/company/project", expected: "gitlab.com/company/project", }, { name: "GitLab http URL", repo: "http://gitlab.com/company/project", expected: "gitlab.com/company/project", }, { name: "Bitbucket URL", repo: "https://bitbucket.org/team/repo", expected: "bitbucket.org/team/repo", }, { name: "Self-hosted Git with https", repo: "https://git.company.com/team/project", expected: "git.company.com/team/project", }, { name: "Self-hosted Git with http", repo: "http://git.internal.local/repo", expected: "git.internal.local/repo", }, { name: "URL with port", repo: "https://git.company.com:8080/team/project", expected: "git.company.com:8080/team/project", }, { name: "URL with path and query params", repo: "https://github.com/overmindtech/workspace.git?ref=main", expected: "github.com/overmindtech/workspace.git", }, { name: "URL with trailing slash", repo: "https://github.com/overmindtech/workspace/", expected: "github.com/overmindtech/workspace/", }, { name: "Supports custom protocols", repo: "custom://github.com/overmindtech/workspace", expected: "github.com/overmindtech/workspace", }, { name: "Empty string", repo: "", expected: "terraform_plan", }, { name: "Case sensitivity test", repo: "HTTPS://GitHub.com/User/Repo", expected: "GitHub.com/User/Repo", }, { name: "SSH URL (should remain unchanged)", repo: "git@github.com:overmindtech/workspace.git", expected: "git@github.com:overmindtech/workspace.git", }, { name: "File path (should remain unchanged)", repo: "/local/path/to/repo", expected: "/local/path/to/repo", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := RepoToScope(tt.repo) if result != tt.expected { t.Errorf("RepoToScope(%q) = %q, expected %q", tt.repo, result, tt.expected) } }) } } ================================================ FILE: tfutils/testdata/config_from_provider/ca-bundle.crt ================================================ Certificate: Data: Version: 1 (0x0) Serial Number: 02:ad:66:7e:4e:45:fe:5e:57:6f:3c:98:19:5e:dd:c0 Signature Algorithm: md2WithRSAEncryption Issuer: C=US, O=RSA Data Security, Inc., OU=Secure Server Certification Authority Validity Not Before: Nov 9 00:00:00 1994 GMT Not After : Jan 7 23:59:59 2010 GMT Subject: C=US, O=RSA Data Security, Inc., OU=Secure Server Certification Authority Subject Public Key Info: Public Key Algorithm: rsaEncryption RSA Public Key: (1000 bit) Modulus (1000 bit): 00:92:ce:7a:c1:ae:83:3e:5a:aa:89:83:57:ac:25: 01:76:0c:ad:ae:8e:2c:37:ce:eb:35:78:64:54:03: e5:84:40:51:c9:bf:8f:08:e2:8a:82:08:d2:16:86: 37:55:e9:b1:21:02:ad:76:68:81:9a:05:a2:4b:c9: 4b:25:66:22:56:6c:88:07:8f:f7:81:59:6d:84:07: 65:70:13:71:76:3e:9b:77:4c:e3:50:89:56:98:48: b9:1d:a7:29:1a:13:2e:4a:11:59:9c:1e:15:d5:49: 54:2c:73:3a:69:82:b1:97:39:9c:6d:70:67:48:e5: dd:2d:d6:c8:1e:7b Exponent: 65537 (0x10001) Signature Algorithm: md2WithRSAEncryption 65:dd:7e:e1:b2:ec:b0:e2:3a:e0:ec:71:46:9a:19:11:b8:d3: c7:a0:b4:03:40:26:02:3e:09:9c:e1:12:b3:d1:5a:f6:37:a5: b7:61:03:b6:5b:16:69:3b:c6:44:08:0c:88:53:0c:6b:97:49: c7:3e:35:dc:6c:b9:bb:aa:df:5c:bb:3a:2f:93:60:b6:a9:4b: 4d:f2:20:f7:cd:5f:7f:64:7b:8e:dc:00:5c:d7:fa:77:ca:39: 16:59:6f:0e:ea:d3:b5:83:7f:4d:4d:42:56:76:b4:c9:5f:04: f8:38:f8:eb:d2:5f:75:5f:cd:7b:fc:e5:8e:80:7c:fc:50 MD5 Fingerprint=74:7B:82:03:43:F0:00:9E:6B:B3:EC:47:BF:85:A5:93 -----BEGIN CERTIFICATE----- MIICNDCCAaECEAKtZn5ORf5eV288mBle3cAwDQYJKoZIhvcNAQECBQAwXzELMAkG A1UEBhMCVVMxIDAeBgNVBAoTF1JTQSBEYXRhIFNlY3VyaXR5LCBJbmMuMS4wLAYD VQQLEyVTZWN1cmUgU2VydmVyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk0 MTEwOTAwMDAwMFoXDTEwMDEwNzIzNTk1OVowXzELMAkGA1UEBhMCVVMxIDAeBgNV BAoTF1JTQSBEYXRhIFNlY3VyaXR5LCBJbmMuMS4wLAYDVQQLEyVTZWN1cmUgU2Vy dmVyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGbMA0GCSqGSIb3DQEBAQUAA4GJ ADCBhQJ+AJLOesGugz5aqomDV6wlAXYMra6OLDfO6zV4ZFQD5YRAUcm/jwjiioII 0haGN1XpsSECrXZogZoFokvJSyVmIlZsiAeP94FZbYQHZXATcXY+m3dM41CJVphI uR2nKRoTLkoRWZweFdVJVCxzOmmCsZc5nG1wZ0jl3S3WyB57AgMBAAEwDQYJKoZI hvcNAQECBQADfgBl3X7hsuyw4jrg7HFGmhkRuNPHoLQDQCYCPgmc4RKz0Vr2N6W3 YQO2WxZpO8ZECAyIUwxrl0nHPjXcbLm7qt9cuzovk2C2qUtN8iD3zV9/ZHuO3ABc 1/p3yjkWWW8O6tO1g39NTUJWdrTJXwT4OPjr0l91X817/OWOgHz8UA== -----END CERTIFICATE----- Certificate: Data: Version: 1 (0x0) Serial Number: 419 (0x1a3) Signature Algorithm: md5WithRSAEncryption Issuer: C=US, O=GTE Corporation, CN=GTE CyberTrust Root Validity Not Before: Feb 23 23:01:00 1996 GMT Not After : Feb 23 23:59:00 2006 GMT Subject: C=US, O=GTE Corporation, CN=GTE CyberTrust Root Subject Public Key Info: Public Key Algorithm: rsaEncryption RSA Public Key: (1024 bit) Modulus (1024 bit): 00:b8:e6:4f:ba:db:98:7c:71:7c:af:44:b7:d3:0f: 46:d9:64:e5:93:c1:42:8e:c7:ba:49:8d:35:2d:7a: e7:8b:bd:e5:05:31:59:c6:b1:2f:0a:0c:fb:9f:a7: 3f:a2:09:66:84:56:1e:37:29:1b:87:e9:7e:0c:ca: 9a:9f:a5:7f:f5:15:94:a3:d5:a2:46:82:d8:68:4c: d1:37:15:06:68:af:bd:f8:b0:b3:f0:29:f5:95:5a: 09:16:61:77:0a:22:25:d4:4f:45:aa:c7:bd:e5:96: df:f9:d4:a8:8e:42:cc:24:c0:1e:91:27:4a:b5:6d: 06:80:63:39:c4:a2:5e:38:03 Exponent: 65537 (0x10001) Signature Algorithm: md5WithRSAEncryption 12:b3:75:c6:5f:1d:e1:61:55:80:00:d4:81:4b:7b:31:0f:23: 63:e7:3d:f3:03:f9:f4:36:a8:bb:d9:e3:a5:97:4d:ea:2b:29: e0:d6:6a:73:81:e6:c0:89:a3:d3:f1:e0:a5:a5:22:37:9a:63: c2:48:20:b4:db:72:e3:c8:f6:d9:7c:be:b1:af:53:da:14:b4: 21:b8:d6:d5:96:e3:fe:4e:0c:59:62:b6:9a:4a:f9:42:dd:8c: 6f:81:a9:71:ff:f4:0a:72:6d:6d:44:0e:9d:f3:74:74:a8:d5: 34:49:e9:5e:9e:e9:b4:7a:e1:e5:5a:1f:84:30:9c:d3:9f:a5: 25:d8 MD5 Fingerprint=C4:D7:F0:B2:A3:C5:7D:61:67:F0:04:CD:43:D3:BA:58 -----BEGIN CERTIFICATE----- MIIB+jCCAWMCAgGjMA0GCSqGSIb3DQEBBAUAMEUxCzAJBgNVBAYTAlVTMRgwFgYD VQQKEw9HVEUgQ29ycG9yYXRpb24xHDAaBgNVBAMTE0dURSBDeWJlclRydXN0IFJv b3QwHhcNOTYwMjIzMjMwMTAwWhcNMDYwMjIzMjM1OTAwWjBFMQswCQYDVQQGEwJV UzEYMBYGA1UEChMPR1RFIENvcnBvcmF0aW9uMRwwGgYDVQQDExNHVEUgQ3liZXJU cnVzdCBSb290MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC45k+625h8cXyv RLfTD0bZZOWTwUKOx7pJjTUteueLveUFMVnGsS8KDPufpz+iCWaEVh43KRuH6X4M ypqfpX/1FZSj1aJGgthoTNE3FQZor734sLPwKfWVWgkWYXcKIiXUT0Wqx73llt/5 1KiOQswkwB6RJ0q1bQaAYznEol44AwIDAQABMA0GCSqGSIb3DQEBBAUAA4GBABKz dcZfHeFhVYAA1IFLezEPI2PnPfMD+fQ2qLvZ46WXTeorKeDWanOB5sCJo9Px4KWl IjeaY8JIILTbcuPI9tl8vrGvU9oUtCG41tWW4/5ODFlitppK+ULdjG+BqXH/9Apy bW1EDp3zdHSo1TRJ6V6e6bR64eVaH4QwnNOfpSXY -----END CERTIFICATE----- ================================================ FILE: tfutils/testdata/config_from_provider/test.tf ================================================ # This exists to test the ConfigFromProvider method, we have to omit a few # things since when we are constructing the AWS config it actually does real # validation like making sure a profile exists in the shared config files, etc. # So we have to omit those fields in the test file. provider "aws" { alias = "everything" access_key = "access_key" secret_key = "secret_key" token = "token" region = "region" custom_ca_bundle = "testdata/config_from_provider/ca-bundle.crt" ec2_metadata_service_endpoint = "ec2_metadata_service_endpoint" ec2_metadata_service_endpoint_mode = "ipv6" skip_metadata_api_check = true http_proxy = "http_proxy" https_proxy = "https_proxy" no_proxy = "no_proxy" max_retries = 10 # profile = "profile" retry_mode = "standard" shared_config_files = ["shared_config_files"] shared_credentials_files = ["shared_credentials_files"] s3_us_east_1_regional_endpoint = "s3_us_east_1_regional_endpoint" use_dualstack_endpoint = false use_fips_endpoint = false assume_role { role_arn = "arn:aws:iam::123456789012:role/ROLE_NAME" session_name = "SESSION_NAME" external_id = "EXTERNAL_ID" duration = "1s" policy = "policy" policy_arns = ["policy_arns"] tags = { key = "value" } } assume_role_with_web_identity { role_arn = "arn:aws:iam::123456789012:role/ROLE_NAME" session_name = "SESSION_NAME" web_identity_token_file = "/Users/tf_user/secrets/web-identity-token" web_identity_token = "web_identity_token" duration = "1s" policy = "policy" policy_arns = ["policy_arns"] } } ================================================ FILE: tfutils/testdata/invalid_vars.tfvars ================================================ this is not valid hcl And therefore shouldn't parse ================================================ FILE: tfutils/testdata/plan.json ================================================ { "planned_values": { "root_module": { "resources": [ { "address": "kubernetes_deployment.api_server", "mode": "managed", "type": "kubernetes_deployment", "name": "api_server", "provider_name": "registry.terraform.io/hashicorp/kubernetes", "schema_version": 1, "values": { "id": "default/api-server", "metadata": [ { "annotations": {}, "generate_name": "", "generation": 18, "labels": {}, "name": "api-server", "namespace": "default", "resource_version": "16505436", "uid": "cd11a255-2964-434a-b366-063ea673bbd2" } ], "spec": [ { "min_ready_seconds": 0, "paused": false, "progress_deadline_seconds": 600, "replicas": "1", "revision_history_limit": 10, "selector": [ { "match_expressions": [], "match_labels": { "app": "api-server" } } ], "strategy": [ { "rolling_update": [ { "max_surge": "25%", "max_unavailable": "25%" } ], "type": "RollingUpdate" } ], "template": [ { "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": { "app": "api-server" }, "name": "", "namespace": "", "resource_version": "", "uid": "" } ], "spec": [ { "active_deadline_seconds": 0, "affinity": [], "automount_service_account_token": true, "container": [ { "args": [], "command": [], "env": [], "env_from": [], "image": "ghcr.io/overmindtech/workspace/api-server@sha256:d10d15d0bce640a7e4e505b57652d7a7e46f8092a3dbd64408de4368cda40270", "image_pull_policy": "IfNotPresent", "lifecycle": [], "liveness_probe": [], "name": "api-server", "port": [ { "container_port": 8080, "host_ip": "", "host_port": 0, "name": "", "protocol": "TCP" } ], "readiness_probe": [], "resources": [ { "limits": { "memory": "2Gi" }, "requests": { "cpu": "250m", "memory": "200Mi" } } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "dns_config": [], "dns_policy": "ClusterFirst", "enable_service_links": true, "host_aliases": [], "host_ipc": false, "host_network": false, "host_pid": false, "hostname": "", "image_pull_secrets": [ { "name": "srcman-registry-credentials" } ], "init_container": [ { "args": [], "command": [ "/bin/mkdir", "-p", "/nats-nkeys/nsc" ], "env": [], "env_from": [], "image": "alpine:latest", "image_pull_policy": "Always", "lifecycle": [], "liveness_probe": [], "name": "create-folder", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" }, { "args": [ "init", "--nsc-location", "/nats-nkeys/nsc", "--nsc-operator", "dogfood", "--revlink-account", "revlink" ], "command": [], "env": [], "env_from": [], "image": "ghcr.io/overmindtech/workspace/api-server@sha256:d10d15d0bce640a7e4e505b57652d7a7e46f8092a3dbd64408de4368cda40270", "image_pull_policy": "IfNotPresent", "lifecycle": [], "liveness_probe": [], "name": "generate-nkeys", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "node_name": "", "node_selector": {}, "priority_class_name": "", "readiness_gate": [], "restart_policy": "Always", "runtime_class_name": "", "scheduler_name": "default-scheduler", "security_context": [], "service_account_name": "api-server-service-account", "share_process_namespace": false, "subdomain": "", "termination_grace_period_seconds": 30, "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "name": "nats-nkeys", "nfs": [], "persistent_volume_claim": [ { "claim_name": "nats-nkeys", "read_only": false } ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": { "create": "2m", "delete": "2m", "update": "2m" }, "wait_for_rollout": true }, "sensitive_values": { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "selector": [ { "match_expressions": [], "match_labels": {} } ], "strategy": [ { "rolling_update": [ {} ] } ], "template": [ { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "affinity": [], "container": [ { "args": [], "command": [], "env": [ { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value": true, "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value": true, "value_from": [] }, { "value": true, "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [ { "config_map_key_ref": [], "field_ref": [], "resource_field_ref": [], "secret_key_ref": [ {} ] } ] }, { "value_from": [ { "config_map_key_ref": [], "field_ref": [], "resource_field_ref": [], "secret_key_ref": [ {} ] } ] }, { "value_from": [] } ], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [ {} ], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] } ], "dns_config": [], "host_aliases": [], "image_pull_secrets": [ {} ], "init_container": [ { "args": [], "command": [ false, false, false ], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] }, { "args": [ false, false, false, false, false, false, false ], "command": [], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] } ], "node_selector": {}, "readiness_gate": [], "security_context": [], "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "nfs": [], "persistent_volume_claim": [ {} ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": {} } }, { "address": "module.eks_elb_controller.aws_iam_policy.lb_controller[0]", "mode": "managed", "type": "aws_iam_policy", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:policy/test-alb-ingress", "description": "Policy for alb-ingress service", "id": "arn:aws:iam::123456789012:policy/test-alb-ingress", "name": "test-alb-ingress", "name_prefix": "", "path": "/", "policy_id": "ANPA5X4M7MOYCYTEF5VUE", "tags": {}, "tags_all": {} }, "sensitive_values": { "tags": {}, "tags_all": {} } }, { "address": "aws_iam_policy.auth0_ses_send_emails", "mode": "managed", "type": "aws_iam_policy", "name": "auth0_ses_send_emails", "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:policy/auth0-ses-send-emails", "description": "Allows Auth0 to send emails via SES", "id": "arn:aws:iam::123456789012:policy/auth0-ses-send-emails", "name": "auth0-ses-send-emails", "name_prefix": "", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"ses:SendRawEmail\",\"ses:SendEmail\"],\"Effect\":\"Allow\",\"Resource\":\"*\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPA5X4M7MOYO7KE6G4J4", "tags": {}, "tags_all": {} }, "sensitive_values": { "tags": {}, "tags_all": {} } }, { "address": "module.eks.aws_iam_policy.cluster_encryption[0]", "mode": "managed", "type": "aws_iam_policy", "name": "cluster_encryption", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:policy/test-cluster-ClusterEncryption2023061613390591120000000e", "description": "Cluster encryption policy to allow cluster role to utilize CMK provided", "id": "arn:aws:iam::123456789012:policy/test-cluster-ClusterEncryption2023061613390591120000000e", "name": "test-cluster-ClusterEncryption2023061613390591120000000e", "name_prefix": "test-cluster-ClusterEncryption", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"kms:Encrypt\",\"kms:Decrypt\",\"kms:ListGrants\",\"kms:DescribeKey\"],\"Effect\":\"Allow\",\"Resource\":\"arn:aws:kms:eu-west-2:12345678901:key/1234567\"}],\"Version\":\"2012-10-17\"}", "policy_id": "foobar", "tags": {}, "tags_all": {} }, "sensitive_values": { "tags": {}, "tags_all": {} } }, { "address": "module.eks.aws_iam_policy.cni_ipv6_policy[0]", "mode": "managed", "type": "aws_iam_policy", "name": "cni_ipv6_policy", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:policy/AmazonEKS_CNI_IPv6_Policy", "description": "IAM policy for EKS CNI to assign IPV6 addresses", "id": "arn:aws:iam::123456789012:policy/AmazonEKS_CNI_IPv6_Policy", "name": "AmazonEKS_CNI_IPv6_Policy", "name_prefix": "", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"ec2:DescribeTags\",\"ec2:DescribeNetworkInterfaces\",\"ec2:DescribeInstances\",\"ec2:DescribeInstanceTypes\",\"ec2:AssignIpv6Addresses\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"AssignDescribe\"},{\"Action\":\"ec2:CreateTags\",\"Effect\":\"Allow\",\"Resource\":\"arn:aws:ec2:*:*:network-interface/*\",\"Sid\":\"CreateTags\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPA5X4M7MOYIF2MVJEGJ", "tags": {}, "tags_all": {} }, "sensitive_values": { "tags": {}, "tags_all": {} } }, { "address": "module.eks_elb_controller.data.aws_iam_policy_document.lb_controller[0]", "mode": "data", "type": "aws_iam_policy_document", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "override_policy_documents": null, "policy_id": null, "source_policy_documents": null, "statement": [ { "actions": [ "iam:CreateServiceLinkedRole" ], "condition": [ { "test": "StringEquals", "values": [ "elasticloadbalancing.amazonaws.com" ], "variable": "iam:AWSServiceName" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:DescribeAccountAttributes", "ec2:DescribeAddresses", "ec2:DescribeAvailabilityZones", "ec2:DescribeCoipPools", "ec2:DescribeInstances", "ec2:DescribeInternetGateways", "ec2:DescribeNetworkInterfaces", "ec2:DescribeSecurityGroups", "ec2:DescribeSubnets", "ec2:DescribeTags", "ec2:DescribeVpcPeeringConnections", "ec2:DescribeVpcs", "ec2:GetCoipPoolUsage", "elasticloadbalancing:DescribeListenerCertificates", "elasticloadbalancing:DescribeListeners", "elasticloadbalancing:DescribeLoadBalancerAttributes", "elasticloadbalancing:DescribeLoadBalancers", "elasticloadbalancing:DescribeRules", "elasticloadbalancing:DescribeSSLPolicies", "elasticloadbalancing:DescribeTags", "elasticloadbalancing:DescribeTargetGroupAttributes", "elasticloadbalancing:DescribeTargetGroups", "elasticloadbalancing:DescribeTargetHealth" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "acm:DescribeCertificate", "acm:ListCertificates", "cognito-idp:DescribeUserPoolClient", "iam:GetServerCertificate", "iam:ListServerCertificates", "shield:CreateProtection", "shield:DeleteProtection", "shield:DescribeProtection", "shield:GetSubscriptionState", "waf-regional:AssociateWebACL", "waf-regional:DisassociateWebACL", "waf-regional:GetWebACL", "waf-regional:GetWebACLForResource", "wafv2:AssociateWebACL", "wafv2:DisassociateWebACL", "wafv2:GetWebACL", "wafv2:GetWebACLForResource" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:RevokeSecurityGroupIngress" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:CreateSecurityGroup" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:CreateTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" }, { "test": "StringEquals", "values": [ "CreateSecurityGroup" ], "variable": "ec2:CreateAction" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:ec2:*:*:security-group/*" ], "sid": null }, { "actions": [ "ec2:CreateTags", "ec2:DeleteTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" }, { "test": "Null", "values": [ "true" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:ec2:*:*:security-group/*" ], "sid": null }, { "actions": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:DeleteSecurityGroup", "ec2:RevokeSecurityGroupIngress" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:CreateLoadBalancer", "elasticloadbalancing:CreateTargetGroup" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:CreateListener", "elasticloadbalancing:CreateRule", "elasticloadbalancing:DeleteListener", "elasticloadbalancing:DeleteRule" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" }, { "test": "Null", "values": [ "true" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:DeleteLoadBalancer", "elasticloadbalancing:DeleteTargetGroup", "elasticloadbalancing:ModifyLoadBalancerAttributes", "elasticloadbalancing:ModifyTargetGroup", "elasticloadbalancing:ModifyTargetGroupAttributes", "elasticloadbalancing:SetIpAddressType", "elasticloadbalancing:SetSecurityGroups", "elasticloadbalancing:SetSubnets" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:DeregisterTargets", "elasticloadbalancing:RegisterTargets" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddListenerCertificates", "elasticloadbalancing:ModifyListener", "elasticloadbalancing:ModifyRule", "elasticloadbalancing:RemoveListenerCertificates", "elasticloadbalancing:SetWebAcl" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null } ], "version": null }, "sensitive_values": { "statement": [ { "actions": [ false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false, false, false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false, false, false, false ] }, { "actions": [ false, false, false, false, false, false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] } ] } } ], "child_modules": [ { "resources": [ { "address": "module.core.kubernetes_secret.apiserver-secrets", "mode": "managed", "type": "kubernetes_secret", "name": "apiserver-secrets", "provider_name": "registry.terraform.io/hashicorp/kubernetes", "schema_version": 0, "values": { "binary_data": null, "id": "default/apiserver-secrets", "immutable": false, "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": {}, "name": "apiserver-secrets", "namespace": "default", "resource_version": "67487020", "uid": "7a9fce0b-b6a2-4464-8f3a-33a93c2fdeb9" } ], "timeouts": null, "type": "Opaque", "wait_for_service_account_token": true }, "sensitive_values": { "data": {}, "metadata": [ { "annotations": {}, "labels": {} } ] } }, { "address": "module.efs_csi_irsa_role.aws_iam_policy.efs_csi[0]", "mode": "managed", "type": "aws_iam_policy", "name": "efs_csi", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "description": "Provides permissions to manage EFS volumes via the container storage interface driver", "id": "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "name": "AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "name_prefix": "AmazonEKS_EFS_CSI_Policy-", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"elasticfilesystem:DescribeMountTargets\",\"elasticfilesystem:DescribeFileSystems\",\"elasticfilesystem:DescribeAccessPoints\",\"ec2:DescribeAvailabilityZones\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:CreateAccessPoint\",\"Condition\":{\"StringLike\":{\"aws:RequestTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:TagResource\",\"Condition\":{\"StringLike\":{\"aws:RequestTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:DeleteAccessPoint\",\"Condition\":{\"StringEquals\":{\"aws:ResourceTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"}],\"Version\":\"2012-10-17\"}", "policy_id": "foobar", "tags": {}, "tags_all": {} }, "sensitive_values": { "tags": {}, "tags_all": {} } }, { "address": "module.efs_csi_irsa_role.aws_iam_role.this[0]", "mode": "managed", "type": "aws_iam_role", "name": "this", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:role/efs-csi", "assume_role_policy": "{\"Statement\":[{\"Action\":\"sts:AssumeRoleWithWebIdentity\",\"Condition\":{\"StringEquals\":{\"foobar:aud\":\"sts.amazonaws.com\",\"foobar:sub\":[\"system:serviceaccount:kube-system:efs-csi-controller-sa\",\"system:serviceaccount:kube-system:efs-csi-node-sa\"]}},\"Effect\":\"Allow\",\"Principal\":{\"Federated\":\"arn:aws:iam::123456789012:oidc-provider/foobar\"}}],\"Version\":\"2012-10-17\"}", "create_date": "2023-03-17T13:43:01Z", "description": "", "force_detach_policies": true, "id": "efs-csi", "inline_policy": [], "managed_policy_arns": [ "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001" ], "max_session_duration": 3600, "name": "efs-csi", "name_prefix": "", "path": "/", "permissions_boundary": null, "tags": {}, "tags_all": {}, "unique_id": "AROA5X4M7MOYP6QYXIIVP" }, "sensitive_values": { "inline_policy": [], "managed_policy_arns": [ false ], "tags": {}, "tags_all": {} } }, { "address": "module.efs_csi_irsa_role.aws_iam_role_policy_attachment.efs_csi[0]", "mode": "managed", "type": "aws_iam_role_policy_attachment", "name": "efs_csi", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "id": "efs-csi-20230317134302181300000003", "policy_arn": "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "role": "efs-csi" }, "sensitive_values": {} } ], "address": "module.efs_csi_irsa_role" }, { "resources": [ { "address": "module.eks_elb_controller.aws_iam_policy.lb_controller[0]", "mode": "managed", "type": "aws_iam_policy", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:policy/test-alb-ingress", "description": "Policy for alb-ingress service", "id": "arn:aws:iam::123456789012:policy/test-alb-ingress", "name": "test-alb-ingress", "name_prefix": "", "path": "/", "policy_id": "ANPA5X4M7MOYCYTEF5VUE", "tags": {}, "tags_all": {} }, "sensitive_values": { "tags": {}, "tags_all": {} } }, { "address": "module.eks_elb_controller.aws_iam_role.lb_controller[0]", "mode": "managed", "type": "aws_iam_role", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "arn": "arn:aws:iam::123456789012:role/test-alb-ingress", "create_date": "2023-06-16T14:41:17Z", "description": "", "force_detach_policies": false, "id": "test-alb-ingress", "inline_policy": [], "managed_policy_arns": [ "arn:aws:iam::123456789012:policy/test-alb-ingress" ], "max_session_duration": 3600, "name": "test-alb-ingress", "name_prefix": "", "path": "/", "permissions_boundary": null, "tags": {}, "tags_all": {}, "unique_id": "AROA5X4M7MOYDU5GZ7DFT" }, "sensitive_values": { "inline_policy": [], "managed_policy_arns": [ false ], "tags": {}, "tags_all": {} } }, { "address": "module.eks_elb_controller.aws_iam_role_policy_attachment.lb_controller[0]", "mode": "managed", "type": "aws_iam_role_policy_attachment", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "id": "test-alb-ingress-20230616144117244100000001", "policy_arn": "arn:aws:iam::123456789012:policy/test-alb-ingress", "role": "test-alb-ingress" }, "sensitive_values": {} }, { "address": "module.eks_elb_controller.data.aws_iam_policy_document.lb_controller[0]", "mode": "data", "type": "aws_iam_policy_document", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "override_policy_documents": null, "policy_id": null, "source_policy_documents": null, "statement": [ { "actions": [ "iam:CreateServiceLinkedRole" ], "condition": [ { "test": "StringEquals", "values": [ "elasticloadbalancing.amazonaws.com" ], "variable": "iam:AWSServiceName" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:DescribeAccountAttributes", "ec2:DescribeAddresses", "ec2:DescribeAvailabilityZones", "ec2:DescribeCoipPools", "ec2:DescribeInstances", "ec2:DescribeInternetGateways", "ec2:DescribeNetworkInterfaces", "ec2:DescribeSecurityGroups", "ec2:DescribeSubnets", "ec2:DescribeTags", "ec2:DescribeVpcPeeringConnections", "ec2:DescribeVpcs", "ec2:GetCoipPoolUsage", "elasticloadbalancing:DescribeListenerCertificates", "elasticloadbalancing:DescribeListeners", "elasticloadbalancing:DescribeLoadBalancerAttributes", "elasticloadbalancing:DescribeLoadBalancers", "elasticloadbalancing:DescribeRules", "elasticloadbalancing:DescribeSSLPolicies", "elasticloadbalancing:DescribeTags", "elasticloadbalancing:DescribeTargetGroupAttributes", "elasticloadbalancing:DescribeTargetGroups", "elasticloadbalancing:DescribeTargetHealth" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "acm:DescribeCertificate", "acm:ListCertificates", "cognito-idp:DescribeUserPoolClient", "iam:GetServerCertificate", "iam:ListServerCertificates", "shield:CreateProtection", "shield:DeleteProtection", "shield:DescribeProtection", "shield:GetSubscriptionState", "waf-regional:AssociateWebACL", "waf-regional:DisassociateWebACL", "waf-regional:GetWebACL", "waf-regional:GetWebACLForResource", "wafv2:AssociateWebACL", "wafv2:DisassociateWebACL", "wafv2:GetWebACL", "wafv2:GetWebACLForResource" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:RevokeSecurityGroupIngress" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:CreateSecurityGroup" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:CreateTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" }, { "test": "StringEquals", "values": [ "CreateSecurityGroup" ], "variable": "ec2:CreateAction" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:ec2:*:*:security-group/*" ], "sid": null }, { "actions": [ "ec2:CreateTags", "ec2:DeleteTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" }, { "test": "Null", "values": [ "true" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:ec2:*:*:security-group/*" ], "sid": null }, { "actions": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:DeleteSecurityGroup", "ec2:RevokeSecurityGroupIngress" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:CreateLoadBalancer", "elasticloadbalancing:CreateTargetGroup" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:CreateListener", "elasticloadbalancing:CreateRule", "elasticloadbalancing:DeleteListener", "elasticloadbalancing:DeleteRule" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" }, { "test": "Null", "values": [ "true" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:DeleteLoadBalancer", "elasticloadbalancing:DeleteTargetGroup", "elasticloadbalancing:ModifyLoadBalancerAttributes", "elasticloadbalancing:ModifyTargetGroup", "elasticloadbalancing:ModifyTargetGroupAttributes", "elasticloadbalancing:SetIpAddressType", "elasticloadbalancing:SetSecurityGroups", "elasticloadbalancing:SetSubnets" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:DeregisterTargets", "elasticloadbalancing:RegisterTargets" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddListenerCertificates", "elasticloadbalancing:ModifyListener", "elasticloadbalancing:ModifyRule", "elasticloadbalancing:RemoveListenerCertificates", "elasticloadbalancing:SetWebAcl" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null } ], "version": null }, "sensitive_values": { "statement": [ { "actions": [ false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false, false, false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false, false, false, false ] }, { "actions": [ false, false, false, false, false, false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] } ] } }, { "address": "module.eks_elb_controller.data.aws_iam_policy_document.lb_controller_assume[0]", "mode": "data", "type": "aws_iam_policy_document", "name": "lb_controller_assume", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 0, "values": { "override_policy_documents": null, "policy_id": null, "source_policy_documents": null, "statement": [ { "actions": [ "sts:AssumeRoleWithWebIdentity" ], "condition": [ { "test": "StringEquals", "values": [ "system:serviceaccount:kube-system:aws-alb-ingress-controller" ], "variable": "foobar:sub" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [ { "identifiers": [ "arn:aws:iam::123456789012:oidc-provider/foobar" ], "type": "Federated" } ], "resources": null, "sid": null } ], "version": null }, "sensitive_values": { "statement": [ { "actions": [ false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [ { "identifiers": [ false ] } ] } ] } }, { "address": "module.eks_elb_controller.helm_release.lb_controller[0]", "mode": "managed", "type": "helm_release", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/helm", "schema_version": 1, "values": { "atomic": false, "chart": "aws-load-balancer-controller", "cleanup_on_fail": false, "create_namespace": false, "dependency_update": false, "description": null, "devel": null, "disable_crd_hooks": false, "disable_openapi_validation": false, "disable_webhooks": false, "force_update": false, "id": "aws-load-balancer-controller", "keyring": null, "lint": false, "manifest": null, "max_history": 0, "metadata": [ { "app_version": "v2.4.3", "chart": "aws-load-balancer-controller", "name": "aws-load-balancer-controller", "namespace": "kube-system", "revision": 1, "values": "{\"clusterName\":\"test\",\"rbac\":{\"create\":true},\"serviceAccount\":{\"annotations\":{\"eks.amazonaws.com/role-arn\":\"arn:aws:iam::123456789012:role/test-alb-ingress\"},\"create\":true,\"name\":\"aws-alb-ingress-controller\"}}", "version": "1.4.4" } ], "name": "aws-load-balancer-controller", "namespace": "kube-system", "pass_credentials": false, "postrender": [], "recreate_pods": false, "render_subchart_notes": true, "replace": false, "repository": "https://aws.github.io/eks-charts", "repository_ca_file": null, "repository_cert_file": null, "repository_key_file": null, "repository_password": null, "repository_username": null, "reset_values": false, "reuse_values": false, "set": [ { "name": "clusterName", "type": "", "value": "test" }, { "name": "rbac.create", "type": "", "value": "true" }, { "name": "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn", "type": "", "value": "arn:aws:iam::123456789012:role/test-alb-ingress" }, { "name": "serviceAccount.create", "type": "", "value": "true" }, { "name": "serviceAccount.name", "type": "", "value": "aws-alb-ingress-controller" } ], "set_list": [], "set_sensitive": [], "skip_crds": false, "status": "deployed", "timeout": 300, "values": [ "{}\n" ], "verify": false, "version": "1.4.4", "wait": true, "wait_for_jobs": false }, "sensitive_values": { "metadata": [ {} ], "postrender": [], "set": [ {}, {}, {}, {}, {} ], "set_list": [], "set_sensitive": [], "values": [ false ] } } ], "address": "module.eks_elb_controller" } ] }, "outputs": { "overmind_mappings": { "sensitive": false, "type": [ "object", { "kubernetes": [ "object", { "cluster_name": "string" } ] } ], "value": { "kubernetes": { "cluster_name": "dogfood" } } }, "test_secret": { "sensitive": true, "type": "string", "value": "test_secret" } } }, "resource_changes": [ { "address": "module.infra.aws_route53_record.frontend_on_vercel[0]", "module_address": "module.infra", "mode": "managed", "type": "aws_route53_record", "name": "frontend_on_vercel", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": [ "delete" ], "before": { "alias": [], "allow_overwrite": null, "cidr_routing_policy": [], "failover_routing_policy": [], "fqdn": "app.overmind.tech", "geolocation_routing_policy": [], "geoproximity_routing_policy": [], "health_check_id": "", "id": "BLAH_app.overmind.tech_A", "latency_routing_policy": [], "multivalue_answer_routing_policy": false, "name": "app.overmind.tech", "records": [ "76.76.21.21" ], "set_identifier": "", "ttl": 300, "type": "A", "weighted_routing_policy": [], "zone_id": "BLAH" }, "after": null, "after_unknown": {}, "before_sensitive": { "alias": [], "cidr_routing_policy": [], "failover_routing_policy": [], "geolocation_routing_policy": [], "geoproximity_routing_policy": [], "latency_routing_policy": [], "records": [ false ], "weighted_routing_policy": [] }, "after_sensitive": false }, "action_reason": "delete_because_count_index" }, { "address": "module.core.kubernetes_secret.apiserver-secrets", "module_address": "module.core", "mode": "managed", "type": "kubernetes_secret", "name": "apiserver-secrets", "provider_name": "registry.terraform.io/hashicorp/kubernetes", "change": { "actions": [ "update" ], "before": { "binary_data": null, "data": {}, "id": "default/apiserver-secrets", "immutable": false, "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": {}, "name": "apiserver-secrets", "namespace": "default", "resource_version": "67487020", "uid": "FOO" } ], "timeouts": null, "type": "Opaque", "wait_for_service_account_token": true }, "after": { "binary_data": null, "id": "default/apiserver-secrets", "immutable": false, "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": {}, "name": "apiserver-secrets", "namespace": "default", "resource_version": "67487020", "uid": "FOO" } ], "timeouts": null, "type": "Opaque", "wait_for_service_account_token": true }, "after_unknown": { "data": true, "metadata": [ { "annotations": {}, "labels": {} } ] }, "before_sensitive": { "binary_data": true, "data": true, "metadata": [ { "annotations": {}, "labels": {} } ] }, "after_sensitive": { "binary_data": true, "data": true, "metadata": [ { "annotations": {}, "labels": {} } ] } } }, { "address": "kubernetes_deployment.nats_box", "mode": "managed", "type": "kubernetes_deployment", "name": "nats_box", "provider_name": "registry.terraform.io/hashicorp/kubernetes", "change": { "actions": [ "delete" ], "before": { "id": "default/nats-box", "metadata": [ { "annotations": {}, "generate_name": "", "generation": 9, "labels": { "app": "nats-box" }, "name": "nats-box", "namespace": "default", "resource_version": "20425079", "uid": "25e4fce6-06a8-435b-90f3-ad0c1d8b52f1" } ], "spec": [ { "min_ready_seconds": 0, "paused": false, "progress_deadline_seconds": 600, "replicas": "0", "revision_history_limit": 10, "selector": [ { "match_expressions": [], "match_labels": { "app": "nats-box" } } ], "strategy": [ { "rolling_update": [ { "max_surge": "25%", "max_unavailable": "25%" } ], "type": "RollingUpdate" } ], "template": [ { "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": { "app": "nats-box" }, "name": "", "namespace": "", "resource_version": "", "uid": "" } ], "spec": [ { "active_deadline_seconds": 0, "affinity": [], "automount_service_account_token": true, "container": [ { "args": [], "command": [ "tail", "-f", "/dev/null" ], "env": [], "env_from": [], "image": "natsio/nats-box:latest", "image_pull_policy": "Always", "lifecycle": [], "liveness_probe": [], "name": "nats", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/etc/nats", "mount_propagation": "None", "name": "nats-config", "read_only": false, "sub_path": "" }, { "mount_path": "/etc/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "dns_config": [], "dns_policy": "ClusterFirst", "enable_service_links": true, "host_aliases": [], "host_ipc": false, "host_network": false, "host_pid": false, "hostname": "", "image_pull_secrets": [], "init_container": [], "node_name": "", "node_selector": {}, "priority_class_name": "", "readiness_gate": [], "restart_policy": "Always", "runtime_class_name": "", "scheduler_name": "default-scheduler", "security_context": [], "service_account_name": "", "share_process_namespace": false, "subdomain": "", "termination_grace_period_seconds": 30, "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "name": "nats-nkeys", "nfs": [], "persistent_volume_claim": [ { "claim_name": "nats-nkeys", "read_only": false } ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] }, { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "name": "nats-config", "nfs": [], "persistent_volume_claim": [ { "claim_name": "nats-config", "read_only": false } ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": { "create": "2m", "delete": "2m", "update": "2m" }, "wait_for_rollout": true }, "after": null, "after_unknown": {}, "before_sensitive": { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "selector": [ { "match_expressions": [], "match_labels": {} } ], "strategy": [ { "rolling_update": [ {} ] } ], "template": [ { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "affinity": [], "container": [ { "args": [], "command": [ false, false, false ], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {}, {} ] } ], "dns_config": [], "host_aliases": [], "image_pull_secrets": [], "init_container": [], "node_selector": {}, "readiness_gate": [], "security_context": [], "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "nfs": [], "persistent_volume_claim": [ {} ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] }, { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "nfs": [], "persistent_volume_claim": [ {} ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": {} }, "after_sensitive": false }, "action_reason": "delete_because_no_resource_config" }, { "address": "kubernetes_deployment.api_server", "mode": "managed", "type": "kubernetes_deployment", "name": "api_server", "provider_name": "registry.terraform.io/hashicorp/kubernetes", "change": { "actions": [ "update" ], "before": { "id": "default/api-server", "metadata": [ { "annotations": {}, "generate_name": "", "generation": 18, "labels": {}, "name": "api-server", "namespace": "default", "resource_version": "16505436", "uid": "cd11a255-2964-434a-b366-063ea673bbd2" } ], "spec": [ { "min_ready_seconds": 0, "paused": false, "progress_deadline_seconds": 600, "replicas": "1", "revision_history_limit": 10, "selector": [ { "match_expressions": [], "match_labels": { "app": "api-server" } } ], "strategy": [ { "rolling_update": [ { "max_surge": "25%", "max_unavailable": "25%" } ], "type": "RollingUpdate" } ], "template": [ { "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": { "app": "api-server" }, "name": "", "namespace": "", "resource_version": "", "uid": "" } ], "spec": [ { "active_deadline_seconds": 0, "affinity": [], "automount_service_account_token": true, "container": [ { "args": [], "command": [], "env": [], "env_from": [], "image": "ghcr.io/overmindtech/workspace/api-server@sha256:41be6bab8dc65bf19fe3771fa9cf54e51621d93161056db8091ca2ff905be24a", "image_pull_policy": "IfNotPresent", "lifecycle": [], "liveness_probe": [], "name": "api-server", "port": [ { "container_port": 8080, "host_ip": "", "host_port": 0, "name": "", "protocol": "TCP" } ], "readiness_probe": [], "resources": [ { "limits": { "memory": "2Gi" }, "requests": { "cpu": "250m", "memory": "200Mi" } } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "dns_config": [], "dns_policy": "ClusterFirst", "enable_service_links": true, "host_aliases": [], "host_ipc": false, "host_network": false, "host_pid": false, "hostname": "", "image_pull_secrets": [ { "name": "srcman-registry-credentials" } ], "init_container": [ { "args": [], "command": [ "/bin/mkdir", "-p", "/nats-nkeys/nsc" ], "env": [], "env_from": [], "image": "alpine:latest", "image_pull_policy": "Always", "lifecycle": [], "liveness_probe": [], "name": "create-folder", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" }, { "args": [ "init", "--nsc-location", "/nats-nkeys/nsc", "--nsc-operator", "dogfood", "--revlink-account", "revlink" ], "command": [], "env": [], "env_from": [], "image": "ghcr.io/overmindtech/workspace/api-server@sha256:41be6bab8dc65bf19fe3771fa9cf54e51621d93161056db8091ca2ff905be24a", "image_pull_policy": "IfNotPresent", "lifecycle": [], "liveness_probe": [], "name": "generate-nkeys", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "node_name": "", "node_selector": {}, "priority_class_name": "", "readiness_gate": [], "restart_policy": "Always", "runtime_class_name": "", "scheduler_name": "default-scheduler", "security_context": [], "service_account_name": "api-server-service-account", "share_process_namespace": false, "subdomain": "", "termination_grace_period_seconds": 30, "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "name": "nats-nkeys", "nfs": [], "persistent_volume_claim": [ { "claim_name": "nats-nkeys", "read_only": false } ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": { "create": "2m", "delete": "2m", "update": "2m" }, "wait_for_rollout": true }, "after": { "id": "default/api-server", "metadata": [ { "annotations": {}, "generate_name": "", "generation": 18, "labels": {}, "name": "api-server", "namespace": "default", "resource_version": "16505436", "uid": "cd11a255-2964-434a-b366-063ea673bbd2" } ], "spec": [ { "min_ready_seconds": 0, "paused": false, "progress_deadline_seconds": 600, "replicas": "1", "revision_history_limit": 10, "selector": [ { "match_expressions": [], "match_labels": { "app": "api-server" } } ], "strategy": [ { "rolling_update": [ { "max_surge": "25%", "max_unavailable": "25%" } ], "type": "RollingUpdate" } ], "template": [ { "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": { "app": "api-server" }, "name": "", "namespace": "", "resource_version": "", "uid": "" } ], "spec": [ { "active_deadline_seconds": 0, "affinity": [], "automount_service_account_token": true, "container": [ { "args": [], "command": [], "env": [], "env_from": [], "image": "ghcr.io/overmindtech/workspace/api-server@sha256:d10d15d0bce640a7e4e505b57652d7a7e46f8092a3dbd64408de4368cda40270", "image_pull_policy": "IfNotPresent", "lifecycle": [], "liveness_probe": [], "name": "api-server", "port": [ { "container_port": 8080, "host_ip": "", "host_port": 0, "name": "", "protocol": "TCP" } ], "readiness_probe": [], "resources": [ { "limits": { "memory": "2Gi" }, "requests": { "cpu": "250m", "memory": "200Mi" } } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "dns_config": [], "dns_policy": "ClusterFirst", "enable_service_links": true, "host_aliases": [], "host_ipc": false, "host_network": false, "host_pid": false, "hostname": "", "image_pull_secrets": [ { "name": "srcman-registry-credentials" } ], "init_container": [ { "args": [], "command": [ "/bin/mkdir", "-p", "/nats-nkeys/nsc" ], "env": [], "env_from": [], "image": "alpine:latest", "image_pull_policy": "Always", "lifecycle": [], "liveness_probe": [], "name": "create-folder", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" }, { "args": [ "init", "--nsc-location", "/nats-nkeys/nsc", "--nsc-operator", "dogfood", "--revlink-account", "revlink" ], "command": [], "env": [], "env_from": [], "image": "ghcr.io/overmindtech/workspace/api-server@sha256:d10d15d0bce640a7e4e505b57652d7a7e46f8092a3dbd64408de4368cda40270", "image_pull_policy": "IfNotPresent", "lifecycle": [], "liveness_probe": [], "name": "generate-nkeys", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "node_name": "", "node_selector": {}, "priority_class_name": "", "readiness_gate": [], "restart_policy": "Always", "runtime_class_name": "", "scheduler_name": "default-scheduler", "security_context": [], "service_account_name": "api-server-service-account", "share_process_namespace": false, "subdomain": "", "termination_grace_period_seconds": 30, "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "name": "nats-nkeys", "nfs": [], "persistent_volume_claim": [ { "claim_name": "nats-nkeys", "read_only": false } ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": { "create": "2m", "delete": "2m", "update": "2m" }, "wait_for_rollout": true }, "after_unknown": {}, "before_sensitive": { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "selector": [ { "match_expressions": [], "match_labels": {} } ], "strategy": [ { "rolling_update": [ {} ] } ], "template": [ { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "affinity": [], "container": [ { "args": [], "command": [], "env": [ { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value": true, "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value": true, "value_from": [] }, { "value": true, "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [ { "config_map_key_ref": [], "field_ref": [], "resource_field_ref": [], "secret_key_ref": [ {} ] } ] }, { "value_from": [ { "config_map_key_ref": [], "field_ref": [], "resource_field_ref": [], "secret_key_ref": [ {} ] } ] }, { "value_from": [] } ], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [ {} ], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] } ], "dns_config": [], "host_aliases": [], "image_pull_secrets": [ {} ], "init_container": [ { "args": [], "command": [ false, false, false ], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] }, { "args": [ false, false, false, false, false, false, false ], "command": [], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] } ], "node_selector": {}, "readiness_gate": [], "security_context": [], "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "nfs": [], "persistent_volume_claim": [ {} ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": {} }, "after_sensitive": { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "selector": [ { "match_expressions": [], "match_labels": {} } ], "strategy": [ { "rolling_update": [ {} ] } ], "template": [ { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "affinity": [], "container": [ { "args": [], "command": [], "env": [ { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value": true, "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value": true, "value_from": [] }, { "value": true, "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [] }, { "value_from": [ { "config_map_key_ref": [], "field_ref": [], "resource_field_ref": [], "secret_key_ref": [ {} ] } ] }, { "value_from": [ { "config_map_key_ref": [], "field_ref": [], "resource_field_ref": [], "secret_key_ref": [ {} ] } ] }, { "value_from": [] } ], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [ {} ], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] } ], "dns_config": [], "host_aliases": [], "image_pull_secrets": [ {} ], "init_container": [ { "args": [], "command": [ false, false, false ], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] }, { "args": [ false, false, false, false, false, false, false ], "command": [], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {} ] } ], "node_selector": {}, "readiness_gate": [], "security_context": [], "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "nfs": [], "persistent_volume_claim": [ {} ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": {} } } }, { "address": "aws_iam_policy.auth0_ses_send_emails", "mode": "managed", "type": "aws_iam_policy", "name": "auth0_ses_send_emails", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": [ "no-op" ], "before": { "arn": "arn:aws:iam::123456789012:policy/auth0-ses-send-emails", "description": "Allows Auth0 to send emails via SES", "id": "arn:aws:iam::123456789012:policy/auth0-ses-send-emails", "name": "auth0-ses-send-emails", "name_prefix": "", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"ses:SendRawEmail\",\"ses:SendEmail\"],\"Effect\":\"Allow\",\"Resource\":\"*\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPA5X4M7MOYO7KE6G4J4", "tags": {}, "tags_all": {} }, "after": { "arn": "arn:aws:iam::123456789012:policy/auth0-ses-send-emails", "description": "Allows Auth0 to send emails via SES", "id": "arn:aws:iam::123456789012:policy/auth0-ses-send-emails", "name": "auth0-ses-send-emails", "name_prefix": "", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"ses:SendRawEmail\",\"ses:SendEmail\"],\"Effect\":\"Allow\",\"Resource\":\"*\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPA5X4M7MOYO7KE6G4J4", "tags": {}, "tags_all": {} }, "after_unknown": {}, "before_sensitive": { "tags": {}, "tags_all": {} }, "after_sensitive": { "tags": {}, "tags_all": {} } } }, { "address": "module.efs_csi_irsa_role.aws_iam_policy.efs_csi[0]", "module_address": "module.efs_csi_irsa_role", "mode": "managed", "type": "aws_iam_policy", "name": "efs_csi", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": [ "no-op" ], "before": { "arn": "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "description": "Provides permissions to manage EFS volumes via the container storage interface driver", "id": "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "name": "AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "name_prefix": "AmazonEKS_EFS_CSI_Policy-", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"elasticfilesystem:DescribeMountTargets\",\"elasticfilesystem:DescribeFileSystems\",\"elasticfilesystem:DescribeAccessPoints\",\"ec2:DescribeAvailabilityZones\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:CreateAccessPoint\",\"Condition\":{\"StringLike\":{\"aws:RequestTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:TagResource\",\"Condition\":{\"StringLike\":{\"aws:RequestTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:DeleteAccessPoint\",\"Condition\":{\"StringEquals\":{\"aws:ResourceTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"}],\"Version\":\"2012-10-17\"}", "policy_id": "foobar", "tags": {}, "tags_all": {} }, "after": { "arn": "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "description": "Provides permissions to manage EFS volumes via the container storage interface driver", "id": "arn:aws:iam::123456789012:policy/AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "name": "AmazonEKS_EFS_CSI_Policy-20230317134301609600000001", "name_prefix": "AmazonEKS_EFS_CSI_Policy-", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"elasticfilesystem:DescribeMountTargets\",\"elasticfilesystem:DescribeFileSystems\",\"elasticfilesystem:DescribeAccessPoints\",\"ec2:DescribeAvailabilityZones\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:CreateAccessPoint\",\"Condition\":{\"StringLike\":{\"aws:RequestTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:TagResource\",\"Condition\":{\"StringLike\":{\"aws:RequestTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"},{\"Action\":\"elasticfilesystem:DeleteAccessPoint\",\"Condition\":{\"StringEquals\":{\"aws:ResourceTag/efs.csi.aws.com/cluster\":\"true\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"\"}],\"Version\":\"2012-10-17\"}", "policy_id": "foobar", "tags": {}, "tags_all": {} }, "after_unknown": {}, "before_sensitive": { "tags": {}, "tags_all": {} }, "after_sensitive": { "tags": {}, "tags_all": {} } } }, { "address": "module.eks.aws_iam_policy.cluster_encryption[0]", "module_address": "module.eks", "mode": "managed", "type": "aws_iam_policy", "name": "cluster_encryption", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": [ "no-op" ], "before": { "arn": "arn:aws:iam::123456789012:policy/test-cluster-ClusterEncryption2023061613390591120000000e", "description": "Cluster encryption policy to allow cluster role to utilize CMK provided", "id": "arn:aws:iam::123456789012:policy/test-cluster-ClusterEncryption2023061613390591120000000e", "name": "test-cluster-ClusterEncryption2023061613390591120000000e", "name_prefix": "test-cluster-ClusterEncryption", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"kms:Encrypt\",\"kms:Decrypt\",\"kms:ListGrants\",\"kms:DescribeKey\"],\"Effect\":\"Allow\",\"Resource\":\"arn:aws:kms:eu-west-2:12345678901:key/1234567\"}],\"Version\":\"2012-10-17\"}", "policy_id": "foobar", "tags": {}, "tags_all": {} }, "after": { "arn": "arn:aws:iam::123456789012:policy/test-cluster-ClusterEncryption2023061613390591120000000e", "description": "Cluster encryption policy to allow cluster role to utilize CMK provided", "id": "arn:aws:iam::123456789012:policy/test-cluster-ClusterEncryption2023061613390591120000000e", "name": "test-cluster-ClusterEncryption2023061613390591120000000e", "name_prefix": "test-cluster-ClusterEncryption", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"kms:Encrypt\",\"kms:Decrypt\",\"kms:ListGrants\",\"kms:DescribeKey\"],\"Effect\":\"Allow\",\"Resource\":\"arn:aws:kms:eu-west-2:12345678901:key/1234567\"}],\"Version\":\"2012-10-17\"}", "policy_id": "foobar", "tags": {}, "tags_all": {} }, "after_unknown": {}, "before_sensitive": { "tags": {}, "tags_all": {} }, "after_sensitive": { "tags": {}, "tags_all": {} } } }, { "address": "module.eks.aws_iam_policy.cni_ipv6_policy[0]", "module_address": "module.eks", "mode": "managed", "type": "aws_iam_policy", "name": "cni_ipv6_policy", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": [ "no-op" ], "before": { "arn": "arn:aws:iam::123456789012:policy/AmazonEKS_CNI_IPv6_Policy", "description": "IAM policy for EKS CNI to assign IPV6 addresses", "id": "arn:aws:iam::123456789012:policy/AmazonEKS_CNI_IPv6_Policy", "name": "AmazonEKS_CNI_IPv6_Policy", "name_prefix": "", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"ec2:DescribeTags\",\"ec2:DescribeNetworkInterfaces\",\"ec2:DescribeInstances\",\"ec2:DescribeInstanceTypes\",\"ec2:AssignIpv6Addresses\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"AssignDescribe\"},{\"Action\":\"ec2:CreateTags\",\"Effect\":\"Allow\",\"Resource\":\"arn:aws:ec2:*:*:network-interface/*\",\"Sid\":\"CreateTags\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPA5X4M7MOYIF2MVJEGJ", "tags": {}, "tags_all": {} }, "after": { "arn": "arn:aws:iam::123456789012:policy/AmazonEKS_CNI_IPv6_Policy", "description": "IAM policy for EKS CNI to assign IPV6 addresses", "id": "arn:aws:iam::123456789012:policy/AmazonEKS_CNI_IPv6_Policy", "name": "AmazonEKS_CNI_IPv6_Policy", "name_prefix": "", "path": "/", "policy": "{\"Statement\":[{\"Action\":[\"ec2:DescribeTags\",\"ec2:DescribeNetworkInterfaces\",\"ec2:DescribeInstances\",\"ec2:DescribeInstanceTypes\",\"ec2:AssignIpv6Addresses\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"AssignDescribe\"},{\"Action\":\"ec2:CreateTags\",\"Effect\":\"Allow\",\"Resource\":\"arn:aws:ec2:*:*:network-interface/*\",\"Sid\":\"CreateTags\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPA5X4M7MOYIF2MVJEGJ", "tags": {}, "tags_all": {} }, "after_unknown": {}, "before_sensitive": { "tags": {}, "tags_all": {} }, "after_sensitive": { "tags": {}, "tags_all": {} } } }, { "address": "module.eks_elb_controller.data.aws_iam_policy_document.lb_controller[0]", "module_address": "module.eks_elb_controller", "mode": "data", "type": "aws_iam_policy_document", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": [ "read" ], "before": null, "after": { "override_policy_documents": null, "policy_id": null, "source_policy_documents": null, "statement": [ { "actions": [ "iam:CreateServiceLinkedRole" ], "condition": [ { "test": "StringEquals", "values": [ "elasticloadbalancing.amazonaws.com" ], "variable": "iam:AWSServiceName" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:DescribeAccountAttributes", "ec2:DescribeAddresses", "ec2:DescribeAvailabilityZones", "ec2:DescribeCoipPools", "ec2:DescribeInstances", "ec2:DescribeInternetGateways", "ec2:DescribeNetworkInterfaces", "ec2:DescribeSecurityGroups", "ec2:DescribeSubnets", "ec2:DescribeTags", "ec2:DescribeVpcPeeringConnections", "ec2:DescribeVpcs", "ec2:GetCoipPoolUsage", "elasticloadbalancing:DescribeListenerCertificates", "elasticloadbalancing:DescribeListeners", "elasticloadbalancing:DescribeLoadBalancerAttributes", "elasticloadbalancing:DescribeLoadBalancers", "elasticloadbalancing:DescribeRules", "elasticloadbalancing:DescribeSSLPolicies", "elasticloadbalancing:DescribeTags", "elasticloadbalancing:DescribeTargetGroupAttributes", "elasticloadbalancing:DescribeTargetGroups", "elasticloadbalancing:DescribeTargetHealth" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "acm:DescribeCertificate", "acm:ListCertificates", "cognito-idp:DescribeUserPoolClient", "iam:GetServerCertificate", "iam:ListServerCertificates", "shield:CreateProtection", "shield:DeleteProtection", "shield:DescribeProtection", "shield:GetSubscriptionState", "waf-regional:AssociateWebACL", "waf-regional:DisassociateWebACL", "waf-regional:GetWebACL", "waf-regional:GetWebACLForResource", "wafv2:AssociateWebACL", "wafv2:DisassociateWebACL", "wafv2:GetWebACL", "wafv2:GetWebACLForResource" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:RevokeSecurityGroupIngress" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:CreateSecurityGroup" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "ec2:CreateTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" }, { "test": "StringEquals", "values": [ "CreateSecurityGroup" ], "variable": "ec2:CreateAction" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:ec2:*:*:security-group/*" ], "sid": null }, { "actions": [ "ec2:CreateTags", "ec2:DeleteTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" }, { "test": "Null", "values": [ "true" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:ec2:*:*:security-group/*" ], "sid": null }, { "actions": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:DeleteSecurityGroup", "ec2:RevokeSecurityGroupIngress" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:CreateLoadBalancer", "elasticloadbalancing:CreateTargetGroup" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:CreateListener", "elasticloadbalancing:CreateRule", "elasticloadbalancing:DeleteListener", "elasticloadbalancing:DeleteRule" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" }, { "test": "Null", "values": [ "true" ], "variable": "aws:RequestTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:DeleteLoadBalancer", "elasticloadbalancing:DeleteTargetGroup", "elasticloadbalancing:ModifyLoadBalancerAttributes", "elasticloadbalancing:ModifyTargetGroup", "elasticloadbalancing:ModifyTargetGroupAttributes", "elasticloadbalancing:SetIpAddressType", "elasticloadbalancing:SetSecurityGroups", "elasticloadbalancing:SetSubnets" ], "condition": [ { "test": "Null", "values": [ "false" ], "variable": "aws:ResourceTag/elbv2.k8s.aws/cluster" } ], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null }, { "actions": [ "elasticloadbalancing:DeregisterTargets", "elasticloadbalancing:RegisterTargets" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" ], "sid": null }, { "actions": [ "elasticloadbalancing:AddListenerCertificates", "elasticloadbalancing:ModifyListener", "elasticloadbalancing:ModifyRule", "elasticloadbalancing:RemoveListenerCertificates", "elasticloadbalancing:SetWebAcl" ], "condition": [], "effect": "Allow", "not_actions": null, "not_principals": [], "not_resources": null, "principals": [], "resources": [ "*" ], "sid": null } ], "version": null }, "after_unknown": { "id": true, "json": true, "statement": [ { "actions": [ false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false, false, false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false, false, false, false ] }, { "actions": [ false, false, false, false, false, false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] } ] }, "before_sensitive": false, "after_sensitive": { "statement": [ { "actions": [ false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [ { "values": [ false ] }, { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false, false, false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false, false, false, false ] }, { "actions": [ false, false, false, false, false, false, false, false ], "condition": [ { "values": [ false ] } ], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] }, { "actions": [ false, false, false, false, false ], "condition": [], "not_principals": [], "principals": [], "resources": [ false ] } ] } }, "action_reason": "read_because_dependency_pending" }, { "address": "module.eks_elb_controller.aws_iam_policy.lb_controller[0]", "module_address": "module.eks_elb_controller", "mode": "managed", "type": "aws_iam_policy", "name": "lb_controller", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": [ "update" ], "before": { "arn": "arn:aws:iam::123456789012:policy/test-alb-ingress", "description": "Policy for alb-ingress service", "id": "arn:aws:iam::123456789012:policy/test-alb-ingress", "name": "test-alb-ingress", "name_prefix": "", "path": "/", "policy": "{\"Statement\":[{\"Action\":\"iam:CreateServiceLinkedRole\",\"Condition\":{\"StringEquals\":{\"iam:AWSServiceName\":\"elasticloadbalancing.amazonaws.com\"}},\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":[\"elasticloadbalancing:DescribeTargetHealth\",\"elasticloadbalancing:DescribeTargetGroups\",\"elasticloadbalancing:DescribeTargetGroupAttributes\",\"elasticloadbalancing:DescribeTags\",\"elasticloadbalancing:DescribeSSLPolicies\",\"elasticloadbalancing:DescribeRules\",\"elasticloadbalancing:DescribeLoadBalancers\",\"elasticloadbalancing:DescribeLoadBalancerAttributes\",\"elasticloadbalancing:DescribeListeners\",\"elasticloadbalancing:DescribeListenerCertificates\",\"ec2:GetCoipPoolUsage\",\"ec2:DescribeVpcs\",\"ec2:DescribeVpcPeeringConnections\",\"ec2:DescribeTags\",\"ec2:DescribeSubnets\",\"ec2:DescribeSecurityGroups\",\"ec2:DescribeNetworkInterfaces\",\"ec2:DescribeInternetGateways\",\"ec2:DescribeInstances\",\"ec2:DescribeCoipPools\",\"ec2:DescribeAvailabilityZones\",\"ec2:DescribeAddresses\",\"ec2:DescribeAccountAttributes\"],\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":[\"wafv2:GetWebACLForResource\",\"wafv2:GetWebACL\",\"wafv2:DisassociateWebACL\",\"wafv2:AssociateWebACL\",\"waf-regional:GetWebACLForResource\",\"waf-regional:GetWebACL\",\"waf-regional:DisassociateWebACL\",\"waf-regional:AssociateWebACL\",\"shield:GetSubscriptionState\",\"shield:DescribeProtection\",\"shield:DeleteProtection\",\"shield:CreateProtection\",\"iam:ListServerCertificates\",\"iam:GetServerCertificate\",\"cognito-idp:DescribeUserPoolClient\",\"acm:ListCertificates\",\"acm:DescribeCertificate\"],\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":[\"ec2:RevokeSecurityGroupIngress\",\"ec2:AuthorizeSecurityGroupIngress\"],\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":\"ec2:CreateSecurityGroup\",\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":\"ec2:CreateTags\",\"Condition\":{\"Null\":{\"aws:RequestTag/elbv2.k8s.aws/cluster\":\"false\"},\"StringEquals\":{\"ec2:CreateAction\":\"CreateSecurityGroup\"}},\"Effect\":\"Allow\",\"Resource\":\"arn:aws:ec2:*:*:security-group/*\"},{\"Action\":[\"ec2:DeleteTags\",\"ec2:CreateTags\"],\"Condition\":{\"Null\":{\"aws:RequestTag/elbv2.k8s.aws/cluster\":\"true\",\"aws:ResourceTag/elbv2.k8s.aws/cluster\":\"false\"}},\"Effect\":\"Allow\",\"Resource\":\"arn:aws:ec2:*:*:security-group/*\"},{\"Action\":[\"ec2:RevokeSecurityGroupIngress\",\"ec2:DeleteSecurityGroup\",\"ec2:AuthorizeSecurityGroupIngress\"],\"Condition\":{\"Null\":{\"aws:ResourceTag/elbv2.k8s.aws/cluster\":\"false\"}},\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":[\"elasticloadbalancing:CreateTargetGroup\",\"elasticloadbalancing:CreateLoadBalancer\"],\"Condition\":{\"Null\":{\"aws:RequestTag/elbv2.k8s.aws/cluster\":\"false\"}},\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":[\"elasticloadbalancing:DeleteRule\",\"elasticloadbalancing:DeleteListener\",\"elasticloadbalancing:CreateRule\",\"elasticloadbalancing:CreateListener\"],\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":[\"elasticloadbalancing:RemoveTags\",\"elasticloadbalancing:AddTags\"],\"Condition\":{\"Null\":{\"aws:RequestTag/elbv2.k8s.aws/cluster\":\"true\",\"aws:ResourceTag/elbv2.k8s.aws/cluster\":\"false\"}},\"Effect\":\"Allow\",\"Resource\":[\"arn:aws:elasticloadbalancing:*:*:targetgroup/*/*\",\"arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*\",\"arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*\"]},{\"Action\":[\"elasticloadbalancing:RemoveTags\",\"elasticloadbalancing:AddTags\"],\"Effect\":\"Allow\",\"Resource\":[\"arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*\",\"arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*\",\"arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*\",\"arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*\"]},{\"Action\":[\"elasticloadbalancing:SetSubnets\",\"elasticloadbalancing:SetSecurityGroups\",\"elasticloadbalancing:SetIpAddressType\",\"elasticloadbalancing:ModifyTargetGroupAttributes\",\"elasticloadbalancing:ModifyTargetGroup\",\"elasticloadbalancing:ModifyLoadBalancerAttributes\",\"elasticloadbalancing:DeleteTargetGroup\",\"elasticloadbalancing:DeleteLoadBalancer\"],\"Condition\":{\"Null\":{\"aws:ResourceTag/elbv2.k8s.aws/cluster\":\"false\"}},\"Effect\":\"Allow\",\"Resource\":\"*\"},{\"Action\":[\"elasticloadbalancing:RegisterTargets\",\"elasticloadbalancing:DeregisterTargets\"],\"Effect\":\"Allow\",\"Resource\":\"arn:aws:elasticloadbalancing:*:*:targetgroup/*/*\"},{\"Action\":[\"elasticloadbalancing:SetWebAcl\",\"elasticloadbalancing:RemoveListenerCertificates\",\"elasticloadbalancing:ModifyRule\",\"elasticloadbalancing:ModifyListener\",\"elasticloadbalancing:AddListenerCertificates\"],\"Effect\":\"Allow\",\"Resource\":\"*\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPA5X4M7MOYCYTEF5VUE", "tags": {}, "tags_all": {} }, "after": { "arn": "arn:aws:iam::123456789012:policy/test-alb-ingress", "description": "Policy for alb-ingress service", "id": "arn:aws:iam::123456789012:policy/test-alb-ingress", "name": "test-alb-ingress", "name_prefix": "", "path": "/", "policy_id": "ANPA5X4M7MOYCYTEF5VUE", "tags": {}, "tags_all": {} }, "after_unknown": { "policy": true, "tags": {}, "tags_all": {} }, "before_sensitive": { "tags": {}, "tags_all": {} }, "after_sensitive": { "tags": {}, "tags_all": {} } } } ], "configuration": { "root_module": { "resources": [ { "address": "kubernetes_deployment.api_server", "mode": "managed", "type": "kubernetes_deployment", "name": "api_server", "provider_config_key": "kubernetes", "expressions": { "metadata": [ { "name": { "constant_value": "api-server" } } ], "spec": [ { "replicas": { "constant_value": 1 }, "selector": [ { "match_labels": { "constant_value": { "app": "api-server" } } } ], "template": [ { "metadata": [ { "labels": { "constant_value": { "app": "api-server" } } } ], "spec": [ { "container": [ { "env": [ { "name": { "constant_value": "LOG" }, "value": { "references": [ "local.default_log_level" ] } }, { "name": { "constant_value": "NSC_LOCATION" }, "value": { "constant_value": "/nats-nkeys/nsc" } }, { "name": { "constant_value": "NSC_OPERATOR" }, "value": { "references": [ "var.terraform_env_name" ] } }, { "name": { "constant_value": "NATS_URL" }, "value": { "references": [ "kubernetes_service.nats.metadata[0].name", "kubernetes_service.nats.metadata[0]", "kubernetes_service.nats.metadata", "kubernetes_service.nats" ] } }, { "name": { "constant_value": "AUTH0_AUDIENCE" }, "value": { "references": [ "auth0_resource_server.api_server.identifier", "auth0_resource_server.api_server" ] } }, { "name": { "constant_value": "AUTH0_DOMAIN" }, "value": { "references": [ "var.auth0_domain" ] } }, { "name": { "constant_value": "AUTH_COOKIE_NAME" }, "value": { "references": [ "local.session_name" ] } }, { "name": { "constant_value": "CORS_ALLOW_ORIGINS" }, "value": { "references": [ "local.cors_origin" ] } }, { "name": { "constant_value": "REVLINK_ACCOUNT" }, "value": { "constant_value": "revlink" } }, { "name": { "constant_value": "API_KEY_CLIENT_ID" }, "value": { "references": [ "auth0_client.api_keys.client_id", "auth0_client.api_keys" ] } }, { "name": { "constant_value": "API_KEY_CLIENT_SECRET" }, "value": { "references": [ "auth0_client.api_keys.client_secret", "auth0_client.api_keys" ] } }, { "name": { "constant_value": "GATEWAY_URL" }, "value": { "constant_value": "http://gateway:8080/api/gateway" } }, { "name": { "constant_value": "BOOKMARKS_BASE_URL" }, "value": { "constant_value": "http://gateway:8080/" } }, { "name": { "constant_value": "SNAPSHOTS_BASE_URL" }, "value": { "constant_value": "http://gateway:8080/" } }, { "name": { "constant_value": "SOURCE_MANAGER" }, "value": { "references": [ "var.terraform_env_name" ] } }, { "name": { "constant_value": "HONEYCOMB_API_KEY" }, "value": { "references": [ "var.honeycomb_api_key" ] } }, { "name": { "constant_value": "SENTRY_DSN" }, "value": { "references": [ "var.backend_sentry_dsn" ] } }, { "name": { "constant_value": "RUN_MODE" }, "value": { "references": [ "var.run_mode" ] } }, { "name": { "constant_value": "PGHOST" }, "value": { "references": [ "local.overmind_db_endpoint" ] } }, { "name": { "constant_value": "PGPORT" }, "value": { "references": [ "local.overmind_db_port" ] } }, { "name": { "constant_value": "PGUSER" }, "value_from": [ { "secret_key_ref": [ { "key": { "constant_value": "username" }, "name": { "constant_value": "apiserverdb-root-creds" } } ] } ] }, { "name": { "constant_value": "PGPASSWORD" }, "value_from": [ { "secret_key_ref": [ { "key": { "constant_value": "password" }, "name": { "constant_value": "apiserverdb-root-creds" } } ] } ] }, { "name": { "constant_value": "PGDBNAME" }, "value": { "references": [ "local.apiserverdb_name" ] } } ], "image": { "references": [ "local.api_server_imageref" ] }, "image_pull_policy": { "references": [ "local.api_server_image_pull_policy" ] }, "name": { "constant_value": "api-server" }, "port": [ { "container_port": { "constant_value": 8080 } } ], "resources": [ { "limits": { "constant_value": { "memory": "2Gi" } }, "requests": { "constant_value": { "cpu": "250m", "memory": "200Mi" } } } ], "volume_mount": [ { "mount_path": { "constant_value": "/nats-nkeys" }, "name": { "constant_value": "nats-nkeys" } } ] } ], "image_pull_secrets": [ { "name": { "constant_value": "srcman-registry-credentials" } } ], "init_container": [ { "command": { "constant_value": [ "/bin/mkdir", "-p", "/nats-nkeys/nsc" ] }, "image": { "constant_value": "alpine:latest" }, "name": { "constant_value": "create-folder" }, "volume_mount": [ { "mount_path": { "constant_value": "/nats-nkeys" }, "name": { "constant_value": "nats-nkeys" } } ] }, { "args": { "references": [ "var.terraform_env_name" ] }, "image": { "references": [ "local.api_server_imageref" ] }, "image_pull_policy": { "references": [ "local.api_server_image_pull_policy" ] }, "name": { "constant_value": "generate-nkeys" }, "volume_mount": [ { "mount_path": { "constant_value": "/nats-nkeys" }, "name": { "constant_value": "nats-nkeys" } } ] } ], "service_account_name": { "constant_value": "api-server-service-account" }, "volume": [ { "name": { "constant_value": "nats-nkeys" }, "persistent_volume_claim": [ { "claim_name": { "constant_value": "nats-nkeys" } } ] } ] } ] } ] } ], "timeouts": { "create": { "constant_value": "2m" }, "delete": { "constant_value": "2m" }, "update": { "constant_value": "2m" } } }, "schema_version": 1, "depends_on": [ "module.eks", "module.api_server_efs", "postgresql_database.apiserverdb", "postgresql_role.apiserverdb_role" ] } ], "module_calls": { "core": { "source": "./modules/ovm-core", "expressions": { "additional_ingress_rules": { "references": [ "local.smartlook_relay_dns" ] }, "api_keys_client_id": { "references": [ "auth0_client.api_keys.id", "auth0_client.api_keys" ] }, "api_keys_client_secret": { "references": [ "data.auth0_client.api_keys.client_secret", "data.auth0_client.api_keys" ] }, "api_server_audience": { "references": [ "auth0_resource_server.api_server.identifier", "auth0_resource_server.api_server" ] }, "api_server_client_id": { "references": [ "auth0_client.api_server.id", "auth0_client.api_server" ] }, "api_server_client_secret": { "references": [ "data.auth0_client.api_server.client_secret", "data.auth0_client.api_server" ] }, "api_server_image_pull_policy": { "references": [ "local.api_server_image_pull_policy" ] }, "api_server_imageref": { "references": [ "local.api_server_imageref" ] }, "auth0_domain": { "references": [ "var.auth0_domain" ] }, "aws_auth_roles": { "references": [ "local.aws_auth_roles" ] }, "backend_sentry_dsn": { "references": [ "var.backend_sentry_dsn" ] }, "cors_origin": { "references": [ "var.terraform_env_name" ] }, "eks_arm_instance_types": { "references": [ "var.terraform_env_name" ] }, "eks_x86_instance_types": { "references": [ "var.terraform_env_name" ] }, "env_name": { "references": [ "var.terraform_env_name" ] }, "gateway_audience": { "references": [ "auth0_resource_server.gateway.identifier", "auth0_resource_server.gateway" ] }, "gateway_client_id": { "references": [ "auth0_client.gateway.id", "auth0_client.gateway" ] }, "gateway_client_secret": { "references": [ "data.auth0_client.gateway.client_secret", "data.auth0_client.gateway" ] }, "gateway_image_pull_policy": { "references": [ "local.gateway_image_pull_policy" ] }, "gateway_imageref": { "references": [ "local.gateway_imageref" ] }, "honeycomb_api_key": { "references": [ "var.honeycomb_api_key" ] }, "hubspot_private_app_token": { "references": [ "var.hubspot_private_app_token" ] }, "ingress_certificate_arn": { "references": [ "module.acm.acm_certificate_arn", "module.acm" ] }, "is_prod": { "references": [ "var.terraform_env_name" ] }, "kms_key_administrators": { "references": [ "local.sso_admin_role_arn", "local.sso_poweruser_role_arn", "local.terraform_deployer_arn" ] }, "namespace": { "constant_value": "default" }, "nats_data_storage_class_name": { "references": [ "local.nats_data_storage_class_name" ] }, "nats_operator_jwt": { "references": [ "var.nats_operator_jwt" ] }, "nats_sys_account_id": { "references": [ "var.nats_sys_account_id" ] }, "nats_sys_account_jwt": { "references": [ "var.nats_sys_account_jwt" ] }, "openai_api_key": { "references": [ "var.openai_api_key" ] }, "region": { "constant_value": "eu-west-2" }, "revlink_client_id": { "references": [ "auth0_client.revlink.id", "auth0_client.revlink" ] }, "revlink_client_secret": { "references": [ "data.auth0_client.revlink.client_secret", "data.auth0_client.revlink" ] }, "revlink_image_pull_policy": { "references": [ "local.revlink_image_pull_policy" ] }, "revlink_imageref": { "references": [ "local.revlink_imageref" ] }, "send_email_iam_policy_arn": { "references": [ "aws_iam_policy.api_server_ses_send_emails.arn", "aws_iam_policy.api_server_ses_send_emails" ] }, "session_name": { "references": [ "local.session_name" ] }, "srcman_admin_github_token": { "references": [ "var.admin_github_token" ] }, "srcman_github_release": { "constant_value": "latest" }, "srcman_github_token": { "references": [ "var.srcman_github_token" ] }, "srcman_github_username": { "references": [ "var.srcman_github_username" ] }, "zone_name": { "references": [ "var.zone_name" ] } }, "module": { "resources": [ { "address": "kubernetes_secret.apiserver-secrets", "mode": "managed", "type": "kubernetes_secret", "name": "apiserver-secrets", "provider_config_key": "module.core:kubernetes", "expressions": { "data": { "references": [ "var.api_keys_client_secret", "var.api_server_client_secret", "var.hubspot_private_app_token", "var.openai_api_key" ] }, "metadata": [ { "name": { "constant_value": "apiserver-secrets" }, "namespace": { "references": [ "var.namespace" ] } } ], "type": { "constant_value": "Opaque" } }, "schema_version": 0 } ] } }, "eks_elb_controller": { "source": "DNXLabs/eks-lb-controller/aws", "expressions": { "cluster_identity_oidc_issuer": { "references": [ "module.eks.cluster_oidc_issuer_url", "module.eks" ] }, "cluster_identity_oidc_issuer_arn": { "references": [ "module.eks.oidc_provider_arn", "module.eks" ] }, "cluster_name": { "references": [ "module.eks.cluster_name", "module.eks" ] } }, "module": { "resources": [ { "address": "aws_iam_policy.lb_controller", "mode": "managed", "type": "aws_iam_policy", "name": "lb_controller", "provider_config_key": "aws", "expressions": { "description": { "constant_value": "Policy for alb-ingress service" }, "name": { "references": [ "var.cluster_name" ] }, "path": { "constant_value": "/" }, "policy": { "references": [ "data.aws_iam_policy_document.lb_controller[0].json", "data.aws_iam_policy_document.lb_controller[0]", "data.aws_iam_policy_document.lb_controller" ] } }, "schema_version": 0, "count_expression": { "references": [ "var.enabled" ] }, "depends_on": [ "var.mod_dependency" ] }, { "address": "aws_iam_role.lb_controller", "mode": "managed", "type": "aws_iam_role", "name": "lb_controller", "provider_config_key": "aws", "expressions": { "assume_role_policy": { "references": [ "data.aws_iam_policy_document.lb_controller_assume[0].json", "data.aws_iam_policy_document.lb_controller_assume[0]", "data.aws_iam_policy_document.lb_controller_assume" ] }, "name": { "references": [ "var.cluster_name" ] } }, "schema_version": 0, "count_expression": { "references": [ "var.enabled" ] } }, { "address": "aws_iam_role_policy_attachment.lb_controller", "mode": "managed", "type": "aws_iam_role_policy_attachment", "name": "lb_controller", "provider_config_key": "aws", "expressions": { "policy_arn": { "references": [ "aws_iam_policy.lb_controller[0].arn", "aws_iam_policy.lb_controller[0]", "aws_iam_policy.lb_controller" ] }, "role": { "references": [ "aws_iam_role.lb_controller[0].name", "aws_iam_role.lb_controller[0]", "aws_iam_role.lb_controller" ] } }, "schema_version": 0, "count_expression": { "references": [ "var.enabled" ] } }, { "address": "helm_release.lb_controller", "mode": "managed", "type": "helm_release", "name": "lb_controller", "provider_config_key": "helm", "expressions": { "chart": { "references": [ "var.helm_chart_release_name" ] }, "name": { "references": [ "var.helm_chart_name" ] }, "namespace": { "references": [ "var.namespace" ] }, "repository": { "references": [ "var.helm_chart_repo" ] }, "set": [ { "name": { "constant_value": "clusterName" }, "value": { "references": [ "var.cluster_name" ] } }, { "name": { "constant_value": "rbac.create" }, "value": { "constant_value": "true" } }, { "name": { "constant_value": "serviceAccount.create" }, "value": { "constant_value": "true" } }, { "name": { "constant_value": "serviceAccount.name" }, "value": { "references": [ "var.service_account_name" ] } }, { "name": { "constant_value": "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn" }, "value": { "references": [ "aws_iam_role.lb_controller[0].arn", "aws_iam_role.lb_controller[0]", "aws_iam_role.lb_controller" ] } } ], "values": { "references": [ "var.settings" ] }, "version": { "references": [ "var.helm_chart_version" ] } }, "schema_version": 1, "count_expression": { "references": [ "var.enabled" ] }, "depends_on": [ "var.mod_dependency", "kubernetes_namespace.lb_controller" ] }, { "address": "kubectl_manifest.cluster_role", "mode": "managed", "type": "kubectl_manifest", "name": "cluster_role", "provider_config_key": "kubectl", "expressions": { "yaml_body": { "references": [ "path.module", "each.value.name", "each.value", "each.value.namespace", "each.value", "each.value.secrets", "each.value" ] } }, "schema_version": 1, "for_each_expression": { "references": [ "var.roles" ] } }, { "address": "kubectl_manifest.cluster_role_binding", "mode": "managed", "type": "kubectl_manifest", "name": "cluster_role_binding", "provider_config_key": "kubectl", "expressions": { "yaml_body": { "references": [ "path.module", "each.value.name", "each.value", "each.value.namespace", "each.value", "each.value.secrets", "each.value" ] } }, "schema_version": 1, "for_each_expression": { "references": [ "var.roles" ] }, "depends_on": [ "kubectl_manifest.cluster_role" ] }, { "address": "kubernetes_namespace.lb_controller", "mode": "managed", "type": "kubernetes_namespace", "name": "lb_controller", "provider_config_key": "kubernetes", "expressions": { "metadata": [ { "name": { "references": [ "var.namespace" ] } } ] }, "schema_version": 0, "count_expression": { "references": [ "var.enabled", "var.create_namespace", "var.namespace" ] }, "depends_on": [ "var.mod_dependency" ] }, { "address": "data.aws_iam_policy_document.lb_controller", "mode": "data", "type": "aws_iam_policy_document", "name": "lb_controller", "provider_config_key": "aws", "expressions": { "statement": [ { "actions": { "constant_value": [ "iam:CreateServiceLinkedRole" ] }, "condition": [ { "test": { "constant_value": "StringEquals" }, "values": { "constant_value": [ "elasticloadbalancing.amazonaws.com" ] }, "variable": { "constant_value": "iam:AWSServiceName" } } ], "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "ec2:DescribeAccountAttributes", "ec2:DescribeAddresses", "ec2:DescribeAvailabilityZones", "ec2:DescribeInternetGateways", "ec2:DescribeVpcs", "ec2:DescribeVpcPeeringConnections", "ec2:DescribeSubnets", "ec2:DescribeSecurityGroups", "ec2:DescribeInstances", "ec2:DescribeNetworkInterfaces", "ec2:DescribeTags", "ec2:GetCoipPoolUsage", "ec2:DescribeCoipPools", "elasticloadbalancing:DescribeLoadBalancers", "elasticloadbalancing:DescribeLoadBalancerAttributes", "elasticloadbalancing:DescribeListeners", "elasticloadbalancing:DescribeListenerCertificates", "elasticloadbalancing:DescribeSSLPolicies", "elasticloadbalancing:DescribeRules", "elasticloadbalancing:DescribeTargetGroups", "elasticloadbalancing:DescribeTargetGroupAttributes", "elasticloadbalancing:DescribeTargetHealth", "elasticloadbalancing:DescribeTags" ] }, "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "cognito-idp:DescribeUserPoolClient", "acm:ListCertificates", "acm:DescribeCertificate", "iam:ListServerCertificates", "iam:GetServerCertificate", "waf-regional:GetWebACL", "waf-regional:GetWebACLForResource", "waf-regional:AssociateWebACL", "waf-regional:DisassociateWebACL", "wafv2:GetWebACL", "wafv2:GetWebACLForResource", "wafv2:AssociateWebACL", "wafv2:DisassociateWebACL", "shield:GetSubscriptionState", "shield:DescribeProtection", "shield:CreateProtection", "shield:DeleteProtection" ] }, "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:RevokeSecurityGroupIngress" ] }, "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "ec2:CreateSecurityGroup" ] }, "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "ec2:CreateTags" ] }, "condition": [ { "test": { "constant_value": "StringEquals" }, "values": { "constant_value": [ "CreateSecurityGroup" ] }, "variable": { "constant_value": "ec2:CreateAction" } }, { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "false" ] }, "variable": { "constant_value": "aws:RequestTag/elbv2.k8s.aws/cluster" } } ], "effect": { "constant_value": "Allow" }, "resources": { "references": [ "var.arn_format" ] } }, { "actions": { "constant_value": [ "ec2:CreateTags", "ec2:DeleteTags" ] }, "condition": [ { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "true" ] }, "variable": { "constant_value": "aws:RequestTag/elbv2.k8s.aws/cluster" } }, { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "false" ] }, "variable": { "constant_value": "aws:ResourceTag/elbv2.k8s.aws/cluster" } } ], "effect": { "constant_value": "Allow" }, "resources": { "references": [ "var.arn_format" ] } }, { "actions": { "constant_value": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:RevokeSecurityGroupIngress", "ec2:DeleteSecurityGroup" ] }, "condition": [ { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "false" ] }, "variable": { "constant_value": "aws:ResourceTag/elbv2.k8s.aws/cluster" } } ], "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "elasticloadbalancing:CreateLoadBalancer", "elasticloadbalancing:CreateTargetGroup" ] }, "condition": [ { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "false" ] }, "variable": { "constant_value": "aws:RequestTag/elbv2.k8s.aws/cluster" } } ], "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "elasticloadbalancing:CreateListener", "elasticloadbalancing:DeleteListener", "elasticloadbalancing:CreateRule", "elasticloadbalancing:DeleteRule" ] }, "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ] }, "condition": [ { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "true" ] }, "variable": { "constant_value": "aws:RequestTag/elbv2.k8s.aws/cluster" } }, { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "false" ] }, "variable": { "constant_value": "aws:ResourceTag/elbv2.k8s.aws/cluster" } } ], "effect": { "constant_value": "Allow" }, "resources": { "references": [ "var.arn_format", "var.arn_format", "var.arn_format" ] } }, { "actions": { "constant_value": [ "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags" ] }, "effect": { "constant_value": "Allow" }, "resources": { "references": [ "var.arn_format", "var.arn_format", "var.arn_format", "var.arn_format" ] } }, { "actions": { "constant_value": [ "elasticloadbalancing:ModifyLoadBalancerAttributes", "elasticloadbalancing:SetIpAddressType", "elasticloadbalancing:SetSecurityGroups", "elasticloadbalancing:SetSubnets", "elasticloadbalancing:DeleteLoadBalancer", "elasticloadbalancing:ModifyTargetGroup", "elasticloadbalancing:ModifyTargetGroupAttributes", "elasticloadbalancing:DeleteTargetGroup" ] }, "condition": [ { "test": { "constant_value": "Null" }, "values": { "constant_value": [ "false" ] }, "variable": { "constant_value": "aws:ResourceTag/elbv2.k8s.aws/cluster" } } ], "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } }, { "actions": { "constant_value": [ "elasticloadbalancing:RegisterTargets", "elasticloadbalancing:DeregisterTargets" ] }, "effect": { "constant_value": "Allow" }, "resources": { "references": [ "var.arn_format" ] } }, { "actions": { "constant_value": [ "elasticloadbalancing:SetWebAcl", "elasticloadbalancing:ModifyListener", "elasticloadbalancing:AddListenerCertificates", "elasticloadbalancing:RemoveListenerCertificates", "elasticloadbalancing:ModifyRule" ] }, "effect": { "constant_value": "Allow" }, "resources": { "constant_value": [ "*" ] } } ] }, "schema_version": 0, "count_expression": { "references": [ "var.enabled" ] } }, { "address": "data.aws_iam_policy_document.lb_controller_assume", "mode": "data", "type": "aws_iam_policy_document", "name": "lb_controller_assume", "provider_config_key": "aws", "expressions": { "statement": [ { "actions": { "constant_value": [ "sts:AssumeRoleWithWebIdentity" ] }, "condition": [ { "test": { "constant_value": "StringEquals" }, "values": { "references": [ "var.namespace", "var.service_account_name" ] }, "variable": { "references": [ "var.cluster_identity_oidc_issuer" ] } } ], "effect": { "constant_value": "Allow" }, "principals": [ { "identifiers": { "references": [ "var.cluster_identity_oidc_issuer_arn" ] }, "type": { "constant_value": "Federated" } } ] } ] }, "schema_version": 0, "count_expression": { "references": [ "var.enabled" ] } } ], "variables": { "arn_format": { "default": "aws", "description": "ARNs identifier, usefull for GovCloud begin with `aws-us-gov-`." }, "cluster_identity_oidc_issuer": { "description": "The OIDC Identity issuer for the cluster." }, "cluster_identity_oidc_issuer_arn": { "description": "The OIDC Identity issuer ARN for the cluster that can be used to associate IAM roles with a service account." }, "cluster_name": { "description": "The name of the cluster." }, "create_namespace": { "default": true, "description": "Whether to create Kubernetes namespace with name defined by `namespace`." }, "enabled": { "default": true, "description": "Variable indicating whether deployment is enabled." }, "helm_chart_name": { "default": "aws-load-balancer-controller", "description": "AWS Load Balancer Controller Helm chart name." }, "helm_chart_release_name": { "default": "aws-load-balancer-controller", "description": "AWS Load Balancer Controller Helm chart release name." }, "helm_chart_repo": { "default": "https://aws.github.io/eks-charts", "description": "AWS Load Balancer Controller Helm repository name." }, "helm_chart_version": { "default": "1.4.4", "description": "AWS Load Balancer Controller Helm chart version." }, "mod_dependency": { "default": null, "description": "Dependence variable binds all AWS resources allocated by this module, dependent modules reference this variable." }, "namespace": { "default": "kube-system", "description": "AWS Load Balancer Controller Helm chart namespace which the service will be created." }, "roles": { "default": [], "description": "RBAC roles that give secret access in other namespaces to the lb controller" }, "service_account_name": { "default": "aws-alb-ingress-controller", "description": "The kubernetes service account name." }, "settings": { "default": {}, "description": "Additional settings which will be passed to the Helm chart values, see https://github.com/aws/eks-charts/tree/master/stable/aws-load-balancer-controller#configuration." } } }, "version_constraint": "0.7.0", "depends_on": [ "module.eks" ] } } } }, "prior_state": { "values": { "root_module": { "resources": [ { "address": "module.infra.aws_route53_record.frontend_on_vercel[0]", "mode": "managed", "type": "aws_route53_record", "name": "frontend_on_vercel", "index": 0, "provider_name": "registry.terraform.io/hashicorp/aws", "schema_version": 2, "values": { "alias": [], "allow_overwrite": null, "cidr_routing_policy": [], "failover_routing_policy": [], "fqdn": "app.overmind.tech", "geolocation_routing_policy": [], "geoproximity_routing_policy": [], "health_check_id": "", "id": "BLAH_app.overmind.tech_A", "latency_routing_policy": [], "multivalue_answer_routing_policy": false, "name": "app.overmind.tech", "records": [ "1.1.1.1" ], "set_identifier": "", "ttl": 300, "type": "A", "weighted_routing_policy": [], "zone_id": "BLAH" }, "sensitive_values": { "alias": [], "cidr_routing_policy": [], "failover_routing_policy": [], "geolocation_routing_policy": [], "geoproximity_routing_policy": [], "latency_routing_policy": [], "records": [ false ], "weighted_routing_policy": [] }, "depends_on": [ "module.infra.aws_route53_zone.zone" ] }, { "address": "kubernetes_deployment.nats_box", "mode": "managed", "type": "kubernetes_deployment", "name": "nats_box", "provider_name": "registry.terraform.io/hashicorp/kubernetes", "schema_version": 1, "values": { "id": "default/nats-box", "metadata": [ { "annotations": {}, "generate_name": "", "generation": 9, "labels": { "app": "nats-box" }, "name": "nats-box", "namespace": "default", "resource_version": "20425079", "uid": "25e4fce6-06a8-435b-90f3-ad0c1d8b52f1" } ], "spec": [ { "min_ready_seconds": 0, "paused": false, "progress_deadline_seconds": 600, "replicas": "0", "revision_history_limit": 10, "selector": [ { "match_expressions": [], "match_labels": { "app": "nats-box" } } ], "strategy": [ { "rolling_update": [ { "max_surge": "25%", "max_unavailable": "25%" } ], "type": "RollingUpdate" } ], "template": [ { "metadata": [ { "annotations": {}, "generate_name": "", "generation": 0, "labels": { "app": "nats-box" }, "name": "", "namespace": "", "resource_version": "", "uid": "" } ], "spec": [ { "active_deadline_seconds": 0, "affinity": [], "automount_service_account_token": true, "container": [ { "args": [], "command": [ "tail", "-f", "/dev/null" ], "env": [], "env_from": [], "image": "natsio/nats-box:latest", "image_pull_policy": "Always", "lifecycle": [], "liveness_probe": [], "name": "nats", "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "stdin": false, "stdin_once": false, "termination_message_path": "/dev/termination-log", "termination_message_policy": "File", "tty": false, "volume_mount": [ { "mount_path": "/etc/nats", "mount_propagation": "None", "name": "nats-config", "read_only": false, "sub_path": "" }, { "mount_path": "/etc/nats-nkeys", "mount_propagation": "None", "name": "nats-nkeys", "read_only": false, "sub_path": "" } ], "working_dir": "" } ], "dns_config": [], "dns_policy": "ClusterFirst", "enable_service_links": true, "host_aliases": [], "host_ipc": false, "host_network": false, "host_pid": false, "hostname": "", "image_pull_secrets": [], "init_container": [], "node_name": "", "node_selector": {}, "priority_class_name": "", "readiness_gate": [], "restart_policy": "Always", "runtime_class_name": "", "scheduler_name": "default-scheduler", "security_context": [], "service_account_name": "", "share_process_namespace": false, "subdomain": "", "termination_grace_period_seconds": 30, "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "name": "nats-nkeys", "nfs": [], "persistent_volume_claim": [ { "claim_name": "nats-nkeys", "read_only": false } ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] }, { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "name": "nats-config", "nfs": [], "persistent_volume_claim": [ { "claim_name": "nats-config", "read_only": false } ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": { "create": "2m", "delete": "2m", "update": "2m" }, "wait_for_rollout": true }, "sensitive_values": { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "selector": [ { "match_expressions": [], "match_labels": {} } ], "strategy": [ { "rolling_update": [ {} ] } ], "template": [ { "metadata": [ { "annotations": {}, "labels": {} } ], "spec": [ { "affinity": [], "container": [ { "args": [], "command": [ false, false, false ], "env": [], "env_from": [], "lifecycle": [], "liveness_probe": [], "port": [], "readiness_probe": [], "resources": [ { "limits": {}, "requests": {} } ], "security_context": [], "startup_probe": [], "volume_mount": [ {}, {} ] } ], "dns_config": [], "host_aliases": [], "image_pull_secrets": [], "init_container": [], "node_selector": {}, "readiness_gate": [], "security_context": [], "toleration": [], "topology_spread_constraint": [], "volume": [ { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "nfs": [], "persistent_volume_claim": [ {} ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] }, { "aws_elastic_block_store": [], "azure_disk": [], "azure_file": [], "ceph_fs": [], "cinder": [], "config_map": [], "csi": [], "downward_api": [], "empty_dir": [], "fc": [], "flex_volume": [], "flocker": [], "gce_persistent_disk": [], "git_repo": [], "glusterfs": [], "host_path": [], "iscsi": [], "local": [], "nfs": [], "persistent_volume_claim": [ {} ], "photon_persistent_disk": [], "projected": [], "quobyte": [], "rbd": [], "secret": [], "vsphere_volume": [] } ] } ] } ] } ], "timeouts": {} }, "depends_on": [ "aws_iam_openid_connect_provider.github", "aws_iam_role.buildkit_connect", "data.aws_availability_zones.available", "data.aws_caller_identity.current", "data.aws_eks_cluster_auth.eks", "data.aws_iam_roles.sso_admins", "data.aws_iam_roles.sso_powerusers", "data.aws_iam_session_context.current", "data.aws_subnets.main_vpc_by_az", "module.eks.aws_cloudwatch_log_group.this", "module.eks.aws_ec2_tag.cluster_primary_security_group", "module.eks.aws_eks_addon.before_compute", "module.eks.aws_eks_addon.this", "module.eks.aws_eks_cluster.this", "module.eks.aws_eks_identity_provider_config.this", "module.eks.aws_iam_openid_connect_provider.oidc_provider", "module.eks.aws_iam_policy.cluster_encryption", "module.eks.aws_iam_policy.cni_ipv6_policy", "module.eks.aws_iam_role.this", "module.eks.aws_iam_role_policy_attachment.additional", "module.eks.aws_iam_role_policy_attachment.cluster_encryption", "module.eks.aws_iam_role_policy_attachment.this", "module.eks.aws_security_group.cluster", "module.eks.aws_security_group.node", "module.eks.aws_security_group_rule.cluster", "module.eks.aws_security_group_rule.node", "module.eks.data.aws_caller_identity.current", "module.eks.data.aws_eks_addon_version.this", "module.eks.data.aws_iam_policy_document.assume_role_policy", "module.eks.data.aws_iam_policy_document.cni_ipv6_policy", "module.eks.data.aws_iam_session_context.current", "module.eks.data.aws_partition.current", "module.eks.data.tls_certificate.this", "module.eks.kubernetes_config_map.aws_auth", "module.eks.kubernetes_config_map_v1_data.aws_auth", "module.eks.module.eks_managed_node_group.aws_autoscaling_schedule.this", "module.eks.module.eks_managed_node_group.aws_eks_node_group.this", "module.eks.module.eks_managed_node_group.aws_iam_role.this", "module.eks.module.eks_managed_node_group.aws_iam_role_policy_attachment.additional", "module.eks.module.eks_managed_node_group.aws_iam_role_policy_attachment.this", "module.eks.module.eks_managed_node_group.aws_launch_template.this", "module.eks.module.eks_managed_node_group.data.aws_caller_identity.current", "module.eks.module.eks_managed_node_group.data.aws_iam_policy_document.assume_role_policy", "module.eks.module.eks_managed_node_group.data.aws_partition.current", "module.eks.module.eks_managed_node_group.module.user_data.data.cloudinit_config.linux_eks_managed_node_group", "module.eks.module.fargate_profile.aws_eks_fargate_profile.this", "module.eks.module.fargate_profile.aws_iam_role.this", "module.eks.module.fargate_profile.aws_iam_role_policy_attachment.additional", "module.eks.module.fargate_profile.aws_iam_role_policy_attachment.this", "module.eks.module.fargate_profile.data.aws_caller_identity.current", "module.eks.module.fargate_profile.data.aws_iam_policy_document.assume_role_policy", "module.eks.module.fargate_profile.data.aws_partition.current", "module.eks.module.kms.aws_kms_alias.this", "module.eks.module.kms.aws_kms_external_key.this", "module.eks.module.kms.aws_kms_grant.this", "module.eks.module.kms.aws_kms_key.this", "module.eks.module.kms.data.aws_caller_identity.current", "module.eks.module.kms.data.aws_iam_policy_document.this", "module.eks.module.kms.data.aws_partition.current", "module.eks.module.self_managed_node_group.aws_autoscaling_group.this", "module.eks.module.self_managed_node_group.aws_autoscaling_schedule.this", "module.eks.module.self_managed_node_group.aws_iam_instance_profile.this", "module.eks.module.self_managed_node_group.aws_iam_role.this", "module.eks.module.self_managed_node_group.aws_iam_role_policy_attachment.additional", "module.eks.module.self_managed_node_group.aws_iam_role_policy_attachment.this", "module.eks.module.self_managed_node_group.aws_launch_template.this", "module.eks.module.self_managed_node_group.data.aws_ami.eks_default", "module.eks.module.self_managed_node_group.data.aws_caller_identity.current", "module.eks.module.self_managed_node_group.data.aws_iam_policy_document.assume_role_policy", "module.eks.module.self_managed_node_group.data.aws_partition.current", "module.eks.module.self_managed_node_group.module.user_data.data.cloudinit_config.linux_eks_managed_node_group", "module.eks.time_sleep.this", "module.iam_eks_role.aws_iam_role.this", "module.iam_eks_role.data.aws_caller_identity.current", "module.iam_eks_role.data.aws_iam_policy_document.this", "module.iam_eks_role.data.aws_partition.current", "module.vpc.aws_subnet.private", "module.vpc.aws_vpc.this", "module.vpc.aws_vpc_ipv4_cidr_block_association.this" ] } ] } } }, "timestamp": "2023-07-17T15:48:38Z" } ================================================ FILE: tfutils/testdata/providers.tf ================================================ terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.28" } } required_version = ">= 1.2.0" } // Provider that should be ignored provider "google" { project = "acme-app" region = "us-central1" } // This should also be ignored variable "image_id" { type = string } // This should be ignored too resource "aws_instance" "app_server" { ami = "ami-830c94e3" instance_type = "t2.micro" tags = { Name = "ExampleAppServerInstance" } } # Example kube provider using data and functions which we don't support reading provider "kubernetes" { host = data.aws_eks_cluster.core_eks.endpoint token = data.aws_eks_cluster_auth.core_eks.token } provider "aws" { region = "us-east-1" } provider "aws" { alias = "assume_role" assume_role { role_arn = "arn:aws:iam::123456789012:role/ROLE_NAME" session_name = "SESSION_NAME" external_id = "EXTERNAL_ID" } } provider "aws" { alias = "everything" access_key = "access_key" secret_key = "secret_key" token = "token" region = "region" custom_ca_bundle = "testdata/providers.tf" ec2_metadata_service_endpoint = "ec2_metadata_service_endpoint" ec2_metadata_service_endpoint_mode = "ipv6" skip_metadata_api_check = true http_proxy = "http_proxy" https_proxy = "https_proxy" no_proxy = "no_proxy" max_retries = 10 profile = "profile" retry_mode = "standard" shared_config_files = ["shared_config_files"] shared_credentials_files = ["shared_credentials_files"] s3_us_east_1_regional_endpoint = "s3_us_east_1_regional_endpoint" use_dualstack_endpoint = false use_fips_endpoint = false assume_role { role_arn = "arn:aws:iam::123456789012:role/ROLE_NAME" session_name = "SESSION_NAME" external_id = "EXTERNAL_ID" duration = "1s" policy = "policy" policy_arns = ["policy_arns"] tags = { key = "value" } } assume_role_with_web_identity { role_arn = "arn:aws:iam::123456789012:role/ROLE_NAME" session_name = "SESSION_NAME" web_identity_token_file = "/Users/tf_user/secrets/web-identity-token" web_identity_token = "web_identity_token" duration = "1s" policy = "policy" policy_arns = ["policy_arns"] } } ================================================ FILE: tfutils/testdata/state.json ================================================ { "format_version": "1.0", "terraform_version": "1.5.7", "values": { "outputs": {}, "root_module": { "resources": [], "child_modules": [] } } } ================================================ FILE: tfutils/testdata/subfolder/more_providers.tf ================================================ provider "aws" { alias = "subdir" region = "us-west-2" access_key = "my-access-key" secret_key = "my-secret-key" } ================================================ FILE: tfutils/testdata/test_vars.tfvars ================================================ # String variable simple_string="example_string" # Number variable example_number = 42 # Boolean variable example_boolean = true # List of strings example_list = ["item1", "item2", "item3"] # Map of strings example_map = { key1 = "value1" key2 = "value2" } # Complex map (nested maps) complex_map = { nested_map1 = { nested_key1 = "nested_value1" nested_key2 = "nested_value2" } nested_map2 = { nested_key1 = "nested_value3" nested_key2 = "nested_value4" } } ================================================ FILE: tfutils/testdata/tfvars.json ================================================ { "string": "example_string", "list": ["item1", "item2"] }